diff --git a/bun.lock b/bun.lock index 008535d89..975a6b6fb 100644 --- a/bun.lock +++ b/bun.lock @@ -819,10 +819,15 @@ "@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", @@ -943,6 +948,8 @@ "@types/bun": "latest", "@types/node": "latest", "@typescript/native-preview": "7.0.0-dev.20260611.2", + "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-rsc": "^0.5.27", "ai": "^6.0.62", "archiver": "^7.0.1", "aws4fetch": "^1.0.20", @@ -956,6 +963,7 @@ "oxfmt": "^0.36.0", "oxlint": "^1.51.0", "pathe": "^2.0.3", + "react-router": "7.16.0", "rolldown": "1.0.1", "solid-js": "latest", "sonda": "^0.11.1", @@ -2178,6 +2186,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=="], @@ -3122,7 +3132,7 @@ "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], @@ -3654,6 +3664,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@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], @@ -3780,7 +3792,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=="], @@ -3844,7 +3856,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=="], @@ -3978,6 +3990,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=="], @@ -4240,6 +4254,8 @@ "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@babel/core/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -4364,6 +4380,8 @@ "@solidjs/start/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "@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=="], @@ -4398,8 +4416,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/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -4456,6 +4472,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=="], + "boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "boxen/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], @@ -4528,9 +4546,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=="], "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -4632,8 +4650,6 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], - "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], @@ -4860,10 +4876,16 @@ "@solidjs/vite-plugin-nitro-2/vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + "@tanstack/directive-functions-plugin/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@tanstack/directive-functions-plugin/@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "@tanstack/server-functions-plugin/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@tanstack/start-plugin-core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@typescript/analyze-trace/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@typescript/analyze-trace/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], diff --git a/package.json b/package.json index 60aad82c3..24aa6ec09 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,8 @@ "@types/bun": "latest", "@types/node": "latest", "@typescript/native-preview": "7.0.0-dev.20260611.2", + "@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", @@ -104,6 +106,7 @@ "drizzle-orm": ">=1.0.0-rc.1", "effect": ">=4.0.0-beta.78 || >=4.0.0", "fast-xml-parser": "^5.3.4", + "react-router": "7.16.0", "rolldown": "1.0.1", "solid-js": "latest", "sonda": "^0.11.1", @@ -151,4 +154,4 @@ "typescript": "latest", "yaml": "^2.8.2" } -} \ No newline at end of file +} diff --git a/packages/alchemy/package.json b/packages/alchemy/package.json index 13fb95e54..fd1b4f23f 100644 --- a/packages/alchemy/package.json +++ b/packages/alchemy/package.json @@ -340,9 +340,14 @@ "@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/packages/alchemy/src/Bundle/Bundle.ts b/packages/alchemy/src/Bundle/Bundle.ts index 4f7191e71..433cd96fd 100644 --- a/packages/alchemy/src/Bundle/Bundle.ts +++ b/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/packages/alchemy/src/Cloudflare/Website/Vite.ts b/packages/alchemy/src/Cloudflare/Website/Vite.ts index d1500acaa..194d70165 100644 --- a/packages/alchemy/src/Cloudflare/Website/Vite.ts +++ b/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 `@distilled.cloud/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,28 @@ 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. + * + * @example RSC topology + * ```typescript + * const app = yield* Cloudflare.Vite("ReactRouter", { + * compatibility: { + * flags: ["nodejs_compat"], + * }, + * viteEnvironment: { + * name: "rsc", + * childEnvironments: ["ssr"], * }, * }); * ``` @@ -95,12 +142,55 @@ 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 Custom Rebuild Scope @@ -170,13 +260,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/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts index d4328c3c0..ddef5d344 100644 --- a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts @@ -383,7 +383,7 @@ export const LocalWorkerProvider = () => const runVite = Effect.fnUntraced(function* ( worker: WorkerConfig, - rootDir: string | undefined, + vite: NonNullable, ) { const proxy = yield* maybeStartProxy(worker.id, worker.dev); yield* proxy.unset().pipe(Effect.forkChild); @@ -391,11 +391,12 @@ export const LocalWorkerProvider = () => // (~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, @@ -439,7 +440,7 @@ export const LocalWorkerProvider = () => const { props, bindings } = options; const config = yield* buildConfig(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, diff --git a/packages/alchemy/src/Cloudflare/Workers/Vite.ts b/packages/alchemy/src/Cloudflare/Workers/Vite.ts index a8f696fd0..f84ce7617 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Vite.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Vite.ts @@ -2,16 +2,81 @@ import cloudflare, { type CloudflareVitePluginOptions, } from "@distilled.cloud/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/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index faf776abd..3219c1bd9 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -6,7 +6,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"; @@ -24,6 +23,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"; @@ -41,6 +41,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, @@ -207,10 +208,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; /** @@ -379,6 +383,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. @@ -813,8 +824,6 @@ export const LiveWorkerProvider = () => Provider.effect( Worker, Effect.gen(function* () { - const path = yield* Path.Path; - const bundler = yield* WorkerBundle; const stack = yield* Stack; @@ -1233,56 +1242,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 `@distilled.cloud/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" }, @@ -1309,30 +1403,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( [ @@ -1352,7 +1438,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), }), ), }, @@ -1844,11 +1931,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), @@ -2417,29 +2501,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/packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts index 3d4e3ddfb..7e31e72d5 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerBundle.ts +++ b/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/packages/alchemy/test/Cloudflare/Website/Vite.test.ts b/packages/alchemy/test/Cloudflare/Website/Vite.test.ts index 429328181..309ebd66a 100644 --- a/packages/alchemy/test/Cloudflare/Website/Vite.test.ts +++ b/packages/alchemy/test/Cloudflare/Website/Vite.test.ts @@ -1,6 +1,8 @@ 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 { expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -8,13 +10,16 @@ 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() }); @@ -24,6 +29,11 @@ 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", +); // 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 +43,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 +107,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 +176,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 +212,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 +232,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 +301,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 +314,301 @@ 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 }, +); + +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 discoverBundleUrl = (siteUrl: string) => Effect.gen(function* () { const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient); diff --git a/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/.gitignore b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/.gitignore new file mode 100644 index 000000000..bf778dc43 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/.gitignore @@ -0,0 +1,3 @@ +.alchemy +dist +node_modules diff --git a/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/alchemy.run.ts b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/alchemy.run.ts new file mode 100644 index 000000000..85d3064fe --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/alchemy.run.ts @@ -0,0 +1,39 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "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/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/root.tsx b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/root.tsx new file mode 100644 index 000000000..508e89cb6 --- /dev/null +++ b/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/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes.ts b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes.ts new file mode 100644 index 000000000..5a2907fb9 --- /dev/null +++ b/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/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes/about.tsx b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes/about.tsx new file mode 100644 index 000000000..88c19c8bf --- /dev/null +++ b/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/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes/home.tsx b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/app/routes/home.tsx new file mode 100644 index 000000000..a2d167c52 --- /dev/null +++ b/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/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/package.json b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/package.json new file mode 100644 index 000000000..8b0a6600d --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/package.json @@ -0,0 +1,25 @@ +{ + "name": "alchemy-react-router-rsc-fixture", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "alchemy dev", + "build": "vite build" + }, + "dependencies": { + "alchemy": "workspace:*", + "effect": "catalog:", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "7.16.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "catalog:", + "@vitejs/plugin-rsc": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.browser.tsx b/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/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/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.rsc.single.tsx b/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/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/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.rsc.tsx b/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/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/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.ssr.tsx b/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/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/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/entry.worker.tsx b/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/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/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/react-router-vite/worker-ssr.tsx b/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/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/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/tsconfig.json b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/tsconfig.json new file mode 100644 index 000000000..e3b7f7d9a --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "types": ["@cloudflare/workers-types"] + } +} diff --git a/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/vite.config.ts b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/vite.config.ts new file mode 100644 index 000000000..71f07a26a --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Website/react-router-rsc-fixture/vite.config.ts @@ -0,0 +1,44 @@ +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 }, + 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/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/.gitignore b/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/.gitignore new file mode 100644 index 000000000..bf778dc43 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/.gitignore @@ -0,0 +1,3 @@ +.alchemy +dist +node_modules diff --git a/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/alchemy.run.ts b/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/alchemy.run.ts new file mode 100644 index 000000000..cba302b11 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/alchemy.run.ts @@ -0,0 +1,31 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "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/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/index.html b/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/index.html new file mode 100644 index 000000000..34d7af37b --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/index.html @@ -0,0 +1,11 @@ + + + + + Vite DO fixture + + +
Vite DO fixture
+ + + diff --git a/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/package.json b/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/package.json new file mode 100644 index 000000000..0b4561e29 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/package.json @@ -0,0 +1,14 @@ +{ + "name": "alchemy-vite-do-fixture", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "alchemy dev" + }, + "devDependencies": { + "alchemy": "workspace:*", + "effect": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/src/main.ts b/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/src/main.ts new file mode 100644 index 000000000..1db10ace1 --- /dev/null +++ b/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/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/src/worker.ts b/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/src/worker.ts new file mode 100644 index 000000000..161aa68dc --- /dev/null +++ b/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/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/vite.config.ts b/packages/alchemy/test/Cloudflare/Website/vite-do-fixture/vite.config.ts new file mode 100644 index 000000000..0803fe1b2 --- /dev/null +++ b/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/packages/alchemy/test/test.resources.ts b/packages/alchemy/test/test.resources.ts index c55203639..d2eee67ac 100644 --- a/packages/alchemy/test/test.resources.ts +++ b/packages/alchemy/test/test.resources.ts @@ -639,6 +639,7 @@ export const OverrideStablesResource = Resource( export const overrideStablesResourceProvider = () => Provider.succeed(OverrideStablesResource, { + list: () => Effect.succeed([]), stables: ["providerStable", "sharedStable"], diff: Effect.fn(function* ({ news = {}, olds = {} }) { if (!isResolved(news)) return undefined; diff --git a/packages/alchemy/tsconfig.test.json b/packages/alchemy/tsconfig.test.json index 309de96c7..e3efb232b 100644 --- a/packages/alchemy/tsconfig.test.json +++ b/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": { "composite": true,