diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2834327a..957080b5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,9 +114,11 @@ jobs: run: bun nx affected -t build --parallel=3 --exclude="$NX_VALIDATION_BUILD_EXCLUDE" --outputStyle=static if: github.event.action != 'labeled' - - name: Check affected docs sites - run: bun nx affected -t docs:check --parallel=1 --outputStyle=static - if: github.event.action != 'labeled' + # The imported Alchemy website docs build is intentionally not a required + # fork PR gate yet. It regenerates provider reference pages and runs a + # full Astro/Starlight build, which currently dominates CI time. Keep the + # Nx target available for deliberate website validation: + # `bun nx docs:check @oddlynew/alchemy-website`. - name: Typecheck affected projects run: bun nx affected -t typecheck --parallel=2 --exclude="$NX_VALIDATION_EXCLUDE" --outputStyle=static diff --git a/bun.lock b/bun.lock index 1fc282d26..62fc205a1 100644 --- a/bun.lock +++ b/bun.lock @@ -736,16 +736,23 @@ "@effect/platform-node-shared": "catalog:", "@oddlynew/alchemy-typescript-config": "workspace:*", "@oxc-project/types": "^0.127.0", + "@tanstack/react-router": "^1.167.4", + "@tanstack/react-start": "^1.166.15", "@types/aws-lambda": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", "@types/pg": "^8.11.0", "@types/picomatch": "^4.0.0", "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", + "@vitejs/plugin-react": "catalog:", + "@vitejs/plugin-rsc": "catalog:", "better-auth": "catalog:", "effect": "catalog:", "react-devtools-core": "^7.0.1", + "react-dom": "^19.2.7", + "react-router": "catalog:", "solid-js": "catalog:", "tsconfig-paths": "^4.2.0", "tsdown": "^0.15.4", @@ -800,6 +807,46 @@ "@oddlynew/alchemy-typescript-config": "workspace:*", }, }, + "projects/cloudflare-tools/fixtures/react-router-rsc": { + "name": "@oddlynew/cloudflare-tools-fixture-react-router-rsc", + "dependencies": { + "react": "catalog:", + "react-dom": "catalog:", + "react-router": "catalog:", + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:workers", + "@oddlynew/distilled-cloudflare-runtime": "workspace:*", + "@oddlynew/distilled-cloudflare-vite-plugin": "workspace:*", + "@tailwindcss/typography": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@vitejs/plugin-rsc": "catalog:", + "tailwindcss": "catalog:", + "vite": "catalog:", + }, + }, + "projects/cloudflare-tools/fixtures/react-rsc": { + "name": "@oddlynew/cloudflare-tools-fixture-react-rsc", + "version": "0.0.0", + "dependencies": { + "react": "catalog:", + "react-dom": "catalog:", + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:workers", + "@oddlynew/distilled-cloudflare-runtime": "workspace:*", + "@oddlynew/distilled-cloudflare-vite-plugin": "workspace:*", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@vitejs/plugin-rsc": "catalog:", + "rsc-html-stream": "catalog:", + "vite": "catalog:", + }, + }, "projects/cloudflare-tools/fixtures/solid-ssr": { "name": "@oddlynew/cloudflare-tools-fixture-solid-ssr", "version": "0.0.0", @@ -1438,10 +1485,16 @@ "@smithy/shared-ini-file-loader": "^4.3.4", "@smithy/types": "^4.8.1", "@smithy/util-base64": "^4.3.0", + "@tailwindcss/typography": "^0.5.20", + "@tailwindcss/vite": "^4.3.1", "@types/aws-lambda": "^8.10.152", "@types/bun": "^1.3.14", "@types/node": "^25.9.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@typescript/native-preview": "^7.0.0-dev.20260618.1", + "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-rsc": "^0.5.27", "ai": "^6.0.62", "archiver": "^7.0.1", "aws4fetch": "^1.0.20", @@ -1456,9 +1509,14 @@ "oxlint": "^1.69.0", "oxlint-tsgolint": "^0.23.0", "pathe": "^2.0.3", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "7.16.0", "rolldown": "1.0.1", + "rsc-html-stream": "^0.0.7", "solid-js": "latest", "sonda": "^0.11.1", + "tailwindcss": "^4.3.1", "tsdown": "^0.22.0", "typescript": "^7.0.1-rc", "uuid": "^14.0.0", @@ -2326,6 +2384,10 @@ "@oddlynew/alchemy-website": ["@oddlynew/alchemy-website@workspace:projects/alchemy/apps/website"], + "@oddlynew/cloudflare-tools-fixture-react-router-rsc": ["@oddlynew/cloudflare-tools-fixture-react-router-rsc@workspace:projects/cloudflare-tools/fixtures/react-router-rsc"], + + "@oddlynew/cloudflare-tools-fixture-react-rsc": ["@oddlynew/cloudflare-tools-fixture-react-rsc@workspace:projects/cloudflare-tools/fixtures/react-rsc"], + "@oddlynew/cloudflare-tools-fixture-solid-ssr": ["@oddlynew/cloudflare-tools-fixture-solid-ssr@workspace:projects/cloudflare-tools/fixtures/solid-ssr"], "@oddlynew/cloudflare-tools-fixture-solidstart": ["@oddlynew/cloudflare-tools-fixture-solidstart@workspace:projects/cloudflare-tools/fixtures/solidstart"], @@ -3152,6 +3214,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], + "@vitejs/plugin-rsc": ["@vitejs/plugin-rsc@0.5.27", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1", "es-module-lexer": "^2.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21", "srvx": "^0.11.15", "strip-literal": "^3.1.0", "turbo-stream": "^3.2.0", "vitefu": "^1.1.3" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-server-dom-webpack": "*", "vite": "*" }, "optionalPeers": ["react-server-dom-webpack"] }, "sha512-s1fd5DUkPXk86DDHPM/kP93WrvI0MoA8klxdDZmD1fMSaA9xujfgunsm8ZoUH0FemR+63vNalFsIDR0AJH4ktg=="], + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.7", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg=="], "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="], @@ -4784,6 +4848,8 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "react-router": ["react-router@7.16.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A=="], + "read-package-json-fast": ["read-package-json-fast@4.0.0", "", { "dependencies": { "json-parse-even-better-errors": "^4.0.0", "npm-normalize-package-bin": "^4.0.0" } }, "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -4892,6 +4958,8 @@ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "rsc-html-stream": ["rsc-html-stream@0.0.7", "", {}, "sha512-v9+fuY7usTgvXdNl8JmfXCvSsQbq2YMd60kOeeMIqCJFZ69fViuIxztHei7v5mlMMa2h3SqS+v44Gu9i9xANZA=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -4924,7 +4992,7 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], @@ -4996,7 +5064,7 @@ "sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="], - "srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="], + "srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], @@ -5136,6 +5204,8 @@ "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + "turbo-stream": ["turbo-stream@3.2.0", "", {}, "sha512-EK+bZ9UVrVh7JLslVFOV0GEMsociOqVOvEMTAd4ixMyffN5YNIEdLZWXUx5PJqDbTxSIBWw04HS9gCY4frYQDQ=="], + "twoslash-eslint": ["twoslash-eslint@0.3.8", "", { "dependencies": { "twoslash-protocol": "0.3.8" }, "peerDependencies": { "eslint": ">=8.50.0" } }, "sha512-4rW6i4ALza33+95G3IOG1l2FgBb84+SYzmX/GCe2suMTvZ2P4kJUTGIlr7tIqgPU90FRzDs3iL2i6X/Chuosug=="], "twoslash-protocol": ["twoslash-protocol@0.3.8", "", {}, "sha512-HmvAHoiEviK8LqvAQyc9/irkdvwTUiR1fHmNwH/0gq8EHxyBt4PWVPixjEXg6wJu1u6yBrILEWXGK9Kw58/8yQ=="], @@ -5632,6 +5702,8 @@ "@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="], + "@solidjs/start/srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="], + "@solidjs/start/vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], "@solidjs/vite-plugin-nitro-2/vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], @@ -5668,8 +5740,6 @@ "@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - "@tanstack/start-plugin-core/srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="], - "@tanstack/start-plugin-core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@typescript/analyze-trace/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -5734,6 +5804,8 @@ "better-auth/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "better-call/set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -5828,9 +5900,9 @@ "globby/unicorn-magic": ["unicorn-magic@0.4.0", "", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="], - "h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + "h3/srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="], - "h3-v2/srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="], + "h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], diff --git a/docs/monorepo/deviance-audit.md b/docs/monorepo/deviance-audit.md index b9f01d5d3..c07b29ad5 100644 --- a/docs/monorepo/deviance-audit.md +++ b/docs/monorepo/deviance-audit.md @@ -14,10 +14,13 @@ project graph, package identities are under the `@oddlynew` namespace, CI runs a build/typecheck/lint with the R2 cache hook, release groups are modeled in Nx, and the second audit round fixed dogfood-specific package export and workflow trust blockers. -The main remaining operational gap is broad test promotion. CI now runs affected tests for projects -tagged `test:ci`; the cache worker is the first tagged project. It does not yet require every -Alchemy, Distilled, or Cloudflare Tools test target because many imported tests exercise live -provider APIs, but adding `test:ci` to hermetic packages is the highest-value next hardening step. +The main remaining operational gaps are broad test promotion and website-docs gating. CI now runs +affected tests for projects tagged `test:ci`; the cache worker is the first tagged project. It does +not yet require every Alchemy, Distilled, or Cloudflare Tools test target because many imported +tests exercise live provider APIs, but adding `test:ci` to hermetic packages is the highest-value +next hardening step. The imported Alchemy website docs build remains available as an Nx target, but +is not currently a required fork PR gate because it dominates CI time and the fork is not relying on +website publishing as the core dogfood proof yet. ## Warranted Deviations @@ -31,7 +34,7 @@ These are intentional differences from the Oddlynew app monorepo. | Large runner | Use `ubuntu-large` for CI and release. | Distilled generated SDK builds, especially GCP, exceed the margin of the default runner. The project-level build targets still limit concurrency where needed. | | Fork PR safety | Keep CI on `pull_request`, with GitHub fork-workflow approval protecting larger runner cost. | The workflow executes PR code, so `pull_request_target` would be the wrong trust model for validation. | | Validation affected exclude | Exclude examples and fixtures from affected build/typecheck/lint. | Those projects remain in the graph and can be run directly, but they are not all hermetic validation gates yet. Distilled and Cloudflare Tools package leaves are part of the default affected gate. | -| Website cache inputs | Override `@oddlynew/alchemy-website` `build` and `docs:check` inputs inline and validate website changes with `docs:check` in CI. | The global `production` input intentionally ignores Markdown for library builds, but the website output depends on MD/MDX docs and generated provider docs. The inline inputs include content while excluding generated docs, `.astro`, `dist`, and downloaded fonts from the hash; full build still runs through deploy. | +| Website cache inputs | Override `@oddlynew/alchemy-website` `build` and `docs:check` inputs inline, but keep `docs:check` manual for now. | The global `production` input intentionally ignores Markdown for library builds, but the website output depends on MD/MDX docs and generated provider docs. The inline inputs include content while excluding generated docs, `.astro`, `dist`, and downloaded fonts from the hash; the target remains available for deliberate website validation without making every fork PR wait for the full Astro/Starlight docs build. | | Distilled `project.json` build overrides | Keep generated SDK package build targets explicit. | Some generated packages need serialized or file-by-file emit behavior that is not yet generic enough for inference. | | Historical docs links | Leave old blog/changelog links pointing at upstream unless they are current dogfood instructions. | Historical release notes should keep pointing to the original issues and PRs; rewriting them would distort provenance. | @@ -92,6 +95,7 @@ fully independent long-term fork. | Oxlint hygiene plugins | `@oddlynew/alchemy-oxlint-config` does not yet include Oddlynew's custom workspace-hygiene plugins. | Port the plugins once generated/imported code exceptions are known, then ratchet rules package by package. | | TypeScript config strictness | Package configs now use Oddlynew-style solution/source/build boundaries, but the shared compiler options are still less strict than Oddlynew's mature baseline. | Tighten after imported package drift is resolved, not during the structural migration. | | Example and fixture targets | Examples and Cloudflare Tools fixtures still keep package-local scripts and are excluded from production CI. | Decide which examples are hermetic smoke tests, then infer or model them as first-class Nx targets. | +| Website docs gate | `@oddlynew/alchemy-website` still has a cacheable `docs:check` target, but CI does not run it by default. | Re-enable it as a separate `ubuntu-latest` docs job, or keep it manual, once website publishing matters to the fork's required validation story. | | Root Alchemy smoke test | `test/smoke.test.ts` remains at the repository root. | Either keep it documented as cross-product smoke infrastructure or move it under `projects/alchemy` with a dedicated Nx target. | | Website links | Historical docs and blog pages still contain upstream GitHub links. Current navigation and edit links point at Oddlynew. | Keep historical release provenance upstream; only rewrite active dogfood navigation, install, and edit links. | | Distilled explicit build targets | Generated packages use small `project.json` files for serialized `tsconfig.build.json` emits. | If the pattern stabilizes, move it into the custom Nx plugin so package roots stay script-first. | diff --git a/package.json b/package.json index 61a8361cd..627a31a55 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,11 @@ "@types/aws-lambda": "^8.10.152", "@types/bun": "^1.3.14", "@types/node": "^25.9.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@typescript/native-preview": "^7.0.0-dev.20260618.1", + "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-rsc": "^0.5.27", "ai": "^6.0.62", "aws4fetch": "^1.0.20", "better-auth": "^1.6.2", @@ -124,11 +128,18 @@ "oxlint": "^1.69.0", "oxlint-tsgolint": "^0.23.0", "pathe": "^2.0.3", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "7.16.0", "@commitlint/cli": "^20.4.2", "@commitlint/config-conventional": "^20.4.2", "@nx/devkit": "23.0.0-rc.3", "@nx/js": "23.0.0-rc.3", - "@nx/vitest": "23.0.0-rc.3" + "@nx/vitest": "23.0.0-rc.3", + "@tailwindcss/typography": "^0.5.20", + "@tailwindcss/vite": "^4.3.1", + "rsc-html-stream": "^0.0.7", + "tailwindcss": "^4.3.1" } }, "devDependencies": { diff --git a/projects/alchemy/apps/website/src/content/docs/concepts/local-development.mdx b/projects/alchemy/apps/website/src/content/docs/concepts/local-development.mdx index a6a48279f..27bdc0226 100644 --- a/projects/alchemy/apps/website/src/content/docs/concepts/local-development.mdx +++ b/projects/alchemy/apps/website/src/content/docs/concepts/local-development.mdx @@ -31,9 +31,12 @@ Three things happen: emulation, no fidelity gaps. 2. **Workers run locally in workerd** — your code executes in workerd, the same runtime used in production. A proxy routes - requests between the cloud and your local process. -3. **File changes hot reload** — edit your code and the Worker - rebuilds instantly. + requests to your local process. +3. **Vite apps use Vite's dev server** — `Cloudflare.Vite` starts a + real Vite server behind Alchemy's local Worker proxy, so client and + server module edits are served by Vite. +4. **Worker changes hot reload** — non-Vite Workers rebuild and restart + locally when their source changes. ## Hot module reloading @@ -52,6 +55,12 @@ Three things happen: application code is rebuilt - Edit, save, refresh +For `Cloudflare.Vite`, Vite owns the framework dev server and HMR +loop. Alchemy owns the resource graph and Worker binding configuration +around that Vite dev server. If you change `alchemy.run.ts` so a Vite +Worker receives a different binding, `alchemy dev` re-applies the stack +and restarts the local Worker with the updated binding set. + ## Why not emulate everything? Full local emulation (Miniflare, LocalStack) replicates cloud APIs @@ -86,6 +95,40 @@ Resources adapt their behavior in dev mode: Each resource's provider checks `ALCHEMY_PHASE` (set to `dev`) and adjusts accordingly. +## Binding behavior + +Bindings are still declared on the Worker with `env`; that is the same +object your Worker receives as its runtime `env` parameter. In local +development Alchemy maps each binding to the closest Cloudflare runtime +behavior. Common examples include: + +| Example binding kind | `alchemy dev` behavior | +| ------------------------------------ | -------------------------------------- | +| Durable Objects, Workflows, Queues | local runtime binding | +| Static assets, text/json/data/wasm | local runtime binding | +| Service bindings between Workers | local Worker-to-Worker binding | +| R2, KV, D1, AI, Images, Vectorize | remote-backed Cloudflare binding | +| Secrets Store and secret key handles | unsupported in local mode | + +Plain `secret_text` bindings still bind locally; Secrets Store and +secret-key handles do not. + +This gives Vite apps the same runtime shape in dev and deploy: + +```typescript +const bucket = yield* Cloudflare.R2Bucket("Uploads"); + +const app = yield* Cloudflare.Vite("App", { + env: { + BUCKET: bucket, + }, +}); +``` + +In the Worker, `env.BUCKET` is the standard Cloudflare R2 binding. In +`alchemy dev` it talks to the real bucket while the Worker code and +Vite HMR stay local. + ## Custom port ```typescript diff --git a/projects/alchemy/apps/website/src/content/docs/guides/frontends.mdx b/projects/alchemy/apps/website/src/content/docs/guides/frontends.mdx index 5c05f4429..184e7094d 100644 --- a/projects/alchemy/apps/website/src/content/docs/guides/frontends.mdx +++ b/projects/alchemy/apps/website/src/content/docs/guides/frontends.mdx @@ -1,22 +1,29 @@ --- title: Frontend frameworks -description: Deploy Vite-based frameworks (TanStack Start, Astro, SolidStart, Nuxt, React) and any custom-built static site (Hugo, Eleventy) to Cloudflare with one declaration. +description: Deploy Vite-based frameworks (TanStack Start, SolidStart, Nuxt, React) and any custom-built static site (Astro, Hugo, Eleventy) to Cloudflare with one declaration. sidebar: order: 5 --- -`Cloudflare.Vite` covers TanStack Start, Astro, SolidStart, Nuxt, and -plain React. `Cloudflare.StaticSite` wraps any custom build pipeline -— Hugo, Eleventy, mdBook, Jekyll. One declaration. Alchemy builds -and deploys to Cloudflare. +`Cloudflare.Vite` covers apps where Alchemy can own the Vite dev/build +call directly, such as TanStack Start, SolidStart, Nuxt, and plain React. +`Cloudflare.StaticSite` wraps frameworks with their own CLI or adapter +pipeline — Astro, Hugo, Eleventy, mdBook, Jekyll. One declaration. +Alchemy builds and deploys to Cloudflare. ## Vite-based frameworks -`Cloudflare.Vite` uses Cloudflare's Vite plugin to build the server -bundle and client assets in one `vite build`. No manual entrypoint, -no Wrangler config, no asset directory plumbing. Inputs are +`Cloudflare.Vite` injects Alchemy's Cloudflare Vite integration to +build the server bundle and client assets in one `vite build`. No +manual entrypoint, no Wrangler config, no asset directory plumbing. Inputs are content-hashed so unchanged projects skip the build entirely. +Keep your app's `vite.config.ts` focused on framework and app plugins. +Do not add `@cloudflare/vite-plugin` or +`@oddlynew/distilled-cloudflare-vite-plugin` yourself; `Cloudflare.Vite` +injects the Cloudflare integration when Alchemy runs Vite for dev and +deploy. + **TanStack Start (SSR)** ```typescript @@ -27,15 +34,6 @@ export const App = Cloudflare.Vite("App", { }); ``` -**Astro** - -```typescript -import * as Cloudflare from "@oddlynew/alchemy/Cloudflare"; - -// Astro builds via Vite — one declaration, static or SSR. -export const Site = Cloudflare.Vite("Site"); -``` - **React + Vite** ```typescript @@ -53,11 +51,31 @@ export const App = Cloudflare.Vite("App"); `.gitignore` by default). Unchanged projects skip both build and deploy. +## Astro and static sites + +For static Astro sites, use `Cloudflare.StaticSite` and let Astro own +its build pipeline: + +```typescript +import * as Cloudflare from "@oddlynew/alchemy/Cloudflare"; + +export const Site = Cloudflare.StaticSite("Site", { + command: "bun astro build", + outdir: "dist", +}); +``` + +Astro is built on Vite, but its CLI and adapter pipeline still own the +build orchestration. `Cloudflare.Vite` interacts with Vite +programmatically, which does not currently match that Astro pipeline well. +Prefer `Cloudflare.StaticSite` unless you have a tested SSR-specific +adapter path. + ## Static sites with any build pipeline -For everything Vite doesn't build, `Cloudflare.StaticSite` runs a -shell command, hashes the output directory, and deploys the result -as a Worker with static assets: +For sites where a framework CLI should own the build, `Cloudflare.StaticSite` +runs a shell command, hashes the output directory, and deploys the result as +a Worker with static assets: ```typescript import * as Cloudflare from "@oddlynew/alchemy/Cloudflare"; @@ -77,7 +95,8 @@ of files. The Worker that powers your SSR app can bind to R2, KV, D1, DynamoDB — anything you've declared elsewhere in the stack. Server -functions and loaders read a typed `env` with no extra wiring. +functions, loaders, and route handlers read a typed `env` with no +extra wiring. ```typescript // Frontend Workers can bind to backend resources. @@ -92,3 +111,9 @@ export const App = Cloudflare.Vite("App", { // env.Bucket.get(key) // typed via Cloudflare.InferEnv // env.DB.prepare(sql) ``` + +During `alchemy dev`, Vite still owns the framework dev server and HMR +loop. Alchemy starts the Worker locally and injects the runtime +bindings declared in `env`. Local-capable bindings such as Durable +Objects run locally; remote-capable bindings such as R2, KV, and D1 +connect to the real Cloudflare resources. diff --git a/projects/alchemy/apps/website/src/content/docs/tutorial/cloudflare/vite-spa.mdx b/projects/alchemy/apps/website/src/content/docs/tutorial/cloudflare/vite-spa.mdx index 73e8bb606..532169867 100644 --- a/projects/alchemy/apps/website/src/content/docs/tutorial/cloudflare/vite-spa.mdx +++ b/projects/alchemy/apps/website/src/content/docs/tutorial/cloudflare/vite-spa.mdx @@ -216,13 +216,14 @@ const web = yield* Cloudflare.Vite("Website", { ``` Your existing `vite.config.ts`, plugins, aliases, and `tsconfig` -are all preserved — Alchemy merges its Cloudflare integration on -top of your config. Two things to check first: - -:::caution[Remove @cloudflare/vite-plugin if present] -Remove @cloudflare/vite-plugin if present from your `vite.config.ts`. -Alchemy ships its own Cloudflare integration and is **not compatible** with `@cloudflare/vite-plugin`. +are all preserved — Alchemy injects its Cloudflare integration when +it runs Vite. Two things to check first: +:::caution[Remove Cloudflare Vite plugins if present] +Remove `@cloudflare/vite-plugin` or +`@oddlynew/distilled-cloudflare-vite-plugin` if present from your +`vite.config.ts`. Alchemy ships its own Cloudflare integration and +injects it when `Cloudflare.Vite` runs Vite. ```diff lang="typescript" // vite.config.ts @@ -316,9 +317,10 @@ its API share one origin in dev, just like they do in production: ``` Edit `src/main.tsx`, save, and the browser updates without a full -reload. Backend resources (R2 buckets, D1 databases, service -bindings) are the **real** cloud resources — only your application -code runs locally, so there's no emulation fidelity gap. +reload. Remote-backed account resources such as R2 buckets, D1 +databases, and KV namespaces are the **real** cloud resources. Service +bindings between Workers in the same dev stack stay local +Worker-to-Worker bindings. The build-time env injection works the same in dev — `VITE_`-prefixed entries are inlined as `import.meta.env.`, and `Output` values @@ -380,9 +382,9 @@ handle to those resources. The walkthrough below uses a TanStack Start route handler because a pure SPA only ships static assets — there's no server-side `env` -to call. The same `bindings` map and the same three call patterns -work in any Vite framework that exposes server routes (SolidStart, -Astro SSR, etc.). +to call. The same `env` map and the same three call patterns work in +any Vite framework that exposes server routes (SolidStart, Astro SSR, +etc.). Start with the simplest backend: an R2 bucket. @@ -399,7 +401,7 @@ something binds to it. ## Bind the bucket to your worker -Add the bucket to the Vite worker's `bindings` map: +Add the bucket to the Vite worker's `env` map: ```diff lang="typescript" // alchemy.run.ts diff --git a/projects/alchemy/packages/alchemy/package.json b/projects/alchemy/packages/alchemy/package.json index 79fa73180..309b56107 100644 --- a/projects/alchemy/packages/alchemy/package.json +++ b/projects/alchemy/packages/alchemy/package.json @@ -516,15 +516,22 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@oxc-project/types": "^0.127.0", + "@tanstack/react-router": "^1.167.4", + "@tanstack/react-start": "^1.166.15", "@types/aws-lambda": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", "@types/pg": "^8.11.0", "@types/picomatch": "^4.0.0", "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", + "@vitejs/plugin-react": "catalog:", + "@vitejs/plugin-rsc": "catalog:", "better-auth": "catalog:", "effect": "catalog:", + "react-dom": "^19.2.7", + "react-router": "catalog:", "react-devtools-core": "^7.0.1", "solid-js": "catalog:", "tsconfig-paths": "^4.2.0", diff --git a/projects/alchemy/packages/alchemy/src/AWS/Providers.ts b/projects/alchemy/packages/alchemy/src/AWS/Providers.ts index 9a5877fd1..433572dd9 100644 --- a/projects/alchemy/packages/alchemy/src/AWS/Providers.ts +++ b/projects/alchemy/packages/alchemy/src/AWS/Providers.ts @@ -18,6 +18,7 @@ import * as Schedule from "effect/Schedule"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { CredentialsStoreLive } from "../Auth/Credentials.ts"; import { Command, CommandProvider } from "../Build/Command.ts"; +import { ExecProvider } from "../Build/Exec.ts"; import { KeyPair, KeyPairProvider } from "../KeyPair.ts"; import * as Provider from "../Provider.ts"; import { Random, RandomProvider } from "../Random.ts"; @@ -590,6 +591,7 @@ export const providers = () => Layer.provideMerge( Layer.mergeAll( CommandProvider(), + ExecProvider(), KeyPairProvider(), RandomProvider(), Assets.AssetsLive, diff --git a/projects/alchemy/packages/alchemy/src/Build/Exec.ts b/projects/alchemy/packages/alchemy/src/Build/Exec.ts new file mode 100644 index 000000000..954a32951 --- /dev/null +++ b/projects/alchemy/packages/alchemy/src/Build/Exec.ts @@ -0,0 +1,194 @@ +import * as Effect from "effect/Effect"; +import type { FileSystem } from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { sha256Object } from "../Util/sha256.ts"; +import { runBuildCommand } from "./Command.ts"; +import { hashDirectory, type MemoOptions } from "./Memo.ts"; + +export interface ExecProps { + /** + * The shell command to run. The command must be safe to re-run: the engine + * may execute it again after a crash between the run and state + * persistence, and any change to the command itself triggers a run. + * @example "bun run src/seed.ts" + */ + command: string; + /** + * Working directory for the command. + * Defaults to the current working directory. + */ + cwd?: string; + /** + * Controls which files are hashed to decide whether the command should + * re-run. By default every non-gitignored file in `cwd` is hashed, plus + * the nearest lockfile. Provide explicit globs to narrow the scope. + * + * Exclude (or gitignore) files the command itself writes inside `cwd` — + * otherwise each run changes the hash and re-triggers the next one. + * + * @see {@link MemoOptions} + */ + memo?: MemoOptions; + /** + * Environment variables to pass to the command. Changes to these values + * re-run the command (secret values are folded into the stored run key as + * a one-way hash, so a rotated secret re-runs too). + */ + env?: Record>; +} + +export interface Exec extends Resource< + "Build.Exec", + ExecProps, + { + /** + * Hash of the run inputs — memoized files, command, cwd, and env — from + * the last completed run. + */ + hash: string; + } +> {} + +/** + * A Build resource that runs a shell command for its side effects, + * re-running it whenever its inputs change — the hashed memo files, the + * command itself, `cwd`, or `env`. Unlike {@link Command} it produces no + * output artifact: there is no `outdir`, and deleting the resource only + * forgets the run key — it never touches the file system. The command's + * side effects (database rows, cache entries, …) are the command's own; + * it must be idempotent. + * + * Use `Exec` for effectful steps like database seeds and migrations, and + * `Command` for builds whose product is a directory of files. + * + * @section Seeding a Database + * @example Re-seed When the Seed Script Changes + * ```typescript + * const seed = yield* Build.Exec("seed", { + * command: "bun run src/seed.ts", + * cwd: "./database", + * env: { DATABASE_URL: database.connectionUrl }, + * memo: { include: ["src/seed.ts"] }, + * }); + * yield* Console.log(seed.hash); // hash of the run inputs + * ``` + * + * @section Sequencing Commands + * @example Seed After Migrations + * ```typescript + * const migrations = yield* Build.Exec("migrate", { + * command: "drizzle-kit migrate", + * cwd: "./database", + * env: { DATABASE_URL: database.connectionUrl }, + * memo: { include: ["migrations/**"] }, + * }); + * + * const seed = yield* Build.Exec("seed", { + * command: "bun run src/seed.ts", + * cwd: "./database", + * env: { + * DATABASE_URL: database.connectionUrl, + * // Threading the hash orders seeding after migrations and re-seeds + * // whenever the migrations re-ran. + * MIGRATIONS_HASH: migrations.hash, + * }, + * memo: { include: ["src/seed.ts"] }, + * }); + * ``` + */ +export const Exec = Resource("Build.Exec"); + +export const ExecProvider = () => + Provider.effect< + Exec, + Path.Path, + never, + FileSystem | Path.Path, + never, + ChildProcessSpawner | FileSystem | Path.Path + >( + Exec, + Effect.gen(function* () { + const pathModule = yield* Path.Path; + + const resolveEnv = (env: ExecProps["env"]) => + env + ? Object.fromEntries( + Object.entries(env).map(([key, value]) => [ + key, + typeof value === "string" ? value : Redacted.value(value), + ]), + ) + : undefined; + + // The run key covers everything that determines what the command + // would do: the memoized input files AND the command, cwd, and env. + // Hashing files alone would silently skip a run when only the + // command or its environment changed (e.g. a recreated database's + // connection URL). `cwd` is hashed as given, not resolved — absolute + // paths in persisted state would break across machines/checkouts. + const execHash = Effect.fnUntraced(function* (props: ExecProps) { + const files = yield* hashDirectory(props); + return yield* sha256Object({ + files, + command: props.command, + cwd: props.cwd ?? null, + env: resolveEnv(props.env) ?? null, + }); + }); + + const runExec = (props: ExecProps) => + Effect.scoped( + runBuildCommand({ + command: props.command, + cwd: props.cwd ? pathModule.resolve(props.cwd) : process.cwd(), + env: resolveEnv(props.env), + }), + ); + + return Exec.Provider.of({ + list: () => Effect.succeed([]), + diff: Effect.fnUntraced(function* ({ news, output }) { + if (!isResolved(news)) return undefined; + if (!output) { + return undefined; + } + const newHash = yield* execHash(news); + if (newHash !== output.hash) { + return { action: "update" as const }; + } + }), + reconcile: Effect.fnUntraced(function* ({ news, output, session }) { + // Observe — the previous run's `output.hash` records the inputs + // the command last completed against; recompute to detect drift. + const desiredHash = yield* execHash(news); + + // Ensure — run the command whenever it has never run or any of + // its inputs changed. + if (output === undefined || output.hash !== desiredHash) { + yield* session.note( + output === undefined + ? `Running command: ${news.command}` + : `Re-running command: ${news.command}`, + ); + yield* runExec(news); + yield* session.note(`Command completed: ${news.command}`); + } + + // Return — the run key the command last completed against. + return { + hash: desiredHash, + }; + }), + delete: Effect.fnUntraced(function* () { + // The command's side effects are not the engine's to reverse. + // Deleting the resource just forgets the run key. + }), + }); + }), + ); diff --git a/projects/alchemy/packages/alchemy/src/Build/index.ts b/projects/alchemy/packages/alchemy/src/Build/index.ts index ec1d68981..fa3674f2a 100644 --- a/projects/alchemy/packages/alchemy/src/Build/index.ts +++ b/projects/alchemy/packages/alchemy/src/Build/index.ts @@ -1 +1,2 @@ export * from "./Command.ts"; +export * from "./Exec.ts"; diff --git a/projects/alchemy/packages/alchemy/src/Bundle/Bundle.ts b/projects/alchemy/packages/alchemy/src/Bundle/Bundle.ts index d870f5b63..c225e86e5 100644 --- a/projects/alchemy/packages/alchemy/src/Bundle/Bundle.ts +++ b/projects/alchemy/packages/alchemy/src/Bundle/Bundle.ts @@ -4,6 +4,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import assert from "node:assert"; +import nodePath from "node:path"; import * as rolldown from "rolldown"; import { sha256, sha256Object } from "../Util/sha256.ts"; import { @@ -57,6 +58,7 @@ export interface BundleFile { readonly path: string; readonly content: string | Uint8Array; readonly hash: string; + readonly contentType?: string; } export class BundleError extends Schema.TaggedErrorClass()( @@ -319,12 +321,38 @@ export function bundleOutputFromFiles( files.map((file) => ({ path: file.path, hash: file.hash, + contentType: file.contentType, })), ), (hash) => ({ files, hash }), ); } +export const contentTypeFromPath = (filePath: string) => { + switch (nodePath.extname(filePath)) { + case ".wasm": + return "application/wasm"; + case ".txt": + case ".html": + case ".sql": + case ".custom": + return "text/plain"; + case ".bin": + return "application/octet-stream"; + case ".json": + return "application/json"; + case ".mjs": + case ".js": + return "application/javascript+module"; + case ".cjs": + return "application/javascript"; + case ".map": + return "application/source-map"; + default: + return "application/octet-stream"; + } +}; + function bundleFileFromOutputChunk( chunk: rolldown.OutputChunk | rolldown.OutputAsset, ): Effect.Effect { @@ -334,12 +362,14 @@ function bundleFileFromOutputChunk( path: chunk.fileName, content: chunk.code, hash, + contentType: contentTypeFromPath(chunk.fileName), })); case "asset": return Effect.map(sha256(chunk.source), (hash) => ({ path: chunk.fileName, content: chunk.source, hash, + contentType: contentTypeFromPath(chunk.fileName), })); } } diff --git a/projects/alchemy/packages/alchemy/src/Cloudflare/LocalRuntime.ts b/projects/alchemy/packages/alchemy/src/Cloudflare/LocalRuntime.ts index a2a68af45..e68980176 100644 --- a/projects/alchemy/packages/alchemy/src/Cloudflare/LocalRuntime.ts +++ b/projects/alchemy/packages/alchemy/src/Cloudflare/LocalRuntime.ts @@ -10,6 +10,11 @@ import { CloudflareEnvironment } from "./CloudflareEnvironment.ts"; import type { Queue } from "./Queue/Queue.ts"; import type { QueueConsumer } from "./Queue/QueueConsumer.ts"; +export type LocalWorkerReload = { + readonly id: string; + readonly reload: (reason: string) => Effect.Effect; +}; + export const LOCAL_ENTRY_URL = import.meta.resolve( // `import.meta.resolve()` is a runtime API — TypeScript's // `rewriteRelativeImportExtensions` does NOT touch the string literal, so @@ -32,6 +37,14 @@ export class LocalRuntimeState extends Context.Service< QueueConsumer["Attributes"]["consumerId"], QueueConsumer["Attributes"] >; + readonly workerReloads: MutableHashMap.MutableHashMap< + string, + LocalWorkerReload + >; + readonly pendingWorkerReloads: MutableHashMap.MutableHashMap< + string, + string + >; } >()("alchemy/cloudflare/LocalRuntimeState") {} @@ -40,6 +53,8 @@ const LocalRuntimeStateLive = Layer.succeed( LocalRuntimeState.of({ queues: MutableHashMap.empty(), queueConsumers: MutableHashMap.empty(), + workerReloads: MutableHashMap.empty(), + pendingWorkerReloads: MutableHashMap.empty(), }), ); diff --git a/projects/alchemy/packages/alchemy/src/Cloudflare/Providers.ts b/projects/alchemy/packages/alchemy/src/Cloudflare/Providers.ts index afe67fbe5..e71d16882 100644 --- a/projects/alchemy/packages/alchemy/src/Cloudflare/Providers.ts +++ b/projects/alchemy/packages/alchemy/src/Cloudflare/Providers.ts @@ -778,6 +778,7 @@ export const providers = () => LoadBalancer.LoadBalancerMonitorGroupProvider(), LoadBalancer.LoadBalancerPoolProvider(), Build.CommandProvider(), + Build.ExecProvider(), KeyPairProvider(), RandomProvider(), ), diff --git a/projects/alchemy/packages/alchemy/src/Cloudflare/Queue/QueueConsumer.ts b/projects/alchemy/packages/alchemy/src/Cloudflare/Queue/QueueConsumer.ts index 16c775dfd..aef0cc93c 100644 --- a/projects/alchemy/packages/alchemy/src/Cloudflare/Queue/QueueConsumer.ts +++ b/projects/alchemy/packages/alchemy/src/Cloudflare/Queue/QueueConsumer.ts @@ -562,6 +562,45 @@ export const QueueConsumerProviderLocal = () => LOCAL_ENTRY_URL, Effect.gen(function* () { const localRuntimeState = yield* LocalRuntimeState; + const reloadWorker = Effect.fnUntraced(function* ( + scriptName: string, + reason: string, + ) { + const registration = MutableHashMap.get( + localRuntimeState.workerReloads, + scriptName, + ).pipe(Option.getOrUndefined); + if (registration) { + yield* registration.reload(reason); + } else { + MutableHashMap.set( + localRuntimeState.pendingWorkerReloads, + scriptName, + reason, + ); + } + }); + const syncConsumer = Effect.fnUntraced(function* ( + consumer: QueueConsumer["Attributes"], + reason: string, + fallbackPrevious?: QueueConsumer["Attributes"], + ) { + const previous = + MutableHashMap.get( + localRuntimeState.queueConsumers, + consumer.consumerId, + ).pipe(Option.getOrUndefined) ?? fallbackPrevious; + MutableHashMap.set( + localRuntimeState.queueConsumers, + consumer.consumerId, + consumer, + ); + if (queueConsumerEquals(previous, consumer)) return; + if (previous && previous.scriptName !== consumer.scriptName) { + yield* reloadWorker(previous.scriptName, reason); + } + yield* reloadWorker(consumer.scriptName, reason); + }); return { list: () => Effect.sync(() => @@ -587,11 +626,7 @@ export const QueueConsumerProviderLocal = () => } // If the resource is a noop, add it to the local runtime state so it's available downstream. // We do it here instead of in the reconcile function so it doesn't appear as an update. - MutableHashMap.set( - localRuntimeState.queueConsumers, - output.consumerId, - output, - ); + yield* syncConsumer(output, "queue consumer restored"); return { action: "noop" }; }), read: Effect.fn(function* ({ output }) { @@ -611,23 +646,37 @@ export const QueueConsumerProviderLocal = () => accountId, settings: news.settings, }; - MutableHashMap.set( - localRuntimeState.queueConsumers, - consumer.consumerId, - consumer, - ); + yield* syncConsumer(consumer, "queue consumer changed", output); return consumer; }), delete: Effect.fn(function* ({ output }) { + const previous = + MutableHashMap.get( + localRuntimeState.queueConsumers, + output.consumerId, + ).pipe(Option.getOrUndefined) ?? output; MutableHashMap.remove( localRuntimeState.queueConsumers, output.consumerId, ); + yield* reloadWorker(previous.scriptName, "queue consumer deleted"); }), }; }), ); +const queueConsumerEquals = ( + left: QueueConsumer["Attributes"] | undefined, + right: QueueConsumer["Attributes"], +) => + left !== undefined && + left.consumerId === right.consumerId && + left.queueId === right.queueId && + left.scriptName === right.scriptName && + left.accountId === right.accountId && + left.deadLetterQueue === right.deadLetterQueue && + JSON.stringify(left.settings ?? {}) === JSON.stringify(right.settings ?? {}); + export const QueueConsumerProvider = () => ProviderLayer.select({ local: () => QueueConsumerProviderLocal(), diff --git a/projects/alchemy/packages/alchemy/src/Cloudflare/Website/Vite.ts b/projects/alchemy/packages/alchemy/src/Cloudflare/Website/Vite.ts index d1500acaa..65d1fbb01 100644 --- a/projects/alchemy/packages/alchemy/src/Cloudflare/Website/Vite.ts +++ b/projects/alchemy/packages/alchemy/src/Cloudflare/Website/Vite.ts @@ -11,6 +11,7 @@ import { type WorkerBindingProps, type WorkerProps, } from "../Workers/Worker.ts"; +import type { CloudflareVitePluginOptionsWithAssets } from "../Workers/Vite.ts"; export interface ViteProps< Bindings extends WorkerBindingProps = {}, > extends Omit, "vite" | "main" | "assets"> { @@ -32,6 +33,11 @@ export interface ViteProps< * Supports `runWorkerFirst`, `htmlHandling`, `notFoundHandling`, etc. */ assets?: AssetsConfig; + /** + * Advanced Vite environment topology for Worker builds. RSC apps usually + * run the Worker in the `rsc` environment and load `ssr` as a child. + */ + viteEnvironment?: CloudflareVitePluginOptionsWithAssets["viteEnvironment"]; } /** @@ -49,6 +55,26 @@ export interface ViteProps< * @product Website * @category Workers & Compute * + * @section Vite Config vs Cloudflare.Vite + * Keep framework configuration in `vite.config.ts`: React, Vue, Tailwind, + * React Router/RSC plugins, framework entries, and extra Vite build inputs + * belong there. + * + * Keep Cloudflare and Alchemy configuration in `Cloudflare.Vite`: resource + * bindings, compatibility flags, asset routing, and Worker environment + * topology belong here. + * + * Do not add `@oddlynew/distilled-cloudflare-vite-plugin` manually to your Vite + * config when using `Cloudflare.Vite`. Alchemy loads the app's normal Vite + * config and injects the distilled Cloudflare Vite plugin programmatically so + * its options stay aligned with Alchemy's resources, bindings, asset settings, + * compatibility settings, deploy diffs, and local dev runtime. + * + * Plain `vite dev` can still be useful for framework-only work, but it does + * not provide Alchemy-managed Cloudflare bindings. Use `alchemy dev` for the + * authoritative local Worker dev path when the app depends on Alchemy + * resources. + * * @section Deploying a Static Site * For a pure static site (no SSR), a single call is all you need. * Vite builds the project and Alchemy deploys the output as a @@ -79,7 +105,31 @@ export interface ViteProps< * flags: ["nodejs_compat"], * }, * assets: { - * config: { runWorkerFirst: true }, + * runWorkerFirst: true, + * }, + * }); + * ``` + * + * @section React Server Components + * For RSC frameworks that use Vite child environments, pass the Worker + * topology through `viteEnvironment`. Alchemy requires the distilled build + * manifest for this topology so it can upload the full Worker module set. The + * framework's RSC entries still belong in `vite.config.ts`; `viteEnvironment` + * tells Alchemy which Vite environment is the Cloudflare Worker and which child + * environments must be available to it at runtime. + * Keep the app's `vite.config.ts` focused on framework plugins; do not add a + * Cloudflare Vite plugin there. `Cloudflare.Vite` injects the Cloudflare + * integration when it runs Vite for dev and deploy. + * + * @example RSC topology + * ```typescript + * const app = yield* Cloudflare.Vite("ReactRouter", { + * compatibility: { + * flags: ["nodejs_compat"], + * }, + * viteEnvironment: { + * name: "rsc", + * childEnvironments: ["ssr"], * }, * }); * ``` @@ -95,10 +145,77 @@ export interface ViteProps< * flags: ["nodejs_compat"], * }, * assets: { - * config: { - * htmlHandling: "auto-trailing-slash", - * notFoundHandling: "single-page-application", - * }, + * htmlHandling: "auto-trailing-slash", + * notFoundHandling: "single-page-application", + * }, + * }); + * ``` + * + * @section Vite Worker With Durable Objects + * For Vite apps that own their Worker entrypoint, configure the entry in the + * Vite project (usually via the framework plugin, or `environments.ssr.build` + * for custom apps). Export the default Worker handler and any local Durable + * Object classes from that Vite entry. Alchemy deploys the Vite-built Worker + * module set and attaches the bindings, Durable Object metadata, migrations, + * compatibility settings, and assets to the same Worker script. + * Declare each local Durable Object with `Cloudflare.DurableObjectNamespace` in + * `env`; exporting the class from the Vite entry makes it available to the + * Worker module, while the `env` binding is what gives Alchemy ownership of the + * namespace and migrations. + * + * @example One Worker With A Local Durable Object + * ```typescript + * // alchemy.run.ts + * import type { Counter } from "./src/worker.ts"; + * + * const app = yield* Cloudflare.Vite("App", { + * env: { + * Counter: Cloudflare.DurableObjectNamespace("Counter", { + * className: "Counter", + * }), + * }, + * assets: { + * runWorkerFirst: ["/api/*"], + * }, + * }); + * + * // src/worker.ts + * import { DurableObject } from "cloudflare:workers"; + * + * export class Counter extends DurableObject { + * async increment() { + * return 1; + * } + * } + * + * export default { + * async fetch(request, env) { + * const count = await env.Counter.getByName("main").increment(); + * return Response.json({ count }); + * }, + * }; + * ``` + * + * @section Local Development With Bindings + * During `alchemy dev`, Vite still owns the dev server and HMR loop. + * Alchemy starts the Worker locally through the Cloudflare runtime and + * resolves the `env` map into the runtime binding hooks used by the Vite + * Cloudflare plugin. Binding types with a local runtime implementation, such + * as Durable Objects, Queues, Workflows, service bindings, and static assets, + * run locally. Binding types that Cloudflare exposes through remote bindings, + * such as R2, KV, D1, AI, Images, Vectorize, and similar account resources, + * connect to the real Cloudflare resource. + * + * @example Vite Worker With R2 In Local Dev + * ```typescript + * const bucket = yield* Cloudflare.R2Bucket("Uploads"); + * + * const app = yield* Cloudflare.Vite("App", { + * env: { + * BUCKET: bucket, + * }, + * assets: { + * runWorkerFirst: ["/api/*"], * }, * }); * ``` @@ -170,13 +287,17 @@ export const Vite: { id, Effect.map( Effect.isEffect(propsEff) ? propsEff : Effect.succeed(propsEff), - (props) => ({ - ...props, - main: undefined!, - vite: { - rootDir: props?.rootDir, - memo: props?.memo, - }, - }), + (props) => { + const viteEnvironment = props?.viteEnvironment; + return { + ...props, + main: undefined!, + vite: { + rootDir: props?.rootDir, + memo: props?.memo, + viteEnvironment, + }, + }; + }, ), )) as any; diff --git a/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts b/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts index bc858fec4..2d2d870d8 100644 --- a/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts +++ b/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts @@ -57,7 +57,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import type * as Bundle from "../../Bundle/Bundle.ts"; -import { isResolved } from "../../Diff.ts"; +import { havePropsChanged, isResolved } from "../../Diff.ts"; import * as RpcProvider from "../../Local/RpcProvider.ts"; import type { ResourceBinding } from "../../Resource.ts"; import { Stack } from "../../Stack.ts"; @@ -84,6 +84,18 @@ export class WorkerValidationError extends Schema.TaggedErrorClass + url.toString().replace(/\/$/, ""); + +const normalizeWorkerDevOptions = (dev: WorkerPropsWithDev["dev"]) => { + const options = dev ?? {}; + return { + ...options, + // This is the default. Vite and cloudflare-runtime will retry if unavailable, unless `strictPort` is true. + port: options.port ?? 1337, + }; +}; + export const LocalWorkerProvider = () => RpcProvider.effect( Worker, @@ -241,21 +253,30 @@ export const LocalWorkerProvider = () => const { accountId } = yield* yield* CloudflareEnvironment; const name = yield* createWorkerName(id, props.name); const compatibility = getCompatibility(props); + const nativeBindingNames = new Set( + bindings.flatMap(({ data }) => + (data.bindings ?? []).map((binding) => binding.name), + ), + ); const workerBindings: BindingHook[] = [ Text.local("ALCHEMY_PHASE", "runtime"), Text.local("ALCHEMY_STACK_NAME", stack.name), Text.local("ALCHEMY_STAGE", stack.stage), Text.local("ALCHEMY_CLOUDFLARE_ACCOUNT_ID", accountId), - ...Object.entries(props.env ?? {}).map(([key, value]) => { + ...Object.entries(props.env ?? {}).flatMap(([key, value]) => { + if (nativeBindingNames.has(key) || value === undefined) return []; const unredacted = Redacted.isRedacted(value) ? Redacted.value(value) : value; - return typeof unredacted === "string" - ? Text.local(key, unredacted) - : Json.local(key, unredacted); + return [ + typeof unredacted === "string" + ? Text.local(key, unredacted) + : Json.local(key, unredacted), + ]; }), ...(props.assets || props.vite ? [Assets.local("ASSETS")] : []), ]; + const bindingModes: RuntimeBindingMode[] = []; const durableObjectNamespaces: Record< string, RuntimeDurableObjectNamespace & { uniqueKey: string } @@ -280,6 +301,7 @@ export const LocalWorkerProvider = () => uniqueKey: namespaceId, sql: true, }; + bindingModes.push(runtimeBindingMode(binding)); workerBindings.push( yield* toRuntimeBinding({ ...binding, @@ -287,6 +309,7 @@ export const LocalWorkerProvider = () => }), ); } else { + bindingModes.push(runtimeBindingMode(binding)); workerBindings.push(yield* toRuntimeBinding(binding)); } } @@ -330,6 +353,7 @@ export const LocalWorkerProvider = () => compatibility, workerBindings, durableObjectNamespaces: Object.values(durableObjectNamespaces), + bindingModes, hyperdrives, env: props.env, bundleOptions: { @@ -343,11 +367,7 @@ export const LocalWorkerProvider = () => extraOptions: props.build, } satisfies WorkerBundleOptions, assets: props.assets, - dev: { - ...props.dev, - // This is the default. Vite and cloudflare-runtime will retry if unavailable, unless `strictPort` is true. - port: props.dev?.port ?? 1337, - }, + dev: normalizeWorkerDevOptions(props.dev), }; }); @@ -357,6 +377,7 @@ export const LocalWorkerProvider = () => let start = Date.now(); let status: "start" | "update" = "start"; const proxy = yield* maybeStartProxy(worker.id, worker.dev); + yield* logBindingModes(worker); yield* bundler.watch(worker.bundleOptions).pipe( Stream.tap((event) => { if (event._tag === "Start") { @@ -403,24 +424,26 @@ export const LocalWorkerProvider = () => Stream.runDrain, Effect.forkScoped, ); - return proxy.url; + return normalizeLocalUrl(proxy.url); }); const runVite = Effect.fnUntraced(function* ( worker: WorkerConfig, - rootDir: string | undefined, + vite: NonNullable, ) { const proxy = yield* maybeStartProxy(worker.id, worker.dev); + yield* logBindingModes(worker); yield* proxy.unset().pipe(Effect.forkChild); // Loaded lazily: `./Vite.ts` pulls in `@oddlynew/distilled-cloudflare-vite-plugin` // (~0.5s); only needed when running a vite dev server. const Vite = yield* Effect.promise(() => import("./Vite.ts")); const devServer = yield* Vite.viteDev( - rootDir, + vite.rootDir, worker.env ?? {}, { compatibilityDate: worker.compatibility.date, compatibilityFlags: worker.compatibility.flags, + viteEnvironment: vite.viteEnvironment, worker: { name: worker.name, bindings: worker.workerBindings, @@ -434,17 +457,24 @@ export const LocalWorkerProvider = () => { port: 0 }, ); yield* proxy.set(new URL(devServer.resolvedUrls!.local[0])); - return proxy.url; + return normalizeLocalUrl(proxy.url); }); const rootScope = yield* Effect.scope; const workerdScopes = new Map(); const context = yield* Effect.context(); + type WorkerInstanceOptions = { + id: string; + props: WorkerPropsWithDev; + bindings: ResourceBinding[]; + config: WorkerConfig; + }; const instances = new Map< string, { hash: number; + options: WorkerInstanceOptions; fiber: Fiber.Fiber< Worker["Attributes"], Bundle.BundleError | WorkerValidationError | RuntimeError @@ -453,16 +483,110 @@ export const LocalWorkerProvider = () => } >(); - const runInstance = Effect.fn(function* (options: { - id: string; - props: WorkerPropsWithDev; - bindings: ResourceBinding[]; - }) { + const stableAttributes = ( + id: string, + output: Worker["Attributes"] | undefined, + name: string, + dev: WorkerPropsWithDev["dev"], + ) => { + const stables: string[] = []; + if (output?.workerName === name) { + stables.push("workerName"); + } + const existingProxy = proxyInstances.get(id); + if ( + output?.url && + existingProxy && + Equal.equals( + existingProxy.serverOptions, + normalizeWorkerDevOptions(dev), + ) + ) { + stables.push("url", "domains"); + } + return stables.length > 0 ? stables : undefined; + }; + + const stableStubAttributes = ( + output: Worker["Attributes"] | undefined, + name: string, + dev: false | string, + ) => { + const stables: string[] = []; + if (output?.workerName === name) { + stables.push("workerName"); + } + const expectedUrl = dev || undefined; + if (output?.url === expectedUrl) { + stables.push("url"); + } + if (output?.domains && output.domains.length === 0) { + stables.push("domains"); + } + return stables.length > 0 ? stables : undefined; + }; + + const stopInstance = Effect.fn(function* ( + id: string, + options: { + stopProxy?: boolean; + unregisterReload?: boolean; + unsetProxy?: boolean; + } = {}, + ) { + const existing = instances.get(id); + if (existing) { + yield* Fiber.interrupt(existing.fiber); + yield* Scope.close(existing.scope, Exit.void); + instances.delete(id); + if (options.unregisterReload !== false) { + MutableHashMap.remove( + localRuntimeState.workerReloads, + existing.options.config.name, + ); + } + } + const workerdScope = workerdScopes.get(id); + if (workerdScope) { + yield* Scope.close(workerdScope, Exit.void); + workerdScopes.delete(id); + } + if (options.unsetProxy) { + const proxy = proxyInstances.get(id); + if (proxy) { + yield* proxy.instance.unset().pipe(Effect.ignore); + } + } + if (options.stopProxy !== false) { + yield* stopProxy(id); + } + }); + + const takePendingReload = (scriptName: string) => { + const reason = MutableHashMap.get( + localRuntimeState.pendingWorkerReloads, + scriptName, + ).pipe(Option.getOrUndefined); + if (reason !== undefined) { + MutableHashMap.remove( + localRuntimeState.pendingWorkerReloads, + scriptName, + ); + } + return reason; + }; + + let restartInstance: ( + id: string, + scriptName: string, + reason: string, + ) => Effect.Effect; + + const runInstance = Effect.fn(function* (options: WorkerInstanceOptions) { const { accountId } = yield* yield* CloudflareEnvironment; - const { props, bindings } = options; - const config = yield* buildConfig(options); + const { props, bindings, config } = options; const url = yield* ( - props.vite ? runVite(config, props.vite.rootDir) : runWorker(config) + props.vite ? runVite(config, props.vite) : runWorker(config) ).pipe(Effect.map((url) => url.toString())); return { workerId: config.name, @@ -484,6 +608,77 @@ export const LocalWorkerProvider = () => } satisfies Worker["Attributes"]; }); + const startInstance = Effect.fn(function* ( + options: WorkerInstanceOptions, + hash: number, + ) { + MutableHashMap.set( + localRuntimeState.workerReloads, + options.config.name, + { + id: options.id, + reload: (reason) => + restartInstance(options.id, options.config.name, reason).pipe( + Effect.asVoid, + ), + }, + ); + const scope = yield* Scope.fork(rootScope); + const fiber = yield* runInstance(options).pipe( + Effect.forkDetach, + Scope.provide(scope), + ); + instances.set(options.id, { hash, options, fiber, scope }); + const pendingReason = takePendingReload(options.config.name); + if (pendingReason !== undefined) { + const restarted = yield* restartInstance( + options.id, + options.config.name, + pendingReason, + ); + if (restarted !== undefined) { + return restarted; + } + } + return yield* Fiber.join(fiber).pipe( + Effect.onExit((exit) => + Effect.sync(() => { + if (exit._tag === "Failure") { + instances.delete(options.id); + MutableHashMap.remove( + localRuntimeState.workerReloads, + options.config.name, + ); + } + }), + ), + ); + }); + + restartInstance = Effect.fn(function* ( + id: string, + scriptName: string, + reason: string, + ) { + const existing = instances.get(id); + if (!existing) { + MutableHashMap.set( + localRuntimeState.pendingWorkerReloads, + scriptName, + reason, + ); + return; + } + yield* Effect.log(`[${id}] Restarting local Worker: ${reason}`); + const { hash, options } = existing; + yield* stopInstance(id, { + stopProxy: false, + unregisterReload: false, + unsetProxy: true, + }); + return yield* startInstance(options, hash); + }); + return { // Local dev provider: there is no cloud enumeration API. The set of // locally running Workers is the in-memory `instances` map; each @@ -495,21 +690,51 @@ export const LocalWorkerProvider = () => (instance) => Fiber.join(instance.fiber), { concurrency: "unbounded" }, ), - diff: Effect.fn(function* ({ id, news, newBindings, output }) { + diff: Effect.fn(function* ({ + id, + olds, + news, + oldBindings, + newBindings, + output, + }) { if (!isResolved(news) || !isResolved(newBindings)) return undefined; + if (news.dev === false || typeof news.dev === "string") { + const name = yield* createWorkerName(id, news.name); + const bindingsChanged = + JSON.stringify(oldBindings ?? []) !== JSON.stringify(newBindings); + const expectedUrl = news.dev || undefined; + if ( + !havePropsChanged(olds, news) && + !bindingsChanged && + output?.workerName === name && + output?.url === expectedUrl + ) { + return { action: "noop" }; + } + return { + action: "update", + stables: stableStubAttributes(output, name, news.dev), + }; + } const options = { id, - props: news, + props: news as WorkerPropsWithDev, bindings: newBindings, }; - const hash = Hash.structure(options); + const hash = yield* buildInstanceHash({ ...options, stack }); if (instances.get(options.id)?.hash === hash) { return { action: "noop" }; } const name = yield* createWorkerName(id, news.name); return { action: "update", - stables: output?.workerName === name ? ["workerName"] : undefined, + stables: stableAttributes( + options.id, + output, + name, + options.props.dev, + ), }; }), precreate: Effect.fn(function* ({ id, news, bindings }) { @@ -558,12 +783,7 @@ export const LocalWorkerProvider = () => // running workerd / proxy behind it. if (news.dev === false || typeof news.dev === "string") { const { accountId } = yield* yield* CloudflareEnvironment; - const existing = instances.get(id); - if (existing) { - yield* Fiber.interrupt(existing.fiber); - yield* Scope.close(existing.scope, Exit.void); - instances.delete(id); - } + yield* stopInstance(id); const name = yield* createWorkerName(id, news.name); return { workerId: name, @@ -578,7 +798,9 @@ export const LocalWorkerProvider = () => } satisfies Worker["Attributes"]; } const options = { id, props: news as WorkerPropsWithDev, bindings }; - const hash = Hash.structure(options); + const config = yield* buildConfig(options); + const hash = yield* buildInstanceHash({ ...options, stack }); + const instanceOptions = { ...options, config }; const existing = instances.get(options.id); if (existing) { if (existing.hash === hash) { @@ -590,38 +812,162 @@ export const LocalWorkerProvider = () => yield* Effect.log( `[${options.id}] Changes detected, interrupting existing instance`, ); - yield* Fiber.interrupt(existing.fiber); - yield* Scope.close(existing.scope, Exit.void); - instances.delete(options.id); + yield* stopInstance(options.id, { + stopProxy: false, + unsetProxy: true, + }); } - const scope = yield* Scope.fork(rootScope); - const fiber = yield* runInstance(options).pipe( - Effect.forkDetach, - Scope.provide(scope), - ); - instances.set(options.id, { hash, fiber, scope }); - return yield* Fiber.join(fiber).pipe( - Effect.onExit((exit) => - Effect.sync(() => { - if (exit._tag === "Failure") { - instances.delete(options.id); - } - }), - ), - ); + return yield* startInstance(instanceOptions, hash); }), delete: Effect.fn(function* ({ id }) { - const existing = instances.get(id); - if (existing) { - yield* Fiber.interrupt(existing.fiber); - yield* Scope.close(existing.scope, Exit.void); - instances.delete(id); - } + yield* stopInstance(id); }), }; }), ); +type RuntimeBindingMode = { + binding: string; + type: WorkerBinding["type"]; + mode: "local" | "remote" | "unsupported"; +}; + +const buildInstanceHash = Effect.fn(function* ({ + id, + props, + bindings, + stack, +}: { + id: string; + props: WorkerProps; + bindings: ResourceBinding[]; + stack: { + name: string; + stage: string; + }; +}) { + const name = yield* createWorkerName(id, props.name); + const nativeBindingNames = new Set( + bindings.flatMap(({ data }) => + (data.bindings ?? []).map((binding) => binding.name), + ), + ); + return Hash.structure({ + id, + name, + compatibility: getCompatibility(props), + env: Object.fromEntries( + Object.entries(props.env ?? {}).flatMap(([key, value]) => { + if (nativeBindingNames.has(key) || value === undefined) return []; + return [ + [key, Redacted.isRedacted(value) ? Redacted.value(value) : value], + ]; + }), + ), + nativeBindings: bindings.map(({ sid, data }) => ({ + sid, + bindings: data.bindings, + hyperdrives: normalizeHashHyperdrives(data.hyperdrives), + crons: data.crons, + containers: data.containers, + })), + assets: props.assets, + bundleOptions: { + main: props.main, + isExternal: props.isExternal, + exports: props.exports, + build: props.build, + }, + dev: props.dev, + vite: props.vite, + crons: props.crons, + stack: { + name: stack.name, + stage: stack.stage, + }, + }); +}); + +const normalizeHashHyperdrives = ( + hyperdrives: + | Record & { password: unknown }> + | undefined, +) => + hyperdrives + ? Object.fromEntries( + Object.entries(hyperdrives).map(([id, origin]) => [ + id, + { + ...origin, + password: Redacted.isRedacted(origin.password) + ? Redacted.value(origin.password) + : origin.password, + }, + ]), + ) + : undefined; + +const logBindingModes = (worker: { + id: string; + bindingModes: ReadonlyArray; +}) => + worker.bindingModes.length === 0 + ? Effect.void + : Effect.log( + `[${worker.id}] Local binding modes: ${worker.bindingModes + .map(({ binding, type, mode }) => `${binding} (${type}, ${mode})`) + .join(", ")}`, + ); + +const runtimeBindingMode = (binding: WorkerBinding): RuntimeBindingMode => ({ + binding: binding.name, + type: binding.type, + mode: runtimeBindingModeValue(binding), +}); + +const runtimeBindingModeValue = ( + binding: WorkerBinding, +): RuntimeBindingMode["mode"] => { + switch (binding.type) { + case "ai": + case "artifacts": + case "browser": + case "d1": + case "dispatch_namespace": + case "flagship": + case "images": + case "kv_namespace": + case "mtls_certificate": + case "pipelines": + case "r2_bucket": + case "send_email": + case "vectorize": + return "remote"; + case "analytics_engine": + case "assets": + case "data_blob": + case "durable_object_namespace": + case "hyperdrive": + case "json": + case "plain_text": + case "queue": + case "ratelimit": + case "secret_text": + case "service": + case "text_blob": + case "version_metadata": + case "wasm_module": + case "worker_loader": + case "workflow": + return "local"; + case "inherit": + case "secret_key": + case "secrets_store_secret": + default: + return "unsupported"; + } +}; + export const toRuntimeBinding = Effect.fnUntraced(function* (b: WorkerBinding) { const unsupported = () => new WorkerValidationError({ diff --git a/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/Vite.ts b/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/Vite.ts index 3ee0c660a..da3d68011 100644 --- a/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/Vite.ts +++ b/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/Vite.ts @@ -2,16 +2,81 @@ import cloudflare, { type CloudflareVitePluginOptions, } from "@oddlynew/distilled-cloudflare-vite-plugin"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Redacted from "effect/Redacted"; import { createRequire } from "node:module"; -import path from "node:path"; +import nodePath from "node:path"; import { pathToFileURL } from "node:url"; import type * as vite from "vite"; +import * as Bundle from "../../Bundle/Bundle.ts"; +import { sha256 } from "../../Util/sha256.ts"; + +const DISTILLED_BUILD_MANIFEST_NAME = "__distilled-build.json"; + +export type DistilledWorkerModuleType = + | "esm" + | "wasm" + | "data" + | "text" + | "json"; + +export interface DistilledWorkerModule { + path: string; + type: DistilledWorkerModuleType; +} + +export interface DistilledBuildManifest { + version: 2; + workers: { + app: { + main: string; + modules: Array; + compatibilityDate?: string; + compatibilityFlags?: Array; + }; + }; + assets?: { + directory: string; + htmlHandling?: + | "auto-trailing-slash" + | "force-trailing-slash" + | "drop-trailing-slash" + | "none"; + notFoundHandling?: "none" | "404-page" | "single-page-application"; + runWorkerFirst?: Array | boolean; + }; +} + +export interface DistilledBuildOutput { + manifest: DistilledBuildManifest; + manifestPath: string; + manifestDirectory: string; + bundle: Bundle.BundleOutput; + assetsDirectory: string | undefined; +} + +export interface ViteBuildOutput { + serverBundle: vite.Rolldown.OutputBundle | undefined; + assetsDirectory: string | undefined; + distilled: DistilledBuildOutput | undefined; +} + +export interface ViteEnvironmentOptions { + name?: string; + childEnvironments?: Array; +} + +export type CloudflareVitePluginOptionsWithAssets = + CloudflareVitePluginOptions & { + assets?: Omit, "directory">; + viteEnvironment?: ViteEnvironmentOptions; + }; export const viteDev = ( rootDir: string = process.cwd(), env: Record, - pluginOptions: CloudflareVitePluginOptions, + pluginOptions: CloudflareVitePluginOptionsWithAssets, serverOptions: vite.ServerOptions, ) => Effect.acquireRelease( @@ -20,7 +85,7 @@ export const viteDev = ( const devServer = await vite.createServer({ root: rootDir, define: getDefine(env), - plugins: [cloudflare(pluginOptions)], + plugins: cloudflarePluginOptions(pluginOptions), server: serverOptions, }); await devServer.listen(); @@ -35,48 +100,75 @@ export const viteDev = ( export const viteBuild = ( rootDir: string = process.cwd(), env: Record, - pluginOptions: CloudflareVitePluginOptions, + pluginOptions: CloudflareVitePluginOptionsWithAssets, ) => - Effect.promise(async () => { - let serverBundle: vite.Rolldown.OutputBundle | undefined; - let assetsDirectory: string | undefined; - const vite = await loadVite(rootDir); - const builder = await vite.createBuilder( - { - root: rootDir, - define: getDefine(env), - plugins: [ - cloudflare(pluginOptions), - { - name: "output:ssr", - applyToEnvironment(environment) { - return environment.name === "ssr"; - }, - generateBundle(_outputOptions, bundle) { - serverBundle = bundle; - }, - }, - { - name: "output:client", - applyToEnvironment(environment) { - return environment.name === "client"; - }, - generateBundle(outputOptions) { - assetsDirectory = outputOptions.dir; - }, - }, - ], - }, - // This is the `useLegacyBuilder` option. The Vite CLI implementation uses `null` here. - // Originally we used `undefined` here, but this caused the static site build to fail. - // https://github.com/vitejs/vite/blob/a07a4bd052ac75f916391c999c408ad5f2867e61/packages/vite/src/node/cli.ts#L367 - null, - ); - await builder.buildApp(); + Effect.gen(function* () { + const build = yield* Effect.promise(async () => { + let serverBundle: vite.Rolldown.OutputBundle | undefined; + let assetsDirectory: string | undefined; + const vite = await loadVite(rootDir); + const outputSsrPlugin: vite.Plugin = { + name: "output:ssr", + applyToEnvironment(environment) { + return environment.name === "ssr"; + }, + generateBundle(_outputOptions, bundle) { + serverBundle = bundle; + }, + }; + const outputClientPlugin: vite.Plugin = { + name: "output:client", + applyToEnvironment(environment) { + return environment.name === "client"; + }, + generateBundle(outputOptions) { + assetsDirectory = outputOptions.dir; + }, + }; + const builder = await vite.createBuilder( + { + root: rootDir, + define: getDefine(env), + plugins: [ + ...cloudflarePluginOptions(pluginOptions), + outputSsrPlugin, + outputClientPlugin, + ], + }, + // This is the `useLegacyBuilder` option. The Vite CLI implementation uses `null` here. + // Originally we used `undefined` here, but this caused the static site build to fail. + // https://github.com/vitejs/vite/blob/a07a4bd052ac75f916391c999c408ad5f2867e61/packages/vite/src/node/cli.ts#L367 + null, + ); + await builder.buildApp(); + const outputDirectories = Object.values(builder.environments).flatMap( + (environment) => + environment.config.build.outDir + ? [ + nodePath.resolve( + builder.config.root, + environment.config.build.outDir, + ), + ] + : [], + ); + if (assetsDirectory) { + outputDirectories.push( + nodePath.resolve(builder.config.root, assetsDirectory), + ); + } + return { + serverBundle, + assetsDirectory, + outputDirectories: Array.from(new Set(outputDirectories)), + }; + }); + const distilled = yield* readDistilledBuildOutput(build.outputDirectories); return { - serverBundle, - assetsDirectory, - }; + serverBundle: build.serverBundle, + assetsDirectory: build.assetsDirectory, + distilled, + } satisfies ViteBuildOutput; }); // Emulate `vite build` env semantics for `props.env`: only @@ -103,7 +195,7 @@ async function loadVite( projectRoot: string = process.cwd(), ): Promise { try { - const require = createRequire(path.join(projectRoot, "package.json")); + const require = createRequire(nodePath.join(projectRoot, "package.json")); const vitePath = require.resolve("vite"); // On Windows, absolute paths must be file:// URLs for ESM import(). const viteUrl = pathToFileURL(vitePath); @@ -114,3 +206,378 @@ async function loadVite( return await import("vite"); } } + +const cloudflarePluginOptions = ( + pluginOptions: CloudflareVitePluginOptionsWithAssets, +) => { + const plugins = cloudflare(pluginOptions); + const filtered: Array = []; + for (const plugin of Array.isArray(plugins) ? plugins : [plugins]) { + if (plugin) { + filtered.push(plugin as vite.Plugin); + } + } + return filtered; +}; + +const readDistilledBuildOutput = Effect.fnUntraced(function* ( + outputDirectories: ReadonlyArray, +) { + const path = yield* Path.Path; + const manifestPaths = Array.from( + new Set( + outputDirectories.flatMap((directory) => [ + path.join(path.dirname(directory), DISTILLED_BUILD_MANIFEST_NAME), + ]), + ), + ); + + for (const manifestPath of manifestPaths) { + const text = yield* readOptionalString(manifestPath); + if (text === undefined) continue; + const manifest = yield* parseDistilledBuildManifest(manifestPath, text); + const manifestDirectory = path.dirname(manifestPath); + const bundle = yield* readDistilledWorkerBundle( + manifestDirectory, + manifest, + ); + const assetsDirectory = manifest.assets + ? yield* resolveManifestPath( + manifestDirectory, + manifest.assets.directory, + "assets directory", + ) + : undefined; + return { + manifest, + manifestPath, + manifestDirectory, + bundle, + assetsDirectory, + } satisfies DistilledBuildOutput; + } + return undefined; +}); + +const readOptionalString = Effect.fnUntraced(function* (file: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs + .readFileString(file) + .pipe( + Effect.catchIf(isNotFoundPlatformError, () => Effect.succeed(undefined)), + ); +}); + +const readDistilledWorkerBundle = Effect.fnUntraced(function* ( + manifestDirectory: string, + manifest: DistilledBuildManifest, +) { + const mainModule = manifest.workers.app.modules.find( + (module) => module.path === manifest.workers.app.main, + ); + if (!mainModule) { + return yield* new Bundle.BundleError({ + message: `Distilled build manifest main module "${manifest.workers.app.main}" is not listed in workers.app.modules`, + }); + } + const modules = [ + mainModule, + ...manifest.workers.app.modules.filter( + (module) => module.path !== mainModule.path, + ), + ] as [DistilledWorkerModule, ...DistilledWorkerModule[]]; + const files = yield* Effect.forEach( + modules, + (module) => readDistilledModuleFile(manifestDirectory, module), + { concurrency: "unbounded" }, + ); + return yield* Bundle.bundleOutputFromFiles( + files as [Bundle.BundleFile, ...Bundle.BundleFile[]], + ); +}); + +const readDistilledModuleFile = Effect.fnUntraced(function* ( + manifestDirectory: string, + module: DistilledWorkerModule, +) { + const fs = yield* FileSystem.FileSystem; + const file = yield* resolveManifestPath( + manifestDirectory, + module.path, + `worker module "${module.path}"`, + ); + const content = yield* fs.readFile(file).pipe( + Effect.mapError( + (cause) => + new Bundle.BundleError({ + message: `Failed to read distilled worker module "${file}"`, + cause, + }), + ), + ); + const hash = yield* sha256(content); + return { + path: module.path, + content, + hash, + contentType: contentTypeFromDistilledModuleType(module.type), + } satisfies Bundle.BundleFile; +}); + +const resolveManifestPath = Effect.fnUntraced(function* ( + manifestDirectory: string, + relativePath: string, + label: string, +) { + const path = yield* Path.Path; + const resolved = path.resolve(manifestDirectory, relativePath); + if (!isPathInside(manifestDirectory, resolved)) { + return yield* new Bundle.BundleError({ + message: `Distilled build manifest ${label} resolves outside the manifest directory`, + }); + } + return resolved; +}); + +const parseDistilledBuildManifest = (manifestPath: string, text: string) => + Effect.try({ + try: () => validateDistilledBuildManifest(JSON.parse(text)), + catch: (cause) => + new Bundle.BundleError({ + message: `Invalid distilled build manifest at "${manifestPath}": ${ + cause instanceof Error ? cause.message : String(cause) + }`, + cause, + }), + }); + +function validateDistilledBuildManifest( + value: unknown, +): DistilledBuildManifest { + assertRecord(value, "manifest"); + if (value.version !== 2) { + throw new Error("expected version 2"); + } + assertRecord(value.workers, "workers"); + const unsupportedWorkers = Object.keys(value.workers).filter( + (name) => name !== "app", + ); + if (unsupportedWorkers.length > 0) { + throw new Error( + `workers contains unsupported entries: ${unsupportedWorkers.join(", ")}`, + ); + } + assertRecord(value.workers.app, "workers.app"); + const app = value.workers.app; + assertString(app.main, "workers.app.main"); + assertRelativeManifestPath(app.main, "workers.app.main"); + if (!Array.isArray(app.modules) || app.modules.length === 0) { + throw new Error("workers.app.modules must be a non-empty array"); + } + const modules = app.modules.map((module, index) => { + assertRecord(module, `workers.app.modules[${index}]`); + assertString(module.path, `workers.app.modules[${index}].path`); + assertRelativeManifestPath( + module.path, + `workers.app.modules[${index}].path`, + ); + if (!isDistilledWorkerModuleType(module.type)) { + throw new Error( + `workers.app.modules[${index}].type must be one of esm, wasm, data, text, json`, + ); + } + return { + path: module.path, + type: module.type, + }; + }); + if (new Set(modules.map((module) => module.path)).size !== modules.length) { + throw new Error("workers.app.modules must not contain duplicate paths"); + } + const compatibilityDate = optionalString( + app.compatibilityDate, + "workers.app.compatibilityDate", + ); + const compatibilityFlags = optionalStringArray( + app.compatibilityFlags, + "workers.app.compatibilityFlags", + ); + const assets = validateOptionalDistilledAssets(value.assets); + return { + version: 2, + workers: { + app: { + main: app.main, + modules, + ...(compatibilityDate !== undefined ? { compatibilityDate } : {}), + ...(compatibilityFlags !== undefined ? { compatibilityFlags } : {}), + }, + }, + ...(assets !== undefined ? { assets } : {}), + }; +} + +function validateOptionalDistilledAssets( + value: unknown, +): DistilledBuildManifest["assets"] | undefined { + if (value === undefined) return undefined; + assertRecord(value, "assets"); + assertString(value.directory, "assets.directory"); + assertRelativeManifestPath(value.directory, "assets.directory"); + const htmlHandling = optionalString( + value.htmlHandling, + "assets.htmlHandling", + ); + if ( + htmlHandling !== undefined && + !isDistilledAssetHtmlHandling(htmlHandling) + ) { + throw new Error("assets.htmlHandling has an unsupported value"); + } + const notFoundHandling = optionalString( + value.notFoundHandling, + "assets.notFoundHandling", + ); + if ( + notFoundHandling !== undefined && + !isDistilledAssetNotFoundHandling(notFoundHandling) + ) { + throw new Error("assets.notFoundHandling has an unsupported value"); + } + const runWorkerFirst = validateOptionalRunWorkerFirst(value.runWorkerFirst); + return { + directory: value.directory, + ...(htmlHandling !== undefined ? { htmlHandling } : {}), + ...(notFoundHandling !== undefined ? { notFoundHandling } : {}), + ...(runWorkerFirst !== undefined ? { runWorkerFirst } : {}), + }; +} + +function validateOptionalRunWorkerFirst(value: unknown) { + if (value === undefined) return undefined; + if (typeof value === "boolean") return value; + if ( + Array.isArray(value) && + value.every((entry) => typeof entry === "string") + ) { + return value; + } + throw new Error("assets.runWorkerFirst must be a boolean or string array"); +} + +function assertRecord( + value: unknown, + label: string, +): asserts value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function assertString(value: unknown, label: string): asserts value is string { + if (typeof value !== "string") { + throw new Error(`${label} must be a string`); + } +} + +function assertRelativeManifestPath(value: string, label: string) { + if ( + value.length === 0 || + value.includes("\0") || + value.startsWith("/") || + /^[a-zA-Z]:[\\/]/.test(value) + ) { + throw new Error(`${label} must be a relative manifest path`); + } +} + +function optionalString(value: unknown, label: string) { + if (value === undefined) return undefined; + assertString(value, label); + return value; +} + +function optionalStringArray(value: unknown, label: string) { + if (value === undefined) return undefined; + if ( + !Array.isArray(value) || + !value.every((entry) => typeof entry === "string") + ) { + throw new Error(`${label} must be a string array`); + } + return value; +} + +function isDistilledWorkerModuleType( + value: unknown, +): value is DistilledWorkerModuleType { + return ( + value === "esm" || + value === "wasm" || + value === "data" || + value === "text" || + value === "json" + ); +} + +function isDistilledAssetHtmlHandling( + value: string, +): value is NonNullable< + NonNullable["htmlHandling"] +> { + return ( + value === "auto-trailing-slash" || + value === "force-trailing-slash" || + value === "drop-trailing-slash" || + value === "none" + ); +} + +function isDistilledAssetNotFoundHandling( + value: string, +): value is NonNullable< + NonNullable["notFoundHandling"] +> { + return ( + value === "none" || + value === "404-page" || + value === "single-page-application" + ); +} + +function contentTypeFromDistilledModuleType(type: DistilledWorkerModuleType) { + switch (type) { + case "esm": + return "application/javascript+module"; + case "wasm": + return "application/wasm"; + case "text": + return "text/plain"; + case "json": + return "application/json"; + case "data": + return "application/octet-stream"; + } +} + +function isPathInside(root: string, file: string) { + const relative = nodePath.relative(root, file); + return ( + relative === "" || + (!relative.startsWith("..") && !nodePath.isAbsolute(relative)) + ); +} + +function isNotFoundPlatformError(error: unknown) { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "PlatformError" && + "reason" in error && + typeof error.reason === "object" && + error.reason !== null && + "_tag" in error.reason && + error.reason._tag === "NotFound" + ); +} diff --git a/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/Worker.ts index 1b27690a4..c1585a4bd 100644 --- a/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -7,7 +7,6 @@ import type { ConfigError } from "effect/Config"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; -import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as Redacted from "effect/Redacted"; import * as Schedule from "effect/Schedule"; @@ -25,6 +24,7 @@ import { Platform, type Main, type PlatformProps } from "../../Platform.ts"; import * as Provider from "../../Provider.ts"; import { Resource, type ResourceBinding } from "../../Resource.ts"; import { Stack } from "../../Stack.ts"; +import { sha256Object } from "../../Util/sha256.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { HyperdriveDevOrigin } from "../Hyperdrive/Hyperdrive.ts"; import { CloudflareLogs } from "../Logs.ts"; @@ -42,6 +42,7 @@ import { } from "./DurableObjectNamespace.ts"; import { LocalWorkerProvider } from "./LocalWorkerProvider.ts"; import { Request } from "./Request.ts"; +import type * as Vite from "./Vite.ts"; import { bindWorkerAsyncBindings, getCronBindings, @@ -208,10 +209,13 @@ export interface WorkerProps< enabled?: boolean; previewsEnabled?: boolean; }; - /** @internal used by Cloudflare.Vite resource */ + /** + * @internal Used by `Cloudflare.Vite`; not a stable public Worker API. + */ vite?: { rootDir?: string; memo?: MemoOptions; + viteEnvironment?: Vite.CloudflareVitePluginOptionsWithAssets["viteEnvironment"]; }; logpush?: boolean; /** @@ -380,6 +384,13 @@ export type Worker = Resource< Providers >; +type ResolvedViteBuildInputs = { + compatibility: ReturnType; + env: Record; + pluginOptions: Vite.CloudflareVitePluginOptionsWithAssets; + input: string; +}; + /** * A Cloudflare Worker host with deploy-time binding support and runtime export * collection. @@ -814,8 +825,6 @@ export const LiveWorkerProvider = () => Provider.effect( Worker, Effect.gen(function* () { - const path = yield* Path.Path; - const bundler = yield* WorkerBundle; const stack = yield* Stack; @@ -1234,56 +1243,141 @@ export const LiveWorkerProvider = () => crypto.createHash("sha256").update(script).digest("hex"), ); - const viteBuild = Effect.fn(function* (props: WorkerProps) { + const resolveViteBuildEnvValue = Effect.fnUntraced(function* ( + value: unknown, + ) { + const materialized = Effect.isEffect(value) + ? yield* value as Effect.Effect + : value; + const resolved = Redacted.isRedacted(materialized) + ? Redacted.value(materialized) + : materialized; + return typeof resolved === "string" || + typeof resolved === "number" || + typeof resolved === "boolean" || + resolved === null + ? resolved + : undefined; + }); + + const resolveViteBuildEnv = Effect.fnUntraced(function* ( + env: WorkerProps["env"], + ) { + return Object.fromEntries( + (yield* Effect.all( + Object.entries(env ?? {}) + .filter(([key]) => key.startsWith("VITE_")) + .map( + Effect.fnUntraced(function* ([key, value]) { + return [key, yield* resolveViteBuildEnvValue(value)]; + }), + ), + )).filter(([_, value]) => value !== undefined), + ); + }); + + const vitePluginOptions = ( + props: WorkerProps, + compatibility: ReturnType, + ): Vite.CloudflareVitePluginOptionsWithAssets => ({ + compatibilityDate: compatibility.date, + compatibilityFlags: compatibility.flags, + assets: viteAssetRoutingOptions(props.assets), + viteEnvironment: props.vite?.viteEnvironment, + }); + + const resolveViteBuildInputs = Effect.fnUntraced(function* ( + props: WorkerProps, + ) { const compatibility = getCompatibility(props); + const [sources, env] = yield* Effect.all( + [ + // hashDirectory expects `{ cwd, memo }`. The vite props + // store the project root under `rootDir`, so map it + // here. Without this, `cwd` falls back to + // `process.cwd()` and the input hash is computed over + // the wrong directory tree (often the entire monorepo + // root), making it both slow and unable to detect + // changes scoped to the actual Vite project. + hashDirectory({ + cwd: props.vite?.rootDir, + memo: props.vite?.memo, + }), + resolveViteBuildEnv(props.env), + ], + { concurrency: "unbounded" }, + ); + const pluginOptions = vitePluginOptions(props, compatibility); + const input = yield* sha256Object({ + sources, + buildEnv: env, + pluginOptions, + }); + return { + compatibility, + env, + pluginOptions, + input, + } satisfies ResolvedViteBuildInputs; + }); + + const viteBuild = Effect.fnUntraced(function* ( + props: WorkerProps, + inputs: ResolvedViteBuildInputs, + ) { // Loaded lazily: `./Vite.ts` pulls in `@oddlynew/distilled-cloudflare-vite-plugin` // (~0.5s), which is only needed for vite-based workers at build time — // not for every Worker definition at module-load time. const Vite = yield* Effect.promise(() => import("./Vite.ts")); - const { assetsDirectory, serverBundle } = yield* Vite.viteBuild( + const build = yield* Vite.viteBuild( props.vite?.rootDir, - Object.fromEntries( - (yield* Effect.all( - Object.entries(props.env ?? {}).map( - Effect.fn(function* ([key, value]) { - return [ - key, - typeof value === "string" - ? value - : Redacted.isRedacted(value) && - typeof Redacted.value(value) === "string" - ? Redacted.value(value) - : Effect.isEffect(value) - ? yield* value as Effect.Effect - : undefined, - ]; - }), - ), - )).filter(([_, value]) => value !== undefined), - ), - { - compatibilityDate: compatibility.date, - compatibilityFlags: compatibility.flags, - }, + inputs.env, + inputs.pluginOptions, ); - if (!assetsDirectory && !serverBundle) { + if (build.distilled) { + yield* validateDistilledBuildCompatibility( + build.distilled.manifest, + inputs.compatibility, + ); + } + + const assetsDirectory = + build.distilled?.assetsDirectory ?? build.assetsDirectory; + const serverBundle = build.serverBundle; + const manifestBundle = build.distilled?.bundle; + + if (!manifestBundle && serverBundle) { + return yield* Effect.die( + new Error( + "Vite build produced a Worker output without __distilled-build.json. " + + "Alchemy needs the distilled build manifest to deploy the complete Worker module set; " + + "check the Cloudflare Vite plugin version, viteEnvironment topology, and custom build.outDir settings.", + ), + ); + } + + if (!assetsDirectory && !serverBundle && !manifestBundle) { return yield* Effect.die( new Error("Vite build produced neither server nor client output"), ); } + const assetConfig = build.distilled?.manifest.assets + ? yield* mergeDistilledAssetConfig( + props.assets, + build.distilled.manifest.assets, + ) + : workerAssetConfig(props.assets); const [assets, bundle] = yield* Effect.all( [ assetsDirectory ? readAssets({ - ...(props.assets && typeof props.assets !== "string" - ? props.assets - : undefined), + ...assetConfig, directory: assetsDirectory, }) : Effect.succeed(undefined), - serverBundle - ? Bundle.bundleOutputFromRolldownOutputBundle(serverBundle) + manifestBundle + ? Effect.succeed(manifestBundle) : Effect.succeed(undefined), ], { concurrency: "unbounded" }, @@ -1310,30 +1404,22 @@ export const LiveWorkerProvider = () => return { assets, bundle: { - files: [{ path: "main.js", content: props.script }], + files: [ + { + path: "main.js", + content: props.script, + hash: bundleHash, + contentType: "application/javascript+module", + }, + ], hash: bundleHash, }, }; } if (props.vite) { - const [{ assets, bundle }, input] = yield* Effect.all( - [ - viteBuild(props), - // hashDirectory expects `{ cwd, memo }`. The vite props - // store the project root under `rootDir`, so map it - // here. Without this, `cwd` falls back to - // `process.cwd()` and the input hash is computed over - // the wrong directory tree (often the entire monorepo - // root), making it both slow and unable to detect - // changes scoped to the actual Vite project. - hashDirectory({ - cwd: props.vite.rootDir, - memo: props.vite.memo, - }), - ], - { concurrency: "unbounded" }, - ); - return { assets, bundle, input }; + const inputs = yield* resolveViteBuildInputs(props); + const { assets, bundle } = yield* viteBuild(props, inputs); + return { assets, bundle, input: inputs.input }; } const [assets, bundle] = yield* Effect.all( [ @@ -1353,7 +1439,8 @@ export const LiveWorkerProvider = () => files: bundle?.files.map( (file) => new File([file.content as BlobPart], file.path, { - type: contentTypeFromExtension(path.extname(file.path)), + type: + file.contentType ?? Bundle.contentTypeFromPath(file.path), }), ), }, @@ -1845,11 +1932,8 @@ export const LiveWorkerProvider = () => return assetsHash !== output.hash?.assets; } if (props.vite) { - const input = yield* hashDirectory({ - cwd: props.vite.rootDir, - memo: props.vite.memo, - }); - return input !== output.hash?.input; + const inputs = yield* resolveViteBuildInputs(props); + return inputs.input !== output.hash?.input; } const bundleHash = yield* prepareBundle(id, props).pipe( Effect.map((b) => b.hash), @@ -2418,29 +2502,123 @@ export const LiveWorkerProvider = () => }), ); -const contentTypeFromExtension = (extension: string) => { - switch (extension) { - case ".wasm": - return "application/wasm"; - case ".txt": - case ".html": - case ".sql": - case ".custom": - return "text/plain"; - case ".bin": - return "application/octet-stream"; - case ".mjs": - case ".js": - return "application/javascript+module"; - case ".cjs": - return "application/javascript"; - case ".map": - return "application/source-map"; - default: - return "application/octet-stream"; +type ViteAssetRoutingOptions = NonNullable< + Vite.CloudflareVitePluginOptionsWithAssets["assets"] +>; + +const viteAssetRoutingOptions = ( + assets: WorkerProps["assets"], +): ViteAssetRoutingOptions | undefined => { + if (!assets || typeof assets === "string") { + return undefined; } + return { + ...(assets.htmlHandling !== undefined + ? { + htmlHandling: + assets.htmlHandling as ViteAssetRoutingOptions["htmlHandling"], + } + : {}), + ...(assets.notFoundHandling !== undefined + ? { + notFoundHandling: + assets.notFoundHandling as ViteAssetRoutingOptions["notFoundHandling"], + } + : {}), + ...(assets.runWorkerFirst !== undefined + ? { runWorkerFirst: assets.runWorkerFirst } + : {}), + }; }; +const workerAssetConfig = (assets: WorkerProps["assets"]) => { + if (!assets || typeof assets === "string") { + return undefined; + } + const { directory: _, ...config } = assets; + if (Predicate.hasProperty(config, "hash")) { + const { hash: _, ...configWithoutHash } = config; + return configWithoutHash; + } + return config; +}; + +const mergeDistilledAssetConfig = ( + assets: WorkerProps["assets"], + manifestAssets: NonNullable, +) => + Effect.try({ + try: () => { + const manifestConfig = { + ...(manifestAssets.htmlHandling !== undefined + ? { htmlHandling: manifestAssets.htmlHandling } + : {}), + ...(manifestAssets.notFoundHandling !== undefined + ? { notFoundHandling: manifestAssets.notFoundHandling } + : {}), + ...(manifestAssets.runWorkerFirst !== undefined + ? { runWorkerFirst: manifestAssets.runWorkerFirst } + : {}), + }; + const propsConfig = workerAssetConfig(assets) ?? {}; + for (const key of [ + "htmlHandling", + "notFoundHandling", + "runWorkerFirst", + ] as const) { + if ( + key in propsConfig && + key in manifestConfig && + JSON.stringify(propsConfig[key]) !== + JSON.stringify(manifestConfig[key]) + ) { + throw new Error( + `Distilled build manifest asset ${key} does not match Cloudflare.Vite assets.${key}`, + ); + } + } + return { + ...manifestConfig, + ...propsConfig, + }; + }, + catch: errorFromUnknown, + }); + +const validateDistilledBuildCompatibility = ( + manifest: Vite.DistilledBuildManifest, + compatibility: ReturnType, +) => + Effect.try({ + try: () => { + const worker = manifest.workers.app; + if (worker.compatibilityDate !== compatibility.date) { + throw new Error( + `Distilled build manifest compatibilityDate ${JSON.stringify( + worker.compatibilityDate, + )} does not match deploy compatibilityDate ${JSON.stringify( + compatibility.date, + )}`, + ); + } + const manifestFlags = [...(worker.compatibilityFlags ?? [])].sort(); + const deployFlags = [...compatibility.flags].sort(); + if (JSON.stringify(manifestFlags) !== JSON.stringify(deployFlags)) { + throw new Error( + `Distilled build manifest compatibilityFlags ${JSON.stringify( + manifestFlags, + )} do not match deploy compatibilityFlags ${JSON.stringify( + deployFlags, + )}`, + ); + } + }, + catch: errorFromUnknown, + }); + +const errorFromUnknown = (cause: unknown) => + cause instanceof Error ? cause : new Error(String(cause)); + function bumpMigrationTagVersion( oldTag: string | undefined, ): string | undefined { diff --git a/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts b/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts index 9f707c8e5..a94b671cc 100644 --- a/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts +++ b/projects/alchemy/packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts @@ -273,7 +273,12 @@ export const readPrebuiltWorkerBundle = Effect.fnUntraced(function* ( ), ); const hash = yield* sha256(content); - return { path: name, content, hash } satisfies Bundle.BundleFile; + return { + path: name, + content, + hash, + contentType: Bundle.contentTypeFromPath(name), + } satisfies Bundle.BundleFile; }); return yield* readModuleFile(entryName).pipe( diff --git a/projects/alchemy/packages/alchemy/src/Diff.ts b/projects/alchemy/packages/alchemy/src/Diff.ts index e3853dbb2..8b67e7cd0 100644 --- a/projects/alchemy/packages/alchemy/src/Diff.ts +++ b/projects/alchemy/packages/alchemy/src/Diff.ts @@ -3,7 +3,7 @@ import * as Redacted from "effect/Redacted"; import type { Input } from "./Input.ts"; import * as Output from "./Output.ts"; import type { BindingNode } from "./Plan.ts"; -import type { ResourceBinding } from "./Resource.ts"; +import { isResourceEffect, type ResourceBinding } from "./Resource.ts"; import { isPrimitive } from "./Util/data.ts"; export type Diff = NoopDiff | UpdateDiff | ReplaceDiff; @@ -45,6 +45,10 @@ export const isResolved = (value: Input): value is T => const _hasUnresolved = (value: unknown): boolean => { if (value == null || isPrimitive(value)) return false; + // Non-class resource references are permanently opaque — `resolveInput` + // never executes them (see Plan.ts) — so they don't make props + // "unresolved": a provider's diff can still run against the rest. + if (isResourceEffect(value)) return false; if (Output.isExpr(value) || Effect.isEffect(value)) return true; if (Array.isArray(value)) return value.some(_hasUnresolved); if (typeof value === "object") { diff --git a/projects/alchemy/packages/alchemy/src/Output.ts b/projects/alchemy/packages/alchemy/src/Output.ts index 26514cd2c..0b09bb7a7 100644 --- a/projects/alchemy/packages/alchemy/src/Output.ts +++ b/projects/alchemy/packages/alchemy/src/Output.ts @@ -7,7 +7,12 @@ import type { Pipeable } from "effect/Pipeable"; import * as Redacted from "effect/Redacted"; import { SingleShotGen } from "effect/Utils"; import { getRefMetadata, isRef, type Ref } from "./Ref.ts"; -import { isResource, type Resource, type ResourceLike } from "./Resource.ts"; +import { + isResource, + isResourceEffect, + type Resource, + type ResourceLike, +} from "./Resource.ts"; import { RuntimeContext, sanitizeKey } from "./RuntimeContext.ts"; import { Stack } from "./Stack.ts"; import { Stage } from "./Stage.ts"; @@ -650,6 +655,18 @@ export const evaluate: ( // Plan.ts for rationale. `Config.redacted` resolves to a `Redacted`, // which stays opaque via the branch below. return yield* evaluate(yield* expr, upstream); + } else if (isResourceEffect(expr)) { + // Non-class resource references (`const db = Hyperdrive("db", …)`) + // stay opaque to evaluation — see resolveInput in Plan.ts for + // rationale. + return expr; + } else if (Effect.isEffect(expr) && typeof expr !== "function") { + // Per-field Effect inputs — see resolveInput in Plan.ts for rationale. + // Resolving here keeps apply-time props symmetric with plan-time + // resolution (and stops the object walk below from shredding an Effect + // into a plain `{"~effect/Effect/args": ...}` object). Function-form + // Effects (resource classes, `Binding.Policy` tags) stay opaque. + return yield* evaluate(yield* expr, upstream); } else if (Duration.isDuration(expr) || Redacted.isRedacted(expr)) { // Opaque value — see resolveInput in Plan.ts for rationale. return expr; diff --git a/projects/alchemy/packages/alchemy/src/Plan.ts b/projects/alchemy/packages/alchemy/src/Plan.ts index 05fcc113f..96efb61b8 100644 --- a/projects/alchemy/packages/alchemy/src/Plan.ts +++ b/projects/alchemy/packages/alchemy/src/Plan.ts @@ -38,6 +38,7 @@ import { } from "./Provider.ts"; import { isResource, + isResourceEffect, type ResourceBinding, type ResourceLike, } from "./Resource.ts"; @@ -375,7 +376,10 @@ export const make = ( )); }); - const resolveInput = (input: any): Effect.Effect => + // The error channel is `unknown` because per-field Effect inputs + // (`Input` admits `Effect`) surface their own failures, + // which are not statically known here. + const resolveInput = (input: any): Effect.Effect => Effect.gen(function* () { if (!input) { return input; @@ -389,6 +393,26 @@ export const make = ( // `Config.redacted` resolves to a `Redacted`, which stays opaque via // the branch below. return yield* resolveInput(yield* input); + } else if (isResourceEffect(input)) { + // Non-class resource references (`const db = Hyperdrive("db", …)`) + // are Effects too, but they are handles to stack resources, not + // per-field values. Executing one here would re-derive its FQN + // from the ambient namespace (none at plan time) and mint a + // phantom resource. They stay opaque — the construction phase + // (e.g. Worker env binding registration) already consumed them. + return input; + } else if (Effect.isEffect(input) && typeof input !== "function") { + // Per-field Effect inputs (e.g. `Stack.useSync(...)`) — `Input` + // admits `Effect` for any prop, so run the effect and resolve + // into its result, mirroring the Config branch above. Function-form + // Effects (resource classes, `effectClass` constructors, + // `Binding.Policy` tags) stay opaque: they are class references + // carried in props (e.g. Worker `exports`), not per-field values. + // Requirements are erased here — the effect runs against the + // ambient plan context (see the Requirements TODO on `Input`). + return yield* resolveInput( + yield* input as Effect.Effect, + ); } else if (Duration.isDuration(input) || Redacted.isRedacted(input)) { // Opaque values that are resolved downstream. We don't walk them // because it would strip their prototype, resulting in a plain object diff --git a/projects/alchemy/packages/alchemy/src/Resource.ts b/projects/alchemy/packages/alchemy/src/Resource.ts index 4dd19b39f..cad81d086 100644 --- a/projects/alchemy/packages/alchemy/src/Resource.ts +++ b/projects/alchemy/packages/alchemy/src/Resource.ts @@ -125,6 +125,21 @@ export const isResource = (value: any): value is ResourceLike => { return typeof value === "object" && value !== null && "Type" in value; }; +// Registered with `Symbol.for` (like `alchemy/Expr`) so the brand survives +// duplicate alchemy module instances in one process. +export const ResourceEffectSymbol = Symbol.for("alchemy/ResourceEffect"); + +/** + * True for the Effect returned by a non-class resource call + * (`const db = Hyperdrive("db", props)`). Such an Effect is a *reference* + * to a stack resource, not a per-field value: input resolution + * (`resolveInput` in Plan.ts, `Output.evaluate`) must leave it opaque + * instead of executing it — execution outside the construction phase would + * re-derive the FQN from the ambient namespace and mint a phantom resource. + */ +export const isResourceEffect = (value: unknown): boolean => + typeof value === "object" && value !== null && ResourceEffectSymbol in value; + export type Resource< Type extends string = any, Props extends object | undefined = any, @@ -174,115 +189,136 @@ export function Resource( const constructor = ( id: string, props: Props | Effect.Effect | undefined, - ) => - Effect.gen(function* () { - const stack = yield* Stack; - const namespace = yield* CurrentNamespace; - const fqn = toFqn(namespace, id); - - const existing = stack.resources[fqn]; - if (existing) { - // // TODO(sam): check if props are different and die - return existing; - } - const bind = ( - ...args: - | [sid: string, data: R["Binding"]] - | [template: TemplateStringsArray, ...args: any[]] - ) => - typeof args[0] === "string" - ? Effect.gen(function* () { - const [sid, data] = args as [sid: string, data: R["Binding"]]; - (stack.bindings[fqn] ??= []).push({ - sid, - data, - }); - return undefined; - }) - : (data: R["Binding"]) => { - const stringifyBindArg = (arg: any): string | undefined => { - if (arg === undefined) { - return undefined; - } + ) => { + // One constructor call denotes one resource per stack. The FQN-keyed + // memo below only collapses re-executions that see the same ambient + // namespace; this per-effect memo also covers re-executions *outside* + // the construction phase (e.g. a per-field Effect like + // `Effect.map(dbRole, …)` resolved by Plan/Apply re-runs the wrapped + // reference with no namespace in scope), which would otherwise + // re-derive a different FQN and mint a phantom resource. + const memo = new WeakMap(); + return Object.assign( + Effect.gen(function* () { + const stack = yield* Stack; + const memoized = memo.get(stack); + if (memoized) { + return memoized; + } + const namespace = yield* CurrentNamespace; + const fqn = toFqn(namespace, id); - if (Array.isArray(arg)) { - return arg - .flatMap((item) => { - const stringified = stringifyBindArg(item); - return stringified === undefined ? [] : [stringified]; - }) - .join(", "); - } + const existing = stack.resources[fqn]; + if (existing) { + // // TODO(sam): check if props are different and die + memo.set(stack, existing as R); + return existing; + } + const bind = ( + ...args: + | [sid: string, data: R["Binding"]] + | [template: TemplateStringsArray, ...args: any[]] + ) => + typeof args[0] === "string" + ? Effect.gen(function* () { + const [sid, data] = args as [sid: string, data: R["Binding"]]; + (stack.bindings[fqn] ??= []).push({ + sid, + data, + }); + return undefined; + }) + : (data: R["Binding"]) => { + const stringifyBindArg = (arg: any): string | undefined => { + if (arg === undefined) { + return undefined; + } - if ( - arg && - (typeof arg === "object" || typeof arg === "function") - ) { - if ("LogicalId" in arg && typeof arg.LogicalId === "string") { - return arg.LogicalId; + if (Array.isArray(arg)) { + return arg + .flatMap((item) => { + const stringified = stringifyBindArg(item); + return stringified === undefined ? [] : [stringified]; + }) + .join(", "); } - if ("id" in arg && typeof arg.id === "string") { - return arg.id; + if ( + arg && + (typeof arg === "object" || typeof arg === "function") + ) { + if ( + "LogicalId" in arg && + typeof arg.LogicalId === "string" + ) { + return arg.LogicalId; + } + + if ("id" in arg && typeof arg.id === "string") { + return arg.id; + } } - } - return String(arg); - }; + return String(arg); + }; - return bind( - `${(args[0] as TemplateStringsArray) - .flatMap((text, i) => { - const stringified = stringifyBindArg(args[i + 1]); - return stringified !== undefined - ? [text, stringified] - : [text]; - }) - .join("")}`, - data, - ); - }; + return bind( + `${(args[0] as TemplateStringsArray) + .flatMap((text, i) => { + const stringified = stringifyBindArg(args[i + 1]); + return stringified !== undefined + ? [text, stringified] + : [text]; + }) + .join("")}`, + data, + ); + }; - const target: any = { - Type: type, - Namespace: namespace, - FQN: fqn, - LogicalId: id, - Props: props, - Provider: ProviderTag as Provider, - RemovalPolicy: yield* Effect.serviceOption(RemovalPolicy).pipe( - Effect.map(Option.getOrElse(() => defaultRemovalPolicy)), - ), - Adopt: yield* Effect.serviceOption(AdoptPolicy).pipe( - Effect.map(Option.getOrUndefined), - ), - bind, - toString(this: typeof target) { - return `Resource<${this.Type}>(${this.LogicalId})`; - }, - [Symbol.toPrimitive](this: typeof target, hint: string) { - return hint === "number" ? NaN : this.toString(); - }, - }; + const target: any = { + Type: type, + Namespace: namespace, + FQN: fqn, + LogicalId: id, + Props: props, + Provider: ProviderTag as Provider, + RemovalPolicy: yield* Effect.serviceOption(RemovalPolicy).pipe( + Effect.map(Option.getOrElse(() => defaultRemovalPolicy)), + ), + Adopt: yield* Effect.serviceOption(AdoptPolicy).pipe( + Effect.map(Option.getOrUndefined), + ), + bind, + toString(this: typeof target) { + return `Resource<${this.Type}>(${this.LogicalId})`; + }, + [Symbol.toPrimitive](this: typeof target, hint: string) { + return hint === "number" ? NaN : this.toString(); + }, + }; - const Resource: R = (stack.resources[fqn] = new Proxy(target, { - set: (t, prop, value) => { - t[prop as keyof typeof t] = value; - return true; - }, - get: (t, prop) => - typeof prop === "symbol" || prop in t - ? t[prop as keyof typeof t] - : new Output.PropExpr(Output.of(Resource), prop), - })) as R; - Resource.Props = Effect.isEffect(props) - ? yield* props.pipe( - Effect.provideService(Self, Resource), - Effect.provideService(Self(type), Resource), - ) - : props; - return Resource; - }); + const Resource: R = (stack.resources[fqn] = new Proxy(target, { + set: (t, prop, value) => { + t[prop as keyof typeof t] = value; + return true; + }, + get: (t, prop) => + typeof prop === "symbol" || prop in t + ? t[prop as keyof typeof t] + : new Output.PropExpr(Output.of(Resource), prop), + })) as R; + Resource.Props = Effect.isEffect(props) + ? yield* props.pipe( + Effect.provideService(Self, Resource), + Effect.provideService(Self(type), Resource), + ) + : props; + memo.set(stack, Resource); + return Resource; + }), + { [ResourceEffectSymbol]: true }, + ); + }; const ProviderTag = Provider(type); diff --git a/projects/alchemy/packages/alchemy/test/Build/Exec.test.ts b/projects/alchemy/packages/alchemy/test/Build/Exec.test.ts new file mode 100644 index 000000000..37bd9100c --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Build/Exec.test.ts @@ -0,0 +1,99 @@ +import * as AWS from "@/AWS"; +import * as Build from "@/Build"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as pathe from "pathe"; + +const { test } = Test.make({ providers: AWS.providers() }); + +const fixtureDir = pathe.resolve(import.meta.dirname, "exec-fixture"); + +test.provider( + "runs on file, env, and command changes; delete leaves side effects alone", + (stack) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + yield* stack.destroy(); + + const runsLog = pathe.join(fixtureDir, "runs.log"); + yield* fs.remove(runsLog).pipe(Effect.catch(() => Effect.void)); + + const countRuns = Effect.gen(function* () { + const content = yield* fs.readFileString(runsLog); + return content.split("\n").filter((line) => line.length > 0).length; + }); + + const deploy = (props: { command?: string; env?: { MARKER: string } }) => + stack.deploy( + Effect.gen(function* () { + return yield* Build.Exec("test-exec", { + command: props.command ?? "bash run.sh", + cwd: fixtureDir, + env: props.env ?? { MARKER: "first" }, + memo: { include: ["src/**"] }, + }); + }), + ); + + const exec1 = yield* deploy({}); + + expect(exec1.hash).toBeDefined(); + expect(typeof exec1.hash).toBe("string"); + expect(exec1.hash.length).toBeGreaterThan(0); + expect(yield* countRuns).toBe(1); + + // Unchanged inputs — the run is skipped. + const exec2 = yield* deploy({}); + expect(exec2.hash).toBe(exec1.hash); + expect(yield* countRuns).toBe(1); + + // An env-only change re-runs (e.g. a recreated database's + // connection URL with identical files). + const exec3 = yield* deploy({ env: { MARKER: "second" } }); + expect(exec3.hash).not.toBe(exec1.hash); + expect(yield* countRuns).toBe(2); + + // A command-only change re-runs. + const exec4 = yield* deploy({ + command: "bash run.sh second-run", + env: { MARKER: "second" }, + }); + expect(exec4.hash).not.toBe(exec3.hash); + expect(yield* countRuns).toBe(3); + + // A memoized input file change re-runs. + yield* fs.writeFileString( + pathe.join(fixtureDir, "src", "input.txt"), + "two\n", + ); + const exec5 = yield* deploy({ + command: "bash run.sh second-run", + env: { MARKER: "second" }, + }); + expect(exec5.hash).not.toBe(exec4.hash); + expect(yield* countRuns).toBe(4); + + // Destroy never reverses the command's side effects… + yield* stack.destroy(); + expect(yield* countRuns).toBe(4); + + // …and forgets the run key, so an unchanged redeploy runs again. + const exec6 = yield* deploy({ + command: "bash run.sh second-run", + env: { MARKER: "second" }, + }); + expect(exec6.hash).toBe(exec5.hash); + expect(yield* countRuns).toBe(5); + + yield* stack.destroy(); + yield* fs.remove(runsLog); + yield* fs.writeFileString( + pathe.join(fixtureDir, "src", "input.txt"), + "one\n", + ); + }), + { timeout: 60000 }, +); diff --git a/projects/alchemy/packages/alchemy/test/Build/exec-fixture/.gitignore b/projects/alchemy/packages/alchemy/test/Build/exec-fixture/.gitignore new file mode 100644 index 000000000..56345fd62 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Build/exec-fixture/.gitignore @@ -0,0 +1 @@ +runs.log diff --git a/projects/alchemy/packages/alchemy/test/Build/exec-fixture/run.sh b/projects/alchemy/packages/alchemy/test/Build/exec-fixture/run.sh new file mode 100644 index 000000000..e5f75149c --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Build/exec-fixture/run.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "run" >> runs.log diff --git a/projects/alchemy/packages/alchemy/test/Build/exec-fixture/src/input.txt b/projects/alchemy/packages/alchemy/test/Build/exec-fixture/src/input.txt new file mode 100644 index 000000000..5626abf0f --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Build/exec-fixture/src/input.txt @@ -0,0 +1 @@ +one diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/Vite.test.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/Vite.test.ts index 429328181..bb0a05454 100644 --- a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/Vite.test.ts +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/Vite.test.ts @@ -1,22 +1,34 @@ import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; import * as Cloudflare from "@/Cloudflare/index.ts"; +import * as Vite from "@/Cloudflare/Workers/Vite.ts"; import * as Test from "@/Test/Vitest"; +import { PlatformServices } from "@/Util/PlatformServices.ts"; +import * as r2 from "@oddlynew/distilled-cloudflare/r2"; import { expect } from "@effect/vitest"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { MinimumLogLevel } from "effect/References"; import * as Schedule from "effect/Schedule"; import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as pathe from "pathe"; +import { test as vitestTest } from "vitest"; import { cloneFixture } from "../Utils/Fixture.ts"; import { expectUrlContains } from "../Utils/Http.ts"; import { expectWorkerExists, waitForWorkerToBeDeleted, } from "../Utils/Worker.ts"; +import type { Counter as ViteDoCounter } from "./vite-do-fixture/src/worker.ts"; const { test } = Test.make({ providers: Cloudflare.providers() }); +const { test: devTest } = Test.make({ + providers: Cloudflare.providers(), + dev: true, +}); const logLevel = Effect.provideService( MinimumLogLevel, @@ -24,6 +36,15 @@ const logLevel = Effect.provideService( ); const fixtureDir = pathe.resolve(import.meta.dirname, "vite-fixture"); +const doFixtureDir = pathe.resolve(import.meta.dirname, "vite-do-fixture"); +const reactRouterRscFixtureDir = pathe.resolve( + import.meta.dirname, + "react-router-rsc-fixture", +); +const tanstackDevBindingsFixtureDir = pathe.resolve( + import.meta.dirname, + "tanstack-dev-bindings-fixture", +); // Vite/Rollup's `vite:build-html` plugin chokes when the project root // is outside the current working directory because it tries to express @@ -33,6 +54,51 @@ const fixtureDir = pathe.resolve(import.meta.dirname, "vite-fixture"); // root as `cwd`. const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp"); +vitestTest( + "Vite: ignores manifest-like files copied into client assets", + async () => { + const build = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(tempRoot, { recursive: true }); + const rootDir = yield* fs.makeTempDirectory({ + prefix: "alchemy-vite-spa-manifest-", + directory: tempRoot, + }); + yield* fs.makeDirectory(path.join(rootDir, "public")); + yield* fs.makeDirectory(path.join(rootDir, "src")); + yield* fs.writeFileString( + path.join(rootDir, "index.html"), + '
\n', + ); + yield* fs.writeFileString( + path.join(rootDir, "src/main.ts"), + 'document.getElementById("app")!.textContent = "spa";\n', + ); + yield* fs.writeFileString( + path.join(rootDir, "public/__distilled-build.json"), + "not json\n", + ); + + return yield* Vite.viteBuild( + rootDir, + {}, + { + compatibilityDate: "2026-03-17", + compatibilityFlags: [], + }, + ); + }); + + const output = await Effect.runPromise( + build.pipe(Effect.provide(PlatformServices)), + ); + expect(output.distilled).toBeUndefined(); + expect(output.serverBundle).toBeUndefined(); + expect(output.assetsDirectory).toBeDefined(); + }, +); + test.provider( "Vite: editing a source file republishes the assets in a single deploy", (stack) => @@ -52,7 +118,12 @@ test.provider( // Restrict the input memo to fixture sources so the test isn't // re-hashing the whole monorepo on every deploy. - const memoInclude = ["index.html", "src/**", "package.json"]; + const memoInclude = [ + "index.html", + "src/**", + "package.json", + "vite.config.ts", + ]; const v1Marker = `vite-v1-${Date.now()}`; yield* fs.writeFileString(indexPath, htmlPage(v1Marker)); @@ -116,7 +187,12 @@ test.provider( entries: ["index.html", "package.json", "vite.config.ts", "src"], }); const indexPath = path.join(rootDir, "index.html"); - const memoInclude = ["index.html", "src/**", "package.json"]; + const memoInclude = [ + "index.html", + "src/**", + "package.json", + "vite.config.ts", + ]; const marker = `vite-class-${Date.now()}`; yield* fs.writeFileString(indexPath, htmlPage(marker)); @@ -147,8 +223,8 @@ test.provider( // ───────────────────────────────────────────────────────────────────── // Path-relocation behavior for the vite path // -// `Cloudflare.Vite` hashes its memo'd input tree (`hash.input`) -// instead of carrying an `AssetsWithHash`. The diff is: +// `Cloudflare.Vite` stores a path-insensitive `hash.input` made from +// the memo'd input tree plus build-affecting Vite options. The diff is: // // `input !== output.hash?.input` // @@ -167,7 +243,12 @@ test.provider( yield* stack.destroy(); - const memoInclude = ["index.html", "src/**", "package.json"]; + const memoInclude = [ + "index.html", + "src/**", + "package.json", + "vite.config.ts", + ]; const marker = `vite-relocate-${Date.now()}`; const rootA = yield* cloneFixture(fixtureDir, { @@ -231,7 +312,7 @@ test.provider( ); test.provider( - "Vite: `env` props are inlined as `import.meta.env.*` into the bundle", + "Vite: `env` props are inlined and env-only changes redeploy", (stack) => Effect.gen(function* () { const { accountId } = yield* yield* CloudflareEnvironment; @@ -244,34 +325,472 @@ test.provider( entries: ["index.html", "package.json", "vite.config.ts", "src"], }); const memoInclude = ["index.html", "src/**", "package.json"]; - const marker = `vite-env-${Date.now()}`; + const marker1 = `vite-env-1-${Date.now()}`; - const site = yield* stack.deploy( + const site1 = yield* stack.deploy( Effect.gen(function* () { return yield* Cloudflare.Vite("FixViteEnv", { ...viteProps(rootDir, memoInclude), - env: { VITE_TEST_MARKER: marker }, + env: { VITE_TEST_MARKER: marker1 }, }); }), ); - expect(site.url).toBeDefined(); + expect(site1.url).toBeDefined(); + expect(site1.hash?.input).toBeDefined(); // Resolve the hashed bundle URL by reading the deployed HTML, then // assert the marker that `main.ts` references via // `import.meta.env.VITE_TEST_MARKER` was actually inlined into the // served JS asset by `Cloudflare.Vite`'s `env`-→-`define` plumbing. - const bundleUrl = yield* discoverBundleUrl(site.url!); - yield* expectUrlContains(bundleUrl, marker, { + const bundleUrl1 = yield* discoverBundleUrl(site1.url!); + yield* expectUrlContains(bundleUrl1, marker1, { + timeout: "60 seconds", + label: "VITE_TEST_MARKER v1 inlined into client bundle", + }); + + const marker2 = `vite-env-2-${Date.now()}`; + const site2 = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Vite("FixViteEnv", { + ...viteProps(rootDir, memoInclude), + env: { VITE_TEST_MARKER: marker2 }, + }); + }), + ); + + expect(site2.hash?.input).toBeDefined(); + expect(site2.hash?.input).not.toEqual(site1.hash?.input); + const bundleUrl2 = yield* discoverBundleUrl(site2.url!); + yield* expectUrlContains(bundleUrl2, marker2, { timeout: "60 seconds", - label: "VITE_TEST_MARKER inlined into client bundle", + label: "VITE_TEST_MARKER v2 inlined into client bundle", }); + yield* stack.destroy(); + yield* waitForWorkerToBeDeleted(site1.workerName, accountId); + }).pipe(logLevel), + { timeout: 360_000 }, +); + +test.provider( + "Vite: worker entry can host a local Durable Object binding", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const rootDir = yield* cloneFixture(doFixtureDir, { + prefix: "alchemy-vite-do-", + tempRoot, + // Keep the fixture's real stack file available for local + // `alchemy dev` smoke tests. The live deploy below uses an inline + // stack so cleanup stays under the provider test harness. + entries: [ + "alchemy.run.ts", + "index.html", + "package.json", + "vite.config.ts", + "src", + ], + }); + const memoInclude = [ + "index.html", + "src/**", + "package.json", + "vite.config.ts", + ]; + const compatibility = { + date: "2026-03-17", + flags: ["nodejs_compat"], + }; + const assets = { + runWorkerFirst: ["/api/*"], + }; + // Direct build assertion covers the distilled Vite manifest contract; + // the live deploy assertion below proves Cloudflare.Vite consumes the + // same manifest bundle instead of falling back to the legacy ssr output. + const build = yield* Vite.viteBuild( + rootDir, + {}, + { + compatibilityDate: compatibility.date, + compatibilityFlags: compatibility.flags, + assets, + }, + ); + const distilled = build.distilled; + expect(distilled).toBeDefined(); + expect(distilled!.manifest.workers.app.main).toBe("server/worker.js"); + expect(distilled!.manifest.workers.app.modules).toContainEqual({ + path: "server/worker.js", + type: "esm", + }); + expect(distilled!.manifest.assets?.runWorkerFirst).toEqual(["/api/*"]); + expect(distilled!.bundle.files).toContainEqual( + expect.objectContaining({ + path: "server/worker.js", + contentType: "application/javascript+module", + }), + ); + + const site = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Vite("ViteDo", { + ...viteProps(rootDir, memoInclude), + compatibility, + assets, + env: { + Counter: Cloudflare.DurableObjectNamespace( + "Counter", + { + className: "Counter", + }, + ), + }, + }); + }), + ); + + expect(site.url).toBeDefined(); + expect(site.hash?.bundle).toEqual(distilled!.bundle.hash); + yield* expectWorkerExists(site.workerName, accountId); + yield* expectUrlContains(`${site.url!}/`, "Vite DO fixture", { + timeout: "120 seconds", + label: "vite do fixture assets", + }); + + const reset = yield* fetchJsonReady<{ ok: boolean }>( + `${site.url!}/api/reset`, + ); + expect(reset.ok).toBe(true); + + const first = yield* fetchJsonReady<{ count: number }>( + `${site.url!}/api/count`, + ); + expect(first.count).toBe(1); + + const second = yield* fetchJsonReady<{ count: number }>( + `${site.url!}/api/count`, + ); + expect(second.count).toBe(2); + yield* stack.destroy(); yield* waitForWorkerToBeDeleted(site.workerName, accountId); }).pipe(logLevel), { timeout: 360_000 }, ); +test.provider( + "Vite: React Router RSC deploys from a distilled manifest", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const rootDir = yield* cloneFixture(reactRouterRscFixtureDir, { + prefix: "alchemy-vite-rsc-", + tempRoot, + // Keep the fixture's stack file available for local `alchemy dev` + // smoke tests. The live deploy below uses an inline stack so cleanup + // stays under the provider test harness. + entries: [ + "alchemy.run.ts", + "app", + "package.json", + "react-router-vite", + "tsconfig.json", + "vite.config.ts", + ], + }); + const memoInclude = [ + "app/**", + "react-router-vite/**", + "package.json", + "tsconfig.json", + "vite.config.ts", + ]; + const compatibility = { + date: "2026-03-10", + flags: ["nodejs_compat"], + }; + const assets = { + runWorkerFirst: true, + }; + const viteEnvironment = { + name: "rsc", + childEnvironments: ["ssr"], + } satisfies NonNullable< + Vite.CloudflareVitePluginOptionsWithAssets["viteEnvironment"] + >; + + // Direct build assertion covers the RSC manifest shape: one Worker + // module set that folds both the rsc entry and ssr child chunks without + // leaking client assets into the uploaded Worker bundle. + const build = yield* Vite.viteBuild( + rootDir, + {}, + { + compatibilityDate: compatibility.date, + compatibilityFlags: compatibility.flags, + assets, + viteEnvironment, + }, + ); + const distilled = build.distilled; + expect(distilled).toBeDefined(); + const worker = distilled!.manifest.workers.app; + const modulePaths = worker.modules.map((module) => module.path); + expect(worker.main).toBe("server/entry.worker.js"); + expect(worker.compatibilityDate).toBe(compatibility.date); + expect(worker.compatibilityFlags).toEqual(compatibility.flags); + expect(modulePaths).toContain(worker.main); + expect(modulePaths).toContain("ssr/worker-ssr.js"); + expect(modulePaths.some((path) => path.startsWith("ssr/"))).toBe(true); + expect(modulePaths.some((path) => path.startsWith("client/"))).toBe( + false, + ); + expect(distilled!.manifest.assets?.directory).toBe("client"); + expect(distilled!.manifest.assets?.runWorkerFirst).toBe(true); + expect(distilled!.bundle.files).toContainEqual( + expect.objectContaining({ + path: "server/entry.worker.js", + contentType: "application/javascript+module", + }), + ); + + const site = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Vite("ReactRouterRsc", { + ...viteProps(rootDir, memoInclude), + assets, + compatibility, + viteEnvironment, + }); + }), + ); + + expect(site.url).toBeDefined(); + expect(site.hash?.bundle).toEqual(distilled!.bundle.hash); + yield* expectWorkerExists(site.workerName, accountId); + yield* expectUrlContains(`${site.url!}/`, "React Router Vite", { + timeout: "120 seconds", + label: "react router rsc home route", + }); + yield* expectUrlContains(`${site.url!}/about`, "About", { + timeout: "60 seconds", + label: "react router rsc client route", + }); + + const render = yield* fetchJsonReady<{ ok: boolean; html: string }>( + `${site.url!}/worker-render`, + ); + expect(render.ok).toBe(true); + expect(render.html).toContain("Worker render via the ssr environment."); + + yield* stack.destroy(); + yield* waitForWorkerToBeDeleted(site.workerName, accountId); + }).pipe(logLevel), + { timeout: 360_000 }, +); + +devTest.provider( + "Vite dev: TanStack Start keeps Alchemy-managed R2 bindings", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const bucketNames = new Set(); + + const cleanup = Effect.gen(function* () { + yield* stack.destroy(); + for (const bucketName of bucketNames) { + yield* waitForBucketToBeDeleted(bucketName, accountId); + } + }); + + const body = Effect.gen(function* () { + yield* stack.destroy(); + + const rootDir = yield* cloneFixture(tanstackDevBindingsFixtureDir, { + prefix: "alchemy-tanstack-dev-bindings-", + tempRoot, + entries: [ + "alchemy.run.ts", + "package.json", + "tsconfig.json", + "vite.config.ts", + "src", + ], + }); + const indexRoutePath = path.join(rootDir, "src/routes/index.tsx"); + const memoInclude = [ + "src/**", + "package.json", + "tsconfig.json", + "vite.config.ts", + "alchemy.run.ts", + ]; + const key = `dev-binding-${Date.now()}.txt`; + + yield* fs.writeFileString( + indexRoutePath, + tanstackIndexRouteSource("hmr-marker-v1"), + ); + + const deploy = (bucketId: string, marker: string) => + stack.deploy( + Effect.gen(function* () { + const bucket = yield* Cloudflare.R2Bucket(bucketId); + const worker = yield* Cloudflare.Vite("TanStackDevBindings", { + ...viteProps(rootDir, memoInclude), + assets: { + runWorkerFirst: true, + }, + dev: { + port: 0, + }, + env: { + BUCKET: bucket, + DEV_MARKER: marker, + }, + }); + return { bucket, worker }; + }), + ); + + const first = yield* deploy("DevBucketA", "dev-marker-v1"); + bucketNames.add(first.bucket.bucketName); + expect(first.worker.url).toBeDefined(); + yield* expectUrlContains(`${first.worker.url!}/`, "hmr-marker-v1", { + timeout: "30 seconds", + label: "tanstack dev initial route", + }); + + const env1 = yield* fetchJsonReady<{ marker: string }>( + `${first.worker.url!}/api/r2?key=${encodeURIComponent(key)}`, + ); + expect(env1.marker).toBe("dev-marker-v1"); + + const put1 = yield* putTextJsonReady<{ ok: boolean }>( + `${first.worker.url!}/api/r2?key=${encodeURIComponent(key)}`, + "from-a", + ); + expect(put1.ok).toBe(true); + + const get1 = yield* fetchJsonReady<{ value: string | null }>( + `${first.worker.url!}/api/r2?key=${encodeURIComponent(key)}`, + ); + expect(get1.value).toBe("from-a"); + + // Change only a TanStack route file. The stack is not re-applied; the + // local Vite server should render the updated route through the same + // Alchemy proxy. + yield* fs.writeFileString( + indexRoutePath, + tanstackIndexRouteSource("hmr-marker-v2"), + ); + yield* expectUrlContains(`${first.worker.url!}/`, "hmr-marker-v2", { + timeout: "30 seconds", + label: "tanstack dev updated route", + }); + + const second = yield* deploy("DevBucketB", "dev-marker-v2"); + bucketNames.add(second.bucket.bucketName); + expect(second.worker.url).toBe(first.worker.url); + + const env2 = yield* fetchJsonReady<{ marker: string }>( + `${second.worker.url!}/api/r2?key=${encodeURIComponent(key)}`, + ); + expect(env2.marker).toBe("dev-marker-v2"); + + // The Worker was rebound to DevBucketB. The object written through + // DevBucketA should not be visible through the new binding. + const reboundRead = yield* fetchJsonReady<{ value: string | null }>( + `${second.worker.url!}/api/r2?key=${encodeURIComponent(key)}`, + ); + expect(reboundRead.value).toBeNull(); + + const put2 = yield* putTextJsonReady<{ ok: boolean }>( + `${second.worker.url!}/api/r2?key=${encodeURIComponent(key)}`, + "from-b", + ); + expect(put2.ok).toBe(true); + + const get2 = yield* fetchJsonReady<{ value: string | null }>( + `${second.worker.url!}/api/r2?key=${encodeURIComponent(key)}`, + ); + expect(get2.value).toBe("from-b"); + }); + + const exit = yield* Effect.exit(body); + if (Exit.isSuccess(exit)) { + yield* cleanup; + return exit.value; + } + + yield* cleanup.pipe( + Effect.tapError((error) => + Effect.logError("Vite dev live test cleanup failed", error), + ), + Effect.ignore, + ); + return yield* Effect.failCause(exit.cause); + }).pipe(logLevel), + { timeout: 360_000 }, +); + +const freshConn = HttpClient.mapRequest( + HttpClientRequest.setHeader("connection", "close"), +); + +const fetchJsonReady = (url: string) => + Effect.gen(function* () { + const client = freshConn(yield* HttpClient.HttpClient); + return yield* client.get(url).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.flatMap(res.text, (body) => + Effect.try({ + try: () => JSON.parse(body) as T, + catch: () => new Error(`non-json body: ${body}`), + }), + ) + : Effect.fail(new Error(`Worker not ready: ${res.status}`)), + ), + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 15, + }), + ); + }); + +const putTextJsonReady = (url: string, body: string) => + Effect.gen(function* () { + return yield* HttpClient.execute( + HttpClientRequest.put(url).pipe( + HttpClientRequest.bodyText(body, "text/plain"), + ), + ).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.flatMap(res.text, (responseBody) => + Effect.try({ + try: () => JSON.parse(responseBody) as T, + catch: () => new Error(`non-json body: ${responseBody}`), + }), + ) + : Effect.fail(new Error(`Worker not ready: ${res.status}`)), + ), + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 15, + }), + ); + }); + const discoverBundleUrl = (siteUrl: string) => Effect.gen(function* () { const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient); @@ -322,3 +841,43 @@ const htmlPage = (marker: string) => ` `; + +const tanstackIndexRouteSource = (marker: string) => ` +/** @jsxImportSource react */ +import { createFileRoute } from "@tanstack/react-router"; + +const marker = ${JSON.stringify(marker)}; + +export const Route = createFileRoute("/")({ + component: Home, +}); + +function Home() { + return
{marker}
; +} +`; + +const waitForBucketToBeDeleted = Effect.fn(function* ( + bucketName: string, + accountId: string, +) { + yield* r2 + .getBucket({ + accountId, + bucketName, + }) + .pipe( + Effect.flatMap(() => Effect.fail(new BucketStillExists())), + Effect.retry({ + while: (error): error is BucketStillExists => + error instanceof BucketStillExists, + schedule: Schedule.exponential("200 millis").pipe( + Schedule.either(Schedule.spaced("2 seconds")), + Schedule.both(Schedule.recurs(20)), + ), + }), + Effect.catchTag("NoSuchBucket", () => Effect.void), + ); +}); + +class BucketStillExists extends Data.TaggedError("BucketStillExists") {} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/.gitignore b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/.gitignore new file mode 100644 index 000000000..bf778dc43 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/.gitignore @@ -0,0 +1,3 @@ +.alchemy +dist +node_modules diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/alchemy.run.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/alchemy.run.ts new file mode 100644 index 000000000..889b41c95 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/alchemy.run.ts @@ -0,0 +1,39 @@ +import * as Alchemy from "@oddlynew/alchemy"; +import * as Cloudflare from "@oddlynew/alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "CloudflareReactRouterRscFixture", + { + providers: Cloudflare.providers(), + state: Cloudflare.state(), + }, + Effect.gen(function* () { + const worker = yield* Cloudflare.Vite("ReactRouterRscFixture", { + assets: { + runWorkerFirst: true, + }, + compatibility: { + date: "2026-03-10", + flags: ["nodejs_compat"], + }, + memo: { + include: [ + "app/**", + "react-router-vite/**", + "package.json", + "tsconfig.json", + "vite.config.ts", + ], + }, + viteEnvironment: { + name: "rsc", + childEnvironments: ["ssr"], + }, + }); + + return { + url: worker.url, + }; + }), +); diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/root.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/root.tsx new file mode 100644 index 000000000..508e89cb6 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/root.tsx @@ -0,0 +1,36 @@ +import { Link, Outlet } from "react-router"; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + React Router Vite + + +
+ +
+ {children} + + + ); +} + +export default function Component() { + return ; +} + +export function ErrorBoundary({ error }: { error?: unknown }) { + return ( +
+

React Router RSC fixture error

+
{error instanceof Error ? error.message : String(error)}
+
+ ); +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes.ts new file mode 100644 index 000000000..5a2907fb9 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes.ts @@ -0,0 +1,21 @@ +import type { unstable_RSCRouteConfigEntry } from "react-router"; + +export const routes: Array = [ + { + id: "root", + path: "", + lazy: () => import("./root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + { + id: "about", + path: "about", + lazy: () => import("./routes/about"), + }, + ], + }, +]; diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes/about.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes/about.tsx new file mode 100644 index 000000000..88c19c8bf --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes/about.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useState } from "react"; + +export function Component() { + const [count, setCount] = useState(0); + + return ( +
+

About

+

This client route verifies the client environment is still emitted.

+ +
+ ); +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes/home.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes/home.tsx new file mode 100644 index 000000000..a2d167c52 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes/home.tsx @@ -0,0 +1,10 @@ +const Component = () => { + return ( +
+

React Router Vite

+

Alchemy-owned React Router RSC fixture.

+
+ ); +}; + +export default Component; diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/package.json b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/package.json new file mode 100644 index 000000000..243e079ea --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/package.json @@ -0,0 +1,25 @@ +{ + "name": "@oddlynew/alchemy-fixture-react-router-rsc", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "alchemy dev", + "build": "vite build" + }, + "dependencies": { + "@oddlynew/alchemy": "workspace:*", + "effect": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-router": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@vitejs/plugin-rsc": "catalog:", + "vite": "catalog:" + } +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.browser.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.browser.tsx new file mode 100644 index 000000000..df0e120a2 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.browser.tsx @@ -0,0 +1,56 @@ +import { + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, +} from "@vitejs/plugin-rsc/browser"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import type { + DataRouter, + unstable_RSCPayload as RSCServerPayload, +} from "react-router"; +import { + unstable_createCallServer as createCallServer, + unstable_getRSCStream as getRSCStream, + unstable_RSCHydratedRouter as RSCHydratedRouter, +} from "react-router/dom"; + +// Create and set the callServer function to support post-hydration server actions. +setServerCallback( + createCallServer({ + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + }), +); + +// Get and decode the initial server payload +createFromReadableStream(getRSCStream()).then((payload) => { + startTransition(async () => { + const formState = + payload.type === "render" ? await payload.formState : undefined; + + hydrateRoot( + document, + + + , + { + // @ts-expect-error - no types for this yet + formState, + }, + ); + }); +}); + +declare let __reactRouterDataRouter: DataRouter; + +if (import.meta.hot) { + import.meta.hot.on("rsc:update", () => { + __reactRouterDataRouter.revalidate(); + }); +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.rsc.single.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.rsc.single.tsx new file mode 100644 index 000000000..5a21ed4f8 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.rsc.single.tsx @@ -0,0 +1,8 @@ +import type * as EntrySsr from "./entry.ssr"; +import { fetchServer } from "./entry.rsc"; + +export default async function handler(request: Request) { + const ssr = await import.meta.viteRsc.loadModule("ssr", "index"); + + return ssr.default(request, await fetchServer(request)); +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.rsc.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.rsc.tsx new file mode 100644 index 000000000..5d3d27fa7 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.rsc.tsx @@ -0,0 +1,36 @@ +import { + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + renderToReadableStream, +} from "@vitejs/plugin-rsc/rsc"; +import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router"; +import { routes } from "../app/routes"; + +export function fetchServer(request: Request) { + return matchRSCServerRequest({ + // Provide the React Server touchpoints. + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + // The incoming request. + request, + // The app routes. + routes, + // Encode the match with the React Server implementation. + generateResponse(match, options) { + return new Response(renderToReadableStream(match.payload, options), { + status: match.statusCode, + headers: match.headers, + }); + }, + }); +} + +if (import.meta.hot) { + import.meta.hot.accept(); +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.ssr.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.ssr.tsx new file mode 100644 index 000000000..8efaba121 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.ssr.tsx @@ -0,0 +1,33 @@ +import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; +import { renderToReadableStream as renderHTMLToReadableStream } from "react-dom/server.edge"; +import { + unstable_routeRSCServerRequest as routeRSCServerRequest, + unstable_RSCStaticRouter as RSCStaticRouter, +} from "react-router"; + +export default async function handler( + request: Request, + serverResponse: Response, +): Promise { + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent("index"); + + return await routeRSCServerRequest({ + request, + serverResponse, + createFromReadableStream, + async renderHTML(getPayload, options) { + const payload = getPayload(); + + return await renderHTMLToReadableStream( + , + { + ...options, + bootstrapScriptContent, + signal: request.signal, + formState: await payload.formState, + }, + ); + }, + }); +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.worker.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.worker.tsx new file mode 100644 index 000000000..99a4274b0 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.worker.tsx @@ -0,0 +1,22 @@ +import type * as WorkerSsr from "./worker-ssr"; +import handler from "./entry.rsc.single"; + +// The distilled Cloudflare worker wrapper expects a `{ fetch }` default export; +// the RSC single-worker handler is a bare (request) => Response function. +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + // Worker code that needs a non-`react-server` module (here `react-dom/server`) + // must not import it directly in this `rsc` entry — it loads it from the + // `ssr` environment via `loadModule`. Exercises a custom (non-`index`) ssr + // input + cross-environment load through the distilled plugin. + if (url.pathname === "/worker-render") { + const { renderWorkerHtml } = + await import.meta.viteRsc.loadModule("ssr", "worker-ssr"); + return Response.json({ ok: true, html: renderWorkerHtml() }); + } + + return handler(request); + }, +}; diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/worker-ssr.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/worker-ssr.tsx new file mode 100644 index 000000000..00880f6cd --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/worker-ssr.tsx @@ -0,0 +1,13 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server.edge"; + +// Lives in the `ssr` environment (no `react-server` condition), so it can use +// `react-dom/server` — which would fail if imported directly in the worker's +// `rsc` entry. The worker reaches it via `loadModule("ssr", "worker-ssr")`. +// This is the pattern James Opstad landed on in +// github.com/agcty/vite-rsc-worker-env-repro PR #1. +export function renderWorkerHtml(): string { + return renderToStaticMarkup( + createElement("section", null, "Worker render via the ssr environment."), + ); +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/tsconfig.json b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/tsconfig.json new file mode 100644 index 000000000..cbf6b0b9b --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": [ + "alchemy.run.ts", + "app/**/*.ts", + "app/**/*.tsx", + "vite.config.ts" + ], + "compilerOptions": { + "allowImportingTsExtensions": true, + "composite": true, + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "jsxImportSource": "react", + "noEmit": true, + "skipLibCheck": true, + "target": "ESNext", + "types": ["vite/client", "react"] + } +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/vite.config.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/vite.config.ts new file mode 100644 index 000000000..5ed7e3715 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/vite.config.ts @@ -0,0 +1,47 @@ +import react from "@vitejs/plugin-react"; +import rsc from "@vitejs/plugin-rsc"; +import { defineConfig } from "vite"; + +// React Router RSC wired directly on @vitejs/plugin-rsc. Alchemy injects the +// distilled Cloudflare Vite plugin at build/dev time through Cloudflare.Vite. +export default defineConfig({ + clearScreen: false, + build: { minify: false }, + resolve: { + dedupe: ["react", "react-dom"], + }, + plugins: [ + react(), + rsc({ + serverHandler: false, + entries: { + client: "./react-router-vite/entry.browser.tsx", + ssr: "./react-router-vite/entry.ssr.tsx", + rsc: "./react-router-vite/entry.worker.tsx", + }, + }), + ], + environments: { + // The Worker is the RSC environment. Alchemy passes this environment + // topology to the distilled plugin via Cloudflare.Vite. + rsc: { + build: { + rollupOptions: { + input: { "entry.worker": "./react-router-vite/entry.worker.tsx" }, + }, + }, + }, + // A second `ssr` input the worker loads on demand via + // loadModule("ssr", "worker-ssr") — alongside the framework's `index`. + ssr: { + build: { + rollupOptions: { + input: { "worker-ssr": "./react-router-vite/worker-ssr.tsx" }, + }, + }, + }, + }, + optimizeDeps: { + include: ["react-router", "react-router/internal/react-server-client"], + }, +}); diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/.gitignore b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/.gitignore new file mode 100644 index 000000000..15db99939 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/.gitignore @@ -0,0 +1,6 @@ +node_modules +.tanstack +.nitro +.output +dist +.alchemy diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/alchemy.run.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/alchemy.run.ts new file mode 100644 index 000000000..11b63bf9b --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/alchemy.run.ts @@ -0,0 +1,44 @@ +import * as Alchemy from "@oddlynew/alchemy"; +import * as Cloudflare from "@oddlynew/alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; + +export const Bucket = Cloudflare.R2Bucket("DevBucket"); + +export const Website = Cloudflare.Vite("TanStackDevBindingsFixture", { + compatibility: { + flags: ["nodejs_compat"], + }, + assets: { + runWorkerFirst: true, + }, + env: { + BUCKET: Bucket, + DEV_MARKER: "manual-dev", + }, + memo: { + include: [ + "src/**", + "package.json", + "tsconfig.json", + "vite.config.ts", + "alchemy.run.ts", + ], + }, +}); + +export type WebsiteEnv = Cloudflare.InferEnv; + +export default Alchemy.Stack( + "CloudflareTanStackDevBindingsFixture", + { + providers: Cloudflare.providers(), + state: Cloudflare.state(), + }, + Effect.gen(function* () { + const website = yield* Website; + + return { + url: website.url, + }; + }), +); diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/package.json b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/package.json new file mode 100644 index 000000000..a83fc0322 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/package.json @@ -0,0 +1,25 @@ +{ + "name": "@oddlynew/alchemy-fixture-tanstack-dev-bindings", + "private": true, + "type": "module", + "scripts": { + "dev": "alchemy dev", + "destroy": "alchemy destroy" + }, + "dependencies": { + "@tanstack/react-router": "^1.167.4", + "@tanstack/react-start": "^1.166.15", + "@oddlynew/alchemy": "workspace:*", + "effect": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "^5.9.3", + "vite": "catalog:" + } +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/env.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/env.ts new file mode 100644 index 000000000..ff28ea666 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/env.ts @@ -0,0 +1,8 @@ +import * as cf from "cloudflare:workers"; +import type { WebsiteEnv } from "../alchemy.run.ts"; + +export const env = new Proxy({} as WebsiteEnv, { + get(_, prop) { + return cf.env[prop as keyof typeof cf.env]; + }, +}); diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routeTree.gen.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routeTree.gen.ts new file mode 100644 index 000000000..509cd4196 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routeTree.gen.ts @@ -0,0 +1,86 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from "./routes/__root"; +import { Route as IndexRouteImport } from "./routes/index"; +import { Route as ApiR2RouteImport } from "./routes/api.r2"; + +const IndexRoute = IndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => rootRouteImport, +} as any); +const ApiR2Route = ApiR2RouteImport.update({ + id: "/api/r2", + path: "/api/r2", + getParentRoute: () => rootRouteImport, +} as any); + +export interface FileRoutesByFullPath { + "/": typeof IndexRoute; + "/api/r2": typeof ApiR2Route; +} +export interface FileRoutesByTo { + "/": typeof IndexRoute; + "/api/r2": typeof ApiR2Route; +} +export interface FileRoutesById { + __root__: typeof rootRouteImport; + "/": typeof IndexRoute; + "/api/r2": typeof ApiR2Route; +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath; + fullPaths: "/" | "/api/r2"; + fileRoutesByTo: FileRoutesByTo; + to: "/" | "/api/r2"; + id: "__root__" | "/" | "/api/r2"; + fileRoutesById: FileRoutesById; +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute; + ApiR2Route: typeof ApiR2Route; +} + +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/": { + id: "/"; + path: "/"; + fullPath: "/"; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/api/r2": { + id: "/api/r2"; + path: "/api/r2"; + fullPath: "/api/r2"; + preLoaderRoute: typeof ApiR2RouteImport; + parentRoute: typeof rootRouteImport; + }; + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ApiR2Route: ApiR2Route, +}; +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes(); + +import type { getRouter } from "./router.tsx"; +import type { createStart } from "@tanstack/react-start"; +declare module "@tanstack/react-start" { + interface Register { + ssr: true; + router: Awaited>; + } +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/router.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/router.tsx new file mode 100644 index 000000000..6f7766f8c --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/router.tsx @@ -0,0 +1,15 @@ +import { createRouter } from "@tanstack/react-router"; +import { routeTree } from "./routeTree.gen"; + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routes/__root.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routes/__root.tsx new file mode 100644 index 000000000..24ac7ac3e --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routes/__root.tsx @@ -0,0 +1,48 @@ +/** @jsxImportSource react */ +import type { ReactNode } from "react"; +import { + HeadContent, + Outlet, + Scripts, + createRootRoute, +} from "@tanstack/react-router"; + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: "utf-8", + }, + { + name: "viewport", + content: "width=device-width, initial-scale=1", + }, + { + title: "TanStack dev bindings fixture", + }, + ], + }), + component: RootComponent, +}); + +function RootComponent() { + return ( + + + + ); +} + +function Document(props: Readonly<{ children: ReactNode }>) { + return ( + + + + + + {props.children} + + + + ); +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routes/api.r2.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routes/api.r2.ts new file mode 100644 index 000000000..bcfb2e780 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routes/api.r2.ts @@ -0,0 +1,43 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "../env.ts"; + +const readKey = (request: Request) => + new URL(request.url).searchParams.get("key"); + +export const Route = createFileRoute("/api/r2")({ + server: { + handlers: { + GET: async ({ request }) => { + const key = readKey(request); + if (!key) { + return Response.json( + { error: "missing key", marker: env.DEV_MARKER, value: null }, + { status: 400 }, + ); + } + + const object = await env.BUCKET.get(key); + return Response.json({ + marker: env.DEV_MARKER, + value: object ? await object.text() : null, + }); + }, + PUT: async ({ request }) => { + const key = readKey(request); + if (!key) { + return Response.json( + { error: "missing key", marker: env.DEV_MARKER }, + { status: 400 }, + ); + } + + await env.BUCKET.put(key, await request.text(), { + httpMetadata: { + contentType: request.headers.get("content-type") ?? "text/plain", + }, + }); + return Response.json({ marker: env.DEV_MARKER, ok: true }); + }, + }, + }, +}); diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routes/index.tsx b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routes/index.tsx new file mode 100644 index 000000000..b0511725e --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/src/routes/index.tsx @@ -0,0 +1,12 @@ +/** @jsxImportSource react */ +import { createFileRoute } from "@tanstack/react-router"; + +const marker = "hmr-marker-fixture"; + +export const Route = createFileRoute("/")({ + component: Home, +}); + +function Home() { + return
{marker}
; +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/tsconfig.json b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/tsconfig.json new file mode 100644 index 000000000..6f8ae17e4 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/tsconfig.json @@ -0,0 +1,26 @@ +{ + "include": [ + "alchemy.run.ts", + "src/**/*.ts", + "src/**/*.tsx", + "vite.config.ts" + ], + "compilerOptions": { + "composite": true, + "rootDir": ".", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "Preserve", + "moduleResolution": "Bundler", + "target": "ESNext", + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "allowJs": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "moduleDetection": "force", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "types": ["bun", "vite/client", "@cloudflare/workers-types"] + } +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/vite.config.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/vite.config.ts new file mode 100644 index 000000000..c30d7e5da --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/tanstack-dev-bindings-fixture/vite.config.ts @@ -0,0 +1,12 @@ +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root: __dirname, + plugins: [tanstackStart(), viteReact()], +}); diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/.gitignore b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/.gitignore new file mode 100644 index 000000000..bf778dc43 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/.gitignore @@ -0,0 +1,3 @@ +.alchemy +dist +node_modules diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/alchemy.run.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/alchemy.run.ts new file mode 100644 index 000000000..0abf12a31 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/alchemy.run.ts @@ -0,0 +1,31 @@ +import * as Alchemy from "@oddlynew/alchemy"; +import * as Cloudflare from "@oddlynew/alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import type { Counter } from "./src/worker.ts"; + +export default Alchemy.Stack( + "CloudflareViteDoFixture", + { + providers: Cloudflare.providers(), + state: Cloudflare.state(), + }, + Effect.gen(function* () { + const worker = yield* Cloudflare.Vite("ViteDoFixture", { + assets: { + runWorkerFirst: ["/api/*"], + }, + env: { + Counter: Cloudflare.DurableObjectNamespace("Counter", { + className: "Counter", + }), + }, + memo: { + include: ["index.html", "src/**", "package.json", "vite.config.ts"], + }, + }); + + return { + url: worker.url, + }; + }), +); diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/index.html b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/index.html new file mode 100644 index 000000000..34d7af37b --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/index.html @@ -0,0 +1,11 @@ + + + + + Vite DO fixture + + +
Vite DO fixture
+ + + diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/package.json b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/package.json new file mode 100644 index 000000000..82305135f --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/package.json @@ -0,0 +1,14 @@ +{ + "name": "@oddlynew/alchemy-fixture-vite-do", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "alchemy dev" + }, + "devDependencies": { + "@oddlynew/alchemy": "workspace:*", + "effect": "catalog:", + "vite": "catalog:" + } +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/src/main.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/src/main.ts new file mode 100644 index 000000000..1db10ace1 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/src/main.ts @@ -0,0 +1,4 @@ +const el = document.getElementById("app"); +if (el) { + el.textContent = "Vite DO fixture hydrated"; +} diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/src/worker.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/src/worker.ts new file mode 100644 index 000000000..161aa68dc --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/src/worker.ts @@ -0,0 +1,56 @@ +import { DurableObject } from "cloudflare:workers"; + +const COUNT_KEY = "count"; + +type CounterStub = { + get(): Promise; + increment(): Promise; + reset(): Promise; +}; + +type Env = { + ASSETS: { + fetch(request: Request): Promise; + }; + Counter: { + getByName(name: string): CounterStub; + }; +}; + +export class Counter extends DurableObject { + async get() { + return (await this.ctx.storage.get(COUNT_KEY)) ?? 0; + } + + async increment() { + const next = (await this.get()) + 1; + await this.ctx.storage.put(COUNT_KEY, next); + return next; + } + + async reset() { + await this.ctx.storage.delete(COUNT_KEY); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const counter = env.Counter.getByName("vite-do-fixture"); + + if (url.pathname === "/api/count") { + return Response.json({ count: await counter.increment() }); + } + + if (url.pathname === "/api/current") { + return Response.json({ count: await counter.get() }); + } + + if (url.pathname === "/api/reset") { + await counter.reset(); + return Response.json({ ok: true }); + } + + return env.ASSETS.fetch(request); + }, +}; diff --git a/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/vite.config.ts b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/vite.config.ts new file mode 100644 index 000000000..0803fe1b2 --- /dev/null +++ b/projects/alchemy/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + environments: { + ssr: { + build: { + rollupOptions: { + input: "./src/worker.ts", + }, + }, + }, + }, +}); diff --git a/projects/alchemy/packages/alchemy/test/Diff.test.ts b/projects/alchemy/packages/alchemy/test/Diff.test.ts index 3c3ecc27d..0dccadbc3 100644 --- a/projects/alchemy/packages/alchemy/test/Diff.test.ts +++ b/projects/alchemy/packages/alchemy/test/Diff.test.ts @@ -1,6 +1,8 @@ -import { deepEqual, havePropsChanged } from "@/Diff"; +import { deepEqual, havePropsChanged, isResolved } from "@/Diff"; import { describe, expect, test } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; +import { Bucket } from "./test.resources.ts"; describe("Diff", () => { describe("havePropsChanged with Redacted values", () => { @@ -108,4 +110,16 @@ describe("Diff", () => { ).toBe(false); }); }); + + describe("isResolved with Effect-valued props", () => { + test("a per-field Effect input counts as unresolved", () => { + expect(isResolved({ domain: Effect.succeed("example.com") })).toBe(false); + }); + + test("a non-class resource reference counts as resolved (opaque)", () => { + // `resolveInput` never executes resource references (see Plan.ts), + // so their presence must not disable a provider's custom diff. + expect(isResolved({ env: { DB: Bucket("A", {}) } })).toBe(true); + }); + }); }); diff --git a/projects/alchemy/packages/alchemy/test/Output.test.ts b/projects/alchemy/packages/alchemy/test/Output.test.ts index 6b5e31249..66cd82a1d 100644 --- a/projects/alchemy/packages/alchemy/test/Output.test.ts +++ b/projects/alchemy/packages/alchemy/test/Output.test.ts @@ -5,7 +5,9 @@ import { Stack } from "@/Stack"; import { Stage } from "@/Stage"; import { inMemoryState } from "@/State/InMemoryState"; import type { ResourceState } from "@/State/ResourceState"; +import { effectClass } from "@/Util/effect"; import { describe, expect, it } from "@effect/vitest"; +import { Bucket } from "./test.resources.ts"; import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -168,6 +170,105 @@ describe("Output.evaluate", () => { ); }); + describe("per-field Effect inputs", () => { + it.effect("resolves a raw Effect value at the top level", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate(Effect.succeed(1337), {}); + expect(result).toBe(1337); + }), + ), + ); + + it.effect("resolves an Effect value nested inside an object", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate( + { domain: Effect.succeed("example.com"), url: false }, + {}, + ); + expect(result).toEqual({ domain: "example.com", url: false }); + }), + ), + ); + + it.effect("resolves an Effect value nested inside an array", () => + provideState( + Effect.gen(function* () { + const [result] = yield* Output.evaluate([Effect.succeed(42)], {}); + expect(result).toBe(42); + }), + ), + ); + + it.effect("an Effect resolving to undefined yields undefined", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate( + { domain: Effect.succeed(undefined) }, + {}, + ); + expect(result.domain).toBeUndefined(); + }), + ), + ); + + it.effect("resolves recursively into the Effect's result", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate( + Effect.succeed({ nested: Effect.succeed("x") }), + {}, + ); + expect(result).toEqual({ nested: "x" }); + }), + ), + ); + + it.effect("an Effect resolving to a Redacted keeps it wrapped", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate( + Effect.succeed(Redacted.make("hunter2")), + {}, + ); + expect(Redacted.isRedacted(result)).toBe(true); + expect( + Redacted.value(result as unknown as Redacted.Redacted), + ).toBe("hunter2"); + }), + ), + ); + + it.effect("function-form Effects (effect classes) stay opaque", () => + provideState( + Effect.gen(function* () { + // Resource classes, `Binding.Policy` tags, and `effectClass` + // constructors are real Effects (`Effect.isEffect(X)` is true) + // but are carried in props as class references (e.g. Worker + // `exports`) — evaluate must not run them. + const cls = effectClass(Effect.succeed("must-not-run")); + const result = yield* Output.evaluate({ exports: { X: cls } }, {}); + expect(result.exports.X).toBe(cls); + }), + ), + ); + + it.effect("non-class resource references stay opaque", () => + provideState( + Effect.gen(function* () { + // `const db = Hyperdrive("db", …)` returns an object-form Effect + // too, but it is a handle to a stack resource — evaluate must + // not run it (execution outside the construction phase would + // mint a phantom resource under a different FQN). + const ref = Bucket("A", {}); + const result = yield* Output.evaluate({ env: { DB: ref } }, {}); + expect(result.env.DB).toBe(ref); + }), + ), + ); + }); + describe("LiteralExpr", () => { it.effect("evaluates Output.literal(value)", () => provideState( diff --git a/projects/alchemy/packages/alchemy/test/apply.test.ts b/projects/alchemy/packages/alchemy/test/apply.test.ts index 0148e7e01..1af9183a0 100644 --- a/projects/alchemy/packages/alchemy/test/apply.test.ts +++ b/projects/alchemy/packages/alchemy/test/apply.test.ts @@ -18,6 +18,7 @@ import * as Redacted from "effect/Redacted"; import { ArtifactProbe, BindingTarget, + Bucket, DeletedBindingRegressionTarget, DurationResource, Function, @@ -4614,3 +4615,151 @@ describe("Duration round-trip through state", () => { }), ); }); + +describe("per-field Effect props", () => { + // Stage-conditional props expressed as per-field Effects + // (`Stack.useSync(...)`) are the only shape that keeps class-form + // `InferEnv` literal inference while varying values per stage. The engine + // must resolve them before any provider lifecycle method reads the props. + test.provider( + "a Stack.useSync prop resolves before the provider sees it", + (stack) => + Effect.gen(function* () { + const seen: unknown[] = []; + const capture = { + create: (id: string, props: TestResourceProps) => + Effect.sync(() => { + if (id === "A") seen.push(props.string); + }), + }; + + const output = yield* TestResource("A", { + string: Stack.useSync((s) => + s.stage === "test" ? `from-${s.stage}` : undefined, + ) as any, + }).pipe(stack.deploy, hook(capture)); + + expect(output.string).toBe("from-test"); + expect(seen).toHaveLength(1); + expect(Effect.isEffect(seen[0])).toBe(false); + expect(seen[0]).toBe("from-test"); + + // The persisted props must hold the concrete value, not a + // shredded Effect object. + const persisted = yield* getState<{ + props: TestResourceProps; + }>("A"); + expect(persisted.props.string).toBe("from-test"); + }), + ); + + test.provider( + "a Stack.useSync prop resolving to undefined arrives as a real undefined", + (stack) => + Effect.gen(function* () { + // The Worker `domain` case: an unresolved Effect object is truthy, + // so a stage where the value resolves to `undefined` would wrongly + // enter reconciliation (and crash the API client). A resolved + // `undefined` keeps provider skip-guards working. + const seen: unknown[] = []; + const capture = { + create: (id: string, props: TestResourceProps) => + Effect.sync(() => { + if (id === "A") seen.push(props.string); + }), + }; + + const output = yield* TestResource("A", { + string: Stack.useSync((s) => + s.stage === "prod" ? "prod-only" : undefined, + ) as any, + }).pipe(stack.deploy, hook(capture)); + + expect(seen).toHaveLength(1); + expect(seen[0]).toBeUndefined(); + // Provider fell through its `news.string ?? id` default — the + // undefined was a genuine undefined, not a truthy Effect object. + expect(output.string).toBe("A"); + }), + ); + + // Non-class resource references (`const db = Cloudflare.Hyperdrive("db", + // …)`) are Effects too. They must NOT be executed by input resolution: + // re-running the constructor outside the construction phase re-derives the + // FQN from the ambient namespace (none at plan/apply time) and mints a + // phantom resource — `MissingSourceError: Source not found`. + test.provider( + "a resource reference in another resource's props stays opaque", + (stack) => + Effect.gen(function* () { + const seen: unknown[] = []; + const capture = { + create: (id: string, props: TestResourceProps) => + Effect.sync(() => { + if (id === "B") seen.push(props.object?.string); + }), + }; + + // Hoisted reference, the way an app hoists + // `const db = Cloudflare.Hyperdrive("db", …)`. + const bucket = Bucket("Bucket", {}); + + yield* Effect.gen(function* () { + // Register the reference under a namespace — the way a Worker's + // env binding values are registered inside the composite's + // `Namespace.push(id)` scope. Executing the reference outside + // this scope would compute FQN "Bucket" instead of "NS/Bucket". + const Site = Construct.fn(function* (_id: string, _props: {}) { + return yield* bucket; + }); + yield* Site("NS", {}); + yield* TestResource("B", { + string: "x", + object: { string: bucket as any }, + }); + }).pipe(stack.deploy, hook(capture)); + + // The deploy converged, and the reference reached the provider as + // the same un-executed Effect object — no phantom resource, no + // MissingSourceError. + expect(seen).toHaveLength(1); + expect(seen[0]).toBe(bucket); + }), + ); + + test.provider( + "a plain Effect wrapping a resource reference resolves through the reference", + (stack) => + Effect.gen(function* () { + const seen: unknown[] = []; + const capture = { + create: (id: string, props: TestResourceProps) => + Effect.sync(() => { + if (id === "B") seen.push(props.string); + }), + }; + + // The `AI_GATEWAY_ID: Effect.map(aiGateway, g => g.gatewayId)` + // shape: a plain (unbranded) Effect wrapping a resource reference. + // Resolving it executes the wrapper — the wrapped reference must + // return the resource registered during construction (under + // "NS/Bucket"), not mint a phantom root-level "Bucket". + const bucket = Bucket("Bucket", {}); + + const output = yield* Effect.gen(function* () { + const Site = Construct.fn(function* (_id: string, _props: {}) { + return yield* bucket; + }); + yield* Site("NS", {}); + return yield* TestResource("B", { + string: Effect.map(bucket, (b) => b.name) as any, + }); + }).pipe(stack.deploy, hook(capture)); + + // The wrapper resolved to the real resource's attribute and the + // provider saw the concrete value. + expect(seen).toEqual(["Bucket"]); + expect(output.string).toBe("Bucket"); + }), + ); +}); diff --git a/projects/alchemy/packages/alchemy/test/plan.test.ts b/projects/alchemy/packages/alchemy/test/plan.test.ts index fcaf70b6c..e9fa7ad54 100644 --- a/projects/alchemy/packages/alchemy/test/plan.test.ts +++ b/projects/alchemy/packages/alchemy/test/plan.test.ts @@ -2552,6 +2552,127 @@ describe("Config props are resolved through plan", () => { ); }); +describe("per-field Effect props are resolved through plan", () => { + test( + "an Effect prop is resolved to its concrete value in the plan", + Effect.gen(function* () { + const plan = yield* Effect.gen(function* () { + yield* TestResource("A", { + string: Effect.succeed("resolved-effect-value") as any, + }); + }).pipe(makePlan); + + const node: any = plan.resources.A!; + expect(node.action).toBe("create"); + const props = node.props as TestResourceProps; + expect(Effect.isEffect(props.string)).toBe(false); + expect(props.string).toBe("resolved-effect-value"); + }), + ); + + test( + "an Effect prop resolving to undefined lands as undefined", + Effect.gen(function* () { + // The domain-shaped case from the Worker provider: an unresolved + // Effect is truthy, so a stage-conditional prop that should be + // "off" would wrongly enter reconciliation. A resolved `undefined` + // keeps provider guards like `domain === undefined` working. + const plan = yield* Effect.gen(function* () { + yield* TestResource("A", { + string: Effect.succeed(undefined) as any, + }); + }).pipe(makePlan); + + const node: any = plan.resources.A!; + expect(node.action).toBe("create"); + const props = node.props as TestResourceProps; + expect(props.string).toBeUndefined(); + }), + ); + + test( + "an Effect nested inside an object prop is resolved in the plan", + Effect.gen(function* () { + const plan = yield* Effect.gen(function* () { + yield* TestResource("A", { + object: { string: Effect.succeed("nested") as any }, + }); + }).pipe(makePlan); + + const node: any = plan.resources.A!; + expect(node.action).toBe("create"); + const props = node.props as TestResourceProps; + expect(props.object).toEqual({ string: "nested" }); + }), + ); + + test( + "diff observes resolved Effect props (noop when value is unchanged)", + Effect.gen(function* () { + yield* seed({ + A: { + instanceId, + providerVersion: 0, + logicalId: "A", + fqn: "A", + namespace: undefined, + resourceType: "Test.TestResource", + status: "created", + props: { + string: "same", + }, + attr: { + string: "same", + stringArray: [], + stableString: "A", + stableArray: ["A"], + replaceString: undefined, + redacted: undefined, + redactedArray: undefined, + }, + downstream: [], + bindings: [], + }, + }); + // If the Effect reached `diff` unresolved, the provider's + // `isResolved(news)` guard would bail and the engine would fall back + // to a conservative prop-hash comparison against the raw Effect — + // producing a spurious update. A noop proves diff saw the value. + const plan = yield* Effect.gen(function* () { + yield* TestResource("A", { + string: Effect.succeed("same") as any, + }); + }).pipe(makePlan); + + expect(plan.resources.A!.action).toBe("noop"); + }), + ); + + test( + "a resource reference prop stays opaque in the plan", + Effect.gen(function* () { + // Non-class resource reference (`const db = Hyperdrive("db", …)`) — + // an Effect, but a handle to a stack resource. resolveInput must not + // execute it: outside the construction phase the ambient namespace + // differs, so execution would mint a phantom resource. + const bucket = Bucket("A", {}); + const plan = yield* Effect.gen(function* () { + yield* bucket; + yield* TestResource("B", { + object: { string: bucket as any }, + }); + }).pipe(makePlan); + + // No phantom resource appeared in the plan. + expect(Object.keys(plan.resources).sort()).toEqual(["A", "B"]); + const node: any = plan.resources.B!; + const props = node.props as TestResourceProps; + // Same reference, un-executed. + expect(props.object!.string).toBe(bucket as any); + }), + ); +}); + describe("Redacted props/outputs are preserved through plan", () => { test( "Redacted prop on a new resource is preserved as a Redacted in the plan", diff --git a/projects/alchemy/packages/alchemy/tsconfig.test.json b/projects/alchemy/packages/alchemy/tsconfig.test.json index 7a0b0623c..a090de670 100644 --- a/projects/alchemy/packages/alchemy/tsconfig.test.json +++ b/projects/alchemy/packages/alchemy/tsconfig.test.json @@ -3,7 +3,8 @@ "include": ["package.json", "test"], "exclude": [ "test/Local/fixtures/rpc-server-entry.ts", - "test/Local/fixtures/rpc-spawner-parent.ts" + "test/Local/fixtures/rpc-spawner-parent.ts", + "test/Cloudflare/Website/react-router-rsc-fixture" ], "compilerOptions": { "module": "Preserve", diff --git a/projects/cloudflare-tools/docs/architecture/build-manifest.md b/projects/cloudflare-tools/docs/architecture/build-manifest.md new file mode 100644 index 000000000..c02150f0d --- /dev/null +++ b/projects/cloudflare-tools/docs/architecture/build-manifest.md @@ -0,0 +1,167 @@ +# Build Manifest Architecture + +The distilled Cloudflare Vite plugin is Wrangler-free. A production build must +therefore emit enough information for a deploy consumer, such as Alchemy's +`Cloudflare.Vite`, to upload the already-built Worker modules and static assets +without reverse engineering Vite output directories. + +The build manifest is written as `__distilled-build.json` in the build output +root. All paths in the manifest are POSIX paths relative to that manifest file. + +## Manifest Shape + +```ts +type BuildManifest = { + version: 2; + workers: { + app: { + main: string; + modules: Array<{ + path: string; + type: "esm" | "wasm" | "data" | "text" | "json"; + }>; + compatibilityDate?: string; + compatibilityFlags?: Array; + }; + }; + assets?: { + directory: string; + htmlHandling?: "auto-trailing-slash" | "force-trailing-slash" | "drop-trailing-slash" | "none"; + notFoundHandling?: "none" | "404-page" | "single-page-application"; + runWorkerFirst?: boolean | Array; + }; +}; +``` + +`version` is required so deploy consumers can reject unknown contract versions. + +`workers.app` is the only Worker key emitted today. Additional `workers.*` keys +are reserved for future multiple-Worker support, but this contract does not +currently imply that deployers should expect or synthesize extra Workers. + +`assets.directory`, when present, points to the static asset output directory +relative to the manifest root. It is derived from Vite's client build output and +is not user-configurable in Vite mode. + +`assets.htmlHandling`, `assets.notFoundHandling`, and `assets.runWorkerFirst` +mirror the Vite plugin's asset routing options. They are emitted only when the +application configures them, so deployers can preserve dev/build routing +semantics without guessing. + +## Worker Modules + +`workers.app.main` is the entry module to upload as the Worker entrypoint. + +`workers.app.modules` is the complete module set for that Worker. Entries are +sorted by `path`, unique, and typed explicitly so deployers do not need to infer +Cloudflare module kind from file extension. + +Module types map as follows: + +| Manifest type | Source files | Cloudflare module kind | +| ------------- | ----------------------------------- | ---------------------- | +| `esm` | `.js`, `.mjs` | ES module | +| `wasm` | `.wasm`, `.wasm?module` emissions | Wasm module | +| `data` | `.bin` | Data module | +| `text` | `.txt`, `.html`, `.sql` | Text module | +| `json` | `.json`, except Vite build metadata | JSON module | + +Source maps are not Worker modules. If deploy consumers need source-map upload +metadata later, that should be a separate manifest field rather than mixed into +`workers.app.modules`. + +## RSC Topology + +React Server Components can produce several Vite environment outputs for one +deployed Worker. In the React Router / `@vitejs/plugin-rsc` topology used by +this plugin, the Worker runs in the `rsc` environment and loads the `ssr` +environment at runtime through `import.meta.viteRsc.loadModule("ssr", ...)`. + +The `ssr` output is not a separate service Worker in this topology. It is a +second module graph whose generated files are imported by the `rsc` Worker +module graph. The manifest therefore folds the entry environment output and all +configured child environment outputs into one `workers.app.modules` set. + +This preserves the relative import layout emitted by the framework. Deployers +should upload the folded module set as one Worker, not rewrite the child output +into a service binding. + +## Compatibility Metadata + +`compatibilityDate` and `compatibilityFlags` record what the Worker was compiled +against. When present, deployers must deploy with the same values. + +`compatibilityDate` is optional because the plugin option is optional. Absence is +still authoritative: a deployer should fail or require an explicit deploy-time +value rather than silently substituting a default and claiming it matches the +build. + +## Vite Configuration Defaults + +The Vite plugin owns the Vite asset output path, so static apps can use +`cloudflare()` with no options and Worker apps do not specify an asset +directory. Asset routing options live at the top level: + +```ts +cloudflare({ + main: "./worker/index.ts", + assets: { + notFoundHandling: "single-page-application", + }, +}); +``` + +The legacy `worker.assets` location is still accepted for advanced runtime +configuration. If the same routing option is provided in both places with +different values, the plugin throws instead of choosing one silently. + +The plugin intentionally does not infer `main` or default the production +`compatibilityDate`. Those values define Worker architecture and runtime +semantics, so a deployer should either read the explicit build metadata or ask +for a deploy-time value. + +## Emission Rules + +A successful Worker build emits a fresh manifest. Before writing, the plugin +removes any stale manifest at the target path. + +If a build has no Worker entry, such as a pure SPA or assets-only build, the +plugin emits no manifest and removes any stale manifest. + +Worker environment output directories are emptied before the build writes. This +keeps stale files from prior builds out of `workers.app.modules`, including in +frameworks that perform scan passes before final write passes. + +Every Worker module path must stay inside the manifest root. If entry and child +environment outputs split across incompatible roots, the plugin skips manifest +emission and warns rather than writing a graph whose generated relative imports +cannot resolve. + +## Future: Multiple Workers + +Alchemy can model multiple Cloudflare Workers and native service bindings. That +is the right abstraction for real Worker-to-Worker relationships. + +The manifest shape is intentionally compatible with future multiple-Worker +support by nesting Worker descriptions under `workers`. A future API could look +like this: + +```ts +cloudflare({ + workers: { + app: { + main: "./app/entry.worker.ts", + viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }, + }, + api: { + main: "./api/worker.ts", + }, + }, +}); +``` + +That future API should add explicit deployment units to the manifest, for +example `workers.api`. It should not infer service Workers from +`viteEnvironment.childEnvironments`; a Vite child environment is a module graph +loaded by another environment unless a framework/runtime protocol says +otherwise. diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/README.md b/projects/cloudflare-tools/fixtures/react-router-rsc/README.md new file mode 100644 index 000000000..27e7f9c77 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/README.md @@ -0,0 +1,34 @@ +# rsc react-router + +https://vite-rsc-react-router.hiro18181.workers.dev + +> [!NOTE] +> React Router now provides [official RSC support](https://reactrouter.com/how-to/react-server-components) for Vite. The example might not be kept up to date with the latest version. Please refer to React Router's official documentation for the latest integrations. + +Vite RSC example based on demo made by React router team with Parcel: + +- https://github.com/jacob-ebey/parcel-plugin-react-router/ +- https://github.com/jacob-ebey/experimental-parcel-react-router-starter +- https://github.com/remix-run/react-router/tree/rsc/playground/rsc-vite + +See also [`rsc-movies`](https://github.com/hi-ogawa/rsc-movies/). + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/react-router?file=src%2Froutes%2Froot.tsx) + +Or try it locally by: + +```sh +npx giget gh:vitejs/vite-plugin-react/packages/plugin-rsc/examples/react-router my-app +cd my-app +npm i +npm run dev +npm run build +npm run preview + +# run on @cloudflare/vite-plugin and deploy. +# a separate configuration is found in ./cf/vite.config.ts +npm run cf-dev +npm run cf-build +npm run cf-preview +npm run cf-release +``` diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/paper.css b/projects/cloudflare-tools/fixtures/react-router-rsc/app/paper.css new file mode 100644 index 000000000..84d3e8723 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/paper.css @@ -0,0 +1,150 @@ +@theme { + --default-font-family: "Patrick Hand SC", sans-serif; + --default-mono-font-family: "Patrick Hand SC", sans-serif; + + --color-foreground: black; + --color-danger: rgb(167, 52, 45); + --color-secondary: rgb(11, 116, 213); + --color-success: rgb(134, 163, 97); + --color-warning: rgb(221, 205, 69); + --color-border: #cdcccb; + --color-border-active: rgba(0, 0, 0, 0.2); + + --color-paper-background: white; + --color-paper-border: #cdcccb; + --shadow-paper: -1px 5px 35px -9px rgba(0, 0, 0, 0.2); + + --shadow-btn: 15px 28px 25px -18px rgba(0, 0, 0, 0.2); + --shadow-btn-hover: 2px 8px 8px -5px rgba(0, 0, 0, 0.3); + --color-btn-border: black; + --btn-color-danger: var(--color-danger); + --btn-color-secondary: var(--color-secondary); + --btn-color-success: var(--color-success); + --btn-color-warning: var(--color-warning); +} + +@utility paper-border { + @apply border-2 border-border; + border-bottom-left-radius: 25px 115px; + border-bottom-right-radius: 155px 25px; + border-top-left-radius: 15px 225px; + border-top-right-radius: 25px 150px; +} + +@utility no-paper-border { + @apply border-0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +@utility paper-underline { + @apply border-b-3 border-[currentcolor]; + border-bottom-left-radius: 15px 3px; + border-bottom-right-radius: 15px 5px; + border-bottom-style: solid; +} + +@utility paper-underline-hover { + @apply paper-underline border-transparent; + @variant hover { + @apply border-[currentcolor]; + } +} + +@utility paper { + @apply border border-paper-border bg-paper-background p-8 shadow-paper; +} + +@utility breadcrumbs { + @apply flex flex-wrap gap-2; + & > * { + @apply inline-block after:text-lg after:content-[""] not-last:after:ml-2 not-last:after:text-foreground not-last:after:content-["/"]; + } + & > a { + @apply text-secondary; + } +} + +@utility btn { + @apply inline-block cursor-pointer bg-paper-background paper-border px-4 py-2 text-lg shadow-btn transition-[shadow_transition]; + + @variant active { + @apply border-border-active; + } + @variant hover { + @apply translate-y-1 shadow-btn-hover; + } + + &.btn-icon { + @apply aspect-square px-2 py-2; + & img, + & svg { + @apply h-7 w-7; + } + } +} + +@utility btn-* { + border-color: --value(--btn-color-*); + color: --value(--btn-color-*); +} + +@utility btn-sm { + @apply px-2 py-1 text-base; +} + +@utility btn-lg { + @apply px-6 py-3 text-2xl; +} + +@utility label { + @apply mb-1 block font-semibold; +} + +@utility input { + @apply paper-border px-3 py-2; + + @variant disabled { + @apply border-border-active; + } +} + +@utility checkbox { + @apply h-6 w-6 paper-border; + + @variant disabled { + @apply border-border-active; + } +} + +@utility select { + @apply paper-border px-3 py-2; + + @variant disabled { + @apply border-border-active; + } +} + +@layer base { + body { + @apply text-foreground; + } + + * { + @apply outline-secondary; + } +} + +@layer utilities { + .prose { + :where(u):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + @apply paper-underline no-underline; + } + + :where(a):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + @apply paper-underline-hover no-underline text-secondary; + } + } +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/root.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/app/root.tsx new file mode 100644 index 000000000..678b3a488 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/root.tsx @@ -0,0 +1,55 @@ +// oxlint-disable-next-line import/no-unassigned-import +import "./styles.css"; +import { Link, Outlet } from "react-router"; +import { TestClientState, TestHydrated } from "./routes/client"; +import { DumpError, GlobalNavigationLoadingBar } from "./routes/root.client"; + +export function Layout({ children }: { children: React.ReactNode }) { + console.log("[debug] root - Layout"); + return ( + + + + + React Router Vite + + +
+ +
+ + {children} + + + ); +} + +export default function Component() { + console.log("[debug] root - Component"); + return ( + <> + + + ); +} + +export function ErrorBoundary() { + return ; +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes.ts b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes.ts new file mode 100644 index 000000000..5a2907fb9 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes.ts @@ -0,0 +1,21 @@ +import type { unstable_RSCRouteConfigEntry } from "react-router"; + +export const routes: Array = [ + { + id: "root", + path: "", + lazy: () => import("./root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + { + id: "about", + path: "about", + lazy: () => import("./routes/about"), + }, + ], + }, +]; diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/about.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/about.tsx new file mode 100644 index 000000000..583f190fb --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/about.tsx @@ -0,0 +1,20 @@ +"use client"; + +import React from "react"; + +export function Component() { + const [count, setCount] = React.useState(0); + + return ( +
+
+

About

+

This is the about page.

+

[test-style-home]

+ +
+
+ ); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/client.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/client.tsx new file mode 100644 index 000000000..8c23e84cb --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/client.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; + +export function TestHydrated() { + const hydrated = React.useSyncExternalStore( + React.useCallback(() => () => {}, []), + () => true, + () => false, + ); + return [hydrated: {hydrated ? 1 : 0}]; +} + +export function TestClientState() { + return ; +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.actions.ts b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.actions.ts new file mode 100644 index 000000000..ece1e13aa --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.actions.ts @@ -0,0 +1,7 @@ +"use server"; + +export async function sayHello(defaultName: string, formData: FormData) { + await new Promise((resolve) => setTimeout(resolve, 500)); + const name = formData.get("name") || defaultName; + console.log(`[debug] sayHello - ${name}`); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.client.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.client.tsx new file mode 100644 index 000000000..8f2c4fad7 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.client.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useFormStatus } from "react-dom"; + +export function PendingButton() { + const status = useFormStatus(); + return ( + + ); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.css b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.css new file mode 100644 index 000000000..7204e2fde --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.css @@ -0,0 +1,3 @@ +.test-style-home { + color: rgb(250, 150, 0); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.tsx new file mode 100644 index 000000000..f82ecd772 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/home.tsx @@ -0,0 +1,34 @@ +import { sayHello } from "./home.actions.ts"; +import { PendingButton } from "./home.client.tsx"; +// oxlint-disable-next-line import/no-unassigned-import +import "./home.css"; +import { TestActionStateServer } from "./test-action-state/server.tsx"; + +const Component = () => { + return ( +
+
+

Home

+

This is the home page.

+ [test-style-home] +

Server Action

+
+
+ + +
+
+ +
+
+
+ +
+
+
+ ); +}; + +export default Component; diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/root.client.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/root.client.tsx new file mode 100644 index 000000000..8729caa0a --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/root.client.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useNavigation, useRouteError } from "react-router"; + +export function GlobalNavigationLoadingBar() { + const navigation = useNavigation(); + + if (navigation.state === "idle") return null; + + return ( +
+
+
+ ); +} + +export function DumpError() { + const error = useRouteError(); + const message = + error instanceof Error ? ( +
+
+          {JSON.stringify(
+            {
+              ...error,
+              name: error.name,
+              message: error.message,
+            },
+            null,
+            2,
+          )}
+        
+ {error.stack &&
{error.stack}
} +
+ ) : ( +
Unknown Error
+ ); + return ( + <> +

Oooops

+
{message}
+ + ); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/test-action-state/client.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/test-action-state/client.tsx new file mode 100644 index 000000000..be67c28b0 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/test-action-state/client.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; + +export function TestActionStateClient(props: { + action: (prev: React.ReactNode) => Promise; +}) { + const [state, formAction, isPending] = React.useActionState(props.action, null); + + return ( +
+ + {isPending ? "pending..." : state} +
+ ); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/test-action-state/server.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/test-action-state/server.tsx new file mode 100644 index 000000000..7044c2e99 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/routes/test-action-state/server.tsx @@ -0,0 +1,20 @@ +import { TestActionStateClient } from "./client"; + +// Test case based on +// https://github.com/remix-run/react-router/issues/13882 + +export function TestActionStateServer({ message }: { message: string }) { + return ( + { + "use server"; + await new Promise((resolve) => setTimeout(resolve, 200)); + return ( + + [(ok) ({message})] {prev} + + ); + }} + /> + ); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/app/styles.css b/projects/cloudflare-tools/fixtures/react-router-rsc/app/styles.css new file mode 100644 index 000000000..c66d16481 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/app/styles.css @@ -0,0 +1,32 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/typography"; + +@import "./paper.css"; + +@theme { + --animate-progress: progress 1s infinite linear; + + @keyframes progress { + 0% { + transform: translateX(0) scaleX(0); + } + 40% { + transform: translateX(0) scaleX(0.4); + } + 100% { + transform: translateX(100%) scaleX(0.5); + } + } +} + +@utility vt-name { + view-transition-name: var(--vt-name); +} + +@utility no-vt { + view-transition-name: none; +} + +@view-transition { + navigation: auto; +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/package.json b/projects/cloudflare-tools/fixtures/react-router-rsc/package.json new file mode 100644 index 000000000..b13ea8e86 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/package.json @@ -0,0 +1,30 @@ +{ + "name": "@oddlynew/cloudflare-tools-fixture-react-router-rsc", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite dev --port 3200", + "build": "vite build", + "preview": "vite preview", + "test": "bun test" + }, + "dependencies": { + "react": "catalog:", + "react-dom": "catalog:", + "react-router": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:workers", + "@oddlynew/distilled-cloudflare-runtime": "workspace:*", + "@oddlynew/distilled-cloudflare-vite-plugin": "workspace:*", + "@tailwindcss/typography": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@vitejs/plugin-rsc": "catalog:", + "tailwindcss": "catalog:", + "vite": "catalog:" + } +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/public/favicon.ico b/projects/cloudflare-tools/fixtures/react-router-rsc/public/favicon.ico new file mode 100644 index 000000000..5dbdfcddc Binary files /dev/null and b/projects/cloudflare-tools/fixtures/react-router-rsc/public/favicon.ico differ diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.browser.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.browser.tsx new file mode 100644 index 000000000..f1c3b792f --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.browser.tsx @@ -0,0 +1,49 @@ +import { + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, +} from "@vitejs/plugin-rsc/browser"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import type { DataRouter, unstable_RSCPayload as RSCServerPayload } from "react-router"; +import { + unstable_createCallServer as createCallServer, + unstable_getRSCStream as getRSCStream, + unstable_RSCHydratedRouter as RSCHydratedRouter, +} from "react-router/dom"; + +// Create and set the callServer function to support post-hydration server actions. +setServerCallback( + createCallServer({ + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + }), +); + +// Get and decode the initial server payload +createFromReadableStream(getRSCStream()).then((payload) => { + startTransition(async () => { + const formState = payload.type === "render" ? await payload.formState : undefined; + + hydrateRoot( + document, + + + , + { + // @ts-expect-error - no types for this yet + formState, + }, + ); + }); +}); + +declare let __reactRouterDataRouter: DataRouter; + +if (import.meta.hot) { + import.meta.hot.on("rsc:update", () => { + __reactRouterDataRouter.revalidate(); + }); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.rsc.single.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.rsc.single.tsx new file mode 100644 index 000000000..5a21ed4f8 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.rsc.single.tsx @@ -0,0 +1,8 @@ +import type * as EntrySsr from "./entry.ssr"; +import { fetchServer } from "./entry.rsc"; + +export default async function handler(request: Request) { + const ssr = await import.meta.viteRsc.loadModule("ssr", "index"); + + return ssr.default(request, await fetchServer(request)); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx new file mode 100644 index 000000000..5d3d27fa7 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.rsc.tsx @@ -0,0 +1,36 @@ +import { + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + renderToReadableStream, +} from "@vitejs/plugin-rsc/rsc"; +import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router"; +import { routes } from "../app/routes"; + +export function fetchServer(request: Request) { + return matchRSCServerRequest({ + // Provide the React Server touchpoints. + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + // The incoming request. + request, + // The app routes. + routes, + // Encode the match with the React Server implementation. + generateResponse(match, options) { + return new Response(renderToReadableStream(match.payload, options), { + status: match.statusCode, + headers: match.headers, + }); + }, + }); +} + +if (import.meta.hot) { + import.meta.hot.accept(); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx new file mode 100644 index 000000000..d4559b379 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.ssr.tsx @@ -0,0 +1,29 @@ +import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; +import { renderToReadableStream as renderHTMLToReadableStream } from "react-dom/server.edge"; +import { + unstable_routeRSCServerRequest as routeRSCServerRequest, + unstable_RSCStaticRouter as RSCStaticRouter, +} from "react-router"; + +export default async function handler( + request: Request, + serverResponse: Response, +): Promise { + const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent("index"); + + return await routeRSCServerRequest({ + request, + serverResponse, + createFromReadableStream, + async renderHTML(getPayload, options) { + const payload = getPayload(); + + return await renderHTMLToReadableStream(, { + ...options, + bootstrapScriptContent, + signal: request.signal, + formState: await payload.formState, + }); + }, + }); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx new file mode 100644 index 000000000..cc1f54c5c --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/entry.worker.tsx @@ -0,0 +1,24 @@ +import type * as WorkerSsr from "./worker-ssr"; +import handler from "./entry.rsc.single"; + +// The distilled Cloudflare worker wrapper expects a `{ fetch }` default export; +// the RSC single-worker handler is a bare (request) => Response function. +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + // Worker code that needs a non-`react-server` module (here `react-dom/server`) + // must not import it directly in this `rsc` entry — it loads it from the + // `ssr` environment via `loadModule`. Exercises a custom (non-`index`) ssr + // input + cross-environment load through the distilled plugin. + if (url.pathname === "/worker-render") { + const { renderWorkerHtml } = await import.meta.viteRsc.loadModule( + "ssr", + "worker-ssr", + ); + return Response.json({ ok: true, html: renderWorkerHtml() }); + } + + return handler(request); + }, +}; diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/types.d.ts b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/types.d.ts new file mode 100644 index 000000000..bb5578e14 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/types.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/worker-ssr.tsx b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/worker-ssr.tsx new file mode 100644 index 000000000..00880f6cd --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/react-router-vite/worker-ssr.tsx @@ -0,0 +1,13 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server.edge"; + +// Lives in the `ssr` environment (no `react-server` condition), so it can use +// `react-dom/server` — which would fail if imported directly in the worker's +// `rsc` entry. The worker reaches it via `loadModule("ssr", "worker-ssr")`. +// This is the pattern James Opstad landed on in +// github.com/agcty/vite-rsc-worker-env-repro PR #1. +export function renderWorkerHtml(): string { + return renderToStaticMarkup( + createElement("section", null, "Worker render via the ssr environment."), + ); +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/test/build.test.ts b/projects/cloudflare-tools/fixtures/react-router-rsc/test/build.test.ts new file mode 100644 index 000000000..935094620 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/test/build.test.ts @@ -0,0 +1,143 @@ +import { beforeAll, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Production-build smoke test for the distilled build manifest. Builds the +// fixture and asserts the emitted `__distilled-build.json` describes a complete, +// self-contained worker module set — the contract a deployer consumes. +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const distDir = path.join(fixtureDir, "dist"); + +type Manifest = { + version: number; + workers: { + app: { + main: string; + modules: Array<{ path: string; type: string }>; + compatibilityDate?: string; + compatibilityFlags?: Array; + }; + }; + assets?: { directory: string }; +}; +let manifest: Manifest; + +beforeAll(() => { + fs.rmSync(distDir, { recursive: true, force: true }); + const result = spawnSync("bun", ["vite", "build"], { cwd: fixtureDir, encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`vite build failed (${result.status}):\n${result.stdout}\n${result.stderr}`); + } + manifest = JSON.parse(fs.readFileSync(path.join(distDir, "__distilled-build.json"), "utf8")); +}, 120_000); + +test("emits a build manifest describing the worker entry and assets", () => { + const worker = manifest.workers.app; + + expect(manifest.version).toBe(2); + // The framework also emits `server/index.js`; the main entry must be the + // distilled worker-entry wrapper captured from the real write pass. + expect(worker.main).toBe("server/entry.worker.js"); + expect(worker.modules).toContainEqual({ path: worker.main, type: "esm" }); + expect(worker.modules.length).toBeGreaterThan(0); + expect(worker.modules.every((module) => module.path && module.type)).toBe(true); + expect(manifest.assets?.directory).toBe("client"); + expect(worker.compatibilityDate).toBe("2026-03-10"); + expect(worker.compatibilityFlags).toContain("nodejs_compat"); +}); + +test("folds the worker-loaded ssr output into the worker module set", () => { + const modulePaths = manifest.workers.app.modules.map((module) => module.path); + + // The single-worker RSC topology loads the `ssr` env at runtime via + // loadModule (`import("../../ssr/...")`), so its output must ship as part of + // the same worker — both the entry (`server/`) and child (`ssr/`) outputs. + expect(modulePaths.some((module) => module.startsWith("server/"))).toBe(true); + expect(modulePaths.some((module) => module.startsWith("ssr/"))).toBe(true); + expect(modulePaths).toContain("ssr/worker-ssr.js"); +}); + +test("worker module entries are sorted and unique", () => { + const modulePaths = manifest.workers.app.modules.map((module) => module.path); + + expect(modulePaths).toEqual([...modulePaths].sort((a, b) => a.localeCompare(b))); + expect(new Set(modulePaths).size).toBe(modulePaths.length); +}); + +test("worker module set is self-contained (every relative import resolves)", () => { + const moduleSet = new Set(manifest.workers.app.modules.map((module) => module.path)); + const transpiler = new Bun.Transpiler({ loader: "js" }); + const unresolved: Array = []; + for (const module of manifest.workers.app.modules) { + if (module.type !== "esm") continue; + for (const imported of transpiler.scanImports( + fs.readFileSync(path.join(distDir, module.path), "utf8"), + )) { + if (!imported.path.startsWith(".")) continue; + const resolved = path.posix.normalize( + path.posix.join(path.posix.dirname(module.path), imported.path), + ); + if (!moduleSet.has(resolved)) unresolved.push(`${module.path} -> ${imported.path}`); + } + } + expect(unresolved).toEqual([]); +}); + +test("client assets are not part of the worker module set", () => { + expect(manifest.workers.app.modules.some((module) => module.path.startsWith("client/"))).toBe( + false, + ); +}); + +test("a custom RSC outDir split removes stale manifest and emits no broken graph", () => { + const customDistDir = path.join(fixtureDir, "dist-custom"); + const customManifestPath = path.join(customDistDir, "__distilled-build.json"); + try { + fs.rmSync(customDistDir, { recursive: true, force: true }); + fs.mkdirSync(customDistDir, { recursive: true }); + fs.writeFileSync( + customManifestPath, + `${JSON.stringify({ + version: 2, + workers: { + app: { + main: "server/stale.js", + modules: [{ path: "server/stale.js", type: "esm" }], + }, + }, + })}\n`, + ); + + const result = spawnSync("bun", ["vite", "build", "--outDir", "dist-custom"], { + cwd: fixtureDir, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error(`vite build failed (${result.status}):\n${result.stdout}\n${result.stderr}`); + } + + expect(result.stderr).toContain(`skipping __distilled-build.json`); + expect(fs.existsSync(customManifestPath)).toBe(false); + } finally { + fs.rmSync(customDistDir, { recursive: true, force: true }); + } +}, 120_000); + +// Must run last: it triggers a second build that rewrites `dist`. +test("a rebuild drops stale worker files left in the output", () => { + const stale = path.join(distDir, "server", "STALE_REVIEW_MARKER.js"); + fs.writeFileSync(stale, "// stale\n"); + const result = spawnSync("bun", ["vite", "build"], { cwd: fixtureDir, encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`vite build failed (${result.status}):\n${result.stdout}\n${result.stderr}`); + } + const rebuilt: Manifest = JSON.parse( + fs.readFileSync(path.join(distDir, "__distilled-build.json"), "utf8"), + ); + expect(rebuilt.workers.app.modules.map((module) => module.path)).not.toContain( + "server/STALE_REVIEW_MARKER.js", + ); + expect(fs.existsSync(stale)).toBe(false); +}, 120_000); diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/test/dev.test.ts b/projects/cloudflare-tools/fixtures/react-router-rsc/test/dev.test.ts new file mode 100644 index 000000000..434ceb6f0 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/test/dev.test.ts @@ -0,0 +1,57 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +// Dev-mode smoke test: boots `vite dev` (the distilled Cloudflare plugin in +// RSC mode) and asserts the RSC routes, routing, and the worker-loaded ssr +// render all respond. Build-mode tests are intentionally absent — RSC build +// is a separate track. +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +let proc: ChildProcess; +let baseUrl: string; + +beforeAll(async () => { + proc = spawn("bun", ["vite", "dev", "--port", "3251"], { + cwd: fixtureDir, + stdio: ["ignore", "pipe", "pipe"], + }); + baseUrl = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000); + const onData = (chunk: Buffer) => { + const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/); + if (match) { + clearTimeout(timer); + resolve(match[1].replace(/\/$/, "")); + } + }; + proc.stdout?.on("data", onData); + proc.stderr?.on("data", onData); + proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))); + }); +}); + +afterAll(() => { + proc?.kill("SIGTERM"); +}); + +test("renders the RSC home route", async () => { + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + expect(await res.text()).toContain("React Router Vite"); +}); + +test("routes to /about", async () => { + const res = await fetch(`${baseUrl}/about`); + expect(res.status).toBe(200); + expect(await res.text()).toContain("About"); +}); + +test("worker loads a custom ssr module via loadModule (react-dom/server in ssr env)", async () => { + const res = await fetch(`${baseUrl}/worker-render`); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; html: string }; + expect(body.ok).toBe(true); + expect(body.html).toContain("Worker render via the ssr environment."); +}); diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/tsconfig.json b/projects/cloudflare-tools/fixtures/react-router-rsc/tsconfig.json new file mode 100644 index 000000000..1950c5a23 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "jsx": "react-jsx" + } +} diff --git a/projects/cloudflare-tools/fixtures/react-router-rsc/vite.config.ts b/projects/cloudflare-tools/fixtures/react-router-rsc/vite.config.ts new file mode 100644 index 000000000..d6d0c681e --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-router-rsc/vite.config.ts @@ -0,0 +1,60 @@ +import cloudflare from "@oddlynew/distilled-cloudflare-vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import rsc from "@vitejs/plugin-rsc"; +import { defineConfig } from "vite"; + +// React Router (hand-rolled on @vitejs/plugin-rsc) wired to the distilled +// Cloudflare vite plugin via the single-worker child-environment model — +// the same topology vermittelbar uses with the official plugin +// (`viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }`). The worker +// IS the `rsc` env; its handler loads `ssr` at runtime via +// `import.meta.viteRsc.loadModule("ssr", ...)`. +export default defineConfig({ + clearScreen: false, + build: { minify: false }, + resolve: { + dedupe: ["react", "react-dom"], + }, + plugins: [ + tailwindcss(), + { + // Workaround for https://github.com/tailwindlabs/tailwindcss/pull/19670 + name: "fix-tailwind-full-reload", + configResolved(config) { + const plugin = config.plugins.find((p) => p.name === "@tailwindcss/vite:generate:serve"); + delete plugin?.hotUpdate; + }, + }, + react(), + rsc({ + serverHandler: false, + entries: { + client: "./react-router-vite/entry.browser.tsx", + ssr: "./react-router-vite/entry.ssr.tsx", + rsc: "./react-router-vite/entry.worker.tsx", + }, + }), + cloudflare({ + main: "./react-router-vite/entry.worker.tsx", + compatibilityDate: "2026-03-10", + compatibilityFlags: ["nodejs_compat"], + viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }, + worker: { name: "fixtures-react-router-rsc" }, + }), + ], + environments: { + // A second `ssr` input the worker loads on demand via + // loadModule("ssr", "worker-ssr") — alongside the framework's `index`. + ssr: { + build: { + rollupOptions: { + input: { "worker-ssr": "./react-router-vite/worker-ssr.tsx" }, + }, + }, + }, + }, + optimizeDeps: { + include: ["react-router", "react-router/internal/react-server-client"], + }, +}); diff --git a/projects/cloudflare-tools/fixtures/react-rsc/README.md b/projects/cloudflare-tools/fixtures/react-rsc/README.md new file mode 100644 index 000000000..cd1117106 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/README.md @@ -0,0 +1,40 @@ +# Vite + RSC + +This example shows how to set up a React application with [Server Component](https://react.dev/reference/rsc/server-components) features on Vite using [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc). + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) + +```sh +# run dev server +npm run dev + +# build for production and preview +npm run build +npm run preview +``` + +## API usage + +See [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) for the documentation. + +- [`vite.config.ts`](./vite.config.ts) + - `@vitejs/plugin-rsc/plugin` +- [`./src/framework/entry.rsc.tsx`](./src/framework/entry.rsc.tsx) + - `@vitejs/plugin-rsc/rsc` + - `import.meta.viteRsc.loadModule` +- [`./src/framework/entry.ssr.tsx`](./src/framework/entry.ssr.tsx) + - `@vitejs/plugin-rsc/ssr` + - `import.meta.viteRsc.loadBootstrapScriptContent` + - `rsc-html-stream/server` +- [`./src/framework/entry.browser.tsx`](./src/framework/entry.browser.tsx) + - `@vitejs/plugin-rsc/browser` + - `rsc-html-stream/client` + +## Notes + +- [`./src/framework/entry.{browser,rsc,ssr}.tsx`](./src/framework) (with inline comments) provides an overview of how low level RSC (React flight) API can be used to build RSC framework. +- You can use [`vite-plugin-inspect`](https://github.com/antfu-collective/vite-plugin-inspect) to understand how `"use client"` and `"use server"` directives are transformed internally. + +## Deployment + +See [vite-plugin-rsc-deploy-example](https://github.com/hi-ogawa/vite-plugin-rsc-deploy-example) diff --git a/projects/cloudflare-tools/fixtures/react-rsc/package.json b/projects/cloudflare-tools/fixtures/react-rsc/package.json new file mode 100644 index 000000000..27337447d --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/package.json @@ -0,0 +1,28 @@ +{ + "name": "@oddlynew/cloudflare-tools-fixture-react-rsc", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite dev --port 3100", + "build": "vite build", + "preview": "vite preview", + "test": "bun test" + }, + "dependencies": { + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:workers", + "@oddlynew/distilled-cloudflare-runtime": "workspace:*", + "@oddlynew/distilled-cloudflare-vite-plugin": "workspace:*", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@vitejs/plugin-rsc": "catalog:", + "rsc-html-stream": "catalog:", + "vite": "catalog:" + } +} diff --git a/projects/cloudflare-tools/fixtures/react-rsc/public/vite.svg b/projects/cloudflare-tools/fixtures/react-rsc/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/projects/cloudflare-tools/fixtures/react-rsc/src/action.tsx b/projects/cloudflare-tools/fixtures/react-rsc/src/action.tsx new file mode 100644 index 000000000..6b5029dcb --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/src/action.tsx @@ -0,0 +1,11 @@ +"use server"; + +let serverCounter = 0; + +export async function getServerCounter() { + return serverCounter; +} + +export async function updateServerCounter(change: number) { + serverCounter += change; +} diff --git a/projects/cloudflare-tools/fixtures/react-rsc/src/assets/react.svg b/projects/cloudflare-tools/fixtures/react-rsc/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/projects/cloudflare-tools/fixtures/react-rsc/src/client.tsx b/projects/cloudflare-tools/fixtures/react-rsc/src/client.tsx new file mode 100644 index 000000000..ac69d863b --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/src/client.tsx @@ -0,0 +1,9 @@ +"use client"; + +import React from "react"; + +export function ClientCounter() { + const [count, setCount] = React.useState(0); + + return ; +} diff --git a/projects/cloudflare-tools/fixtures/react-rsc/src/framework/entry.browser.tsx b/projects/cloudflare-tools/fixtures/react-rsc/src/framework/entry.browser.tsx new file mode 100644 index 000000000..5e2c20318 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/src/framework/entry.browser.tsx @@ -0,0 +1,138 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from "@vitejs/plugin-rsc/browser"; +import React from "react"; +import { createRoot, hydrateRoot } from "react-dom/client"; +import { rscStream } from "rsc-html-stream/client"; +import type { RscPayload } from "./entry.rsc"; +import { GlobalErrorBoundary } from "./error-boundary"; +import { createRscRenderRequest } from "./request"; + +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void; + + // deserialize RSC stream back to React VDOM for CSR + const initialPayload = await createFromReadableStream( + // initial RSC stream is injected in SSR stream as + rscStream, + ); + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload); + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)); + }, [setPayload_]); + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()); + }, []); + + return payload.root; + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const renderRequest = createRscRenderRequest(window.location.href); + const payload = await createFromFetch(fetch(renderRequest)); + setPayload(payload); + } + + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const temporaryReferences = createTemporaryReferenceSet(); + const renderRequest = createRscRenderRequest(window.location.href, { + id, + body: await encodeReply(args, { temporaryReferences }), + }); + const payload = await createFromFetch(fetch(renderRequest), { + temporaryReferences, + }); + setPayload(payload); + const { ok, data } = payload.returnValue!; + if (!ok) throw data; + return data; + }); + + // hydration + const browserRoot = ( + + + + + + ); + if ("__NO_HYDRATE" in globalThis) { + createRoot(document).render(browserRoot); + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }); + } + + // implement server HMR by triggering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on("rsc:update", () => { + fetchRscPayload(); + }); + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener("popstate", onNavigation); + + const oldPushState = window.history.pushState; + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args); + onNavigation(); + return res; + }; + + const oldReplaceState = window.history.replaceState; + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args); + onNavigation(); + return res; + }; + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest("a"); + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === "_self") && + link.origin === location.origin && + !link.hasAttribute("download") && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault(); + history.pushState(null, "", link.href); + } + } + document.addEventListener("click", onClick); + + return () => { + document.removeEventListener("click", onClick); + window.removeEventListener("popstate", onNavigation); + window.history.pushState = oldPushState; + window.history.replaceState = oldReplaceState; + }; +} + +main(); diff --git a/projects/cloudflare-tools/fixtures/react-rsc/src/framework/entry.rsc.tsx b/projects/cloudflare-tools/fixtures/react-rsc/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..68f4eb94f --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/src/framework/entry.rsc.tsx @@ -0,0 +1,121 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from "@vitejs/plugin-rsc/rsc"; +import type { ReactFormState } from "react-dom/client"; +import type * as EntrySsr from "./entry.ssr.tsx"; +import { Root } from "../root.tsx"; +import { parseRenderRequest } from "./request.tsx"; + +// The schema of payload which is serialized into RSC stream on rsc environment +// and deserialized on ssr/client environments. +export type RscPayload = { + // this demo renders/serializes/deserizlies entire root html element + // but this mechanism can be changed to render/fetch different parts of components + // based on your own route conventions. + root: React.ReactNode; + // server action return value of non-progressive enhancement case + returnValue?: { ok: boolean; data: unknown }; + // server action form state (e.g. useActionState) of progressive enhancement case + formState?: ReactFormState; +}; + +// the plugin by default assumes `rsc` entry having default export of request handler. +// however, how server entries are executed can be customized by registering own server handler. +export default { fetch: handler }; + +async function handler(request: Request): Promise { + // differentiate RSC, SSR, action, etc. + const renderRequest = parseRenderRequest(request); + request = renderRequest.request; + + // handle server function request + let returnValue: RscPayload["returnValue"] | undefined; + let formState: ReactFormState | undefined; + let temporaryReferences: unknown | undefined; + let actionStatus: number | undefined; + if (renderRequest.isAction === true) { + if (renderRequest.actionId) { + // action is called via `ReactClient.setServerCallback`. + const contentType = request.headers.get("content-type"); + const body = contentType?.startsWith("multipart/form-data") + ? await request.formData() + : await request.text(); + temporaryReferences = createTemporaryReferenceSet(); + const args = await decodeReply(body, { temporaryReferences }); + const action = await loadServerAction(renderRequest.actionId); + try { + const data = await action.apply(null, args); + returnValue = { ok: true, data }; + } catch (e) { + returnValue = { ok: false, data: e }; + actionStatus = 500; + } + } else { + // otherwise server function is called via `
` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. + const formData = await request.formData(); + const decodedAction = await decodeAction(formData); + try { + const result = await decodedAction(); + formState = await decodeFormState(result, formData); + } catch { + // there's no single general obvious way to surface this error, + // so explicitly return classic 500 response. + return new Response("Internal Server Error: server action failed", { + status: 500, + }); + } + } + } + + // serialization from React VDOM tree to RSC stream. + // we render RSC stream after handling server function request + // so that new render reflects updated state from server function call + // to achieve single round trip to mutate and fetch from server. + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + }; + const rscOptions = { temporaryReferences }; + const rscStream = renderToReadableStream(rscPayload, rscOptions); + + // Respond RSC stream without HTML rendering as decided by `RenderRequest` + if (renderRequest.isRsc) { + return new Response(rscStream, { + status: actionStatus, + headers: { + "content-type": "text/x-component;charset=utf-8", + }, + }); + } + + // Delegate to SSR environment for html rendering. + // The plugin provides `loadModule` helper to allow loading SSR environment entry module + // in RSC environment. however this can be customized by implementing own runtime communication + // e.g. `@cloudflare/vite-plugin`'s service binding. + const ssrEntryModule = await import.meta.viteRsc.loadModule("ssr", "index"); + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + // allow quick simulation of javascript disabled browser + debugNojs: renderRequest.url.searchParams.has("__nojs"), + }); + + // respond html + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + "Content-type": "text/html", + }, + }); +} + +if (import.meta.hot) { + import.meta.hot.accept(); +} diff --git a/projects/cloudflare-tools/fixtures/react-rsc/src/framework/entry.ssr.tsx b/projects/cloudflare-tools/fixtures/react-rsc/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..27d8ae715 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/src/framework/entry.ssr.tsx @@ -0,0 +1,70 @@ +import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; +import React from "react"; +import type { ReactFormState } from "react-dom/client"; +import { renderToReadableStream } from "react-dom/server.edge"; +import { injectRSCPayload } from "rsc-html-stream/server"; +import type { RscPayload } from "./entry.rsc"; + +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState; + nonce?: string; + debugNojs?: boolean; + }, +): Promise<{ stream: ReadableStream; status?: number }> { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . + const [rscStream1, rscStream2] = rscStream.tee(); + + // deserialize RSC stream back to React VDOM + let payload: Promise | undefined; + function SsrRoot() { + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1); + return React.use(payload).root; + } + + // render html (traditional SSR) + const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent("index"); + let htmlStream: ReadableStream; + let status: number | undefined; + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs ? undefined : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }); + } catch { + // fallback to render an empty shell and run pure CSR on browser, + // which can replay server component error and trigger error boundary. + status = 500; + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + (options?.debugNojs ? "" : bootstrapScriptContent), + nonce: options?.nonce, + }, + ); + } + + let responseStream: ReadableStream = htmlStream; + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + // using utility made by devongovett https://github.com/devongovett/rsc-html-stream + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ); + } + + return { stream: responseStream, status }; +} diff --git a/projects/cloudflare-tools/fixtures/react-rsc/src/framework/error-boundary.tsx b/projects/cloudflare-tools/fixtures/react-rsc/src/framework/error-boundary.tsx new file mode 100644 index 000000000..ccfb696dc --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/src/framework/error-boundary.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React from "react"; + +// Minimal ErrorBoundary example to handle errors globally on browser +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return {props.children}; +} + +// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx +// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary +class ErrorBoundary extends React.Component<{ + children?: React.ReactNode; + errorComponent: React.FC<{ + error: Error; + reset: () => void; + }>; +}> { + state: { error?: Error } = {}; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + reset = () => { + this.setState({ error: null }); + }; + + render() { + const error = this.state.error; + if (error) { + return ; + } + return this.props.children; + } +} + +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 +function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { + return ( + + + Unexpected Error + + +

Caught an unexpected error

+
+          Error:{" "}
+          {import.meta.env.DEV && "message" in props.error ? props.error.message : "(Unknown)"}
+        
+ + + + ); +} diff --git a/projects/cloudflare-tools/fixtures/react-rsc/src/framework/request.tsx b/projects/cloudflare-tools/fixtures/react-rsc/src/framework/request.tsx new file mode 100644 index 000000000..7d48788ea --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/src/framework/request.tsx @@ -0,0 +1,58 @@ +// Framework conventions (arbitrary choices for this demo): +// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests +// - Use `x-rsc-action` header to pass server action ID +const URL_POSTFIX = "_.rsc"; +const HEADER_ACTION_ID = "x-rsc-action"; + +// Parsed request information used to route between RSC/SSR rendering and action handling. +// Created by parseRenderRequest() from incoming HTTP requests. +type RenderRequest = { + isRsc: boolean; // true if request should return RSC payload (via _.rsc suffix) + isAction: boolean; // true if this is a server action call (POST request) + actionId?: string; // server action ID from x-rsc-action header + request: Request; // normalized Request with _.rsc suffix removed from URL + url: URL; // normalized URL with _.rsc suffix removed +}; + +export function createRscRenderRequest( + urlString: string, + action?: { id: string; body: BodyInit }, +): Request { + const url = new URL(urlString); + url.pathname += URL_POSTFIX; + const headers = new Headers(); + if (action) { + headers.set(HEADER_ACTION_ID, action.id); + } + return new Request(url.toString(), { + method: action ? "POST" : "GET", + headers, + body: action?.body, + }); +} + +export function parseRenderRequest(request: Request): RenderRequest { + const url = new URL(request.url); + const isAction = request.method === "POST"; + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length); + const actionId = request.headers.get(HEADER_ACTION_ID) || undefined; + if (request.method === "POST" && !actionId) { + throw new Error("Missing action id header for RSC action request"); + } + return { + isRsc: true, + isAction, + actionId, + request: new Request(url, request), + url, + }; + } else { + return { + isRsc: false, + isAction, + request, + url, + }; + } +} diff --git a/projects/cloudflare-tools/fixtures/react-rsc/src/index.css b/projects/cloudflare-tools/fixtures/react-rsc/src/index.css new file mode 100644 index 000000000..f4d2128c0 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/src/index.css @@ -0,0 +1,112 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 1rem; +} + +.read-the-docs { + color: #888; + text-align: left; +} diff --git a/projects/cloudflare-tools/fixtures/react-rsc/src/root.tsx b/projects/cloudflare-tools/fixtures/react-rsc/src/root.tsx new file mode 100644 index 000000000..aaf322d06 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/src/root.tsx @@ -0,0 +1,70 @@ +// oxlint-disable-next-line import/no-unassigned-import +import "./index.css"; // css import is automatically injected in exported server components +// oxlint-disable-next-line import/no-absolute-path +import viteLogo from "/vite.svg"; +import { getServerCounter, updateServerCounter } from "./action.tsx"; +import reactLogo from "./assets/react.svg"; +import { ClientCounter } from "./client.tsx"; + +export function Root(props: { url: URL }) { + return ( + + + + + + Vite + RSC + + + + + + ); +} + +function App(props: { url: URL }) { + return ( +
+ +

Vite + RSC

+
+ +
+
+ + + +
+
Request URL: {props.url?.href}
+
    +
  • + Edit src/client.tsx to test client HMR. +
  • +
  • + Edit src/root.tsx to test server HMR. +
  • +
  • + Visit{" "} + + _.rsc + {" "} + to view RSC stream payload. +
  • +
  • + Visit{" "} + + ?__nojs + {" "} + to test server action without js enabled. +
  • +
+
+ ); +} diff --git a/projects/cloudflare-tools/fixtures/react-rsc/test/dev.test.ts b/projects/cloudflare-tools/fixtures/react-rsc/test/dev.test.ts new file mode 100644 index 000000000..6a86c442a --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/test/dev.test.ts @@ -0,0 +1,46 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +// Dev-mode smoke test: boots `vite dev` (the distilled Cloudflare plugin in +// RSC mode) and asserts the minimal RSC app renders — server components, +// the client component, and the server action are all present in the SSR'd +// HTML. Build-mode is a separate track and intentionally not tested here. +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +let proc: ChildProcess; +let baseUrl: string; + +beforeAll(async () => { + proc = spawn("bun", ["vite", "dev", "--port", "3151"], { + cwd: fixtureDir, + stdio: ["ignore", "pipe", "pipe"], + }); + baseUrl = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000); + const onData = (chunk: Buffer) => { + const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/); + if (match) { + clearTimeout(timer); + resolve(match[1].replace(/\/$/, "")); + } + }; + proc.stdout?.on("data", onData); + proc.stderr?.on("data", onData); + proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))); + }); +}); + +afterAll(() => { + proc?.kill("SIGTERM"); +}); + +test("server-renders the RSC app (server component + client component + server action)", async () => { + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Vite + RSC"); // server component + expect(html).toContain("Client Counter"); // client component + expect(html).toContain("Server Counter"); // server action +}); diff --git a/projects/cloudflare-tools/fixtures/react-rsc/test/plugin-order.test.ts b/projects/cloudflare-tools/fixtures/react-rsc/test/plugin-order.test.ts new file mode 100644 index 000000000..3aa02ec43 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/test/plugin-order.test.ts @@ -0,0 +1,47 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +// Locks order-independence (review finding): with the Cloudflare plugin +// registered BEFORE rsc(), the rsc env's resolve.conditions lists `react-server` +// after the workerd conditions (not first). Export-condition resolution is +// set-membership, so the app must still render correctly — if `react-server` +// weren't effective in the rsc env, flight generation would 500. See +// vite.config.cf-first.ts. +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +let proc: ChildProcess; +let baseUrl: string; + +beforeAll(async () => { + proc = spawn("bun", ["vite", "dev", "-c", "vite.config.cf-first.ts", "--port", "3152"], { + cwd: fixtureDir, + stdio: ["ignore", "pipe", "pipe"], + }); + baseUrl = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000); + const onData = (chunk: Buffer) => { + const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/); + if (match) { + clearTimeout(timer); + resolve(match[1].replace(/\/$/, "")); + } + }; + proc.stdout?.on("data", onData); + proc.stderr?.on("data", onData); + proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))); + }); +}); + +afterAll(() => { + proc?.kill("SIGTERM"); +}); + +test("RSC renders correctly even with cloudflare() before rsc() (react-server not first in conditions)", async () => { + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Vite + RSC"); + expect(html).toContain("Client Counter"); +}); diff --git a/projects/cloudflare-tools/fixtures/react-rsc/tsconfig.json b/projects/cloudflare-tools/fixtures/react-rsc/tsconfig.json new file mode 100644 index 000000000..b212cd7a7 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/projects/cloudflare-tools/fixtures/react-rsc/vite.config.cf-first.ts b/projects/cloudflare-tools/fixtures/react-rsc/vite.config.cf-first.ts new file mode 100644 index 000000000..3ca5df1ee --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/vite.config.cf-first.ts @@ -0,0 +1,40 @@ +import cloudflare from "@oddlynew/distilled-cloudflare-vite-plugin"; +import react from "@vitejs/plugin-react"; +import rsc from "@vitejs/plugin-rsc"; +import { defineConfig } from "vite"; + +// Same app as vite.config.ts but with the Cloudflare plugin registered BEFORE +// `rsc()`. This deliberately produces the non-conventional condition ordering +// (the rsc env's `resolve.conditions` lists workerd conditions before +// `react-server`). The plugin-order.test.ts boots this config to prove RSC dev +// resolves correctly regardless of plugin order — export-condition resolution +// is set-membership (React's own `exports` ordering decides), so `react-server` +// being present is what matters, not its index in the conditions array. +export default defineConfig({ + resolve: { + dedupe: ["react", "react-dom"], + }, + plugins: [ + cloudflare({ + main: "./src/framework/entry.rsc.tsx", + compatibilityDate: "2026-03-10", + compatibilityFlags: ["nodejs_compat"], + viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }, + worker: { name: "fixtures-react-rsc-cf-first" }, + }), + rsc({ serverHandler: false }), + react(), + ], + + environments: { + rsc: { + build: { rollupOptions: { input: { index: "./src/framework/entry.rsc.tsx" } } }, + }, + ssr: { + build: { rollupOptions: { input: { index: "./src/framework/entry.ssr.tsx" } } }, + }, + client: { + build: { rollupOptions: { input: { index: "./src/framework/entry.browser.tsx" } } }, + }, + }, +}); diff --git a/projects/cloudflare-tools/fixtures/react-rsc/vite.config.ts b/projects/cloudflare-tools/fixtures/react-rsc/vite.config.ts new file mode 100644 index 000000000..bace0b240 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/react-rsc/vite.config.ts @@ -0,0 +1,42 @@ +import cloudflare from "@oddlynew/distilled-cloudflare-vite-plugin"; +import react from "@vitejs/plugin-react"; +import rsc from "@vitejs/plugin-rsc"; +import { defineConfig } from "vite"; + +// Minimal React Server Components app (the @vitejs/plugin-rsc `starter`) +// wired to the distilled Cloudflare vite plugin, to reproduce and then fix +// RSC dev support (cloudflare-tools#43). +// +// The worker IS the `rsc` environment: plugin-rsc resolves it with the +// `react-server` condition and its `default export` ({ fetch }) is the +// request handler. `serverHandler: false` tells plugin-rsc not to mount its +// own Node dev middleware, so requests route into workerd instead. +export default defineConfig({ + resolve: { + dedupe: ["react", "react-dom"], + }, + plugins: [ + rsc({ serverHandler: false }), + react(), + cloudflare({ + main: "./src/framework/entry.rsc.tsx", + compatibilityDate: "2026-03-10", + compatibilityFlags: ["nodejs_compat"], + // The Worker is the `rsc` environment; it loads `ssr` modules at runtime. + viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }, + worker: { name: "fixtures-react-rsc" }, + }), + ], + + environments: { + rsc: { + build: { rollupOptions: { input: { index: "./src/framework/entry.rsc.tsx" } } }, + }, + ssr: { + build: { rollupOptions: { input: { index: "./src/framework/entry.ssr.tsx" } } }, + }, + client: { + build: { rollupOptions: { input: { index: "./src/framework/entry.browser.tsx" } } }, + }, + }, +}); diff --git a/projects/cloudflare-tools/fixtures/static-website/build-manifest.test.ts b/projects/cloudflare-tools/fixtures/static-website/build-manifest.test.ts new file mode 100644 index 000000000..dc244be16 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/static-website/build-manifest.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const fixtureDir = path.dirname(fileURLToPath(import.meta.url)); +const distDir = path.join(fixtureDir, "dist"); +const manifestPath = path.join(distDir, "__distilled-build.json"); + +test("an assets-only SPA build removes a stale distilled build manifest", () => { + fs.rmSync(distDir, { recursive: true, force: true }); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync( + manifestPath, + `${JSON.stringify({ + version: 2, + workers: { + app: { + main: "server/stale.js", + modules: [{ path: "server/stale.js", type: "esm" }], + }, + }, + })}\n`, + ); + + const result = spawnSync("bun", ["vite", "build"], { + cwd: fixtureDir, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error(`vite build failed (${result.status}):\n${result.stdout}\n${result.stderr}`); + } + + expect(fs.existsSync(path.join(distDir, "client", "index.html"))).toBe(true); + expect(fs.existsSync(manifestPath)).toBe(false); +}, 120_000); diff --git a/projects/cloudflare-tools/fixtures/static-website/package.json b/projects/cloudflare-tools/fixtures/static-website/package.json index b3b1022d0..e8697d3c4 100644 --- a/projects/cloudflare-tools/fixtures/static-website/package.json +++ b/projects/cloudflare-tools/fixtures/static-website/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "test": "playwright test", + "test": "bun test build-manifest.test.ts && playwright test", "test:update-snapshots": "playwright test --update-snapshots", "pretest": "playwright install chromium" }, diff --git a/projects/cloudflare-tools/fixtures/static-website/src/server.ts b/projects/cloudflare-tools/fixtures/static-website/src/server.ts index 47ae6e97e..8771f28ca 100644 --- a/projects/cloudflare-tools/fixtures/static-website/src/server.ts +++ b/projects/cloudflare-tools/fixtures/static-website/src/server.ts @@ -1,5 +1,9 @@ export default { - async fetch(_: Request) { - return new Response("Hello World"); + async fetch(request: Request) { + const url = new URL(request.url); + if (url.pathname.startsWith("/api/")) { + return Response.json({ name: "Cloudflare" }); + } + return new Response(null, { status: 404 }); }, }; diff --git a/projects/cloudflare-tools/fixtures/static-website/test/dev.test.ts b/projects/cloudflare-tools/fixtures/static-website/test/dev.test.ts new file mode 100644 index 000000000..4e938e4e3 --- /dev/null +++ b/projects/cloudflare-tools/fixtures/static-website/test/dev.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; +import { spawn, type ChildProcess } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const fixtureDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +let proc: ChildProcess; +let baseUrl: string; + +test.beforeAll(async () => { + proc = spawn("bun", ["vite", "dev", "--host", "127.0.0.1", "--port", "3351"], { + cwd: fixtureDir, + env: { ...process.env, DISTILLED_STATIC_WEBSITE_WORKER: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }); + baseUrl = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("dev server did not start in time")), 90_000); + const onData = (chunk: Buffer) => { + const match = String(chunk).match(/Local:\s+(http:\/\/\S+?)\/?\s/); + if (match) { + clearTimeout(timer); + resolve(match[1].replace(/\/$/, "")); + } + }; + proc.stdout?.on("data", onData); + proc.stderr?.on("data", onData); + proc.on("exit", (code) => reject(new Error(`dev server exited early (code ${code})`))); + }); +}); + +test.afterAll(() => { + proc?.kill("SIGTERM"); +}); + +test("dev server serves Vite HTML assets before falling through to the Worker", async ({ + request, +}) => { + const home = await request.get(`${baseUrl}/`); + expect(home.status()).toBe(200); + expect(await home.text()).toContain('/src/main.ts"'); + + const html = await request.get(`${baseUrl}/index.html`); + expect(html.status()).toBe(200); + expect(await html.text()).toContain('/src/main.ts"'); + + const api = await request.get(`${baseUrl}/api/test`); + expect(api.status()).toBe(200); + expect(await api.json()).toEqual({ name: "Cloudflare" }); +}); diff --git a/projects/cloudflare-tools/fixtures/static-website/vite.config.ts b/projects/cloudflare-tools/fixtures/static-website/vite.config.ts index f6c03e221..ff03da08f 100644 --- a/projects/cloudflare-tools/fixtures/static-website/vite.config.ts +++ b/projects/cloudflare-tools/fixtures/static-website/vite.config.ts @@ -1,8 +1,25 @@ import cloudflare from "@oddlynew/distilled-cloudflare-vite-plugin"; import { defineConfig } from "vite"; +const enableTestWorker = process.env.DISTILLED_STATIC_WEBSITE_WORKER === "1"; + const config = defineConfig({ - plugins: [cloudflare({})], + plugins: [ + cloudflare( + enableTestWorker + ? { + main: "./src/server.ts", + compatibilityDate: "2025-09-27", + assets: { + notFoundHandling: "single-page-application", + }, + worker: { + name: "fixtures-static-website", + }, + } + : {}, + ), + ], }); export default config; diff --git a/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/options.ts b/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/options.ts index 9ad597fb5..53f71450a 100644 --- a/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/options.ts +++ b/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/options.ts @@ -28,4 +28,66 @@ export interface CloudflarePluginOptions { * ``` */ exports?: Array; + /** + * Which Vite environment hosts the Worker, and any child environments it + * loads at runtime. Defaults to the single `ssr` environment. + * + * `@vitejs/plugin-rsc` apps run the Worker in the `rsc` environment (resolved + * with the `react-server` condition) and load the `ssr` environment from it, + * so they set `{ name: "rsc", childEnvironments: ["ssr"] }`. Every named + * environment is given the Worker treatment (workerd resolve conditions, + * dependency pre-bundling) and a module runner in dev. Mirrors the + * official `@cloudflare/vite-plugin` option of the same name. + * + * @default { name: "ssr", childEnvironments: [] } + */ + viteEnvironment?: { + name?: string; + childEnvironments?: Array; + }; +} + +/** + * Resolves the Worker's entry Vite environment and any child environments it + * loads at runtime. The entry environment owns the Worker's input and build + * output; children (e.g. an RSC app's `ssr` environment) still need the Worker + * treatment and a dev module runner so the entry can load modules from them. + */ +export function workerEnvironments(options: CloudflarePluginOptions): { + entry: string; + children: Array; + all: Array; +} { + const entry = options.viteEnvironment?.name ?? "ssr"; + const children = options.viteEnvironment?.childEnvironments ?? []; + + // `client` is Vite's reserved browser environment — it must never be given + // Worker config. Children must be distinct and must not collide with the + // entry, since each environment name becomes a computed key in the generated + // config (a collision would silently overwrite one env with another's + // settings). Mirrors the validation in `@cloudflare/vite-plugin`. + if (entry === "client") { + throw new Error( + `[cloudflare] viteEnvironment.name cannot be "client" (the reserved browser environment).`, + ); + } + for (const child of children) { + if (child === "client") { + throw new Error( + `[cloudflare] viteEnvironment.childEnvironments cannot include "client" (the reserved browser environment).`, + ); + } + if (child === entry) { + throw new Error( + `[cloudflare] viteEnvironment.childEnvironments cannot include the entry environment "${entry}".`, + ); + } + } + if (new Set(children).size !== children.length) { + throw new Error( + `[cloudflare] viteEnvironment.childEnvironments contains duplicate names: ${JSON.stringify(children)}`, + ); + } + + return { entry, children, all: [entry, ...children] }; } diff --git a/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/additional-modules.ts b/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/additional-modules.ts index 43b4674b9..9b55deaeb 100644 --- a/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/additional-modules.ts +++ b/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/additional-modules.ts @@ -11,9 +11,9 @@ import { createPlugin } from "../factory.js"; import { sanitizePath } from "../utils.js"; export const MODULE_RULES = [ - { type: "CompiledWasm", pattern: /\.wasm(\?module)?$/ }, - { type: "Data", pattern: /\.bin$/ }, - { type: "Text", pattern: /\.(txt|html|sql)$/ }, + { type: "CompiledWasm", manifestType: "wasm", pattern: /\.wasm(\?module)?$/ }, + { type: "Data", manifestType: "data", pattern: /\.bin$/ }, + { type: "Text", manifestType: "text", pattern: /\.(txt|html|sql)$/ }, ] as const; const MODULE_REFERENCE_PATTERN = `__CLOUDFLARE_MODULE__(${MODULE_RULES.map((rule) => rule.type).join("|")})__(.*?)__CLOUDFLARE_MODULE__`; diff --git a/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/build-manifest.ts b/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/build-manifest.ts new file mode 100644 index 000000000..ab2fd451f --- /dev/null +++ b/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/build-manifest.ts @@ -0,0 +1,286 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type * as vite from "vite"; +import type { CloudflarePluginOptions } from "../options.js"; +import { workerEnvironments } from "../options.js"; +import { MODULE_RULES } from "./additional-modules.js"; +import { WORKER_ENTRY_PREFIX } from "./virtual-modules.js"; + +/** Filename of the build manifest, written to the build output root. */ +export const BUILD_MANIFEST_NAME = "__distilled-build.json"; + +/** + * The deploy contract a production build emits. A deployer (e.g. Alchemy's + * `Cloudflare.Vite`) reads this to learn the Worker's entry, its full module + * set, and the static assets directory — rather than inferring them from + * directory convention or a single environment's bundle. + * + * All paths are POSIX and relative to the manifest's own directory (the build + * output root). The Worker's `modules` span the entry environment's output AND + * every child environment it loads at runtime (e.g. an RSC app's `ssr` output, + * pulled in via `import("../../ssr/index.js")`). Their relative layout is + * preserved on disk, so those cross-environment imports resolve once the set is + * uploaded as one Worker. Module kind is explicit: `.js`/`.mjs` are ES modules, + * `.json` files are JSON modules, and the {@link MODULE_RULES} extensions map + * to their Cloudflare module type (`.wasm` → Wasm, `.bin` → Data, + * `.txt`/`.html`/`.sql` → Text). Source maps are auxiliary debug artifacts, not + * Worker modules, and are not listed here. + * + * The manifest describes only the *build* output. Deploy-time inputs the build + * doesn't own — bindings, secrets, Durable Object classes and migrations, + * source-map upload policy — are the deployer's responsibility. + * `compatibilityDate` and `compatibilityFlags` record what the Worker was + * *compiled* against and are authoritative: a deployer must deploy against the + * same values. If `compatibilityDate` is absent, that absence is authoritative + * too; deployers should fail or require an explicit deploy-time value rather + * than silently substituting a default that may not match the build. + */ +export interface DistilledBuildManifest { + version: 2; + workers: { + app: { + /** Entry module, relative to the manifest directory (e.g. `server/index.js`). */ + main: string; + /** Every Worker module, relative to the manifest directory. */ + modules: Array; + compatibilityDate?: string; + compatibilityFlags?: Array; + }; + }; + /** Static assets, relative to the manifest directory. */ + assets?: { + directory: string; + htmlHandling?: DistilledAssetHtmlHandling; + notFoundHandling?: DistilledAssetNotFoundHandling; + runWorkerFirst?: Array | boolean; + }; +} + +export type DistilledWorkerModuleType = "esm" | "wasm" | "data" | "text" | "json"; +export type DistilledAssetHtmlHandling = + | "auto-trailing-slash" + | "force-trailing-slash" + | "drop-trailing-slash" + | "none"; +export type DistilledAssetNotFoundHandling = "none" | "404-page" | "single-page-application"; + +export interface DistilledWorkerModule { + path: string; + type: DistilledWorkerModuleType; +} + +interface BuildManifestPluginOptions extends CloudflarePluginOptions { + assets?: { + htmlHandling?: DistilledAssetHtmlHandling; + notFoundHandling?: DistilledAssetNotFoundHandling; + runWorkerFirst?: Array | boolean; + }; +} + +const toPosix = (p: string) => p.split(path.sep).join("/"); + +/** + * A file is a Worker module if it's an ES module (`.js`/`.mjs`) or matches one + * of the Cloudflare additional-module rules — the same rules the build uses to + * *emit* those modules, so the two never drift. + */ +function getWorkerModuleType(name: string): DistilledWorkerModuleType | undefined { + if (name.endsWith(".js") || name.endsWith(".mjs")) { + return "esm"; + } + if (name.endsWith(".json")) { + return "json"; + } + return MODULE_RULES.find((rule) => rule.pattern.test(name))?.manifestType; +} + +/** Recursively list the Worker module files under `dir`, relative to `root`. */ +function listModules(dir: string, root: string): Array { + const modules: Array = []; + const walk = (current: string) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(full); + } else { + const relativePath = toPosix(path.relative(root, full)); + const type = getWorkerModuleType(entry.name); + if ( + type && + relativePath !== ".vite/manifest.json" && + !relativePath.endsWith("/.vite/manifest.json") + ) { + modules.push({ path: relativePath, type }); + } + } + } + }; + if (fs.existsSync(dir)) walk(dir); + return modules; +} + +function getManifestDir(entryOutDir: string | undefined, clientOutDir: string | undefined) { + const outputRoot = entryOutDir ?? clientOutDir; + return outputRoot ? path.dirname(outputRoot) : undefined; +} + +/** + * Emits the build manifest after a production build. + * + * The Worker module set is read from disk (the on-disk tree includes files the + * framework writes outside the rollup bundle, e.g. plugin-rsc's assets manifest + * and encryption key). To keep that read exact, the Worker environment output + * directories are emptied up front in `configResolved` — Vite's own + * `emptyOutDir` is skipped for these environments because the framework's + * non-writing scan passes mark them "rendered" first, which would otherwise let + * stale files from previous builds leak into the manifest. + * + * The manifest is written in the `buildApp` hook with `order: "post"`, after + * every environment (including the framework's multi-pass orchestration) has + * been written. The Worker entry is the chunk built from the distilled + * worker-entry wrapper (identified by its module marker, captured on the real + * write). A build with no Worker entry (a pure SPA / assets-only site) emits no + * manifest, and any stale manifest from a previous build is removed. + */ +export function buildManifestPlugin(options: BuildManifestPluginOptions): vite.Plugin { + const { entry, children } = workerEnvironments(options); + const workerEnvNames = [entry, ...children]; + const wantedEntryName = options.main ? path.parse(options.main).name : undefined; + let mainFileName: string | undefined; + + return { + name: "distilled-cloudflare:build-manifest", + apply: "build", + sharedDuringBuild: true, + // Empty each Worker environment's output directory before the build writes, + // so the on-disk module walk reflects only this build's output. + configResolved(config) { + const resolveOutDir = (name: string): string | undefined => { + const outDir = config.environments[name]?.build.outDir; + return outDir ? path.resolve(config.root, outDir) : undefined; + }; + const manifestDir = getManifestDir(resolveOutDir(entry), resolveOutDir("client")); + if (manifestDir) { + // A plain Vite SPA build does not run the app builder hooks, so remove + // any stale manifest before the build starts. Builds with a Worker will + // write a fresh manifest later in buildApp. + fs.rmSync(path.join(manifestDir, BUILD_MANIFEST_NAME), { force: true }); + } + for (const name of workerEnvNames) { + const outDir = resolveOutDir(name); + if (outDir) { + fs.rmSync(outDir, { recursive: true, force: true }); + } + } + }, + // Capture the entry chunk on a real write only — `writeBundle` doesn't fire + // for the framework's non-writing scan passes (`build.write === false`), so + // the filename always reflects the final emitted Worker. + writeBundle(_outputOptions, bundle) { + if (this.environment.name !== entry) return; + const entryChunks = Object.values(bundle).filter( + (chunk): chunk is vite.Rollup.OutputChunk => chunk.type === "chunk" && chunk.isEntry, + ); + // Only the distilled worker-entry wrapper carries the marker; a + // framework's own entry (e.g. plugin-rsc's `index`) does not. Fall back + // to the configured `main`'s name, then the sole entry chunk. + const byMarker = entryChunks.find((chunk) => + chunk.facadeModuleId?.startsWith(WORKER_ENTRY_PREFIX), + ); + const byName = wantedEntryName + ? entryChunks.find((chunk) => chunk.name === wantedEntryName) + : undefined; + const picked = byMarker ?? byName ?? entryChunks[0]; + if (picked) mainFileName = picked.fileName; + }, + buildApp: { + order: "post", + async handler(builder) { + const resolveOutDir = (name: string): string | undefined => { + const environment = builder.environments[name]; + return environment + ? path.resolve(builder.config.root, environment.config.build.outDir) + : undefined; + }; + + const entryOutDir = resolveOutDir(entry); + const clientOutDir = resolveOutDir("client"); + + // The manifest sits at the build output root — the parent of the entry + // output, or, for an assets-only build with no worker environment, the + // client output. The distilled plugin places the entry, every child, and + // the client output directly under this root (see `getOutputDirectory`), + // so module paths resolve relative to it and the framework's + // cross-environment imports stay intact. + const manifestDir = getManifestDir(entryOutDir, clientOutDir); + if (!manifestDir) return; + const manifestPath = path.join(manifestDir, BUILD_MANIFEST_NAME); + + // Start from a clean slate: a successful build emits a fresh manifest or, + // for a pure SPA / assets-only build with no worker entry, none — never a + // stale one. + fs.rmSync(manifestPath, { force: true }); + + if (!entryOutDir || !mainFileName) return; + + const modules = Array.from( + new Map( + workerEnvNames + .map(resolveOutDir) + .filter((dir): dir is string => dir !== undefined) + .flatMap((dir) => listModules(dir, manifestDir)) + .map((module) => [module.path, module]), + ).values(), + ).sort((a, b) => a.path.localeCompare(b.path)); + + // Every Worker output must live under the manifest root. A module that + // escapes it means the entry and child environments were written to + // different roots — which the framework's baked cross-environment + // imports can't satisfy either. That happens with a custom + // `build.outDir` under the child-environment (RSC) topology; the result + // isn't deployable, so emit nothing rather than a broken manifest. + const escaping = modules.filter((module) => module.path.startsWith("../")); + if (escaping.length > 0) { + builder.config.logger.warn( + `[cloudflare] skipping ${BUILD_MANIFEST_NAME}: ${escaping.length} worker module(s) ` + + `resolve outside the build root. A custom build.outDir is not supported with the ` + + `child-environment (RSC) topology.`, + ); + return; + } + + const manifest: DistilledBuildManifest = { + version: 2, + workers: { + app: { + main: toPosix(path.relative(manifestDir, path.join(entryOutDir, mainFileName))), + modules, + compatibilityDate: options.compatibilityDate, + compatibilityFlags: options.compatibilityFlags, + }, + }, + assets: clientOutDir + ? { + directory: toPosix(path.relative(manifestDir, clientOutDir)), + ...assetRoutingManifestFields(options.assets), + } + : undefined, + }; + + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + }, + }, + }; +} + +function assetRoutingManifestFields( + assets: BuildManifestPluginOptions["assets"], +): Omit, "directory"> { + return { + ...(assets?.htmlHandling !== undefined ? { htmlHandling: assets.htmlHandling } : {}), + ...(assets?.notFoundHandling !== undefined + ? { notFoundHandling: assets.notFoundHandling } + : {}), + ...(assets?.runWorkerFirst !== undefined ? { runWorkerFirst: assets.runWorkerFirst } : {}), + }; +} diff --git a/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/index.ts b/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/index.ts index a1850b3b4..14ced8d60 100644 --- a/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/index.ts +++ b/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/index.ts @@ -1,4 +1,5 @@ export * from "./additional-modules.js"; +export * from "./build-manifest.js"; export * from "./cloudflare-externals.js"; export * from "./nodejs-compat.js"; export * from "./options.js"; diff --git a/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/options.ts b/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/options.ts index 61862ebd8..3c87509f1 100644 --- a/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/options.ts +++ b/projects/cloudflare-tools/packages/cloudflare-rolldown-plugin/src/plugins/options.ts @@ -1,7 +1,7 @@ import path from "node:path"; import type * as vite from "vite"; import { createPlugin } from "../factory.js"; -import type { CloudflarePluginOptions } from "../options.js"; +import { type CloudflarePluginOptions, workerEnvironments } from "../options.js"; import { hasNodejsCompat } from "../utils.js"; import { WORKER_ENTRY_PREFIX } from "./virtual-modules.js"; @@ -55,10 +55,11 @@ export const optionsPlugin = createPlugin<"options", OptionsApi>("options", (plu async config(userConfig) { const vite = await import("vite"); const isRolldown = "rolldownVersion" in this.meta; + const { entry: entryEnv, children: childEnvs } = workerEnvironments(pluginOptions); input = normalizeInput( pluginOptions.main ?? - userConfig.environments?.ssr?.build?.rolldownOptions?.input ?? - userConfig.environments?.ssr?.build?.rollupOptions?.input ?? + userConfig.environments?.[entryEnv]?.build?.rolldownOptions?.input ?? + userConfig.environments?.[entryEnv]?.build?.rollupOptions?.input ?? {}, ); const rollupOptions: vite.Rollup.RollupOptions = { @@ -70,8 +71,122 @@ export const optionsPlugin = createPlugin<"options", OptionsApi>("options", (plu process.env.NODE_ENV || userConfig.mode || "production", ); const appType = userConfig.appType ?? (Object.keys(input).length === 0 ? "spa" : "custom"); + + // The dep-prebundle scanner for each Worker environment must be rooted + // at THAT environment's own entry, so its whole module graph (e.g. an + // RSC app's `ssr` env pulling react-dom/server + react) is bundled in + // the initial optimize pass. Sharing one `entries` (or one config + // object) across envs leaves children with no scan root, so their deps + // are discovered lazily and re-optimized mid-session — which re-hashes + // and duplicates singletons like React (a null hooks dispatcher). + const resolveEnvironmentEntries = (name: string): Array | undefined => { + // Single-worker (non-RSC) default: preserve the original behavior + // exactly — the optimizer's scan root comes only from an explicit + // `main`, otherwise it's left to Vite's auto-discovery. The per-env + // input fallback below applies only to multi-environment topologies. + if (childEnvs.length === 0) { + return pluginOptions.main ? [vite.normalizePath(pluginOptions.main)] : undefined; + } + const rawInput = + name === entryEnv + ? (pluginOptions.main ?? + userConfig.environments?.[name]?.build?.rolldownOptions?.input ?? + userConfig.environments?.[name]?.build?.rollupOptions?.input) + : (userConfig.environments?.[name]?.build?.rolldownOptions?.input ?? + userConfig.environments?.[name]?.build?.rollupOptions?.input ?? + pluginOptions.main); + if (!rawInput) return undefined; + const values = + typeof rawInput === "string" + ? [rawInput] + : Array.isArray(rawInput) + ? rawInput + : Object.values(rawInput); + return values.length > 0 ? values.map((value) => vite.normalizePath(value)) : undefined; + }; + + // The Worker treatment (workerd resolve conditions + dependency + // pre-bundling) applies to the entry environment AND every child + // environment it loads at runtime — without `noDiscovery: false` the + // child/entry envs get Vite's throwing deps optimizer, which breaks + // RSC dev (`registerMissingImport is not supported in dev `). A + // fresh config object is built per environment so each can carry its + // own `optimizeDeps.entries` without aliasing the others. + const makeWorkerEnvironment = ( + name: string, + { isEntry }: { isEntry: boolean }, + ): vite.EnvironmentOptions => ({ + resolve: { + noExternal: true, + conditions: [...DEFAULT_RESOLVE_CONDITION_NAMES, "development|production"], + }, + optimizeDeps: { + noDiscovery: false, + ignoreOutdatedRequests: true, + entries: resolveEnvironmentEntries(name), + ...(isRolldown + ? { + rolldownOptions: { + platform: "neutral", + resolve: { + conditionNames: [ + ...DEFAULT_RESOLVE_CONDITION_NAMES, + "development|production", + ], + mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, + extensions: DEFAULT_RESOLVE_EXTENSIONS, + }, + transform: { + target: TARGET, + define, + }, + }, + } + : { + esbuildOptions: { + platform: "neutral", + conditions: [...DEFAULT_RESOLVE_CONDITION_NAMES, "development|production"], + resolveExtensions: DEFAULT_RESOLVE_EXTENSIONS, + mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, + target: TARGET, + define, + }, + }), + }, + keepProcessEnv: true, + // The entry environment owns the Worker's build input and server + // output directory; children (e.g. `ssr`) keep their own build config + // from the framework plugin (`@vitejs/plugin-rsc`). + ...(isEntry + ? { + build: { + ssr: true, + target: TARGET, + emitAssets: true, + copyPublicDir: false, + outDir: getOutputDirectory(userConfig, "server"), + ...(isRolldown + ? { + rolldownOptions: { + ...rollupOptions, + platform: "neutral", + resolve: { + mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, + extensions: DEFAULT_RESOLVE_EXTENSIONS, + }, + }, + } + : { rollupOptions }), + }, + } + : {}), + }); + return { appType, + // Legacy top-level `ssr` namespace: a baseline for any SSR-kind env + // that doesn't override. Each Worker env sets its own `resolve` + // below; this stays as a harmless default. ssr: { noExternal: true, resolve: { @@ -79,7 +194,12 @@ export const optionsPlugin = createPlugin<"options", OptionsApi>("options", (plu }, }, builder: - appType === "spa" + // Multi-environment (RSC) topologies are orchestrated by the + // framework plugin's own multi-pass `buildApp` (e.g. + // `@vitejs/plugin-rsc`). Defer to it; setting our own here rebuilds + // every environment on top of the framework's passes. The + // single-worker path keeps the explicit per-environment builder. + appType === "spa" || childEnvs.length > 0 ? undefined : { buildApp: async (app) => { @@ -94,65 +214,10 @@ export const optionsPlugin = createPlugin<"options", OptionsApi>("options", (plu outDir: getOutputDirectory(userConfig, "client"), }, }, - ssr: { - resolve: { - noExternal: true, - conditions: [...DEFAULT_RESOLVE_CONDITION_NAMES, "development|production"], - }, - build: { - ssr: true, - target: TARGET, - emitAssets: true, - copyPublicDir: false, - outDir: getOutputDirectory(userConfig, "server"), - ...(isRolldown - ? { - rolldownOptions: { - ...rollupOptions, - platform: "neutral", - resolve: { - mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, - extensions: DEFAULT_RESOLVE_EXTENSIONS, - }, - }, - } - : { rollupOptions }), - }, - optimizeDeps: { - noDiscovery: false, - ignoreOutdatedRequests: true, - entries: pluginOptions.main ? vite.normalizePath(pluginOptions.main) : undefined, - ...(isRolldown - ? { - rolldownOptions: { - platform: "neutral", - resolve: { - conditionNames: [ - ...DEFAULT_RESOLVE_CONDITION_NAMES, - "development|production", - ], - mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, - extensions: DEFAULT_RESOLVE_EXTENSIONS, - }, - transform: { - target: TARGET, - define, - }, - }, - } - : { - esbuildOptions: { - platform: "neutral", - conditions: [...DEFAULT_RESOLVE_CONDITION_NAMES, "development|production"], - resolveExtensions: DEFAULT_RESOLVE_EXTENSIONS, - mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, - target: TARGET, - define, - }, - }), - }, - keepProcessEnv: true, - }, + [entryEnv]: makeWorkerEnvironment(entryEnv, { isEntry: true }), + ...Object.fromEntries( + childEnvs.map((name) => [name, makeWorkerEnvironment(name, { isEntry: false })]), + ), }, }; }, diff --git a/projects/cloudflare-tools/packages/cloudflare-runtime/src/Runtime.ts b/projects/cloudflare-tools/packages/cloudflare-runtime/src/Runtime.ts index 232d9a77e..9977c73b9 100644 --- a/projects/cloudflare-tools/packages/cloudflare-runtime/src/Runtime.ts +++ b/projects/cloudflare-tools/packages/cloudflare-runtime/src/Runtime.ts @@ -36,9 +36,14 @@ export const RuntimeLive = Layer.effect( const workerd = yield* Workerd.Workerd; const storage = yield* Storage.Storage; const docker = yield* Docker.Docker; - const plugins = yield* PluginContext.pickPluginsFromContext(); + const basePlugins = yield* PluginContext.pickPluginsFromContext(); const preparePlugins = Effect.fnUntraced(function* (worker: RuntimeWorker) { + const plugins = new Map(basePlugins); + const overrides = yield* PluginContext.pickPluginsFromContext(); + for (const [key, builder] of overrides) { + plugins.set(key, builder); + } const context = yield* PluginContext.make(worker as RuntimeWorker, plugins); const bindings = yield* Effect.all(worker.bindings as ReadonlyArray>, { concurrency: "unbounded", diff --git a/projects/cloudflare-tools/packages/cloudflare-runtime/src/bindings/assets/Assets.ts b/projects/cloudflare-tools/packages/cloudflare-runtime/src/bindings/assets/Assets.ts index 750ced391..6044baa13 100644 --- a/projects/cloudflare-tools/packages/cloudflare-runtime/src/bindings/assets/Assets.ts +++ b/projects/cloudflare-tools/packages/cloudflare-runtime/src/bindings/assets/Assets.ts @@ -333,7 +333,7 @@ export const buildAssetConfigs = ( staticRouting = parseStaticRouting(worker.assets.runWorkerFirst); } const routerConfig: RouterConfig = { - invoke_user_worker_ahead_of_assets: worker.assets?.runWorkerFirst !== false, + invoke_user_worker_ahead_of_assets: worker.assets?.runWorkerFirst === true, static_routing: staticRouting, has_user_worker: true, }; diff --git a/projects/cloudflare-tools/packages/cloudflare-runtime/test/Plugin.test.ts b/projects/cloudflare-tools/packages/cloudflare-runtime/test/Plugin.test.ts index 56c71cc5a..aabac49d5 100644 --- a/projects/cloudflare-tools/packages/cloudflare-runtime/test/Plugin.test.ts +++ b/projects/cloudflare-tools/packages/cloudflare-runtime/test/Plugin.test.ts @@ -92,6 +92,55 @@ describe("Plugin / PluginContext", () => { }), ); + it.effect("keeps plugin:entry as the innermost middleware before the user worker", () => + Effect.gen(function* () { + class Outer extends Plugin.Service()("cloudflare-runtime/plugin/Outer") {} + class Entry extends Plugin.Service()("cloudflare-runtime/plugin/Entry") {} + const outerLayer = Layer.succeed( + Outer, + Outer.of({ + middlewares: [ + { + name: "assets:router", + worker: { compatibilityDate: "2026-03-10" }, + upstreamBindingName: "USER_WORKER", + }, + ], + }), + ); + const entryLayer = Layer.succeed( + Entry, + Entry.of({ + middlewares: [ + { + name: "plugin:entry", + worker: { compatibilityDate: "2026-03-10" }, + upstreamBindingName: "USER_WORKER", + }, + ], + }), + ); + const ctx = yield* PluginContext.make(makeWorker()).pipe( + Effect.provide(Layer.mergeAll(entryLayer, outerLayer)), + ); + const config = yield* ctx.config; + expect(config.entry).toBe("assets:router"); + + const router = config.services.find((s) => s.name === "assets:router") as { + worker: { bindings: Array }; + }; + const entry = config.services.find((s) => s.name === "plugin:entry") as { + worker: { bindings: Array }; + }; + expect(router.worker.bindings.find((b) => b.name === "USER_WORKER")?.service?.name).toBe( + "plugin:entry", + ); + expect(entry.worker.bindings.find((b) => b.name === "USER_WORKER")?.service?.name).toBe( + SERVICE_USER_WORKER, + ); + }), + ); + it.effect("Plugin.useSync reads a plugin field synchronously", () => Effect.gen(function* () { const ctx = yield* PluginContext.make(makeWorker()); diff --git a/projects/cloudflare-tools/packages/cloudflare-runtime/test/bindings/Assets.test.ts b/projects/cloudflare-tools/packages/cloudflare-runtime/test/bindings/Assets.test.ts index d22851e41..2ec00b0ea 100644 --- a/projects/cloudflare-tools/packages/cloudflare-runtime/test/bindings/Assets.test.ts +++ b/projects/cloudflare-tools/packages/cloudflare-runtime/test/bindings/Assets.test.ts @@ -63,7 +63,7 @@ describe("Assets / buildAssetConfigs", () => { }, }); expect(routerConfig).toMatchObject({ - invoke_user_worker_ahead_of_assets: true, + invoke_user_worker_ahead_of_assets: false, has_user_worker: true, }); expect(routerConfig.static_routing).toBeDefined(); @@ -83,4 +83,22 @@ describe("Assets / buildAssetConfigs", () => { }); expect(routerConfig.invoke_user_worker_ahead_of_assets).toBe(false); }); + + it("defaults to assets-first routing when runWorkerFirst is not specified", () => { + const { routerConfig } = Assets.buildAssetConfigs({ + compatibilityDate: "2026-03-10", + compatibilityFlags: [], + assets: { directory: "/tmp/x" }, + }); + expect(routerConfig.invoke_user_worker_ahead_of_assets).toBe(false); + }); + + it("routes every request to the user worker when runWorkerFirst is true", () => { + const { routerConfig } = Assets.buildAssetConfigs({ + compatibilityDate: "2026-03-10", + compatibilityFlags: [], + assets: { directory: "/tmp/x", runWorkerFirst: true }, + }); + expect(routerConfig.invoke_user_worker_ahead_of_assets).toBe(true); + }); }); diff --git a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/README.md b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/README.md index e69de29bb..9fa00d7bb 100644 --- a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/README.md +++ b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/README.md @@ -0,0 +1,68 @@ +# `@oddlynew/distilled-cloudflare-vite-plugin` + +Vite plugin for running Cloudflare Workers with the distilled runtime. + +## Usage + +Static Vite apps can use the plugin with no options: + +```ts +import cloudflare from "@oddlynew/distilled-cloudflare-vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [cloudflare()], +}); +``` + +For an app with a Worker entry and SPA asset fallback: + +```ts +import cloudflare from "@oddlynew/distilled-cloudflare-vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + cloudflare({ + main: "./worker/index.ts", + compatibilityDate: "2026-03-10", + assets: { + notFoundHandling: "single-page-application", + }, + }), + ], +}); +``` + +The plugin derives the asset directory from Vite's client build output. Do not +configure an asset directory in Vite mode. + +## Options + +- `main?: string` - Worker entry module. Static/assets-only apps omit this. +- `compatibilityDate?: string` - Worker compatibility date. Keep this explicit + for production builds so deploys match the build. +- `compatibilityFlags?: Array` - Worker compatibility flags. +- `viteEnvironment?: { name?: string; childEnvironments?: Array }` - + Worker Vite environment topology, including RSC child environments. +- `assets?: { htmlHandling?, notFoundHandling?, runWorkerFirst? }` - routing + options for Vite-managed assets. Assets are served before the Worker unless + `runWorkerFirst` opts into Worker-first routing. +- `worker?: { name?, bindings?, durableObjectNamespaces?, hyperdrives?, assets? }` + - advanced local runtime options. +- `context?: Context.Context` - custom runtime service context. + +`worker.assets` remains supported for advanced runtime configuration, but the +top-level `assets` option is preferred for Vite asset routing. If the same +asset routing option is set in both places with different values, the plugin +throws. + +## Build Manifest + +Worker builds write `__distilled-build.json` beside the build output. The +manifest records the Worker entry module, every Worker module that must be +uploaded, compatibility metadata, and the Vite client asset directory. Asset +routing options from `assets` are copied into the manifest so deploy consumers +can preserve the same routing behavior. + +Pure static/assets-only builds do not emit a Worker manifest. diff --git a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/dev-plugin.ts b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/dev-plugin.ts index d1758e0bb..23e78fcf8 100644 --- a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/dev-plugin.ts +++ b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/dev-plugin.ts @@ -1,3 +1,4 @@ +import { workerEnvironments } from "@oddlynew/distilled-cloudflare-rolldown-plugin/options"; import type { OptionsApi } from "@oddlynew/distilled-cloudflare-rolldown-plugin/plugins"; import { resolvePluginApi } from "@oddlynew/distilled-cloudflare-rolldown-plugin/utils"; import type { RuntimeServices } from "@oddlynew/distilled-cloudflare-runtime"; @@ -6,7 +7,7 @@ import * as NodeHttp from "node:http"; import * as vite from "vite"; import { DistilledDevEnvironment } from "./dev-environment.js"; import { createDefaultContext, startServer, type ServerHandle } from "./dev-server.js"; -import type { CloudflareVitePluginOptions } from "./plugin.js"; +import type { CloudflareVitePluginOptions } from "./options.js"; import { handleWebSocket } from "./websockets.js"; let context: Context.Context | undefined; @@ -28,26 +29,28 @@ export function dev(options: CloudflareVitePluginOptions): vite.Plugin { optionsApi = resolvePluginApi(plugins ?? [], "distilled-cloudflare:options"); }, config() { - return { - environments: { - ssr: { - dev: { - createEnvironment(name, config) { - const hasConfigureServer = config.plugins.some( - (plugin) => - plugin.name === "distilled-cloudflare:dev" && - plugin.configureServer !== undefined, - ); - if (!hasConfigureServer) { - return vite.createRunnableDevEnvironment(name, config); - } + const devEnvironmentConfig = { + dev: { + createEnvironment(name: string, config: vite.ResolvedConfig) { + const hasConfigureServer = config.plugins.some( + (plugin) => + plugin.name === "distilled-cloudflare:dev" && plugin.configureServer !== undefined, + ); + if (!hasConfigureServer) { + return vite.createRunnableDevEnvironment(name, config); + } - return new DistilledDevEnvironment(name, config); - }, - }, + return new DistilledDevEnvironment(name, config); }, }, }; + // The entry environment and every child environment it loads at runtime + // run in workerd with a module runner. (Default: the single `ssr` env.) + return { + environments: Object.fromEntries( + workerEnvironments(options).all.map((name) => [name, devEnvironmentConfig]), + ), + }; }, async buildEnd() { if (!isServerRestarting) { @@ -89,9 +92,18 @@ export function dev(options: CloudflareVitePluginOptions): vite.Plugin { options.context ?? context!, ); const address = handle.address; - const ssrEnvironment = server.environments.ssr; - if (ssrEnvironment instanceof DistilledDevEnvironment) { - await ssrEnvironment.connect(address); + // Connect a module runner for the entry environment and each child it + // loads at runtime, so cross-environment imports (e.g. an RSC entry + // loading `ssr` modules) resolve to a live runner inside workerd. + // Settle each environment's dep optimizer BEFORE its runner starts + // importing: a re-optimization after first import re-hashes shared deps + // and duplicates singletons like React (a null hooks dispatcher). + for (const environmentName of workerEnvironments(options).all) { + const environment = server.environments[environmentName]; + if (environment instanceof DistilledDevEnvironment) { + await environment.depsOptimizer?.init(); + await environment.connect(address); + } } if (!input) { // If there is no input, we are in SPA mode, so we don't need to route requests to the server. diff --git a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/dev-server.ts b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/dev-server.ts index 31dbde3c2..21d0d6e76 100644 --- a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/dev-server.ts +++ b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/dev-server.ts @@ -1,3 +1,4 @@ +import { workerEnvironments } from "@oddlynew/distilled-cloudflare-rolldown-plugin/options"; import type { BindingHooks, Module, RuntimeServices } from "@oddlynew/distilled-cloudflare-runtime"; import { layerRuntime, Runtime } from "@oddlynew/distilled-cloudflare-runtime"; import { @@ -7,7 +8,7 @@ import { UnsafeEval, } from "@oddlynew/distilled-cloudflare-runtime/bindings"; import * as Credentials from "@oddlynew/distilled-cloudflare/Credentials"; -import type * as Context from "effect/Context"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -22,7 +23,7 @@ import * as ModuleRunnerWorker from "worker:./module-runner/module-runner.worker import * as WrapperWorker from "worker:./module-runner/wrapper.worker.ts"; import * as ViteAssets from "./assets/ViteAssets"; import { ENVIRONMENT_NAME_HEADER } from "./module-runner/constants.shared.ts"; -import type { CloudflareVitePluginOptions } from "./plugin"; +import type { CloudflareVitePluginOptions } from "./options"; export type ServerHandle = Awaited>; @@ -36,9 +37,14 @@ export const startServer = async ( context: Context.Context, ) => { const scope = Scope.makeUnsafe(); - const address = await serve(options, entry, server).pipe( - Effect.provide(ViteAssets.ViteAssetsLive(server)), + const assetsContext = await ViteAssets.ViteAssetsLive(server).pipe( + Layer.buildWithScope(scope), Effect.provide(context), + Effect.runPromise, + ); + const devContext = Context.merge(context, assetsContext); + const address = await serve(options, entry, server).pipe( + Effect.provideContext(devContext), Scope.provide(scope), Effect.runPromise, ); @@ -103,7 +109,9 @@ const serve = Effect.fn(function* ( className: "ModuleRunnerDO", }), Json.local("__DISTILLED_ENVIRONMENT__", { - environmentName: "ssr", + // The Worker runs the entry environment (the `rsc` env for RSC apps, + // `ssr` otherwise); its module runner imports the entry from here. + environmentName: workerEnvironments(options).entry, entryId: entry.id, entryName: entry.name, }), diff --git a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts index a7a0771de..250f71ce6 100644 --- a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts +++ b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/module-runner/module-runner.worker.ts @@ -57,7 +57,12 @@ export class ModuleRunnerDO extends DurableObject { globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async (environmentName: string, id: string) => { const moduleRunner = this.moduleRunners.get(environmentName); if (!moduleRunner) { - throw new Error(`Module runner not initialized for environment: "${environmentName}"`); + throw new Error( + `Module runner not initialized for environment: "${environmentName}". ` + + `If this is a child environment loaded at runtime (e.g. an RSC app's ` + + `"ssr" environment), ensure it's listed in the plugin's ` + + `viteEnvironment.childEnvironments.`, + ); } return callbacks.run(this.env, () => moduleRunner.import(id)); }; diff --git a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/options.ts b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/options.ts new file mode 100644 index 000000000..fae007607 --- /dev/null +++ b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/options.ts @@ -0,0 +1,117 @@ +import type { CloudflarePluginOptions } from "@oddlynew/distilled-cloudflare-rolldown-plugin/options"; +import type { + Assets as RuntimeAssets, + BindingHooks, + RuntimeServices, + RuntimeWorker, +} from "@oddlynew/distilled-cloudflare-runtime"; +import type * as Context from "effect/Context"; + +const ASSET_ROUTING_KEYS = ["htmlHandling", "notFoundHandling", "runWorkerFirst"] as const; + +export type CloudflareViteAssetsOptions = Pick; + +export type CloudflareViteWorkerOptions = Partial< + Omit, "compatibilityDate" | "compatibilityFlags" | "modules" | "assets"> +> & { + /** + * Advanced asset runtime options. In Vite mode the asset directory is always + * derived from the client build output; prefer the top-level `assets` + * shorthand for routing options such as SPA fallback. + */ + assets?: RuntimeAssets; +}; + +export interface CloudflareVitePluginOptions< + B extends BindingHooks = BindingHooks, +> extends CloudflarePluginOptions { + /** + * Asset routing options for the Vite-managed client output. The output + * directory is inferred from Vite and must not be configured here. + */ + assets?: CloudflareViteAssetsOptions; + worker?: CloudflareViteWorkerOptions; + context?: Context.Context; +} + +export function normalizeCloudflareVitePluginOptions( + options: CloudflareVitePluginOptions, +): CloudflareVitePluginOptions { + const assets = mergeAssetRoutingOptions(options.assets, options.worker?.assets); + if (!assets) return options; + + return { + ...options, + assets, + worker: { + ...options.worker, + assets: { + ...options.worker?.assets, + ...assets, + }, + }, + }; +} + +function mergeAssetRoutingOptions( + topLevelAssets: CloudflareViteAssetsOptions | undefined, + workerAssets: RuntimeAssets | undefined, +): CloudflareViteAssetsOptions | undefined { + const pickedWorkerAssets = pickAssetRoutingOptions(workerAssets); + const hasTopLevelAssets = hasAssetRoutingOptions(topLevelAssets); + const hasWorkerAssets = hasAssetRoutingOptions(pickedWorkerAssets); + + if (!hasTopLevelAssets && !hasWorkerAssets) return undefined; + + for (const key of ASSET_ROUTING_KEYS) { + const topLevelValue = topLevelAssets?.[key]; + const workerValue = pickedWorkerAssets?.[key]; + if ( + topLevelValue !== undefined && + workerValue !== undefined && + !assetRoutingValuesEqual(topLevelValue, workerValue) + ) { + throw new Error( + `[cloudflare] assets.${key} is configured both at the top level and ` + + `under worker.assets with different values. Use one location for asset routing options.`, + ); + } + } + + return { + ...pickedWorkerAssets, + ...topLevelAssets, + }; +} + +function pickAssetRoutingOptions( + assets: RuntimeAssets | undefined, +): CloudflareViteAssetsOptions | undefined { + if (!assets) return undefined; + + return Object.fromEntries( + ASSET_ROUTING_KEYS.flatMap((key) => { + const value = assets[key]; + return value === undefined ? [] : [[key, value]]; + }), + ) as CloudflareViteAssetsOptions; +} + +function hasAssetRoutingOptions(assets: CloudflareViteAssetsOptions | undefined) { + return ASSET_ROUTING_KEYS.some((key) => assets?.[key] !== undefined); +} + +function assetRoutingValuesEqual( + a: CloudflareViteAssetsOptions[(typeof ASSET_ROUTING_KEYS)[number]], + b: CloudflareViteAssetsOptions[(typeof ASSET_ROUTING_KEYS)[number]], +) { + if (Array.isArray(a) || Array.isArray(b)) { + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((value, index) => value === b[index]) + ); + } + return a === b; +} diff --git a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/plugin.ts b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/plugin.ts index f528302f8..70b172519 100644 --- a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/plugin.ts +++ b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/src/plugin.ts @@ -1,6 +1,6 @@ -import type { CloudflarePluginOptions } from "@oddlynew/distilled-cloudflare-rolldown-plugin/options"; import { additionalModulesPlugin, + buildManifestPlugin, cloudflareExternalsPlugin, nodejsAlsPlugin, nodejsImportWarningPlugin, @@ -9,34 +9,33 @@ import { virtualModulesPlugin, wasmInitPlugin, } from "@oddlynew/distilled-cloudflare-rolldown-plugin/plugins"; -import type { - BindingHooks, - RuntimeServices, - RuntimeWorker, -} from "@oddlynew/distilled-cloudflare-runtime"; -import type * as Context from "effect/Context"; import type * as vite from "vite"; import { dev } from "./dev-plugin.js"; +import { + normalizeCloudflareVitePluginOptions, + type CloudflareVitePluginOptions, +} from "./options.js"; -export interface CloudflareVitePluginOptions< - B extends BindingHooks = BindingHooks, -> extends CloudflarePluginOptions { - worker?: Omit, "compatibilityDate" | "compatibilityFlags" | "modules">; - context?: Context.Context; -} +export type { + CloudflareViteAssetsOptions, + CloudflareVitePluginOptions, + CloudflareViteWorkerOptions, +} from "./options.js"; export default function cloudflareVitePlugin( options: CloudflareVitePluginOptions = {}, ): Array { + const resolvedOptions = normalizeCloudflareVitePluginOptions(options); + return [ - optionsPlugin.vite(options), - cloudflareExternalsPlugin.vite(options), - nodejsAlsPlugin.vite(options), - nodejsImportWarningPlugin.vite(options), - nodejsUnenvPlugin.vite(options), - virtualModulesPlugin.vite(options), - wasmInitPlugin.vite(options), - additionalModulesPlugin.vite(options), + optionsPlugin.vite(resolvedOptions), + cloudflareExternalsPlugin.vite(resolvedOptions), + nodejsAlsPlugin.vite(resolvedOptions), + nodejsImportWarningPlugin.vite(resolvedOptions), + nodejsUnenvPlugin.vite(resolvedOptions), + virtualModulesPlugin.vite(resolvedOptions), + wasmInitPlugin.vite(resolvedOptions), + additionalModulesPlugin.vite(resolvedOptions), { name: "distilled-cloudflare:rsc", enforce: "pre", @@ -44,6 +43,7 @@ export default function cloudflareVitePlugin( return { rsc: { serverHandler: false } } as vite.UserConfig; }, } as vite.Plugin, - dev(options), + buildManifestPlugin(resolvedOptions), + dev(resolvedOptions), ]; } diff --git a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/test/build-manifest.test.ts b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/test/build-manifest.test.ts new file mode 100644 index 000000000..8e963908a --- /dev/null +++ b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/test/build-manifest.test.ts @@ -0,0 +1,136 @@ +import cloudflare from "@oddlynew/distilled-cloudflare-vite-plugin"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { createBuilder, type Plugin } from "vite"; +import * as fs from "node:fs"; +import path from "node:path"; + +const sourceFixtureDir = path.resolve( + import.meta.dirname, + "../../cloudflare-rolldown-plugin/test/fixtures/additional-modules", +); +const fixtureDir = path.resolve(import.meta.dirname, "../.test-tmp/additional-modules"); +const distDir = path.join(fixtureDir, "dist"); +const manifestPath = path.join(distDir, "__distilled-build.json"); + +type Manifest = { + version: number; + workers: { + app: { + main: string; + modules: Array<{ path: string; type: string }>; + compatibilityDate?: string; + compatibilityFlags?: Array; + }; + }; + assets?: { + directory: string; + htmlHandling?: string; + notFoundHandling?: string; + runWorkerFirst?: Array | boolean; + }; +}; + +const workerArtifactPlugin = { + name: "test-worker-artifacts", + generateBundle() { + if (this.environment.name !== "ssr") return; + this.emitFile({ + type: "asset", + fileName: "diagnostic.json", + source: JSON.stringify({ ok: true }), + }); + this.emitFile({ + type: "asset", + fileName: "ignored.js.map", + source: "{}", + }); + }, +} satisfies Plugin; + +describe("build manifest", () => { + let manifest: Manifest; + + beforeAll(async () => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + fs.cpSync(sourceFixtureDir, fixtureDir, { recursive: true }); + fs.writeFileSync(path.join(fixtureDir, "index.html"), '
\n'); + + const builder = await createBuilder({ + root: fixtureDir, + logLevel: "silent", + plugins: [ + cloudflare({ + main: "./index.ts", + compatibilityDate: "2026-03-10", + compatibilityFlags: ["nodejs_compat"], + assets: { + htmlHandling: "auto-trailing-slash", + notFoundHandling: "single-page-application", + runWorkerFirst: ["/api/*"], + }, + }), + workerArtifactPlugin, + ], + build: { + minify: false, + }, + }); + await builder.buildApp(); + manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + }); + + afterAll(() => { + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }); + + test("records explicit module types for emitted worker modules", () => { + const worker = manifest.workers.app; + const modulePaths = worker.modules.map((module) => module.path); + + expect(manifest.version).toBe(2); + expect(worker.compatibilityDate).toBe("2026-03-10"); + expect(worker.compatibilityFlags).toEqual(["nodejs_compat"]); + expect(manifest.assets).toEqual({ + directory: "client", + htmlHandling: "auto-trailing-slash", + notFoundHandling: "single-page-application", + runWorkerFirst: ["/api/*"], + }); + expect(modulePaths).toEqual([...modulePaths].sort((a, b) => a.localeCompare(b))); + expect(new Set(modulePaths).size).toBe(modulePaths.length); + expect(worker.modules).toContainEqual({ + path: worker.main, + type: "esm", + }); + expect(worker.modules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: expect.stringMatching(/bin-example.*\.bin$/), + type: "data", + }), + expect.objectContaining({ + path: expect.stringMatching(/html-example.*\.html$/), + type: "text", + }), + expect.objectContaining({ + path: expect.stringMatching(/text-example.*\.txt$/), + type: "text", + }), + expect.objectContaining({ + path: expect.stringMatching(/sql-example.*\.sql$/), + type: "text", + }), + expect.objectContaining({ + path: expect.stringMatching(/wasm-example.*\.wasm$/), + type: "wasm", + }), + expect.objectContaining({ + path: "server/diagnostic.json", + type: "json", + }), + ]), + ); + expect(worker.modules.some((module) => module.path.endsWith(".map"))).toBe(false); + expect(worker.modules.some((module) => module.type === "sourcemap")).toBe(false); + }); +}); diff --git a/projects/cloudflare-tools/packages/cloudflare-vite-plugin/test/options.test.ts b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/test/options.test.ts new file mode 100644 index 000000000..3ef5c50fb --- /dev/null +++ b/projects/cloudflare-tools/packages/cloudflare-vite-plugin/test/options.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "vitest"; +import { normalizeCloudflareVitePluginOptions } from "../src/options.ts"; + +describe("Cloudflare Vite plugin options", () => { + test("normalizes top-level asset routing into worker assets", () => { + const options = normalizeCloudflareVitePluginOptions({ + assets: { + notFoundHandling: "single-page-application", + runWorkerFirst: ["/api/*"], + }, + }); + + expect(options.assets).toEqual({ + notFoundHandling: "single-page-application", + runWorkerFirst: ["/api/*"], + }); + expect(options.worker?.assets).toEqual({ + notFoundHandling: "single-page-application", + runWorkerFirst: ["/api/*"], + }); + }); + + test("preserves worker asset fields while exposing routing fields for deploy", () => { + const options = normalizeCloudflareVitePluginOptions({ + worker: { + name: "app", + assets: { + directory: "/tmp/ignored-by-vite-build", + headers: "_headers", + htmlHandling: "none", + }, + }, + }); + + expect(options.assets).toEqual({ htmlHandling: "none" }); + expect(options.worker?.assets).toEqual({ + directory: "/tmp/ignored-by-vite-build", + headers: "_headers", + htmlHandling: "none", + }); + }); + + test("rejects conflicting asset routing options", () => { + expect(() => + normalizeCloudflareVitePluginOptions({ + assets: { notFoundHandling: "single-page-application" }, + worker: { + assets: { notFoundHandling: "404-page" }, + }, + }), + ).toThrow( + `[cloudflare] assets.notFoundHandling is configured both at the top level and under worker.assets with different values.`, + ); + }); +});