diff --git a/README.md b/README.md index 071d8c0..75a3b56 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Every meaningful input can leave a trace that explains why the UI changed. ## this is -- A **Bun-native** framework (no webpack, no vite by default — just `bun --hot`) +- A **Bun-hosted runtime with a Vite client pipeline** — Bun owns the program/stream host; Vite owns browser modules, CSS, React Refresh, and React Compiler wiring - **Effect-native workflow runtime** — typed, composable, testable server logic - **Resource-driven** — define your data as resources, and the runtime automatically tracks who reads what - **Program-owned state** — durable workflow truth stays in resources/actions; server-observed view/editing context is modeled as `UIState` @@ -119,7 +119,10 @@ const screen = Screen.define("approval.deployments") .route("/teams/:teamId/deployments", { params: Schema.Struct({ teamId: Schema.String }), }) - .patchManifest(approvalProjectionPatchManifest) + .regions({ + layout: Region.merge(), + pendingDeployments: Region.replace(), + }) .project((view, ctx) => Effect.gen(function* () { return { @@ -246,8 +249,9 @@ That's the bet. It might be wrong. But it's the reason this repo exists. ## built with - **[Bun](https://bun.sh)** — runtime, bundler, test runner, package manager +- **[Vite](https://vite.dev)** — browser client pipeline for modules, CSS, React Refresh, and production assets - **[Effect](https://effect.website)** — typed effects for server logic -- **[React 19](https://react.dev)** — UI rendering layer +- **[React 19](https://react.dev)** — UI rendering layer with adapter-owned root, provider, optimistic, and error-boundary conventions --- diff --git a/bun.lock b/bun.lock index d568807..3296827 100644 --- a/bun.lock +++ b/bun.lock @@ -10,16 +10,74 @@ "react-dom": "^19.2.6", }, "devDependencies": { + "@babel/core": "^7.29.0", + "@rolldown/plugin-babel": "^0.2.3", + "@types/babel__core": "^7.20.5", "@types/bun": "^1.3.13", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "babel-plugin-react-compiler": "^1.0.0", "oxfmt": "^0.49.0", "oxlint": "^1.64.0", "typescript": "^6.0.3", + "vite": "^8.0.13", }, }, }, "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-HbifJ84prIh9+55CTPAU35JdRQrwg47y16cGerCC+iejSKOuHXYo2WDql6l7cQlzrYVtc3f4UWY+dBj2lRmOeA=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ef7SKJqAaH2d7E6eXZZa2OffIShbhFMxnGK0zd93p4qiyTJr75B0qf7lrPD+qQOwcf04BrjYJ0JUxq8d5+yZwg=="], @@ -96,8 +154,52 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-9CBR+LO0JVST87fNTzzNxS5I29jIUO5gxT9i9+M3SDHHALElj9sY1Prf12tad3vIRC6OD7Ehtvvh+sn13vSwHw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.1", "", { "os": "none", "cpu": "arm64" }, "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.1", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ=="], + + "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.3", "", { "dependencies": { "picomatch": "^4.0.4" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], @@ -106,30 +208,116 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@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=="], + + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "effect": ["effect@3.21.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.357", "", {}, "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="], + "oxfmt": ["oxfmt@0.49.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.49.0", "@oxfmt/binding-android-arm64": "0.49.0", "@oxfmt/binding-darwin-arm64": "0.49.0", "@oxfmt/binding-darwin-x64": "0.49.0", "@oxfmt/binding-freebsd-x64": "0.49.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.49.0", "@oxfmt/binding-linux-arm-musleabihf": "0.49.0", "@oxfmt/binding-linux-arm64-gnu": "0.49.0", "@oxfmt/binding-linux-arm64-musl": "0.49.0", "@oxfmt/binding-linux-ppc64-gnu": "0.49.0", "@oxfmt/binding-linux-riscv64-gnu": "0.49.0", "@oxfmt/binding-linux-riscv64-musl": "0.49.0", "@oxfmt/binding-linux-s390x-gnu": "0.49.0", "@oxfmt/binding-linux-x64-gnu": "0.49.0", "@oxfmt/binding-linux-x64-musl": "0.49.0", "@oxfmt/binding-openharmony-arm64": "0.49.0", "@oxfmt/binding-win32-arm64-msvc": "0.49.0", "@oxfmt/binding-win32-ia32-msvc": "0.49.0", "@oxfmt/binding-win32-x64-msvc": "0.49.0" }, "peerDependencies": { "svelte": "^5.0.0" }, "optionalPeers": ["svelte"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IAHFMdlJSWe+oAr65dx22UvjCtV9DBMisAuLnKpDqMQrctzCkGnj3QRwNHm0d+uwSWPalsDF8ZYLz9rh6nH2IQ=="], "oxlint": ["oxlint@1.64.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.64.0", "@oxlint/binding-android-arm64": "1.64.0", "@oxlint/binding-darwin-arm64": "1.64.0", "@oxlint/binding-darwin-x64": "1.64.0", "@oxlint/binding-freebsd-x64": "1.64.0", "@oxlint/binding-linux-arm-gnueabihf": "1.64.0", "@oxlint/binding-linux-arm-musleabihf": "1.64.0", "@oxlint/binding-linux-arm64-gnu": "1.64.0", "@oxlint/binding-linux-arm64-musl": "1.64.0", "@oxlint/binding-linux-ppc64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-gnu": "1.64.0", "@oxlint/binding-linux-riscv64-musl": "1.64.0", "@oxlint/binding-linux-s390x-gnu": "1.64.0", "@oxlint/binding-linux-x64-gnu": "1.64.0", "@oxlint/binding-linux-x64-musl": "1.64.0", "@oxlint/binding-openharmony-arm64": "1.64.0", "@oxlint/binding-win32-arm64-msvc": "1.64.0", "@oxlint/binding-win32-ia32-msvc": "1.64.0", "@oxlint/binding-win32-x64-msvc": "1.64.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Star3SNpWPeWFPw7kRXIhXUSn6fdiAl25q15CQzH/9WaOtG6e9CWTc25vNZOCr4PE1yEP1GtKJKIKglhj3OmEQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@8.0.13", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.1", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], } } diff --git a/docs/design/developer-experience.md b/docs/design/developer-experience.md index 29cf22b..5b7ae99 100644 --- a/docs/design/developer-experience.md +++ b/docs/design/developer-experience.md @@ -311,7 +311,9 @@ const ApprovalScreen = Screen.define("approval.deployments") .route("/teams/:teamId/deployments", { params: Schema.Struct({ teamId: Schema.String }), }) - .patchManifest(approvalProjectionPatchManifest) + .regions({ + pendingDeployments: Region.replace(), + }) .project((view, context) => Effect.gen(function* () { return { diff --git a/docs/design/runtime.md b/docs/design/runtime.md index 7eaaf54..93af6c7 100644 --- a/docs/design/runtime.md +++ b/docs/design/runtime.md @@ -32,9 +32,11 @@ flowchart LR Views --> Trace ``` -### Bun Host +### Bun Host And Client Pipeline -The Bun host is the first runtime target. +The Bun host is the first server runtime target. The browser client pipeline can be Bun-built for +small fixtures, but the current default demo path uses Vite for browser modules, CSS, React Refresh, +and React Compiler integration. Responsibilities: @@ -42,8 +44,8 @@ Responsibilities: - provide request and socket entrypoints - load the server program - host the custom stream transport -- integrate with Bun's bundling and dev server capabilities over time -- run Bun-native asset hooks for styles and other development outputs +- integrate with a client asset pipeline such as Vite without moving program runtime ownership into the client dev server +- keep Bun-native asset hooks available for low-level fixtures and custom style build outputs - provide the first local development story Bun should be treated as the practical host, not the whole architecture. The model should still be shaped by serverless constraints: processes can die, memory can disappear, and reconnect should be expected. @@ -159,6 +161,8 @@ The React adapter renders projections and hosts React components. Responsibilities: - mount the app shell +- provide root hydration/render helpers and React 19 root error callbacks +- expose a provider/context hook layer over the lower-level stream client - connect browser events to framework inputs - render server projections - host client islands diff --git a/package.json b/package.json index 94763a5..be7adc4 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,17 @@ "react-dom": "^19.2.6" }, "devDependencies": { + "@babel/core": "^7.29.0", + "@rolldown/plugin-babel": "^0.2.3", + "@types/babel__core": "^7.20.5", "@types/bun": "^1.3.13", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "babel-plugin-react-compiler": "^1.0.0", "oxfmt": "^0.49.0", "oxlint": "^1.64.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vite": "^8.0.13" } } diff --git a/src/adapters/react/index.ts b/src/adapters/react/index.ts index 2cf3818..a97a07a 100644 --- a/src/adapters/react/index.ts +++ b/src/adapters/react/index.ts @@ -1,3 +1,5 @@ export * from "./program-stream"; export * from "./projection-patch"; +export * from "./program-provider"; export * from "./react-adapter"; +export * from "./root"; diff --git a/src/adapters/react/program-provider.tsx b/src/adapters/react/program-provider.tsx new file mode 100644 index 0000000..d4c78fd --- /dev/null +++ b/src/adapters/react/program-provider.tsx @@ -0,0 +1,99 @@ +import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from "react"; +import { + useProgramStream, + type ProgramStreamReactOptions, + type ProgramStreamReactState, +} from "./react-adapter"; + +type ProgramStreamContextValue = { + stream: unknown; + projectionReady: Promise; +}; + +const ProgramStreamContext = createContext(null); + +export function ProgramStreamProvider< + TInput extends { type: string }, + TProjection, + TTrace extends { traceId: string }, +>(props: { options: ProgramStreamReactOptions; children: ReactNode }) { + const stream = useProgramStream(props.options); + const deferred = useRef(createDeferred()); + + useEffect(() => { + if (stream.projection.value !== null) { + deferred.current.resolve(); + } + }, [stream.projection.value]); + + const value = useMemo( + () => ({ + stream, + projectionReady: deferred.current.promise, + }), + [stream], + ); + + return {props.children}; +} + +export function useProgramStreamState< + TInput extends { type: string }, + TProjection, + TTrace extends { traceId: string }, +>(): ProgramStreamReactState { + const context = useProgramStreamContext(); + return context.stream as ProgramStreamReactState; +} + +export function useProgramProjection(options?: { + suspense?: boolean; +}): TProjection | null { + const context = useProgramStreamContext(); + const stream = context.stream as ProgramStreamReactState< + { type: string }, + TProjection, + { traceId: string } + >; + const projection = stream.projection.value; + + if (options?.suspense && projection === null) { + throw context.projectionReady; + } + + return projection; +} + +export function useProgramActions() { + return useProgramStreamState().actions; +} + +export function useProgramNavigation() { + return useProgramStreamState<{ type: string }, unknown, { traceId: string }>().navigate; +} + +export function useProgramErrors() { + return useProgramStreamState<{ type: string }, unknown, { traceId: string }>().errors; +} + +function useProgramStreamContext(): ProgramStreamContextValue { + const context = useContext(ProgramStreamContext); + + if (!context) { + throw new Error("Program stream hooks must be used inside ProgramStreamProvider"); + } + + return context; +} + +function createDeferred(): { + promise: Promise; + resolve: () => void; +} { + let resolve: () => void = () => undefined; + const promise = new Promise((done) => { + resolve = done; + }); + + return { promise, resolve }; +} diff --git a/src/adapters/react/projection-patch.ts b/src/adapters/react/projection-patch.ts index ce0a420..b023a31 100644 --- a/src/adapters/react/projection-patch.ts +++ b/src/adapters/react/projection-patch.ts @@ -44,10 +44,12 @@ export function applyRegionValuePatch( } export function createProjectionPatchApplier( - manifest: ProjectionPatchManifest, + manifest?: ProjectionPatchManifest, ): (projection: TProjection, envelope: ProjectionPatchEnvelope) => TProjection { return (projection, envelope) => - applyRegionValuePatchWithManifest(projection, envelope, manifest); + manifest + ? applyRegionValuePatchWithManifest(projection, envelope, manifest) + : applyRegionValuePatchAutomatically(projection, envelope); } export function applyRegionValuePatchWithManifest( @@ -77,14 +79,29 @@ export function applyRegionValuePatchWithManifest( throw new Error(`No projection patch strategy registered for region: ${region.id}`); } - next = applyStrategy(next, region.value, strategy); + next = applyStrategy(next, region.id, region.value, strategy); } return next; } +export function applyRegionValuePatchAutomatically( + projection: TProjection, + envelope: ProjectionPatchEnvelope, +): TProjection { + if (envelope.patch.kind !== "region-values") { + return projection; + } + + return envelope.patch.regions.reduce( + (current, region) => applyAutomaticRegionPatch(current, region.id, region.value), + projection, + ); +} + function applyStrategy( projection: TProjection, + regionId: string, value: JsonValue, strategy: RegionValuePatchStrategy, ): TProjection { @@ -92,6 +109,14 @@ function applyStrategy( return strategy.apply(projection, value); } + if (strategy.kind === "replace-region") { + return setPath(projection, [regionId], value); + } + + if (strategy.kind === "merge-fields") { + return mergeFields(projection, value); + } + if (strategy.kind === "replace-at-path") { return setPath(projection, strategy.path, value); } @@ -102,6 +127,41 @@ function applyStrategy( }, projection); } +function applyAutomaticRegionPatch( + projection: TProjection, + regionId: string, + value: JsonValue, +): TProjection { + if ( + projection !== null && + typeof projection === "object" && + !Array.isArray(projection) && + regionId in projection + ) { + return setPath(projection, [regionId], value); + } + + return mergeFields(projection, value); +} + +function mergeFields(projection: TProjection, value: JsonValue): TProjection { + if ( + projection !== null && + typeof projection === "object" && + !Array.isArray(projection) && + value !== null && + typeof value === "object" && + !Array.isArray(value) + ) { + return { + ...(projection as Record), + ...(value as Record), + } as TProjection; + } + + return projection; +} + function setPath( projection: TProjection, path: ProjectionPath, diff --git a/src/adapters/react/react-adapter.ts b/src/adapters/react/react-adapter.ts index 5862877..6fffe02 100644 --- a/src/adapters/react/react-adapter.ts +++ b/src/adapters/react/react-adapter.ts @@ -12,7 +12,7 @@ import { type ConnectionState, type ProgramStreamClient, } from "./program-stream"; - +import { applyRegionValuePatchAutomatically } from "./projection-patch"; export type ProgramStreamReactOptions = { route: string; params: Record; @@ -157,18 +157,8 @@ export function useProgramStream = RootOptions & { + root: HTMLElement; + bootstrap?: TBootstrap; + render: (bootstrap: TBootstrap | undefined) => ReactNode; + errorFallback?: (error: unknown) => ReactNode; +}; + +export function mountProgramReact(options: ProgramReactRootOptions): Root { + const app = ( + + {options.render(options.bootstrap)} + + ); + const rootOptions: RootOptions = { + identifierPrefix: options.identifierPrefix, + onCaughtError: options.onCaughtError, + onRecoverableError: options.onRecoverableError, + onUncaughtError: options.onUncaughtError, + }; + + if (options.bootstrap !== undefined && options.root.hasChildNodes()) { + return hydrateRoot(options.root, app, rootOptions); + } + + const root = createRoot(options.root, rootOptions); + root.render(app); + return root; +} + +class ProgramRootErrorBoundary extends Component< + { + children: ReactNode; + fallback?: (error: unknown) => ReactNode; + }, + { + error: unknown; + } +> { + state = { + error: null, + }; + + static getDerivedStateFromError(error: unknown) { + return { error }; + } + + override componentDidCatch(_error: unknown, _info: ErrorInfo): void { + // Root callbacks receive the structured React 19 error details. + } + + override render() { + if (this.state.error) { + return this.props.fallback ? this.props.fallback(this.state.error) : null; + } + + return this.props.children; + } +} diff --git a/src/demo/approvals/client/app.tsx b/src/demo/approvals/client/app.tsx index 6e903d2..87cc180 100644 --- a/src/demo/approvals/client/app.tsx +++ b/src/demo/approvals/client/app.tsx @@ -1,7 +1,8 @@ -import { createRoot, hydrateRoot } from "react-dom/client"; +import { mountProgramReact } from "../../../adapters/react"; import type { ProgramStreamBootstrap, TraceSnapshot } from "../../../framework"; import type { ApprovalProjection } from "../types"; import { ApprovalApp } from "./approval-app"; +import "./styles.css"; declare global { interface Window { @@ -11,10 +12,18 @@ declare global { const root = document.getElementById("root") as HTMLElement; const bootstrap = window.__STUPID_FP_BOOTSTRAP__; -const app = ; +const renderErrorMessage = + process.env.NODE_ENV === "development" ? undefined : "An unexpected error occurred"; -if (bootstrap && root.hasChildNodes()) { - hydrateRoot(root, app); -} else { - createRoot(root).render(app); -} +mountProgramReact({ + root, + bootstrap, + render: (initial) => , + errorFallback: (error) => ( +
+
+ {renderErrorMessage ?? (error instanceof Error ? error.message : "React render failed")} +
+
+ ), +}); diff --git a/src/demo/approvals/client/approval-app.tsx b/src/demo/approvals/client/approval-app.tsx index 8b374cb..a29f389 100644 --- a/src/demo/approvals/client/approval-app.tsx +++ b/src/demo/approvals/client/approval-app.tsx @@ -1,11 +1,10 @@ import { useMemo, useState } from "react"; import { - createProjectionPatchApplier, - useProgramStream, + ProgramStreamProvider, + useProgramStreamState, type ProgramStreamReactOptions, } from "../../../adapters/react"; import type { ProgramStreamBootstrap, TraceSnapshot } from "../../../framework"; -import { approvalProjectionPatchManifest } from "../projection-manifest"; import type { ApprovalClientInput, ApprovalProjection } from "../types"; export function ApprovalApp(props: { @@ -20,13 +19,20 @@ export function ApprovalApp(props: { storageKey: "approval-stream", bootstrap: props.bootstrap, projectionTraces: (projection) => projection.traces, - applyPatch: createProjectionPatchApplier(approvalProjectionPatchManifest), router: { mode: "history" }, }; }, [props.bootstrap]); - const stream = useProgramStream( - streamOptions, + return ( + + options={streamOptions} + > + + ); +} + +function ApprovalWorkspace() { + const stream = useProgramStreamState(); const [deploymentFilter, setDeploymentFilter] = useState(""); const projection = stream.projection.value; diff --git a/src/demo/approvals/projection-manifest.ts b/src/demo/approvals/projection-manifest.ts deleted file mode 100644 index 008a559..0000000 --- a/src/demo/approvals/projection-manifest.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ProjectionPatchManifest } from "../../framework"; -import type { ApprovalProjection } from "./types"; - -export const approvalProjectionPatchManifest: ProjectionPatchManifest = { - projectionVersion: 1, - regions: { - layout: { - kind: "replace-fields", - fields: [ - { from: ["team"], to: ["team"] }, - { from: ["currentUser"], to: ["currentUser"] }, - { from: ["navigation"], to: ["navigation"] }, - { from: ["tracePanelOpen"], to: ["tracePanelOpen"] }, - { from: ["traces"], to: ["traces"] }, - ], - }, - pendingDeployments: { - kind: "replace-at-path", - path: ["pendingDeployments"], - }, - selectedDeployment: { - kind: "replace-at-path", - path: ["selectedDeployment"], - }, - activeRuns: { - kind: "replace-at-path", - path: ["activeRuns"], - }, - tracePanel: { - kind: "replace-fields", - fields: [ - { from: ["open"], to: ["tracePanelOpen"] }, - { from: ["traces"], to: ["traces"] }, - ], - }, - }, -}; diff --git a/src/demo/approvals/screen.tsx b/src/demo/approvals/screen.tsx index 42e7f9a..e46e026 100644 --- a/src/demo/approvals/screen.tsx +++ b/src/demo/approvals/screen.tsx @@ -1,12 +1,12 @@ import { Effect, Layout, + Region, Screen, Schema, type ProjectionContext, type ScreenDefinition, } from "../../framework"; -import { approvalProjectionPatchManifest } from "./projection-manifest"; import { ActiveDeploymentRunsResource, AuditTrailResource, @@ -37,7 +37,12 @@ export const approvalDeploymentsScreen: ScreenDefinition< .route("/teams/:teamId/deployments", { params: teamRouteParams, }) - .patchManifest(approvalProjectionPatchManifest) + .regions({ + layout: Region.merge(), + pendingDeployments: Region.replace(), + selectedDeployment: Region.replace(), + activeRuns: Region.replace(), + }) .project((view, context) => { return Effect.gen(function* () { const teamId = view.params.teamId; @@ -77,7 +82,12 @@ export const approvalRunsScreen: ScreenDefinition< .route("/teams/:teamId/runs", { params: teamRouteParams, }) - .patchManifest(approvalProjectionPatchManifest) + .regions({ + layout: Region.merge(), + pendingDeployments: Region.replace(), + selectedDeployment: Region.replace(), + activeRuns: Region.replace(), + }) .project((view, context) => { return Effect.gen(function* () { const teamId = view.params.teamId; diff --git a/src/framework/bun-host.ts b/src/framework/bun-host.ts index 7e5c7d5..c78cd80 100644 --- a/src/framework/bun-host.ts +++ b/src/framework/bun-host.ts @@ -1,6 +1,6 @@ import { watch, type FSWatcher } from "node:fs"; import { mkdir } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { parseClientEnvelope, type ClientEnvelope, @@ -21,6 +21,20 @@ export type BunStyleAsset = { build?: (asset: { input: string; output: string; route: string }) => void | Promise; }; +export type BunClientPipeline = + | { + kind?: "bun"; + } + | { + kind: "vite"; + root?: string; + outdir?: string; + dev?: { + port?: number; + }; + reactCompiler?: boolean; + }; + export type BunRuntime = { connect: ( envelope: Extract, { type: "connect" }>, @@ -35,6 +49,7 @@ export type BunProgramHostOptions = { rootDir: string; clientEntry: string; shellPath: string; + client?: BunClientPipeline; assets?: { styles?: BunStyleAsset[]; }; @@ -58,15 +73,14 @@ export async function serveBunProgram( options: BunProgramHostOptions, ): Promise> { const outdir = options.outdir ?? join(options.rootDir, "..", "dist"); - const clientOut = join(outdir, "app.js"); const devState: BunDevState = { lastBuildError: null }; const delivery = new SocketDelivery(); const reload = new DevReloadDelivery(); - await buildClient(options.clientEntry, outdir); + const client = await prepareClient(options, outdir, devState); const assets = await prepareAssets(options, outdir, devState); const watchers = options.dev?.watch - ? watchDevInputs(options, outdir, assets, reload, devState) + ? watchDevInputs(options, outdir, assets, client, reload, devState) : []; const server = Bun.serve({ @@ -74,6 +88,10 @@ export async function serveBunProgram( async fetch(request, server) { const url = new URL(request.url); + if (hasUnsafePathSegment(url.pathname)) { + return new Response("Bad request", { status: 400 }); + } + if (url.pathname === "/stream") { if (server.upgrade(request, { data: { kind: "stream" as const } })) { return; @@ -98,9 +116,7 @@ export async function serveBunProgram( } if (url.pathname === "/client.js") { - return new Response(Bun.file(clientOut), { - headers: { "Content-Type": "text/javascript; charset=utf-8" }, - }); + return client.serveClientJs(); } const asset = assets.byRoute.get(url.pathname); @@ -111,6 +127,12 @@ export async function serveBunProgram( }); } + const clientResponse = await client.serve(request); + + if (clientResponse) { + return clientResponse; + } + if (options.initialRender) { const route = options.initialRender.resolve(request); @@ -125,7 +147,10 @@ export async function serveBunProgram( const shell = await Bun.file(options.shellPath).text(); return new Response( - injectDevReload(injectInitialRender(shell, rendered, bootstrap), options), + await client.transformHtml( + request, + injectDevReload(injectInitialRender(shell, rendered, bootstrap), options), + ), { headers: { "Content-Type": "text/html; charset=utf-8" }, }, @@ -134,7 +159,7 @@ export async function serveBunProgram( } const shell = await Bun.file(options.shellPath).text(); - return new Response(injectDevReload(shell, options), { + return new Response(await client.transformHtml(request, injectDevReload(shell, options)), { headers: { "Content-Type": "text/html; charset=utf-8" }, }); }, @@ -180,6 +205,7 @@ export async function serveBunProgram( watcher.close(); } + void client.close(); stop(closeActiveConnections); }) as typeof server.stop; @@ -219,12 +245,13 @@ function watchDevInputs( options: BunProgramHostOptions, outdir: string, assets: PreparedAssets, + client: PreparedClient, reload: DevReloadDelivery, devState: BunDevState, ): FSWatcher[] { let pending: unknown = null; const watched = new Set([ - dirname(options.clientEntry), + ...(options.client?.kind === "vite" ? [] : [dirname(options.clientEntry)]), ...assets.styles.flatMap((asset) => asset.watchPaths), ]); const rebuild = () => { @@ -234,7 +261,7 @@ function watchDevInputs( pending = setTimeout(() => { pending = null; - void rebuildDevOutputs(options, outdir, assets, reload, devState); + void rebuildDevOutputs(options, outdir, assets, client, reload, devState); }, 40); }; @@ -245,11 +272,12 @@ async function rebuildDevOutputs( options: BunProgramHostOptions, outdir: string, assets: PreparedAssets, + client: PreparedClient, reload: DevReloadDelivery, devState: BunDevState, ): Promise { try { - await buildClient(options.clientEntry, outdir); + await client.rebuild(); await buildStyleAssets(assets.styles); devState.lastBuildError = null; reload.reload(); @@ -260,6 +288,39 @@ async function rebuildDevOutputs( } } +async function prepareClient( + options: BunProgramHostOptions, + outdir: string, + devState: BunDevState, +): Promise { + if (options.client?.kind === "vite") { + return prepareViteClient(options, outdir, devState, options.client); + } + + const clientOut = join(outdir, "app.js"); + await buildClient(options.clientEntry, outdir); + + return { + async transformHtml(_request, html) { + return html; + }, + async serve(_request) { + return null; + }, + serveClientJs() { + return new Response(Bun.file(clientOut), { + headers: { "Content-Type": "text/javascript; charset=utf-8" }, + }); + }, + async rebuild() { + await buildClient(options.clientEntry, outdir); + }, + async close() { + return undefined; + }, + }; +} + async function prepareAssets( options: BunProgramHostOptions, outdir: string, @@ -382,6 +443,203 @@ function serializeBootstrap( return JSON.stringify(bootstrap).replaceAll("<", "\\u003c"); } +type ViteManifestEntry = { + file: string; + css?: string[]; + imports?: string[]; +}; + +function viteEntryRoute(root: string, entrypoint: string): string { + const absolute = isAbsolute(entrypoint) ? entrypoint : join(root, entrypoint); + return `/${relative(root, absolute).replaceAll(sep, "/")}`; +} + +async function proxyViteAsset(origin: string, request: Request): Promise { + const url = new URL(request.url); + + if (!isViteAssetPath(url.pathname)) { + return null; + } + + const upstream = await fetch(`${origin}${url.pathname}${url.search}`); + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: upstream.headers, + }); +} + +function isViteAssetPath(pathname: string): boolean { + return ( + pathname.startsWith("/@vite/") || + pathname.startsWith("/@react-refresh") || + pathname.startsWith("/@id/") || + pathname.startsWith("/@fs/") || + pathname.startsWith("/node_modules/") || + pathname.startsWith("/src/") || + pathname.includes("/demo/") || + /\.[cm]?[tj]sx?$/.test(pathname) || + pathname.endsWith(".css") || + isStaticAssetPath(pathname) + ); +} + +function isStaticAssetPath(pathname: string): boolean { + return /\.(?:avif|gif|ico|jpe?g|json|png|svg|txt|webp|woff2?)$/i.test(pathname); +} + +function isViteTransformPath(pathname: string): boolean { + return /\.[cm]?[tj]sx?$/i.test(pathname); +} + +function hasUnsafePathSegment(pathname: string): boolean { + let decoded: string; + + try { + decoded = decodeURIComponent(pathname); + } catch { + return true; + } + + return decoded.includes("\0") || decoded.split("/").includes(".."); +} + +function replaceClientScript(html: string, entry: string): string { + return html.replace( + /`, + ); +} + +function prefixViteDevAssets(html: string, origin: string): string { + return html.replaceAll( + /(src|href)="\/(@vite|@react-refresh|@id|@fs|node_modules|src|demo|client)/g, + `$1="${origin}/$2`, + ); +} + +function removeClientScript(html: string): string { + return html.replace(/`; + const tags = `${styles}${script}`; + + return html.includes("") ? html.replace("", `${tags}`) : `${html}${tags}`; +} + +function contentType(pathname: string): string { + if (pathname.endsWith(".css")) { + return "text/css; charset=utf-8"; + } + + if (pathname.endsWith(".js")) { + return "text/javascript; charset=utf-8"; + } + + if (pathname.endsWith(".svg")) { + return "image/svg+xml"; + } + + if (pathname.endsWith(".png")) { + return "image/png"; + } + + if (pathname.endsWith(".ico")) { + return "image/x-icon"; + } + + if (pathname.endsWith(".json")) { + return "application/json; charset=utf-8"; + } + + if (pathname.endsWith(".txt")) { + return "text/plain; charset=utf-8"; + } + + return "application/octet-stream"; +} + +async function serveViteProductionAsset( + clientOutdir: string, + request: Request, +): Promise { + return serveViteFile(clientOutdir, request, { denyViteMetadata: true }); +} + +async function serveVitePublicAsset(publicDir: string, request: Request): Promise { + return serveViteFile(publicDir, request); +} + +async function serveViteFile( + root: string, + request: Request, + options?: { denyViteMetadata?: boolean }, +): Promise { + const url = new URL(request.url); + let pathname: string; + + try { + pathname = decodeURIComponent(url.pathname); + } catch { + return null; + } + + if (pathname.includes("\0") || (options?.denyViteMetadata && pathname.startsWith("/.vite/"))) { + return null; + } + + const assetPath = resolve(root, `.${pathname}`); + const routeToAsset = relative(root, assetPath); + + if (routeToAsset.startsWith("..") || isAbsolute(routeToAsset)) { + return null; + } + + const file = Bun.file(assetPath); + + if (!(await file.exists())) { + return null; + } + + return new Response(file, { + headers: { "Content-Type": contentType(pathname) }, + }); +} + async function buildClient(entrypoint: string, outdir: string): Promise { await mkdir(dirname(join(outdir, "app.js")), { recursive: true }); @@ -436,6 +694,14 @@ type PreparedAssets = { byRoute: Map; }; +type PreparedClient = { + transformHtml: (request: Request, html: string) => Promise; + serve: (request: Request) => Promise; + serveClientJs: () => Response | Promise; + rebuild: () => Promise; + close: () => Promise; +}; + type PreparedStyleAsset = { input: string; route: string; @@ -444,6 +710,123 @@ type PreparedStyleAsset = { build?: BunStyleAsset["build"]; }; +async function prepareViteClient( + options: BunProgramHostOptions, + outdir: string, + devState: BunDevState, + client: Extract, +): Promise { + const vite = await import("vite"); + const { default: react, reactCompilerPreset } = await import("@vitejs/plugin-react"); + const { default: babel } = await import("@rolldown/plugin-babel"); + const root = client.root ?? options.rootDir; + const entry = viteEntryRoute(root, options.clientEntry); + const clientOutdir = client.outdir ?? join(outdir, "client"); + const plugins = client.reactCompiler + ? [react(), babel({ presets: [reactCompilerPreset()] })] + : [react()]; + + if (options.dev?.watch) { + const server = await vite.createServer({ + root, + appType: "custom", + clearScreen: false, + logLevel: "error", + plugins, + server: { + hmr: true, + port: client.dev?.port, + strictPort: false, + }, + }); + await server.listen(); + const urls = server.resolvedUrls; + const origin = urls?.local[0]?.replace(/\/$/, "") ?? `http://localhost:5173`; + const publicDir = server.config.publicDir; + + return { + async transformHtml(request, html) { + const withEntry = replaceClientScript(html, entry); + const transformed = await server.transformIndexHtml( + new URL(request.url).pathname, + withEntry, + ); + return prefixViteDevAssets(transformed, origin); + }, + async serve(request) { + const url = new URL(request.url); + const publicAsset = await serveVitePublicAsset(publicDir, request); + + if (publicAsset) { + return publicAsset; + } + + if (isViteTransformPath(url.pathname)) { + const transformed = await server.transformRequest(`${url.pathname}${url.search}`); + + if (transformed) { + return new Response(transformed.code, { + headers: { "Content-Type": "text/javascript; charset=utf-8" }, + }); + } + } + + return proxyViteAsset(origin, request); + }, + serveClientJs() { + return Response.redirect(`${origin}${entry}`, 302); + }, + async rebuild() { + devState.lastBuildError = null; + }, + async close() { + await server.close(); + }, + }; + } + + await vite.build({ + root, + appType: "custom", + plugins, + build: { + outDir: clientOutdir, + emptyOutDir: true, + manifest: true, + rollupOptions: { + input: options.clientEntry, + }, + }, + }); + const manifest = (await Bun.file(join(clientOutdir, ".vite", "manifest.json")).json()) as Record< + string, + ViteManifestEntry + >; + const manifestEntry = manifest[relative(root, options.clientEntry).replaceAll(sep, "/")]; + + if (!manifestEntry) { + throw new Error("Vite client build did not produce a manifest entry"); + } + + return { + async transformHtml(_request, html) { + return injectViteProductionAssets(removeClientScript(html), manifest, manifestEntry); + }, + async serve(request) { + return serveViteProductionAsset(clientOutdir, request); + }, + serveClientJs() { + return Response.redirect(`/${manifestEntry.file}`, 302); + }, + async rebuild() { + return undefined; + }, + async close() { + return undefined; + }, + }; +} + class SocketDelivery { readonly #viewsockets = new Map>>(); readonly #socketview = new WeakMap, string>(); diff --git a/src/framework/program.ts b/src/framework/program.ts index 4b99e5c..8ef15ee 100644 --- a/src/framework/program.ts +++ b/src/framework/program.ts @@ -92,81 +92,100 @@ class ProgramBuilder< TProjection = never, > { readonly #id: string; - #layer?: Layer.Layer; - #resources: ResourceDefinition[] = []; - #uiState?: UIStateDefinition; - #screens: ScreenDefinition[] = []; - #actions: ActionDefinition[] = []; - #plugins: FrameworkPlugin[] = []; - - constructor(id: string) { + readonly #definition: ProgramBuilderStorage; + + constructor( + id: string, + definition: ProgramBuilderStorage = { + resources: [], + screens: [], + actions: [], + plugins: [], + }, + ) { this.#id = id; + this.#definition = definition; } layer( layer: Layer.Layer, ): ProgramBuilder { - this.#layer = layer as unknown as Layer.Layer; - return this as unknown as ProgramBuilder; + return new ProgramBuilder(this.#id, { + ...this.#definition, + layer, + }); } - plugins( - ...plugins: FrameworkPlugin[] - ): ProgramBuilder { - this.#plugins = plugins as unknown as FrameworkPlugin[]; - return this as unknown as ProgramBuilder; + plugins( + ...plugins: FrameworkPlugin[] + ): ProgramBuilder { + return new ProgramBuilder(this.#id, { + ...this.#definition, + plugins, + }); } - resources( - ...resources: ResourceDefinition[] - ): ProgramBuilder { - this.#resources = resources as unknown as ResourceDefinition[]; - return this as unknown as ProgramBuilder; + resources( + ...resources: ResourceDefinition[] + ): ProgramBuilder { + return new ProgramBuilder(this.#id, { + ...this.#definition, + resources, + }); } ui( uiState: UIStateDefinition, ): ProgramBuilder { - this.#uiState = uiState as unknown as UIStateDefinition; - return this as unknown as ProgramBuilder< - R, - TNextUIState, - TNextUIEvent, - TActionInput, - TProjection - >; + return new ProgramBuilder(this.#id, { + ...this.#definition, + uiState, + }); } screens( ...screens: ScreenDefinition[] ): ProgramBuilder { - this.#screens = screens as unknown as ScreenDefinition[]; - return this as unknown as ProgramBuilder; + return new ProgramBuilder(this.#id, { + ...this.#definition, + screens, + }); } actions( ...actions: ActionDefinition[] ): ProgramBuilder { - this.#actions = actions as unknown as ActionDefinition[]; - return this as unknown as ProgramBuilder; + return new ProgramBuilder(this.#id, { + ...this.#definition, + actions, + }); } build(): Program { - if (!this.#uiState) { + if (!this.#definition.uiState) { throw new Error(`Program ${this.#id} must define UI state before build()`); } return defineProgram({ - layer: this.#layer, - plugins: this.#plugins, - resources: this.#resources, - uiState: this.#uiState, - screens: this.#screens, - actions: this.#actions, + layer: this.#definition.layer as Layer.Layer | undefined, + plugins: this.#definition.plugins as FrameworkPlugin[], + resources: this.#definition.resources as ResourceDefinition[], + uiState: this.#definition.uiState as UIStateDefinition, + screens: this.#definition.screens as ScreenDefinition[], + actions: this.#definition.actions as ActionDefinition[], }); } } +type ProgramBuilderStorage = { + layer?: unknown; + resources: unknown[]; + uiState?: unknown; + screens: unknown[]; + actions: unknown[]; + plugins: unknown[]; +}; + export function screenRoutePattern( screen: ScreenDefinition, ): string { diff --git a/src/framework/projection.ts b/src/framework/projection.ts index f4644e3..df71273 100644 --- a/src/framework/projection.ts +++ b/src/framework/projection.ts @@ -23,6 +23,12 @@ export type ProjectionRegionSnapshot = { export type ProjectionPath = readonly (string | number)[]; export type ProjectionRegionPatchStrategy = + | { + kind: "replace-region"; + } + | { + kind: "merge-fields"; + } | { kind: "replace-at-path"; path: ProjectionPath; @@ -44,6 +50,8 @@ export type ProjectionPatchManifest = { regions: Record>; }; +export type ProjectionRegionDefinition = ProjectionRegionPatchStrategy; + export type ProjectionFailure = { type: "projection-error"; message: string; @@ -78,6 +86,23 @@ export const Layout = { }, }; +export const Region = { + replace(): ProjectionRegionDefinition { + return { kind: "replace-region" }; + }, + merge(): ProjectionRegionDefinition { + return { kind: "merge-fields" }; + }, + custom( + apply: (projection: TProjection, value: JsonValue) => TProjection, + ): ProjectionRegionDefinition { + return { kind: "custom", apply }; + }, + replaceAt(path: ProjectionPath): ProjectionRegionDefinition { + return { kind: "replace-at-path", path }; + }, +}; + export const Screen = { define(id: string) { return new ScreenBuilder(id); @@ -112,6 +137,17 @@ class ScreenBuilder { return this; } + regions( + regions: Record>, + options?: { projectionVersion?: number }, + ): ScreenBuilder { + this.#patchManifest = { + projectionVersion: options?.projectionVersion ?? 1, + regions, + } as ProjectionPatchManifest; + return this as unknown as ScreenBuilder; + } + patchManifest( manifest: ProjectionPatchManifest, ): ScreenBuilder { diff --git a/src/server.ts b/src/server.ts index eb264bd..b110d82 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,14 +17,10 @@ const server = await serveBunProgram Deployment approvals prototype -
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..cbe652d --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/tests/bun-host.test.ts b/tests/bun-host.test.ts index e0ac3db..c9f2c52 100644 --- a/tests/bun-host.test.ts +++ b/tests/bun-host.test.ts @@ -198,6 +198,125 @@ describe("Bun host stream delivery", () => { server.stop(true); } }); + + test("Vite client pipeline serves dev modules while Bun owns the stream runtime", async () => { + const root = join(import.meta.dir, "..", ".tmp", `stupid-fp-host-${crypto.randomUUID()}`); + await mkdir(root, { recursive: true }); + await mkdir(join(root, "public"), { recursive: true }); + await mkdir(join(root, "public", ".well-known"), { recursive: true }); + const clientEntry = join(root, "client.ts"); + const shellPath = join(root, "shell.html"); + const styleInput = join(root, "host.css"); + await writeFile(clientEntry, "console.log('vite host test');\n"); + await writeFile(join(root, "public", "favicon.svg"), "dev\n"); + await writeFile(join(root, "public", ".well-known", "security"), "contact=dev\n"); + await writeFile(styleInput, ".host { color: red; }\n"); + await writeFile( + shellPath, + '
', + ); + + const server = await serveBunProgram({ + runtime: createFanoutRuntime(), + rootDir: root, + clientEntry, + shellPath, + outdir: join(root, "dist"), + port: 0, + dev: { watch: true }, + client: { + kind: "vite", + root, + reactCompiler: false, + }, + assets: { + styles: [ + { + input: styleInput, + route: "/assets/host.css", + build: async ({ input, output }) => { + const source = await readFile(input, "utf8"); + await writeFile(output, `/* host */\n${source}`); + }, + }, + ], + }, + }); + + try { + const htmlResponse = await fetch(`http://localhost:${server.port}/`); + const html = await htmlResponse.text(); + const clientResponse = await fetch(`http://localhost:${server.port}/client.ts`); + const faviconResponse = await fetch(`http://localhost:${server.port}/favicon.svg`); + const wellKnownResponse = await fetch(`http://localhost:${server.port}/.well-known/security`); + const hostStyleResponse = await fetch(`http://localhost:${server.port}/assets/host.css`); + const traversalResponse = await fetch(`http://localhost:${server.port}/%2e%2e%2ffavicon.svg`); + + expect(html).toContain("/@vite/client"); + expect(html).toContain("/client.ts"); + expect(await clientResponse.text()).toContain("vite host test"); + expect(faviconResponse.headers.get("content-type")).toContain("image/svg+xml"); + expect(await faviconResponse.text()).toContain("dev"); + expect(await wellKnownResponse.text()).toContain("contact=dev"); + expect(await hostStyleResponse.text()).toContain("/* host */"); + expect(traversalResponse.status).not.toBe(200); + } finally { + server.stop(true); + } + }); + + test("Vite client pipeline serves production manifest and public assets", async () => { + const root = join(import.meta.dir, "..", ".tmp", `stupid-fp-host-${crypto.randomUUID()}`); + await mkdir(join(root, "public"), { recursive: true }); + await mkdir(join(root, "public", ".well-known"), { recursive: true }); + const clientEntry = join(root, "client.ts"); + const shellPath = join(root, "shell.html"); + await writeFile(clientEntry, "console.log('vite production host test');\n"); + await writeFile(join(root, "public", "favicon.svg"), "production\n"); + await writeFile(join(root, "public", ".well-known", "security"), "contact=production\n"); + await writeFile( + shellPath, + '
', + ); + + const server = await serveBunProgram({ + runtime: createFanoutRuntime(), + rootDir: root, + clientEntry, + shellPath, + outdir: join(root, "dist"), + port: 0, + client: { + kind: "vite", + root, + reactCompiler: false, + }, + }); + + try { + const htmlResponse = await fetch(`http://localhost:${server.port}/`); + const html = await htmlResponse.text(); + const scriptPath = html.match(/