From 9c7319ffcaa1d618d153e8acad27a90e6a9c8956 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 14:56:30 +0800 Subject: [PATCH 01/16] Remove over-comments, dead code, and duplicate logic. Repo-wide quality cleanup with no intended behavior change: - Remove comments that restate code, bug-fix history, and stale notes. - Delete unused functions, exports, parameters, branches, and tests. - Replace copied logic with existing or shared helpers. - Fix stale facts in docs and playground READMEs. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/.vitepress/theme/FileTree.vue | 1 - docs/guide/ai.md | 2 +- docs/guide/auth.md | 2 +- docs/guide/database/postgresql.md | 4 +- docs/guide/edge/rewrites.md | 6 +- docs/guide/email/sending.md | 2 - docs/guide/pages-routing/markdown.md | 22 +- docs/guide/platform/administration/access.md | 10 +- .../platform/administration/operations.md | 15 +- docs/guide/platform/development/local.md | 2 +- docs/guide/platform/development/runtime.md | 2 +- docs/guide/platform/development/schema-ci.md | 2 +- docs/guide/platform/installation/domains.md | 2 +- .../platform/installation/first-deployment.md | 2 +- docs/guide/project-collaboration.md | 4 +- docs/guide/remote-dev.md | 10 +- docs/guide/sandboxes.md | 4 +- docs/guide/sse.md | 2 - docs/integrations/nodejs-bun-deno.md | 1 + docs/reference/api/auth.md | 2 +- docs/reference/api/handlers.md | 2 +- docs/reference/cli/database.md | 7 +- docs/reference/cli/generate.md | 2 +- docs/reference/cli/github.md | 15 +- docs/reference/cli/platform-config.md | 2 +- docs/reference/cli/platform-email.md | 5 +- docs/reference/cli/platform-installation.md | 2 +- docs/reference/cli/platform-users.md | 2 +- docs/reference/cli/project.md | 4 +- docs/reference/cli/secrets.md | 6 +- docs/reference/config.md | 24 +- docs/reference/resource-inference.md | 17 +- packages/create-void/src/index.ts | 7 +- packages/deploy-cloudflare/src/config.ts | 53 +- packages/deploy-cloudflare/src/execute.ts | 27 +- packages/deploy-cloudflare/src/existing.ts | 109 +-- packages/deploy-cloudflare/src/metadata.ts | 13 +- packages/deploy-cloudflare/src/version.ts | 414 +++++----- .../test/unit/version.test.ts | 11 +- packages/deploy-core/src/migration-plan.ts | 46 +- packages/deploy-core/src/sql-tokenize.ts | 10 +- packages/deploy-core/src/validate.ts | 162 ++-- packages/edge/src/headers.ts | 1 - packages/edge/src/pattern.ts | 88 +-- packages/edge/src/runtime.ts | 10 +- packages/edge/src/static.ts | 3 +- packages/isr/src/index.ts | 50 +- packages/isr/src/runtime.ts | 93 ++- packages/isr/test/unit/variant-hash.test.ts | 5 +- packages/md/src/compile.ts | 31 +- packages/md/src/emit-vue.ts | 79 +- packages/md/src/metadata.ts | 2 +- packages/md/src/plugin.ts | 28 +- packages/md/src/plugins/pre-wrapper.ts | 2 +- packages/md/src/plugins/toc.ts | 52 +- packages/md/test/unit/compile.test.ts | 5 - packages/md/test/unit/extract-script.test.ts | 6 +- packages/platform/scripts/build-runtime.mjs | 38 +- packages/platform/src/discovery.ts | 27 +- packages/platform/src/email-protocol.ts | 137 ++-- packages/platform/src/installation.ts | 18 +- packages/platform/src/installer.ts | 4 +- packages/platform/src/manifest.ts | 9 +- packages/react/src/pages-manifest.ts | 1 - packages/react/src/plugin-islands.ts | 49 +- packages/react/src/plugin-pages.ts | 12 +- packages/react/src/plugin.ts | 7 +- packages/react/src/runtime/use-island-form.ts | 2 +- packages/react/test/unit/plugin-pages.test.ts | 1 - packages/solid/src/pages-manifest.ts | 1 - packages/solid/src/plugin-islands.ts | 50 +- packages/solid/src/plugin-pages.ts | 9 +- packages/solid/src/plugin.ts | 7 +- packages/solid/src/runtime/use-island-form.ts | 2 +- packages/svelte/src/pages-manifest.ts | 1 - packages/svelte/src/plugin-islands.ts | 50 +- packages/svelte/src/plugin-pages.ts | 13 +- packages/svelte/src/plugin.ts | 7 +- .../src/runtime/use-island-form.svelte.ts | 2 +- packages/void/AGENTS.md | 2 +- packages/void/src/asset-routing.ts | 23 +- packages/void/src/auth/drizzle.ts | 63 +- packages/void/src/cli/auth.ts | 12 +- packages/void/src/cli/cf-access.ts | 30 +- packages/void/src/cli/cf-build-output.ts | 165 ++-- packages/void/src/cli/cf-config-migrate.ts | 13 +- packages/void/src/cli/cf-session.ts | 7 +- packages/void/src/cli/cf-version-api.ts | 79 +- packages/void/src/cli/cf-version-runner.ts | 111 ++- packages/void/src/cli/cli.ts | 177 ++--- packages/void/src/cli/client.ts | 2 +- packages/void/src/cli/cloudflare-auth.ts | 30 +- packages/void/src/cli/cloudflare-candidate.ts | 25 +- .../src/cli/cloudflare-deploy/classify.ts | 15 +- packages/void/src/cli/cloudflare-link.ts | 18 +- .../src/cli/cloudflare-operation-process.ts | 59 +- .../void/src/cli/cloudflare-operations.ts | 124 ++- packages/void/src/cli/cloudflare-process.ts | 41 +- packages/void/src/cli/cloudflare-readiness.ts | 20 +- packages/void/src/cli/create-project.ts | 1 - packages/void/src/cli/db.ts | 15 +- .../src/cli/deploy-artifact-validation.ts | 29 +- .../void/src/cli/deploy-binding-snapshot.ts | 16 +- .../void/src/cli/deploy-cloudflare-auth.ts | 13 +- .../void/src/cli/deploy-cloudflare-build.ts | 15 +- .../void/src/cli/deploy-cloudflare-email.ts | 15 +- .../void/src/cli/deploy-cloudflare-helpers.ts | 217 ++++- .../void/src/cli/deploy-cloudflare-migrate.ts | 118 ++- .../src/cli/deploy-cloudflare-preflight.ts | 129 +-- .../void/src/cli/deploy-cloudflare-publish.ts | 163 ++-- .../void/src/cli/deploy-cloudflare-static.ts | 198 ++--- packages/void/src/cli/deploy-cloudflare.ts | 167 ++-- packages/void/src/cli/deploy-drift.ts | 29 +- packages/void/src/cli/deploy-errors.ts | 2 +- packages/void/src/cli/deploy-helpers.ts | 116 ++- .../void/src/cli/deploy-managed-events.ts | 30 +- .../void/src/cli/deploy-managed-framework.ts | 103 +-- packages/void/src/cli/deploy-managed-full.ts | 171 +--- .../void/src/cli/deploy-managed-static.ts | 16 +- packages/void/src/cli/deploy-migrations.ts | 33 +- packages/void/src/cli/deploy-resources.ts | 11 +- packages/void/src/cli/deploy-setup.ts | 17 +- packages/void/src/cli/deploy-transport.ts | 24 - packages/void/src/cli/deploy.ts | 45 +- packages/void/src/cli/direct-r2-upload.ts | 3 +- packages/void/src/cli/domain.ts | 73 +- packages/void/src/cli/email-args.ts | 20 +- packages/void/src/cli/email-cloudflare.ts | 2 +- packages/void/src/cli/email.ts | 1 - packages/void/src/cli/env-scaffold.ts | 77 +- packages/void/src/cli/env-schema-probe.ts | 5 +- packages/void/src/cli/env-validation.ts | 38 +- .../void/src/cli/framework-config-migrate.ts | 97 ++- packages/void/src/cli/gen-args.ts | 53 +- packages/void/src/cli/gen.ts | 47 +- packages/void/src/cli/git-metadata.ts | 2 +- packages/void/src/cli/github-cmd.ts | 250 ++---- packages/void/src/cli/headers.ts | 4 +- packages/void/src/cli/init.ts | 228 ++---- packages/void/src/cli/link.ts | 2 - packages/void/src/cli/log-file.ts | 43 +- packages/void/src/cli/log-summary.ts | 21 +- packages/void/src/cli/operator-cmd.ts | 4 +- packages/void/src/cli/package.ts | 216 ++--- .../void/src/cli/platform-access-health.ts | 10 +- .../void/src/cli/platform-access-preflight.ts | 11 +- .../void/src/cli/platform-access-provision.ts | 44 +- .../void/src/cli/platform-access-setup.ts | 61 +- packages/void/src/cli/platform-app-access.ts | 27 +- packages/void/src/cli/platform-auth-config.ts | 52 +- packages/void/src/cli/platform-auth.ts | 46 +- packages/void/src/cli/platform-cloudflare.ts | 18 - packages/void/src/cli/platform-errors.ts | 2 +- .../void/src/cli/platform-install-auth.ts | 32 +- .../src/cli/platform-installer-database.ts | 2 +- packages/void/src/cli/platform-management.ts | 6 +- packages/void/src/cli/platform-setup.ts | 10 +- packages/void/src/cli/preset.ts | 45 +- packages/void/src/cli/project-team.ts | 8 +- packages/void/src/cli/project-token.ts | 7 +- packages/void/src/cli/project-zero-trust.ts | 7 +- packages/void/src/cli/project.ts | 5 - packages/void/src/cli/provision.ts | 114 ++- packages/void/src/cli/resolve-project.ts | 13 +- packages/void/src/cli/rollback.ts | 76 +- packages/void/src/cli/wrangler.ts | 424 +++------- packages/void/src/cli/yarn-pnp.ts | 65 +- packages/void/src/codegen/env-types.ts | 2 +- packages/void/src/codegen/project-tsconfig.ts | 3 +- packages/void/src/config.ts | 65 +- packages/void/src/crons/scan.ts | 8 +- packages/void/src/drizzle/discover.ts | 7 +- packages/void/src/durable/scan.ts | 6 +- packages/void/src/host/load-user-env.ts | 6 +- packages/void/src/index.ts | 80 +- packages/void/src/inference/walk.ts | 6 +- packages/void/src/isr-config.ts | 27 +- packages/void/src/migrations/plan.ts | 14 +- packages/void/src/migrations/runner-mysql.ts | 36 +- packages/void/src/migrations/runner-pg.ts | 8 +- packages/void/src/migrations/runner.ts | 3 +- packages/void/src/migrations/validate.ts | 137 +--- packages/void/src/pages/client.ts | 336 +++----- packages/void/src/pages/defer.ts | 1 - packages/void/src/pages/dev-css-assets.ts | 32 +- packages/void/src/pages/head.ts | 108 +-- packages/void/src/pages/island-imports.ts | 37 +- packages/void/src/pages/layout-resolution.ts | 14 +- packages/void/src/pages/manifest.ts | 16 +- packages/void/src/pages/navigation-engine.ts | 243 ++---- packages/void/src/pages/protocol.ts | 148 ++-- packages/void/src/pages/scan.ts | 39 +- packages/void/src/plugin-ai-setup.ts | 6 +- packages/void/src/plugin-client-stubs.ts | 20 +- packages/void/src/plugin-dev-config.ts | 50 +- packages/void/src/plugin-dev-triggers-hint.ts | 23 +- packages/void/src/plugin-drizzle.ts | 2 +- packages/void/src/plugin-email-dev-inbox.ts | 25 +- packages/void/src/plugin-env-client-fold.ts | 145 +--- packages/void/src/plugin-env-client-guard.ts | 107 ++- packages/void/src/plugin-env-schema.ts | 71 +- packages/void/src/plugin-framework-entry.ts | 16 +- .../void/src/plugin-framework-triggers.ts | 108 +-- packages/void/src/plugin-migration.ts | 11 +- packages/void/src/plugin-prerender.ts | 3 - packages/void/src/plugin-routing.ts | 161 ++-- packages/void/src/plugin-worker-bindings.ts | 159 ++-- packages/void/src/queues/scan.ts | 29 +- packages/void/src/router/compile.ts | 73 +- packages/void/src/router/email.ts | 2 +- packages/void/src/router/scan.ts | 1 - packages/void/src/runtime/ai.ts | 128 +-- packages/void/src/runtime/auth-migrations.ts | 66 +- .../void/src/runtime/better-auth-mysql.ts | 21 +- packages/void/src/runtime/better-auth-pg.ts | 28 +- .../void/src/runtime/better-auth-shared.ts | 34 +- packages/void/src/runtime/better-auth.ts | 5 +- packages/void/src/runtime/durable.ts | 13 +- packages/void/src/runtime/email.ts | 41 +- packages/void/src/runtime/email/dev-inbox.ts | 35 +- packages/void/src/runtime/email/inbound.ts | 91 +-- packages/void/src/runtime/email/mime.ts | 204 ++--- packages/void/src/runtime/email/testing.ts | 20 +- packages/void/src/runtime/env-public.ts | 53 +- packages/void/src/runtime/env-raw.ts | 3 +- packages/void/src/runtime/fetch-stream.ts | 74 +- packages/void/src/runtime/fetch.ts | 8 +- packages/void/src/runtime/handler.ts | 17 +- packages/void/src/runtime/isr.ts | 6 +- packages/void/src/runtime/kv.ts | 38 +- packages/void/src/runtime/live-client.ts | 37 +- packages/void/src/runtime/live-server.ts | 55 +- packages/void/src/runtime/live.ts | 23 +- .../void/src/runtime/migration-handler.ts | 111 +-- .../src/runtime/remote/binding-handler.ts | 18 +- packages/void/src/runtime/remote/client.ts | 10 +- packages/void/src/runtime/remote/d1.ts | 39 +- packages/void/src/runtime/remote/kv.ts | 37 +- packages/void/src/runtime/remote/r2.ts | 43 +- packages/void/src/runtime/routing-errors.ts | 43 +- packages/void/src/runtime/sse.ts | 3 +- packages/void/src/runtime/ws-server.ts | 191 +---- packages/void/src/sandbox/config.ts | 16 +- packages/void/src/shared/dotenv.ts | 7 +- packages/void/src/shared/env.ts | 18 +- packages/void/src/ssr/entry.ts | 28 +- .../test/e2e/cloudflare-deploy-live.test.ts | 47 +- packages/void/test/integration/astro.test.ts | 32 +- .../test/integration/browser-helpers.test.ts | 2 +- .../cloudflare-deploy-build.test.ts | 43 +- .../cloudflare-durable-build.test.ts | 2 +- .../void/test/integration/env-cli.test.ts | 5 +- .../integration/full-deploy-manifest.test.ts | 33 +- .../void/test/integration/info-cli.test.ts | 2 +- .../integration/kitchen-sink-build.test.ts | 6 +- .../test/integration/kitchen-sink.test.ts | 9 +- .../test/integration/named-layouts.test.ts | 1 - .../integration/pages-target-assets.test.ts | 12 +- .../void/test/integration/pg-test.test.ts | 17 +- .../integration/plugin-env-schema.test.ts | 7 +- .../resource-name-migration.test.ts | 1 - .../integration/typed-routing-runtime.test.ts | 2 - .../void/test/unit/build-logs-follow.test.ts | 2 +- .../void/test/unit/cloudflare-auth.test.ts | 22 +- packages/void/test/unit/compile.test.ts | 2 +- packages/void/test/unit/db-commands.test.ts | 30 +- .../test/unit/deploy-asset-config.test.ts | 2 +- .../void/test/unit/direct-r2-deploy.test.ts | 2 +- packages/void/test/unit/gen.test.ts | 61 +- .../void/test/unit/github-install-cmd.test.ts | 14 +- .../void/test/unit/github-join-cmd.test.ts | 4 +- .../void/test/unit/github-link-cmd.test.ts | 2 +- packages/void/test/unit/init.test.ts | 503 +++++------- packages/void/test/unit/isr-config.test.ts | 27 +- .../test/unit/platform-cloudflare.test.ts | 31 - .../void/test/unit/platform-recovery.test.ts | 1 - .../test/unit/validate-migrations.test.ts | 59 +- packages/vue/src/pages-manifest.ts | 1 - packages/vue/src/plugin-islands.ts | 51 +- packages/vue/src/plugin-pages.ts | 9 +- packages/vue/src/runtime/use-form.ts | 1 - packages/vue/src/runtime/use-island-form.ts | 2 +- platform/meta/operations.md | 2 +- .../packages/api/container/build-agent.mjs | 77 +- .../api/src/admin/backfill-queue-tokens.ts | 6 +- .../packages/api/src/admin/fate/page-data.ts | 27 +- .../api/src/admin/fate/page-server.ts | 390 ++------- .../packages/api/src/admin/fate/render.ts | 9 +- .../packages/api/src/admin/fate/server.ts | 3 +- .../api/src/admin/react/AdminProviders.tsx | 3 +- .../api/src/admin/react/page-utils.ts | 9 - .../packages/api/src/admin/react/server.ts | 5 +- .../packages/api/src/admin/react/styles.css | 25 +- .../src/admin/react/views/BuildDetailPage.tsx | 12 +- .../api/src/admin/react/views/BuildLogs.tsx | 3 - .../api/src/admin/react/views/BuildTable.tsx | 12 +- .../src/admin/react/views/DashboardPage.tsx | 6 +- .../react/views/DeploymentDetailPage.tsx | 13 +- .../src/admin/react/views/DeploymentLogs.tsx | 45 +- .../admin/react/views/EmailProjectPage.tsx | 9 +- .../api/src/admin/react/views/PlansPage.tsx | 3 - .../admin/react/views/ProjectDetailPage.tsx | 4 +- .../src/admin/react/views/UserDetailPage.tsx | 3 +- .../api/src/admin/react/views/format.ts | 2 +- .../api/src/admin/routes/auth-config.tsx | 24 +- .../packages/api/src/admin/routes/builds.tsx | 20 +- .../packages/api/src/admin/routes/email.tsx | 61 +- .../packages/api/src/admin/routes/plans.ts | 10 +- .../api/src/admin/routes/signup-control.tsx | 12 +- .../packages/api/src/admin/routes/system.tsx | 5 +- platform/packages/api/src/assets.ts | 125 ++- platform/packages/api/src/auth/bootstrap.ts | 2 +- .../packages/api/src/auth/deploy-token.ts | 6 +- platform/packages/api/src/auth/jwt.ts | 52 +- platform/packages/api/src/auth/pkce.ts | 4 +- platform/packages/api/src/auth/redirects.ts | 5 +- platform/packages/api/src/build-manager.ts | 249 +++--- platform/packages/api/src/build-scheduler.ts | 6 +- platform/packages/api/src/cron-fires-queue.ts | 13 +- platform/packages/api/src/cron-scheduler.ts | 25 +- platform/packages/api/src/db.ts | 1 - platform/packages/api/src/db/admin.ts | 147 ++-- platform/packages/api/src/db/builds.ts | 128 +-- platform/packages/api/src/db/deployments.ts | 46 +- platform/packages/api/src/db/domains.ts | 2 +- platform/packages/api/src/db/email.ts | 175 ++--- .../packages/api/src/db/github-authority.ts | 76 +- platform/packages/api/src/db/github.ts | 29 +- platform/packages/api/src/db/projects.ts | 14 +- platform/packages/api/src/db/resources.ts | 11 +- platform/packages/api/src/db/users.ts | 16 +- .../packages/api/src/deploy/compensate.ts | 86 +- .../api/src/deploy/durable-bindings.ts | 17 +- platform/packages/api/src/deploy/execute.ts | 150 ++-- platform/packages/api/src/deploy/guards.ts | 39 +- .../packages/api/src/deploy/migrate-worker.ts | 53 +- .../packages/api/src/deploy/migrations.ts | 1 - platform/packages/api/src/deploy/pipeline.ts | 66 +- .../packages/api/src/deploy/publish-static.ts | 87 +- .../packages/api/src/deploy/publish-worker.ts | 119 +-- platform/packages/api/src/deploy/recovery.ts | 155 ++-- platform/packages/api/src/deploy/shared.ts | 24 +- .../packages/api/src/deploy/upload-worker.ts | 31 +- .../packages/api/src/deployment-execution.ts | 77 +- .../packages/api/src/deployment-recovery.ts | 13 +- platform/packages/api/src/email.ts | 15 +- platform/packages/api/src/email/admission.ts | 147 ++-- .../packages/api/src/email/cf-customer.ts | 9 +- .../api/src/email/connection-service.ts | 740 ++++++------------ .../packages/api/src/email/domain-service.ts | 36 +- .../api/src/email/ingress-resolution.ts | 72 +- .../packages/api/src/email/internal-body.ts | 2 +- platform/packages/api/src/email/settings.ts | 28 +- .../api/src/email/shared-recipient-service.ts | 80 +- platform/packages/api/src/env.ts | 7 +- .../packages/api/src/github/cancel-builds.ts | 6 +- platform/packages/api/src/github/check-run.ts | 25 +- .../api/src/github/installation-repos.ts | 19 +- .../api/src/github/installation-token.ts | 57 +- .../api/src/github/user-installations.ts | 8 +- .../api/src/github/webhook-deliveries.ts | 11 +- platform/packages/api/src/index.ts | 46 +- platform/packages/api/src/middleware/auth.ts | 1 + .../api/src/middleware/cli-version.ts | 3 - .../api/src/migrations/execution-plan.ts | 2 - .../api/src/migrations/remote-runner.ts | 20 +- .../packages/api/src/observability/logger.ts | 10 +- .../api/src/operator/access-service.ts | 7 +- .../api/src/operator/asset-migration.ts | 2 +- platform/packages/api/src/operator/audit.ts | 48 +- .../api/src/operator/deployment-operations.ts | 4 +- .../packages/api/src/operator/email-routes.ts | 70 +- .../api/src/operator/operations-routes.ts | 3 +- platform/packages/api/src/operator/plans.ts | 64 +- .../api/src/operator/sandbox-operations.ts | 54 +- .../api/src/operator/tenant-service.ts | 4 +- platform/packages/api/src/project-secrets.ts | 4 - .../api/src/provisioning/activate-domain.ts | 12 +- .../packages/api/src/provisioning/cleanup.ts | 16 +- .../api/src/provisioning/cloudflare-api.ts | 8 +- .../api/src/provisioning/containers.ts | 3 - .../api/src/provisioning/email-routing.ts | 8 +- .../api/src/provisioning/provision.ts | 13 +- .../src/provisioning/sandbox-controller.ts | 66 +- platform/packages/api/src/r2-presign.ts | 13 +- .../packages/api/src/reserved-bindings.ts | 7 +- platform/packages/api/src/revalidate-core.ts | 61 +- platform/packages/api/src/routes/auth-oidc.ts | 5 +- platform/packages/api/src/routes/auth.ts | 21 +- platform/packages/api/src/routes/deploy.ts | 31 +- platform/packages/api/src/routes/domains.ts | 10 +- platform/packages/api/src/routes/email.ts | 13 +- platform/packages/api/src/routes/github.ts | 501 +++++------- .../api/src/routes/internal-email-actions.ts | 52 +- .../src/routes/internal-resolve-resource.ts | 2 +- .../api/src/routes/internal-revalidate.ts | 1 - .../api/src/routes/internal-verify-owner.ts | 6 +- .../packages/api/src/routes/project-team.ts | 69 +- .../packages/api/src/routes/project-tokens.ts | 115 ++- platform/packages/api/src/routes/projects.ts | 290 +++---- .../packages/api/src/routes/revalidate.ts | 1 - platform/packages/api/src/routes/secrets.ts | 25 +- platform/packages/api/src/routes/usage.ts | 63 +- platform/packages/api/src/routes/webhooks.ts | 154 ++-- platform/packages/api/src/routing-limits.ts | 2 +- platform/packages/api/src/routing.ts | 4 +- platform/packages/api/src/schema.ts | 4 +- platform/packages/api/src/tiers.ts | 2 - platform/packages/api/src/types.ts | 24 +- platform/packages/api/src/usage-counter.ts | 159 ++-- platform/packages/api/src/usage-queue.ts | 26 +- .../api/test/integration/admin-builds.test.ts | 4 - .../api/test/integration/admin-email.test.ts | 49 +- .../api/test/integration/api-builds.test.ts | 3 +- .../test/integration/api-check-runs.test.ts | 82 +- .../api/test/integration/api-delete.test.ts | 51 +- .../api/test/integration/api-deploy.test.ts | 170 ++-- .../api/test/integration/api-domains.test.ts | 42 +- .../api/test/integration/api-email.test.ts | 26 +- .../api/test/integration/api-github.test.ts | 148 ++-- .../test/integration/api-migrations.test.ts | 30 +- .../api/test/integration/api-oidc.test.ts | 38 +- .../api/test/integration/api-projects.test.ts | 49 +- .../test/integration/api-revalidate.test.ts | 17 +- .../api/test/integration/api-sandbox.test.ts | 60 +- .../api/test/integration/api-webhooks.test.ts | 125 ++- .../auth-email-verification.test.ts | 16 +- .../test/integration/cli-version-gate.test.ts | 41 +- .../api/test/integration/db-builds.test.ts | 25 +- .../integration/db-github-members.test.ts | 10 +- .../integration/deploy-token-scope.test.ts | 7 +- .../test/integration/email-domain-cf-stub.ts | 30 +- .../email-shared-recipients.test.ts | 2 +- .../github-connect-authorize-routes.test.ts | 1 - .../github-identity-link-routes.test.ts | 24 +- .../integration/github-identity-link.test.ts | 20 - .../integration/github-join-routes.test.ts | 17 +- .../packages/api/test/integration/helpers.ts | 100 +-- .../test/integration/operator-auth.test.ts | 2 +- .../test/integration/operator-cli-e2e.test.ts | 1 - .../integration/operator-operations.test.ts | 6 +- .../test/integration/operator-tenants.test.ts | 56 +- .../integration/platform-bootstrap.test.ts | 3 +- .../platform-human-sessions.test.ts | 1 - .../api/test/integration/scheduled.test.ts | 9 +- .../packages/api/test/integration/setup.ts | 42 +- .../integration/unread-deploy-cleanup.test.ts | 3 +- .../zero-trust-deploy-compensation.test.ts | 6 +- .../zero-trust-routing-backfill.test.ts | 4 +- .../integration/zero-trust-service.test.ts | 167 +--- .../packages/api/test/unit/assets.test.ts | 2 +- .../test/unit/build-agent-exit-path.test.ts | 14 +- .../api/test/unit/build-agent-flush.test.ts | 4 +- .../unit/build-agent-source-download.test.ts | 55 +- .../api/test/unit/build-manager.test.ts | 292 +++---- .../api/test/unit/build-scheduler.test.ts | 10 +- .../api/test/unit/clear-isr-cache.test.ts | 4 +- .../api/test/unit/cli-version.test.ts | 5 +- .../api/test/unit/cron-scheduler.test.ts | 9 +- .../api/test/unit/error-alert-monitor.test.ts | 3 - .../packages/api/test/unit/provision.test.ts | 9 +- .../unit/sandbox-concurrency-counter.test.ts | 3 - .../api/test/unit/sandbox-control.test.ts | 1 - .../unit/sanitize-log-line-oracle.test.ts | 17 +- .../api/test/unit/usage-counter.test.ts | 11 +- .../api/test/unit/usage-queue.test.ts | 5 - .../api/test/unit/webhook-deliveries.test.ts | 5 +- platform/packages/dashboard/lib/api.ts | 29 +- .../dashboard/pages/auth/callback.server.ts | 61 +- .../packages/dashboard/pages/index.server.ts | 3 +- .../test/access-token-script.test.ts | 16 +- .../dashboard/test/unit/auth-callback.test.ts | 2 - .../dashboard/ui/components/AppHeader.tsx | 77 +- .../dashboard/ui/types/theme-config.ts | 2 - platform/packages/dispatch/src/error-page.ts | 4 +- platform/packages/dispatch/src/index.ts | 233 ++---- platform/packages/dispatch/src/isr.ts | 4 +- .../packages/dispatch/src/request-type.ts | 13 +- .../packages/dispatch/test/unit/index.test.ts | 72 +- platform/packages/email-gateway/src/index.ts | 13 +- platform/packages/proxy/README.md | 6 +- platform/packages/proxy/src/ai.ts | 2 +- platform/packages/proxy/src/body.ts | 2 - platform/packages/proxy/src/dispatch.ts | 29 +- .../packages/proxy/src/email-admission.ts | 14 +- platform/packages/proxy/src/email-lease.ts | 29 +- .../proxy/src/email-provider-deadline.ts | 2 +- platform/packages/proxy/src/email-router.ts | 37 +- platform/packages/proxy/src/email.ts | 351 ++++----- platform/packages/proxy/src/hmac.ts | 29 + platform/packages/proxy/src/index.ts | 24 +- platform/packages/proxy/src/kv.ts | 1 - platform/packages/proxy/src/request-body.ts | 4 +- platform/packages/proxy/src/resolve.ts | 32 +- .../test/unit/email-custom-domain.test.ts | 1 - .../proxy/test/unit/email-router.test.ts | 2 +- platform/packages/proxy/wrangler.jsonc | 6 +- platform/scripts/kitchen-sink-smoke-test.md | 4 +- platform/scripts/kitchen-sink-smoke-test.ts | 51 -- playground/pg-test/routes/api/posts.ts | 2 - playground/pg-test/routes/api/users.ts | 2 - playground/pg-test/routes/api/users/[id].ts | 3 - playground/react-router/README.md | 7 +- playground/spa/README.md | 5 +- playground/tanstack-start/README.md | 7 +- playground/vue-pages/README.md | 5 +- playground/websockets/README.md | 2 +- test/check-playground-build-artifacts.mjs | 3 +- test/integration-helpers.ts | 1 - test/pages-client-contract.ts | 3 - test/pages-island-contract.ts | 1 - 511 files changed, 7630 insertions(+), 13667 deletions(-) delete mode 100644 packages/react/src/pages-manifest.ts delete mode 100644 packages/solid/src/pages-manifest.ts delete mode 100644 packages/svelte/src/pages-manifest.ts delete mode 100644 packages/vue/src/pages-manifest.ts create mode 100644 platform/packages/proxy/src/hmac.ts diff --git a/docs/.vitepress/theme/FileTree.vue b/docs/.vitepress/theme/FileTree.vue index e1fa92d55..13f5a79e0 100644 --- a/docs/.vitepress/theme/FileTree.vue +++ b/docs/.vitepress/theme/FileTree.vue @@ -365,7 +365,6 @@ const TreeNode = defineComponent({ color: var(--vp-c-brand-1); } -/* Adapter tabs */ /* Adapter tabs */ .adapter-tabs { display: flex; diff --git a/docs/guide/ai.md b/docs/guide/ai.md index 54345de84..aba223c15 100644 --- a/docs/guide/ai.md +++ b/docs/guide/ai.md @@ -41,7 +41,7 @@ export const POST = defineHandler(async (c) => { }); ``` -AI methods return promises. Await them to receive the result, or return the promise directly from a handler. Usage limits reject with [`UsageLimit`](#usage-limits); Void route handlers and Pages actions automatically send a structured HTTP 429 when it is left uncaught. +AI methods return promises. Await them to receive the result, or return the promise directly from a handler. See [Usage Limits](#usage-limits) for limit errors. You can use any model available through Cloudflare's AI binding, including Workers AI models such as `@cf/meta/llama-3.3-70b-instruct-fp8-fast` and Cloudflare Gateway models such as `google/gemini-2.5-flash` or `openai/gpt-4.1-mini`. The input object must match the selected Cloudflare model's schema. Models that return binary data, such as generated images, are returned as a `Blob` from `ai.run()`. diff --git a/docs/guide/auth.md b/docs/guide/auth.md index 3ba4e9b76..77ada7a1c 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -92,7 +92,7 @@ Email/password is enabled by default. To add social providers: } ``` -Use `auth.providers` when you want to enable social providers explicitly. Provider credentials come from `AUTH__CLIENT_ID` and `AUTH__CLIENT_SECRET`. +Provider credentials come from `AUTH__CLIENT_ID` and `AUTH__CLIENT_SECRET`. See the full provider list in the [config reference](../reference/config.md#auth). diff --git a/docs/guide/database/postgresql.md b/docs/guide/database/postgresql.md index 946a333af..88f064768 100644 --- a/docs/guide/database/postgresql.md +++ b/docs/guide/database/postgresql.md @@ -59,7 +59,7 @@ export const users = pgTable('users', { }); ``` -`void gen model` generates PostgreSQL-appropriate code when the dialect is set to `postgresql`. +`void gen model` generates PostgreSQL-appropriate code when `database` is set to `"pg"`. ## Migrations @@ -88,4 +88,4 @@ To update a linked Void project's connection, for example after moving the datab void db set-url ``` -This prompts for the new connection string and updates the Hyperdrive configuration. The change takes effect on the next deploy. +This prompts for the new connection string and updates the Hyperdrive configuration. diff --git a/docs/guide/edge/rewrites.md b/docs/guide/edge/rewrites.md index 41e502fed..73e314b2f 100644 --- a/docs/guide/edge/rewrites.md +++ b/docs/guide/edge/rewrites.md @@ -109,15 +109,13 @@ The file can include [3xx redirects](./redirects) too. Rules in `void.config.ts` ## Client navigation -Rewrites run on the server. In Pages mode, a client-side `` navigation may fetch loader data for `/docs` rather than the rewritten route `/en/docs`. +Rewrites run on the server. In Pages mode, a client-side `` navigation may fetch loader data for `/docs` rather than the rewritten route `/en/docs`. Use `` to make a full server request when the destination has a different loader. Use a redirect if the URL should change. ## ISR cache keys with rewrites -Static rewrites cache the destination separately for each original pathname. Query parameters vary the cache only when included in `routing.revalidateQueryAllowlist`. Middleware rewrites don't create a separate cache variant. - -Purge the destination with `revalidate({ paths: ['/en/docs/foo'] })` to clear direct and rewritten requests. Purging only `/docs/foo` does not clear that entry. See [Revalidation](./revalidation). +Purge the destination path, such as `/en/docs/foo`, to clear rewritten cache entries. See [Cache keys and rewrites](./revalidation#cache-keys-and-rewrites). ## Debugging with `X-Void-Routing` diff --git a/docs/guide/email/sending.md b/docs/guide/email/sending.md index c23b804c3..e2df02aaf 100644 --- a/docs/guide/email/sending.md +++ b/docs/guide/email/sending.md @@ -59,8 +59,6 @@ Cloudflare emails the recipient with a verification link. Once they click it and void email destinations ``` -The project owner’s email is added automatically, but still needs verification as described in [Setup](#setup). - ::: warning When this is the right fit The shared sender suits team alerts, project notifications, and replies to inbound mail. diff --git a/docs/guide/pages-routing/markdown.md b/docs/guide/pages-routing/markdown.md index f2fb99b20..26f6d7c15 100644 --- a/docs/guide/pages-routing/markdown.md +++ b/docs/guide/pages-routing/markdown.md @@ -401,7 +401,7 @@ Code blocks include a copy button. ### Line Numbers -Enable per code block with `:line-numbers` or disable with `:no-line-numbers`: +Enable per code block with `:line-numbers`: ````md ```ts :line-numbers @@ -412,14 +412,6 @@ const b = 2; Start from a specific number with `:line-numbers=5`. -### Snippet Imports - -Import code from external files: - -```md -<<< ./path/to/file.ts -``` - ### Emoji Shortcodes convert to unicode: `:tada:` becomes :tada:, `:rocket:` becomes :rocket:. @@ -455,16 +447,16 @@ Headings include permalink anchors. Use `[[toc]]` for a table of contents. Image ```ts voidMarkdown({ shiki: { - themes: { light: 'github-light', dark: 'github-dark' }, // Shiki themes - langs: ['sql', 'graphql'], // additional languages + themes: { light: 'github-light', dark: 'github-dark' }, }, }); ``` -| Option | Type | Default | Description | -| -------------- | -------------------------------- | ------------------------------------------------ | ---------------------------- | -| `shiki.themes` | `{ light: string; dark: string}` | `{ light: "github-light", dark: "github-dark" }` | Shiki color themes | -| `shiki.langs` | `string[]` | Common web languages | Additional languages to load | +| Option | Type | Default | Description | +| -------------- | -------------------------------- | ------------------------------------------------ | ------------------ | +| `shiki.themes` | `{ light: string; dark: string}` | `{ light: "github-light", dark: "github-dark" }` | Shiki color themes | + +Code block languages load on demand from Shiki's bundled languages. ## Building a Sidebar diff --git a/docs/guide/platform/administration/access.md b/docs/guide/platform/administration/access.md index 6856277f1..a6988d10b 100644 --- a/docs/guide/platform/administration/access.md +++ b/docs/guide/platform/administration/access.md @@ -30,15 +30,7 @@ the company sign-in before Void login. Interactive Access authentication uses a locally installed `cloudflared` and saves its short-lived credential in your system keychain for that platform origin. You do not need to copy browser cookies. -For CI, use a [project deploy token](/guide/platform/installation/first-deployment) alongside your Access credentials. An Access service token does not authorize a Void deployment. - -Store Access credentials in origin-keyed `VOID_ACCESS_CREDENTIALS`. A deploy -that uses prerendering or remote bindings needs entries for both the exact API -and proxy HTTPS origins, even when both entries contain the same admitted -service-token pair. `VOID_ACCESS_ORIGIN` selects only one recipient and therefore -cannot cover both calls. Keep the JSON value in your secret manager, not in -application configuration. See [CI deployment setup](/guide/platform/installation/first-deployment) -for the required shape. +For CI, use a [project deploy token](/guide/platform/installation/first-deployment) alongside your Access credentials in origin-keyed `VOID_ACCESS_CREDENTIALS`. An Access service token does not authorize a Void deployment. See [CI deployment setup](/guide/platform/installation/first-deployment) for the required origins and shape. ## Choosing a Platform diff --git a/docs/guide/platform/administration/operations.md b/docs/guide/platform/administration/operations.md index 2522678a3..41bc895a0 100644 --- a/docs/guide/platform/administration/operations.md +++ b/docs/guide/platform/administration/operations.md @@ -66,18 +66,9 @@ The dashboard’s **System Status** page refreshes health checks every 30 second Use `void platform upgrade`, `repair`, `disable`, and `enable` to maintain your platform. See [platform maintenance](/guide/platform/installation/maintenance#resume-repair-recover-and-upgrade). -Upgrades and rollbacks run through the CLI; watching their progress is optional. -During maintenance, navigating the admin UI automatically redirects to -`/admin/status`. You can also open that page directly on the API origin. It -refreshes automatically and shows saved progress and the API's traffic state -while the API and authentication database remain reachable. If a status refresh -fails, the last confirmed snapshot is marked as stale. Normal administrator -changes stay paused during maintenance. -If the CLI reports an error or a waiting state, follow its recovery instructions -to resume the operation. See -[Follow upgrade and rollback progress](/guide/platform/installation/maintenance#follow-upgrade-and-rollback-progress). - -For disaster recovery, keep a matching PostgreSQL backup, provider data backups, installation identity and complete project-encryption keyring, and immutable runtime artifacts. Keep traffic disabled while restoring data, run `discover` to reconstruct verified local metadata, and review `repair --plan` before recreating missing installer-owned infrastructure. Neither command restores backed-up data. See [Prepare for disaster recovery](/guide/platform/installation/maintenance#prepare-for-disaster-recovery) for the full sequence. +During an upgrade or rollback, the admin UI shows progress at `/admin/status`. See [Follow upgrade and rollback progress](/guide/platform/installation/maintenance#follow-upgrade-and-rollback-progress). + +For backups and restores, see [Prepare for disaster recovery](/guide/platform/installation/maintenance#prepare-for-disaster-recovery). ## Using Scripts diff --git a/docs/guide/platform/development/local.md b/docs/guide/platform/development/local.md index aaf6a3989..59250a12a 100644 --- a/docs/guide/platform/development/local.md +++ b/docs/guide/platform/development/local.md @@ -36,7 +36,7 @@ Use the Node.js version recorded in `.node-version`. The `@voidcloud/*` names identify workspace implementation packages. Their `private: true` flags prevent publishing those packages to npm. Deployable core Workers are bundled into the public `@void/platform` package. -The core installer creates the API, dispatch, proxy, and tail Workers. The dashboard and managed GitHub build services are available in the source tree but are not included in that installation. Adding an optional service requires its infrastructure, bindings, authentication, and capability configuration together. +The core installer creates the API, dispatch, proxy, tail, and email gateway Workers. The dashboard and managed GitHub build services are available in the source tree but are not included in that installation. Adding an optional service requires its infrastructure, bindings, authentication, and capability configuration together. ## Running the API Locally diff --git a/docs/guide/platform/development/runtime.md b/docs/guide/platform/development/runtime.md index 7a4c86cc2..17ffa7804 100644 --- a/docs/guide/platform/development/runtime.md +++ b/docs/guide/platform/development/runtime.md @@ -25,7 +25,7 @@ void-dev platform install \ --plan ``` -Before applying the plan, complete the [installation guides](/guide/self-hosted-platform). Run the command without `--plan` to install. For testing before your domain is ready, replace `--application-domain` and `--zone` with `--workers-dev`; PostgreSQL and the runtime/GitHub/R2 credentials are still needed. Use `void-dev` in place of `void` and keep `--runtime packages/platform/dist/runtime` on install and resume commands. For an interrupted installation, keep that runtime build available until it completes. +Before applying the plan, complete the [installation guides](/guide/self-hosted-platform). Run the command without `--plan` to install. For testing before your domain is ready, replace `--application-domain` and `--zone` with `--workers-dev`; PostgreSQL and the runtime, login-provider, and R2 credentials are still needed. Use `void-dev` in place of `void` and keep `--runtime packages/platform/dist/runtime` on install and resume commands. For an interrupted installation, keep that runtime build available until it completes. Later, run `void-dev platform domain set example.app`. Domain setup uses the existing installed runtime and needs no `--runtime`, rebuild, or app redeploy. It keeps the platform API URL and original workers.dev app URLs available. diff --git a/docs/guide/platform/development/schema-ci.md b/docs/guide/platform/development/schema-ci.md index ae0949582..f9c6aa6b7 100644 --- a/docs/guide/platform/development/schema-ci.md +++ b/docs/guide/platform/development/schema-ci.md @@ -20,7 +20,7 @@ Review the generated SQL and migration journal before committing them. Released `@void/platform` contains installation manifests, resource naming, discovery contracts, Worker configuration, and deployable bundles under the `./installer` export. Platform source lives in `platform/packages/`. -Runtime artifacts include migration SQL, its journal, and a rollback compatibility manifest. Drizzle authoring snapshots remain in the source tree. Migrations are forward-only; compatibility edges in `platform/packages/api/drizzle/compatibility.json` must be tested against the earlier runtime before publication. The build verifies schema lineage and hashes the journal and SQL files. +Runtime artifacts include migration SQL, its journal, and a rollback compatibility manifest. Drizzle authoring snapshots remain in the source tree. ## CI in a Fork diff --git a/docs/guide/platform/installation/domains.md b/docs/guide/platform/installation/domains.md index 29a728b32..50efaa7b3 100644 --- a/docs/guide/platform/installation/domains.md +++ b/docs/guide/platform/installation/domains.md @@ -91,7 +91,7 @@ New platform resources use deterministic `void--` names | Workers | 5, plus one per app using workers.dev | API/control plane, proxy, tail ingestion, dispatch, email gateway, and test-origin forwarders | | KV namespaces | 3 | Routing, ISR cache, and static asset storage | | R2 buckets | 1 | Static and deployment assets | -| Queues | 2 | Usage events and cron firing | +| Queues | 3 | Usage events, cron firing, and deployments | | Workers for Platforms dispatch namespaces | 1 | User application Workers | | Hyperdrive configurations | 1 | External platform PostgreSQL | | AI Gateways | 1 | Installation-isolated AI routing and metering | diff --git a/docs/guide/platform/installation/first-deployment.md b/docs/guide/platform/installation/first-deployment.md index c6a6a1196..8507272af 100644 --- a/docs/guide/platform/installation/first-deployment.md +++ b/docs/guide/platform/installation/first-deployment.md @@ -83,7 +83,7 @@ void platform signup allow github teammate Void shows the proposed access change and asks you to confirm it. Once approved, `teammate` can connect to the platform's API URL and sign in with GitHub. -To see who can join, run `void platform signup show`. You can also allow an email address or a domain such as `*@example.com`. For an OIDC user without verified email, use `void platform signup allow identity `; the subject match is exact and the provider's domain or group restrictions still apply. Remove that grant with `void platform signup disallow identity `. `void platform signup open` permits public signup; `void platform signup restrict` requires an allowlist match again. +To allow email addresses, domains, or OIDC identities, or to open signup, see [Giving People Access](/guide/platform/administration/access#giving-people-access). Your administrator session lasts for one hour. Use it to inspect users, projects, logs, and platform health. The [Platform Administration guide](/guide/platform-administration) walks through those workflows, previews, and automation. You can also open `/admin/login` to use the browser admin UI. diff --git a/docs/guide/project-collaboration.md b/docs/guide/project-collaboration.md index bd31f3b4c..852b78128 100644 --- a/docs/guide/project-collaboration.md +++ b/docs/guide/project-collaboration.md @@ -66,8 +66,8 @@ An invitation is bound to the registered account. Forwarding its ID does not let These three commands use the active platform connection selected by `void connect`, even inside a directory linked to another project or deployed directly to Cloudflare. `VOID_API_URL` takes precedence when set. If you use -`VOID_TOKEN`, set `VOID_API_URL` to the matching platform; a token without that -URL selects Void Cloud. Each command shows the platform it contacts. +`VOID_TOKEN`, also set `VOID_API_URL` to the matching platform. Each command +shows the platform it contacts. Accepting an invitation grants access without linking your current directory. Open the invited application's directory and run `void project link` if it is diff --git a/docs/guide/remote-dev.md b/docs/guide/remote-dev.md index 3bbcc7ba4..f97fe3228 100644 --- a/docs/guide/remote-dev.md +++ b/docs/guide/remote-dev.md @@ -49,16 +49,16 @@ AI requests use your platform’s account and allowance in both local and remote When the dev server starts with remote mode active, it prints: ``` -⚡ Remote bindings active (my-project.apps.example.com) - DB → remote D1 - KV → remote KV - STORAGE → remote R2 +[void] Remote bindings active + DB → remote D1 + KV → remote KV + STORAGE → remote R2 ``` ## Limitations - **Network latency:** each binding call makes a network request, so responses may be slower than local development. - **R2 multipart uploads:** `createMultipartUpload()` and `resumeMultipartUpload()` are not supported in remote mode. -- **R2 conditional writes:** `put(..., { onlyIf })` requires a current Void platform and an active deployment with the native remote-binding handler. Update the platform and redeploy the project if this operation is unavailable. Failed preconditions return `null`. +- **R2 conditional writes:** `put(..., { onlyIf })` requires an updated platform and project deployment. Failed preconditions return `null`. - **D1 dump:** `db.dump()` is not supported in remote mode. - **D1 batches:** `db.batch()` requires an updated platform and project deployment. diff --git a/docs/guide/sandboxes.md b/docs/guide/sandboxes.md index 2b1745c30..f00b4fc60 100644 --- a/docs/guide/sandboxes.md +++ b/docs/guide/sandboxes.md @@ -97,7 +97,7 @@ Files, running processes, and listening servers last only as long as the contain ## Configuration -The default environment provides Node.js 24 on Debian Trixie. Managed platform deploys use Cloudflare's managed Node.js image. +The default environment provides Node.js 26 on Debian Trixie. Managed platform deploys use Cloudflare's managed Node.js image. Use `void.config.ts` when you need a custom image or container size: @@ -118,7 +118,7 @@ See the [configuration reference](../reference/config.md#sandbox) for binding na Custom images must include the helper matching Void's installed Sandbox SDK version. The SDK image supplies this binary, but is not a runnable environment itself: ```dockerfile -FROM node:24.21.0-trixie-slim +FROM node:26.10.0-trixie-slim COPY --from=docker.io/cloudflare/sandbox:1.0.0 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim WORKDIR /workspace CMD ["sleep", "infinity"] diff --git a/docs/guide/sse.md b/docs/guide/sse.md index c456c14a8..99b53ff44 100644 --- a/docs/guide/sse.md +++ b/docs/guide/sse.md @@ -168,6 +168,4 @@ export const GET = defineHandler(async (c) => { }); ``` -Native `EventSource` can send cookies with `withCredentials: true`. For non-cookie auth, generate a short-lived signed URL and validate it in the route handler. - For shared topics and subscriptions, use [Live Event Streams](./live.md). For two-way communication, use [WebSockets](./websockets.md). diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md index 9976fa8ee..44b30e743 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -111,6 +111,7 @@ The following imports are **not available** with a non-CF target and produce a c | `void/storage` | R2 (blob storage) | | `void/auth` | Void-managed Better Auth | | `void/ai` | Workers AI | +| `void/sandbox` | Cloudflare Sandboxes | | `void/env` | Typed environment schema | If you need a database or storage, use an external provider and connect via standard Node.js libraries. diff --git a/docs/reference/api/auth.md b/docs/reference/api/auth.md index 1d59f65a9..a759edf75 100644 --- a/docs/reference/api/auth.md +++ b/docs/reference/api/auth.md @@ -4,7 +4,7 @@ outline: deep # Authentication {#auth} -Imported from `"void"` or `"void/auth"`. Client-side helpers imported from `"void/client"`. +Imported from `"void/auth"`. Client-side helpers imported from `"void/client"`. ## `defineAuth(config)` {#defineauth-config} diff --git a/docs/reference/api/handlers.md b/docs/reference/api/handlers.md index 4d92e739c..33e4279fb 100644 --- a/docs/reference/api/handlers.md +++ b/docs/reference/api/handlers.md @@ -182,7 +182,7 @@ export default defineQueue<{ to: string; subject: string }>(async (batch, env) = ```ts function defineQueue( handler: (batch: QueueBatch, env: CloudEnv['Bindings']) => void | Promise, -): QueueFn; +): TypedQueueHandler; ``` ## `defineRender(handler)` {#definerender-handler} diff --git a/docs/reference/cli/database.md b/docs/reference/cli/database.md index eddf2b351..d47efaf2a 100644 --- a/docs/reference/cli/database.md +++ b/docs/reference/cli/database.md @@ -24,7 +24,7 @@ Show migration status. Displays which migrations are applied or pending locally, ## `void db reset` {#void-db-reset} -Drop the local D1 database and re-apply all migrations. Does not affect the remote database. +Drop the development database and re-apply all migrations. D1 resets the local database; PostgreSQL and MySQL drop all tables in the `DATABASE_URL` database from `.env`. Does not affect the deployed database. ## `void db seed` {#void-db-seed} @@ -79,11 +79,10 @@ Open [Drizzle Studio](https://orm.drizzle.team/docs/drizzle-kit-studio) for the By default, targets the local database. Pass `--remote` to open Studio against the deployed database: -- **PostgreSQL and MySQL projects**: fetch the stored connection string from the platform and open Studio against it. If the URL isn't stored yet, run `void db set-url` first. +- **Hosted PostgreSQL and MySQL projects**: fetch the stored connection string from the platform and open Studio against it. If the URL isn't stored yet, run `void db set-url` first. +- **Direct Cloudflare PostgreSQL and MySQL projects**: use `DATABASE_URL` from the current shell. - **D1 projects**: remote Studio is not yet supported. Use `void db execute --remote` for ad-hoc queries against your deployed D1 database. -On direct Cloudflare PostgreSQL/MySQL targets, remote Studio uses `DATABASE_URL` from the current shell. Direct D1 Studio remains unsupported; use `void db execute --remote`. - ## `void db rename-migrations` {#void-db-rename-migrations} Rename existing migrations from the old numeric prefix format (`0001_name.sql`) to timestamp-based format (`20260410161500_name.sql`). Updates local tracking table and remote records if logged in with a linked project. diff --git a/docs/reference/cli/generate.md b/docs/reference/cli/generate.md index 9350f82e6..4771765b0 100644 --- a/docs/reference/cli/generate.md +++ b/docs/reference/cli/generate.md @@ -109,7 +109,7 @@ void gen cron hourly-sync void gen queue ``` -Create a queue consumer file in `queues/` with `defineQueue`, a `Message` interface, and commented-out batch options. +Create a queue consumer file in `queues/` with `defineQueue`, a `Message` type, and commented-out batch options. ```sh void gen queue emails diff --git a/docs/reference/cli/github.md b/docs/reference/cli/github.md index f84e93e6d..0e6e60cae 100644 --- a/docs/reference/cli/github.md +++ b/docs/reference/cli/github.md @@ -6,6 +6,8 @@ outline: deep Deploy-on-GitHub works from **any** Void login — Google, GitHub, or other SSO. The first time you connect GitHub, Void links your GitHub identity to your current account (a one-time step, independent of how you logged in); it never creates a second account. +Project-scoped `github` and `build` commands resolve the project in the same order as deploy: the `[project]` argument or `--project`, then `VOID_PROJECT`, then the linked project (`.void/project.json`). + ## `void github link` {#void-github-link} ``` @@ -78,8 +80,6 @@ void github connect my-app \ --branch main ``` -**Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`). - You can connect only repositories your GitHub account can access. A project has one GitHub connection. Running `void github connect` on a project that is already connected fails. To connect a different repository, run `void github disconnect` first. @@ -107,8 +107,6 @@ The CLI prompts for new settings using the current values as defaults. In CI, pa void github update my-app --executor github_actions ``` -**Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`). - ## `void github status` {#void-github-status} ``` @@ -127,15 +125,13 @@ Show the connected repository, branch, executor, and workflow. Container builds void github status my-app ``` -**Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`). - ## `void github disconnect` {#void-github-disconnect} ``` void github disconnect [project] ``` -Disconnect a project from its GitHub repository, stopping automatic deploys. Any in-flight builds for the project are cancelled (their deploy tokens are revoked) before the connection is removed. If the project has no connection, it reports that and exits successfully. To point a project at a different repository, disconnect first, then run `void github connect`. +Disconnect a project from its GitHub repository, stopping automatic deploys. Any in-flight builds for the project are cancelled before the connection is removed. If the project has no connection, it reports that and exits successfully. To point a project at a different repository, disconnect first, then run `void github connect`. You are asked to confirm before anything is removed. Pass `--yes` to skip the prompt; `--yes` is **required** in a non-interactive shell (CI), where there is no prompt to answer. @@ -150,8 +146,6 @@ You are asked to confirm before anything is removed. Pass `--yes` to skip the pr void github disconnect my-app --yes ``` -**Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`). - ## Build {#build} Inspect Deploy-on-GitHub builds. @@ -171,9 +165,6 @@ Stream, tail, or download the build logs for a **container** build. With no | `--output `, `-o` | Write logs to a file instead of stdout (appends while following). | stdout | | `--project `, `-p` | Target project. | linked | -**Project resolution** follows the same order as deploy: positional / `--project`, -`VOID_PROJECT`, linked project (`.void/project.json`). - Builds run on **GitHub Actions** keep their logs on GitHub — the command prints the Actions run URL instead of streaming. Only the last 10,000 log lines of a container build are retained. diff --git a/docs/reference/cli/platform-config.md b/docs/reference/cli/platform-config.md index 437b92a19..aa0c98af3 100644 --- a/docs/reference/cli/platform-config.md +++ b/docs/reference/cli/platform-config.md @@ -89,4 +89,4 @@ when using `--yes`. Recovery requires Cloudflare management/database authority, original recovery keys, and a successful browser provider test. Use `--client-secret-env ` for new or rotated credentials. -For automation, supply `VOID_OPERATOR_TOKEN` with an explicit `VOID_API_URL` or `--connection`. Operator tokens are stored separately from application deployment credentials. The API checks your current administrator access on every request. See [Using Scripts](../../guide/platform/administration/operations.md#using-scripts) for an example. +For automation, see [operator authentication](./platform.md#operator-authentication). diff --git a/docs/reference/cli/platform-email.md b/docs/reference/cli/platform-email.md index 98c336d25..5cde91924 100644 --- a/docs/reference/cli/platform-email.md +++ b/docs/reference/cli/platform-email.md @@ -42,9 +42,8 @@ has ended and the operation is at least 24 hours old, inspect the exact resource named by the preview and attest whether its write was `applied` or `not-applied`. Applied writes continue at the next step; not-applied writes retry the same persisted intent. The running platform version must match that intent, so restore -the matching version before recovering an operation created by older code. The -preview pins the step, attempt, connection and route generations, resource -identity, and digest used by the apply request. Time alone never retries a write. +the matching version before recovering an operation created by older code. Time +alone never retries a write. `shared-recipients` discovers shared inbound addresses, provider rule names and IDs, and interrupted creation intents, including projects awaiting deletion. diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index 944270e14..ffc352525 100644 --- a/docs/reference/cli/platform-installation.md +++ b/docs/reference/cli/platform-installation.md @@ -113,7 +113,7 @@ use `repair --dashboard-url ` or `upgrade --dashboard-url `. Preview with `--plan` first. Existing Access protection must cover the configured dashboard origin before maintenance can proceed. See [Optional Dashboard](../../guide/platform/installation/domains.md#optional-dashboard). -After discovery on another machine, set `VOID_PLATFORM_DATABASE_URL`. An upgrade that preserves every deployed Worker also preserves its secrets. For an email-enabled installation without its encrypted recovery file, restore `VOID_PLATFORM_EMAIL_SIGNING_SECRET`; recreating only the email gateway needs that key and does not need the Cloudflare runtime token or JWT signing key. Recreating the API or proxy also requires the email key when email is enabled, in addition to their normal secrets. Recreating the API requires its original runtime-token, GitHub, R2, JWT, and project-encryption values; recreating the proxy requires the runtime token and JWT signing key. +After discovery on another machine, set `VOID_PLATFORM_DATABASE_URL`. See [Manage an installation from another machine](../../guide/platform/installation/maintenance.md#manage-an-installation-from-another-machine) for the secrets needed to recreate a missing Worker. | Command | Behavior | | ----------- | ----------------------------------------------------------------------------------------------------------- | diff --git a/docs/reference/cli/platform-users.md b/docs/reference/cli/platform-users.md index de45c8e1b..73bb0ec5c 100644 --- a/docs/reference/cli/platform-users.md +++ b/docs/reference/cli/platform-users.md @@ -16,7 +16,7 @@ void platform user show Use the same ID to change a plan, suspend or restore the account, or delete it: ```sh -void platform user plan +void platform user plan void platform user suspend [--reason ] void platform user restore void platform user delete diff --git a/docs/reference/cli/project.md b/docs/reference/cli/project.md index e0ce5c974..d47afec4b 100644 --- a/docs/reference/cli/project.md +++ b/docs/reference/cli/project.md @@ -195,7 +195,7 @@ This command is hosted-only. Direct Cloudflare deploys are local operations and ## `void project delete [name]` {#void-project-delete-name} -Permanently delete a hosted Void project and all its resources (databases, KV namespaces, R2 buckets, deployments). Requires typing the project slug to confirm. +Permanently delete a hosted Void project and all its resources (databases, KV namespaces, R2 buckets, deployments). Requires typing the project slug to confirm; `--force` (`-f`) skips the prompt. If `[name]` is omitted, uses the linked project. @@ -211,4 +211,4 @@ Purge all cached pages for the linked project. The edge cache will clear within If `--project` is provided, purges that project's cache instead of the linked project. -This command is currently hosted-only. Direct Cloudflare cache purge fails closed with guidance. +This command is hosted-only. For direct Cloudflare targets, redeploy or purge the configured ISR KV namespace yourself. diff --git a/docs/reference/cli/secrets.md b/docs/reference/cli/secrets.md index f166d7d68..b0994eb05 100644 --- a/docs/reference/cli/secrets.md +++ b/docs/reference/cli/secrets.md @@ -14,8 +14,6 @@ List production secret names for the saved target. Secret values are never print ## `void secret put` {#void-secret-put} -On hosted projects, secret writes and deletes return a retryable conflict while a deployment or rollback is in progress. Wait for that operation to finish and retry; the rejected operation leaves the stored secret unchanged. - ``` void secret put [--project ] void secret put [--project ] @@ -27,7 +25,7 @@ Value input modes: - prompt (TTY): `void secret put API_KEY` (masked input) - stdin: `echo -n "abcd" | void secret put API_KEY` -On a direct Cloudflare target, the value is sent to Cloudflare over stdin and stored as an encrypted Worker secret. +On a direct Cloudflare target, the value is stored as an encrypted Worker secret. ## `void secret sync` {#void-secret-sync} @@ -51,6 +49,8 @@ void secret delete [--project ] Secret commands use the platform saved in `.void/project.json`. Hosted project resolution follows the same order as deploy (`--project`, env var, linked project). Direct Cloudflare targets reject `--project` and use the pinned root Cloudflare config. +On hosted projects, secret writes and deletes return a retryable conflict while a deployment or rollback is in progress. Wait for that operation to finish and retry; the rejected operation leaves the stored secret unchanged. + ## Env Schema {#env-schema} ### `void env check` {#void-env-check} diff --git a/docs/reference/config.md b/docs/reference/config.md index ebd29022b..4328b214b 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -297,6 +297,18 @@ Curated Cloudflare Workers configuration. `void init` pins `compatibility_date` `worker.vars` values must be strings. Root `.env` values override them during local development only. Production builds do not load `.env`, and reject any `worker.vars` name declared as a server key in `env.ts`. Use `void secret put` for production server values. +`worker.limits.cpu_ms` sets the CPU time limit per request, from 1 to 300000 ms. On a Void platform, deploy fails if the limit exceeds the account plan; lower it in `void.config.ts` before retrying. Rollback instead caps the old limit at the current plan ceiling. On direct deploys, [Cloudflare enforces the value](https://developers.cloudflare.com/workers/platform/limits/#cpu-time): Workers Free allows up to 10 ms and Workers Paid up to 300000 ms per request. + +```json +{ + "worker": { + "limits": { + "cpu_ms": 30000 + } + } +} +``` + ### `cloudflare` The complete Cloudflare Worker configuration for direct Cloudflare builds and deployment. Put custom bindings, `name`, `account_id`, routes, services, vars, migrations, and any other Cloudflare fields here. Void passes these fields through to the generated Cloudflare build configuration. Settings you edit here take precedence over values Void previously recorded in `void.lock.json`. @@ -325,18 +337,6 @@ deploy: { cloudflare: { mode: 'atomic' } }, This choice applies to subsequent `void deploy` runs. Atomic deployment sends traffic to the new Worker before Void checks readiness; a Durable Object class migration cannot be rolled back across its migration boundary. Use `--atomic` for a single deployment instead. `deploy` is a Void setting and is not passed to Cloudflare's Worker config. -`worker.limits.cpu_ms` sets the CPU time limit per request, from 1 to 300000 ms. On a Void platform, deploy fails if the limit exceeds the account plan; lower it in `void.config.ts` before retrying. Rollback instead caps the old limit at the current plan ceiling. On direct deploys, [Cloudflare enforces the value](https://developers.cloudflare.com/workers/platform/limits/#cpu-time): Workers Free allows up to 10 ms and Workers Paid up to 300000 ms per request. - -```json -{ - "worker": { - "limits": { - "cpu_ms": 30000 - } - } -} -``` - ### `routing` Routing and edge configuration for headers, redirects, rewrites, and caching. diff --git a/docs/reference/resource-inference.md b/docs/reference/resource-inference.md index a4d2cb78c..7f49e59ec 100644 --- a/docs/reference/resource-inference.md +++ b/docs/reference/resource-inference.md @@ -80,22 +80,7 @@ Paths are relative to the project root. When `inference.scanDirs` is set, it rep ## Explicit Binding Overrides -Override individual bindings when inference does not match your setup: - -```json -{ - "inference": { - "bindings": { - "db": true, - "kv": true, - "storage": false, - "ai": false - } - } -} -``` - -Explicit values override only their named bindings. Omitted bindings continue to be inferred, so setting `kv` does not disable storage, auth, or other features. Auth and checked-in migrations still require a database even when `db` is `false`. +Use [`inference.bindings`](./config.md#inference-bindings) to enable, disable, or rename a binding when inference does not match your setup. It overrides only the bindings you name; the rest are still inferred. ## File Types diff --git a/packages/create-void/src/index.ts b/packages/create-void/src/index.ts index 832edbca8..12b11d5bc 100644 --- a/packages/create-void/src/index.ts +++ b/packages/create-void/src/index.ts @@ -11,7 +11,6 @@ const pm = detectPM(); async function main() { console.log(); - // 1. Get app name from argv or prompt let appName = process.argv[2]; if (!appName) { @@ -32,7 +31,6 @@ async function main() { appName = name; } - // 2. Validate target dir const targetDir = resolve(appName); if (existsSync(targetDir)) { @@ -49,7 +47,6 @@ async function main() { readFileSync(new URL('../package.json', import.meta.url), 'utf8'), ) as { version: string }; - // 4. Scaffold: create dir, write package.json, install void, run init, install starter deps mkdirSync(targetDir, { recursive: true }); const pkg = { @@ -68,13 +65,13 @@ async function main() { writePnpmBuildPolicy(targetDir, pm); p.log.step('Installing Void...'); - await run([...pmInstall(pm)], targetDir); + await run(pmInstall(pm), targetDir); p.log.step('Scaffolding starter...'); await run([...pmExec(pm), 'void', 'init'], targetDir); p.log.step('Installing starter dependencies...'); - await run([...pmInstall(pm)], targetDir); + await run(pmInstall(pm), targetDir); p.log.success(`Created ${pc.bold(appName)}`); p.outro(`cd ${appName} && ${pm === 'npm' ? 'npm run' : pm} dev`); diff --git a/packages/deploy-cloudflare/src/config.ts b/packages/deploy-cloudflare/src/config.ts index e625fb752..2a8b88157 100644 --- a/packages/deploy-cloudflare/src/config.ts +++ b/packages/deploy-cloudflare/src/config.ts @@ -25,31 +25,32 @@ export function readWranglerTopLevelString( config: Record, field: string, ): string | undefined { - const value = config[field]; - return typeof value === 'string' && value.length > 0 ? value : undefined; + return readConfigEntryString(config, field); } -export function readStoredResourceName( +/** The first entry of `config[field]` whose `binding` matches. */ +function findBindingEntry( config: Record, field: string, binding: string, - nameField: string, -): string | undefined { +): Record | undefined { const list = config[field]; if (!Array.isArray(list)) { return undefined; } - for (const entry of list) { - if ( - entry && - typeof entry === 'object' && - (entry as { binding?: unknown }).binding === binding - ) { - const name = (entry as Record)[nameField]; - return typeof name === 'string' && name.length > 0 ? name : undefined; - } - } - return undefined; + return list.find( + (entry): entry is Record => + entry && typeof entry === 'object' && (entry as { binding?: unknown }).binding === binding, + ); +} + +export function readStoredResourceName( + config: Record, + field: string, + binding: string, + nameField: string, +): string | undefined { + return readConfigEntryString(findBindingEntry(config, field, binding), nameField); } export function readBindingFieldPresence( @@ -58,22 +59,10 @@ export function readBindingFieldPresence( binding: string, key: string, ): { present: boolean; value: unknown } { - const list = config[field]; - if (!Array.isArray(list)) { - return { present: false, value: undefined }; - } - for (const entry of list) { - if ( - entry && - typeof entry === 'object' && - (entry as { binding?: unknown }).binding === binding - ) { - return Object.hasOwn(entry as object, key) - ? { present: true, value: (entry as Record)[key] } - : { present: false, value: undefined }; - } - } - return { present: false, value: undefined }; + const entry = findBindingEntry(config, field, binding); + return entry && Object.hasOwn(entry, key) + ? { present: true, value: entry[key] } + : { present: false, value: undefined }; } export function readConfigEntryString(entry: unknown, field: string): string | undefined { diff --git a/packages/deploy-cloudflare/src/execute.ts b/packages/deploy-cloudflare/src/execute.ts index 3767cdcb9..a53154e3b 100644 --- a/packages/deploy-cloudflare/src/execute.ts +++ b/packages/deploy-cloudflare/src/execute.ts @@ -32,11 +32,9 @@ export async function executeCloudflareDeploy(options: { return { ok: false, reason: 'uploader-changed' }; } - const result = await Promise.resolve( - options.runWrangler( - { type: 'worker.deploy', ...(options.configPath ? { configPath: options.configPath } : {}) }, - { capture: false }, - ), + const result = await options.runWrangler( + { type: 'worker.deploy', ...(options.configPath ? { configPath: options.configPath } : {}) }, + { capture: false }, ); if (!isCleanCommandExit(result)) { return { ok: false, reason: 'deploy-failed', status: result.status }; @@ -44,8 +42,23 @@ export async function executeCloudflareDeploy(options: { return { ok: true }; } -function isCleanCommandExit(result: CloudflareCommandResult): boolean { +/** The command ran to completion, whatever its exit status. */ +export function isCompletedCommand(result: CloudflareCommandResult): boolean { return ( - result.status === 0 && result.signal == null && (result.error == null || result.error === '') + Number.isSafeInteger(result.status) && + result.status >= 0 && + result.signal == null && + (result.error == null || result.error === '') + ); +} + +export function isCleanCommandExit(result: CloudflareCommandResult): boolean { + return result.status === 0 && isCompletedCommand(result); +} + +/** Cloudflare reported that the Worker or its deployment does not exist (code 10007). */ +export function isWorkerNotFound(result: CloudflareCommandResult): boolean { + return ( + isCompletedCommand(result) && (result.failure === 'not-found' || result.errorCode === 10007) ); } diff --git a/packages/deploy-cloudflare/src/existing.ts b/packages/deploy-cloudflare/src/existing.ts index c3923ae61..cec61ebd5 100644 --- a/packages/deploy-cloudflare/src/existing.ts +++ b/packages/deploy-cloudflare/src/existing.ts @@ -1,6 +1,7 @@ import { isDeepStrictEqual } from 'node:util'; +import { isRecord } from '@void/deploy-core'; import { parseRootWranglerConfig } from './config.ts'; -import type { CloudflareCommandRunner } from './execute.ts'; +import { isCleanCommandExit, isWorkerNotFound, type CloudflareCommandRunner } from './execute.ts'; import type { CloudflareOperation } from './operations.ts'; import { isVoidSecretOnlyDraft, isWranglerSecretOnlyDraft } from './secret-draft.ts'; @@ -39,12 +40,9 @@ export type CloudflareWorkerMigrationTagOptions = { }; /** Read the deployed version, not the most recently uploaded candidate. */ -export async function readExistingCloudflareWorker(options: { - runWrangler: CloudflareCommandRunner; - configPath: string; - workerName: string; - versionId?: string; -}): Promise { +export async function readExistingCloudflareWorker( + options: ExistingCloudflareWorkerOptions, +): Promise { const worker = await readCloudflareWorker(options, false); if (!worker) { throw new Error(`Cloudflare Worker "${options.workerName}" does not have an active version.`); @@ -63,28 +61,16 @@ async function readCloudflareWorker( missingIsNull = false, ): Promise | null> => { const result = await options.runWrangler(operation, { capture: true }); - if (result.status !== 0 || result.signal || result.error) { - if ( - missingIsNull && - Number.isSafeInteger(result.status) && - result.status >= 0 && - result.signal == null && - (result.error == null || result.error === '') && - (result.failure === 'not-found' || result.errorCode === 10007) - ) { + if (!isCleanCommandExit(result)) { + if (missingIsNull && isWorkerNotFound(result)) { return null; } throw new CloudflareWorkerInspectionError( `Could not verify existing Cloudflare Worker "${options.workerName}". Check the account, Worker name, and Cloudflare permissions before retrying.`, ); } - try { - const value: unknown = result.data; - if (record(value)) { - return value; - } - } catch { - /* Report no response contents: bindings may contain private values. */ + if (isRecord(result.data)) { + return result.data; } throw new Error('Cloudflare returned an invalid existing Worker response.'); }; @@ -101,7 +87,7 @@ async function readCloudflareWorker( if ( !Array.isArray(versions) || versions.length !== 1 || - !record(versions[0]) || + !isRecord(versions[0]) || versions[0].percentage !== 100 || typeof versions[0].version_id !== 'string' ) { @@ -117,11 +103,11 @@ async function readCloudflareWorker( const resources = version.resources; if ( version.id !== versionId || - !record(resources) || + !isRecord(resources) || !Array.isArray(resources.bindings) || - !resources.bindings.every(record) || - !record(resources.script_runtime) || - !record(resources.script) + !resources.bindings.every(isRecord) || + !isRecord(resources.script_runtime) || + !isRecord(resources.script) ) { throw new Error('Cloudflare did not return the existing Worker configuration.'); } @@ -129,7 +115,7 @@ async function readCloudflareWorker( if ( proveSecretDraft && !isSecretOnlyDraft && - record(version.annotations) && + isRecord(version.annotations) && Object.keys(version.annotations).length === 1 && version.annotations['workers/triggered_by'] === 'secret' ) { @@ -159,14 +145,14 @@ async function readCloudflareWorker( /** Parse legacy version-detail shapes; this is not authoritative Worker migration state. */ export function readCloudflareVersionMigrationTag(version: unknown): string | null { - if (!record(version)) { + if (!isRecord(version)) { return null; } - const resources = record(version.resources) ? version.resources : undefined; + const resources = isRecord(version.resources) ? version.resources : undefined; const candidates = [ - resources && record(resources.script_runtime) ? resources.script_runtime.migration_tag : null, - resources && record(resources.script) ? resources.script.migration_tag : null, - record(version.script) ? version.script.migration_tag : null, + resources && isRecord(resources.script_runtime) ? resources.script_runtime.migration_tag : null, + resources && isRecord(resources.script) ? resources.script.migration_tag : null, + isRecord(version.script) ? version.script.migration_tag : null, version.migration_tag, ]; const tag = candidates.find((candidate) => typeof candidate === 'string'); @@ -199,15 +185,15 @@ export async function readCloudflareWorkerMigrationTag( `Cloudflare returned an invalid Durable Object migration response for Worker "${options.workerName}".`, ); } - if (!response.ok || !record(body) || body.success !== true || !Array.isArray(body.result)) { + if (!response.ok || !isRecord(body) || body.success !== true || !Array.isArray(body.result)) { throw new Error( `Could not verify Durable Object migration state for Cloudflare Worker "${options.workerName}".`, ); } const worker = body.result.find( - (candidate) => record(candidate) && candidate.id === options.workerName, + (candidate) => isRecord(candidate) && candidate.id === options.workerName, ); - if (!record(worker)) { + if (!isRecord(worker)) { throw new Error( `Cloudflare did not return migration state for Worker "${options.workerName}".`, ); @@ -227,16 +213,11 @@ async function readCloudflareApiAuthentication( runWrangler: CloudflareCommandRunner, ): Promise> { const result = await runWrangler({ type: 'auth.token' }, { capture: true }); - if (result.status !== 0 || result.signal || result.error) { + if (!isCleanCommandExit(result)) { throw new Error('Could not read the authenticated Cloudflare session.'); } - let value: unknown; - try { - value = result.data; - } catch { - throw new Error('Cloudflare returned an invalid authentication response.'); - } - if (!record(value)) { + const value = result.data; + if (!isRecord(value)) { throw new Error('Cloudflare returned an invalid authentication response.'); } if ( @@ -342,7 +323,7 @@ function parseDurableObjectMigrations( throw new Error('deploy: generated Cloudflare config has a malformed migrations field.'); } return value.map((migration) => { - if (!record(migration) || typeof migration.tag !== 'string') { + if (!isRecord(migration) || typeof migration.tag !== 'string') { throw new Error('deploy: generated Cloudflare config has a malformed class migration.'); } const fields = [ @@ -369,7 +350,7 @@ function declarativeClassesMatch( migrations: Array<{ step: Record }>, actualValue: unknown, ): boolean { - if (!record(actualValue)) { + if (!isRecord(actualValue)) { return false; } const desired = new Map(); @@ -386,7 +367,7 @@ function declarativeClassesMatch( } } for (const item of (step.renamed_classes ?? []) as Array) { - if (!record(item) || typeof item.from !== 'string' || typeof item.to !== 'string') { + if (!isRecord(item) || typeof item.from !== 'string' || typeof item.to !== 'string') { return false; } const storage = desired.get(item.from); @@ -409,9 +390,9 @@ function declarativeClassesMatch( return [...desired].every(([name, storage]) => { const current = actualValue[name]; if (storage === 'deleted') { - return current === undefined || (record(current) && current.state === 'deleted'); + return current === undefined || (isRecord(current) && current.state === 'deleted'); } - return record(current) && current.type === 'durable-object' && current.storage === storage; + return isRecord(current) && current.type === 'durable-object' && current.storage === storage; }); } @@ -477,7 +458,7 @@ export function assertCloudflareBindingsPreserved( function isAssetsOnlyWorker(worker: ExistingCloudflareWorker): boolean { const handlers = worker.script.handlers; return ( - record(worker.runtime.assets) && + isRecord(worker.runtime.assets) && (handlers === null || (Array.isArray(handlers) && handlers.length === 0)) ); } @@ -500,7 +481,7 @@ export function assertExistingCloudflareConfig( const entries = config.kv_namespaces; for (const addition of additions) { const matches = Array.isArray(entries) - ? entries.filter((entry) => record(entry) && entry.binding === addition.binding) + ? entries.filter((entry) => isRecord(entry) && entry.binding === addition.binding) : []; if ( (built || matches.length > 0) && @@ -516,7 +497,7 @@ export function assertExistingCloudflareConfig( ...config, kv_namespaces: entries.filter( (entry) => - !record(entry) || !additions.some((addition) => addition.binding === entry.binding), + !isRecord(entry) || !additions.some((addition) => addition.binding === entry.binding), ), }; } @@ -547,9 +528,9 @@ export function assertExistingCloudflareConfig( } } if ( - record(existing.runtime.exports) && + isRecord(existing.runtime.exports) && Object.values(existing.runtime.exports).some( - (value) => record(value) && value.type !== 'worker', + (value) => isRecord(value) && value.type !== 'worker', ) ) { throw new Error( @@ -569,11 +550,11 @@ export function assertExistingCloudflareConfig( ); } } - const vars = record(config.vars) ? config.vars : {}; + const vars = isRecord(config.vars) ? config.vars : {}; const metadataBindings = existing.bindings.filter( (binding) => binding.type === 'version_metadata', ); - const metadata = record(config.version_metadata) ? config.version_metadata : undefined; + const metadata = isRecord(config.version_metadata) ? config.version_metadata : undefined; if ( metadataBindings.length > 1 || (metadataBindings.length === 1 && metadata?.binding !== metadataBindings[0]!.name) || @@ -621,7 +602,7 @@ export function assertExistingCloudflareConfig( continue; } if (binding.type === 'assets') { - if (built && (!record(config.assets) || config.assets.binding !== binding.name)) { + if (built && (!isRecord(config.assets) || config.assets.binding !== binding.name)) { throw new Error(`Keep the existing assets binding ${binding.name} when linking.`); } continue; @@ -635,12 +616,12 @@ export function assertExistingCloudflareConfig( const [field, localId, remoteId] = mapping; const entries = config[field]; const matches = Array.isArray(entries) - ? entries.filter((entry) => record(entry) && entry.binding === binding.name) + ? entries.filter((entry) => isRecord(entry) && entry.binding === binding.name) : []; const entry = matches[0]; if ( matches.length !== 1 || - !record(entry) || + !isRecord(entry) || typeof binding[remoteId] !== 'string' || entry[localId] !== binding[remoteId] || (binding.type === 'service' && @@ -659,7 +640,7 @@ export function assertExistingCloudflareConfig( const entries = config[field]; if ( Array.isArray(entries) && - entries.some((entry) => !record(entry) || !found.has(String(entry.binding))) + entries.some((entry) => !isRecord(entry) || !found.has(String(entry.binding))) ) { throw new Error( `Linking cannot add or replace ${field}. Use the existing resource bindings.`, @@ -719,10 +700,6 @@ function stringSetEqual(left: unknown, right: unknown): boolean { return isDeepStrictEqual([...new Set(left)].sort(), [...new Set(right)].sort()); } -function record(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function hasContent(value: unknown): boolean { if (value === undefined || value === null) { return false; @@ -730,7 +707,7 @@ function hasContent(value: unknown): boolean { if (Array.isArray(value)) { return value.length > 0; } - if (record(value)) { + if (isRecord(value)) { return Object.values(value).some(hasContent); } return true; diff --git a/packages/deploy-cloudflare/src/metadata.ts b/packages/deploy-cloudflare/src/metadata.ts index cf7e9926c..f5548eaf0 100644 --- a/packages/deploy-cloudflare/src/metadata.ts +++ b/packages/deploy-cloudflare/src/metadata.ts @@ -1,3 +1,4 @@ +import { isRecord } from '@void/deploy-core'; import type { CloudflareCommandRunner } from './execute.ts'; /** @@ -81,7 +82,7 @@ export function createCloudflareMetadataRunner(options: { } if (response.ok) { const body: unknown = await response.json(); - if (record(body) && body.success === true && record(body.result)) { + if (isRecord(body) && body.success === true && isRecord(body.result)) { const result = body.result; if (kind === 'deployment' && Array.isArray(result.deployments)) { const latest: unknown = result.deployments[0]; @@ -93,7 +94,7 @@ export function createCloudflareMetadataRunner(options: { errorCode: 10007, }; } - if (record(latest) && Array.isArray(latest.versions)) { + if (isRecord(latest) && Array.isArray(latest.versions)) { return { status: 0, stdout: '', data: latest, stderr: '' }; } } @@ -105,8 +106,8 @@ export function createCloudflareMetadataRunner(options: { if ( items.every( (item) => - record(item) && - record(item.metadata) && + isRecord(item) && + isRecord(item.metadata) && typeof item.metadata.created_on === 'string', ) ) { @@ -132,7 +133,3 @@ export function createCloudflareMetadataRunner(options: { return fallback(operation, runOptions); }; } - -function record(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index 29a202d49..95457ff3a 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -6,7 +6,13 @@ import { isRecord } from '@void/deploy-core'; import { join } from 'pathslash'; import { parseRootWranglerConfig } from './config.ts'; import { cloudflareCommandDiagnostic } from './diagnostics.ts'; -import type { CloudflareCommandResult, CloudflareCommandRunner } from './execute.ts'; +import { + isCleanCommandExit, + isCompletedCommand, + isWorkerNotFound, + type CloudflareCommandResult, + type CloudflareCommandRunner, +} from './execute.ts'; import type { CloudflareUploadResult } from './operations.ts'; import { isVoidSecretOnlyDraft, isWranglerSecretOnlyDraft } from './secret-draft.ts'; @@ -408,6 +414,15 @@ export async function stabilizeCloudflareVersionTriggers(options: { return { ok: false, reason: 'status-failed' }; } +function triggerRepairOutcome( + result: CloudflareTriggerStabilization, +): 'succeeded' | 'failed' | 'unavailable' { + if (result.ok) { + return 'succeeded'; + } + return result.reason === 'metadata-unavailable' ? 'unavailable' : 'failed'; +} + function mergeTriggerCleanupBaselines( ...configs: Array ): CloudflareTriggerConfig { @@ -590,6 +605,17 @@ function triggerConfigFromWrangler(config: Record): CloudflareT }; } +/** Trigger state recorded by a schema-2 version message, otherwise read from local config. */ +function desiredTriggerConfigFor( + metadata: CloudflareVersionMetadata | null, + configPath: string | undefined, +): CloudflareTriggerConfig { + if (metadata?.schema === 2) { + return metadata.triggerConfig; + } + return configPath ? readCloudflareTriggerConfig(configPath) : triggerConfigFromWrangler({}); +} + /** A well-formed `addresses` array, or `undefined` when the field is absent or malformed. */ function readEmailAddresses(value: unknown): ReadonlyArray | undefined { return Array.isArray(value) && value.every((entry) => typeof entry === 'string') @@ -675,17 +701,11 @@ function triggerWranglerConfig( config.routes = [...triggerConfig.routes]; } // Only a snapshot that carried `addresses` may reach Wrangler's Email Routing plan: an - // absent key skips the plan, which is what a version deployed without email must do. - // That holds in both directions of a rollback — the snapshot being LEFT lends nothing - // here — so rolling back from an email-enabled version to one recorded without the key - // (a pre-`addresses` snapshot, or a deploy that had no handlers) leaves the rules where - // they are, routed to a Worker that no longer exports `email()`. Do not encode that - // transition as `[]`: wrangler plans the deletion of every rule this Worker owns on `[]`, - // a destructive plan it confirms (default No) in a TTY and refuses in CI, and a - // non-zero `triggers deploy` here is `triggers-failed`, which the rollback compensates - // by restoring the version it just left. Whether a rollback should touch Email Routing - // at all is a product call (design doc 0078, decision 13); this code replays the - // snapshot verbatim. + // absent key skips the plan, so rolling back to a version recorded without the key leaves + // existing rules in place. Never send `[]` for that case: Wrangler plans deletion of every + // rule this Worker owns, confirms it in a TTY and refuses it in CI, which fails the rollback + // as `triggers-failed`. Whether a rollback should touch Email Routing is a product call + // (design doc 0078, decision 13); this code replays the snapshot verbatim. if (triggerConfig.addresses !== undefined) { config.addresses = [...triggerConfig.addresses]; } @@ -966,12 +986,7 @@ async function activateCloudflareVersionWithSecretsFile( inheritanceBaseline = recent; } const desiredMetadata = parseCloudflareVersionMessage(options.message); - let desiredTriggerConfig = - desiredMetadata?.schema === 2 - ? desiredMetadata.triggerConfig - : options.configPath - ? readCloudflareTriggerConfig(options.configPath) - : triggerConfigFromWrangler({}); + let desiredTriggerConfig = desiredTriggerConfigFor(desiredMetadata, options.configPath); let previousVersion = previousVersionId && desiredMetadata?.schema === 2 ? await readCloudflareVersionInspection( @@ -1182,19 +1197,17 @@ async function activateCloudflareVersionWithSecretsFile( } } options.onProgress?.('Uploading the Worker and assets...'); - const upload = await Promise.resolve( - options.runWrangler( - { - type: 'versions.upload', - configPath, - workerName: options.workerName, - message, - secretsFilePath: options.candidateSecretsFilePath, - assetsDirectory: options.assetsDirectoryOverride, - previewAlias: alias, - }, - { capture: true }, - ), + const upload = await options.runWrangler( + { + type: 'versions.upload', + configPath, + workerName: options.workerName, + message, + secretsFilePath: options.candidateSecretsFilePath, + assetsDirectory: options.assetsDirectoryOverride, + previewAlias: alias, + }, + { capture: true }, ); if (!isCleanCommandExit(upload)) { const cloudflareErrorCode = upload.errorCode; @@ -1335,59 +1348,34 @@ async function activateCloudflareVersionWithSecretsFile( if (fallbackSubdomain && fallbackSubdomain === options.readinessHeadersWorkersDevSubdomain) { authenticatedWorkersDevSubdomain = fallbackSubdomain; } - if (!options.verifyUploader()) { - return { - ok: false, - reason: 'uploader-changed', - versionId, - previousVersionId: previous.versionId, - rollback: 'not-needed', - }; - } - const stagingOwnerFailure = await verifyReadinessStagingOwner( + const stagingFailure = await stageCandidateForReadiness( options, configPath, previous.versionId, versionId, - ); - if (stagingOwnerFailure) { - return stagingOwnerFailure; - } - const staged = await deployVersionPercentages( - options, - configPath, - [`${previous.versionId}@100`, `${versionId}@0`], message, ); - if (!isCleanCommandExit(staged)) { - const cloudflareErrorCode = staged.errorCode; - return { - ok: false, - reason: 'readiness-staging-failed', - status: staged.status, - ...commandDiagnosticFields(staged), - ...(cloudflareErrorCode === undefined ? {} : { cloudflareErrorCode }), - versionId, - previousVersionId: previous.versionId, - rollback: 'not-needed', - }; + if (stagingFailure) { + return stagingFailure; } stagedVersionOverride = true; } + const probe = (url: string) => + probeReadiness( + `${url.replace(/\/$/, '')}/__void/ready`, + options.workerName, + readinessUsesCustomRoute ? undefined : options.readinessHeaders, + authenticatedWorkersDevSubdomain, + stagedVersionOverride ? versionId : undefined, + options.fetch ?? globalThis.fetch, + options.sleep ?? defaultSleep, + options.readinessAttempts ?? 6, + Number.POSITIVE_INFINITY, + options.requireVersionMetadata ? versionId : undefined, + ); options.onProgress?.('Waiting for the Worker to become ready...'); - let ready = await probeReadiness( - `${readinessUrl.replace(/\/$/, '')}/__void/ready`, - options.workerName, - readinessUsesCustomRoute ? undefined : options.readinessHeaders, - authenticatedWorkersDevSubdomain, - stagedVersionOverride ? versionId : undefined, - options.fetch ?? globalThis.fetch, - options.sleep ?? defaultSleep, - options.readinessAttempts ?? 6, - Number.POSITIVE_INFINITY, - options.requireVersionMetadata ? versionId : undefined, - ); + let ready = await probe(readinessUrl); // Account-wide Access default-deny commonly blocks Wrangler's generated preview alias even // when the stable `..workers.dev` destination has an application admitting the @@ -1409,59 +1397,21 @@ async function activateCloudflareVersionWithSecretsFile( ) : customRouteReadinessUrl(desiredTriggerConfig.routes); if (stableReadinessUrl && stableReadinessUrl !== readinessUrl) { - if (!options.verifyUploader()) { - return { - ok: false, - reason: 'uploader-changed', - versionId, - previousVersionId: previous.versionId, - rollback: 'not-needed', - }; - } - const stagingOwnerFailure = await verifyReadinessStagingOwner( + const stagingFailure = await stageCandidateForReadiness( options, configPath, previous.versionId, versionId, - ); - if (stagingOwnerFailure) { - return stagingOwnerFailure; - } - const staged = await deployVersionPercentages( - options, - configPath, - [`${previous.versionId}@100`, `${versionId}@0`], message, ); - if (!isCleanCommandExit(staged)) { - const cloudflareErrorCode = staged.errorCode; - return { - ok: false, - reason: 'readiness-staging-failed', - status: staged.status, - ...commandDiagnosticFields(staged), - ...(cloudflareErrorCode === undefined ? {} : { cloudflareErrorCode }), - versionId, - previousVersionId: previous.versionId, - rollback: 'not-needed', - }; + if (stagingFailure) { + return stagingFailure; } readinessUrl = stableReadinessUrl; authenticatedWorkersDevSubdomain = extractWorkersDevSubdomain(stableReadinessUrl); readinessUsesCustomRoute = !desiredTriggerConfig.workers_dev; stagedVersionOverride = true; - ready = await probeReadiness( - `${readinessUrl.replace(/\/$/, '')}/__void/ready`, - options.workerName, - readinessUsesCustomRoute ? undefined : options.readinessHeaders, - authenticatedWorkersDevSubdomain, - versionId, - options.fetch ?? globalThis.fetch, - options.sleep ?? defaultSleep, - options.readinessAttempts ?? 6, - Number.POSITIVE_INFINITY, - options.requireVersionMetadata ? versionId : undefined, - ); + ready = await probe(readinessUrl); } } if (ready !== 'ready') { @@ -1632,7 +1582,7 @@ async function activateCloudflareVersionWithSecretsFile( previousVersionId: previous.versionId, activeVersionId: triggers.activeVersionId, rollback: 'not-needed', - triggerRepair: triggers.reason === 'metadata-unavailable' ? 'unavailable' : 'failed', + triggerRepair: triggerRepairOutcome(triggers), }; } if (!triggers.activeVersionId) { @@ -1676,11 +1626,7 @@ async function activateCloudflareVersionWithSecretsFile( previousVersionId: previous.versionId, activeVersionId: repair.activeVersionId ?? ownerBeforeCompensation.versionId, rollback: 'not-needed', - triggerRepair: repair.ok - ? 'succeeded' - : repair.reason === 'metadata-unavailable' - ? 'unavailable' - : 'failed', + triggerRepair: triggerRepairOutcome(repair), }; } const rollback = await restorePreviousVersionAndTriggers( @@ -1850,17 +1796,15 @@ async function readCloudflareVersionInspection( Object.keys(annotations).length === 1 && !isWranglerSecretOnlyDraft(entry) ) { - const inspection = await Promise.resolve( - options.runWrangler( - { - type: 'versions.view', - versionId, - configPath, - workerName: options.workerName, - includeSource: true, - }, - { capture: true }, - ), + const inspection = await options.runWrangler( + { + type: 'versions.view', + versionId, + configPath, + workerName: options.workerName, + includeSource: true, + }, + { capture: true }, ); if (isCleanCommandExit(inspection)) { const inspected: unknown = inspection.data; @@ -1889,43 +1833,34 @@ async function readRecentCloudflareVersionIds( options: Pick[0], 'runWrangler' | 'workerName'>, configPath: string | undefined, ): Promise | null> { - const result = await Promise.resolve( - options.runWrangler( - { type: 'versions.list', configPath, workerName: options.workerName }, - { capture: true }, - ), + const result = await options.runWrangler( + { type: 'versions.list', configPath, workerName: options.workerName }, + { capture: true }, ); if (!isCleanCommandExit(result)) { - if ( - isCompletedCommand(result) && - (result.failure === 'not-found' || result.errorCode === 10007) - ) { + if (isWorkerNotFound(result)) { return []; } return null; } - try { - const versions = result.data as unknown; - if ( - !Array.isArray(versions) || - !versions.every( - (version) => - isRecord(version) && - typeof version.id === 'string' && - typeof version.number === 'number' && - Number.isSafeInteger(version.number), - ) - ) { - return null; - } - return versions.map((version) => ({ - id: version.id as string, - number: version.number as number, - inspection: version, - })); - } catch { + const versions = result.data; + if ( + !Array.isArray(versions) || + !versions.every( + (version) => + isRecord(version) && + typeof version.id === 'string' && + typeof version.number === 'number' && + Number.isSafeInteger(version.number), + ) + ) { return null; } + return versions.map((version) => ({ + id: version.id as string, + number: version.number as number, + inspection: version, + })); } function arraysEqual(left: ReadonlyArray, right: ReadonlyArray): boolean { @@ -1966,38 +1901,32 @@ async function readCloudflareVersionSecretBindings( configPath: string | undefined, versionId: string, ): Promise | null> { - const result = await Promise.resolve( - options.runWrangler( - { type: 'versions.view', versionId, configPath, workerName: options.workerName }, - { capture: true }, - ), + const result = await options.runWrangler( + { type: 'versions.view', versionId, configPath, workerName: options.workerName }, + { capture: true }, ); if (!isCleanCommandExit(result)) { return null; } - try { - const version = result.data as unknown; - if (!isRecord(version) || version.id !== versionId || !isRecord(version.resources)) { - return null; + const version = result.data; + if (!isRecord(version) || version.id !== versionId || !isRecord(version.resources)) { + return null; + } + const bindings = version.resources.bindings; + if (!Array.isArray(bindings) || !bindings.every(isRecord)) { + return null; + } + const secrets: Array<{ name: string; type: 'secret_key' | 'secret_text' }> = []; + for (const binding of bindings) { + if (binding.type !== 'secret_text' && binding.type !== 'secret_key') { + continue; } - const bindings = version.resources.bindings; - if (!Array.isArray(bindings) || !bindings.every(isRecord)) { + if (typeof binding.name !== 'string') { return null; } - const secrets: Array<{ name: string; type: 'secret_key' | 'secret_text' }> = []; - for (const binding of bindings) { - if (binding.type !== 'secret_text' && binding.type !== 'secret_key') { - continue; - } - if (typeof binding.name !== 'string') { - return null; - } - secrets.push({ name: binding.name, type: binding.type }); - } - return secrets; - } catch { - return null; + secrets.push({ name: binding.name, type: binding.type }); } + return secrets; } /** @@ -2040,18 +1969,16 @@ async function bootstrapFirstDeployment( options.onProgress?.( atomic ? 'Publishing the Worker atomically...' : 'Publishing the first Worker deployment...', ); - const bootstrap = await Promise.resolve( - options.runWrangler( - { - type: 'worker.deploy', - configPath, - workerName: options.workerName, - message: options.message, - secretsFilePath: options.candidateSecretsFilePath, - assetsDirectory: options.assetsDirectoryOverride, - }, - { capture: true }, - ), + const bootstrap = await options.runWrangler( + { + type: 'worker.deploy', + configPath, + workerName: options.workerName, + message: options.message, + secretsFilePath: options.candidateSecretsFilePath, + assetsDirectory: options.assetsDirectoryOverride, + }, + { capture: true }, ); if (!isCleanCommandExit(bootstrap)) { const cloudflareErrorCode = bootstrap.errorCode; @@ -2067,12 +1994,7 @@ async function bootstrapFirstDeployment( const published = readUploadResult(bootstrap); const versionId = published?.versionId; const desiredMetadata = parseCloudflareVersionMessage(options.message); - const desiredTriggerConfig = - desiredMetadata?.schema === 2 - ? desiredMetadata.triggerConfig - : options.configPath - ? readCloudflareTriggerConfig(options.configPath) - : triggerConfigFromWrangler({}); + const desiredTriggerConfig = desiredTriggerConfigFor(desiredMetadata, options.configPath); const reportedUrl = published?.previewUrl ?? published?.workersDevUrl; const reportedOrConfiguredUrl = reportedUrl ?? @@ -2220,11 +2142,7 @@ async function bootstrapFirstDeployment( rollback: superseded ? 'not-needed' : triggers.activeVersionId ? 'unavailable' : 'unsafe', ...(superseded ? { - triggerRepair: triggers.ok - ? 'succeeded' - : triggers.reason === 'metadata-unavailable' - ? 'unavailable' - : 'failed', + triggerRepair: triggerRepairOutcome(triggers), } : {}), }; @@ -2308,11 +2226,7 @@ async function bootstrapFirstDeployment( previousVersionId, activeVersionId: repair.activeVersionId, rollback: 'not-needed', - triggerRepair: repair.ok - ? 'succeeded' - : repair.reason === 'metadata-unavailable' - ? 'unavailable' - : 'failed', + triggerRepair: triggerRepairOutcome(repair), }; } return { @@ -2383,17 +2297,12 @@ async function readCurrentProductionVersion( options: Pick[0], 'runWrangler' | 'workerName'>, configPath: string | undefined, ): Promise { - const result = await Promise.resolve( - options.runWrangler( - { type: 'deployments.status', configPath, workerName: options.workerName }, - { capture: true }, - ), + const result = await options.runWrangler( + { type: 'deployments.status', configPath, workerName: options.workerName }, + { capture: true }, ); if (!isCleanCommandExit(result)) { - if ( - isCompletedCommand(result) && - (result.failure === 'not-found' || result.errorCode === 10007) - ) { + if (isWorkerNotFound(result)) { return { kind: 'ok', versionId: null, versions: [] }; } return { kind: 'failed', status: result.status }; @@ -2460,6 +2369,54 @@ function stagedVersionPercentages( : []; } +/** Stage the candidate at 0% beside the unchanged previous version so readiness can select it. */ +async function stageCandidateForReadiness( + options: CloudflareVersionActivationOptions, + configPath: string | undefined, + previousVersionId: string | null, + versionId: string, + message: string, +): Promise | null> { + if (!options.verifyUploader()) { + return { + ok: false, + reason: 'uploader-changed', + versionId, + previousVersionId, + rollback: 'not-needed', + }; + } + const ownerFailure = await verifyReadinessStagingOwner( + options, + configPath, + previousVersionId, + versionId, + ); + if (ownerFailure) { + return ownerFailure; + } + const staged = await deployVersionPercentages( + options, + configPath, + [`${previousVersionId}@100`, `${versionId}@0`], + message, + ); + if (!isCleanCommandExit(staged)) { + const cloudflareErrorCode = staged.errorCode; + return { + ok: false, + reason: 'readiness-staging-failed', + status: staged.status, + ...commandDiagnosticFields(staged), + ...(cloudflareErrorCode === undefined ? {} : { cloudflareErrorCode }), + versionId, + previousVersionId, + rollback: 'not-needed', + }; + } + return null; +} + async function verifyReadinessStagingOwner( options: Pick[0], 'runWrangler' | 'workerName'>, configPath: string | undefined, @@ -2777,19 +2734,6 @@ function workersDevSubdomainResult(url: string): { workersDevSubdomain?: string return workersDevSubdomain ? { workersDevSubdomain } : {}; } -function isCompletedCommand(result: CloudflareCommandResult): boolean { - return ( - Number.isSafeInteger(result.status) && - result.status >= 0 && - result.signal == null && - (result.error == null || result.error === '') - ); -} - -function isCleanCommandExit(result: CloudflareCommandResult): boolean { - return result.status === 0 && isCompletedCommand(result); -} - function defaultSleep(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index 61ae5c4c1..771f43430 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -1880,15 +1880,8 @@ describe('Cloudflare version activation', () => { const without = readCloudflareTriggerConfig(configPath); expect(without).not.toHaveProperty('addresses'); expect(await applied(without)).not.toHaveProperty('addresses'); - // A rollback replays the TARGET snapshot only: the version being left never lends its - // addresses to the trigger config, so a rollback from an email-enabled version to one - // recorded without `addresses` skips the plan and leaves the rules where they are — - // routed to a Worker that no longer exports `email()`. That is the one shape both an - // absent key and a pre-`addresses` (legacy) snapshot share, and it is not `[]`: a - // snapshot carrying `[]` would make every rollback to it a destructive plan (wrangler's - // default-No y/n in a TTY, a UserError in CI), which `stabilizeCloudflareVersionTriggers` - // reports as `triggers-failed` and the caller compensates by restoring the version it - // just left. Encoding "handlers removed" as `[]` is therefore a product call, not a fix. + // A rollback replays the target snapshot only; the version being left never lends its + // addresses, so a target without `addresses` skips the plan instead of sending `[]`. let rollbackConfig: Record | undefined; await deployCloudflareVersionTriggers({ runWrangler: operationFixture(async (args) => { diff --git a/packages/deploy-core/src/migration-plan.ts b/packages/deploy-core/src/migration-plan.ts index ddbedeae6..b4d9b81c1 100644 --- a/packages/deploy-core/src/migration-plan.ts +++ b/packages/deploy-core/src/migration-plan.ts @@ -20,18 +20,22 @@ export function canonicalSql(sql: string): string { return withoutBom.replace(/\r\n?/g, '\n'); } -function isStandalonePragmaLine(line: string, pragma: string): boolean { - return line.trim() === pragma; -} - export function hasStandalonePragma(sql: string, pragma: string): boolean { return canonicalSql(sql) .split('\n') - .some((line) => isStandalonePragmaLine(line, pragma)); + .some((line) => line.trim() === pragma); } -function isBreakpointComment(text: string): boolean { - return text.trim() === '--> statement-breakpoint'; +/** Yield `--> statement-breakpoint` line comments outside literals. */ +function* breakpointComments(normalized: string): Generator<{ start: number; end: number }> { + for (const segment of tokenizeSql(normalized)) { + if ( + segment.kind === 'line-comment' && + normalized.slice(segment.start, segment.end).trim() === '--> statement-breakpoint' + ) { + yield segment; + } + } } function splitBreakpointFramedSql(sql: string): Array { @@ -39,18 +43,12 @@ function splitBreakpointFramedSql(sql: string): Array { const statements: Array = []; let cursor = 0; - for (const segment of tokenizeSql(normalized)) { - if (segment.kind !== 'line-comment') { - continue; - } - const comment = normalized.slice(segment.start, segment.end); - if (isBreakpointComment(comment)) { - const statement = normalized.slice(cursor, segment.start).trim(); - if (statement) { - statements.push(statement); - } - cursor = segment.end; + for (const segment of breakpointComments(normalized)) { + const statement = normalized.slice(cursor, segment.start).trim(); + if (statement) { + statements.push(statement); } + cursor = segment.end; } const statement = normalized.slice(cursor).trim(); @@ -61,17 +59,7 @@ function splitBreakpointFramedSql(sql: string): Array { } function hasStatementBreakpointOutsideLiterals(sql: string): boolean { - const normalized = canonicalSql(sql); - for (const segment of tokenizeSql(normalized)) { - if (segment.kind !== 'line-comment') { - continue; - } - const comment = normalized.slice(segment.start, segment.end); - if (isBreakpointComment(comment)) { - return true; - } - } - return false; + return !breakpointComments(canonicalSql(sql)).next().done; } type StatementScan = { diff --git a/packages/deploy-core/src/sql-tokenize.ts b/packages/deploy-core/src/sql-tokenize.ts index bdb0a6cd3..a8f41f1ca 100644 --- a/packages/deploy-core/src/sql-tokenize.ts +++ b/packages/deploy-core/src/sql-tokenize.ts @@ -1,6 +1,6 @@ /** - * Char-by-char SQL tokenizer shared by the destructive-SQL guard - * (projects.ts) and the single-statement classifier (remote-runner.ts). + * Char-by-char SQL tokenizer shared by the migration classifier, the + * destructive-SQL guard, and the remote migration runner. * * Walks the input and yields contiguous segments. `sql` runs are * emitted as `{ kind: 'sql', start, end }` so callers can `sql.slice` @@ -16,12 +16,12 @@ * `]` closes the identifier (SQLite semantics; * differs from MS SQL) * - * Not handled — intentional, both current callers target SQLite only: + * Not handled — intentional, every caller targets SQLite only: * - Postgres `$tag$ ... $tag$` dollar-quoting * - Postgres `E'...'` extended string literals * - * Adding a new quoting form anywhere should land here so both callers - * stay in lockstep. + * Adding a new quoting form anywhere should land here so every caller + * stays in lockstep. */ export type SqlSegment = diff --git a/packages/deploy-core/src/validate.ts b/packages/deploy-core/src/validate.ts index 238f31171..8d98d816b 100644 --- a/packages/deploy-core/src/validate.ts +++ b/packages/deploy-core/src/validate.ts @@ -1,13 +1,9 @@ +import { isRecord } from './is-record.ts'; + export const MANIFEST_REDIRECT_RULE_STATUSES: ReadonlySet = new Set([ 200, 301, 302, 303, 307, 308, ]); -// Runtime shape validation for incoming deploy manifests. Downstream code -// indexes deeply into the manifest (bindings.d1, queues[i].name, -// websocket.classes, etc.) without re-guarding, so a malformed-but-JSON-valid -// payload would otherwise crash with a 500 — sometimes after a deployment row -// or CF resources were created. Everything the deploy pipeline reads must be -// validated here BEFORE createDeployment runs. // Caps on user-supplied routing rule lists. Each rule compiles a regex per // request in dispatch (see `routing-rules.ts`), so an unbounded list lets a // 100k-entry manifest exhaust CPU on every request to the project. The limit @@ -55,23 +51,27 @@ function validateRuleHost( return null; } +/** + * Runtime shape validation for incoming deploy manifests. Downstream code + * indexes deeply into the manifest (bindings.d1, queues[i].name, + * websocket.classes, etc.) without re-guarding, so a malformed-but-JSON-valid + * payload would otherwise crash with a 500 — sometimes after a deployment row + * or CF resources were created. Everything the deploy pipeline reads must be + * validated here BEFORE createDeployment runs. + */ export function validateManifestShape(parsed: unknown): string | null { - if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { + if (!isRecord(parsed)) { return 'invalid manifest shape: expected a JSON object'; } - const manifest = parsed as Record; + const manifest = parsed; if (manifest.emailProtocolVersion !== undefined && manifest.emailProtocolVersion !== 1) { return 'invalid manifest shape: emailProtocolVersion must be 1'; } - if ( - manifest.bindings == null || - typeof manifest.bindings !== 'object' || - Array.isArray(manifest.bindings) - ) { + if (!isRecord(manifest.bindings)) { return 'invalid manifest shape: bindings must be an object'; } - const bindings = manifest.bindings as Record; + const bindings = manifest.bindings; for (const key of ['d1', 'kv', 'r2', 'hyperdrive'] as const) { const bindingValue = bindings[key]; if (bindingValue === undefined) { @@ -87,10 +87,10 @@ export function validateManifestShape(parsed: unknown): string | null { return 'invalid manifest shape: queues must be an array'; } for (const queue of manifest.queues) { - if (queue == null || typeof queue !== 'object' || Array.isArray(queue)) { + if (!isRecord(queue)) { return 'invalid manifest shape: queues entries must be objects'; } - const queueObject = queue as Record; + const queueObject = queue; if (typeof queueObject.name !== 'string' || typeof queueObject.bindingName !== 'string') { return 'invalid manifest shape: queues entries must have string name and bindingName'; } @@ -116,10 +116,10 @@ export function validateManifestShape(parsed: unknown): string | null { return 'invalid manifest shape: schedules must be an array'; } for (const schedule of manifest.schedules) { - if (schedule == null || typeof schedule !== 'object' || Array.isArray(schedule)) { + if (!isRecord(schedule)) { return 'invalid manifest shape: schedules entries must be objects'; } - const scheduleObject = schedule as Record; + const scheduleObject = schedule; if (typeof scheduleObject.name !== 'string' || typeof scheduleObject.cron !== 'string') { return 'invalid manifest shape: schedules entries must have string name and cron'; } @@ -136,14 +136,10 @@ export function validateManifestShape(parsed: unknown): string | null { } if (manifest.vars !== undefined) { - if ( - manifest.vars == null || - typeof manifest.vars !== 'object' || - Array.isArray(manifest.vars) - ) { + if (!isRecord(manifest.vars)) { return 'invalid manifest shape: vars must be an object'; } - for (const [k, v] of Object.entries(manifest.vars as Record)) { + for (const [k, v] of Object.entries(manifest.vars)) { if (typeof v !== 'string') { return `invalid manifest shape: vars.${k} must be a string`; } @@ -151,14 +147,10 @@ export function validateManifestShape(parsed: unknown): string | null { } if (manifest.migrations !== undefined) { - if ( - manifest.migrations == null || - typeof manifest.migrations !== 'object' || - Array.isArray(manifest.migrations) - ) { + if (!isRecord(manifest.migrations)) { return 'invalid manifest shape: migrations must be an object'; } - const mig = manifest.migrations as Record; + const mig = manifest.migrations; // `dialect` and `pending` are read unconditionally by the migration // dispatch path (`manifest.migrations.pending > 0`). A wrong-typed value // would reach the CF migration runner and crash mid-deploy. @@ -231,14 +223,10 @@ export function validateManifestShape(parsed: unknown): string | null { // here; the plan ceiling (`maxCpuMsPerRequest`) is enforced by the deploy // handler once the authenticated user's plan is in scope. if (manifest.limits !== undefined) { - if ( - manifest.limits == null || - typeof manifest.limits !== 'object' || - Array.isArray(manifest.limits) - ) { + if (!isRecord(manifest.limits)) { return 'invalid manifest shape: limits must be an object'; } - const limits = manifest.limits as Record; + const limits = manifest.limits; if (limits.cpuMs !== undefined) { if ( typeof limits.cpuMs !== 'number' || @@ -258,14 +246,10 @@ export function validateManifestShape(parsed: unknown): string | null { } if (manifest.assetConfig !== undefined) { - if ( - manifest.assetConfig == null || - typeof manifest.assetConfig !== 'object' || - Array.isArray(manifest.assetConfig) - ) { + if (!isRecord(manifest.assetConfig)) { return 'invalid manifest shape: assetConfig must be an object'; } - const assetConfig = manifest.assetConfig as Record; + const assetConfig = manifest.assetConfig; if ( assetConfig.not_found_handling !== undefined && !['none', 'single-page-application', '404-page'].includes( @@ -287,16 +271,10 @@ export function validateManifestShape(parsed: unknown): string | null { if (manifest.revalidate !== undefined) { if (typeof manifest.revalidate === 'number') { // ok - } else if ( - manifest.revalidate == null || - typeof manifest.revalidate !== 'object' || - Array.isArray(manifest.revalidate) - ) { + } else if (!isRecord(manifest.revalidate)) { return 'invalid manifest shape: revalidate must be a number or object'; } else { - for (const [pathname, value] of Object.entries( - manifest.revalidate as Record, - )) { + for (const [pathname, value] of Object.entries(manifest.revalidate)) { if (typeof value !== 'number') { return `invalid manifest shape: revalidate.${pathname} must be a number`; } @@ -309,10 +287,10 @@ export function validateManifestShape(parsed: unknown): string | null { return 'invalid manifest shape: headerRules must be an array'; } for (const rule of manifest.headerRules) { - if (rule == null || typeof rule !== 'object' || Array.isArray(rule)) { + if (!isRecord(rule)) { return 'invalid manifest shape: headerRules entries must be objects'; } - const ruleObject = rule as Record; + const ruleObject = rule; if (typeof ruleObject.pattern !== 'string') { return 'invalid manifest shape: headerRules entries must have a string pattern'; } @@ -344,10 +322,10 @@ export function validateManifestShape(parsed: unknown): string | null { return `invalid manifest shape: redirectRules has ${manifest.redirectRules.length} entries, exceeds the ${MAX_ROUTING_RULES} per-list limit`; } for (const rule of manifest.redirectRules) { - if (rule == null || typeof rule !== 'object' || Array.isArray(rule)) { + if (!isRecord(rule)) { return 'invalid manifest shape: redirectRules entries must be objects'; } - const ruleObject = rule as Record; + const ruleObject = rule; if ( typeof ruleObject.source !== 'string' || typeof ruleObject.destination !== 'string' || @@ -355,8 +333,6 @@ export function validateManifestShape(parsed: unknown): string | null { ) { return 'invalid manifest shape: redirectRules entries must have string source, string destination, and numeric status'; } - // `JSON.stringify` safely quotes and escapes the user-supplied source - // string in the error message (handles embedded quotes, newlines, etc). const sourceRef = JSON.stringify(ruleObject.source as string); if (!MANIFEST_REDIRECT_RULE_STATUSES.has(ruleObject.status)) { return `invalid manifest shape: redirectRules entry ${sourceRef} status must be one of ${[...MANIFEST_REDIRECT_RULE_STATUSES].join(', ')} (200 = rewrite, 3xx = redirect)`; @@ -389,14 +365,10 @@ export function validateManifestShape(parsed: unknown): string | null { // only enforces shape; semantic correctness (does the pattern match a // real route?) is the user's responsibility. if (manifest.revalidateQueryAllowlist !== undefined) { - if ( - manifest.revalidateQueryAllowlist == null || - typeof manifest.revalidateQueryAllowlist !== 'object' || - Array.isArray(manifest.revalidateQueryAllowlist) - ) { + if (!isRecord(manifest.revalidateQueryAllowlist)) { return 'invalid manifest shape: revalidateQueryAllowlist must be an object'; } - const entries = Object.entries(manifest.revalidateQueryAllowlist as Record); + const entries = Object.entries(manifest.revalidateQueryAllowlist); if (entries.length > MAX_REVALIDATE_QUERY_ALLOWLIST_ENTRIES) { return `invalid manifest shape: revalidateQueryAllowlist has ${entries.length} entries, exceeds the ${MAX_REVALIDATE_QUERY_ALLOWLIST_ENTRIES} entry limit`; } @@ -427,10 +399,10 @@ export function validateManifestShape(parsed: unknown): string | null { return `invalid manifest shape: pages has ${manifest.pages.length} entries, exceeds the ${MAX_PAGES} entry limit`; } for (const entry of manifest.pages as Array) { - if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) { + if (!isRecord(entry)) { return 'invalid manifest shape: each pages entry must be an object'; } - const e = entry as Record; + const e = entry; if (typeof e.pattern !== 'string' || e.pattern === '') { return 'invalid manifest shape: pages entry pattern must be a string'; } @@ -445,16 +417,10 @@ export function validateManifestShape(parsed: unknown): string | null { // baked revalidate) against the real request path. Same shape as the object // form of `revalidate`: a map of pattern → number. if (manifest.pathRevalidate !== undefined) { - if ( - manifest.pathRevalidate == null || - typeof manifest.pathRevalidate !== 'object' || - Array.isArray(manifest.pathRevalidate) - ) { + if (!isRecord(manifest.pathRevalidate)) { return 'invalid manifest shape: pathRevalidate must be an object'; } - for (const [pathname, value] of Object.entries( - manifest.pathRevalidate as Record, - )) { + for (const [pathname, value] of Object.entries(manifest.pathRevalidate)) { if (typeof value !== 'number') { return `invalid manifest shape: pathRevalidate.${pathname} must be a number`; } @@ -472,10 +438,10 @@ export function validateManifestShape(parsed: unknown): string | null { return `invalid manifest shape: fallbackRules has ${manifest.fallbackRules.length} entries, exceeds the ${MAX_ROUTING_RULES} per-list limit`; } for (const rule of manifest.fallbackRules) { - if (rule == null || typeof rule !== 'object' || Array.isArray(rule)) { + if (!isRecord(rule)) { return 'invalid manifest shape: fallbackRules entries must be objects'; } - const ruleObject = rule as Record; + const ruleObject = rule; if ( typeof ruleObject.source !== 'string' || typeof ruleObject.destination !== 'string' || @@ -505,22 +471,18 @@ export function validateManifestShape(parsed: unknown): string | null { } if (manifest.websocket !== undefined) { - if ( - manifest.websocket == null || - typeof manifest.websocket !== 'object' || - Array.isArray(manifest.websocket) - ) { + if (!isRecord(manifest.websocket)) { return 'invalid manifest shape: websocket must be an object'; } - const websocket = manifest.websocket as Record; + const websocket = manifest.websocket; if (!Array.isArray(websocket.routes)) { return 'invalid manifest shape: websocket.routes must be an array'; } for (const route of websocket.routes) { - if (route == null || typeof route !== 'object' || Array.isArray(route)) { + if (!isRecord(route)) { return 'invalid manifest shape: websocket.routes entries must be objects'; } - const routeObject = route as Record; + const routeObject = route; if (typeof routeObject.className !== 'string') { return 'invalid manifest shape: websocket.routes entries must have a string className'; } @@ -539,10 +501,10 @@ export function validateManifestShape(parsed: unknown): string | null { return 'invalid manifest shape: websocket.migrations must be an array'; } for (const migration of websocket.migrations) { - if (migration == null || typeof migration !== 'object' || Array.isArray(migration)) { + if (!isRecord(migration)) { return 'invalid manifest shape: websocket.migrations entries must be objects'; } - const migrationObject = migration as Record; + const migrationObject = migration; if (typeof migrationObject.tag !== 'string' || migrationObject.tag.length === 0) { return 'invalid manifest shape: websocket.migrations entries must have a non-empty string tag'; } @@ -569,14 +531,10 @@ export function validateManifestShape(parsed: unknown): string | null { if (manifest.type === 'static' || manifest.type === 'spa') { return 'sandbox is not supported for static/spa deployments'; } - if ( - manifest.sandbox == null || - typeof manifest.sandbox !== 'object' || - Array.isArray(manifest.sandbox) - ) { + if (!isRecord(manifest.sandbox)) { return 'invalid manifest shape: sandbox must be an object'; } - const sandbox = manifest.sandbox as Record; + const sandbox = manifest.sandbox; for (const key of ['bindingName', 'className', 'containerName', 'image']) { if (typeof sandbox[key] !== 'string' || sandbox[key].length === 0) { return `invalid manifest shape: sandbox.${key} must be a non-empty string`; @@ -601,10 +559,10 @@ export function validateManifestShape(parsed: unknown): string | null { return 'invalid manifest shape: sandbox.migrations must be an array'; } for (const migration of sandbox.migrations) { - if (migration == null || typeof migration !== 'object' || Array.isArray(migration)) { + if (!isRecord(migration)) { return 'invalid manifest shape: sandbox.migrations entries must be objects'; } - const migrationObject = migration as Record; + const migrationObject = migration; if (typeof migrationObject.tag !== 'string') { return 'invalid manifest shape: sandbox.migrations entries must have a string tag'; } @@ -627,14 +585,10 @@ export function validateManifestShape(parsed: unknown): string | null { } if (manifest.live !== undefined) { - if ( - manifest.live == null || - typeof manifest.live !== 'object' || - Array.isArray(manifest.live) - ) { + if (!isRecord(manifest.live)) { return 'invalid manifest shape: live must be an object'; } - const live = manifest.live as Record; + const live = manifest.live; for (const key of ['bindingName', 'className']) { if (typeof live[key] !== 'string' || live[key].length === 0) { return `invalid manifest shape: live.${key} must be a non-empty string`; @@ -651,10 +605,10 @@ export function validateManifestShape(parsed: unknown): string | null { return 'invalid manifest shape: live.migrations must be an array'; } for (const migration of live.migrations) { - if (migration == null || typeof migration !== 'object' || Array.isArray(migration)) { + if (!isRecord(migration)) { return 'invalid manifest shape: live.migrations entries must be objects'; } - const migrationObject = migration as Record; + const migrationObject = migration; if (typeof migrationObject.tag !== 'string') { return 'invalid manifest shape: live.migrations entries must have a string tag'; } @@ -673,13 +627,7 @@ export function validateManifestShape(parsed: unknown): string | null { } function isLocalImageReference(image: string): boolean { - const lowerImage = image.toLowerCase(); return ( - image.startsWith('.') || - image.startsWith('/') || - lowerImage === 'dockerfile' || - lowerImage.includes('dockerfile') || - lowerImage.endsWith('/dockerfile') || - lowerImage.endsWith('\\dockerfile') + image.startsWith('.') || image.startsWith('/') || image.toLowerCase().includes('dockerfile') ); } diff --git a/packages/edge/src/headers.ts b/packages/edge/src/headers.ts index f70d4a314..79d74baf4 100644 --- a/packages/edge/src/headers.ts +++ b/packages/edge/src/headers.ts @@ -161,7 +161,6 @@ export function applyHeaderRules( return response; } - // Clone response to modify headers const newResponse = new Response(response.body, response); // Fallback for a Clear-Site-Data rule that sanitizes to nothing: last-match-wins // means such a rule discards an earlier rule's value, so we fall back to the diff --git a/packages/edge/src/pattern.ts b/packages/edge/src/pattern.ts index e7a9bae76..a560230f6 100644 --- a/packages/edge/src/pattern.ts +++ b/packages/edge/src/pattern.ts @@ -23,13 +23,30 @@ const compileCache = new Map(); const REGEX_SPECIAL = /[.+?^${}()|[\]\\]/; const PARAM_CHAR = /[A-Za-z0-9_]/; +/** LRU read: re-inserting a hit moves it to the most-recently-used end of Map order. */ +function lruGet(cache: Map, key: string): V | undefined { + const value = cache.get(key); + if (value !== undefined) { + cache.delete(key); + cache.set(key, value); + } + return value; +} + +/** LRU write: evict the least-recently-used entry once the cache is full. */ +function lruSet(cache: Map, key: string, value: V): void { + if (cache.size >= MAX_CACHE) { + const first = cache.keys().next(); + if (!first.done) { + cache.delete(first.value); + } + } + cache.set(key, value); +} + export function compilePattern(source: string): CompiledPattern { - const cached = compileCache.get(source); + const cached = lruGet(compileCache, source); if (cached) { - // LRU touch: re-inserting moves the entry to the most-recently-used end - // of Map iteration order, so eviction below removes the true LRU. - compileCache.delete(source); - compileCache.set(source, cached); return cached; } @@ -50,12 +67,11 @@ export function compilePattern(source: string): CompiledPattern { index++; continue; } + paramNames.push(name); if (source[nameEnd] === '*') { - paramNames.push(name); pattern += `(?<${name}>.*)`; index = nameEnd + 1; } else { - paramNames.push(name); pattern += `(?<${name}>[^/]+)`; index = nameEnd; } @@ -78,13 +94,7 @@ export function compilePattern(source: string): CompiledPattern { } const compiled: CompiledPattern = { regex: new RegExp(`^${pattern}$`), paramNames }; - if (compileCache.size >= MAX_CACHE) { - const first = compileCache.keys().next(); - if (!first.done) { - compileCache.delete(first.value); - } - } - compileCache.set(source, compiled); + lruSet(compileCache, source, compiled); return compiled; } @@ -114,8 +124,7 @@ export function matchPatternCaptures( } /** - * Match a pattern against a pathname (boolean form). Header-rule callers only - * need a yes/no — they don't care about captures. Malformed patterns yield + * Match a pattern against a pathname (boolean form). Malformed patterns yield * `false` rather than throwing (see `matchPatternCaptures`). */ export function matchPattern(pattern: string, pathname: string): boolean { @@ -133,35 +142,21 @@ const literalRegexCache = new Map(); * captures. Only `*` is special (matches any characters including `/`); every * other character (including `:`) is treated as a literal. * - * This preserves the pre-`feat/rewrites` semantics of header rules, - * `routing.revalidate` keys, and `routing.revalidateQueryAllowlist` keys — - * those callers never had `:name` capture syntax and existing tenant configs - * may include literal `:` (e.g. `/api/v1:health`, `:batchGet`-style URLs). - * - * The capture-aware `matchPattern` above continues to be used by routing - * rules (`redirects`/`rewrites`/`fallbacks`) where `:name` capture syntax was - * intentionally introduced (see design-doc 0064). + * Used for header rules and `routing.revalidateQueryAllowlist` keys, which + * never had `:name` capture syntax; existing tenant configs may include literal + * `:` (e.g. `/api/v1:health`, `:batchGet`-style URLs). Routing rules + * (`redirects`/`rewrites`/`fallbacks`) use the capture-aware `compilePattern`. */ export function matchLiteralPattern(pattern: string, pathname: string): boolean { - let regex = literalRegexCache.get(pattern); - if (regex) { - // LRU touch — same scheme as the legacy header matcher. - literalRegexCache.delete(pattern); - literalRegexCache.set(pattern, regex); - } else { + let regex = lruGet(literalRegexCache, pattern); + if (!regex) { try { const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*'); regex = new RegExp(`^${escaped}$`); } catch { return false; } - if (literalRegexCache.size >= MAX_CACHE) { - const first = literalRegexCache.keys().next(); - if (!first.done) { - literalRegexCache.delete(first.value); - } - } - literalRegexCache.set(pattern, regex); + lruSet(literalRegexCache, pattern, regex); } try { return regex.test(pathname); @@ -192,8 +187,7 @@ const segmentRegexCache = new Map(); * from page routes on the deploy write side (`[slug]` → `/blog/:slug`, * `[...path]` → `/docs/:path{.+}`), so the `:name` here is a real route param * and MUST bind the same way the framework's own client route matcher - * (`patternToRegex` in `pages/manifest.ts`) binds it — matching the write-side - * matcher in `packages/api/src/routes/deploy.ts`. + * (`patternToRegex` in `pages/manifest.ts`) binds it. * * It is intentionally NOT used for user-authored header rules * (`applyHeaderRules`) or `revalidateQueryAllowlist` keys @@ -206,12 +200,8 @@ const segmentRegexCache = new Map(); * not be shared or they would cross-contaminate. */ export function matchSegmentPattern(pattern: string, pathname: string): boolean { - let regex = segmentRegexCache.get(pattern); - if (regex) { - // LRU touch — same scheme as the literal matcher. - segmentRegexCache.delete(pattern); - segmentRegexCache.set(pattern, regex); - } else { + let regex = lruGet(segmentRegexCache, pattern); + if (!regex) { try { // Two private-use sentinels stand in for route params so the regex-special // escape step below can't touch the classes we swap in last: @@ -232,13 +222,7 @@ export function matchSegmentPattern(pattern: string, pathname: string): boolean } catch { return false; } - if (segmentRegexCache.size >= MAX_CACHE) { - const first = segmentRegexCache.keys().next(); - if (!first.done) { - segmentRegexCache.delete(first.value); - } - } - segmentRegexCache.set(pattern, regex); + lruSet(segmentRegexCache, pattern, regex); } try { return regex.test(pathname); diff --git a/packages/edge/src/runtime.ts b/packages/edge/src/runtime.ts index b6df7d5d0..7dff960ca 100644 --- a/packages/edge/src/runtime.ts +++ b/packages/edge/src/runtime.ts @@ -37,13 +37,11 @@ export function createStaticEdgeHandler(config: StaticEdgeConfig): StaticEdgeHan if (!config.versionMetadataBinding) { return Response.json({ ready: true }); } - const metadata = config.versionMetadataBinding - ? (env as unknown as Record)[ - config.versionMetadataBinding - ] - : undefined; + const metadata = (env as unknown as Record)[ + config.versionMetadataBinding + ]; return Response.json( - { ready: true, ...(config.versionMetadataBinding ? { versionId: metadata?.id } : {}) }, + { ready: true, versionId: metadata?.id }, { headers: { 'cache-control': 'no-store' } }, ); } diff --git a/packages/edge/src/static.ts b/packages/edge/src/static.ts index f78576ae4..5c9542da9 100644 --- a/packages/edge/src/static.ts +++ b/packages/edge/src/static.ts @@ -35,8 +35,7 @@ export type ResolvedAsset = Readonly<{ * * Known limitation: a file whose name contains a literal `%` (e.g. `logo%20.svg`, * requested as `logo%2520.svg`) is not resolved — the path encoder leaves `%` - * as-is, so the round-trip can't reproduce the double-escaped request. Such - * names 404'd before this change too, so this is not a regression. + * as-is, so the round-trip can't reproduce the double-escaped request. */ export function safeDecodePathname(pathname: string): string { let decoded: string; diff --git a/packages/isr/src/index.ts b/packages/isr/src/index.ts index 37c7d351d..a9b161f8f 100644 --- a/packages/isr/src/index.ts +++ b/packages/isr/src/index.ts @@ -35,7 +35,7 @@ const ISR_LEGACY_CACHE_KEY_VERSION = 'v2'; * KV metadata stored alongside every ISR cache entry. This is the single * canonical shape shared by the dispatch worker (which writes it on a live * miss and reads it back on a hit — see `dispatch/src/isr.ts`) AND the API - * prerender-warm path (`api/src/routes/deploy.ts`), which pre-populates the + * prerender-warm path (`api/src/deploy/publish-worker.ts`), which pre-populates the * same KV keys during deploy. Both writers MUST use these exact field names: * the reader requires `c` (createdAt) and `t` (ttl) and returns null without * them, so a writer that drifts (e.g. writes `context` instead of `c`) produces @@ -81,20 +81,15 @@ export function canonicalIsrPathname(pathname: string): string { return denormalizeIsrPath(normalizeIsrPath(pathname)); } -/** - * Prefix shared by every ISR key for a project at the current version — - * `/isr/v3/`. Used internally by `isrCachePrefix`. - */ -function isrCacheVersionPrefix(projectId: string): string { - return `${projectId}/isr/${ISR_CACHE_KEY_VERSION}/`; -} - -/** - * Prefix shared by every LEGACY (hostless) ISR key for a project — - * `/isr/v2/`. Used internally by `isrLegacyCachePrefix`. - */ -function isrLegacyCacheVersionPrefix(projectId: string): string { - return `${projectId}/isr/${ISR_LEGACY_CACHE_KEY_VERSION}/`; +function versionedIsrCachePrefix( + projectId: string, + version: string, + deploymentId: string | undefined, +): string { + if (deploymentId !== undefined) { + return `${projectId}/isr/${version}/${deploymentId}/`; + } + return `${projectId}/isr/`; } /** @@ -107,10 +102,7 @@ function isrLegacyCacheVersionPrefix(projectId: string): string { * legacy scan-all prefix (`/isr/`). */ export function isrCachePrefix(projectId: string, deploymentId?: string): string { - if (deploymentId !== undefined) { - return `${isrCacheVersionPrefix(projectId)}${deploymentId}/`; - } - return `${projectId}/isr/`; + return versionedIsrCachePrefix(projectId, ISR_CACHE_KEY_VERSION, deploymentId); } /** @@ -126,10 +118,7 @@ export function isrCachePrefix(projectId: string, deploymentId?: string): string * entries have aged out. */ export function isrLegacyCachePrefix(projectId: string, deploymentId?: string): string { - if (deploymentId !== undefined) { - return `${isrLegacyCacheVersionPrefix(projectId)}${deploymentId}/`; - } - return `${projectId}/isr/`; + return versionedIsrCachePrefix(projectId, ISR_LEGACY_CACHE_KEY_VERSION, deploymentId); } /** @@ -152,21 +141,13 @@ const ISR_VARIANT_SEPARATOR = '#rw='; * * Host-scoping is a `v3` key format: `/isr/v3///`. The * version bump is load-bearing, not cosmetic — see `ISR_LEGACY_CACHE_KEY_VERSION`. - * Under a single version, this key would be byte-identical to a legacy hostless - * key for a page whose leading path segment looks like a hostname (e.g. - * `(host=example.com, /about)` vs the hostless page `/example.com/about`, both - * `/example.com/about`). Dotted leading segments are legal, so the collision - * is reachable. `v3` keys are ALWAYS host-scoped and `v2` keys ALWAYS hostless, - * so the version segment alone disambiguates the two shapes. * * When `originalUrl` is provided, the request was served via a dispatch-side * rewrite (`redirectRules`/`fallbackRules` with status 200). The original * request URL is mixed into the key so a direct hit to `/en/docs` and a * rewrite from `/docs` to `/en/docs` do not collide — user code can branch * on `c.isRewritten()` / `c.originalUrl()`, and the cache must honor that. - * A missing `originalUrl` means "direct hit" and produces the legacy - * rewriteless key (backward-compatible with entries written before this - * feature landed). + * A missing `originalUrl` means "direct hit" and produces the unsuffixed key. */ export function isrCacheKey( projectId: string, @@ -247,9 +228,8 @@ export function isrLegacyCacheVariantPrefix( * - Sync (Workers `crypto.subtle.digest` is async; we don't want to push * async through every read/write path for this). * - 16 hex chars (64 bits) — bounds the KV key length regardless of input. - * - Non-recoverable: KV is operator-readable, and the previous base64url - * encoding let any operator with list access read the original URLs - * (including sensitive query strings like `?token=…`, `?email=…`). + * - Non-recoverable: KV is operator-readable, so the token must not reveal + * original URLs (including sensitive query strings like `?token=…`). * - Not cryptographic: collision resistance is ~2^32 (birthday). Fine for * the per-deployment, per-pathname variant set we ever store in one slot. * diff --git a/packages/isr/src/runtime.ts b/packages/isr/src/runtime.ts index ea0cf5cfb..58c7f3779 100644 --- a/packages/isr/src/runtime.ts +++ b/packages/isr/src/runtime.ts @@ -13,7 +13,6 @@ import { packIsrValue, markSharedCachePolicy, resolvePageTtl, - resolvePathTtl, resolveRevalidateTtl, SHARED_CACHE_POLICY_VERSION, SHARED_CACHE_POLICY_HEADER, @@ -60,7 +59,7 @@ export { canonicalIsrPathname, denormalizeIsrPath, normalizeIsrPath } from './in * - No `projectId`, no cross-tenant metering. * - No `#rw=` rewrite variant, no query-string variant fanout, no variant cap. * - The deployment id is baked at build time as a compile-time literal and - * surfaced on `env.__VOID_ISR_DEPLOYMENT_ID` (Task 7). The KV helpers read + * surfaced on `env.__VOID_ISR_DEPLOYMENT_ID`. The KV helpers read * it from `env` (matching the `revalidate()` purge path in `runtime/isr.ts`), * while `handleIsr` uses the `deploymentId` option for the edge-cache keys — * the two are the same value. @@ -224,8 +223,6 @@ export async function writeIsrCache( if (!kv) { return; } - // Clone synchronously (no await before this) — the clone has its own - // independent body; the original is free to be consumed elsewhere. const responseClone = response.clone(); const headers = serializeHeaders(responseClone); const status = responseClone.status; @@ -346,7 +343,7 @@ export async function deleteIsrCache( } // --------------------------------------------------------------------------- -// Response builders (ported from dispatch/src/index.ts:61–101) +// Response builders // --------------------------------------------------------------------------- const CLIENT_REVALIDATE_CACHE_CONTROL = 'public, max-age=0, must-revalidate'; @@ -358,13 +355,42 @@ export function clientRevalidateResponse(response: Response): Response { return res; } +function mediaType(response: Response): string | undefined { + return response.headers.get('content-type')?.toLowerCase().split(';', 1)[0]?.trim(); +} + export function isEventStreamResponse(response: Response): boolean { + return mediaType(response) === 'text/event-stream'; +} + +function isHtmlResponse(response: Response): boolean { + return mediaType(response) === 'text/html'; +} + +/** Whether a fresh render may be written to the shared KV and edge caches. */ +function isStorableRender(response: Response, ttl: number, htmlOnly: boolean): boolean { + return ttl > 0 && isSharedCacheableResponse(response) && (!htmlOnly || isHtmlResponse(response)); +} + +/** Whether a successful render opts out of sharing, so cached copies must go. */ +function isUncacheableRender(response: Response, ttl: number, htmlOnly: boolean): boolean { return ( - response.headers.get('content-type')?.toLowerCase().split(';', 1)[0]?.trim() === - 'text/event-stream' + response.status === 200 && + (ttl === 0 || hasSharedCacheOptOut(response) || (htmlOnly && !isHtmlResponse(response))) ); } +/** Headers for a shared HTML cache copy, without ISR-managed or private headers. */ +function sharedHtmlCacheHeaders(response: Response, ttl: number, state: 'HIT' | 'MISS'): Headers { + const headers = new Headers(response.headers); + headers.set('x-isr-cache', state); + headers.set('Cache-Control', `public, s-maxage=${ttl}, max-age=0, must-revalidate`); + markSharedCachePolicy(headers); + headers.delete('set-cookie'); + headers.delete('x-void-page-data'); + return headers; +} + function jsonCacheResponse(pageData: string, ttl: number, state: 'HIT' | 'MISS'): Response { const response = new Response(pageData, { headers: { @@ -479,9 +505,7 @@ async function deleteSharedIsrVariants( } // --------------------------------------------------------------------------- -// Background revalidation (ported from dispatch/src/index.ts:1206–1295, -// stripped of getWorker / applyWorkerFallbackRules / maybeServeWfpAssetFallback -// / rebuildRequest — single-tenant re-renders through the `render` closure) +// Background revalidation (single-tenant: re-renders through `render`) // --------------------------------------------------------------------------- /** @@ -524,32 +548,24 @@ async function revalidateInBackground( const ttl = resolveRevalidateTtl(response, defaultTtl); const cache = edgeCache(); if (response.status === 200 && isEventStreamResponse(response)) { - if (ttl === 0 || hasSharedCacheOptOut(response)) { + if (isUncacheableRender(response, ttl, false)) { await deleteSharedIsrVariants(env, cache, host, pathname, keys, variantUrl); } return; } - if (ttl > 0 && isSharedCacheableResponse(response) && (!htmlOnly || isHtmlResponse(response))) { + if (isStorableRender(response, ttl, htmlOnly)) { await writeIsrCache(env, host, pathname, response, ttl, variantUrl); - // Update HTML edge cache with the fresh response. - const headers = new Headers(response.headers); - headers.set('x-isr-cache', 'HIT'); - headers.set('Cache-Control', `public, s-maxage=${ttl}, max-age=0, must-revalidate`); - markSharedCachePolicy(headers); - headers.delete('set-cookie'); - headers.delete('x-void-page-data'); await safeCachePut( cache, keys.htmlKey, new Response(response.clone().body, { status: response.status, - headers, + headers: sharedHtmlCacheHeaders(response, ttl, 'HIT'), }), ); await populateJsonEdgeCache(cache, keys.jsonKey, response, ttl, 'HIT'); } else if ( - (response.status === 200 && - (ttl === 0 || hasSharedCacheOptOut(response) || (htmlOnly && !isHtmlResponse(response)))) || + isUncacheableRender(response, ttl, htmlOnly) || ((response.status === 404 || response.status === 410) && Date.now() - staleSinceMs > ISR_STALE_EVICT_GRACE_MS) ) { @@ -566,12 +582,6 @@ async function revalidateInBackground( // Orchestrator // --------------------------------------------------------------------------- -function isHtmlResponse(response: Response): boolean { - return ( - response.headers.get('content-type')?.toLowerCase().split(';', 1)[0]?.trim() === 'text/html' - ); -} - /** Cache only framework document HTML; leave client data requests and other response types live. */ export async function handleFrameworkIsr( request: Request, @@ -652,9 +662,9 @@ async function handleIsrInternal( } // Keep the KV helpers (which derive the deployment id from env — matching the - // `revalidate()` purge path) aligned with the edge keys built here. Task 7 - // already sets this at the top of the fetch handler; the `??=` makes the - // module correct in isolation when a caller only passes the option. + // `revalidate()` purge path) aligned with the edge keys built here. The worker + // entry usually sets this already; the `??=` covers callers that only pass + // the option. env.__VOID_ISR_DEPLOYMENT_ID ??= deploymentId; const host = url.hostname; @@ -686,7 +696,7 @@ async function handleIsrInternal( // (cold colo re-renders JSON). Matches the platform dispatch behavior. const response = await render(request); const ttl = resolveRevalidateTtl(response, isrTtl); - if (response.status === 200 && (ttl === 0 || hasSharedCacheOptOut(response))) { + if (isUncacheableRender(response, ttl, false)) { ctx.waitUntil( deleteSharedIsrVariants(env, cache, host, pathname, { htmlKey, jsonKey }, variantUrl), ); @@ -757,27 +767,19 @@ async function handleIsrInternal( return response; } const ttl = resolveRevalidateTtl(response, isrTtl); - if (ttl > 0 && isSharedCacheableResponse(response) && (!htmlOnly || isHtmlResponse(response))) { + if (isStorableRender(response, ttl, htmlOnly)) { // Every clone below is taken synchronously (before `response.body` is read // for the client) so no consumer races the body away. ctx.waitUntil(writeIsrCache(env, host, pathname, response, ttl, variantUrl)); ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, 'HIT')); - const cacheHeaders = new Headers(response.headers); - cacheHeaders.set('Cache-Control', `public, s-maxage=${ttl}, max-age=0, must-revalidate`); - cacheHeaders.set('x-isr-cache', 'MISS'); - markSharedCachePolicy(cacheHeaders); - cacheHeaders.delete('x-void-page-data'); - cacheHeaders.delete('set-cookie'); - const edgeHeaders = new Headers(cacheHeaders); - edgeHeaders.set('x-isr-cache', 'HIT'); const cacheResponse = new Response(response.clone().body, { status: response.status, - headers: edgeHeaders, + headers: sharedHtmlCacheHeaders(response, ttl, 'HIT'), }); ctx.waitUntil(safeCachePut(cache, htmlKey, cacheResponse)); - const clientHeaders = new Headers(cacheHeaders); + const clientHeaders = sharedHtmlCacheHeaders(response, ttl, 'MISS'); clientHeaders.set('Cache-Control', CLIENT_REVALIDATE_CACHE_CONTROL); clientHeaders.delete(SHARED_CACHE_POLICY_HEADER); return new Response(response.body, { @@ -785,10 +787,7 @@ async function handleIsrInternal( headers: clientHeaders, }); } - if ( - response.status === 200 && - (ttl === 0 || hasSharedCacheOptOut(response) || (htmlOnly && !isHtmlResponse(response))) - ) { + if (isUncacheableRender(response, ttl, htmlOnly)) { ctx.waitUntil( deleteSharedIsrVariants(env, cache, host, pathname, { htmlKey, jsonKey }, variantUrl), ); diff --git a/packages/isr/test/unit/variant-hash.test.ts b/packages/isr/test/unit/variant-hash.test.ts index 14ee565b1..02908ac93 100644 --- a/packages/isr/test/unit/variant-hash.test.ts +++ b/packages/isr/test/unit/variant-hash.test.ts @@ -71,10 +71,7 @@ describe('ISR variant token hashing', () => { it('splitIsrCacheKey returns the (opaque) variant token, not a URL', () => { const fullKey = isrCacheKey('p', 'd', 'h', '/x', 'https://h/foo'); - const tail = fullKey.slice(isrCacheVariantPrefix('p', 'd', 'h', '/x').length); - // Reconstruct the part that splitIsrCacheKey actually consumes (the - // suffix after the deployment+host prefix), then call it on that form. - void tail; + // splitIsrCacheKey consumes the suffix after the deployment+host prefix. const split = splitIsrCacheKey(fullKey.slice(`p/isr/v3/d/h/`.length)); expect(split.variantToken).toMatch(/^[0-9a-f]+$/); expect(split.variantToken).not.toContain('https://'); diff --git a/packages/md/src/compile.ts b/packages/md/src/compile.ts index 7cf212667..303155203 100644 --- a/packages/md/src/compile.ts +++ b/packages/md/src/compile.ts @@ -14,6 +14,7 @@ import attrs from 'markdown-it-attrs'; import { full as emoji } from 'markdown-it-emoji'; import footnote from 'markdown-it-footnote'; import { createHighlighter, bundledLanguages, isSpecialLang, type BundledLanguage } from 'shiki'; +import { extractHeadings } from './metadata.ts'; import { containerPlugin } from './plugins/containers.ts'; import { gitHubAlertsPlugin } from './plugins/github-alerts.ts'; import { imagePlugin } from './plugins/image.ts'; @@ -83,13 +84,9 @@ async function createCompiler(options?: CompileOptions): Promise { lang = 'text'; } } - // `markdown-it-attrs` strips the `{1,3-5}` line-highlight ranges out of - // `token.info` at parse time (it treats `{…}` as an attribute block), so by - // render time only the language remains. The `stash_fence_info` core rule - // preserves the original info string on `token.meta.rawInfo` before that - // happens; fall back to `token.info` when it is absent (a fence with no - // curly meta). Without this, `transformerMetaHighlight` never sees the - // ranges and line highlighting silently no-ops. + // `markdown-it-attrs` strips `{1,3-5}` line-highlight ranges from + // `token.info`; `stash_fence_info` keeps the original so + // `transformerMetaHighlight` still sees them. const rawInfo = (token.meta as { rawInfo?: string } | null)?.rawInfo ?? token.info; return highlighter.codeToHtml(code, { lang, @@ -155,26 +152,6 @@ async function createCompiler(options?: CompileOptions): Promise { return markdown; } -function extractHeadings(tokens: ReadonlyArray): ReadonlyArray { - const headings: Array = []; - for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) { - const token = tokens[tokenIndex]; - if (token.type === 'heading_open') { - const depth = Number(token.tag.slice(1)); - const slug = token.attrGet?.('id') ?? ''; - const inline = tokens[tokenIndex + 1]; - const text = ( - inline?.children - ?.filter((child) => child.type === 'text' || child.type === 'code_inline') - .map((child) => child.content) - .join('') ?? '' - ).trim(); - headings.push({ depth, slug, text }); - } - } - return headings; -} - export async function compile(source: string, options?: CompileOptions): Promise { const { data: frontmatter, content } = matter(source); const compiler = await getCompiler(options); diff --git a/packages/md/src/emit-vue.ts b/packages/md/src/emit-vue.ts index 75fba1aca..f85e0bfe6 100644 --- a/packages/md/src/emit-vue.ts +++ b/packages/md/src/emit-vue.ts @@ -1,5 +1,5 @@ import type { CompileResult } from './compile.ts'; -import { toIslandId } from './emit-shared.ts'; +import { buildRenderChunks, toIslandId } from './emit-shared.ts'; import type { IslandImport } from './extract-script.ts'; export function emitVueComponent(result: CompileResult): string { @@ -22,64 +22,19 @@ export default defineComponent({ `; } -// Match self-closing uppercase tags: , -// and paired uppercase tags: ... -const ISLAND_TAG_RE = - /<([A-Z]\w*)\s*((?:[^>/"']|"[^"]*"|'[^']*')*)\/\s*>|<([A-Z]\w*)\s*((?:[^>/"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/\3\s*>/g; - -type IslandMatch = { - name: string; - start: number; - end: number; - attrs: string; - children: string; -}; - -function findIslandTags(html: string, names: Set): ReadonlyArray { - const matches: Array = []; - let tagMatch: RegExpExecArray | null; - ISLAND_TAG_RE.lastIndex = 0; - while ((tagMatch = ISLAND_TAG_RE.exec(html)) !== null) { - const name = tagMatch[1] ?? tagMatch[3]; - if (names.has(name)) { - matches.push({ - name, - start: tagMatch.index, - end: tagMatch.index + tagMatch[0].length, - attrs: (tagMatch[2] ?? tagMatch[4] ?? '').trim(), - children: tagMatch[5] ?? '', - }); - } - } - return matches; -} - -function parseAttrs(attrStr: string): Record { - const result: Record = {}; - const attrRe = /(\w[\w-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g; - let attrMatch: RegExpExecArray | null; - while ((attrMatch = attrRe.exec(attrStr)) !== null) { - result[attrMatch[1]] = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? ''; - } - return result; -} - export function emitVueComponentWithIslands( result: CompileResult, islandImports: ReadonlyArray, ): string { - const nameSet = new Set(islandImports.map((islandImport) => islandImport.name)); - const islandTags = findIslandTags(result.html, nameSet); + const chunks = buildRenderChunks(result.html, islandImports); let code = `import { defineComponent, h, createStaticVNode, provide } from "vue";\n`; - // Import raw island components for (let islandIndex = 0; islandIndex < islandImports.length; islandIndex++) { const islandImport = islandImports[islandIndex]; code += `import _raw_island_${islandIndex} from ${JSON.stringify(islandImport.specifier)};\n`; } - // Create island wrapper components for (let islandIndex = 0; islandIndex < islandImports.length; islandIndex++) { const islandImport = islandImports[islandIndex]; code += `const ${islandImport.name} = { inheritAttrs: false, render() { @@ -92,32 +47,14 @@ export function emitVueComponentWithIslands( }};\n`; } - // Build render function that interleaves static segments and island vnodes const vnodes: Array = []; - let cursor = 0; - - for (const tag of islandTags) { - // Static HTML before this island tag - if (tag.start > cursor) { - const segment = result.html.slice(cursor, tag.start); - if (segment.trim()) { - vnodes.push(`createStaticVNode(${JSON.stringify(segment)}, 1)`); - } - } - - const attrs = parseAttrs(tag.attrs); - const propsStr = Object.keys(attrs).length > 0 ? JSON.stringify(attrs) : 'null'; - // Use the wrapper component - vnodes.push(`h(${tag.name}, ${propsStr})`); - - cursor = tag.end; - } - // Remaining HTML after last island tag - if (cursor < result.html.length) { - const segment = result.html.slice(cursor); - if (segment.trim()) { - vnodes.push(`createStaticVNode(${JSON.stringify(segment)}, 1)`); + for (const chunk of chunks) { + if (chunk.type === 'html') { + vnodes.push(`createStaticVNode(${JSON.stringify(chunk.html)}, 1)`); + } else { + const propsStr = Object.keys(chunk.props).length > 0 ? JSON.stringify(chunk.props) : 'null'; + vnodes.push(`h(${chunk.islandImport.name}, ${propsStr})`); } } diff --git a/packages/md/src/metadata.ts b/packages/md/src/metadata.ts index 98ed14bc6..e43afc173 100644 --- a/packages/md/src/metadata.ts +++ b/packages/md/src/metadata.ts @@ -26,7 +26,7 @@ function getMetadataCompiler(): MarkdownExit { return markdown; } -function extractHeadings(tokens: ReadonlyArray): ReadonlyArray { +export function extractHeadings(tokens: ReadonlyArray): ReadonlyArray { const headings: Array = []; for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) { const token = tokens[tokenIndex]; diff --git a/packages/md/src/plugin.ts b/packages/md/src/plugin.ts index 762acb28c..6586f340e 100644 --- a/packages/md/src/plugin.ts +++ b/packages/md/src/plugin.ts @@ -38,6 +38,13 @@ export type MarkdownOptions = Readonly<{ shiki?: CompileOptions['shiki']; }>; +function stripPageSuffix(file: string): string { + return file + .replace(/\.md$/, '') + .replace(/\/index$/, '') + .replace(/^index$/, ''); +} + function hasMdFilesWithScript(dir: string): boolean { try { for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) { @@ -100,24 +107,15 @@ export function voidMarkdown(options?: MarkdownOptions): Array { const { body, clientCode } = extractScript(content); const result = extractMetadata(body); - const componentId = - file - .replace(/\.md$/, '') - .replace(/\/index$/, '') - .replace(/^index$/, '') || 'index'; + const routeName = stripPageSuffix(file); + const componentId = routeName || 'index'; if (clientCode) { clientScripts.set(componentId, clientCode); } - const routePath = - '/' + - file - .replace(/\.md$/, '') - .replace(/\/index$/, '') - .replace(/^index$/, ''); return { - path: routePath || '/', + path: '/' + routeName, title: result.title, frontmatter: result.frontmatter, headings: result.headings, @@ -143,9 +141,6 @@ export function voidMarkdown(options?: MarkdownOptions): Array { getClientScripts(): Map { return clientScripts; }, - getClientScriptIds(): Set { - return new Set(clientScripts.keys()); - }, }, config(config, env) { @@ -328,8 +323,7 @@ export function useFrontmatter() { return useContext(FrontmatterContext) ?? {}; const pagesPrefix = pagesDir + '/'; if (_id.startsWith(pagesPrefix) && clientCode) { - const rel = _id.slice(pagesPrefix.length).replace(/\.md$/, ''); - const componentId = rel.replace(/\/index$/, '').replace(/^index$/, '') || 'index'; + const componentId = stripPageSuffix(_id.slice(pagesPrefix.length)) || 'index'; clientScripts.set(componentId, clientCode); } for (const imp of islandImports) { diff --git a/packages/md/src/plugins/pre-wrapper.ts b/packages/md/src/plugins/pre-wrapper.ts index ba8b88ac0..2a8b8028d 100644 --- a/packages/md/src/plugins/pre-wrapper.ts +++ b/packages/md/src/plugins/pre-wrapper.ts @@ -10,7 +10,7 @@ type FenceRender = ( self: Renderer, ) => string | Promise; -export function extractLang(info: string): string { +function extractLang(info: string): string { return ( /^[a-zA-Z0-9-_]+/ .exec(info)?.[0] diff --git a/packages/md/src/plugins/toc.ts b/packages/md/src/plugins/toc.ts index 626b94cb8..84d18809e 100644 --- a/packages/md/src/plugins/toc.ts +++ b/packages/md/src/plugins/toc.ts @@ -1,5 +1,7 @@ -import type { RenderOptions, Renderer, RuleBlock, StateBlock, Token } from 'markdown-exit'; +import type { RuleBlock, StateBlock, Token } from 'markdown-exit'; import type MarkdownExit from 'markdown-exit'; +import type { Heading } from '../compile.ts'; +import { extractHeadings } from '../metadata.ts'; export type TocOptions = Readonly<{ pattern?: RegExp; @@ -9,13 +11,7 @@ export type TocOptions = Readonly<{ listTag?: string; }>; -type Header = { - depth: number; - slug: string; - text: string; -}; - -function renderHeaders(headers: ReadonlyArray
, listTag: string): string { +function renderHeaders(headers: ReadonlyArray, listTag: string): string { if (headers.length === 0) { return ''; } @@ -70,46 +66,8 @@ export function tocPlugin(md: MarkdownExit, options?: TocOptions): void { { alt: ['paragraph', 'reference', 'blockquote'] }, ); - md.renderer.rules.toc_open = ( - tokens: Array, - idx: number, - options: RenderOptions, - _env: unknown, - self: Renderer, - ) => { - return self.renderToken(tokens, idx, options); - }; - md.renderer.rules.toc_body = (tokens: Array) => { - // Walk all tokens to find headings at the specified levels - const headers: Array
= []; - for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex++) { - const token = tokens[tokenIndex]; - if (token.type === 'heading_open') { - const depth = Number(token.tag.slice(1)); - if (level.includes(depth)) { - const slug = token.attrGet?.('id') ?? ''; - const inline = tokens[tokenIndex + 1]; - const text = ( - inline?.children - ?.filter((child) => child.type === 'text' || child.type === 'code_inline') - .map((child) => child.content) - .join('') ?? '' - ).trim(); - headers.push({ depth, slug, text }); - } - } - } + const headers = extractHeadings(tokens).filter((heading) => level.includes(heading.depth)); return renderHeaders(headers, listTag); }; - - md.renderer.rules.toc_close = ( - tokens: Array, - idx: number, - options: RenderOptions, - _env: unknown, - self: Renderer, - ) => { - return self.renderToken(tokens, idx, options); - }; } diff --git a/packages/md/test/unit/compile.test.ts b/packages/md/test/unit/compile.test.ts index a46d1d94e..4b87928f8 100644 --- a/packages/md/test/unit/compile.test.ts +++ b/packages/md/test/unit/compile.test.ts @@ -76,11 +76,6 @@ describe('compile', () => { expect(result.html).toContain('greeting'); }); - it('converts emoji shortcodes', async () => { - const result = await compile('Hello :tada:'); - expect(result.html).toContain('\u{1F389}'); // 🎉 - }); - it('adds anchor links to headings', async () => { const result = await compile('## My Heading'); expect(result.html).toContain('id='); diff --git a/packages/md/test/unit/extract-script.test.ts b/packages/md/test/unit/extract-script.test.ts index d2b8439cd..69acaed81 100644 --- a/packages/md/test/unit/extract-script.test.ts +++ b/packages/md/test/unit/extract-script.test.ts @@ -7,6 +7,7 @@ describe('extractScript', () => { expect(result.script).toBeNull(); expect(result.body).toBe('# Hello\n\nWorld'); expect(result.islandImports).toEqual([]); + expect(result.clientCode).toBeNull(); }); it('extracts `); } else if (attrs) { @@ -194,22 +174,12 @@ export function renderHeadToString(head: HeadDescriptor): string { /** Serialize htmlAttrs to an attribute string (includes leading space). */ export function renderHtmlAttrs(head: HeadDescriptor): string { - if (!head.htmlAttrs) { - return ''; - } - const attrs = Object.entries(head.htmlAttrs) - .map(([key, value]) => `${key}="${escapeHtml(value)}"`) - .join(' '); + const attrs = head.htmlAttrs ? serializeAttrs(head.htmlAttrs) : ''; return attrs ? ' ' + attrs : ''; } /** Serialize bodyAttrs to an attribute string (includes leading space). */ export function renderBodyAttrs(head: HeadDescriptor): string { - if (!head.bodyAttrs) { - return ''; - } - const attrs = Object.entries(head.bodyAttrs) - .map(([key, value]) => `${key}="${escapeHtml(value)}"`) - .join(' '); + const attrs = head.bodyAttrs ? serializeAttrs(head.bodyAttrs) : ''; return attrs ? ' ' + attrs : ''; } diff --git a/packages/void/src/pages/island-imports.ts b/packages/void/src/pages/island-imports.ts index df13870c7..c2ed8d185 100644 --- a/packages/void/src/pages/island-imports.ts +++ b/packages/void/src/pages/island-imports.ts @@ -15,19 +15,27 @@ export type IslandSourceEdit = { start: number; end: number; text: string }; export type StaticCssImport = { statement: string; specifier: string }; +type ScriptBlock = { source: string; offset: number; filename: string }; + +/** The whole JS/TS module, or each `