From 0fa55912c52aaf5891052f5b43b7bcc76980f671 Mon Sep 17 00:00:00 2001 From: remorses Date: Sun, 9 Feb 2025 10:05:25 +0100 Subject: [PATCH 001/226] adding react things --- package.json | 2 +- pnpm-lock.yaml | 357 +++++++++++++++++- spiceflow/package.json | 2 + spiceflow/src/react/entry.client.tsx | 120 ++++++ spiceflow/src/react/entry.rsc.tsx | 101 +++++ spiceflow/src/react/entry.ssr.tsx | 72 ++++ spiceflow/src/react/types/ambient.d.ts | 61 +++ spiceflow/src/react/types/index.ts | 19 + spiceflow/src/react/utils/client-reference.ts | 25 ++ spiceflow/src/react/utils/fetch.ts | 77 ++++ spiceflow/src/react/utils/stream-script.ts | 45 +++ spiceflow/src/vite.tsx | 316 ++++++++++++++++ website/package.json | 2 +- 13 files changed, 1188 insertions(+), 11 deletions(-) create mode 100644 spiceflow/src/react/entry.client.tsx create mode 100644 spiceflow/src/react/entry.rsc.tsx create mode 100644 spiceflow/src/react/entry.ssr.tsx create mode 100644 spiceflow/src/react/types/ambient.d.ts create mode 100644 spiceflow/src/react/types/index.ts create mode 100644 spiceflow/src/react/utils/client-reference.ts create mode 100644 spiceflow/src/react/utils/fetch.ts create mode 100644 spiceflow/src/react/utils/stream-script.ts create mode 100644 spiceflow/src/vite.tsx diff --git a/package.json b/package.json index ec78b95..b1bc158 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "spiceflow": "workspace:*", "tsx": "^4.19.2", "typescript": "^5.7.3", - "vite": "^6.0.11", + "vite": "^6.1.0", "vitest": "^3.0.4" }, "repository": "https://github.com/remorses/", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a9753b..9a89a7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,8 +31,8 @@ importers: specifier: ^5.7.3 version: 5.7.3 vite: - specifier: ^6.0.11 - version: 6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + specifier: ^6.1.0 + version: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) vitest: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) @@ -146,6 +146,9 @@ importers: '@sinclair/typebox': specifier: ^0.34.14 version: 0.34.15 + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.3.4(vite@6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) ajv: specifier: ^8.17.1 version: 8.17.1 @@ -161,6 +164,9 @@ importers: openapi-types: specifier: ^12.1.3 version: 12.1.3 + react-server-dom-vite: + specifier: npm:@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14 + version: '@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)' superjson: specifier: ^2.2.2 version: 2.2.2 @@ -275,8 +281,8 @@ importers: specifier: ^5.7.3 version: 5.7.3 vite: - specifier: ^6.0.11 - version: 6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + specifier: ^6.1.0 + version: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^4.2.1 version: 4.3.2(typescript@5.7.3)(vite@6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) @@ -459,6 +465,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.25.9': + resolution: {integrity: sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.25.9': + resolution: {integrity: sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.26.7': resolution: {integrity: sha512-5cJurntg+AT+cgelGP9Bt788DKiAw9gIMSMU2NJrLAilnj0m8WZWUNZPSLOmadYsujHutpgElO+50foX+ib/Wg==} engines: {node: '>=6.9.0'} @@ -1389,6 +1407,13 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} + '@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14': + resolution: {integrity: sha512-4bBG0uLS/XuddOi1KjJb6j/A49rdEe62yzcFjd7jghQmPXT8jNinyIislylz9xupJB3TvHB8JGv5/PB+LPASHg==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.0.0 + react-dom: ^19.0.0 + '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -1593,96 +1618,191 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.34.6': + resolution: {integrity: sha512-+GcCXtOQoWuC7hhX1P00LqjjIiS/iOouHXhMdiDSnq/1DGTox4SpUvO52Xm+div6+106r+TcvOeo/cxvyEyTgg==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.34.0': resolution: {integrity: sha512-yVh0Kf1f0Fq4tWNf6mWcbQBCLDpDrDEl88lzPgKhrgTcDrTtlmun92ywEF9dCjmYO3EFiSuJeeo9cYRxl2FswA==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.34.6': + resolution: {integrity: sha512-E8+2qCIjciYUnCa1AiVF1BkRgqIGW9KzJeesQqVfyRITGQN+dFuoivO0hnro1DjT74wXLRZ7QF8MIbz+luGaJA==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.34.0': resolution: {integrity: sha512-gCs0ErAZ9s0Osejpc3qahTsqIPUDjSKIyxK/0BGKvL+Tn0n3Kwvj8BrCv7Y5sR1Ypz1K2qz9Ny0VvkVyoXBVUQ==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.34.6': + resolution: {integrity: sha512-z9Ib+OzqN3DZEjX7PDQMHEhtF+t6Mi2z/ueChQPLS/qUMKY7Ybn5A2ggFoKRNRh1q1T03YTQfBTQCJZiepESAg==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.34.0': resolution: {integrity: sha512-aIB5Anc8hngk15t3GUkiO4pv42ykXHfmpXGS+CzM9CTyiWyT8HIS5ygRAy7KcFb/wiw4Br+vh1byqcHRTfq2tQ==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.34.6': + resolution: {integrity: sha512-PShKVY4u0FDAR7jskyFIYVyHEPCPnIQY8s5OcXkdU8mz3Y7eXDJPdyM/ZWjkYdR2m0izD9HHWA8sGcXn+Qrsyg==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-freebsd-arm64@4.34.0': resolution: {integrity: sha512-kpdsUdMlVJMRMaOf/tIvxk8TQdzHhY47imwmASOuMajg/GXpw8GKNd8LNwIHE5Yd1onehNpcUB9jHY6wgw9nHQ==} cpu: [arm64] os: [freebsd] + '@rollup/rollup-freebsd-arm64@4.34.6': + resolution: {integrity: sha512-YSwyOqlDAdKqs0iKuqvRHLN4SrD2TiswfoLfvYXseKbL47ht1grQpq46MSiQAx6rQEN8o8URtpXARCpqabqxGQ==} + cpu: [arm64] + os: [freebsd] + '@rollup/rollup-freebsd-x64@4.34.0': resolution: {integrity: sha512-D0RDyHygOBCQiqookcPevrvgEarN0CttBecG4chOeIYCNtlKHmf5oi5kAVpXV7qs0Xh/WO2RnxeicZPtT50V0g==} cpu: [x64] os: [freebsd] + '@rollup/rollup-freebsd-x64@4.34.6': + resolution: {integrity: sha512-HEP4CgPAY1RxXwwL5sPFv6BBM3tVeLnshF03HMhJYCNc6kvSqBgTMmsEjb72RkZBAWIqiPUyF1JpEBv5XT9wKQ==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.34.0': resolution: {integrity: sha512-mCIw8j5LPDXmCOW8mfMZwT6F/Kza03EnSr4wGYEswrEfjTfVsFOxvgYfuRMxTuUF/XmRb9WSMD5GhCWDe2iNrg==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.34.6': + resolution: {integrity: sha512-88fSzjC5xeH9S2Vg3rPgXJULkHcLYMkh8faix8DX4h4TIAL65ekwuQMA/g2CXq8W+NJC43V6fUpYZNjaX3+IIg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.34.0': resolution: {integrity: sha512-AwwldAu4aCJPob7zmjuDUMvvuatgs8B/QiVB0KwkUarAcPB3W+ToOT+18TQwY4z09Al7G0BvCcmLRop5zBLTag==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.34.6': + resolution: {integrity: sha512-wM4ztnutBqYFyvNeR7Av+reWI/enK9tDOTKNF+6Kk2Q96k9bwhDDOlnCUNRPvromlVXo04riSliMBs/Z7RteEg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.34.0': resolution: {integrity: sha512-e7kDUGVP+xw05pV65ZKb0zulRploU3gTu6qH1qL58PrULDGxULIS0OSDQJLH7WiFnpd3ZKUU4VM3u/Z7Zw+e7Q==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.34.6': + resolution: {integrity: sha512-9RyprECbRa9zEjXLtvvshhw4CMrRa3K+0wcp3KME0zmBe1ILmvcVHnypZ/aIDXpRyfhSYSuN4EPdCCj5Du8FIA==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.34.0': resolution: {integrity: sha512-SXYJw3zpwHgaBqTXeAZ31qfW/v50wq4HhNVvKFhRr5MnptRX2Af4KebLWR1wpxGJtLgfS2hEPuALRIY3LPAAcA==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.34.6': + resolution: {integrity: sha512-qTmklhCTyaJSB05S+iSovfo++EwnIEZxHkzv5dep4qoszUMX5Ca4WM4zAVUMbfdviLgCSQOu5oU8YoGk1s6M9Q==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-loongarch64-gnu@4.34.0': resolution: {integrity: sha512-e5XiCinINCI4RdyU3sFyBH4zzz7LiQRvHqDtRe9Dt8o/8hTBaYpdPimayF00eY2qy5j4PaaWK0azRgUench6WQ==} cpu: [loong64] os: [linux] + '@rollup/rollup-linux-loongarch64-gnu@4.34.6': + resolution: {integrity: sha512-4Qmkaps9yqmpjY5pvpkfOerYgKNUGzQpFxV6rnS7c/JfYbDSU0y6WpbbredB5cCpLFGJEqYX40WUmxMkwhWCjw==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.34.0': resolution: {integrity: sha512-3SWN3e0bAsm9ToprLFBSro8nJe6YN+5xmB11N4FfNf92wvLye/+Rh5JGQtKOpwLKt6e61R1RBc9g+luLJsc23A==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.34.6': + resolution: {integrity: sha512-Zsrtux3PuaxuBTX/zHdLaFmcofWGzaWW1scwLU3ZbW/X+hSsFbz9wDIp6XvnT7pzYRl9MezWqEqKy7ssmDEnuQ==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.34.0': resolution: {integrity: sha512-B1Oqt3GLh7qmhvfnc2WQla4NuHlcxAD5LyueUi5WtMc76ZWY+6qDtQYqnxARx9r+7mDGfamD+8kTJO0pKUJeJA==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.34.6': + resolution: {integrity: sha512-aK+Zp+CRM55iPrlyKiU3/zyhgzWBxLVrw2mwiQSYJRobCURb781+XstzvA8Gkjg/hbdQFuDw44aUOxVQFycrAg==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.34.0': resolution: {integrity: sha512-UfUCo0h/uj48Jq2lnhX0AOhZPSTAq3Eostas+XZ+GGk22pI+Op1Y6cxQ1JkUuKYu2iU+mXj1QjPrZm9nNWV9rg==} cpu: [s390x] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.34.6': + resolution: {integrity: sha512-WoKLVrY9ogmaYPXwTH326+ErlCIgMmsoRSx6bO+l68YgJnlOXhygDYSZe/qbUJCSiCiZAQ+tKm88NcWuUXqOzw==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.34.0': resolution: {integrity: sha512-chZLTUIPbgcpm+Z7ALmomXW8Zh+wE2icrG+K6nt/HenPLmtwCajhQC5flNSk1Xy5EDMt/QAOz2MhzfOfJOLSiA==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.34.6': + resolution: {integrity: sha512-Sht4aFvmA4ToHd2vFzwMFaQCiYm2lDFho5rPcvPBT5pCdC+GwHG6CMch4GQfmWTQ1SwRKS0dhDYb54khSrjDWw==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.34.0': resolution: {integrity: sha512-jo0UolK70O28BifvEsFD/8r25shFezl0aUk2t0VJzREWHkq19e+pcLu4kX5HiVXNz5qqkD+aAq04Ct8rkxgbyQ==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.34.6': + resolution: {integrity: sha512-zmmpOQh8vXc2QITsnCiODCDGXFC8LMi64+/oPpPx5qz3pqv0s6x46ps4xoycfUiVZps5PFn1gksZzo4RGTKT+A==} + cpu: [x64] + os: [linux] + '@rollup/rollup-win32-arm64-msvc@4.34.0': resolution: {integrity: sha512-Vmg0NhAap2S54JojJchiu5An54qa6t/oKT7LmDaWggpIcaiL8WcWHEN6OQrfTdL6mQ2GFyH7j2T5/3YPEDOOGA==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.34.6': + resolution: {integrity: sha512-3/q1qUsO/tLqGBaD4uXsB6coVGB3usxw3qyeVb59aArCgedSF66MPdgRStUd7vbZOsko/CgVaY5fo2vkvPLWiA==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.34.0': resolution: {integrity: sha512-CV2aqhDDOsABKHKhNcs1SZFryffQf8vK2XrxP6lxC99ELZAdvsDgPklIBfd65R8R+qvOm1SmLaZ/Fdq961+m7A==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.34.6': + resolution: {integrity: sha512-oLHxuyywc6efdKVTxvc0135zPrRdtYVjtVD5GUm55I3ODxhU/PwkQFD97z16Xzxa1Fz0AEe4W/2hzRtd+IfpOA==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.34.0': resolution: {integrity: sha512-g2ASy1QwHP88y5KWvblUolJz9rN+i4ZOsYzkEwcNfaNooxNUXG+ON6F5xFo0NIItpHqxcdAyls05VXpBnludGw==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.34.6': + resolution: {integrity: sha512-0PVwmgzZ8+TZ9oGBmdZoQVXflbvuwzN/HRclujpl4N/q3i+y0lqLw8n1bXA8ru3sApDjlmONaNAuYr38y1Kr9w==} + cpu: [x64] + os: [win32] + '@sinclair/typebox@0.34.15': resolution: {integrity: sha512-xeIzl3h1Znn9w/LTITqpiwag0gXjA+ldi2ZkXIBxGEppGCW211Tza+eL6D4pKqs10bj5z2umBWk5WL6spQ2OCQ==} @@ -1716,6 +1836,18 @@ packages: '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.6.8': + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.6': + resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -1858,6 +1990,12 @@ packages: '@vanilla-extract/private@1.0.6': resolution: {integrity: sha512-ytsG/JLweEjw7DBuZ/0JCN4WAQgM9erfSTdS1NQY778hFQSZ6cfCDEZZ0sgVm4k54uNz6ImKB33AYvSR//fjxw==} + '@vitejs/plugin-react@4.3.4': + resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + '@vitest/expect@3.0.4': resolution: {integrity: sha512-Nm5kJmYw6P2BxhJPkO3eKKhGYKRsnqJqf+r0yOGRKpEP+bSCBDsjXgiu1/5QFrnPMEgzfC38ZEjvCFgaNBC0Eg==} @@ -4578,6 +4716,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.34.6: + resolution: {integrity: sha512-wc2cBWqJgkU3Iz5oztRkQbfVkbxoz5EhnCGOrnJvnLnQ7O0WhQUYyv18qQI79O8L7DdHrrlJNeCHd4VGpnaXKQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -5319,6 +5462,46 @@ packages: yaml: optional: true + vite@6.1.0: + resolution: {integrity: sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@3.0.4: resolution: {integrity: sha512-6XG8oTKy2gnJIFTHP6LD7ExFeNLxiTkK3CfMvT7IfR8IN+BYICCf0lXUQmX7i7JoxUP8QmeP4mTnWXgflu4yjw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -5714,6 +5897,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.7)': + dependencies: + '@babel/core': 7.26.7 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.7)': + dependencies: + '@babel/core': 7.26.7 + '@babel/helper-plugin-utils': 7.26.5 + '@babel/plugin-transform-typescript@7.26.7(@babel/core@7.26.7)': dependencies: '@babel/core': 7.26.7 @@ -6386,6 +6579,11 @@ snapshots: '@istanbuljs/schema@0.1.3': {} + '@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -6747,60 +6945,117 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.34.0': optional: true + '@rollup/rollup-android-arm-eabi@4.34.6': + optional: true + '@rollup/rollup-android-arm64@4.34.0': optional: true + '@rollup/rollup-android-arm64@4.34.6': + optional: true + '@rollup/rollup-darwin-arm64@4.34.0': optional: true + '@rollup/rollup-darwin-arm64@4.34.6': + optional: true + '@rollup/rollup-darwin-x64@4.34.0': optional: true + '@rollup/rollup-darwin-x64@4.34.6': + optional: true + '@rollup/rollup-freebsd-arm64@4.34.0': optional: true + '@rollup/rollup-freebsd-arm64@4.34.6': + optional: true + '@rollup/rollup-freebsd-x64@4.34.0': optional: true + '@rollup/rollup-freebsd-x64@4.34.6': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.34.0': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.34.6': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.34.0': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.34.6': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.34.0': optional: true + '@rollup/rollup-linux-arm64-gnu@4.34.6': + optional: true + '@rollup/rollup-linux-arm64-musl@4.34.0': optional: true + '@rollup/rollup-linux-arm64-musl@4.34.6': + optional: true + '@rollup/rollup-linux-loongarch64-gnu@4.34.0': optional: true + '@rollup/rollup-linux-loongarch64-gnu@4.34.6': + optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.34.0': optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.34.6': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.34.0': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.34.6': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.34.0': optional: true + '@rollup/rollup-linux-s390x-gnu@4.34.6': + optional: true + '@rollup/rollup-linux-x64-gnu@4.34.0': optional: true + '@rollup/rollup-linux-x64-gnu@4.34.6': + optional: true + '@rollup/rollup-linux-x64-musl@4.34.0': optional: true + '@rollup/rollup-linux-x64-musl@4.34.6': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.34.0': optional: true + '@rollup/rollup-win32-arm64-msvc@4.34.6': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.34.0': optional: true + '@rollup/rollup-win32-ia32-msvc@4.34.6': + optional: true + '@rollup/rollup-win32-x64-msvc@4.34.0': optional: true + '@rollup/rollup-win32-x64-msvc@4.34.6': + optional: true + '@sinclair/typebox@0.34.15': {} '@stefanprobst/rehype-extract-toc@2.2.1': @@ -6843,6 +7098,27 @@ snapshots: dependencies: '@types/estree': 1.0.6 + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.26.7 + '@babel/types': 7.26.7 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.6 + + '@types/babel__generator@7.6.8': + dependencies: + '@babel/types': 7.26.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.26.7 + '@babel/types': 7.26.7 + + '@types/babel__traverse@7.20.6': + dependencies: + '@babel/types': 7.26.7 + '@types/cookie@0.6.0': {} '@types/debug@4.1.12': @@ -7048,6 +7324,17 @@ snapshots: '@vanilla-extract/private@1.0.6': {} + '@vitejs/plugin-react@4.3.4(vite@6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + dependencies: + '@babel/core': 7.26.7 + '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.7) + '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.7) + '@types/babel__core': 7.20.5 + react-refresh: 0.14.2 + vite: 6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.0.4': dependencies: '@vitest/spy': 3.0.4 @@ -7055,13 +7342,13 @@ snapshots: chai: 5.1.2 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.4(vite@6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + '@vitest/mocker@3.0.4(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': dependencies: '@vitest/spy': 3.0.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) '@vitest/pretty-format@3.0.4': dependencies: @@ -10616,6 +10903,31 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.34.0 fsevents: 2.3.3 + rollup@4.34.6: + dependencies: + '@types/estree': 1.0.6 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.34.6 + '@rollup/rollup-android-arm64': 4.34.6 + '@rollup/rollup-darwin-arm64': 4.34.6 + '@rollup/rollup-darwin-x64': 4.34.6 + '@rollup/rollup-freebsd-arm64': 4.34.6 + '@rollup/rollup-freebsd-x64': 4.34.6 + '@rollup/rollup-linux-arm-gnueabihf': 4.34.6 + '@rollup/rollup-linux-arm-musleabihf': 4.34.6 + '@rollup/rollup-linux-arm64-gnu': 4.34.6 + '@rollup/rollup-linux-arm64-musl': 4.34.6 + '@rollup/rollup-linux-loongarch64-gnu': 4.34.6 + '@rollup/rollup-linux-powerpc64le-gnu': 4.34.6 + '@rollup/rollup-linux-riscv64-gnu': 4.34.6 + '@rollup/rollup-linux-s390x-gnu': 4.34.6 + '@rollup/rollup-linux-x64-gnu': 4.34.6 + '@rollup/rollup-linux-x64-musl': 4.34.6 + '@rollup/rollup-win32-arm64-msvc': 4.34.6 + '@rollup/rollup-win32-ia32-msvc': 4.34.6 + '@rollup/rollup-win32-x64-msvc': 4.34.6 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -11427,7 +11739,7 @@ snapshots: debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 2.0.2 - vite: 6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -11475,11 +11787,38 @@ snapshots: terser: 5.31.6 tsx: 4.19.2 yaml: 2.7.0 + optional: true + + vite@6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + dependencies: + esbuild: 0.24.2 + postcss: 8.5.1 + rollup: 4.34.6 + optionalDependencies: + '@types/node': 22.12.0 + fsevents: 2.3.3 + jiti: 1.21.7 + terser: 5.31.6 + tsx: 4.19.2 + yaml: 2.7.0 + + vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + dependencies: + esbuild: 0.24.2 + postcss: 8.5.1 + rollup: 4.34.6 + optionalDependencies: + '@types/node': 22.13.0 + fsevents: 2.3.3 + jiti: 1.21.7 + terser: 5.31.6 + tsx: 4.19.2 + yaml: 2.7.0 vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: '@vitest/expect': 3.0.4 - '@vitest/mocker': 3.0.4(vite@6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + '@vitest/mocker': 3.0.4(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) '@vitest/pretty-format': 3.0.4 '@vitest/runner': 3.0.4 '@vitest/snapshot': 3.0.4 @@ -11495,7 +11834,7 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) vite-node: 3.0.4(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: diff --git a/spiceflow/package.json b/spiceflow/package.json index 296a4fb..967c15f 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -55,6 +55,8 @@ "dependencies": { "@medley/router": "^0.2.1", "@sinclair/typebox": "^0.34.14", + "@vitejs/plugin-react": "^4.3.4", + "react-server-dom-vite": "npm:@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "eventsource-parser": "^3.0.0", diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx new file mode 100644 index 0000000..b4c1229 --- /dev/null +++ b/spiceflow/src/react/entry.client.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import ReactDomClient from "react-dom/client"; +import ReactClient from "react-server-dom-vite/client"; +import type { ServerPayload } from "./entry.rsc"; +import type { CallServerFn } from "./types"; +import { clientReferenceManifest } from "./utils/client-reference"; +import { getFlightStreamBrowser } from "./utils/stream-script"; + +async function main() { + const callServer: CallServerFn = async (id, args) => { + const url = new URL(window.location.href); + url.searchParams.set("__rsc", id); + const payload = await ReactClient.createFromFetch( + fetch(url, { + method: "POST", + body: await ReactClient.encodeReply(args), + }), + clientReferenceManifest, + { callServer }, + ); + setPayload(payload); + return payload.returnValue; + }; + Object.assign(globalThis, { __callServer: callServer }); + + async function onNavigation() { + const url = new URL(window.location.href); + url.searchParams.set("__rsc", ""); + const payload = await ReactClient.createFromFetch( + fetch(url), + clientReferenceManifest, + { callServer }, + ); + setPayload(payload); + } + + const initialPayload = + await ReactClient.createFromReadableStream( + getFlightStreamBrowser(), + clientReferenceManifest, + { callServer }, + ); + + let setPayload: (v: ServerPayload) => void; + + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload); + const [_isPending, startTransition] = React.useTransition(); + + React.useEffect(() => { + setPayload = (v) => startTransition(() => setPayload_(v)); + }, [startTransition, setPayload_]); + + React.useEffect(() => { + return listenNavigation(onNavigation); + }, []); + + return payload.root; + } + + ReactDomClient.hydrateRoot(document, , { + formState: initialPayload.formState, + }); + + if (import.meta.hot) { + import.meta.hot.on("react-server:update", (e) => { + console.log("[react-server:update]", e.file); + window.history.replaceState({}, "", window.location.href); + }); + } +} + +function listenNavigation(onNavigation: () => void) { + window.addEventListener("popstate", onNavigation); + + const oldPushState = window.history.pushState; + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args); + onNavigation(); + return res; + }; + + const oldReplaceState = window.history.replaceState; + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args); + onNavigation(); + return res; + }; + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest("a"); + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === "_self") && + link.origin === location.origin && + !link.hasAttribute("download") && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault(); + history.pushState(null, "", link.href); + } + } + document.addEventListener("click", onClick); + + return () => { + document.removeEventListener("click", onClick); + window.removeEventListener("popstate", onNavigation); + window.history.pushState = oldPushState; + window.history.replaceState = oldReplaceState; + }; +} + +main(); diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx new file mode 100644 index 0000000..b5fea1e --- /dev/null +++ b/spiceflow/src/react/entry.rsc.tsx @@ -0,0 +1,101 @@ +import type { ReactFormState } from "react-dom/client"; +import ReactServer from "react-server-dom-vite/server"; +import { Router } from "./app/routes"; +import type { + ClientReferenceMetadataManifest, + ServerReferenceManifest, +} from "./types"; +import { fromPipeableToWebReadable } from "./utils/fetch"; + +export interface RscHandlerResult { + stream: ReadableStream; +} + +export interface ServerPayload { + root: React.ReactNode; + formState?: ReactFormState; + returnValue?: unknown; +} + +export async function handler( + url: URL, + request: Request, +): Promise { + // handle action + let returnValue: unknown | undefined; + let formState: ReactFormState | undefined; + if (request.method === "POST") { + const actionId = url.searchParams.get("__rsc"); + if (actionId) { + // client stream request + const contentType = request.headers.get("content-type"); + const body = contentType?.startsWith("multipart/form-data") + ? await request.formData() + : await request.text(); + const args = await ReactServer.decodeReply(body); + const reference = + serverReferenceManifest.resolveServerReference(actionId); + await reference.preload(); + const action = await reference.get(); + returnValue = await (action as any).apply(null, args); + } else { + // progressive enhancement + const formData = await request.formData(); + const decodedAction = await ReactServer.decodeAction( + formData, + serverReferenceManifest, + ); + formState = await ReactServer.decodeFormState( + await decodedAction(), + formData, + serverReferenceManifest, + ); + } + } + + // render flight stream + const stream = fromPipeableToWebReadable( + ReactServer.renderToPipeableStream( + { + root: , + returnValue, + formState, + }, + clientReferenceMetadataManifest, + {}, + ), + ); + + return { + stream, + }; +} + +const serverReferenceManifest: ServerReferenceManifest = { + resolveServerReference(reference: string) { + const [id, name] = reference.split("#"); + let resolved: unknown; + return { + async preload() { + let mod: Record; + if (import.meta.env.DEV) { + mod = await import(/* @vite-ignore */ id); + } else { + const references = await import("virtual:build-server-references"); + mod = await references.default[id](); + } + resolved = mod[name]; + }, + get() { + return resolved; + }, + }; + }, +}; + +const clientReferenceMetadataManifest: ClientReferenceMetadataManifest = { + resolveClientReferenceMetadata(metadata) { + // console.log("[debug:resolveClientReferenceMetadata]", { metadata }, Object.getOwnPropertyDescriptors(metadata)); + return metadata.$$id; + }, +}; diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx new file mode 100644 index 0000000..1fc3726 --- /dev/null +++ b/spiceflow/src/react/entry.ssr.tsx @@ -0,0 +1,72 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import ReactDomServer from "react-dom/server"; +import ReactClient from "react-server-dom-vite/client"; +import type { ModuleRunner } from "vite/module-runner"; +import type { ServerPayload } from "./entry.rsc"; +import { clientReferenceManifest } from "./utils/client-reference"; +import { + createRequest, + fromPipeableToWebReadable, + fromWebToNodeReadable, + sendResponse, +} from "./utils/fetch"; +import { injectFlightStream } from "./utils/stream-script"; + +export default async function handler( + req: IncomingMessage, + res: ServerResponse, +) { + const request = createRequest(req, res); + const url = new URL(request.url); + const rscEntry = await importRscEntry(); + const rscResult = await rscEntry.handler(url, request); + + if (url.searchParams.has("__rsc")) { + const response = new Response(rscResult.stream, { + headers: { + "content-type": "text/x-component;charset=utf-8", + }, + }); + sendResponse(response, res); + return; + } + + const [flightStream1, flightStream2] = rscResult.stream.tee(); + + const payload = await ReactClient.createFromNodeStream( + fromWebToNodeReadable(flightStream1), + clientReferenceManifest, + ); + + const ssrAssets = await import("virtual:ssr-assets"); + + const htmlStream = fromPipeableToWebReadable( + ReactDomServer.renderToPipeableStream(payload.root, { + bootstrapModules: ssrAssets.bootstrapModules, + // @ts-ignore no type? + formState: payload.formState, + }), + ); + + const response = new Response( + htmlStream + .pipeThrough(new TextDecoderStream()) + .pipeThrough(injectFlightStream(flightStream2)), + { + headers: { + "content-type": "text/html;charset=utf-8", + }, + }, + ); + sendResponse(response, res); +} + +declare let __rscRunner: ModuleRunner; + +async function importRscEntry(): Promise { + if (import.meta.env.DEV) { + return await __rscRunner.import("/src/entry.rsc.tsx"); + } else { + return await import("virtual:build-rsc-entry" as any); + } +} diff --git a/spiceflow/src/react/types/ambient.d.ts b/spiceflow/src/react/types/ambient.d.ts new file mode 100644 index 0000000..2414f28 --- /dev/null +++ b/spiceflow/src/react/types/ambient.d.ts @@ -0,0 +1,61 @@ +/// + +declare module "react-server-dom-vite/server" { + export function renderToPipeableStream( + data: T, + manifest: import(".").ClientReferenceMetadataManifest, + opitons?: unknown, + ): import("react-dom/server").PipeableStream; + + export function decodeReply(body: string | FormData): Promise; + + export function decodeAction( + body: FormData, + manifest: import(".").ServerReferenceManifest, + ): Promise<() => Promise>; + + export function decodeFormState( + returnValue: unknown, + body: FormData, + manifest: import(".").ServerReferenceManifest, + ): Promise; +} + +declare module "react-server-dom-vite/client" { + export function createFromNodeStream( + stream: import("node:stream").Readable, + manifest: import(".").ClientReferenceManifest, + ): Promise; + + export function createFromReadableStream( + stream: ReadableStream, + manifest: import(".").ClientReferenceManifest, + options: { + callServer: import(".").CallServerFn; + }, + ): Promise; + + export function createFromFetch( + fetchReturn: ReturnType, + manifest: unknown, + options: { + callServer: import(".").CallServerFn; + }, + ): Promise; + + export function encodeReply(v: unknown[]): Promise; +} + +declare module "virtual:ssr-assets" { + export const bootstrapModules: string[]; +} + +declare module "virtual:build-client-references" { + const value: Record Promise>>; + export default value; +} + +declare module "virtual:build-server-references" { + const value: Record Promise>>; + export default value; +} diff --git a/spiceflow/src/react/types/index.ts b/spiceflow/src/react/types/index.ts new file mode 100644 index 0000000..59edbc4 --- /dev/null +++ b/spiceflow/src/react/types/index.ts @@ -0,0 +1,19 @@ +export type ClientReferenceMetadataManifest = { + resolveClientReferenceMetadata(metadata: { $$id: string }): string; +}; + +export type ClientReferenceManifest = { + resolveClientReference(reference: string): { + preload(): Promise; + get(): unknown; + }; +}; + +export type ServerReferenceManifest = { + resolveServerReference(reference: string): { + preload(): Promise; + get(): unknown; + }; +}; + +export type CallServerFn = (id: string, args: unknown[]) => unknown; diff --git a/spiceflow/src/react/utils/client-reference.ts b/spiceflow/src/react/utils/client-reference.ts new file mode 100644 index 0000000..efe4553 --- /dev/null +++ b/spiceflow/src/react/utils/client-reference.ts @@ -0,0 +1,25 @@ +import type { ClientReferenceManifest } from "../types"; + +export const clientReferenceManifest: ClientReferenceManifest = { + resolveClientReference(reference: string) { + const [id, name] = reference.split("#"); + let resolved: unknown; + return { + async preload() { + let mod: Record; + if (import.meta.env.DEV) { + mod = await import(/* @vite-ignore */ id); + } else { + const references = await import( + "virtual:build-client-references" as string + ); + mod = await references.default[id](); + } + resolved = mod[name]; + }, + get() { + return resolved; + }, + }; + }, +}; diff --git a/spiceflow/src/react/utils/fetch.ts b/spiceflow/src/react/utils/fetch.ts new file mode 100644 index 0000000..2d45071 --- /dev/null +++ b/spiceflow/src/react/utils/fetch.ts @@ -0,0 +1,77 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { PassThrough, Readable } from "node:stream"; +import type { PipeableStream } from "react-dom/server"; + +export function createRequest( + req: IncomingMessage, + res: ServerResponse, +): Request { + const abortController = new AbortController(); + res.once("close", () => { + if (req.destroyed) { + abortController.abort(); + } + }); + + const headers = new Headers(); + for (const [k, v] of Object.entries(req.headers)) { + if (k.startsWith(":")) { + continue; + } + if (typeof v === "string") { + headers.set(k, v); + } else if (Array.isArray(v)) { + v.forEach((v) => headers.append(k, v)); + } + } + + return new Request( + new URL( + req.url || "/", + `${headers.get("x-forwarded-proto") ?? "http"}://${ + req.headers.host || "unknown.local" + }`, + ), + { + method: req.method, + body: + req.method === "GET" || req.method === "HEAD" + ? null + : (Readable.toWeb(req) as any), + headers, + signal: abortController.signal, + // @ts-ignore for undici + duplex: "half", + }, + ); +} + +export function sendResponse(response: Response, res: ServerResponse) { + const headers = Object.fromEntries(response.headers); + if (headers["set-cookie"]) { + delete headers["set-cookie"]; + res.setHeader("set-cookie", response.headers.getSetCookie()); + } + res.writeHead(response.status, response.statusText, headers); + + if (response.body) { + const abortController = new AbortController(); + res.once("close", () => abortController.abort()); + res.once("error", () => abortController.abort()); + Readable.fromWeb(response.body as any, { + signal: abortController.signal, + }).pipe(res); + } else { + res.end(); + } +} + +export function fromPipeableToWebReadable(stream: PipeableStream) { + return Readable.toWeb( + stream.pipe(new PassThrough()), + ) as ReadableStream; +} + +export function fromWebToNodeReadable(stream: ReadableStream) { + return Readable.fromWeb(stream as any); +} diff --git a/spiceflow/src/react/utils/stream-script.ts b/spiceflow/src/react/utils/stream-script.ts new file mode 100644 index 0000000..cedea64 --- /dev/null +++ b/spiceflow/src/react/utils/stream-script.ts @@ -0,0 +1,45 @@ +const INIT_SCRIPT = ` +self.__flightStream = new ReadableStream({ + start(controller) { + self.__f_push = (c) => controller.enqueue(c); + self.__f_close = () => controller.close(); + } +}).pipeThrough(new TextEncoderStream()); +`; + +export function injectFlightStream(stream: ReadableStream) { + return new TransformStream({ + async transform(chunk, controller) { + // TODO: chunk is not guaranteed to include entire end tag `` + if (chunk.includes("")) { + chunk = chunk.replace( + "", + () => ``, + ); + } + if (chunk.includes("")) { + const i = chunk.indexOf(""); + controller.enqueue(chunk.slice(0, i)); + await stream.pipeThrough(new TextDecoderStream()).pipeTo( + new WritableStream({ + write(chunk) { + controller.enqueue( + ``, + ); + }, + close() { + controller.enqueue(``); + }, + }), + ); + controller.enqueue(chunk.slice(i)); + } else { + controller.enqueue(chunk); + } + }, + }); +} + +export function getFlightStreamBrowser(): ReadableStream { + return (self as any).__flightStream; +} diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx new file mode 100644 index 0000000..e62b2ed --- /dev/null +++ b/spiceflow/src/vite.tsx @@ -0,0 +1,316 @@ +import assert from 'node:assert' +import path from 'node:path' +import react from '@vitejs/plugin-react' +import { + type Manifest, + type Plugin, + type RunnableDevEnvironment, + createRunnableDevEnvironment, + defineConfig, +} from 'vite' + +// state for build orchestration +let browserManifest: Manifest +let clientReferences: Record = {} // TODO: normalize id +let serverReferences: Record = {} +let buildScan = false + +export default defineConfig({ + appType: 'custom', + environments: { + client: { + optimizeDeps: { + include: ['react-dom/client', 'react-server-dom-vite/client'], + }, + build: { + manifest: true, + outDir: 'dist/client', + rollupOptions: { + input: { index: 'virtual:browser-entry' }, + }, + }, + }, + ssr: { + build: { + outDir: 'dist/ssr', + rollupOptions: { + input: { index: '/src/entry.ssr.tsx' }, + }, + }, + }, + rsc: { + optimizeDeps: { + include: [ + 'react', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-server-dom-vite/server', + ], + }, + resolve: { + conditions: ['react-server'], + noExternal: true, + }, + dev: { + createEnvironment(name, config) { + return createRunnableDevEnvironment(name, config, { hot: false }) + }, + }, + build: { + outDir: 'dist/rsc', + rollupOptions: { + input: { index: '/src/entry.rsc.tsx' }, + }, + }, + }, + }, + plugins: [ + { + name: 'ssr-middleware', + configureServer(server) { + const ssrRunner = (server.environments.ssr as RunnableDevEnvironment) + .runner + const rscRunner = (server.environments.rsc as RunnableDevEnvironment) + .runner + Object.assign(globalThis, { __rscRunner: rscRunner }) + return () => { + server.middlewares.use(async (req, res, next) => { + try { + const mod: any = await ssrRunner.import('/src/entry.ssr.tsx') + await mod.default(req, res) + } catch (e) { + next(e) + } + }) + } + }, + async configurePreviewServer(server) { + const mod = await import(path.resolve('dist/ssr/index.js')) + return () => { + server.middlewares.use(async (req, res, next) => { + try { + await mod.default(req, res) + } catch (e) { + next(e) + } + }) + } + }, + }, + { + name: 'virtual:build-rsc-entry', + resolveId(source) { + if (source === 'virtual:build-rsc-entry') { + // externalize rsc entry in ssr entry as relative path + return { id: '../rsc/index.js', external: true } + } + }, + }, + createVirtualPlugin('ssr-assets', function () { + assert(this.environment.name === 'ssr') + let bootstrapModules: string[] = [] + if (this.environment.mode === 'dev') { + bootstrapModules = ['/@id/__x00__virtual:browser-entry'] + } + if (this.environment.mode === 'build') { + bootstrapModules = [browserManifest['virtual:browser-entry'].file] + } + return `export const bootstrapModules = ${JSON.stringify(bootstrapModules)}` + }), + createVirtualPlugin('browser-entry', function () { + if (this.environment.mode === 'dev') { + return ` + import "/@vite/client"; + import RefreshRuntime from "/@react-refresh"; + RefreshRuntime.injectIntoGlobalHook(window); + window.$RefreshReg$ = () => {}; + window.$RefreshSig$ = () => (type) => type; + window.__vite_plugin_react_preamble_installed__ = true; + await import("/src/entry.client.tsx"); + ` + } else { + return `import "/src/entry.client.tsx";` + } + }), + { + name: 'misc', + hotUpdate(ctx) { + if (this.environment.name === 'rsc') { + const ids = ctx.modules.map((mod) => mod.id).filter((v) => v !== null) + if (ids.length > 0) { + // client reference id is also in react server module graph, + // but we skip RSC HMR for this case since Client HMR handles it. + if (!ids.some((id) => id in clientReferences)) { + ctx.server.environments.client.hot.send({ + type: 'custom', + event: 'react-server:update', + data: { + file: ctx.file, + }, + }) + } + } + } + }, + writeBundle(_options, bundle) { + if (this.environment.name === 'client') { + const output = bundle['.vite/manifest.json'] + assert(output.type === 'asset') + assert(typeof output.source === 'string') + browserManifest = JSON.parse(output.source) + } + }, + }, + vitePluginUseClient(), + vitePluginUseServer(), + vitePluginSilenceDirectiveBuildWarning(), + react(), + ], + builder: { + sharedPlugins: true, + async buildApp(builder) { + buildScan = true + await builder.build(builder.environments.rsc) + buildScan = false + await builder.build(builder.environments.rsc) + await builder.build(builder.environments.client) + await builder.build(builder.environments.ssr) + }, + }, +}) + +function vitePluginUseClient(): Plugin[] { + return [ + { + name: vitePluginUseClient.name, + transform(code, id) { + if (this.environment.name === 'rsc') { + if (/^(("use client")|('use client'))/.test(code)) { + // pass through client code to find server reference used only by client + if (buildScan) { + return + } + clientReferences[id] = id // TODO: normalize + const matches = [ + ...code.matchAll(/export function (\w+)\(/g), + ...code.matchAll(/export (default) (function|class) /g), + ] + const result = [ + `import $$ReactServer from "react-server-dom-vite/server"`, + ...[...matches].map( + ([, name]) => + `export ${name === 'default' ? 'default' : `const ${name} =`} $$ReactServer.registerClientReference({}, ${JSON.stringify(id)}, ${JSON.stringify(name)})`, + ), + ].join(';\n') + return { code: result, map: null } + } + } + }, + }, + createVirtualPlugin('build-client-references', () => { + const code = Object.keys(clientReferences) + .map( + (id) => `${JSON.stringify(id)}: () => import(${JSON.stringify(id)}),`, + ) + .join('\n') + return `export default {${code}}` + }), + ] +} + +function vitePluginUseServer(): Plugin[] { + return [ + { + name: vitePluginUseServer.name, + transform(code, id) { + if (/^(("use server")|('use server'))/.test(code)) { + serverReferences[id] = id + if (this.environment.name === 'rsc') { + const matches = code.matchAll(/export async function (\w+)\(/g) + const result = [ + code, + `import $$ReactServer from "react-server-dom-vite/server"`, + ...[...matches].map( + ([, name]) => + `${name} = $$ReactServer.registerServerReference(${name}, ${JSON.stringify(id)}, ${JSON.stringify(name)})`, + ), + ].join(';\n') + return { code: result, map: null } + } else { + const matches = code.matchAll(/export async function (\w+)\(/g) + const result = [ + `import $$ReactClient from "react-server-dom-vite/client"`, + ...[...matches].map( + ([, name]) => + `export const ${name} = $$ReactClient.createServerReference(${JSON.stringify(id + '#' + name)}, (...args) => __callServer(...args))`, + ), + ].join(';\n') + return { code: result, map: null } + } + } + }, + }, + createVirtualPlugin('build-server-references', () => { + const code = Object.keys(serverReferences) + .map( + (id) => `${JSON.stringify(id)}: () => import(${JSON.stringify(id)}),`, + ) + .join('\n') + return `export default {${code}}` + }), + ] +} + +function createVirtualPlugin(name: string, load: Plugin['load']) { + name = 'virtual:' + name + return { + name: `virtual-${name}`, + resolveId(source, _importer, _options) { + return source === name ? '\0' + name : undefined + }, + load(id, options) { + if (id === '\0' + name) { + return (load as Function).apply(this, [id, options]) + } + }, + } satisfies Plugin +} + +// silence warning due to "use ..." directives +// https://github.com/vitejs/vite-plugin-react/blob/814ed8043d321f4b4679a9f4a781d1ed14f185e4/packages/plugin-react/src/index.ts#L303 +function vitePluginSilenceDirectiveBuildWarning(): Plugin { + return { + name: vitePluginSilenceDirectiveBuildWarning.name, + enforce: 'post', + config(config, _env) { + return { + build: { + rollupOptions: { + onwarn(warning, defaultHandler) { + // https://github.com/vitejs/vite/issues/15012#issuecomment-1948550039 + if ( + warning.code === 'SOURCEMAP_ERROR' && + warning.message.includes('(1:0)') + ) { + return + } + // https://github.com/TanStack/query/pull/5161#issuecomment-1506683450 + if ( + warning.code === 'MODULE_LEVEL_DIRECTIVE' && + (warning.message.includes(`use client`) || + warning.message.includes(`use server`)) + ) { + return + } + if (config.build?.rollupOptions?.onwarn) { + config.build.rollupOptions.onwarn(warning, defaultHandler) + } else { + defaultHandler(warning) + } + }, + }, + }, + } + }, + } +} diff --git a/website/package.json b/website/package.json index 58d4d10..474d1e7 100644 --- a/website/package.json +++ b/website/package.json @@ -45,7 +45,7 @@ "rehype-mdx-import-media": "^1.2.0", "tailwindcss": "^3.4.3", "typescript": "^5.7.3", - "vite": "^6.0.11", + "vite": "^6.1.0", "vite-tsconfig-paths": "^4.2.1", "wrangler": "^3.48.0" }, From 420227ac2dd76e12a4dfc7598727d7b3232b4aaa Mon Sep 17 00:00:00 2001 From: remorses Date: Sun, 9 Feb 2025 10:05:49 +0100 Subject: [PATCH 002/226] adding js extension --- spiceflow/scripts/example-app.ts | 2 +- spiceflow/scripts/openapi-fern.ts | 2 +- spiceflow/scripts/play-sdk.ts | 4 ++-- spiceflow/src/react/entry.client.tsx | 8 ++++---- spiceflow/src/react/entry.rsc.tsx | 6 +++--- spiceflow/src/react/entry.ssr.tsx | 8 ++++---- spiceflow/src/react/utils/client-reference.ts | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/spiceflow/scripts/example-app.ts b/spiceflow/scripts/example-app.ts index 8ffe8ae..2d20416 100644 --- a/spiceflow/scripts/example-app.ts +++ b/spiceflow/scripts/example-app.ts @@ -1,4 +1,4 @@ -import { Spiceflow } from '../src' +import { Spiceflow } from "../src.js" import { z } from 'zod' import { openapi } from '../src/openapi.js' diff --git a/spiceflow/scripts/openapi-fern.ts b/spiceflow/scripts/openapi-fern.ts index f4423eb..e9871ba 100644 --- a/spiceflow/scripts/openapi-fern.ts +++ b/spiceflow/scripts/openapi-fern.ts @@ -2,7 +2,7 @@ import fs from 'fs' import path from 'path' import yaml from 'js-yaml' -import { createSpiceflowClient } from '../src/client' +import { createSpiceflowClient } from "../src/client.js" import { app } from './example-app.js' async function main() { diff --git a/spiceflow/scripts/play-sdk.ts b/spiceflow/scripts/play-sdk.ts index dd9cb65..797bdb0 100644 --- a/spiceflow/scripts/play-sdk.ts +++ b/spiceflow/scripts/play-sdk.ts @@ -1,5 +1,5 @@ -import { app } from './example-app' -import { ExampleSdkClient } from './sdk-typescript' +import { app } from "./example-app.js" +import { ExampleSdkClient } from "./sdk-typescript.js" async function main() { const port = 3340 diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index b4c1229..21d1330 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -1,10 +1,10 @@ import React from "react"; import ReactDomClient from "react-dom/client"; import ReactClient from "react-server-dom-vite/client"; -import type { ServerPayload } from "./entry.rsc"; -import type { CallServerFn } from "./types"; -import { clientReferenceManifest } from "./utils/client-reference"; -import { getFlightStreamBrowser } from "./utils/stream-script"; +import type { ServerPayload } from "./entry.rsc.js"; +import type { CallServerFn } from "./types.js"; +import { clientReferenceManifest } from "./utils/client-reference.js"; +import { getFlightStreamBrowser } from "./utils/stream-script.js"; async function main() { const callServer: CallServerFn = async (id, args) => { diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx index b5fea1e..1e22268 100644 --- a/spiceflow/src/react/entry.rsc.tsx +++ b/spiceflow/src/react/entry.rsc.tsx @@ -1,11 +1,11 @@ import type { ReactFormState } from "react-dom/client"; import ReactServer from "react-server-dom-vite/server"; -import { Router } from "./app/routes"; +import { Router } from "./app/routes.js"; import type { ClientReferenceMetadataManifest, ServerReferenceManifest, -} from "./types"; -import { fromPipeableToWebReadable } from "./utils/fetch"; +} from "./types.js"; +import { fromPipeableToWebReadable } from "./utils/fetch.js"; export interface RscHandlerResult { stream: ReadableStream; diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 1fc3726..25946cb 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -2,15 +2,15 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import ReactDomServer from "react-dom/server"; import ReactClient from "react-server-dom-vite/client"; import type { ModuleRunner } from "vite/module-runner"; -import type { ServerPayload } from "./entry.rsc"; -import { clientReferenceManifest } from "./utils/client-reference"; +import type { ServerPayload } from "./entry.rsc.js"; +import { clientReferenceManifest } from "./utils/client-reference.js"; import { createRequest, fromPipeableToWebReadable, fromWebToNodeReadable, sendResponse, -} from "./utils/fetch"; -import { injectFlightStream } from "./utils/stream-script"; +} from "./utils/fetch.js"; +import { injectFlightStream } from "./utils/stream-script.js"; export default async function handler( req: IncomingMessage, diff --git a/spiceflow/src/react/utils/client-reference.ts b/spiceflow/src/react/utils/client-reference.ts index efe4553..7795fd5 100644 --- a/spiceflow/src/react/utils/client-reference.ts +++ b/spiceflow/src/react/utils/client-reference.ts @@ -1,4 +1,4 @@ -import type { ClientReferenceManifest } from "../types"; +import type { ClientReferenceManifest } from "../types.js"; export const clientReferenceManifest: ClientReferenceManifest = { resolveClientReference(reference: string) { From f949eca1bad78bf9a3d4b458dc7f80eaa91cd43a Mon Sep 17 00:00:00 2001 From: remorses Date: Sun, 9 Feb 2025 10:08:16 +0100 Subject: [PATCH 003/226] fix tsc errors --- pnpm-lock.yaml | 87 +++++-------------- spiceflow/package.json | 7 +- spiceflow/src/react/entry.client.tsx | 2 +- spiceflow/src/react/entry.rsc.tsx | 5 +- spiceflow/src/react/entry.ssr.tsx | 2 +- spiceflow/src/react/utils/client-reference.ts | 2 +- spiceflow/tsconfig.json | 1 + 7 files changed, 33 insertions(+), 73 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a89a7c..f9abdb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,12 +164,21 @@ importers: openapi-types: specifier: ^12.1.3 version: 12.1.3 + react: + specifier: 19.0.0 + version: 19.0.0 + react-dom: + specifier: 19.0.0 + version: 19.0.0(react@19.0.0) react-server-dom-vite: specifier: npm:@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14 version: '@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)' superjson: specifier: ^2.2.2 version: 2.2.2 + vite: + specifier: ^6.1.0 + version: 6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) zod: specifier: ^3.24.1 version: 3.24.1 @@ -206,7 +215,7 @@ importers: version: 3.1.0(@types/react@19.0.8)(react@19.0.0) '@mdx-js/rollup': specifier: ^3.1.0 - version: 3.1.0(acorn@8.14.0)(rollup@4.34.0) + version: 3.1.0(acorn@8.14.0)(rollup@4.34.6) '@remix-run/cloudflare': specifier: ^2.15.3 version: 2.15.3(@cloudflare/workers-types@4.20250129.0)(typescript@5.7.3) @@ -255,7 +264,7 @@ importers: version: 4.20250129.0 '@remix-run/dev': specifier: ^2.15.3 - version: 2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.0)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3)(vite@6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0)) + version: 2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.0)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0)) '@types/react': specifier: ^19.0.8 version: 19.0.8 @@ -285,7 +294,7 @@ importers: version: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^4.2.1 - version: 4.3.2(typescript@5.7.3)(vite@6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + version: 4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) wrangler: specifier: ^3.48.0 version: 3.107.2(@cloudflare/workers-types@4.20250129.0) @@ -5422,46 +5431,6 @@ packages: terser: optional: true - vite@6.0.11: - resolution: {integrity: sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@6.1.0: resolution: {integrity: sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6702,11 +6671,11 @@ snapshots: '@types/react': 19.0.8 react: 19.0.0 - '@mdx-js/rollup@3.1.0(acorn@8.14.0)(rollup@4.34.0)': + '@mdx-js/rollup@3.1.0(acorn@8.14.0)(rollup@4.34.6)': dependencies: '@mdx-js/mdx': 3.1.0(acorn@8.14.0) - '@rollup/pluginutils': 5.1.4(rollup@4.34.0) - rollup: 4.34.0 + '@rollup/pluginutils': 5.1.4(rollup@4.34.6) + rollup: 4.34.6 source-map: 0.7.4 vfile: 6.0.3 transitivePeerDependencies: @@ -6790,7 +6759,7 @@ snapshots: optionalDependencies: typescript: 5.7.3 - '@remix-run/dev@2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.0)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3)(vite@6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0))': + '@remix-run/dev@2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.0)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0))': dependencies: '@babel/core': 7.26.7 '@babel/generator': 7.26.5 @@ -6850,7 +6819,7 @@ snapshots: ws: 7.5.10 optionalDependencies: typescript: 5.7.3 - vite: 6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) wrangler: 3.107.2(@cloudflare/workers-types@4.20250129.0) transitivePeerDependencies: - '@types/node' @@ -6934,13 +6903,13 @@ snapshots: dependencies: web-streams-polyfill: 3.3.3 - '@rollup/pluginutils@5.1.4(rollup@4.34.0)': + '@rollup/pluginutils@5.1.4(rollup@4.34.6)': dependencies: '@types/estree': 1.0.6 estree-walker: 2.0.2 picomatch: 4.0.2 optionalDependencies: - rollup: 4.34.0 + rollup: 4.34.6 '@rollup/rollup-android-arm-eabi@4.34.0': optional: true @@ -11754,13 +11723,13 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@4.3.2(typescript@5.7.3)(vite@6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)): + vite-tsconfig-paths@4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)): dependencies: debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.4(typescript@5.7.3) optionalDependencies: - vite: 6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - supports-color - typescript @@ -11775,20 +11744,6 @@ snapshots: fsevents: 2.3.3 terser: 5.31.6 - vite@6.0.11(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): - dependencies: - esbuild: 0.24.2 - postcss: 8.5.1 - rollup: 4.34.0 - optionalDependencies: - '@types/node': 22.13.0 - fsevents: 2.3.3 - jiti: 1.21.7 - terser: 5.31.6 - tsx: 4.19.2 - yaml: 2.7.0 - optional: true - vite@6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: esbuild: 0.24.2 diff --git a/spiceflow/package.json b/spiceflow/package.json index 967c15f..9cdb59b 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -42,7 +42,7 @@ "fern-docs": "pnpm run gen-openapi && fern generate --docs --force", "play-sdk:build": "pnpm vite build --config ./scripts/play-sdk.vite.ts", "test": "pnpm vitest", - "prepare": "pnpm build", + "prepare": "# pnpm build", "watch": "tsc -w" }, "files": [ @@ -56,13 +56,16 @@ "@medley/router": "^0.2.1", "@sinclair/typebox": "^0.34.14", "@vitejs/plugin-react": "^4.3.4", - "react-server-dom-vite": "npm:@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "eventsource-parser": "^3.0.0", "lodash.clonedeep": "^4.5.0", "openapi-types": "^12.1.3", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-server-dom-vite": "npm:@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14", "superjson": "^2.2.2", + "vite": "^6.1.0", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.1" }, diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index 21d1330..c2414d5 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -2,7 +2,7 @@ import React from "react"; import ReactDomClient from "react-dom/client"; import ReactClient from "react-server-dom-vite/client"; import type { ServerPayload } from "./entry.rsc.js"; -import type { CallServerFn } from "./types.js"; +import type { CallServerFn } from "./types/index.js"; import { clientReferenceManifest } from "./utils/client-reference.js"; import { getFlightStreamBrowser } from "./utils/stream-script.js"; diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx index 1e22268..207d62d 100644 --- a/spiceflow/src/react/entry.rsc.tsx +++ b/spiceflow/src/react/entry.rsc.tsx @@ -1,10 +1,11 @@ import type { ReactFormState } from "react-dom/client"; +import React from "react"; import ReactServer from "react-server-dom-vite/server"; -import { Router } from "./app/routes.js"; + import type { ClientReferenceMetadataManifest, ServerReferenceManifest, -} from "./types.js"; +} from "./types/index.js"; import { fromPipeableToWebReadable } from "./utils/fetch.js"; export interface RscHandlerResult { diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 25946cb..1664732 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -63,7 +63,7 @@ export default async function handler( declare let __rscRunner: ModuleRunner; -async function importRscEntry(): Promise { +async function importRscEntry(): Promise { if (import.meta.env.DEV) { return await __rscRunner.import("/src/entry.rsc.tsx"); } else { diff --git a/spiceflow/src/react/utils/client-reference.ts b/spiceflow/src/react/utils/client-reference.ts index 7795fd5..4517f3f 100644 --- a/spiceflow/src/react/utils/client-reference.ts +++ b/spiceflow/src/react/utils/client-reference.ts @@ -1,4 +1,4 @@ -import type { ClientReferenceManifest } from "../types.js"; +import type { ClientReferenceManifest } from "../types/index.js"; export const clientReferenceManifest: ClientReferenceManifest = { resolveClientReference(reference: string) { diff --git a/spiceflow/tsconfig.json b/spiceflow/tsconfig.json index 6f9eef8..9c18dab 100644 --- a/spiceflow/tsconfig.json +++ b/spiceflow/tsconfig.json @@ -8,6 +8,7 @@ "moduleResolution": "NodeNext", "declarationMap": true, "sourceMap": true, + "jsx": "react-jsx", "resolveJsonModule": true, "outDir": "dist" }, From 50edca0a8a20f674372336689b3d86bfe568b930 Mon Sep 17 00:00:00 2001 From: remorses Date: Sun, 9 Feb 2025 10:23:15 +0100 Subject: [PATCH 004/226] making vite plugin --- example-react/package.json | 18 ++ example-react/src/main.tsx | 10 + example-react/vite.config.ts | 11 + pnpm-lock.yaml | 15 ++ spiceflow/src/react/entry.rsc.tsx | 4 + spiceflow/src/vite.tsx | 407 ++++++++++++++++-------------- 6 files changed, 269 insertions(+), 196 deletions(-) create mode 100644 example-react/package.json create mode 100644 example-react/src/main.tsx create mode 100644 example-react/vite.config.ts diff --git a/example-react/package.json b/example-react/package.json new file mode 100644 index 0000000..802b8dd --- /dev/null +++ b/example-react/package.json @@ -0,0 +1,18 @@ +{ + "name": "example-react", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "dev": "vite" + }, + "keywords": [], + "author": "remorses ", + "dependencies": { + "react": "19.0.0", + "react-dom": "19.0.0", + "spiceflow": "workspace:*", + "vite": "^6.1.0" + }, + "license": "ISC" +} diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx new file mode 100644 index 0000000..8bbfcb8 --- /dev/null +++ b/example-react/src/main.tsx @@ -0,0 +1,10 @@ +import { Spiceflow } from 'spiceflow' + +const app = new Spiceflow() + .get('/hello', () => 'Hello, World!') + .post('/echo', async ({ request }) => { + const body = await request.json() + return { echo: body } + }) + +export default app diff --git a/example-react/vite.config.ts b/example-react/vite.config.ts new file mode 100644 index 0000000..9856ca1 --- /dev/null +++ b/example-react/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import { spiceflowPlugin } from 'spiceflow/dist/vite' + +export default defineConfig({ + clearScreen: false, + plugins: [ + spiceflowPlugin({ + entry: './src/main.tsx', + }), + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9abdb1..c61565f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,21 @@ importers: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + example-react: + dependencies: + react: + specifier: 19.0.0 + version: 19.0.0 + react-dom: + specifier: 19.0.0 + version: 19.0.0(react@19.0.0) + spiceflow: + specifier: workspace:* + version: link:../spiceflow + vite: + specifier: ^6.1.0 + version: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + openapi-schema-diff: dependencies: json-schema-ref-resolver: diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx index 207d62d..826ae45 100644 --- a/spiceflow/src/react/entry.rsc.tsx +++ b/spiceflow/src/react/entry.rsc.tsx @@ -18,6 +18,10 @@ export interface ServerPayload { returnValue?: unknown; } +function Router() { + return
hello world
+} + export async function handler( url: URL, request: Request, diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index e62b2ed..bba381c 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -1,70 +1,90 @@ import assert from 'node:assert' +import url from 'node:url' import path from 'node:path' import react from '@vitejs/plugin-react' import { type Manifest, type Plugin, + PluginOption, type RunnableDevEnvironment, createRunnableDevEnvironment, defineConfig, } from 'vite' -// state for build orchestration -let browserManifest: Manifest -let clientReferences: Record = {} // TODO: normalize id -let serverReferences: Record = {} -let buildScan = false +export function spiceflowPlugin({ entry }): PluginOption { + // Move state variables inside plugin closure + let browserManifest: Manifest + let clientReferences: Record = {} // TODO: normalize id + let serverReferences: Record = {} + let buildScan = false -export default defineConfig({ - appType: 'custom', - environments: { - client: { - optimizeDeps: { - include: ['react-dom/client', 'react-server-dom-vite/client'], - }, - build: { - manifest: true, - outDir: 'dist/client', - rollupOptions: { - input: { index: 'virtual:browser-entry' }, - }, - }, - }, - ssr: { - build: { - outDir: 'dist/ssr', - rollupOptions: { - input: { index: '/src/entry.ssr.tsx' }, - }, - }, - }, - rsc: { - optimizeDeps: { - include: [ - 'react', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - 'react-server-dom-vite/server', - ], - }, - resolve: { - conditions: ['react-server'], - noExternal: true, - }, - dev: { - createEnvironment(name, config) { - return createRunnableDevEnvironment(name, config, { hot: false }) + return [ + { + name: 'spiceflow', + config: () => ({ + appType: 'custom', + environments: { + client: { + optimizeDeps: { + include: ['react-dom/client', 'react-server-dom-vite/client'], + }, + build: { + manifest: true, + outDir: 'dist/client', + rollupOptions: { + input: { index: 'virtual:browser-entry' }, + }, + }, + }, + ssr: { + build: { + outDir: 'dist/ssr', + rollupOptions: { + input: { index: '/src/entry.ssr.tsx' }, + }, + }, + }, + rsc: { + optimizeDeps: { + include: [ + 'react', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-server-dom-vite/server', + ], + }, + resolve: { + conditions: ['react-server'], + noExternal: true, + }, + dev: { + createEnvironment(name, config) { + return createRunnableDevEnvironment(name, config, { + hot: false, + }) + }, + }, + build: { + outDir: 'dist/rsc', + rollupOptions: { + input: { index: '/src/entry.rsc.tsx' }, + }, + }, + }, }, - }, - build: { - outDir: 'dist/rsc', - rollupOptions: { - input: { index: '/src/entry.rsc.tsx' }, + builder: { + sharedPlugins: true, + async buildApp(builder) { + buildScan = true + await builder.build(builder.environments.rsc) + buildScan = false + await builder.build(builder.environments.rsc) + await builder.build(builder.environments.client) + await builder.build(builder.environments.ssr) + }, }, - }, + }), }, - }, - plugins: [ { name: 'ssr-middleware', configureServer(server) { @@ -106,6 +126,10 @@ export default defineConfig({ } }, }, + createVirtualPlugin('app-entry', () => { + return `export * from '${url.pathToFileURL(path.resolve(entry))}'` + }), + createVirtualPlugin('ssr-assets', function () { assert(this.environment.name === 'ssr') let bootstrapModules: string[] = [] @@ -120,14 +144,14 @@ export default defineConfig({ createVirtualPlugin('browser-entry', function () { if (this.environment.mode === 'dev') { return ` - import "/@vite/client"; - import RefreshRuntime from "/@react-refresh"; - RefreshRuntime.injectIntoGlobalHook(window); - window.$RefreshReg$ = () => {}; - window.$RefreshSig$ = () => (type) => type; - window.__vite_plugin_react_preamble_installed__ = true; - await import("/src/entry.client.tsx"); - ` + import "/@vite/client"; + import RefreshRuntime from "/@react-refresh"; + RefreshRuntime.injectIntoGlobalHook(window); + window.$RefreshReg$ = () => {}; + window.$RefreshSig$ = () => (type) => type; + window.__vite_plugin_react_preamble_installed__ = true; + await import("/src/entry.client.tsx"); + ` } else { return `import "/src/entry.client.tsx";` } @@ -165,152 +189,143 @@ export default defineConfig({ vitePluginUseServer(), vitePluginSilenceDirectiveBuildWarning(), react(), - ], - builder: { - sharedPlugins: true, - async buildApp(builder) { - buildScan = true - await builder.build(builder.environments.rsc) - buildScan = false - await builder.build(builder.environments.rsc) - await builder.build(builder.environments.client) - await builder.build(builder.environments.ssr) - }, - }, -}) + ] -function vitePluginUseClient(): Plugin[] { - return [ - { - name: vitePluginUseClient.name, - transform(code, id) { - if (this.environment.name === 'rsc') { - if (/^(("use client")|('use client'))/.test(code)) { - // pass through client code to find server reference used only by client - if (buildScan) { - return + function vitePluginUseClient(): Plugin[] { + return [ + { + name: vitePluginUseClient.name, + transform(code, id) { + if (this.environment.name === 'rsc') { + if (/^(("use client")|('use client'))/.test(code)) { + // pass through client code to find server reference used only by client + if (buildScan) { + return + } + clientReferences[id] = id // TODO: normalize + const matches = [ + ...code.matchAll(/export function (\w+)\(/g), + ...code.matchAll(/export (default) (function|class) /g), + ] + const result = [ + `import $$ReactServer from "react-server-dom-vite/server"`, + ...[...matches].map( + ([, name]) => + `export ${name === 'default' ? 'default' : `const ${name} =`} $$ReactServer.registerClientReference({}, ${JSON.stringify(id)}, ${JSON.stringify(name)})`, + ), + ].join(';\n') + return { code: result, map: null } } - clientReferences[id] = id // TODO: normalize - const matches = [ - ...code.matchAll(/export function (\w+)\(/g), - ...code.matchAll(/export (default) (function|class) /g), - ] - const result = [ - `import $$ReactServer from "react-server-dom-vite/server"`, - ...[...matches].map( - ([, name]) => - `export ${name === 'default' ? 'default' : `const ${name} =`} $$ReactServer.registerClientReference({}, ${JSON.stringify(id)}, ${JSON.stringify(name)})`, - ), - ].join(';\n') - return { code: result, map: null } } - } + }, }, - }, - createVirtualPlugin('build-client-references', () => { - const code = Object.keys(clientReferences) - .map( - (id) => `${JSON.stringify(id)}: () => import(${JSON.stringify(id)}),`, - ) - .join('\n') - return `export default {${code}}` - }), - ] -} + createVirtualPlugin('build-client-references', () => { + const code = Object.keys(clientReferences) + .map( + (id) => + `${JSON.stringify(id)}: () => import(${JSON.stringify(id)}),`, + ) + .join('\n') + return `export default {${code}}` + }), + ] + } -function vitePluginUseServer(): Plugin[] { - return [ - { - name: vitePluginUseServer.name, - transform(code, id) { - if (/^(("use server")|('use server'))/.test(code)) { - serverReferences[id] = id - if (this.environment.name === 'rsc') { - const matches = code.matchAll(/export async function (\w+)\(/g) - const result = [ - code, - `import $$ReactServer from "react-server-dom-vite/server"`, - ...[...matches].map( - ([, name]) => - `${name} = $$ReactServer.registerServerReference(${name}, ${JSON.stringify(id)}, ${JSON.stringify(name)})`, - ), - ].join(';\n') - return { code: result, map: null } - } else { - const matches = code.matchAll(/export async function (\w+)\(/g) - const result = [ - `import $$ReactClient from "react-server-dom-vite/client"`, - ...[...matches].map( - ([, name]) => - `export const ${name} = $$ReactClient.createServerReference(${JSON.stringify(id + '#' + name)}, (...args) => __callServer(...args))`, - ), - ].join(';\n') - return { code: result, map: null } + function vitePluginUseServer(): Plugin[] { + return [ + { + name: vitePluginUseServer.name, + transform(code, id) { + if (/^(("use server")|('use server'))/.test(code)) { + serverReferences[id] = id + if (this.environment.name === 'rsc') { + const matches = code.matchAll(/export async function (\w+)\(/g) + const result = [ + code, + `import $$ReactServer from "react-server-dom-vite/server"`, + ...[...matches].map( + ([, name]) => + `${name} = $$ReactServer.registerServerReference(${name}, ${JSON.stringify(id)}, ${JSON.stringify(name)})`, + ), + ].join(';\n') + return { code: result, map: null } + } else { + const matches = code.matchAll(/export async function (\w+)\(/g) + const result = [ + `import $$ReactClient from "react-server-dom-vite/client"`, + ...[...matches].map( + ([, name]) => + `export const ${name} = $$ReactClient.createServerReference(${JSON.stringify(id + '#' + name)}, (...args) => __callServer(...args))`, + ), + ].join(';\n') + return { code: result, map: null } + } } - } + }, }, - }, - createVirtualPlugin('build-server-references', () => { - const code = Object.keys(serverReferences) - .map( - (id) => `${JSON.stringify(id)}: () => import(${JSON.stringify(id)}),`, - ) - .join('\n') - return `export default {${code}}` - }), - ] -} + createVirtualPlugin('build-server-references', () => { + const code = Object.keys(serverReferences) + .map( + (id) => + `${JSON.stringify(id)}: () => import(${JSON.stringify(id)}),`, + ) + .join('\n') + return `export default {${code}}` + }), + ] + } -function createVirtualPlugin(name: string, load: Plugin['load']) { - name = 'virtual:' + name - return { - name: `virtual-${name}`, - resolveId(source, _importer, _options) { - return source === name ? '\0' + name : undefined - }, - load(id, options) { - if (id === '\0' + name) { - return (load as Function).apply(this, [id, options]) - } - }, - } satisfies Plugin -} + function createVirtualPlugin(name: string, load: Plugin['load']) { + name = 'virtual:' + name + return { + name: `virtual-${name}`, + resolveId(source, _importer, _options) { + return source === name ? '\0' + name : undefined + }, + load(id, options) { + if (id === '\0' + name) { + return (load as Function).apply(this, [id, options]) + } + }, + } satisfies Plugin + } -// silence warning due to "use ..." directives -// https://github.com/vitejs/vite-plugin-react/blob/814ed8043d321f4b4679a9f4a781d1ed14f185e4/packages/plugin-react/src/index.ts#L303 -function vitePluginSilenceDirectiveBuildWarning(): Plugin { - return { - name: vitePluginSilenceDirectiveBuildWarning.name, - enforce: 'post', - config(config, _env) { - return { - build: { - rollupOptions: { - onwarn(warning, defaultHandler) { - // https://github.com/vitejs/vite/issues/15012#issuecomment-1948550039 - if ( - warning.code === 'SOURCEMAP_ERROR' && - warning.message.includes('(1:0)') - ) { - return - } - // https://github.com/TanStack/query/pull/5161#issuecomment-1506683450 - if ( - warning.code === 'MODULE_LEVEL_DIRECTIVE' && - (warning.message.includes(`use client`) || - warning.message.includes(`use server`)) - ) { - return - } - if (config.build?.rollupOptions?.onwarn) { - config.build.rollupOptions.onwarn(warning, defaultHandler) - } else { - defaultHandler(warning) - } + // silence warning due to "use ..." directives + // https://github.com/vitejs/vite-plugin-react/blob/814ed8043d321f4b4679a9f4a781d1ed14f185e4/packages/plugin-react/src/index.ts#L303 + function vitePluginSilenceDirectiveBuildWarning(): Plugin { + return { + name: vitePluginSilenceDirectiveBuildWarning.name, + enforce: 'post', + config(config, _env) { + return { + build: { + rollupOptions: { + onwarn(warning, defaultHandler) { + // https://github.com/vitejs/vite/issues/15012#issuecomment-1948550039 + if ( + warning.code === 'SOURCEMAP_ERROR' && + warning.message.includes('(1:0)') + ) { + return + } + // https://github.com/TanStack/query/pull/5161#issuecomment-1506683450 + if ( + warning.code === 'MODULE_LEVEL_DIRECTIVE' && + (warning.message.includes(`use client`) || + warning.message.includes(`use server`)) + ) { + return + } + if (config.build?.rollupOptions?.onwarn) { + config.build.rollupOptions.onwarn(warning, defaultHandler) + } else { + defaultHandler(warning) + } + }, }, }, - }, - } - }, + } + }, + } } } From e4b82f1e2d7841a09df44bfaa088a1201b8438d1 Mon Sep 17 00:00:00 2001 From: remorses Date: Sun, 9 Feb 2025 11:16:14 +0100 Subject: [PATCH 005/226] it works, vite optimize deps thing is awful, added util in exclude or the react server would be optimized for browser --- pnpm-lock.yaml | 27 +++++++++++++++++++- spiceflow/package.json | 4 +++ spiceflow/src/react/entry.rsc.tsx | 2 +- spiceflow/src/react/entry.ssr.tsx | 2 +- spiceflow/src/react/server-dom-optimized.tsx | 3 +++ spiceflow/src/vite.tsx | 22 ++++++++++------ 6 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 spiceflow/src/react/server-dom-optimized.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c61565f..cf556a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: react-server-dom-vite: specifier: npm:@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14 version: '@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)' + spiceflow: + specifier: '*' + version: 1.6.1(@modelcontextprotocol/sdk@1.0.4) superjson: specifier: ^2.2.2 version: 2.2.2 @@ -4898,6 +4901,14 @@ packages: spdx-license-ids@3.0.21: resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} + spiceflow@1.6.1: + resolution: {integrity: sha512-PBJ4QC/RjBIoNKVBALDAQZkTF7KG94xV7rmSil9HXi0kXh4k+akaI7pFG15pTMPfX7EZPQRo/3kml9tSEeJ9gw==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.0.4 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -7285,7 +7296,7 @@ snapshots: '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.7) '@vanilla-extract/babel-plugin-debug-ids': 1.2.0 '@vanilla-extract/css': 1.17.1 - esbuild: 0.17.19 + esbuild: 0.17.6 eval: 0.1.8 find-up: 5.0.0 javascript-stringify: 2.1.0 @@ -11086,6 +11097,20 @@ snapshots: spdx-license-ids@3.0.21: {} + spiceflow@1.6.1(@modelcontextprotocol/sdk@1.0.4): + dependencies: + '@medley/router': 0.2.1 + '@sinclair/typebox': 0.34.15 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + eventsource-parser: 3.0.0 + openapi-types: 12.1.3 + superjson: 2.2.2 + zod: 3.24.1 + zod-to-json-schema: 3.24.1(zod@3.24.1) + optionalDependencies: + '@modelcontextprotocol/sdk': 1.0.4 + sprintf-js@1.0.3: {} ssri@10.0.6: diff --git a/spiceflow/package.json b/spiceflow/package.json index 9cdb59b..0574054 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -27,6 +27,9 @@ "types": "./dist/openapi.d.ts", "default": "./dist/openapi.js" }, + "./src/*": { + "default": "./src/*" + }, "./dist/*": { "types": "./dist/*.d.ts", "default": "./dist/*.js" @@ -67,6 +70,7 @@ "superjson": "^2.2.2", "vite": "^6.1.0", "zod": "^3.24.1", + "spiceflow": "*", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx index 826ae45..35533ac 100644 --- a/spiceflow/src/react/entry.rsc.tsx +++ b/spiceflow/src/react/entry.rsc.tsx @@ -1,6 +1,6 @@ import type { ReactFormState } from "react-dom/client"; import React from "react"; -import ReactServer from "react-server-dom-vite/server"; +import ReactServer from "spiceflow/dist/react/server-dom-optimized"; import type { ClientReferenceMetadataManifest, diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 1664732..7418def 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -65,7 +65,7 @@ declare let __rscRunner: ModuleRunner; async function importRscEntry(): Promise { if (import.meta.env.DEV) { - return await __rscRunner.import("/src/entry.rsc.tsx"); + return await __rscRunner.import("spiceflow/src/react/entry.rsc.tsx"); } else { return await import("virtual:build-rsc-entry" as any); } diff --git a/spiceflow/src/react/server-dom-optimized.tsx b/spiceflow/src/react/server-dom-optimized.tsx new file mode 100644 index 0000000..c391398 --- /dev/null +++ b/spiceflow/src/react/server-dom-optimized.tsx @@ -0,0 +1,3 @@ + +import ReactServer from 'react-server-dom-vite/server' +export default ReactServer \ No newline at end of file diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index bba381c..879915d 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -10,6 +10,9 @@ import { createRunnableDevEnvironment, defineConfig, } from 'vite' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) export function spiceflowPlugin({ entry }): PluginOption { // Move state variables inside plugin closure @@ -40,7 +43,7 @@ export function spiceflowPlugin({ entry }): PluginOption { build: { outDir: 'dist/ssr', rollupOptions: { - input: { index: '/src/entry.ssr.tsx' }, + input: { index: 'spiceflow/src/react/entry.ssr.tsx' }, }, }, }, @@ -50,8 +53,9 @@ export function spiceflowPlugin({ entry }): PluginOption { 'react', 'react/jsx-runtime', 'react/jsx-dev-runtime', - 'react-server-dom-vite/server', + 'spiceflow/dist/react/server-dom-optimized', ], + exclude: ['util'], }, resolve: { conditions: ['react-server'], @@ -67,7 +71,7 @@ export function spiceflowPlugin({ entry }): PluginOption { build: { outDir: 'dist/rsc', rollupOptions: { - input: { index: '/src/entry.rsc.tsx' }, + input: { index: 'spiceflow/src/react/entry.rsc.tsx' }, }, }, }, @@ -96,7 +100,9 @@ export function spiceflowPlugin({ entry }): PluginOption { return () => { server.middlewares.use(async (req, res, next) => { try { - const mod: any = await ssrRunner.import('/src/entry.ssr.tsx') + const mod: any = await ssrRunner.import( + 'spiceflow/src/react/entry.ssr.tsx', + ) await mod.default(req, res) } catch (e) { next(e) @@ -150,10 +156,10 @@ export function spiceflowPlugin({ entry }): PluginOption { window.$RefreshReg$ = () => {}; window.$RefreshSig$ = () => (type) => type; window.__vite_plugin_react_preamble_installed__ = true; - await import("/src/entry.client.tsx"); + await import("spiceflow/src/react/entry.client.tsx"); ` } else { - return `import "/src/entry.client.tsx";` + return `import "spiceflow/src/react/entry.client.tsx";` } }), { @@ -208,7 +214,7 @@ export function spiceflowPlugin({ entry }): PluginOption { ...code.matchAll(/export (default) (function|class) /g), ] const result = [ - `import $$ReactServer from "react-server-dom-vite/server"`, + `import $$ReactServer from "spiceflow/dist/react/server-dom-optimized"`, ...[...matches].map( ([, name]) => `export ${name === 'default' ? 'default' : `const ${name} =`} $$ReactServer.registerClientReference({}, ${JSON.stringify(id)}, ${JSON.stringify(name)})`, @@ -242,7 +248,7 @@ export function spiceflowPlugin({ entry }): PluginOption { const matches = code.matchAll(/export async function (\w+)\(/g) const result = [ code, - `import $$ReactServer from "react-server-dom-vite/server"`, + `import $$ReactServer from "spiceflow/dist/react/server-dom-optimized"`, ...[...matches].map( ([, name]) => `${name} = $$ReactServer.registerServerReference(${name}, ${JSON.stringify(id)}, ${JSON.stringify(name)})`, From 76698adcdd429f0c23875e1138dac8493ac3dda3 Mon Sep 17 00:00:00 2001 From: remorses Date: Sun, 9 Feb 2025 11:35:42 +0100 Subject: [PATCH 006/226] nn --- spiceflow/src/react/entry.ssr.tsx | 3 +++ spiceflow/src/vite.tsx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 7418def..b547019 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -12,6 +12,9 @@ import { } from "./utils/fetch.js"; import { injectFlightStream } from "./utils/stream-script.js"; + + + export default async function handler( req: IncomingMessage, res: ServerResponse, diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 879915d..054d2cd 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -8,6 +8,7 @@ import { PluginOption, type RunnableDevEnvironment, createRunnableDevEnvironment, + createServerModuleRunner, defineConfig, } from 'vite' import { fileURLToPath } from 'node:url' @@ -48,6 +49,7 @@ export function spiceflowPlugin({ entry }): PluginOption { }, }, rsc: { + optimizeDeps: { include: [ 'react', @@ -65,6 +67,7 @@ export function spiceflowPlugin({ entry }): PluginOption { createEnvironment(name, config) { return createRunnableDevEnvironment(name, config, { hot: false, + }) }, }, From 2dc1480b7f2e50f7b731c58e2ae9bba47961f71e Mon Sep 17 00:00:00 2001 From: remorses Date: Sun, 9 Feb 2025 11:58:40 +0100 Subject: [PATCH 007/226] spiceflow works, removed noExternal because commonjs does not work that way --- example-react/src/main.tsx | 1 + spiceflow/src/react/entry.rsc.tsx | 17 +++++++++++------ spiceflow/src/react/entry.ssr.tsx | 5 +++++ spiceflow/src/react/types/ambient.d.ts | 7 +++++++ spiceflow/src/vite.tsx | 19 ++++++++++--------- spiceflow/tsconfig.json | 1 + 6 files changed, 35 insertions(+), 15 deletions(-) diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 8bbfcb8..0940f57 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -1,6 +1,7 @@ import { Spiceflow } from 'spiceflow' const app = new Spiceflow() + .get('/', () => 'Hello, World!') .get('/hello', () => 'Hello, World!') .post('/echo', async ({ request }) => { const body = await request.json() diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx index 35533ac..dca8e3e 100644 --- a/spiceflow/src/react/entry.rsc.tsx +++ b/spiceflow/src/react/entry.rsc.tsx @@ -6,6 +6,7 @@ import type { ClientReferenceMetadataManifest, ServerReferenceManifest, } from "./types/index.js"; +import app from 'virtual:app-entry' import { fromPipeableToWebReadable } from "./utils/fetch.js"; export interface RscHandlerResult { @@ -18,14 +19,11 @@ export interface ServerPayload { returnValue?: unknown; } -function Router() { - return
hello world
-} export async function handler( url: URL, request: Request, -): Promise { +) { // handle action let returnValue: unknown | undefined; let formState: ReactFormState | undefined; @@ -58,11 +56,17 @@ export async function handler( } } + const root = await app.handle(request) + + if (root instanceof Response) { + return root + } + // render flight stream const stream = fromPipeableToWebReadable( ReactServer.renderToPipeableStream( { - root: , + root, returnValue, formState, }, @@ -71,9 +75,10 @@ export async function handler( ), ); - return { + let r : RscHandlerResult = { stream, }; + return r } const serverReferenceManifest: ServerReferenceManifest = { diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index b547019..f6bbb21 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -24,6 +24,11 @@ export default async function handler( const rscEntry = await importRscEntry(); const rscResult = await rscEntry.handler(url, request); + if (rscResult instanceof Response) { + sendResponse(rscResult, res); + return; + } + if (url.searchParams.has("__rsc")) { const response = new Response(rscResult.stream, { headers: { diff --git a/spiceflow/src/react/types/ambient.d.ts b/spiceflow/src/react/types/ambient.d.ts index 2414f28..75d9c2a 100644 --- a/spiceflow/src/react/types/ambient.d.ts +++ b/spiceflow/src/react/types/ambient.d.ts @@ -1,4 +1,5 @@ /// +// import {Spiceflow} from '../../spiceflow.js' declare module "react-server-dom-vite/server" { export function renderToPipeableStream( @@ -50,6 +51,12 @@ declare module "virtual:ssr-assets" { export const bootstrapModules: string[]; } +declare module "virtual:app-entry" { + import type { Spiceflow } from "spiceflow"; + const app: Spiceflow; + export default app +} + declare module "virtual:build-client-references" { const value: Record Promise>>; export default value; diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 054d2cd..727b9bc 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -61,18 +61,19 @@ export function spiceflowPlugin({ entry }): PluginOption { }, resolve: { conditions: ['react-server'], - noExternal: true, + // noExternal: true, }, - dev: { - createEnvironment(name, config) { - return createRunnableDevEnvironment(name, config, { - hot: false, + // dev: { + // createEnvironment(name, config) { + // return createRunnableDevEnvironment(name, config, { + // hot: false, - }) - }, - }, + // }) + // }, + // }, build: { outDir: 'dist/rsc', + ssr: true, rollupOptions: { input: { index: 'spiceflow/src/react/entry.rsc.tsx' }, }, @@ -136,7 +137,7 @@ export function spiceflowPlugin({ entry }): PluginOption { }, }, createVirtualPlugin('app-entry', () => { - return `export * from '${url.pathToFileURL(path.resolve(entry))}'` + return `export {default} from '${url.pathToFileURL(path.resolve(entry))}'` }), createVirtualPlugin('ssr-assets', function () { diff --git a/spiceflow/tsconfig.json b/spiceflow/tsconfig.json index 9c18dab..90a6a11 100644 --- a/spiceflow/tsconfig.json +++ b/spiceflow/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "rootDir": "src", + "typeRoots": ["./src/react/types/ambient.d.ts"], "module": "NodeNext", "target": "ESNext", "lib": ["DOM", "ESNext", "dom.iterable"], From c9b94b6293e1a059daaa27364c5dfa06aafb51ad Mon Sep 17 00:00:00 2001 From: remorses Date: Sun, 9 Feb 2025 12:16:02 +0100 Subject: [PATCH 008/226] kind of works, adding example app in react and react method --- example-react/src/app/action-by-client.tsx | 14 +++++ example-react/src/app/action.tsx | 12 ++++ example-react/src/app/client.tsx | 62 +++++++++++++++++++ example-react/src/app/index.tsx | 28 +++++++++ example-react/src/app/layout.tsx | 25 ++++++++ example-react/src/app/other.tsx | 3 + example-react/src/main.tsx | 6 +- example-react/tsconfig.json | 27 ++++++++ pnpm-lock.yaml | 5 +- spiceflow/package.json | 3 +- spiceflow/src/react/entry.client.tsx | 2 +- spiceflow/src/react/entry.ssr.tsx | 3 +- .../src/react/server-dom-client-optimized.tsx | 3 + spiceflow/src/react/types/ambient.d.ts | 2 +- spiceflow/src/spiceflow.ts | 60 +++++++++++++++++- spiceflow/src/vite.tsx | 4 +- spiceflow/tsconfig.json | 1 - 17 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 example-react/src/app/action-by-client.tsx create mode 100644 example-react/src/app/action.tsx create mode 100644 example-react/src/app/client.tsx create mode 100644 example-react/src/app/index.tsx create mode 100644 example-react/src/app/layout.tsx create mode 100644 example-react/src/app/other.tsx create mode 100644 example-react/tsconfig.json create mode 100644 spiceflow/src/react/server-dom-client-optimized.tsx diff --git a/example-react/src/app/action-by-client.tsx b/example-react/src/app/action-by-client.tsx new file mode 100644 index 0000000..559f302 --- /dev/null +++ b/example-react/src/app/action-by-client.tsx @@ -0,0 +1,14 @@ +"use server"; + +export async function add(_prev: unknown, formData: FormData) { + let x = formData.get("x"); + let y = formData.get("y"); + if (typeof x === "string" && typeof y === "string") { + let x2 = parseFloat(x); + let y2 = parseFloat(y); + if (!Number.isNaN(x2) && !Number.isNaN(y2)) { + return x2 + y2; + } + } + return "(invalid input)"; +} diff --git a/example-react/src/app/action.tsx b/example-react/src/app/action.tsx new file mode 100644 index 0000000..4920b18 --- /dev/null +++ b/example-react/src/app/action.tsx @@ -0,0 +1,12 @@ +"use server"; + +let counter = 0; + +export function getCounter() { + return counter; +} + +export async function changeCounter(formData: FormData) { + const change = Number(formData.get("change")); + counter += change; +} diff --git a/example-react/src/app/client.tsx b/example-react/src/app/client.tsx new file mode 100644 index 0000000..5f63437 --- /dev/null +++ b/example-react/src/app/client.tsx @@ -0,0 +1,62 @@ +"use client"; + +import React from "react"; +import { add } from "./action-by-client"; + +export function Counter() { + const [count, setCount] = React.useState(0); + return ( +
+
Client counter: {count}
+
+ + +
+
+ ); +} + +export function Hydrated() { + return
[hydrated: {Number(useHydrated())}]
; +} + +function useHydrated() { + return React.useSyncExternalStore( + React.useCallback(() => () => {}, []), + () => true, + () => false, + ); +} + +export function Calculator() { + const [returnValue, formAction, _isPending] = React.useActionState(add, null); + const [x, setX] = React.useState(""); + const [y, setY] = React.useState(""); + + return ( +
+
Calculator
+
+ setX(e.target.value)} + /> + + + setY(e.target.value)} + /> + ={returnValue ?? "?"} +
+ +
+ ); +} diff --git a/example-react/src/app/index.tsx b/example-react/src/app/index.tsx new file mode 100644 index 0000000..268655d --- /dev/null +++ b/example-react/src/app/index.tsx @@ -0,0 +1,28 @@ +import { changeCounter, getCounter } from "./action"; +import { Calculator, Counter, Hydrated } from "./client"; + +export async function IndexPage() { + return ( +
+
server random: {Math.random().toString(36).slice(2)}
+ + +
+
Server counter: {getCounter()}
+
+ + +
+
+ +
+ ); +} diff --git a/example-react/src/app/layout.tsx b/example-react/src/app/layout.tsx new file mode 100644 index 0000000..f6009c5 --- /dev/null +++ b/example-react/src/app/layout.tsx @@ -0,0 +1,25 @@ +export function Layout(props: React.PropsWithChildren) { + return ( + + + + react-server + + + + + {props.children} + + + ); +} diff --git a/example-react/src/app/other.tsx b/example-react/src/app/other.tsx new file mode 100644 index 0000000..d7a7b25 --- /dev/null +++ b/example-react/src/app/other.tsx @@ -0,0 +1,3 @@ +export default function OtherPage() { + return
Other Page
; +} diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 0940f57..415df78 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -1,7 +1,11 @@ import { Spiceflow } from 'spiceflow' +import { IndexPage } from './app/index' const app = new Spiceflow() - .get('/', () => 'Hello, World!') + .react('/', ({ request}) => { + const url = new URL(request.url) + return + }) .get('/hello', () => 'Hello, World!') .post('/echo', async ({ request }) => { const body = await request.json() diff --git a/example-react/tsconfig.json b/example-react/tsconfig.json new file mode 100644 index 0000000..28d3155 --- /dev/null +++ b/example-react/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022", "esnext"], + "types": [ + "vite/client", + ], + "rootDir": "src", + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@/*": ["./app/*"] + }, + "noImplicitAny": false, + // Vite takes care of building everything, not tsc. + "noEmit": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf556a7..67cb941 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ importers: '@types/node': specifier: 22.12.0 version: 22.12.0 + '@types/react': + specifier: ^19.0.8 + version: 19.0.8 eventsource: specifier: ^3.0.5 version: 3.0.5 @@ -7296,7 +7299,7 @@ snapshots: '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.7) '@vanilla-extract/babel-plugin-debug-ids': 1.2.0 '@vanilla-extract/css': 1.17.1 - esbuild: 0.17.6 + esbuild: 0.17.19 eval: 0.1.8 find-up: 5.0.0 javascript-stringify: 2.1.0 diff --git a/spiceflow/package.json b/spiceflow/package.json index 0574054..b30ea42 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -67,10 +67,10 @@ "react": "19.0.0", "react-dom": "19.0.0", "react-server-dom-vite": "npm:@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14", + "spiceflow": "*", "superjson": "^2.2.2", "vite": "^6.1.0", "zod": "^3.24.1", - "spiceflow": "*", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { @@ -84,6 +84,7 @@ "devDependencies": { "@types/lodash.clonedeep": "^4.5.9", "@types/node": "22.12.0", + "@types/react": "^19.0.8", "eventsource": "^3.0.5", "formdata-node": "^6.0.3", "js-base64": "^3.7.7", diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index c2414d5..c453f2a 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -1,6 +1,6 @@ import React from "react"; import ReactDomClient from "react-dom/client"; -import ReactClient from "react-server-dom-vite/client"; +import ReactClient from "spiceflow/dist/react/server-dom-client-optimized"; import type { ServerPayload } from "./entry.rsc.js"; import type { CallServerFn } from "./types/index.js"; import { clientReferenceManifest } from "./utils/client-reference.js"; diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index f6bbb21..5f90b85 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import ReactDomServer from "react-dom/server"; -import ReactClient from "react-server-dom-vite/client"; +import ReactClient from "spiceflow/dist/react/server-dom-client-optimized"; import type { ModuleRunner } from "vite/module-runner"; import type { ServerPayload } from "./entry.rsc.js"; import { clientReferenceManifest } from "./utils/client-reference.js"; @@ -28,6 +28,7 @@ export default async function handler( sendResponse(rscResult, res); return; } + if (url.searchParams.has("__rsc")) { const response = new Response(rscResult.stream, { diff --git a/spiceflow/src/react/server-dom-client-optimized.tsx b/spiceflow/src/react/server-dom-client-optimized.tsx new file mode 100644 index 0000000..e5e4bbb --- /dev/null +++ b/spiceflow/src/react/server-dom-client-optimized.tsx @@ -0,0 +1,3 @@ + +import ReactServer from 'react-server-dom-vite/client' +export default ReactServer \ No newline at end of file diff --git a/spiceflow/src/react/types/ambient.d.ts b/spiceflow/src/react/types/ambient.d.ts index 75d9c2a..33e0c8b 100644 --- a/spiceflow/src/react/types/ambient.d.ts +++ b/spiceflow/src/react/types/ambient.d.ts @@ -22,7 +22,7 @@ declare module "react-server-dom-vite/server" { ): Promise; } -declare module "react-server-dom-vite/client" { +declare module "spiceflow/dist/react/server-dom-client-optimized" { export function createFromNodeStream( stream: import("node:stream").Readable, manifest: import(".").ClientReferenceManifest, diff --git a/spiceflow/src/spiceflow.ts b/spiceflow/src/spiceflow.ts index 8bc1120..4e24943 100644 --- a/spiceflow/src/spiceflow.ts +++ b/spiceflow/src/spiceflow.ts @@ -34,7 +34,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema' import { Context, MiddlewareContext } from './context.js' import { isProduction, ValidationError } from './error.js' import { isAsyncIterable, isResponse, redirect } from './utils.js' -import { json } from 'stream/consumers' +import { isValidElement } from 'react' const ajv = (addFormats.default || addFormats)( new (Ajv.default || Ajv)({ useDefaults: true }), @@ -71,6 +71,7 @@ export type InternalRoute = { validateBody?: ValidateFunction validateQuery?: ValidateFunction validateParams?: ValidateFunction + kind?: 'react' // prefix: string } @@ -675,6 +676,57 @@ export class Spiceflow< return this as any } + react< + const Path extends string, + const LocalSchema extends InputSchema, + const Schema extends UnwrapRoute, + const Handle extends InlineHandler< + Schema, + Singleton, + JoinPath + >, + >( + path: Path, + handler: Handle, + hook?: LocalHook< + LocalSchema, + Schema, + Singleton, + Definitions['error'], + Metadata['macro'], + JoinPath + >, + ): Spiceflow< + BasePath, + Scoped, + Singleton, + Definitions, + Metadata, + Routes & + CreateClient< + JoinPath, + { + get: { + body: Schema['body'] + params: undefined extends Schema['params'] + ? ResolvePath + : Schema['params'] + query: Schema['query'] + response: ComposeSpiceflowResponse + } + } + > + > { + this.add({ + method: 'GET', + path, + handler: handler, + hooks: hook, + kind: 'react' + }) + return this as any + } + private scoped?: Scoped = true as Scoped use( @@ -765,6 +817,12 @@ export class Spiceflow< params: _params, redirect, } satisfies MiddlewareContext + + if (route?.internalRoute?.kind === 'react') { + const root = await route.internalRoute?.handler(context) + console.log('root', root) + return root + } let handlerResponse: Response | undefined async function getResForError(err: any) { if (isResponse(err)) return err diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 727b9bc..68b1d83 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -30,7 +30,7 @@ export function spiceflowPlugin({ entry }): PluginOption { environments: { client: { optimizeDeps: { - include: ['react-dom/client', 'react-server-dom-vite/client'], + include: ['react-dom/client', 'spiceflow/dist/react/server-dom-client-optimized'], }, build: { manifest: true, @@ -262,7 +262,7 @@ export function spiceflowPlugin({ entry }): PluginOption { } else { const matches = code.matchAll(/export async function (\w+)\(/g) const result = [ - `import $$ReactClient from "react-server-dom-vite/client"`, + `import $$ReactClient from "spiceflow/dist/react/server-dom-client-optimized"`, ...[...matches].map( ([, name]) => `export const ${name} = $$ReactClient.createServerReference(${JSON.stringify(id + '#' + name)}, (...args) => __callServer(...args))`, diff --git a/spiceflow/tsconfig.json b/spiceflow/tsconfig.json index 90a6a11..9c18dab 100644 --- a/spiceflow/tsconfig.json +++ b/spiceflow/tsconfig.json @@ -2,7 +2,6 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "typeRoots": ["./src/react/types/ambient.d.ts"], "module": "NodeNext", "target": "ESNext", "lib": ["DOM", "ESNext", "dom.iterable"], From fc86f4499a34caf2359d69a2ffb403ce84fccdd0 Mon Sep 17 00:00:00 2001 From: remorses Date: Sun, 9 Feb 2025 12:28:01 +0100 Subject: [PATCH 009/226] fix, use parcel rsc-html-stream --- example-react/src/main.tsx | 9 ++++- pnpm-lock.yaml | 8 ++++ spiceflow/package.json | 1 + spiceflow/src/react/entry.client.tsx | 5 ++- spiceflow/src/react/entry.ssr.tsx | 7 ++-- spiceflow/src/react/utils/stream-script.ts | 45 ---------------------- spiceflow/src/spiceflow.ts | 8 +++- 7 files changed, 30 insertions(+), 53 deletions(-) delete mode 100644 spiceflow/src/react/utils/stream-script.ts diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 415df78..8f80651 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -1,10 +1,15 @@ import { Spiceflow } from 'spiceflow' import { IndexPage } from './app/index' +import { Layout } from './app/layout' const app = new Spiceflow() - .react('/', ({ request}) => { + .react('/', ({ request }) => { const url = new URL(request.url) - return + return ( + + + + ) }) .get('/hello', () => 'Hello, World!') .post('/echo', async ({ request }) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67cb941..fc2bd6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: react-server-dom-vite: specifier: npm:@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14 version: '@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)' + rsc-html-stream: + specifier: ^0.0.4 + version: 0.0.4 spiceflow: specifier: '*' version: 1.6.1(@modelcontextprotocol/sdk@1.0.4) @@ -4751,6 +4754,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rsc-html-stream@0.0.4: + resolution: {integrity: sha512-1isiXIrlTI/vRLTvS3O4fMrO9qIHje1FSphufrIV5QfzHUgBDCZFwP9b8+rH63nbhxtcKTqfyziwM+2khfX0Uw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -10926,6 +10932,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.34.6 fsevents: 2.3.3 + rsc-html-stream@0.0.4: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 diff --git a/spiceflow/package.json b/spiceflow/package.json index b30ea42..3e0aa38 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -67,6 +67,7 @@ "react": "19.0.0", "react-dom": "19.0.0", "react-server-dom-vite": "npm:@jacob-ebey/react-server-dom-vite@19.0.0-experimental.14", + "rsc-html-stream": "^0.0.4", "spiceflow": "*", "superjson": "^2.2.2", "vite": "^6.1.0", diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index c453f2a..e412f8b 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -4,7 +4,8 @@ import ReactClient from "spiceflow/dist/react/server-dom-client-optimized"; import type { ServerPayload } from "./entry.rsc.js"; import type { CallServerFn } from "./types/index.js"; import { clientReferenceManifest } from "./utils/client-reference.js"; -import { getFlightStreamBrowser } from "./utils/stream-script.js"; +import {rscStream} from 'rsc-html-stream/client'; + async function main() { const callServer: CallServerFn = async (id, args) => { @@ -36,7 +37,7 @@ async function main() { const initialPayload = await ReactClient.createFromReadableStream( - getFlightStreamBrowser(), + rscStream, clientReferenceManifest, { callServer }, ); diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 5f90b85..d92d94b 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -10,7 +10,8 @@ import { fromWebToNodeReadable, sendResponse, } from "./utils/fetch.js"; -import { injectFlightStream } from "./utils/stream-script.js"; +import {injectRSCPayload} from 'rsc-html-stream/server'; + @@ -59,8 +60,8 @@ export default async function handler( const response = new Response( htmlStream - .pipeThrough(new TextDecoderStream()) - .pipeThrough(injectFlightStream(flightStream2)), + + .pipeThrough(injectRSCPayload(flightStream2)), { headers: { "content-type": "text/html;charset=utf-8", diff --git a/spiceflow/src/react/utils/stream-script.ts b/spiceflow/src/react/utils/stream-script.ts deleted file mode 100644 index cedea64..0000000 --- a/spiceflow/src/react/utils/stream-script.ts +++ /dev/null @@ -1,45 +0,0 @@ -const INIT_SCRIPT = ` -self.__flightStream = new ReadableStream({ - start(controller) { - self.__f_push = (c) => controller.enqueue(c); - self.__f_close = () => controller.close(); - } -}).pipeThrough(new TextEncoderStream()); -`; - -export function injectFlightStream(stream: ReadableStream) { - return new TransformStream({ - async transform(chunk, controller) { - // TODO: chunk is not guaranteed to include entire end tag `` - if (chunk.includes("")) { - chunk = chunk.replace( - "", - () => ``, - ); - } - if (chunk.includes("")) { - const i = chunk.indexOf(""); - controller.enqueue(chunk.slice(0, i)); - await stream.pipeThrough(new TextDecoderStream()).pipeTo( - new WritableStream({ - write(chunk) { - controller.enqueue( - ``, - ); - }, - close() { - controller.enqueue(``); - }, - }), - ); - controller.enqueue(chunk.slice(i)); - } else { - controller.enqueue(chunk); - } - }, - }); -} - -export function getFlightStreamBrowser(): ReadableStream { - return (self as any).__flightStream; -} diff --git a/spiceflow/src/spiceflow.ts b/spiceflow/src/spiceflow.ts index 4e24943..63334cd 100644 --- a/spiceflow/src/spiceflow.ts +++ b/spiceflow/src/spiceflow.ts @@ -724,6 +724,13 @@ export class Spiceflow< hooks: hook, kind: 'react' }) + this.add({ + method: 'POST', + path, + handler: handler, + hooks: hook, + kind: 'react' + }) return this as any } @@ -820,7 +827,6 @@ export class Spiceflow< if (route?.internalRoute?.kind === 'react') { const root = await route.internalRoute?.handler(context) - console.log('root', root) return root } let handlerResponse: Response | undefined From 66808b97b100b349dc35d2216a2524e5e5f4d2ad Mon Sep 17 00:00:00 2001 From: remorses Date: Sun, 9 Feb 2025 14:46:41 +0100 Subject: [PATCH 010/226] adding e2e tests --- example-react/.gitignore | 4 + example-react/e2e/basic.test.ts | 128 ++++++++++++++++++ example-react/e2e/helper.ts | 15 ++ example-react/package.json | 6 +- example-react/playwright.config.ts | 32 +++++ example-react/src/app/client.tsx | 4 + example-react/src/app/index.tsx | 2 +- example-react/src/main.tsx | 10 +- example-react/tsconfig.json | 44 +++--- pnpm-lock.yaml | 38 ++++++ spiceflow/src/react/utils/client-reference.ts | 1 + spiceflow/src/spiceflow.ts | 38 ++---- spiceflow/src/vite.tsx | 1 + 13 files changed, 268 insertions(+), 55 deletions(-) create mode 100644 example-react/.gitignore create mode 100644 example-react/e2e/basic.test.ts create mode 100644 example-react/e2e/helper.ts create mode 100644 example-react/playwright.config.ts diff --git a/example-react/.gitignore b/example-react/.gitignore new file mode 100644 index 0000000..beed9cf --- /dev/null +++ b/example-react/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +test-results +*.tsbuildinfo diff --git a/example-react/e2e/basic.test.ts b/example-react/e2e/basic.test.ts new file mode 100644 index 0000000..8a0b083 --- /dev/null +++ b/example-react/e2e/basic.test.ts @@ -0,0 +1,128 @@ +import { type Page, expect, test } from "@playwright/test"; +import { createEditor } from "./helper.js"; + +test("client reference", async ({ page }) => { + await page.goto("/"); + await page.getByText("[hydrated: 1]").click(); + await page.getByText("Client counter: 0").click(); + await page + .getByTestId("client-counter") + .getByRole("button", { name: "+" }) + .click(); + await page.getByText("Client counter: 1").click(); + await page.reload(); + await page.getByText("Client counter: 0").click(); +}); + +test("server reference in server @js", async ({ page }) => { + await testServerAction(page); +}); + +test.describe(() => { + test.use({ javaScriptEnabled: false }); + test("server reference in server @nojs", async ({ page }) => { + await testServerAction(page); + }); +}); + +async function testServerAction(page: Page) { + await page.goto("/"); + await page.getByText("Server counter: 0").click(); + await page + .getByTestId("server-counter") + .getByRole("button", { name: "+" }) + .click(); + await page.getByText("Server counter: 1").click(); + await page.goto("/"); + await page.getByText("Server counter: 1").click(); + await page + .getByTestId("server-counter") + .getByRole("button", { name: "-" }) + .click(); + await page.getByText("Server counter: 0").click(); +} + +test("server reference in client @js", async ({ page }) => { + await testServerAction2(page, { js: true }); +}); + +test.describe(() => { + test.use({ javaScriptEnabled: false }); + test("server reference in client @nojs", async ({ page }) => { + await testServerAction2(page, { js: false }); + }); +}); + +async function testServerAction2(page: Page, options: { js: boolean }) { + await page.goto("/"); + if (options.js) { + await page.getByText("[hydrated: 1]").click(); + } + await page.locator('input[name="x"]').fill("2"); + await page.locator('input[name="y"]').fill("3"); + await page.locator('input[name="y"]').press("Enter"); + await expect(page.getByTestId("calculator-answer")).toContainText("5"); + await page.locator('input[name="x"]').fill("2"); + await page.locator('input[name="y"]').fill("three"); + await page.locator('input[name="y"]').press("Enter"); + await expect(page.getByTestId("calculator-answer")).toContainText( + "(invalid input)", + ); + if (options.js) { + await expect(page.locator('input[name="x"]')).toHaveValue("2"); + await expect(page.locator('input[name="y"]')).toHaveValue("three"); + } else { + await expect(page.locator('input[name="x"]')).toHaveValue(""); + await expect(page.locator('input[name="y"]')).toHaveValue(""); + } +} + +test("client hmr @dev", async ({ page }) => { + await page.goto("/"); + await page.getByText("[hydrated: 1]").click(); + // client +1 + await page.getByText("Client counter: 0").click(); + await page + .getByTestId("client-counter") + .getByRole("button", { name: "+" }) + .click(); + await page.getByText("Client counter: 1").click(); + // edit client + using file = createEditor("src/app/client.tsx"); + file.edit((s) => s.replace("Client counter", "Client [EDIT] counter")); + await page.getByText("Client [EDIT] counter: 1").click(); +}); + +test("server hmr @dev", async ({ page }) => { + await page.goto("/"); + await page.getByText("[hydrated: 1]").click(); + + // server +1 + await page.getByText("Server counter: 0").click(); + await page + .getByTestId("server-counter") + .getByRole("button", { name: "+" }) + .click(); + await page.getByText("Server counter: 1").click(); + + // client +1 + await page.getByText("Client counter: 0").click(); + await page + .getByTestId("client-counter") + .getByRole("button", { name: "+" }) + .click(); + await page.getByText("Client counter: 1").click(); + + // edit server + using file = createEditor("src/app/index.tsx"); + file.edit((s) => s.replace("Server counter", "Server [EDIT] counter")); + await page.getByText("Server [EDIT] counter: 1").click(); + await page.getByText("Client counter: 1").click(); + + // server -1 + await page + .getByTestId("server-counter") + .getByRole("button", { name: "-" }) + .click(); + await page.getByText("Server [EDIT] counter: 0").click(); +}); diff --git a/example-react/e2e/helper.ts b/example-react/e2e/helper.ts new file mode 100644 index 0000000..cc33429 --- /dev/null +++ b/example-react/e2e/helper.ts @@ -0,0 +1,15 @@ +import fs from "node:fs"; + +export function createEditor(filepath: string) { + let init = fs.readFileSync(filepath, "utf-8"); + let data = init; + return { + edit(editFn: (data: string) => string) { + data = editFn(data); + fs.writeFileSync(filepath, data); + }, + [Symbol.dispose]() { + fs.writeFileSync(filepath, init); + }, + }; +} diff --git a/example-react/package.json b/example-react/package.json index 802b8dd..a43f241 100644 --- a/example-react/package.json +++ b/example-react/package.json @@ -4,11 +4,15 @@ "description": "", "main": "index.js", "scripts": { - "dev": "vite" + "dev": "vite", + "preview": "vite preview", + "test-e2e": "playwright test", + "test-e2e-preview": "E2E_PREVIEW=1 playwright test" }, "keywords": [], "author": "remorses ", "dependencies": { + "@playwright/test": "^1.50.1", "react": "19.0.0", "react-dom": "19.0.0", "spiceflow": "workspace:*", diff --git a/example-react/playwright.config.ts b/example-react/playwright.config.ts new file mode 100644 index 0000000..3dab541 --- /dev/null +++ b/example-react/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = Number(process.env.E2E_PORT || 6174); +const isPreview = Boolean(process.env.E2E_PREVIEW); +const command = isPreview + ? `pnpm preview --port ${port} --strict-port` + : `pnpm dev --port ${port} --strict-port`; + +export default defineConfig({ + testDir: "e2e", + use: { + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + viewport: null, + deviceScaleFactor: undefined, + }, + }, + ], + webServer: { + command, + port, + }, + grepInvert: isPreview ? /@dev/ : /@build/, + forbidOnly: !!process.env["CI"], + retries: process.env["CI"] ? 2 : 0, + reporter: "list", +}); diff --git a/example-react/src/app/client.tsx b/example-react/src/app/client.tsx index 5f63437..cbc9732 100644 --- a/example-react/src/app/client.tsx +++ b/example-react/src/app/client.tsx @@ -60,3 +60,7 @@ export function Calculator() { ); } + + + + diff --git a/example-react/src/app/index.tsx b/example-react/src/app/index.tsx index 268655d..648f2a0 100644 --- a/example-react/src/app/index.tsx +++ b/example-react/src/app/index.tsx @@ -12,7 +12,7 @@ export async function IndexPage() { data-testid="server-counter" style={{ padding: "0.5rem" }} > -
Server counter: {getCounter()}
+
Server [EDIT] counter: {getCounter()}
+ ); } - - - diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index ffeba74..f73cb49 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -10,14 +10,7 @@ const app = new Spiceflow() const url = new URL(request.url); return ; }) - .page("/:id", async ({ request, params }) => { - const url = new URL(request.url); - return ( - - - - ); - }) + .get("/hello", () => "Hello, World!") .page("/redirect", async () => { throw new Response("Redirect", { @@ -27,6 +20,14 @@ const app = new Spiceflow() }, }); }) + .page("/:id", async ({ request, params }) => { + const url = new URL(request.url); + return ( + + + + ); + }) .page("/redirect-in-rsc", async () => { return ; }) From c1c3445b3e194f2a2f7237edad1008b4b1a5d153 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 06:48:03 +0100 Subject: [PATCH 033/226] layouts seem to work too --- example-react/src/main.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index f73cb49..dba4af3 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -20,12 +20,25 @@ const app = new Spiceflow() }, }); }) + .layout("/page/*", async ({ request, children }) => { + return ( +
+

/page layout

+ {children} +
+ ); + }) + .page("/page", async ({ request }) => { + const url = new URL(request.url); + return ; + }) .page("/:id", async ({ request, params }) => { const url = new URL(request.url); return ( - +
+

:id page

- +
); }) .page("/redirect-in-rsc", async () => { From 6bf8bf1d1f09d7e391f5459c1be2473afb493457 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 06:48:21 +0100 Subject: [PATCH 034/226] more layouts --- example-react/src/main.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index dba4af3..296d5d8 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -23,7 +23,15 @@ const app = new Spiceflow() .layout("/page/*", async ({ request, children }) => { return (
-

/page layout

+

/page layout 1

+ {children} +
+ ); + }) + .layout("/page/*", async ({ request, children }) => { + return ( +
+

/page layout 2

{children}
); From d70da5e997b386fa02c986d88ce0ab666d2b4110 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 06:49:20 +0100 Subject: [PATCH 035/226] tried links too --- example-react/src/main.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 296d5d8..1effc5c 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -38,7 +38,21 @@ const app = new Spiceflow() }) .page("/page", async ({ request }) => { const url = new URL(request.url); - return ; + return ( +
+ ); + }) + .page("/page/1", async ({ request }) => { + const url = new URL(request.url); + return ( +
+ /page + +
+ ); }) .page("/:id", async ({ request, params }) => { const url = new URL(request.url); From 75f3443d7ef11c89549313ef24e181c304f0b09c Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 10:39:20 +0100 Subject: [PATCH 036/226] tailwindcss works --- example-react/package.json | 4 +- example-react/src/main.tsx | 1 + example-react/src/styles.css | 1 + example-react/vite.config.ts | 3 + pnpm-lock.yaml | 473 +++++++++++++++++++++++++++-------- 5 files changed, 382 insertions(+), 100 deletions(-) create mode 100644 example-react/src/styles.css diff --git a/example-react/package.json b/example-react/package.json index a2a8a6f..8dd26f9 100644 --- a/example-react/package.json +++ b/example-react/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "", "main": "index.js", + "type": "module", "scripts": { "dev": "vite", "preview": "vite preview", @@ -13,10 +14,11 @@ "author": "remorses ", "dependencies": { "@playwright/test": "^1.50.1", + "@tailwindcss/vite": "^4.0.5", "react": "19.0.0", "react-dom": "19.0.0", "spiceflow": "workspace:*", - + "tailwindcss": "^4.0.5", "vite": "^6.1.0" }, "license": "ISC", diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 1effc5c..c4bca19 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -1,6 +1,7 @@ import { Spiceflow } from "spiceflow"; import { IndexPage } from "./app/index"; import { Layout } from "./app/layout"; +import './styles.css' const app = new Spiceflow() .layout("/*", async ({ children, request }) => { diff --git a/example-react/src/styles.css b/example-react/src/styles.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/example-react/src/styles.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/example-react/vite.config.ts b/example-react/vite.config.ts index 0205758..4fa4a4d 100644 --- a/example-react/vite.config.ts +++ b/example-react/vite.config.ts @@ -1,11 +1,14 @@ import { defineConfig } from 'vite' import { spiceflowPlugin } from 'spiceflow/dist/vite' +import tailwindcss from '@tailwindcss/vite' + import inspect from 'vite-plugin-inspect' export default defineConfig({ clearScreen: false, plugins: [ // inspect(), + tailwindcss(), spiceflowPlugin({ entry: './src/main.tsx', }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b8cf7f..6010f2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,16 +32,19 @@ importers: version: 5.7.3 vite: specifier: ^6.1.0 - version: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + version: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) vitest: specifier: ^3.0.4 - version: 3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + version: 3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) example-react: dependencies: '@playwright/test': specifier: ^1.50.1 version: 1.50.1 + '@tailwindcss/vite': + specifier: ^4.0.5 + version: 4.0.5(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) react: specifier: 19.0.0 version: 19.0.0 @@ -51,13 +54,16 @@ importers: spiceflow: specifier: workspace:* version: link:../spiceflow + tailwindcss: + specifier: ^4.0.5 + version: 4.0.5 vite: specifier: ^6.1.0 - version: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + version: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) devDependencies: vite-plugin-inspect: specifier: ^10.1.1 - version: 10.1.1(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + version: 10.1.1(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) openapi-schema-diff: dependencies: @@ -76,10 +82,10 @@ importers: version: 10.1.3 eslint: specifier: ^9.19.0 - version: 9.19.0(jiti@1.21.7) + version: 9.19.0(jiti@2.4.2) neostandard: specifier: ^0.12.0 - version: 0.12.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) + version: 0.12.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) typescript: specifier: ^5.7.3 version: 5.7.3 @@ -164,7 +170,7 @@ importers: version: 0.0.0 '@jacob-ebey/vite-react-server-dom': specifier: ^0.0.12 - version: 0.0.12(@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(rollup@4.34.6)(vite@6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + version: 0.0.12(@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(rollup@4.34.6)(vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) '@modelcontextprotocol/sdk': specifier: ^1.0.4 version: 1.0.4 @@ -173,7 +179,7 @@ importers: version: 0.34.15 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.3.4(vite@6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + version: 4.3.4(vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) ajv: specifier: ^8.17.1 version: 8.17.1 @@ -215,7 +221,7 @@ importers: version: 0.0.11(rollup@4.34.6) vite: specifier: ^6.1.0 - version: 6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + version: 6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) zod: specifier: ^3.24.1 version: 3.24.1 @@ -307,7 +313,7 @@ importers: version: 4.20250129.0 '@remix-run/dev': specifier: ^2.15.3 - version: 2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.0)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0)) + version: 2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0)) '@types/react': specifier: ^19.0.8 version: 19.0.8 @@ -334,10 +340,10 @@ importers: version: 5.7.3 vite: specifier: ^6.1.0 - version: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + version: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^4.2.1 - version: 4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + version: 4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) wrangler: specifier: ^3.48.0 version: 3.107.2(@cloudflare/workers-types@4.20250129.0) @@ -1936,11 +1942,89 @@ packages: peerDependencies: eslint: '>=8.40.0' + '@tailwindcss/node@4.0.5': + resolution: {integrity: sha512-ffTz4DX1cgr4XPuqjhm32YV6Lyx58R1CxAAnSFTamg6wXwfk3oWdb6exgAbGesPzvUgicTO0gwUdQGSsg4nNog==} + + '@tailwindcss/oxide-android-arm64@4.0.5': + resolution: {integrity: sha512-kK/ik8aIAKWDIEYDZGUCJcnU1qU5sPoMBlVzPvtsUqiV6cSHcnVRUdkcLwKqTeUowzZtjjRiamELLd9Gb0x5BQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.0.5': + resolution: {integrity: sha512-vkbXFv0FfAEbrSa5NBjFEE+xi06ha7mxuxjY8LRn7d7/tBGrAZOEJnnsEbB6M1+x2pGRTjjei0XyTIXdVCglJA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.0.5': + resolution: {integrity: sha512-PedA64rHBXEa4e6abBWE4Yj4gHulfPb5T+rBNnX+WGkjjge5Txa2oS99TLmJ5BPDkXXqz/Ba7oweWIDDG7i5NQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.0.5': + resolution: {integrity: sha512-silz3nuZdEYDfic3v/ooVUQChj9hbxDSee43GCQNwr/iD9L4K/JsZtoNqr0w69pUkvWcKINOGOG0r7WqUqkAeg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.5': + resolution: {integrity: sha512-ElneG75XS64B9I2G83A/Hc7EtNVOD5xahs7avq0aeW7mEX6CtMc8m8RCXMn3jGhz8enFE52l6QU0wO7iVkEtXQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.0.5': + resolution: {integrity: sha512-8yoXpWTeIFaByUaKy2qRAppznLVaDHP9xYCAbS3FG7+uUwHi8CHE4TcomM7eyamo0U7dbUIDgKMGoAX5s2iVrA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.0.5': + resolution: {integrity: sha512-BDlVSiiJ08GRz9KKnXgaPFs2fkukPF3pym6uK3oWEKW45jKlVGgybLqulcV5nLEqREOuyq4Rn4vnZss4/bbQ/g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.0.5': + resolution: {integrity: sha512-DYgieNDRkTy69bWPgdsc47nAXa74P63P/RetUwYM9vYj5USyOfHCEcqIthkCuYw3dXKBhjgwe697TmL2g2jpAw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.0.5': + resolution: {integrity: sha512-z2RzUvOQl0ZqrZqmCFP53tJbBXQ3UmLD/E6J7+q0e+4VaFnXCcIYTfQbHgI8f3fash+q6gK80Ko/ywEQ+bvv6Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-win32-arm64-msvc@4.0.5': + resolution: {integrity: sha512-ho1dJ4o5Q8nAOxdMkbfBu5aSqI+/bzQ0jEeHcXaEdEJzf2fSWs3HY7bIKtE6vQS8c4SmSBvls7IhGPuJxNg+2Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.0.5': + resolution: {integrity: sha512-yjw6JhtyDXr+G0aZrj3L3NlEV7CobSqOdPyfo6G3d91WEZ5b8PyGm86IAreX08Jp9DChGXEd53gWysVpWCTs+w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.0.5': + resolution: {integrity: sha512-iWGyOCu0TuzvCBisWbGv2K9+7QCfE0ztgtrZOvb9iF7V7ChVkD15Obe3HevZrhjngAc34jDA+OMSuSvkrpTy4A==} + engines: {node: '>= 10'} + '@tailwindcss/typography@0.5.16': resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==} peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tailwindcss/vite@4.0.5': + resolution: {integrity: sha512-/i4hjLTUYVjUG0MTUviQP3HR/hzwyzv8Sq4sz2pnsNuf+FIjjhJB0vcnIMH1KIX0k8ozD6CBv2Dl76tlm/JFFA==} + peerDependencies: + vite: ^5.2.0 || ^6 + '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -2701,6 +2785,11 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -3667,6 +3756,10 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + js-base64@3.7.7: resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} @@ -3739,6 +3832,70 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-darwin-arm64@1.29.1: + resolution: {integrity: sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.29.1: + resolution: {integrity: sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.29.1: + resolution: {integrity: sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.29.1: + resolution: {integrity: sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.29.1: + resolution: {integrity: sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.29.1: + resolution: {integrity: sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.29.1: + resolution: {integrity: sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.29.1: + resolution: {integrity: sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.29.1: + resolution: {integrity: sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.29.1: + resolution: {integrity: sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.29.1: + resolution: {integrity: sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -5221,6 +5378,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + tailwindcss@4.0.5: + resolution: {integrity: sha512-DZZIKX3tA23LGTjHdnwlJOTxfICD1cPeykLLsYF1RQBI9QsCR3i0szohJfJDVjr6aNRAIio5WVO7FGB77fRHwg==} + tapable@2.2.1: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} @@ -6763,9 +6923,9 @@ snapshots: '@esbuild/win32-x64@0.24.2': optional: true - '@eslint-community/eslint-utils@4.4.1(eslint@9.19.0(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.4.1(eslint@9.19.0(jiti@2.4.2))': dependencies: - eslint: 9.19.0(jiti@1.21.7) + eslint: 9.19.0(jiti@2.4.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -6855,13 +7015,13 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - '@jacob-ebey/vite-react-server-dom@0.0.12(@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(rollup@4.34.6)(vite@6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + '@jacob-ebey/vite-react-server-dom@0.0.12(@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(rollup@4.34.6)(vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': dependencies: '@jacob-ebey/react-server-dom-vite': 19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@mjackson/node-fetch-server': 0.5.0 - '@vitejs/plugin-react': 4.3.4(vite@6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + '@vitejs/plugin-react': 4.3.4(vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) unplugin-rsc: 0.0.11(rollup@4.34.6) - vite: 6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - rollup - supports-color @@ -7080,7 +7240,7 @@ snapshots: optionalDependencies: typescript: 5.7.3 - '@remix-run/dev@2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.0)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0))': + '@remix-run/dev@2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0))': dependencies: '@babel/core': 7.26.7 '@babel/generator': 7.26.5 @@ -7097,7 +7257,7 @@ snapshots: '@remix-run/router': 1.22.0 '@remix-run/server-runtime': 2.15.3(typescript@5.7.3) '@types/mdx': 2.0.13 - '@vanilla-extract/integration': 6.5.0(@types/node@22.13.0)(terser@5.31.6) + '@vanilla-extract/integration': 6.5.0(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6) arg: 5.0.2 cacache: 17.1.4 chalk: 4.1.2 @@ -7136,11 +7296,11 @@ snapshots: tar-fs: 2.1.2 tsconfig-paths: 4.2.0 valibot: 0.41.0(typescript@5.7.3) - vite-node: 1.6.0(@types/node@22.13.0)(terser@5.31.6) + vite-node: 1.6.0(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6) ws: 7.5.10 optionalDependencies: typescript: 5.7.3 - vite: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) wrangler: 3.107.2(@cloudflare/workers-types@4.20250129.0) transitivePeerDependencies: - '@types/node' @@ -7364,10 +7524,10 @@ snapshots: hast-util-to-string: 2.0.0 unist-util-visit: 4.1.2 - '@stylistic/eslint-plugin@2.11.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3)': + '@stylistic/eslint-plugin@2.11.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)': dependencies: - '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) - eslint: 9.19.0(jiti@1.21.7) + '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + eslint: 9.19.0(jiti@2.4.2) eslint-visitor-keys: 4.2.0 espree: 10.3.0 estraverse: 5.3.0 @@ -7376,6 +7536,59 @@ snapshots: - supports-color - typescript + '@tailwindcss/node@4.0.5': + dependencies: + enhanced-resolve: 5.18.0 + jiti: 2.4.2 + tailwindcss: 4.0.5 + + '@tailwindcss/oxide-android-arm64@4.0.5': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.0.5': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.0.5': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.0.5': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.5': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.0.5': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.0.5': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.0.5': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.0.5': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.0.5': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.0.5': + optional: true + + '@tailwindcss/oxide@4.0.5': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.0.5 + '@tailwindcss/oxide-darwin-arm64': 4.0.5 + '@tailwindcss/oxide-darwin-x64': 4.0.5 + '@tailwindcss/oxide-freebsd-x64': 4.0.5 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.0.5 + '@tailwindcss/oxide-linux-arm64-gnu': 4.0.5 + '@tailwindcss/oxide-linux-arm64-musl': 4.0.5 + '@tailwindcss/oxide-linux-x64-gnu': 4.0.5 + '@tailwindcss/oxide-linux-x64-musl': 4.0.5 + '@tailwindcss/oxide-win32-arm64-msvc': 4.0.5 + '@tailwindcss/oxide-win32-x64-msvc': 4.0.5 + '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3)))': dependencies: lodash.castarray: 4.4.0 @@ -7384,6 +7597,14 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3)) + '@tailwindcss/vite@4.0.5(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + dependencies: + '@tailwindcss/node': 4.0.5 + '@tailwindcss/oxide': 4.0.5 + lightningcss: 1.29.1 + tailwindcss: 4.0.5 + vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -7491,15 +7712,15 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3))(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3)': + '@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) + '@typescript-eslint/parser': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) '@typescript-eslint/scope-manager': 8.19.0 - '@typescript-eslint/type-utils': 8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) - '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) + '@typescript-eslint/type-utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) '@typescript-eslint/visitor-keys': 8.19.0 - eslint: 9.19.0(jiti@1.21.7) + eslint: 9.19.0(jiti@2.4.2) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -7508,14 +7729,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3)': + '@typescript-eslint/parser@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)': dependencies: '@typescript-eslint/scope-manager': 8.19.0 '@typescript-eslint/types': 8.19.0 '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.7.3) '@typescript-eslint/visitor-keys': 8.19.0 debug: 4.4.0 - eslint: 9.19.0(jiti@1.21.7) + eslint: 9.19.0(jiti@2.4.2) typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -7525,12 +7746,12 @@ snapshots: '@typescript-eslint/types': 8.19.0 '@typescript-eslint/visitor-keys': 8.19.0 - '@typescript-eslint/type-utils@8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3)': + '@typescript-eslint/type-utils@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)': dependencies: '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.7.3) - '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) + '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) debug: 4.4.0 - eslint: 9.19.0(jiti@1.21.7) + eslint: 9.19.0(jiti@2.4.2) ts-api-utils: 1.4.3(typescript@5.7.3) typescript: 5.7.3 transitivePeerDependencies: @@ -7552,13 +7773,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3)': + '@typescript-eslint/utils@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@2.4.2)) '@typescript-eslint/scope-manager': 8.19.0 '@typescript-eslint/types': 8.19.0 '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.7.3) - eslint: 9.19.0(jiti@1.21.7) + eslint: 9.19.0(jiti@2.4.2) typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -7593,7 +7814,7 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros - '@vanilla-extract/integration@6.5.0(@types/node@22.13.0)(terser@5.31.6)': + '@vanilla-extract/integration@6.5.0(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6)': dependencies: '@babel/core': 7.26.7 '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.7) @@ -7606,8 +7827,8 @@ snapshots: lodash: 4.17.21 mlly: 1.7.4 outdent: 0.8.0 - vite: 5.4.14(@types/node@22.13.0)(terser@5.31.6) - vite-node: 1.6.0(@types/node@22.13.0)(terser@5.31.6) + vite: 5.4.14(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6) + vite-node: 1.6.0(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -7622,14 +7843,14 @@ snapshots: '@vanilla-extract/private@1.0.6': {} - '@vitejs/plugin-react@4.3.4(vite@6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + '@vitejs/plugin-react@4.3.4(vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': dependencies: '@babel/core': 7.26.7 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.7) '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.7) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - supports-color @@ -7640,13 +7861,13 @@ snapshots: chai: 5.1.2 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.4(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + '@vitest/mocker@3.0.4(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': dependencies: '@vitest/spy': 3.0.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) '@vitest/pretty-format@3.0.4': dependencies: @@ -8214,6 +8435,8 @@ snapshots: detect-indent@6.1.0: {} + detect-libc@1.0.3: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -8549,9 +8772,9 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-compat-utils@0.5.1(eslint@9.19.0(jiti@1.21.7)): + eslint-compat-utils@0.5.1(eslint@9.19.0(jiti@2.4.2)): dependencies: - eslint: 9.19.0(jiti@1.21.7) + eslint: 9.19.0(jiti@2.4.2) semver: 7.7.0 eslint-import-resolver-node@0.3.9: @@ -8562,38 +8785,38 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.7.0(eslint-plugin-import-x@4.6.1(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3))(eslint@9.19.0(jiti@1.21.7)): + eslint-import-resolver-typescript@3.7.0(eslint-plugin-import-x@4.6.1(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 enhanced-resolve: 5.18.0 - eslint: 9.19.0(jiti@1.21.7) + eslint: 9.19.0(jiti@2.4.2) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.3.0 is-glob: 4.0.3 stable-hash: 0.0.4 optionalDependencies: - eslint-plugin-import-x: 4.6.1(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) + eslint-plugin-import-x: 4.6.1(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) transitivePeerDependencies: - supports-color - eslint-plugin-es-x@7.8.0(eslint@9.19.0(jiti@1.21.7)): + eslint-plugin-es-x@7.8.0(eslint@9.19.0(jiti@2.4.2)): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.12.1 - eslint: 9.19.0(jiti@1.21.7) - eslint-compat-utils: 0.5.1(eslint@9.19.0(jiti@1.21.7)) + eslint: 9.19.0(jiti@2.4.2) + eslint-compat-utils: 0.5.1(eslint@9.19.0(jiti@2.4.2)) - eslint-plugin-import-x@4.6.1(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3): + eslint-plugin-import-x@4.6.1(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3): dependencies: '@types/doctrine': 0.0.9 '@typescript-eslint/scope-manager': 8.19.0 - '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) + '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) debug: 4.4.0 doctrine: 3.0.0 enhanced-resolve: 5.18.0 - eslint: 9.19.0(jiti@1.21.7) + eslint: 9.19.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 get-tsconfig: 4.8.1 is-glob: 4.0.3 @@ -8605,24 +8828,24 @@ snapshots: - supports-color - typescript - eslint-plugin-n@17.15.1(eslint@9.19.0(jiti@1.21.7)): + eslint-plugin-n@17.15.1(eslint@9.19.0(jiti@2.4.2)): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@2.4.2)) enhanced-resolve: 5.18.0 - eslint: 9.19.0(jiti@1.21.7) - eslint-plugin-es-x: 7.8.0(eslint@9.19.0(jiti@1.21.7)) + eslint: 9.19.0(jiti@2.4.2) + eslint-plugin-es-x: 7.8.0(eslint@9.19.0(jiti@2.4.2)) get-tsconfig: 4.8.1 globals: 15.14.0 ignore: 5.3.2 minimatch: 9.0.5 semver: 7.6.3 - eslint-plugin-promise@7.2.1(eslint@9.19.0(jiti@1.21.7)): + eslint-plugin-promise@7.2.1(eslint@9.19.0(jiti@2.4.2)): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@1.21.7)) - eslint: 9.19.0(jiti@1.21.7) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@2.4.2)) + eslint: 9.19.0(jiti@2.4.2) - eslint-plugin-react@7.37.3(eslint@9.19.0(jiti@1.21.7)): + eslint-plugin-react@7.37.3(eslint@9.19.0(jiti@2.4.2)): dependencies: array-includes: 3.1.8 array.prototype.findlast: 1.2.5 @@ -8630,7 +8853,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.19.0(jiti@1.21.7) + eslint: 9.19.0(jiti@2.4.2) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -8653,9 +8876,9 @@ snapshots: eslint-visitor-keys@4.2.0: {} - eslint@9.19.0(jiti@1.21.7): + eslint@9.19.0(jiti@2.4.2): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.19.1 '@eslint/core': 0.10.0 @@ -8690,7 +8913,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 1.21.7 + jiti: 2.4.2 transitivePeerDependencies: - supports-color @@ -9515,6 +9738,8 @@ snapshots: jiti@1.21.7: {} + jiti@2.4.2: {} + js-base64@3.7.7: {} js-tokens@4.0.0: {} @@ -9582,6 +9807,51 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-darwin-arm64@1.29.1: + optional: true + + lightningcss-darwin-x64@1.29.1: + optional: true + + lightningcss-freebsd-x64@1.29.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.29.1: + optional: true + + lightningcss-linux-arm64-gnu@1.29.1: + optional: true + + lightningcss-linux-arm64-musl@1.29.1: + optional: true + + lightningcss-linux-x64-gnu@1.29.1: + optional: true + + lightningcss-linux-x64-musl@1.29.1: + optional: true + + lightningcss-win32-arm64-msvc@1.29.1: + optional: true + + lightningcss-win32-x64-msvc@1.29.1: + optional: true + + lightningcss@1.29.1: + dependencies: + detect-libc: 1.0.3 + optionalDependencies: + lightningcss-darwin-arm64: 1.29.1 + lightningcss-darwin-x64: 1.29.1 + lightningcss-freebsd-x64: 1.29.1 + lightningcss-linux-arm-gnueabihf: 1.29.1 + lightningcss-linux-arm64-gnu: 1.29.1 + lightningcss-linux-arm64-musl: 1.29.1 + lightningcss-linux-x64-gnu: 1.29.1 + lightningcss-linux-x64-musl: 1.29.1 + lightningcss-win32-arm64-msvc: 1.29.1 + lightningcss-win32-x64-msvc: 1.29.1 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -10427,20 +10697,20 @@ snapshots: negotiator@0.6.3: {} - neostandard@0.12.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3): + neostandard@0.12.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3): dependencies: '@humanwhocodes/gitignore-to-minimatch': 1.0.2 - '@stylistic/eslint-plugin': 2.11.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) - eslint: 9.19.0(jiti@1.21.7) - eslint-import-resolver-typescript: 3.7.0(eslint-plugin-import-x@4.6.1(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3))(eslint@9.19.0(jiti@1.21.7)) - eslint-plugin-import-x: 4.6.1(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) - eslint-plugin-n: 17.15.1(eslint@9.19.0(jiti@1.21.7)) - eslint-plugin-promise: 7.2.1(eslint@9.19.0(jiti@1.21.7)) - eslint-plugin-react: 7.37.3(eslint@9.19.0(jiti@1.21.7)) + '@stylistic/eslint-plugin': 2.11.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + eslint: 9.19.0(jiti@2.4.2) + eslint-import-resolver-typescript: 3.7.0(eslint-plugin-import-x@4.6.1(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2)) + eslint-plugin-import-x: 4.6.1(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + eslint-plugin-n: 17.15.1(eslint@9.19.0(jiti@2.4.2)) + eslint-plugin-promise: 7.2.1(eslint@9.19.0(jiti@2.4.2)) + eslint-plugin-react: 7.37.3(eslint@9.19.0(jiti@2.4.2)) find-up: 5.0.0 globals: 15.14.0 peowly: 1.3.2 - typescript-eslint: 8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) + typescript-eslint: 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) transitivePeerDependencies: - eslint-plugin-import - supports-color @@ -11669,6 +11939,8 @@ snapshots: transitivePeerDependencies: - ts-node + tailwindcss@4.0.5: {} + tapable@2.2.1: {} tar-fs@2.1.2: @@ -11864,12 +12136,12 @@ snapshots: possible-typed-array-names: 1.0.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3): + typescript-eslint@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3))(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) - '@typescript-eslint/parser': 8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) - '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@1.21.7))(typescript@5.7.3) - eslint: 9.19.0(jiti@1.21.7) + '@typescript-eslint/eslint-plugin': 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + '@typescript-eslint/parser': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + eslint: 9.19.0(jiti@2.4.2) typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -12111,13 +12383,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-node@1.6.0(@types/node@22.13.0)(terser@5.31.6): + vite-node@1.6.0(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6): dependencies: cac: 6.7.14 debug: 4.4.0 pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.14(@types/node@22.13.0)(terser@5.31.6) + vite: 5.4.14(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6) transitivePeerDependencies: - '@types/node' - less @@ -12129,13 +12401,13 @@ snapshots: - supports-color - terser - vite-node@3.0.4(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + vite-node@3.0.4(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 2.0.2 - vite: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -12150,29 +12422,29 @@ snapshots: - tsx - yaml - vite-plugin-inspect@10.1.1(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)): + vite-plugin-inspect@10.1.1(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)): dependencies: debug: 4.4.0 error-stack-parser-es: 1.0.5 open: 10.1.0 picocolors: 1.1.1 sirv: 3.0.0 - vite: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - supports-color - vite-tsconfig-paths@4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)): + vite-tsconfig-paths@4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)): dependencies: debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.4(typescript@5.7.3) optionalDependencies: - vite: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - supports-color - typescript - vite@5.4.14(@types/node@22.13.0)(terser@5.31.6): + vite@5.4.14(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6): dependencies: esbuild: 0.21.5 postcss: 8.5.1 @@ -12180,9 +12452,10 @@ snapshots: optionalDependencies: '@types/node': 22.13.0 fsevents: 2.3.3 + lightningcss: 1.29.1 terser: 5.31.6 - vite@6.1.0(@types/node@22.12.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: esbuild: 0.24.2 postcss: 8.5.1 @@ -12190,12 +12463,13 @@ snapshots: optionalDependencies: '@types/node': 22.12.0 fsevents: 2.3.3 - jiti: 1.21.7 + jiti: 2.4.2 + lightningcss: 1.29.1 terser: 5.31.6 tsx: 4.19.2 yaml: 2.7.0 - vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: esbuild: 0.24.2 postcss: 8.5.1 @@ -12203,15 +12477,16 @@ snapshots: optionalDependencies: '@types/node': 22.13.0 fsevents: 2.3.3 - jiti: 1.21.7 + jiti: 2.4.2 + lightningcss: 1.29.1 terser: 5.31.6 tsx: 4.19.2 yaml: 2.7.0 - vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: '@vitest/expect': 3.0.4 - '@vitest/mocker': 3.0.4(vite@6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + '@vitest/mocker': 3.0.4(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) '@vitest/pretty-format': 3.0.4 '@vitest/runner': 3.0.4 '@vitest/snapshot': 3.0.4 @@ -12227,8 +12502,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.1.0(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) - vite-node: 3.0.4(@types/node@22.13.0)(jiti@1.21.7)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite-node: 3.0.4(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 From df79c2450e323400e6f7269397653df18fc16d27 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 12:33:48 +0100 Subject: [PATCH 037/226] fix build time css, debugging missing server references --- example-react/package.json | 1 + example-react/server.js | 12 ++ example-react/src/app/button.tsx | 17 ++ example-react/src/app/client.css | 5 + example-react/src/app/client.tsx | 1 + example-react/src/app/index.tsx | 11 +- example-react/src/main.tsx | 3 +- example-react/vite.config.ts | 26 +-- openapi-schema-diff | 2 +- pnpm-lock.yaml | 167 ++++++++---------- sdk/package.json | 2 +- spiceflow/package.json | 2 +- spiceflow/src/react/components.tsx | 1 - spiceflow/src/react/css.tsx | 28 +++ spiceflow/src/react/entry.rsc.tsx | 7 +- spiceflow/src/react/entry.ssr.tsx | 11 +- spiceflow/src/react/utils/client-reference.ts | 6 +- spiceflow/src/spiceflow.ts | 1 - spiceflow/src/vite.tsx | 82 ++++++--- 19 files changed, 234 insertions(+), 151 deletions(-) create mode 100644 example-react/server.js create mode 100644 example-react/src/app/button.tsx create mode 100644 example-react/src/app/client.css create mode 100644 spiceflow/src/react/css.tsx diff --git a/example-react/package.json b/example-react/package.json index 8dd26f9..b234960 100644 --- a/example-react/package.json +++ b/example-react/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "dev": "vite", + "build": "vite build --app", "preview": "vite preview", "test-e2e": "playwright test", "test-e2e-preview": "E2E_PREVIEW=1 playwright test" diff --git a/example-react/server.js b/example-react/server.js new file mode 100644 index 0000000..f5a7ce4 --- /dev/null +++ b/example-react/server.js @@ -0,0 +1,12 @@ +import handler from './dist/ssr/index.js' + +import http from 'node:http' + +const server = http.createServer((req, res) => { + handler(req, res) +}) + +const port = process.env.PORT || 3000 +server.listen(port, () => { + console.log(`Server running at http://localhost:${port}`) +}) diff --git a/example-react/src/app/button.tsx b/example-react/src/app/button.tsx new file mode 100644 index 0000000..db7af13 --- /dev/null +++ b/example-react/src/app/button.tsx @@ -0,0 +1,17 @@ +"use client"; + +import React from "react"; + +type ButtonProps = React.ButtonHTMLAttributes; + +export function Button(props: ButtonProps) { + const className = `px-2 py-1 rounded-md transition-colors ${props.className || ""}`; + + return ( + - + + diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index c4bca19..f94b75c 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -3,13 +3,14 @@ import { IndexPage } from "./app/index"; import { Layout } from "./app/layout"; import './styles.css' + const app = new Spiceflow() .layout("/*", async ({ children, request }) => { return {children}; }) .page("/", async ({ request }) => { const url = new URL(request.url); - return ; + return ; }) .get("/hello", () => "Hello, World!") diff --git a/example-react/vite.config.ts b/example-react/vite.config.ts index 4fa4a4d..fa71a7b 100644 --- a/example-react/vite.config.ts +++ b/example-react/vite.config.ts @@ -1,16 +1,16 @@ -import { defineConfig } from 'vite' -import { spiceflowPlugin } from 'spiceflow/dist/vite' -import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from "vite"; +import { spiceflowPlugin } from "spiceflow/dist/vite"; +import tailwindcss from "@tailwindcss/vite"; -import inspect from 'vite-plugin-inspect' +import inspect from "vite-plugin-inspect"; export default defineConfig({ - clearScreen: false, - plugins: [ - // inspect(), - tailwindcss(), - spiceflowPlugin({ - entry: './src/main.tsx', - }), - ], -}) + clearScreen: false, + plugins: [ + // inspect(), + tailwindcss(), + spiceflowPlugin({ + entry: "./src/main.tsx", + }), + ], +}); diff --git a/openapi-schema-diff b/openapi-schema-diff index e19fb8d..1fabd44 160000 --- a/openapi-schema-diff +++ b/openapi-schema-diff @@ -1 +1 @@ -Subproject commit e19fb8d331e11235801d12d2168b93d495e086d2 +Subproject commit 1fabd44001a2f2c1464342c009bf60e42574fe64 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6010f2a..78bd58c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,10 +32,10 @@ importers: version: 5.7.3 vite: specifier: ^6.1.0 - version: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + version: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) vitest: specifier: ^3.0.4 - version: 3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + version: 3.0.4(@types/debug@4.1.12)(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) example-react: dependencies: @@ -44,7 +44,7 @@ importers: version: 1.50.1 '@tailwindcss/vite': specifier: ^4.0.5 - version: 4.0.5(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + version: 4.0.5(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) react: specifier: 19.0.0 version: 19.0.0 @@ -59,11 +59,11 @@ importers: version: 4.0.5 vite: specifier: ^6.1.0 - version: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + version: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) devDependencies: vite-plugin-inspect: specifier: ^10.1.1 - version: 10.1.1(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + version: 10.1.1(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) openapi-schema-diff: dependencies: @@ -75,8 +75,8 @@ importers: version: 7.6.3 devDependencies: '@types/node': - specifier: ^22.12.0 - version: 22.12.0 + specifier: ^22.13.1 + version: 22.13.1 c8: specifier: ^10.1.3 version: 10.1.3 @@ -157,8 +157,8 @@ importers: specifier: ^4.0.9 version: 4.0.9 '@types/node': - specifier: 22.12.0 - version: 22.12.0 + specifier: 22.13.1 + version: 22.13.1 js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -170,7 +170,7 @@ importers: version: 0.0.0 '@jacob-ebey/vite-react-server-dom': specifier: ^0.0.12 - version: 0.0.12(@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(rollup@4.34.6)(vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + version: 0.0.12(@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(rollup@4.34.6)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) '@modelcontextprotocol/sdk': specifier: ^1.0.4 version: 1.0.4 @@ -179,7 +179,7 @@ importers: version: 0.34.15 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.3.4(vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + version: 4.3.4(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) ajv: specifier: ^8.17.1 version: 8.17.1 @@ -221,7 +221,7 @@ importers: version: 0.0.11(rollup@4.34.6) vite: specifier: ^6.1.0 - version: 6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + version: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) zod: specifier: ^3.24.1 version: 3.24.1 @@ -233,8 +233,8 @@ importers: specifier: ^4.5.9 version: 4.5.9 '@types/node': - specifier: 22.12.0 - version: 22.12.0 + specifier: 22.13.1 + version: 22.13.1 '@types/react': specifier: ^19.0.8 version: 19.0.8 @@ -279,7 +279,7 @@ importers: version: 2.2.1 '@tailwindcss/typography': specifier: ^0.5.13 - version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))) + version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3))) '@types/mdx': specifier: ^2.0.13 version: 2.0.13 @@ -313,7 +313,7 @@ importers: version: 4.20250129.0 '@remix-run/dev': specifier: ^2.15.3 - version: 2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0)) + version: 2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0)) '@types/react': specifier: ^19.0.8 version: 19.0.8 @@ -334,16 +334,16 @@ importers: version: 1.2.0 tailwindcss: specifier: ^3.4.3 - version: 3.4.17(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3)) + version: 3.4.17(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)) typescript: specifier: ^5.7.3 version: 5.7.3 vite: specifier: ^6.1.0 - version: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + version: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^4.2.1 - version: 4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + version: 4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) wrangler: specifier: ^3.48.0 version: 3.107.2(@cloudflare/workers-types@4.20250129.0) @@ -2109,14 +2109,11 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@16.18.123': - resolution: {integrity: sha512-/n7I6V/4agSpJtFDKKFEa763Hc1z3hmvchobHS1TisCOTKD5nxq8NJ2iK7SRIMYL276Q9mgWOx2AWp5n2XI6eA==} + '@types/node@16.18.126': + resolution: {integrity: sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==} - '@types/node@22.12.0': - resolution: {integrity: sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==} - - '@types/node@22.13.0': - resolution: {integrity: sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==} + '@types/node@22.13.1': + resolution: {integrity: sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==} '@types/react-dom@19.0.3': resolution: {integrity: sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==} @@ -7015,13 +7012,13 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - '@jacob-ebey/vite-react-server-dom@0.0.12(@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(rollup@4.34.6)(vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + '@jacob-ebey/vite-react-server-dom@0.0.12(@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(rollup@4.34.6)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': dependencies: '@jacob-ebey/react-server-dom-vite': 19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@mjackson/node-fetch-server': 0.5.0 - '@vitejs/plugin-react': 4.3.4(vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + '@vitejs/plugin-react': 4.3.4(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) unplugin-rsc: 0.0.11(rollup@4.34.6) - vite: 6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - rollup - supports-color @@ -7075,11 +7072,11 @@ snapshots: '@mark.probst/typescript-json-schema@0.55.0': dependencies: '@types/json-schema': 7.0.15 - '@types/node': 16.18.123 + '@types/node': 16.18.126 glob: 7.2.3 path-equal: 1.2.5 safe-stable-stringify: 2.5.0 - ts-node: 10.9.2(@types/node@16.18.123)(typescript@4.9.4) + ts-node: 10.9.2(@types/node@16.18.126)(typescript@4.9.4) typescript: 4.9.4 yargs: 17.7.2 transitivePeerDependencies: @@ -7240,7 +7237,7 @@ snapshots: optionalDependencies: typescript: 5.7.3 - '@remix-run/dev@2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0))': + '@remix-run/dev@2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0))': dependencies: '@babel/core': 7.26.7 '@babel/generator': 7.26.5 @@ -7257,7 +7254,7 @@ snapshots: '@remix-run/router': 1.22.0 '@remix-run/server-runtime': 2.15.3(typescript@5.7.3) '@types/mdx': 2.0.13 - '@vanilla-extract/integration': 6.5.0(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6) + '@vanilla-extract/integration': 6.5.0(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6) arg: 5.0.2 cacache: 17.1.4 chalk: 4.1.2 @@ -7284,7 +7281,7 @@ snapshots: pidtree: 0.6.0 postcss: 8.5.1 postcss-discard-duplicates: 5.1.0(postcss@8.5.1) - postcss-load-config: 4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3)) + postcss-load-config: 4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)) postcss-modules: 6.0.1(postcss@8.5.1) prettier: 2.8.8 pretty-ms: 7.0.1 @@ -7296,11 +7293,11 @@ snapshots: tar-fs: 2.1.2 tsconfig-paths: 4.2.0 valibot: 0.41.0(typescript@5.7.3) - vite-node: 1.6.0(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6) + vite-node: 1.6.0(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6) ws: 7.5.10 optionalDependencies: typescript: 5.7.3 - vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) wrangler: 3.107.2(@cloudflare/workers-types@4.20250129.0) transitivePeerDependencies: - '@types/node' @@ -7589,21 +7586,21 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.0.5 '@tailwindcss/oxide-win32-x64-msvc': 4.0.5 - '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3)))': + '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)))': dependencies: lodash.castarray: 4.4.0 lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)) - '@tailwindcss/vite@4.0.5(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + '@tailwindcss/vite@4.0.5(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': dependencies: '@tailwindcss/node': 4.0.5 '@tailwindcss/oxide': 4.0.5 lightningcss: 1.29.1 tailwindcss: 4.0.5 - vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) '@tsconfig/node10@1.0.11': {} @@ -7690,13 +7687,9 @@ snapshots: '@types/node@12.20.55': {} - '@types/node@16.18.123': {} + '@types/node@16.18.126': {} - '@types/node@22.12.0': - dependencies: - undici-types: 6.20.0 - - '@types/node@22.13.0': + '@types/node@22.13.1': dependencies: undici-types: 6.20.0 @@ -7814,21 +7807,21 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros - '@vanilla-extract/integration@6.5.0(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6)': + '@vanilla-extract/integration@6.5.0(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6)': dependencies: '@babel/core': 7.26.7 '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.7) '@vanilla-extract/babel-plugin-debug-ids': 1.2.0 '@vanilla-extract/css': 1.17.1 - esbuild: 0.17.19 + esbuild: 0.17.6 eval: 0.1.8 find-up: 5.0.0 javascript-stringify: 2.1.0 lodash: 4.17.21 mlly: 1.7.4 outdent: 0.8.0 - vite: 5.4.14(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6) - vite-node: 1.6.0(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6) + vite: 5.4.14(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6) + vite-node: 1.6.0(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -7843,14 +7836,14 @@ snapshots: '@vanilla-extract/private@1.0.6': {} - '@vitejs/plugin-react@4.3.4(vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + '@vitejs/plugin-react@4.3.4(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': dependencies: '@babel/core': 7.26.7 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.7) '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.7) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - supports-color @@ -7861,13 +7854,13 @@ snapshots: chai: 5.1.2 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.4(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + '@vitest/mocker@3.0.4(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': dependencies: '@vitest/spy': 3.0.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) '@vitest/pretty-format@3.0.4': dependencies: @@ -9011,7 +9004,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 22.13.0 + '@types/node': 22.13.1 require-like: 0.1.2 event-target-shim@5.0.1: {} @@ -11015,13 +11008,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.1 - postcss-load-config@4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3)): + postcss-load-config@4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)): dependencies: lilconfig: 3.1.3 yaml: 2.7.0 optionalDependencies: postcss: 8.5.1 - ts-node: 10.9.2(@types/node@22.13.0)(typescript@5.7.3) + ts-node: 10.9.2(@types/node@22.13.1)(typescript@5.7.3) postcss-modules-extract-imports@3.1.0(postcss@8.5.1): dependencies: @@ -11912,7 +11905,7 @@ snapshots: array-back: 6.2.2 wordwrapjs: 5.1.0 - tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3)): + tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -11931,7 +11924,7 @@ snapshots: postcss: 8.5.1 postcss-import: 15.1.0(postcss@8.5.1) postcss-js: 4.0.1(postcss@8.5.1) - postcss-load-config: 4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3)) + postcss-load-config: 4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)) postcss-nested: 6.2.0(postcss@8.5.1) postcss-selector-parser: 6.1.2 resolve: 1.22.10 @@ -12036,14 +12029,14 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-node@10.9.2(@types/node@16.18.123)(typescript@4.9.4): + ts-node@10.9.2(@types/node@16.18.126)(typescript@4.9.4): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 16.18.123 + '@types/node': 16.18.126 acorn: 8.14.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -12054,14 +12047,14 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@22.13.0)(typescript@5.7.3): + ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 22.13.0 + '@types/node': 22.13.1 acorn: 8.14.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -12383,13 +12376,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-node@1.6.0(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6): + vite-node@1.6.0(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6): dependencies: cac: 6.7.14 debug: 4.4.0 pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.14(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6) + vite: 5.4.14(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6) transitivePeerDependencies: - '@types/node' - less @@ -12401,13 +12394,13 @@ snapshots: - supports-color - terser - vite-node@3.0.4(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + vite-node@3.0.4(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 2.0.2 - vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -12422,60 +12415,46 @@ snapshots: - tsx - yaml - vite-plugin-inspect@10.1.1(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)): + vite-plugin-inspect@10.1.1(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)): dependencies: debug: 4.4.0 error-stack-parser-es: 1.0.5 open: 10.1.0 picocolors: 1.1.1 sirv: 3.0.0 - vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - supports-color - vite-tsconfig-paths@4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)): + vite-tsconfig-paths@4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)): dependencies: debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.4(typescript@5.7.3) optionalDependencies: - vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - supports-color - typescript - vite@5.4.14(@types/node@22.13.0)(lightningcss@1.29.1)(terser@5.31.6): + vite@5.4.14(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6): dependencies: esbuild: 0.21.5 postcss: 8.5.1 rollup: 4.34.0 optionalDependencies: - '@types/node': 22.13.0 - fsevents: 2.3.3 - lightningcss: 1.29.1 - terser: 5.31.6 - - vite@6.1.0(@types/node@22.12.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): - dependencies: - esbuild: 0.24.2 - postcss: 8.5.1 - rollup: 4.34.6 - optionalDependencies: - '@types/node': 22.12.0 + '@types/node': 22.13.1 fsevents: 2.3.3 - jiti: 2.4.2 lightningcss: 1.29.1 terser: 5.31.6 - tsx: 4.19.2 - yaml: 2.7.0 - vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: esbuild: 0.24.2 postcss: 8.5.1 rollup: 4.34.6 optionalDependencies: - '@types/node': 22.13.0 + '@types/node': 22.13.1 fsevents: 2.3.3 jiti: 2.4.2 lightningcss: 1.29.1 @@ -12483,10 +12462,10 @@ snapshots: tsx: 4.19.2 yaml: 2.7.0 - vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: '@vitest/expect': 3.0.4 - '@vitest/mocker': 3.0.4(vite@6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + '@vitest/mocker': 3.0.4(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) '@vitest/pretty-format': 3.0.4 '@vitest/runner': 3.0.4 '@vitest/snapshot': 3.0.4 @@ -12502,12 +12481,12 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.1.0(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) - vite-node: 3.0.4(@types/node@22.13.0)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite-node: 3.0.4(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 22.13.0 + '@types/node': 22.13.1 transitivePeerDependencies: - jiti - less diff --git a/sdk/package.json b/sdk/package.json index 2aed11a..1d4527e 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -12,7 +12,7 @@ "devDependencies": { "@types/diff": "^7.0.0", "@types/js-yaml": "^4.0.9", - "@types/node": "22.12.0", + "@types/node": "22.13.1", "js-yaml": "^4.1.0" }, "dependencies": { diff --git a/spiceflow/package.json b/spiceflow/package.json index 108959f..6a2c030 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -87,7 +87,7 @@ }, "devDependencies": { "@types/lodash.clonedeep": "^4.5.9", - "@types/node": "22.12.0", + "@types/node": "22.13.1", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "eventsource": "^3.0.5", diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index ec6832c..e639d4d 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -15,7 +15,6 @@ export function useFlightData() { export function LayoutContent(props: { id: string }) { const data = useFlightData() - console.log('data', data) const layoutIndex = data.layouts.findIndex((layout) => layout.id === props.id) let nextLayout = data.layouts[layoutIndex + 1]?.element if (nextLayout) { diff --git a/spiceflow/src/react/css.tsx b/spiceflow/src/react/css.tsx new file mode 100644 index 0000000..ec6cdca --- /dev/null +++ b/spiceflow/src/react/css.tsx @@ -0,0 +1,28 @@ +import { DevEnvironment, EnvironmentModuleNode, isCSSRequest } from 'vite' + +export async function collectStyleUrls( + server: DevEnvironment, + { entries }: { entries: string[] }, +) { + const visited = new Set() + + async function traverse(url: string) { + const [, id] = await server.moduleGraph.resolveUrl(url) + const mod = server.moduleGraph.getModuleById(id) + if (!mod || visited.has(mod)) { + return + } + visited.add(mod) + await Promise.all( + [...mod.importedModules].map((childMod) => traverse(childMod.url)), + ) + } + + // ensure import analysis is ready for top entries + await Promise.all(entries.map((e) => server.transformRequest(e))) + + // traverse + await Promise.all(entries.map((url) => traverse(url))) + + return [...visited].map((mod) => mod.url).filter((url) => isCSSRequest(url)) +} diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx index f6aa17b..c895aa7 100644 --- a/spiceflow/src/react/entry.rsc.tsx +++ b/spiceflow/src/react/entry.rsc.tsx @@ -102,7 +102,12 @@ const serverReferenceManifest: ServerReferenceManifest = { mod = await import(/* @vite-ignore */ id); } else { const references = await import("virtual:build-server-references"); - mod = await references.default[id](); + const ref = references.default[id] + if (!ref) { + const availableKeys = Object.keys(references.default); + throw new Error(`Could not find server reference for id: ${id}. This likely means the server reference was not properly registered. Available reference keys are: ${availableKeys.join(', ')}`); + } + mod = await ref(); } resolved = mod[name]; }, diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 5e6ae42..b0f4f5e 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -14,9 +14,7 @@ import {injectRSCPayload} from 'rsc-html-stream/server'; import { FlightDataContext } from "./components.js"; import { bootstrapModules } from "virtual:ssr-assets"; import { clientReferenceManifest } from "./utils/client-reference.js"; - - - +import cssUrls from 'virtual:app-styles' export default async function handler( @@ -52,15 +50,16 @@ export default async function handler( ); const ssrAssets = await import("virtual:ssr-assets"); + console.log(cssUrls) - - console.log('payload', payload.root) const el = + {cssUrls.map((url) => ( + + ))} {payload.root?.layouts?.[0]?.element ?? payload.root.page} - console.log('bootstrapModules', bootstrapModules) const htmlStream = fromPipeableToWebReadable( ReactDomServer.renderToPipeableStream(el, { bootstrapModules: ssrAssets.bootstrapModules, diff --git a/spiceflow/src/react/utils/client-reference.ts b/spiceflow/src/react/utils/client-reference.ts index c492bea..467496f 100644 --- a/spiceflow/src/react/utils/client-reference.ts +++ b/spiceflow/src/react/utils/client-reference.ts @@ -19,7 +19,11 @@ export const clientReferenceManifest: ClientReferenceManifest = { const references = await import( 'virtual:build-client-references' as string ) - mod = await references.default[id]() + const ref = references.default[id] + if (!ref) { + throw new Error(`Can't find client reference for module ${id}, among ${Object.keys(references.default).join(', ')}`) + } + mod = await ref() } resolved = mod[name] }, diff --git a/spiceflow/src/spiceflow.ts b/spiceflow/src/spiceflow.ts index bcf4e91..48d32b9 100644 --- a/spiceflow/src/spiceflow.ts +++ b/spiceflow/src/spiceflow.ts @@ -904,7 +904,6 @@ export class Spiceflow< page, layouts, } - console.log(data,) return data } catch (err) { return await getResForError(err) diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index cdc769b..66fbcec 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -19,6 +19,8 @@ import crypto from 'node:crypto' import reactServerDOM from './vite-jacob.js' import { serverTransform, clientTransform } from 'unplugin-rsc' import { noramlizeClientReferenceId } from './react/utils/normalize.js' +import { collectStyleUrls } from './react/css.js' +import { normalizeId } from 'ajv/dist/compile/resolve.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -34,13 +36,14 @@ function makeHash(filename: string) { export function spiceflowPlugin({ entry }): PluginOption { // Move state variables inside plugin closure let browserManifest: Manifest + let rscManifest: Manifest const clientModules = new Map() const serverModules = new Map() let buildScan = false let command: string = '' let server: ViteDevServer - + let buildType: 'scan' | 'server' | 'browser' | 'ssr' | undefined return [ react(), @@ -55,15 +58,9 @@ export function spiceflowPlugin({ entry }): PluginOption { }, async transform(code, id) { - if (id === '\0virtual:react-manifest') { - debugTransformResult({ - envName: this.environment.name, - transformedCode: code, - id, - }) - } let result = code const ext = id.slice(id.lastIndexOf('.')) + if ( EXTENSIONS_TO_TRANSFORM.has(ext) && code.match(/['"]use (client|server)['"]/g) @@ -85,6 +82,7 @@ export function spiceflowPlugin({ entry }): PluginOption { clientModules.set(filename, id) return id } + if (this.environment.name === 'rsc') { const transformed = serverTransform(code, id, { id: generateId, @@ -98,10 +96,7 @@ export function spiceflowPlugin({ entry }): PluginOption { transformedCode: result, id, }) - } else if ( - this.environment.name === 'client' || - this.environment.name === 'ssr' - ) { + } else { const transformed = clientTransform( code, id, @@ -159,6 +154,7 @@ export function spiceflowPlugin({ entry }): PluginOption { rollupOptions: { input: { index: 'spiceflow/dist/react/entry.ssr' }, }, + ssrEmitAssets: true, }, }, rsc: { @@ -172,9 +168,10 @@ export function spiceflowPlugin({ entry }): PluginOption { ], exclude: ['util'], }, + resolve: { conditions: ['react-server'], - // noExternal: ['spiceflow'], + noExternal: ['react', 'react-dom'], }, dev: { createEnvironment(name, config) { @@ -185,7 +182,10 @@ export function spiceflowPlugin({ entry }): PluginOption { }, build: { outDir: 'dist/rsc', + manifest: true, ssr: true, + ssrEmitAssets: true, + emitAssets: true, rollupOptions: { input: { index: 'spiceflow/dist/react/entry.rsc' }, }, @@ -196,6 +196,7 @@ export function spiceflowPlugin({ entry }): PluginOption { sharedPlugins: true, async buildApp(builder) { buildScan = true + // this scan part seems necessary to find all the server references and client references, otherwise they are empty await builder.build(builder.environments.rsc) buildScan = false await builder.build(builder.environments.rsc) @@ -255,6 +256,21 @@ export function spiceflowPlugin({ entry }): PluginOption { createVirtualPlugin('app-entry', () => { return `export {default} from '${url.pathToFileURL(path.resolve(entry))}'` }), + createVirtualPlugin('app-styles', async function () { + if (this.environment.mode !== 'dev') { + const rscCss = Object.values(rscManifest).flatMap((x) => x.css) + const clientCss = Object.values(browserManifest).flatMap((x) => x.css) + + const allStyles = [...rscCss, ...clientCss].filter(Boolean) + return `export default ${JSON.stringify(allStyles)}` + } + const allStyles = await collectStyleUrls(server.environments['rsc'], { + entries: [entry], + }) + const code = `export default ${JSON.stringify(allStyles)}\n\n` + // ensure hmr boundary since css module doesn't have `import.meta.hot.accept` + return code + `if (import.meta.hot) { import.meta.hot.accept() }` + }), createVirtualPlugin('ssr-assets', function () { // TODO this should also add other client modules used to speed loading up during build @@ -313,24 +329,39 @@ export function spiceflowPlugin({ entry }): PluginOption { assert(typeof output.source === 'string') browserManifest = JSON.parse(output.source) } + if (this.environment.name === 'rsc') { + const output = bundle['.vite/manifest.json'] + assert(output.type === 'asset') + assert(typeof output.source === 'string') + rscManifest = JSON.parse(output.source) + } }, }, createVirtualPlugin('build-client-references', () => { - const code = Array.from(clientModules.keys()) - .map( - (id) => `${JSON.stringify(id)}: () => import(${JSON.stringify(id)}),`, - ) - .join('\n') + let result = `export default {\n` + for (let [filename, id] of clientModules) { + // Handle virtual modules by removing \0 prefix if present + const importPath = filename.startsWith('\0') + ? filename.slice(1) + : filename + result += `"${id}": () => import("${importPath}"),\n` + } + result += `};\n` - return `export default {${code}}` + return { code: result, map: null } }), createVirtualPlugin('build-server-references', () => { - const code = Array.from(serverModules.keys()) - .map( - (id) => `${JSON.stringify(id)}: () => import(${JSON.stringify(id)}),`, - ) - .join('\n') - return `export default {${code}}` + let result = `export default {\n` + for (let [filename, id] of serverModules) { + // Handle virtual modules by removing \0 prefix if present + const importPath = filename.startsWith('\0') + ? filename.slice(1) + : filename + result += `"${id}": () => import("${importPath}"),\n` + } + result += `};\n` + + return { code: result, map: null } }), // vitePluginSilenceDirectiveBuildWarning(), @@ -340,6 +371,7 @@ export function spiceflowPlugin({ entry }): PluginOption { name = 'virtual:' + name return { name: `virtual-${name}`, + resolveId(source, _importer, _options) { return source === name ? '\0' + name : undefined }, From 76a569e06e6c9feda2d52d4e6a59c20d6ae43ad9 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 12:36:05 +0100 Subject: [PATCH 038/226] fix missing server references from client components in build --- spiceflow/src/vite.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 66fbcec..4c9eb02 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -65,7 +65,12 @@ export function spiceflowPlugin({ entry }): PluginOption { EXTENSIONS_TO_TRANSFORM.has(ext) && code.match(/['"]use (client|server)['"]/g) ) { - const mod = await server.moduleGraph.getModuleByUrl(id) + const isUseClient = /^\s*(("use client")|('use client'))/.test(code) + if (isUseClient && buildScan) { + // This is needed to let scan discover server references found in the use client components + return + } + const mod = await server?.moduleGraph?.getModuleByUrl(id) let generateId = (filename, directive) => { let id = '' if (command === 'build') { @@ -371,7 +376,7 @@ export function spiceflowPlugin({ entry }): PluginOption { name = 'virtual:' + name return { name: `virtual-${name}`, - + resolveId(source, _importer, _options) { return source === name ? '\0' + name : undefined }, From 1e57519a4b02bd406771a2a744431708db2bb3b5 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 13:05:31 +0100 Subject: [PATCH 039/226] fix css during build, need to copy them, thanks to preserveEntrySignatures that fixes the empty ssr manifest, found this trick from jacob --- spiceflow/src/react/entry.ssr.tsx | 2 - spiceflow/src/vite.tsx | 68 +++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index b0f4f5e..7357f30 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -50,8 +50,6 @@ export default async function handler( ); const ssrAssets = await import("virtual:ssr-assets"); - console.log(cssUrls) - const el = {cssUrls.map((url) => ( diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 4c9eb02..2f88b4c 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -1,4 +1,6 @@ import assert from 'node:assert' +import * as vite from 'vite' + import fs from 'node:fs' import url from 'node:url' import path from 'node:path' @@ -147,6 +149,7 @@ export function spiceflowPlugin({ entry }): PluginOption { }, build: { manifest: true, + outDir: 'dist/client', rollupOptions: { input: { index: 'virtual:browser-entry' }, @@ -155,10 +158,16 @@ export function spiceflowPlugin({ entry }): PluginOption { }, ssr: { build: { + manifest: true, + ssrManifest: true, + outDir: 'dist/ssr', rollupOptions: { + // preserveEntrySignatures: 'exports-only', + input: { index: 'spiceflow/dist/react/entry.ssr' }, }, + emitAssets: true, ssrEmitAssets: true, }, }, @@ -186,12 +195,14 @@ export function spiceflowPlugin({ entry }): PluginOption { }, }, build: { + ssrManifest: true, outDir: 'dist/rsc', manifest: true, - ssr: true, ssrEmitAssets: true, emitAssets: true, rollupOptions: { + preserveEntrySignatures: 'exports-only', + input: { index: 'spiceflow/dist/react/entry.rsc' }, }, }, @@ -204,9 +215,28 @@ export function spiceflowPlugin({ entry }): PluginOption { // this scan part seems necessary to find all the server references and client references, otherwise they are empty await builder.build(builder.environments.rsc) buildScan = false - await builder.build(builder.environments.rsc) - await builder.build(builder.environments.client) - await builder.build(builder.environments.ssr) + const rscOutputs = (await builder.build( + builder.environments.rsc, + )) as vite.Rollup.RollupOutput + const clientOutputs = (await builder.build( + builder.environments.client, + )) as vite.Rollup.RollupOutput + const ssrOutputs = (await builder.build( + builder.environments.ssr, + )) as vite.Rollup.RollupOutput + + const clientOutDir = builder.environments.client.config.build.outDir + + moveStaticAssets( + ssrOutputs, + builder.environments.ssr.config.build.outDir, + clientOutDir, + ) + moveStaticAssets( + rscOutputs, + builder.environments.rsc.config.build.outDir, + clientOutDir, + ) }, }, }), @@ -466,3 +496,33 @@ const EXTENSIONS_TO_TRANSFORM = new Set([ '.mts', '.mtsx', ]) + +function moveStaticAssets( + output: vite.Rollup.RollupOutput, + outDir: string, + clientOutDir: string, +) { + const manifestAsset = output.output.find( + (asset) => asset.fileName === '.vite/ssr-manifest.json', + ) + if (!manifestAsset || manifestAsset.type !== 'asset') { + // console.log(output.output) + throw new Error('could not find manifest') + } + const manifest = JSON.parse(manifestAsset.source as string) + + const processed = new Set() + for (const assets of Object.values(manifest) as string[][]) { + for (const asset of assets) { + const fullPath = path.join(outDir, asset.slice(1)) + + // console.log({ fullPath }) + if (asset.endsWith('.js') || processed.has(fullPath)) continue + processed.add(fullPath) + if (!fs.existsSync(fullPath)) continue + + const relative = path.relative(outDir, fullPath) + fs.renameSync(fullPath, path.join(clientOutDir, relative)) + } + } +} From 41db076a0fbf26fce9186ff2e8670e4365ce8154 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 15:10:29 +0100 Subject: [PATCH 040/226] use routeSorter to make sure static routes have priority --- example-react/e2e/basic.test.ts | 4 +- example-react/src/main.tsx | 3 + spiceflow/src/react.test.ts | 125 +++++++++++++----------------- spiceflow/src/react/entry.rsc.tsx | 2 + spiceflow/src/spiceflow.ts | 41 ++++++---- 5 files changed, 87 insertions(+), 88 deletions(-) diff --git a/example-react/e2e/basic.test.ts b/example-react/e2e/basic.test.ts index a69db13..eb923e2 100644 --- a/example-react/e2e/basic.test.ts +++ b/example-react/e2e/basic.test.ts @@ -33,6 +33,8 @@ test.describe("redirect", () => { }); + + test.describe(() => { test.use({ javaScriptEnabled: false }); test("server reference in server @nojs", async ({ page }) => { @@ -63,7 +65,7 @@ test("server reference in client @js", async ({ page }) => { test.describe(() => { test.use({ javaScriptEnabled: false }); - test("server reference in client @nojs", async ({ page }) => { + test.skip("server reference in client @nojs", async ({ page }) => { await testServerAction2(page, { js: false }); }); }); diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index f94b75c..8d76c39 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -65,6 +65,9 @@ const app = new Spiceflow() ); }) + .page('/rsc-error', async () => { + throw new Error('test error'); + }) .page("/redirect-in-rsc", async () => { return ; }) diff --git a/spiceflow/src/react.test.ts b/spiceflow/src/react.test.ts index aa00bff..f9ae1eb 100644 --- a/spiceflow/src/react.test.ts +++ b/spiceflow/src/react.test.ts @@ -4,16 +4,52 @@ import { bfs, cloneDeep, Spiceflow } from './spiceflow.js' import { z } from 'zod' import { createSpiceflowClient } from './client/index.js' -test.skip('layout and page work together', async () => { +test('layout and page work together', async () => { const res = await new Spiceflow() .layout('/xxx', () => ({ layout: 'layout' })) - .post('/xxx', () => ({ page: 'page' })) + .page('/xxx', () => ({ page: 'page' })) .handle(new Request('http://localhost/xxx', { method: 'POST' })) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ - layout: 'layout', - page: 'page', - }) + + expect(res).toMatchInlineSnapshot(` + { + "layouts": [ + { + "element": { + "layout": "layout", + }, + "id": "layout-post--xxx", + }, + ], + "page": { + "page": "page", + }, + "url": "http://localhost/xxx", + } + `) +}) +test('layout and page, static routes have priority', async () => { + const res = await new Spiceflow() + .layout('/xxx', () => ({ layout: 'layout' })) + .page('/:id', () => ({ page: ':id' })) + .page('/xxx', () => ({ page: 'page' })) + .handle(new Request('http://localhost/xxx', { method: 'POST' })) + + expect(res).toMatchInlineSnapshot(` + { + "layouts": [ + { + "element": { + "layout": "layout", + }, + "id": "layout-post--xxx", + }, + ], + "page": { + "page": "page", + }, + "url": "http://localhost/xxx", + } + `) }) test('layout and page work together with params', async () => { @@ -27,6 +63,7 @@ test('layout and page work together with params', async () => { { "handler": [Function], "hooks": undefined, + "id": "layout-get--", "kind": "layout", "method": "GET", "path": "/", @@ -38,6 +75,7 @@ test('layout and page work together with params', async () => { { "handler": [Function], "hooks": undefined, + "id": "layout-post--", "kind": "layout", "method": "POST", "path": "/", @@ -49,6 +87,7 @@ test('layout and page work together with params', async () => { { "handler": [Function], "hooks": undefined, + "id": "page-get--:id", "kind": "page", "method": "GET", "path": "/:id", @@ -60,6 +99,7 @@ test('layout and page work together with params', async () => { { "handler": [Function], "hooks": undefined, + "id": "page-post--:id", "kind": "page", "method": "POST", "path": "/:id", @@ -80,73 +120,12 @@ test('layout and page work together with params', async () => { `) expect(await res).toMatchInlineSnapshot(` - Response { - Symbol(state): { - "aborted": false, - "body": { - "length": 65, - "source": "{"message":"Cannot read properties of undefined (reading 'map')"}", - "stream": ReadableStream { - Symbol(kType): "ReadableStream", - Symbol(kState): { - "controller": ReadableByteStreamController { - Symbol(kType): "ReadableByteStreamController", - Symbol(kState): { - "autoAllocateChunkSize": undefined, - "byobRequest": null, - "cancelAlgorithm": [Function], - "closeRequested": false, - "highWaterMark": 0, - "pendingPullIntos": [], - "pullAgain": false, - "pullAlgorithm": [Function], - "pulling": false, - "queue": [], - "queueTotalSize": 0, - "started": true, - "stream": [Circular], - }, - }, - "disturbed": false, - "reader": undefined, - "state": "readable", - "storedError": undefined, - "transfer": { - "port1": undefined, - "port2": undefined, - "promise": undefined, - "writable": undefined, - }, - }, - Symbol(nodejs.webstream.isClosedPromise): { - "promise": Promise {}, - "reject": [Function], - "resolve": [Function], - }, - Symbol(nodejs.webstream.controllerErrorFunction): [Function], - }, - }, - "cacheState": "", - "headersList": HeadersList { - "cookies": null, - Symbol(headers map): Map { - "content-type" => { - "name": "content-type", - "value": "application/json", - }, - }, - Symbol(headers map sorted): null, - }, - "rangeRequested": false, - "requestIncludesCredentials": false, - "status": 500, - "statusText": "", - "timingAllowPassed": false, - "timingInfo": null, - "type": "default", - "urlList": [], + { + "layouts": [], + "page": { + "page": "123", }, - Symbol(headers): Headers {}, + "url": "http://localhost/123", } `) }) diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx index c895aa7..5cdc828 100644 --- a/spiceflow/src/react/entry.rsc.tsx +++ b/spiceflow/src/react/entry.rsc.tsx @@ -46,6 +46,7 @@ export async function handler( } else { // progressive enhancement const formData = await request.formData(); + console.log(formData); const decodedAction = await ReactServer.decodeAction( formData, serverReferenceManifest, @@ -93,6 +94,7 @@ export async function handler( const serverReferenceManifest: ServerReferenceManifest = { resolveServerReference(reference: string) { + const [id, name] = reference.split("#"); let resolved: unknown; return { diff --git a/spiceflow/src/spiceflow.ts b/spiceflow/src/spiceflow.ts index 48d32b9..af2bfe3 100644 --- a/spiceflow/src/spiceflow.ts +++ b/spiceflow/src/spiceflow.ts @@ -188,23 +188,25 @@ export class Spiceflow< } private getAllDecodedParams( - matchResult: Result, + _matchResult: Result, pathname: string, routeIndex, ): Record { - if (!matchResult?.length || !matchResult?.[0]?.[routeIndex]?.[1]) { + if (!_matchResult?.length || !_matchResult?.[0]?.[routeIndex]?.[1]) { return {} } + + const matches = _matchResult[0] const internalRoute = - matchResult[0].find(([route]) => route.path.includes('*'))?.[0] || - matchResult[0][routeIndex][0] + matches.find(([route]) => route.path.includes('*'))?.[0] || + matches[routeIndex][0] const decoded: Record = extractWildcardParam(pathname, internalRoute?.path) || {} - const keys = Object.keys(matchResult[0][routeIndex][1]) + const keys = Object.keys(matches[routeIndex][1]) for (const key of keys) { - const value = matchResult[0][routeIndex][1][key] + const value = matches[routeIndex][1][key] if (value) { decoded[key] = /\%/.test(value) ? decodeURIComponent_(value) : value } @@ -240,11 +242,15 @@ export class Spiceflow< } // Get all matched routes - const routes = matchedRoutes[0].map(([route, params], index) => ({ - app, - route, - params: this.getAllDecodedParams(matchedRoutes, originalPath, index), - })) + const routes = matchedRoutes[0] + .map(([route, params], index) => ({ + app, + route, + params: this.getAllDecodedParams(matchedRoutes, originalPath, index), + })) + .sort((a, b) => { + return routeSorter(a.route, b.route) + }) if (routes.length) { return routes @@ -1591,10 +1597,17 @@ export function cloneDeep(x) { return lodashCloneDeep(x) } -console.log(`piceflow running`) +function routeSorter(a: InternalRoute, b: InternalRoute) { + // Count dynamic parameters (:param and *) in each route + const aCount = a.path + .split('/') + .filter((p) => p.startsWith(':') || p === '*').length + const bCount = b.path + .split('/') + .filter((p) => p.startsWith(':') || p === '*').length + return aCount - bCount +} -const tryDecodeURIComponent = (str: string) => - tryDecode(str, decodeURIComponent_) function partition(arr: T[], predicate: (item: T) => boolean): [T[], T[]] { return arr.reduce( (acc, item) => { From bffac7d592b2fb82e9bd5b307f442e3cad7f6c9e Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 16:33:47 +0100 Subject: [PATCH 041/226] checking what happens on errors --- example-react/src/app/client.tsx | 5 +++++ example-react/src/app/index.tsx | 2 ++ example-react/src/main.tsx | 15 ++++++++++++++- example-react/tsconfig.json | 1 - 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/example-react/src/app/client.tsx b/example-react/src/app/client.tsx index 1719baa..12d2afa 100644 --- a/example-react/src/app/client.tsx +++ b/example-react/src/app/client.tsx @@ -65,3 +65,8 @@ export function Calculator() { + +export function ClientComponentThrows() { + throw new Error('Client component error'); + return
Client component
; +} diff --git a/example-react/src/app/index.tsx b/example-react/src/app/index.tsx index fa69251..6f8452c 100644 --- a/example-react/src/app/index.tsx +++ b/example-react/src/app/index.tsx @@ -27,3 +27,5 @@ export async function IndexPage() { ); } + + diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 8d76c39..172aea9 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -2,6 +2,7 @@ import { Spiceflow } from "spiceflow"; import { IndexPage } from "./app/index"; import { Layout } from "./app/layout"; import './styles.css' +import { ClientComponentThrows } from "./app/client"; const app = new Spiceflow() @@ -65,8 +66,14 @@ const app = new Spiceflow() ); }) - .page('/rsc-error', async () => { + .page('/loader-error', async () => { throw new Error('test error'); + }) + .page('/rsc-error', async () => { + return + }) + .page('/client-error', async () => { + return }) .page("/redirect-in-rsc", async () => { return ; @@ -86,4 +93,10 @@ async function Redirects() { return
Redirect
; } +function ServerComponentThrows() { + throw new Error('Server component error'); + return
Server component
; +} + + export default app; diff --git a/example-react/tsconfig.json b/example-react/tsconfig.json index 2bcdce2..5b56fc6 100644 --- a/example-react/tsconfig.json +++ b/example-react/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022", "esnext"], "types": ["vite/client"], - "rootDirs": ["src", "e2e"], "isolatedModules": true, "esModuleInterop": true, "jsx": "react-jsx", From e75204fd20945c3f0399c360c974a7c44a3f9df2 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 16:34:30 +0100 Subject: [PATCH 042/226] run prettier --- spiceflow/src/client/types.ts | 92 ++++--- spiceflow/src/cors.test.ts | 16 +- spiceflow/src/mcp.ts | 2 +- spiceflow/src/middleware.test.ts | 30 ++- spiceflow/src/react/components.tsx | 2 +- spiceflow/src/react/entry.client.tsx | 239 +++++++++--------- spiceflow/src/react/entry.rsc.tsx | 209 ++++++++------- spiceflow/src/react/entry.ssr.tsx | 147 ++++++----- spiceflow/src/react/references.browser.tsx | 6 +- .../src/react/server-dom-client-optimized.tsx | 6 +- spiceflow/src/react/server-dom-optimized.tsx | 8 +- spiceflow/src/react/types/ambient.d.ts | 115 +++++---- spiceflow/src/react/types/index.ts | 26 +- spiceflow/src/react/utils/client-reference.ts | 12 +- spiceflow/src/react/utils/fetch.ts | 122 ++++----- spiceflow/src/react/utils/normalize.ts | 4 +- spiceflow/src/spiceflow.test.ts | 5 +- spiceflow/src/trie-router/node.ts | 55 +++- spiceflow/src/trie-router/router.ts | 2 - spiceflow/src/trie-router/url.ts | 54 +++- spiceflow/src/trie-router/utils.ts | 3 +- spiceflow/src/types.ts | 239 +++++++++--------- spiceflow/src/vite-jacob.ts | 4 +- 23 files changed, 717 insertions(+), 681 deletions(-) diff --git a/spiceflow/src/client/types.ts b/spiceflow/src/client/types.ts index ca27add..70a697b 100644 --- a/spiceflow/src/client/types.ts +++ b/spiceflow/src/client/types.ts @@ -15,11 +15,11 @@ type ReplaceBlobWithFiles> = { [K in keyof RecordType]: RecordType[K] extends any ? RecordType[K] : RecordType[K] extends - | Blob - | Blob[] - | { arrayBuffer: () => Promise } - ? Files - : RecordType[K] + | Blob + | Blob[] + | { arrayBuffer: () => Promise } + ? Files + : RecordType[K] } & {} type And = A extends true @@ -34,18 +34,18 @@ type ReplaceGeneratorWithAsyncGenerator< [K in keyof RecordType]: RecordType[K] extends any ? RecordType[K] : RecordType[K] extends Generator - ? And>, void extends B ? true : false> extends true - ? AsyncGenerator - : And, void extends B ? false : true> extends true - ? B - : AsyncGenerator | B - : RecordType[K] extends AsyncGenerator - ? And>, void extends B ? true : false> extends true - ? AsyncGenerator - : And, void extends B ? false : true> extends true - ? B - : AsyncGenerator | B - : RecordType[K] + ? And>, void extends B ? true : false> extends true + ? AsyncGenerator + : And, void extends B ? false : true> extends true + ? B + : AsyncGenerator | B + : RecordType[K] extends AsyncGenerator + ? And>, void extends B ? true : false> extends true + ? AsyncGenerator + : And, void extends B ? false : true> extends true + ? B + : AsyncGenerator | B + : RecordType[K] } & {} type MaybeArray = T | T[] @@ -97,40 +97,38 @@ export namespace SpiceflowClient { ClientResponse> > : K extends 'get' | 'head' - ? ( - options: Prettify, - ) => Promise< - ClientResponse> - > - : ( - body: Body extends Record - ? ReplaceBlobWithFiles - : Body, - options: Prettify, - ) => Promise< - ClientResponse> - > + ? ( + options: Prettify, + ) => Promise< + ClientResponse> + > + : ( + body: Body extends Record + ? ReplaceBlobWithFiles + : Body, + options: Prettify, + ) => Promise< + ClientResponse> + > : never : CreateParams } - type CreateParams> = Extract< - keyof Route, - `:${string}` - > extends infer Path extends string - ? IsNever extends true - ? Prettify> - : // ! DO NOT USE PRETTIFY ON THIS LINE, OTHERWISE FUNCTION CALLING WILL BE OMITTED - (((params: { - [param in Path extends `:${infer Param}` - ? Param extends `${infer Param}?` - ? Param - : Param - : never]: string | number - }) => Prettify> & CreateParams) & - Prettify>) & - (Path extends `:${string}?` ? CreateParams : {}) - : never + type CreateParams> = + Extract extends infer Path extends string + ? IsNever extends true + ? Prettify> + : // ! DO NOT USE PRETTIFY ON THIS LINE, OTHERWISE FUNCTION CALLING WILL BE OMITTED + (((params: { + [param in Path extends `:${infer Param}` + ? Param extends `${infer Param}?` + ? Param + : Param + : never]: string | number + }) => Prettify> & CreateParams) & + Prettify>) & + (Path extends `:${string}?` ? CreateParams : {}) + : never export interface Config { // fetch?: Omit diff --git a/spiceflow/src/cors.test.ts b/spiceflow/src/cors.test.ts index a09a7e7..faac4f3 100644 --- a/spiceflow/src/cors.test.ts +++ b/spiceflow/src/cors.test.ts @@ -50,9 +50,9 @@ describe('cors middleware', () => { }) test('CORS headers are set when an error is thrown', async () => { - let errorRouteCallCount = 0; + let errorRouteCallCount = 0 const errorApp = new Spiceflow().use(cors()).get('/error', () => { - errorRouteCallCount++; + errorRouteCallCount++ throw new Error('Test error') }) @@ -64,29 +64,31 @@ test('CORS headers are set when an error is thrown', async () => { }) test('CORS headers are set for OPTIONS request when an error is thrown', async () => { - let errorRouteCallCount = 0; + let errorRouteCallCount = 0 const errorApp = new Spiceflow().use(cors()).options('/error', () => { - errorRouteCallCount++; + errorRouteCallCount++ throw new Error('Test error') }) const res = await errorApp.handle(request('error', 'OPTIONS')) expect(res.status).toBe(204) expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') - expect(res.headers.get('Access-Control-Allow-Methods')).toBe('GET,HEAD,PUT,POST,DELETE,PATCH') + expect(res.headers.get('Access-Control-Allow-Methods')).toBe( + 'GET,HEAD,PUT,POST,DELETE,PATCH', + ) expect(errorRouteCallCount).toBe(1) }) // TODO should middleware errors be handled? errors can be a way to short circuit other middlewares test('CORS headers are set when an error is thrown in middleware', async () => { - let errorRouteCallCount = 0; + let errorRouteCallCount = 0 const errorApp = new Spiceflow() .use((c) => { throw new Error('middleware error') }) .use(cors()) .get('/error', () => { - errorRouteCallCount++; + errorRouteCallCount++ throw new Error('Test error') }) diff --git a/spiceflow/src/mcp.ts b/spiceflow/src/mcp.ts index 3f1b443..62faa51 100644 --- a/spiceflow/src/mcp.ts +++ b/spiceflow/src/mcp.ts @@ -45,7 +45,7 @@ function getOperationParameters(operation: OpenAPIV3.OperationObject): { operation.parameters.forEach((param) => { if ('$ref' in param) return // TODO referenced parameters - + if (param.in === 'query') { queryProperties[param.name] = param.schema as OpenAPIV3.SchemaObject if (param.required) queryRequired.push(param.name) diff --git a/spiceflow/src/middleware.test.ts b/spiceflow/src/middleware.test.ts index 833b648..644d075 100644 --- a/spiceflow/src/middleware.test.ts +++ b/spiceflow/src/middleware.test.ts @@ -37,7 +37,6 @@ test('middleware with no handlers works', async () => { expect(await res.text()).toEqual('ok') }) - test('middleware calling next() without returning it works', async () => { const res = await new Spiceflow() .use(async ({ request }, next) => { @@ -76,7 +75,6 @@ test('middleware state is not shared between requests', async () => { .state('x', -1) .use(async ({ request, state, query }, next) => { state.x = Number(query?.x || -1) - }) .get('/get', ({ state }) => { return state.x @@ -333,7 +331,6 @@ test('middleware returning response and middleware adding header with mounted Sp expect(res.headers.get('X-Added-Header')).toBe('HeaderValue') }) - test('each middleware and route is called exactly once if an error is thrown', async () => { const callOrder: string[] = [] @@ -359,20 +356,29 @@ test('each middleware and route is called exactly once if an error is thrown', a const res = await app.handle(new Request('http://localhost/test')) expect(res.status).toBe(500) - expect(await res.text()).toMatchInlineSnapshot(`"{"message":"Route response"}"`) - expect(callOrder).toEqual(['middleware1', 'middleware2', 'middleware3', 'route']) - + expect(await res.text()).toMatchInlineSnapshot( + `"{"message":"Route response"}"`, + ) + expect(callOrder).toEqual([ + 'middleware1', + 'middleware2', + 'middleware3', + 'route', + ]) + // Check that each middleware and route is called exactly once - const counts = callOrder.reduce((acc, item) => { - acc[item] = (acc[item] || 0) + 1 - return acc - }, {} as Record) + const counts = callOrder.reduce( + (acc, item) => { + acc[item] = (acc[item] || 0) + 1 + return acc + }, + {} as Record, + ) expect(counts).toEqual({ middleware1: 1, middleware2: 1, middleware3: 1, - route: 1 + route: 1, }) }) - diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index e639d4d..8bd2caf 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -20,7 +20,7 @@ export function LayoutContent(props: { id: string }) { if (nextLayout) { return nextLayout } - + return data.page } diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index 490cbd2..2979e7e 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -1,126 +1,127 @@ -import React from "react"; -import ReactDomClient from "react-dom/client"; -import ReactClient from "spiceflow/dist/react/server-dom-client-optimized"; -import type { ServerPayload } from "./entry.rsc.js"; -import type { CallServerFn } from "./types/index.js"; -import { clientReferenceManifest } from "./utils/client-reference.js"; -import {rscStream} from 'rsc-html-stream/client'; -import { FlightDataContext } from "./components.js"; - +import React from 'react' +import ReactDomClient from 'react-dom/client' +import ReactClient from 'spiceflow/dist/react/server-dom-client-optimized' +import type { ServerPayload } from './entry.rsc.js' +import type { CallServerFn } from './types/index.js' +import { clientReferenceManifest } from './utils/client-reference.js' +import { rscStream } from 'rsc-html-stream/client' +import { FlightDataContext } from './components.js' async function main() { - const callServer: CallServerFn = async (id, args) => { - const url = new URL(window.location.href); - url.searchParams.set("__rsc", id); - const payload = await ReactClient.createFromFetch( - fetch(url, { - method: "POST", - body: await ReactClient.encodeReply(args), - }), - clientReferenceManifest, - { callServer }, - ); - setPayload(payload); - return payload.returnValue; - }; - Object.assign(globalThis, { __callServer: callServer }); - - async function onNavigation() { - const url = new URL(window.location.href); - url.searchParams.set("__rsc", ""); - const payload = await ReactClient.createFromFetch( - fetch(url), - clientReferenceManifest, - - { callServer }, - ); - setPayload(payload); - } - - const initialPayload = - await ReactClient.createFromReadableStream( - rscStream, - clientReferenceManifest, - - { callServer }, - ); - - let setPayload: (v: ServerPayload) => void; - - function BrowserRoot() { - const [payload, setPayload_] = React.useState(initialPayload); - const [_isPending, startTransition] = React.useTransition(); - - React.useEffect(() => { - setPayload = (v) => startTransition(() => setPayload_(v)); - }, [startTransition, setPayload_]); - - React.useEffect(() => { - return listenNavigation(onNavigation); - }, []); - - return - {payload.root?.layouts?.[0]?.element ?? payload.root.page} - - } - - ReactDomClient.hydrateRoot(document, , { - formState: initialPayload.formState, - }); - - if (import.meta.hot) { - import.meta.hot.on("react-server:update", (e) => { - console.log("[react-server:update]", e.file); - window.history.replaceState({}, "", window.location.href); - }); - } + const callServer: CallServerFn = async (id, args) => { + const url = new URL(window.location.href) + url.searchParams.set('__rsc', id) + const payload = await ReactClient.createFromFetch( + fetch(url, { + method: 'POST', + body: await ReactClient.encodeReply(args), + }), + clientReferenceManifest, + { callServer }, + ) + setPayload(payload) + return payload.returnValue + } + Object.assign(globalThis, { __callServer: callServer }) + + async function onNavigation() { + const url = new URL(window.location.href) + url.searchParams.set('__rsc', '') + const payload = await ReactClient.createFromFetch( + fetch(url), + clientReferenceManifest, + + { callServer }, + ) + setPayload(payload) + } + + const initialPayload = + await ReactClient.createFromReadableStream( + rscStream, + clientReferenceManifest, + + { callServer }, + ) + + let setPayload: (v: ServerPayload) => void + + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + const [_isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setPayload = (v) => startTransition(() => setPayload_(v)) + }, [startTransition, setPayload_]) + + React.useEffect(() => { + return listenNavigation(onNavigation) + }, []) + + return ( + + {payload.root?.layouts?.[0]?.element ?? payload.root.page} + + ) + } + + ReactDomClient.hydrateRoot(document, , { + formState: initialPayload.formState, + }) + + if (import.meta.hot) { + import.meta.hot.on('react-server:update', (e) => { + console.log('[react-server:update]', e.file) + window.history.replaceState({}, '', window.location.href) + }) + } } function listenNavigation(onNavigation: () => void) { - window.addEventListener("popstate", onNavigation); - - const oldPushState = window.history.pushState; - window.history.pushState = function (...args) { - const res = oldPushState.apply(this, args); - onNavigation(); - return res; - }; - - const oldReplaceState = window.history.replaceState; - window.history.replaceState = function (...args) { - const res = oldReplaceState.apply(this, args); - onNavigation(); - return res; - }; - - function onClick(e: MouseEvent) { - let link = (e.target as Element).closest("a"); - if ( - link && - link instanceof HTMLAnchorElement && - link.href && - (!link.target || link.target === "_self") && - link.origin === location.origin && - !link.hasAttribute("download") && - e.button === 0 && // left clicks only - !e.metaKey && // open in new tab (mac) - !e.ctrlKey && // open in new tab (windows) - !e.altKey && // download - !e.shiftKey && - !e.defaultPrevented - ) { - e.preventDefault(); - history.pushState(null, "", link.href); - } - } - document.addEventListener("click", onClick); - - return () => { - document.removeEventListener("click", onClick); - window.removeEventListener("popstate", onNavigation); - window.history.pushState = oldPushState; - window.history.replaceState = oldReplaceState; - }; + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } } -main(); +main() diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx index 5cdc828..10437c3 100644 --- a/spiceflow/src/react/entry.rsc.tsx +++ b/spiceflow/src/react/entry.rsc.tsx @@ -1,129 +1,118 @@ -import type { ReactFormState } from "react-dom/client"; -import React from "react"; -import ReactServer from "spiceflow/dist/react/server-dom-optimized"; +import type { ReactFormState } from 'react-dom/client' +import React from 'react' +import ReactServer from 'spiceflow/dist/react/server-dom-optimized' import type { - ClientReferenceMetadataManifest, - ServerReferenceManifest, -} from "./types/index.js"; + ClientReferenceMetadataManifest, + ServerReferenceManifest, +} from './types/index.js' import app from 'virtual:app-entry' -import { fromPipeableToWebReadable } from "./utils/fetch.js"; -import { FlightData } from "./components.js"; - +import { fromPipeableToWebReadable } from './utils/fetch.js' +import { FlightData } from './components.js' export interface RscHandlerResult { - stream: ReadableStream; + stream: ReadableStream } export interface ServerPayload { - root: FlightData; - formState?: ReactFormState; - returnValue?: unknown; + root: FlightData + formState?: ReactFormState + returnValue?: unknown } +export async function handler(url: URL, request: Request) { + // handle action + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + if (request.method === 'POST') { + const actionId = url.searchParams.get('__rsc') + if (actionId) { + // client stream request + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + const args = await ReactServer.decodeReply(body) + const reference = serverReferenceManifest.resolveServerReference(actionId) + await reference.preload() + const action = await reference.get() + returnValue = await (action as any).apply(null, args) + } else { + // progressive enhancement + const formData = await request.formData() + console.log(formData) + const decodedAction = await ReactServer.decodeAction( + formData, + serverReferenceManifest, + ) + formState = await ReactServer.decodeFormState( + await decodedAction(), + formData, + serverReferenceManifest, + ) + } + } -export async function handler( - url: URL, - request: Request, -) { - // handle action - let returnValue: unknown | undefined; - let formState: ReactFormState | undefined; - if (request.method === "POST") { - const actionId = url.searchParams.get("__rsc"); - if (actionId) { - // client stream request - const contentType = request.headers.get("content-type"); - const body = contentType?.startsWith("multipart/form-data") - ? await request.formData() - : await request.text(); - const args = await ReactServer.decodeReply(body); - const reference = - serverReferenceManifest.resolveServerReference(actionId); - await reference.preload(); - const action = await reference.get(); - returnValue = await (action as any).apply(null, args); - } else { - // progressive enhancement - const formData = await request.formData(); - console.log(formData); - const decodedAction = await ReactServer.decodeAction( - formData, - serverReferenceManifest, - ); - formState = await ReactServer.decodeFormState( - await decodedAction(), - formData, - serverReferenceManifest, - ); - } - } + const root = await app.handle(request) - const root = await app.handle(request) - - if (root instanceof Response) { - return root - } - const {page, layouts} = root + if (root instanceof Response) { + return root + } + const { page, layouts } = root - let abortable = ReactServer.renderToPipeableStream( - { - root, - returnValue, - formState, - }, - clientReferenceMetadataManifest, - {onError(error) { - - },}, - ) - // render flight stream - const stream = fromPipeableToWebReadable( - abortable - ); - request.signal.addEventListener('abort', () => { - abortable.abort() - }) + let abortable = ReactServer.renderToPipeableStream( + { + root, + returnValue, + formState, + }, + clientReferenceMetadataManifest, + { onError(error) {} }, + ) + // render flight stream + const stream = fromPipeableToWebReadable(abortable) + request.signal.addEventListener('abort', () => { + abortable.abort() + }) - let r : RscHandlerResult = { - stream, - }; - return r + let r: RscHandlerResult = { + stream, + } + return r } - const serverReferenceManifest: ServerReferenceManifest = { - resolveServerReference(reference: string) { - - const [id, name] = reference.split("#"); - let resolved: unknown; - return { - async preload() { - let mod: Record; - if (import.meta.env.DEV) { - mod = await import(/* @vite-ignore */ id); - } else { - const references = await import("virtual:build-server-references"); - const ref = references.default[id] - if (!ref) { - const availableKeys = Object.keys(references.default); - throw new Error(`Could not find server reference for id: ${id}. This likely means the server reference was not properly registered. Available reference keys are: ${availableKeys.join(', ')}`); - } - mod = await ref(); - } - resolved = mod[name]; - }, - get() { - return resolved; - }, - }; - }, -}; + resolveServerReference(reference: string) { + const [id, name] = reference.split('#') + let resolved: unknown + return { + async preload() { + let mod: Record + if (import.meta.env.DEV) { + mod = await import(/* @vite-ignore */ id) + } else { + const references = await import('virtual:build-server-references') + const ref = references.default[id] + if (!ref) { + const availableKeys = Object.keys(references.default) + throw new Error( + `Could not find server reference for id: ${id}. This likely means the server reference was not properly registered. Available reference keys are: ${availableKeys.join(', ')}`, + ) + } + mod = await ref() + } + resolved = mod[name] + }, + get() { + return resolved + }, + } + }, +} const clientReferenceMetadataManifest: ClientReferenceMetadataManifest = { - resolveClientReferenceMetadata(metadata) { - // console.log("[debug:resolveClientReferenceMetadata]", { metadata }, Object.getOwnPropertyDescriptors(metadata)); - return metadata.$$id; - }, -}; - + resolveClientReferenceMetadata(metadata) { + // console.log("[debug:resolveClientReferenceMetadata]", { metadata }, Object.getOwnPropertyDescriptors(metadata)); + return metadata.$$id + }, +} diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 7357f30..1dc4d1a 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -1,91 +1,88 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; -import ReactDomServer from "react-dom/server"; -import ReactClient from "spiceflow/dist/react/server-dom-client-optimized"; -import type { ModuleRunner } from "vite/module-runner"; -import type { ServerPayload } from "./entry.rsc.js"; +import type { IncomingMessage, ServerResponse } from 'node:http' +import ReactDomServer from 'react-dom/server' +import ReactClient from 'spiceflow/dist/react/server-dom-client-optimized' +import type { ModuleRunner } from 'vite/module-runner' +import type { ServerPayload } from './entry.rsc.js' import { - createRequest, - fromPipeableToWebReadable, - fromWebToNodeReadable, - sendResponse, -} from "./utils/fetch.js"; -import {injectRSCPayload} from 'rsc-html-stream/server'; -import { FlightDataContext } from "./components.js"; -import { bootstrapModules } from "virtual:ssr-assets"; -import { clientReferenceManifest } from "./utils/client-reference.js"; + createRequest, + fromPipeableToWebReadable, + fromWebToNodeReadable, + sendResponse, +} from './utils/fetch.js' +import { injectRSCPayload } from 'rsc-html-stream/server' +import { FlightDataContext } from './components.js' +import { bootstrapModules } from 'virtual:ssr-assets' +import { clientReferenceManifest } from './utils/client-reference.js' import cssUrls from 'virtual:app-styles' - export default async function handler( - req: IncomingMessage, - res: ServerResponse, + req: IncomingMessage, + res: ServerResponse, ) { - const request = createRequest(req, res); - const url = new URL(request.url); - const rscEntry = await importRscEntry(); - const rscResult = await rscEntry.handler(url, request); + const request = createRequest(req, res) + const url = new URL(request.url) + const rscEntry = await importRscEntry() + const rscResult = await rscEntry.handler(url, request) + + if (rscResult instanceof Response) { + sendResponse(rscResult, res) + return + } + + if (url.searchParams.has('__rsc')) { + const response = new Response(rscResult.stream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + sendResponse(response, res) + return + } - if (rscResult instanceof Response) { - sendResponse(rscResult, res); - return; - } - + const [flightStream1, flightStream2] = rscResult.stream.tee() - if (url.searchParams.has("__rsc")) { - const response = new Response(rscResult.stream, { - headers: { - "content-type": "text/x-component;charset=utf-8", - }, - }); - sendResponse(response, res); - return; - } + const payload = await ReactClient.createFromNodeStream( + fromWebToNodeReadable(flightStream1), + clientReferenceManifest, + ) + const ssrAssets = await import('virtual:ssr-assets') - const [flightStream1, flightStream2] = rscResult.stream.tee(); + const el = ( + + {cssUrls.map((url) => ( + + ))} + {payload.root?.layouts?.[0]?.element ?? payload.root.page} + + ) - const payload = await ReactClient.createFromNodeStream( - fromWebToNodeReadable(flightStream1), - clientReferenceManifest, - ); - const ssrAssets = await import("virtual:ssr-assets"); + const htmlStream = fromPipeableToWebReadable( + ReactDomServer.renderToPipeableStream(el, { + bootstrapModules: ssrAssets.bootstrapModules, - - const el = - {cssUrls.map((url) => ( - - ))} - {payload.root?.layouts?.[0]?.element ?? payload.root.page} - - - const htmlStream = fromPipeableToWebReadable( - ReactDomServer.renderToPipeableStream(el, { - bootstrapModules: ssrAssets.bootstrapModules, - - // @ts-ignore no type? - formState: payload.formState, - }), - ); + // @ts-ignore no type? + formState: payload.formState, + }), + ) - const response = new Response( - htmlStream - - .pipeThrough(injectRSCPayload(flightStream2)), - { - headers: { - "content-type": "text/html;charset=utf-8", - }, - }, - ); - sendResponse(response, res); + const response = new Response( + htmlStream.pipeThrough(injectRSCPayload(flightStream2)), + { + headers: { + 'content-type': 'text/html;charset=utf-8', + }, + }, + ) + sendResponse(response, res) } -declare let __rscRunner: ModuleRunner; +declare let __rscRunner: ModuleRunner -async function importRscEntry(): Promise { - if (import.meta.env.DEV) { - return await __rscRunner.import("spiceflow/dist/react/entry.rsc"); - } else { - return await import("virtual:build-rsc-entry" as any); - } +async function importRscEntry(): Promise { + if (import.meta.env.DEV) { + return await __rscRunner.import('spiceflow/dist/react/entry.rsc') + } else { + return await import('virtual:build-rsc-entry' as any) + } } diff --git a/spiceflow/src/react/references.browser.tsx b/spiceflow/src/react/references.browser.tsx index 93a9d6a..446d9b0 100644 --- a/spiceflow/src/react/references.browser.tsx +++ b/spiceflow/src/react/references.browser.tsx @@ -1,8 +1,4 @@ -import { - createServerReference as createServerReferenceImp -} from 'react-server-dom-vite/client' - - +import { createServerReference as createServerReferenceImp } from 'react-server-dom-vite/client' export function createServerReference(imp: unknown, id: string, name: string) { return createServerReferenceImp(`${id}#${name}`, __callServer) diff --git a/spiceflow/src/react/server-dom-client-optimized.tsx b/spiceflow/src/react/server-dom-client-optimized.tsx index e52a82d..41d2e61 100644 --- a/spiceflow/src/react/server-dom-client-optimized.tsx +++ b/spiceflow/src/react/server-dom-client-optimized.tsx @@ -1,6 +1,4 @@ - import RSD from 'react-server-dom-vite/client' - -export const createServerReference = RSD.createServerReference; -export default RSD \ No newline at end of file +export const createServerReference = RSD.createServerReference +export default RSD diff --git a/spiceflow/src/react/server-dom-optimized.tsx b/spiceflow/src/react/server-dom-optimized.tsx index 7ae31f3..07a046d 100644 --- a/spiceflow/src/react/server-dom-optimized.tsx +++ b/spiceflow/src/react/server-dom-optimized.tsx @@ -1,7 +1,5 @@ - import RSD from 'react-server-dom-vite/server' - -export const registerServerReference = RSD.registerServerReference; -export const registerClientReference = RSD.registerClientReference; -export default RSD \ No newline at end of file +export const registerServerReference = RSD.registerServerReference +export const registerClientReference = RSD.registerClientReference +export default RSD diff --git a/spiceflow/src/react/types/ambient.d.ts b/spiceflow/src/react/types/ambient.d.ts index 0f6d1bc..37f080b 100644 --- a/spiceflow/src/react/types/ambient.d.ts +++ b/spiceflow/src/react/types/ambient.d.ts @@ -1,79 +1,78 @@ /// // import {Spiceflow} from '../../spiceflow.js' -declare module "react-server-dom-vite/server" { - export function renderToPipeableStream( - data: T, - manifest: import(".").ClientReferenceMetadataManifest, - options?: { - environmentName?: string | (() => string); - filterStackFrame?: (url: string, functionName: string) => boolean; - onError?: (error: any) => void; - onPostpone?: (reason: string) => void; - identifierPrefix?: string; - temporaryReferences?: any; - }, - ): import("react-dom/server").PipeableStream; +declare module 'react-server-dom-vite/server' { + export function renderToPipeableStream( + data: T, + manifest: import('.').ClientReferenceMetadataManifest, + options?: { + environmentName?: string | (() => string) + filterStackFrame?: (url: string, functionName: string) => boolean + onError?: (error: any) => void + onPostpone?: (reason: string) => void + identifierPrefix?: string + temporaryReferences?: any + }, + ): import('react-dom/server').PipeableStream - export function decodeReply(body: string | FormData): Promise; + export function decodeReply(body: string | FormData): Promise - export function decodeAction( - body: FormData, - manifest: import(".").ServerReferenceManifest, - ): Promise<() => Promise>; + export function decodeAction( + body: FormData, + manifest: import('.').ServerReferenceManifest, + ): Promise<() => Promise> - export function decodeFormState( - returnValue: unknown, - body: FormData, - manifest: import(".").ServerReferenceManifest, - ): Promise; + export function decodeFormState( + returnValue: unknown, + body: FormData, + manifest: import('.').ServerReferenceManifest, + ): Promise } -declare module "spiceflow/dist/react/server-dom-client-optimized" { - export function createFromNodeStream( - stream: import("node:stream").Readable, - manifest: import(".").ClientReferenceManifest, - ): Promise; +declare module 'spiceflow/dist/react/server-dom-client-optimized' { + export function createFromNodeStream( + stream: import('node:stream').Readable, + manifest: import('.').ClientReferenceManifest, + ): Promise - export function createFromReadableStream( - stream: ReadableStream, - manifest: import(".").ClientReferenceManifest, - options: { - callServer: import(".").CallServerFn; - }, - ): Promise; + export function createFromReadableStream( + stream: ReadableStream, + manifest: import('.').ClientReferenceManifest, + options: { + callServer: import('.').CallServerFn + }, + ): Promise - export function createFromFetch( - fetchReturn: ReturnType, - manifest: unknown, - options: { - callServer: import(".").CallServerFn; - }, - ): Promise; + export function createFromFetch( + fetchReturn: ReturnType, + manifest: unknown, + options: { + callServer: import('.').CallServerFn + }, + ): Promise - export function encodeReply(v: unknown[]): Promise; + export function encodeReply(v: unknown[]): Promise } -declare module "virtual:ssr-assets" { - export const bootstrapModules: string[]; +declare module 'virtual:ssr-assets' { + export const bootstrapModules: string[] } -declare module "virtual:app-entry" { - import type { Spiceflow } from "spiceflow"; - const app: Spiceflow; - export default app +declare module 'virtual:app-entry' { + import type { Spiceflow } from 'spiceflow' + const app: Spiceflow + export default app } -declare module "virtual:build-client-references" { - const value: Record Promise>>; - export default value; +declare module 'virtual:build-client-references' { + const value: Record Promise>> + export default value } -declare module "virtual:build-server-references" { - const value: Record Promise>>; - export default value; +declare module 'virtual:build-server-references' { + const value: Record Promise>> + export default value } - -declare const __raw_import: (id: string) => Promise; -declare const __callServer: CallServerFn; +declare const __raw_import: (id: string) => Promise +declare const __callServer: CallServerFn diff --git a/spiceflow/src/react/types/index.ts b/spiceflow/src/react/types/index.ts index 59edbc4..d7499e2 100644 --- a/spiceflow/src/react/types/index.ts +++ b/spiceflow/src/react/types/index.ts @@ -1,19 +1,19 @@ export type ClientReferenceMetadataManifest = { - resolveClientReferenceMetadata(metadata: { $$id: string }): string; -}; + resolveClientReferenceMetadata(metadata: { $$id: string }): string +} export type ClientReferenceManifest = { - resolveClientReference(reference: string): { - preload(): Promise; - get(): unknown; - }; -}; + resolveClientReference(reference: string): { + preload(): Promise + get(): unknown + } +} export type ServerReferenceManifest = { - resolveServerReference(reference: string): { - preload(): Promise; - get(): unknown; - }; -}; + resolveServerReference(reference: string): { + preload(): Promise + get(): unknown + } +} -export type CallServerFn = (id: string, args: unknown[]) => unknown; +export type CallServerFn = (id: string, args: unknown[]) => unknown diff --git a/spiceflow/src/react/utils/client-reference.ts b/spiceflow/src/react/utils/client-reference.ts index 467496f..36d7fb9 100644 --- a/spiceflow/src/react/utils/client-reference.ts +++ b/spiceflow/src/react/utils/client-reference.ts @@ -9,7 +9,7 @@ export const clientReferenceManifest: ClientReferenceManifest = { let mod: Record if (import.meta.env.DEV) { // console.log('importing client reference', id) - console.log('importing client reference', id) + console.log('importing client reference', id) mod = typeof __raw_import !== 'undefined' ? // on browser development need to use __raw_import to not add ?import at the end, otherwise the browser duplicates the module instance, context stops working @@ -19,10 +19,12 @@ export const clientReferenceManifest: ClientReferenceManifest = { const references = await import( 'virtual:build-client-references' as string ) - const ref = references.default[id] - if (!ref) { - throw new Error(`Can't find client reference for module ${id}, among ${Object.keys(references.default).join(', ')}`) - } + const ref = references.default[id] + if (!ref) { + throw new Error( + `Can't find client reference for module ${id}, among ${Object.keys(references.default).join(', ')}`, + ) + } mod = await ref() } resolved = mod[name] diff --git a/spiceflow/src/react/utils/fetch.ts b/spiceflow/src/react/utils/fetch.ts index 2d45071..299a5f3 100644 --- a/spiceflow/src/react/utils/fetch.ts +++ b/spiceflow/src/react/utils/fetch.ts @@ -1,77 +1,77 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; -import { PassThrough, Readable } from "node:stream"; -import type { PipeableStream } from "react-dom/server"; +import type { IncomingMessage, ServerResponse } from 'node:http' +import { PassThrough, Readable } from 'node:stream' +import type { PipeableStream } from 'react-dom/server' export function createRequest( - req: IncomingMessage, - res: ServerResponse, + req: IncomingMessage, + res: ServerResponse, ): Request { - const abortController = new AbortController(); - res.once("close", () => { - if (req.destroyed) { - abortController.abort(); - } - }); + const abortController = new AbortController() + res.once('close', () => { + if (req.destroyed) { + abortController.abort() + } + }) - const headers = new Headers(); - for (const [k, v] of Object.entries(req.headers)) { - if (k.startsWith(":")) { - continue; - } - if (typeof v === "string") { - headers.set(k, v); - } else if (Array.isArray(v)) { - v.forEach((v) => headers.append(k, v)); - } - } + const headers = new Headers() + for (const [k, v] of Object.entries(req.headers)) { + if (k.startsWith(':')) { + continue + } + if (typeof v === 'string') { + headers.set(k, v) + } else if (Array.isArray(v)) { + v.forEach((v) => headers.append(k, v)) + } + } - return new Request( - new URL( - req.url || "/", - `${headers.get("x-forwarded-proto") ?? "http"}://${ - req.headers.host || "unknown.local" - }`, - ), - { - method: req.method, - body: - req.method === "GET" || req.method === "HEAD" - ? null - : (Readable.toWeb(req) as any), - headers, - signal: abortController.signal, - // @ts-ignore for undici - duplex: "half", - }, - ); + return new Request( + new URL( + req.url || '/', + `${headers.get('x-forwarded-proto') ?? 'http'}://${ + req.headers.host || 'unknown.local' + }`, + ), + { + method: req.method, + body: + req.method === 'GET' || req.method === 'HEAD' + ? null + : (Readable.toWeb(req) as any), + headers, + signal: abortController.signal, + // @ts-ignore for undici + duplex: 'half', + }, + ) } export function sendResponse(response: Response, res: ServerResponse) { - const headers = Object.fromEntries(response.headers); - if (headers["set-cookie"]) { - delete headers["set-cookie"]; - res.setHeader("set-cookie", response.headers.getSetCookie()); - } - res.writeHead(response.status, response.statusText, headers); + const headers = Object.fromEntries(response.headers) + if (headers['set-cookie']) { + delete headers['set-cookie'] + res.setHeader('set-cookie', response.headers.getSetCookie()) + } + res.writeHead(response.status, response.statusText, headers) - if (response.body) { - const abortController = new AbortController(); - res.once("close", () => abortController.abort()); - res.once("error", () => abortController.abort()); - Readable.fromWeb(response.body as any, { - signal: abortController.signal, - }).pipe(res); - } else { - res.end(); - } + if (response.body) { + const abortController = new AbortController() + res.once('close', () => abortController.abort()) + res.once('error', () => abortController.abort()) + Readable.fromWeb(response.body as any, { + signal: abortController.signal, + }).pipe(res) + } else { + res.end() + } } export function fromPipeableToWebReadable(stream: PipeableStream) { - return Readable.toWeb( - stream.pipe(new PassThrough()), - ) as ReadableStream; + return Readable.toWeb( + stream.pipe(new PassThrough()), + ) as ReadableStream } export function fromWebToNodeReadable(stream: ReadableStream) { - return Readable.fromWeb(stream as any); + return Readable.fromWeb(stream as any) } diff --git a/spiceflow/src/react/utils/normalize.ts b/spiceflow/src/react/utils/normalize.ts index ce94c91..c5e5ac0 100644 --- a/spiceflow/src/react/utils/normalize.ts +++ b/spiceflow/src/react/utils/normalize.ts @@ -8,7 +8,7 @@ import { ModuleNode, ViteDevServer } from 'vite' export function noramlizeClientReferenceId( id: string, parentServer: ViteDevServer, - mod?: ModuleNode + mod?: ModuleNode, ) { const root = parentServer.config.root if (id.startsWith(root)) { @@ -21,7 +21,7 @@ export function noramlizeClientReferenceId( // this is needed only for browser, so we'll strip it off // during ssr client reference import // TODO - + if (mod && mod.lastHMRTimestamp > 0) { id += `?t=${mod.lastHMRTimestamp}` } diff --git a/spiceflow/src/spiceflow.test.ts b/spiceflow/src/spiceflow.test.ts index da3502e..9342b6b 100644 --- a/spiceflow/src/spiceflow.test.ts +++ b/spiceflow/src/spiceflow.test.ts @@ -424,9 +424,8 @@ test('extractWildcardParam correctly extracts wildcard segments', () => { } `) - expect( - extractWildcardParam('/files/123/path/to/file.txt', '/files/:id/*'), - ).toMatchInlineSnapshot(` + expect(extractWildcardParam('/files/123/path/to/file.txt', '/files/:id/*')) + .toMatchInlineSnapshot(` { "*": "path/to/file.txt", } diff --git a/spiceflow/src/trie-router/node.ts b/spiceflow/src/trie-router/node.ts index fbdc7e6..6b9b820 100644 --- a/spiceflow/src/trie-router/node.ts +++ b/spiceflow/src/trie-router/node.ts @@ -1,6 +1,5 @@ -import { Pattern, splitRoutingPath, getPattern, splitPath } from "./url.js" -import { Params } from "./utils.js" - +import { Pattern, splitRoutingPath, getPattern, splitPath } from './url.js' +import { Params } from './utils.js' const METHOD_NAME_ALL = 'ALL' @@ -24,7 +23,11 @@ export class Node { #order: number = 0 #params: Record = emptyParams - constructor(method?: string, handler?: T, children?: Record>) { + constructor( + method?: string, + handler?: T, + children?: Record>, + ) { this.#children = children || Object.create(null) this.#methods = [] if (method && handler) { @@ -86,12 +89,13 @@ export class Node { node: Node, method: string, nodeParams: Record, - params?: Record + params?: Record, ): HandlerParamsSet[] { const handlerSets: HandlerParamsSet[] = [] for (let i = 0, len = node.#methods.length; i < len; i++) { const m = node.#methods[i] - const handlerSet = (m[method] || m[METHOD_NAME_ALL]) as HandlerParamsSet + const handlerSet = (m[method] || + m[METHOD_NAME_ALL]) as HandlerParamsSet const processedSet: Record = {} if (handlerSet !== undefined) { handlerSet.params = Object.create(null) @@ -101,7 +105,9 @@ export class Node { const key = handlerSet.possibleKeys[i] const processed = processedSet[handlerSet.score] handlerSet.params[key] = - params?.[key] && !processed ? params[key] : nodeParams[key] ?? params?.[key] + params?.[key] && !processed + ? params[key] + : (nodeParams[key] ?? params?.[key]) processedSet[handlerSet.score] = true } } @@ -135,10 +141,16 @@ export class Node { // '/hello/*' => match '/hello' if (nextNode.#children['*']) { handlerSets.push( - ...this.#getHandlerSets(nextNode.#children['*'], method, node.#params) + ...this.#getHandlerSets( + nextNode.#children['*'], + method, + node.#params, + ), ) } - handlerSets.push(...this.#getHandlerSets(nextNode, method, node.#params)) + handlerSets.push( + ...this.#getHandlerSets(nextNode, method, node.#params), + ) } else { tempNodes.push(nextNode) } @@ -153,7 +165,9 @@ export class Node { if (pattern === '*') { const astNode = node.#children['*'] if (astNode) { - handlerSets.push(...this.#getHandlerSets(astNode, method, node.#params)) + handlerSets.push( + ...this.#getHandlerSets(astNode, method, node.#params), + ) astNode.#params = params tempNodes.push(astNode) } @@ -174,7 +188,9 @@ export class Node { const m = matcher.exec(restPathString) if (m) { params[name] = m[0] - handlerSets.push(...this.#getHandlerSets(child, method, node.#params, params)) + handlerSets.push( + ...this.#getHandlerSets(child, method, node.#params, params), + ) if (Object.keys(child.#children).length) { child.#params = params @@ -190,10 +206,17 @@ export class Node { if (matcher === true || matcher.test(part)) { params[name] = part if (isLast) { - handlerSets.push(...this.#getHandlerSets(child, method, params, node.#params)) + handlerSets.push( + ...this.#getHandlerSets(child, method, params, node.#params), + ) if (child.#children['*']) { handlerSets.push( - ...this.#getHandlerSets(child.#children['*'], method, params, node.#params) + ...this.#getHandlerSets( + child.#children['*'], + method, + params, + node.#params, + ), ) } } else { @@ -213,6 +236,10 @@ export class Node { }) } - return [handlerSets.map(({ handler, params }) => [handler, params] as [T, Params])] + return [ + handlerSets.map( + ({ handler, params }) => [handler, params] as [T, Params], + ), + ] } } diff --git a/spiceflow/src/trie-router/router.ts b/spiceflow/src/trie-router/router.ts index 87eef7f..42f675a 100644 --- a/spiceflow/src/trie-router/router.ts +++ b/spiceflow/src/trie-router/router.ts @@ -1,5 +1,3 @@ - - import { Node } from './node.js' import { checkOptionalParameter, Result } from './utils.js' diff --git a/spiceflow/src/trie-router/url.ts b/spiceflow/src/trie-router/url.ts index e1c31f0..bc0e4ab 100644 --- a/spiceflow/src/trie-router/url.ts +++ b/spiceflow/src/trie-router/url.ts @@ -20,7 +20,9 @@ export const splitRoutingPath = (routePath: string): string[] => { return replaceGroupMarks(paths, groups) } -const extractGroupsFromPath = (path: string): { groups: [string, string][]; path: string } => { +const extractGroupsFromPath = ( + path: string, +): { groups: [string, string][]; path: string } => { const groups: [string, string][] = [] path = path.replace(/\{[^}]+\}/g, (match, index) => { @@ -32,7 +34,10 @@ const extractGroupsFromPath = (path: string): { groups: [string, string][]; path return { groups, path } } -const replaceGroupMarks = (paths: string[], groups: [string, string][]): string[] => { +const replaceGroupMarks = ( + paths: string[], + groups: [string, string][], +): string[] => { for (let i = groups.length - 1; i >= 0; i--) { const [mark] = groups[i] @@ -115,7 +120,9 @@ export const getPath = (request: Request): string => { // Although this is a performance disadvantage, it is acceptable since we prefer cases that do not include percent encoding. const queryIndex = url.indexOf('?', i) const path = url.slice(start, queryIndex === -1 ? undefined : queryIndex) - return tryDecodeURI(path.includes('%25') ? path.replace(/%25/g, '%2525') : path) + return tryDecodeURI( + path.includes('%25') ? path.replace(/%25/g, '%2525') : path, + ) } else if (charCode === 63) { // '?' break @@ -133,7 +140,9 @@ export const getPathNoStrict = (request: Request): string => { const result = getPath(request) // if strict routing is false => `/hello/hey/` and `/hello/hey` are treated the same - return result.length > 1 && result.at(-1) === '/' ? result.slice(0, -1) : result + return result.length > 1 && result.at(-1) === '/' + ? result.slice(0, -1) + : result } export const mergePath = (...paths: string[]): string => { @@ -219,8 +228,13 @@ const _decodeURI = (value: string) => { const _getQueryParam = ( url: string, key?: string, - multiple?: boolean -): string | undefined | Record | string[] | Record => { + multiple?: boolean, +): + | string + | undefined + | Record + | string[] + | Record => { let encoded if (!multiple && key && !/[%+]/.test(key)) { @@ -235,7 +249,9 @@ const _getQueryParam = ( if (trailingKeyCode === 61) { const valueIndex = keyIndex + key.length + 2 const endIndex = url.indexOf('&', valueIndex) - return _decodeURI(url.slice(valueIndex, endIndex === -1 ? undefined : endIndex)) + return _decodeURI( + url.slice(valueIndex, endIndex === -1 ? undefined : endIndex), + ) } else if (trailingKeyCode == 38 || isNaN(trailingKeyCode)) { return '' } @@ -261,7 +277,11 @@ const _getQueryParam = ( } let name = url.slice( keyIndex + 1, - valueIndex === -1 ? (nextKeyIndex === -1 ? undefined : nextKeyIndex) : valueIndex + valueIndex === -1 + ? nextKeyIndex === -1 + ? undefined + : nextKeyIndex + : valueIndex, ) if (encoded) { name = _decodeURI(name) @@ -277,7 +297,10 @@ const _getQueryParam = ( if (valueIndex === -1) { value = '' } else { - value = url.slice(valueIndex + 1, nextKeyIndex === -1 ? undefined : nextKeyIndex) + value = url.slice( + valueIndex + 1, + nextKeyIndex === -1 ? undefined : nextKeyIndex, + ) if (encoded) { value = _decodeURI(value) } @@ -298,19 +321,22 @@ const _getQueryParam = ( export const getQueryParam: ( url: string, - key?: string + key?: string, ) => string | undefined | Record = _getQueryParam as ( url: string, - key?: string + key?: string, ) => string | undefined | Record export const getQueryParams = ( url: string, - key?: string + key?: string, ): string[] | undefined | Record => { - return _getQueryParam(url, key, true) as string[] | undefined | Record + return _getQueryParam(url, key, true) as + | string[] + | undefined + | Record } // `decodeURIComponent` is a long name. // By making it a function, we can use it commonly when minified, reducing the amount of code. -export const decodeURIComponent_ = decodeURIComponent \ No newline at end of file +export const decodeURIComponent_ = decodeURIComponent diff --git a/spiceflow/src/trie-router/utils.ts b/spiceflow/src/trie-router/utils.ts index 7081aae..aaacc47 100644 --- a/spiceflow/src/trie-router/utils.ts +++ b/spiceflow/src/trie-router/utils.ts @@ -54,7 +54,7 @@ export type Params = Record * An array of handlers with their corresponding parameter maps. * * Example: - * + * * [[handler, params][]] * ```typescript * [ @@ -67,4 +67,3 @@ export type Params = Record * ``` */ export type Result = [[T, Params][]] - diff --git a/spiceflow/src/types.ts b/spiceflow/src/types.ts index b75b028..2bcf6cb 100644 --- a/spiceflow/src/types.ts +++ b/spiceflow/src/types.ts @@ -1,7 +1,6 @@ // https://github.com/remorses/elysia/blob/main/src/types.ts#L6 import Ajv, { ValidateFunction } from 'ajv' - import z from 'zod' import type { @@ -33,8 +32,8 @@ export type ObjectValues = T[keyof T] type IsPathParameter = Part extends `:${infer Parameter}` ? Parameter : Part extends `*` - ? '*' - : never + ? '*' + : never export type GetPathParameter = Path extends `${infer A}/${infer B}` @@ -70,15 +69,16 @@ export type NeverKey = { [K in keyof T]?: T[K] } & {} -type IsBothObject = A extends Record - ? B extends Record - ? IsClass extends false - ? IsClass extends false - ? true +type IsBothObject = + A extends Record + ? B extends Record + ? IsClass extends false + ? IsClass extends false + ? true + : false : false : false : false - : false type IsClass = V extends abstract new (...args: any) => any ? true : false @@ -91,57 +91,57 @@ export type Reconcile< > = Stack['length'] extends 16 ? A : Override extends true - ? { - [key in keyof A as key extends keyof B ? never : key]: A[key] - } extends infer Collision - ? {} extends Collision - ? { - [key in keyof B]: IsBothObject< - // @ts-ignore trust me bro - A[key], - B[key] - > extends true - ? Reconcile< - // @ts-ignore trust me bro - A[key], - B[key], - Override, - [0, ...Stack] - > - : B[key] - } - : Prettify< - Collision & { - [key in keyof B]: B[key] - } - > - : never - : { - [key in keyof B as key extends keyof A ? never : key]: B[key] - } extends infer Collision - ? {} extends Collision ? { - [key in keyof A]: IsBothObject< - A[key], - // @ts-ignore trust me bro - B[key] - > extends true - ? Reconcile< + [key in keyof A as key extends keyof B ? never : key]: A[key] + } extends infer Collision + ? {} extends Collision + ? { + [key in keyof B]: IsBothObject< // @ts-ignore trust me bro + A[key], + B[key] + > extends true + ? Reconcile< + // @ts-ignore trust me bro + A[key], + B[key], + Override, + [0, ...Stack] + > + : B[key] + } + : Prettify< + Collision & { + [key in keyof B]: B[key] + } + > + : never + : { + [key in keyof B as key extends keyof A ? never : key]: B[key] + } extends infer Collision + ? {} extends Collision + ? { + [key in keyof A]: IsBothObject< A[key], // @ts-ignore trust me bro - B[key], - Override, - [0, ...Stack] - > - : A[key] - } - : Prettify< - { - [key in keyof A]: A[key] - } & Collision - > - : never + B[key] + > extends true + ? Reconcile< + // @ts-ignore trust me bro + A[key], + // @ts-ignore trust me bro + B[key], + Override, + [0, ...Stack] + > + : A[key] + } + : Prettify< + { + [key in keyof A]: A[key] + } & Collision + > + : never export interface SingletonBase { state: Record @@ -181,16 +181,16 @@ export type UnwrapSchema< > = undefined extends Schema ? unknown : Schema extends ZodTypeAny - ? z.infer - : Schema extends TSchema - ? Schema extends OptionalField - ? Prettify>> - : StaticDecode - : Schema extends string - ? Definitions extends Record - ? NamedSchema - : Definitions - : unknown + ? z.infer + : Schema extends TSchema + ? Schema extends OptionalField + ? Prettify>> + : StaticDecode + : Schema extends string + ? Definitions extends Record + ? NamedSchema + : Definitions + : unknown export interface UnwrapRoute< in out Schema extends InputSchema, @@ -204,13 +204,13 @@ export interface UnwrapRoute< 200: UnwrapSchema } : Schema['response'] extends Record - ? { - [k in keyof Schema['response']]: UnwrapSchema< - Schema['response'][k], - Definitions - > - } - : unknown | void + ? { + [k in keyof Schema['response']]: UnwrapSchema< + Schema['response'][k], + Definitions + > + } + : unknown | void } export type LifeCycleEvent = @@ -299,8 +299,8 @@ export interface MergeSchema< ? {} : B['response'] : {} extends B['response'] - ? A['response'] - : A['response'] & Omit + ? A['response'] + : A['response'] & Omit } export type Handler< @@ -317,29 +317,31 @@ export type Handler< : Route['response'][keyof Route['response']] > -export type Replace = IsAny extends true - ? Original - : Original extends Record - ? { - [K in keyof Original]: Original[K] extends Target ? With : Original[K] - } - : Original extends Target - ? With - : Original +export type Replace = + IsAny extends true + ? Original + : Original extends Record + ? { + [K in keyof Original]: Original[K] extends Target ? With : Original[K] + } + : Original extends Target + ? With + : Original export type IsAny = 0 extends 1 & T ? true : false -export type CoExist = IsAny extends true - ? Original - : Original extends Record - ? { - [K in keyof Original]: Original[K] extends Target - ? Original[K] | With - : Original[K] - } - : Original extends Target - ? Original | With - : Original +export type CoExist = + IsAny extends true + ? Original + : Original extends Record + ? { + [K in keyof Original]: Original[K] extends Target + ? Original[K] | With + : Original[K] + } + : Original extends Target + ? Original | With + : Original export type InlineHandler< Route extends RouteSchema = {}, @@ -376,11 +378,12 @@ export type OptionalHandler< state: {} }, Path extends string = '', -> = Handler extends ( - context: infer Context, -) => infer Returned - ? (context: Context) => Returned | MaybePromise - : never +> = + Handler extends ( + context: infer Context, + ) => infer Returned + ? (context: Context) => Returned | MaybePromise + : never export type AfterHandler< in out Route extends RouteSchema = {}, @@ -388,17 +391,18 @@ export type AfterHandler< state: {} }, Path extends string = '', -> = Handler extends ( - context: infer Context, -) => infer Returned - ? ( - context: Prettify< - { - response: Route['response'] - } & Context - >, - ) => Returned | MaybePromise - : never +> = + Handler extends ( + context: infer Context, + ) => infer Returned + ? ( + context: Prettify< + { + response: Route['response'] + } & Context + >, + ) => Returned | MaybePromise + : never export type MapResponse< in out Route extends RouteSchema = {}, @@ -643,8 +647,8 @@ export type CreateClient< > = Path extends `/${infer Rest}` ? _CreateClient : Path extends '' - ? _CreateClient<'index', Property> - : _CreateClient + ? _CreateClient<'index', Property> + : _CreateClient export type ComposeSpiceflowResponse = Handle extends ( ...a: any[] @@ -880,11 +884,10 @@ export type HTTPHeaders = Record & { export type JoinPath = `${A}${B extends '/' ? '/index' : B extends '' - ? B - : B extends `/${string}` - ? B - : B}` + ? B + : B extends `/${string}` + ? B + : B}` export type PartialWithRequired = Partial> & Pick - diff --git a/spiceflow/src/vite-jacob.ts b/spiceflow/src/vite-jacob.ts index d24d26c..5386b14 100644 --- a/spiceflow/src/vite-jacob.ts +++ b/spiceflow/src/vite-jacob.ts @@ -39,9 +39,7 @@ export default function reactServerDOM(): vite.PluginOption { return noramlizeClientReferenceId(filename, server) } - return [ - - ] + return [] } function rollupInputsToArray( From 581517857364dadb12ee30ba527c828a6a8bfb92 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 16:49:19 +0100 Subject: [PATCH 043/226] put rsc entry inside spiceflow --- spiceflow/src/react/entry.rsc.tsx | 105 ++---------------------- spiceflow/src/react/entry.ssr.tsx | 17 ++-- spiceflow/src/spiceflow.ts | 131 ++++++++++++++++++++++++++---- 3 files changed, 128 insertions(+), 125 deletions(-) diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx index 10437c3..6b4a25c 100644 --- a/spiceflow/src/react/entry.rsc.tsx +++ b/spiceflow/src/react/entry.rsc.tsx @@ -14,105 +14,10 @@ export interface RscHandlerResult { stream: ReadableStream } -export interface ServerPayload { - root: FlightData - formState?: ReactFormState - returnValue?: unknown -} - export async function handler(url: URL, request: Request) { // handle action - let returnValue: unknown | undefined - let formState: ReactFormState | undefined - if (request.method === 'POST') { - const actionId = url.searchParams.get('__rsc') - if (actionId) { - // client stream request - const contentType = request.headers.get('content-type') - const body = contentType?.startsWith('multipart/form-data') - ? await request.formData() - : await request.text() - const args = await ReactServer.decodeReply(body) - const reference = serverReferenceManifest.resolveServerReference(actionId) - await reference.preload() - const action = await reference.get() - returnValue = await (action as any).apply(null, args) - } else { - // progressive enhancement - const formData = await request.formData() - console.log(formData) - const decodedAction = await ReactServer.decodeAction( - formData, - serverReferenceManifest, - ) - formState = await ReactServer.decodeFormState( - await decodedAction(), - formData, - serverReferenceManifest, - ) - } - } - - const root = await app.handle(request) - - if (root instanceof Response) { - return root - } - const { page, layouts } = root - - let abortable = ReactServer.renderToPipeableStream( - { - root, - returnValue, - formState, - }, - clientReferenceMetadataManifest, - { onError(error) {} }, - ) - // render flight stream - const stream = fromPipeableToWebReadable(abortable) - request.signal.addEventListener('abort', () => { - abortable.abort() - }) - - let r: RscHandlerResult = { - stream, - } - return r -} - -const serverReferenceManifest: ServerReferenceManifest = { - resolveServerReference(reference: string) { - const [id, name] = reference.split('#') - let resolved: unknown - return { - async preload() { - let mod: Record - if (import.meta.env.DEV) { - mod = await import(/* @vite-ignore */ id) - } else { - const references = await import('virtual:build-server-references') - const ref = references.default[id] - if (!ref) { - const availableKeys = Object.keys(references.default) - throw new Error( - `Could not find server reference for id: ${id}. This likely means the server reference was not properly registered. Available reference keys are: ${availableKeys.join(', ')}`, - ) - } - mod = await ref() - } - resolved = mod[name] - }, - get() { - return resolved - }, - } - }, -} - -const clientReferenceMetadataManifest: ClientReferenceMetadataManifest = { - resolveClientReferenceMetadata(metadata) { - // console.log("[debug:resolveClientReferenceMetadata]", { metadata }, Object.getOwnPropertyDescriptors(metadata)); - return metadata.$$id - }, -} + + const response = await app.handle(request) + + return response +} \ No newline at end of file diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 1dc4d1a..6731766 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -23,24 +23,19 @@ export default async function handler( const request = createRequest(req, res) const url = new URL(request.url) const rscEntry = await importRscEntry() - const rscResult = await rscEntry.handler(url, request) + const response = await rscEntry.handler(url, request) - if (rscResult instanceof Response) { - sendResponse(rscResult, res) + if (!response.headers.get('content-type')?.startsWith('text/x-component')) { + sendResponse(response, res) return } if (url.searchParams.has('__rsc')) { - const response = new Response(rscResult.stream, { - headers: { - 'content-type': 'text/x-component;charset=utf-8', - }, - }) sendResponse(response, res) return } - const [flightStream1, flightStream2] = rscResult.stream.tee() + const [flightStream1, flightStream2] = response.body!.tee() const payload = await ReactClient.createFromNodeStream( fromWebToNodeReadable(flightStream1), @@ -66,7 +61,7 @@ export default async function handler( }), ) - const response = new Response( + const htmlResponse = new Response( htmlStream.pipeThrough(injectRSCPayload(flightStream2)), { headers: { @@ -74,7 +69,7 @@ export default async function handler( }, }, ) - sendResponse(response, res) + sendResponse(htmlResponse, res) } declare let __rscRunner: ModuleRunner diff --git a/spiceflow/src/spiceflow.ts b/spiceflow/src/spiceflow.ts index af2bfe3..32ab0ed 100644 --- a/spiceflow/src/spiceflow.ts +++ b/spiceflow/src/spiceflow.ts @@ -1,14 +1,15 @@ +import type { ReactFormState } from 'react-dom/client' +import ReactServer from 'spiceflow/dist/react/server-dom-optimized' + import addFormats from 'ajv-formats' import lodashCloneDeep from 'lodash.clonedeep' import superjson from 'superjson' import { ComposeSpiceflowResponse, - ContentType, CreateClient, DefinitionBase, ErrorHandler, - HTTPMethod, InlineHandler, InputSchema, InternalRoute, @@ -25,29 +26,27 @@ import { RouteSchema, SingletonBase, TypeSchema, - UnwrapRoute, + UnwrapRoute } from './types.js' let globalIndex = 0 import Ajv, { ValidateFunction } from 'ajv' +import { createElement } from 'react' import { z, ZodType } from 'zod' import { zodToJsonSchema } from 'zod-to-json-schema' -import { Context, MiddlewareContext } from './context.js' +import { MiddlewareContext } from './context.js' import { isProduction, ValidationError } from './error.js' import { isAsyncIterable, isResponse, redirect } from './utils.js' -import { createElement, isValidElement } from 'react' -import value from 'virtual:build-client-references' + import { FlightData, LayoutContent } from './react/components.js' +import { ClientReferenceMetadataManifest, ServerReferenceManifest } from './react/types/index.js' +import { fromPipeableToWebReadable } from './react/utils/fetch.js' import { TrieRouter } from './trie-router/router.js' +import { decodeURIComponent_ } from './trie-router/url.js' import { - ParamIndexMap, - Params, - ParamStash, - Result, + Result } from './trie-router/utils.js' -import { decodeURIComponent_, tryDecode } from './trie-router/url.js' -import path from 'path' const ajv = (addFormats.default || addFormats)( new (Ajv.default || Ajv)({ useDefaults: true }), @@ -905,12 +904,71 @@ export class Spiceflow< }), ]) - let data: FlightData = { + let root: FlightData = { url: request.url, page, layouts, } - return data + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + if (request.method === 'POST') { + const url = new URL(request.url) + const actionId = url.searchParams.get('__rsc') + if (actionId) { + // client stream request + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + const args = await ReactServer.decodeReply(body) + const reference = + serverReferenceManifest.resolveServerReference(actionId) + await reference.preload() + const action = await reference.get() + returnValue = await (action as any).apply(null, args) + } else { + // progressive enhancement + const formData = await request.formData() + console.log(formData) + const decodedAction = await ReactServer.decodeAction( + formData, + serverReferenceManifest, + ) + formState = await ReactServer.decodeFormState( + await decodedAction(), + formData, + serverReferenceManifest, + ) + } + } + + + + if (root instanceof Response) { + return root + } + + + let abortable = ReactServer.renderToPipeableStream( + { + root, + returnValue, + formState, + }, + clientReferenceMetadataManifest, + { onError(error) {} }, + ) + // render flight stream + const stream = fromPipeableToWebReadable(abortable) + request.signal.addEventListener('abort', () => { + abortable.abort() + }) + + return new Response(stream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8' + } + }) } catch (err) { return await getResForError(err) } @@ -1617,3 +1675,48 @@ function partition(arr: T[], predicate: (item: T) => boolean): [T[], T[]] { [[], []] as [T[], T[]], ) } + + + +const serverReferenceManifest: ServerReferenceManifest = { + resolveServerReference(reference: string) { + const [id, name] = reference.split('#') + let resolved: unknown + return { + async preload() { + let mod: Record + if (import.meta.env.DEV) { + mod = await import(/* @vite-ignore */ id) + } else { + const references = await import('virtual:build-server-references') + const ref = references.default[id] + if (!ref) { + const availableKeys = Object.keys(references.default) + throw new Error( + `Could not find server reference for id: ${id}. This likely means the server reference was not properly registered. Available reference keys are: ${availableKeys.join(', ')}`, + ) + } + mod = await ref() + } + resolved = mod[name] + }, + get() { + return resolved + }, + } + }, +} + +const clientReferenceMetadataManifest: ClientReferenceMetadataManifest = { + resolveClientReferenceMetadata(metadata) { + // console.log("[debug:resolveClientReferenceMetadata]", { metadata }, Object.getOwnPropertyDescriptors(metadata)); + return metadata.$$id + }, +} + + +export interface ServerPayload { + root: FlightData + formState?: ReactFormState + returnValue?: unknown +} From c74c6af245e10238a2a0712e3c82091b4b4a69a3 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 16:52:55 +0100 Subject: [PATCH 044/226] fix tsc errors --- spiceflow/src/react/components.tsx | 2 - spiceflow/src/react/entry.client.tsx | 3 +- spiceflow/src/react/entry.ssr.tsx | 3 +- spiceflow/src/react/types/ambient.d.ts | 13 +++ spiceflow/src/vite-jacob.ts | 114 ------------------------- spiceflow/src/vite.tsx | 18 ++-- 6 files changed, 23 insertions(+), 130 deletions(-) delete mode 100644 spiceflow/src/vite-jacob.ts diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index 8bd2caf..99660ef 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -1,9 +1,7 @@ 'use client' import React from 'react' -import { RouteMatch } from '../router.js' import { ReactFormState } from 'react-dom/client' -import { InternalRoute } from '../types.js' export const FlightDataContext = React.createContext(undefined!) // Get $$id property that was set by registerClientReference diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index 2979e7e..50bdd27 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -1,11 +1,12 @@ import React from 'react' import ReactDomClient from 'react-dom/client' import ReactClient from 'spiceflow/dist/react/server-dom-client-optimized' -import type { ServerPayload } from './entry.rsc.js' + import type { CallServerFn } from './types/index.js' import { clientReferenceManifest } from './utils/client-reference.js' import { rscStream } from 'rsc-html-stream/client' import { FlightDataContext } from './components.js' +import { ServerPayload } from '../spiceflow.js' async function main() { const callServer: CallServerFn = async (id, args) => { diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 6731766..871fc50 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import ReactDomServer from 'react-dom/server' import ReactClient from 'spiceflow/dist/react/server-dom-client-optimized' import type { ModuleRunner } from 'vite/module-runner' -import type { ServerPayload } from './entry.rsc.js' + import { createRequest, @@ -15,6 +15,7 @@ import { FlightDataContext } from './components.js' import { bootstrapModules } from 'virtual:ssr-assets' import { clientReferenceManifest } from './utils/client-reference.js' import cssUrls from 'virtual:app-styles' +import { ServerPayload } from '../spiceflow.js' export default async function handler( req: IncomingMessage, diff --git a/spiceflow/src/react/types/ambient.d.ts b/spiceflow/src/react/types/ambient.d.ts index 37f080b..e8f2fed 100644 --- a/spiceflow/src/react/types/ambient.d.ts +++ b/spiceflow/src/react/types/ambient.d.ts @@ -27,6 +27,15 @@ declare module 'react-server-dom-vite/server' { body: FormData, manifest: import('.').ServerReferenceManifest, ): Promise + const defaultExport: { + registerServerReference: Function + registerClientReference: Function + decodeReply: decodeReply + decodeAction: decodeAction + decodeFormState: decodeFormState + renderToPipeableStream: renderToPipeableStream + } + export default defaultExport } declare module 'spiceflow/dist/react/server-dom-client-optimized' { @@ -57,6 +66,10 @@ declare module 'spiceflow/dist/react/server-dom-client-optimized' { declare module 'virtual:ssr-assets' { export const bootstrapModules: string[] } +declare module 'virtual:app-styles' { + const cssUrls: string[] + export default cssUrls +} declare module 'virtual:app-entry' { import type { Spiceflow } from 'spiceflow' diff --git a/spiceflow/src/vite-jacob.ts b/spiceflow/src/vite-jacob.ts deleted file mode 100644 index 5386b14..0000000 --- a/spiceflow/src/vite-jacob.ts +++ /dev/null @@ -1,114 +0,0 @@ -import * as crypto from 'node:crypto' -import * as fs from 'node:fs' -import * as path from 'node:path' -import react, { type Options as ReactOptions } from '@vitejs/plugin-react' -import { clientTransform, serverTransform } from 'unplugin-rsc' -import * as vite from 'vite' -import { debugTransformResult } from './vite.js' -import { noramlizeClientReferenceId } from './react/utils/normalize.js' - -export default function reactServerDOM(): vite.PluginOption { - let env: vite.ConfigEnv - const serverEnvironments = new Set(['rsc']) - const ssrEnvironments = new Set(['ssr']) - const browserEnvironment = 'client' - - const clientEntries = new Set() - const clientModules = new Map() - const serverModules = new Map() - let browserOutput: vite.Rollup.RollupOutput | undefined - - let server: vite.ViteDevServer - let generateId = (filename, directive) => { - if (env.command === 'build') { - const hash = crypto - .createHash('sha256') - .update(filename) - .digest('hex') - .slice(0, 8) - - if (directive === 'use server') { - serverModules.set(filename, hash) - return hash - } - - clientModules.set(filename, hash) - return hash - } - - return noramlizeClientReferenceId(filename, server) - } - - return [] -} - -function rollupInputsToArray( - rollupInputs: vite.Rollup.InputOption | undefined, -) { - return Array.isArray(rollupInputs) - ? rollupInputs - : typeof rollupInputs === 'string' - ? [rollupInputs] - : rollupInputs - ? Object.values(rollupInputs) - : [] -} - -function collectChunks( - base: string, - forFilename: string, - manifest: Record, - collected: Set = new Set(), -) { - if (manifest[forFilename]) { - collected.add(base + manifest[forFilename].file) - for (const imp of manifest[forFilename].imports ?? []) { - collectChunks(base, imp, manifest, collected) - } - } - - return Array.from(collected) -} - -function moveStaticAssets( - output: vite.Rollup.RollupOutput, - outDir: string, - clientOutDir: string, -) { - const manifestAsset = output.output.find( - (asset) => asset.fileName === '.vite/ssr-manifest.json', - ) - if (!manifestAsset || manifestAsset.type !== 'asset') - throw new Error('could not find manifest') - const manifest = JSON.parse(manifestAsset.source as string) - - const processed = new Set() - for (const assets of Object.values(manifest) as string[][]) { - for (const asset of assets) { - const fullPath = path.join(outDir, asset.slice(1)) - - if (asset.endsWith('.js') || processed.has(fullPath)) continue - processed.add(fullPath) - - if (!fs.existsSync(fullPath)) continue - - const relative = path.relative(outDir, fullPath) - fs.renameSync(fullPath, path.join(clientOutDir, relative)) - } - } -} - -const EXTENSIONS_TO_TRANSFORM = new Set([ - '.js', - '.jsx', - '.cjs', - '.cjsx', - '.mjs', - '.mjsx', - '.ts', - '.tsx', - '.cts', - '.ctsx', - '.mts', - '.mtsx', -]) diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 2f88b4c..c67e70b 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -1,28 +1,22 @@ import assert from 'node:assert' import * as vite from 'vite' +import react from '@vitejs/plugin-react' +import crypto from 'node:crypto' import fs from 'node:fs' -import url from 'node:url' import path from 'node:path' -import react from '@vitejs/plugin-react' +import url, { fileURLToPath } from 'node:url' +import { clientTransform, serverTransform } from 'unplugin-rsc' import { type Manifest, type Plugin, PluginOption, type RunnableDevEnvironment, - UserConfig, ViteDevServer, - createRunnableDevEnvironment, - createServerModuleRunner, - defineConfig, + createRunnableDevEnvironment } from 'vite' -import { fileURLToPath } from 'node:url' -import crypto from 'node:crypto' -import reactServerDOM from './vite-jacob.js' -import { serverTransform, clientTransform } from 'unplugin-rsc' -import { noramlizeClientReferenceId } from './react/utils/normalize.js' import { collectStyleUrls } from './react/css.js' -import { normalizeId } from 'ajv/dist/compile/resolve.js' +import { noramlizeClientReferenceId } from './react/utils/normalize.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) From 7cdecea01fc1df4db05b19e2ae4419ae31bab454 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 17:39:02 +0100 Subject: [PATCH 045/226] adding a bit of error handling in ssr and client --- spiceflow/src/react/components.tsx | 125 +++++++++++++++++++++++++-- spiceflow/src/react/entry.client.tsx | 18 ++-- spiceflow/src/react/entry.ssr.tsx | 54 +++++++++--- 3 files changed, 175 insertions(+), 22 deletions(-) diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index 99660ef..2b06c04 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -1,6 +1,6 @@ 'use client' -import React from 'react' +import React, { Suspense } from 'react' import { ReactFormState } from 'react-dom/client' export const FlightDataContext = React.createContext(undefined!) @@ -13,13 +13,18 @@ export function useFlightData() { export function LayoutContent(props: { id: string }) { const data = useFlightData() - const layoutIndex = data.layouts.findIndex((layout) => layout.id === props.id) - let nextLayout = data.layouts[layoutIndex + 1]?.element - if (nextLayout) { - return nextLayout - } + const elem = (() => { + const layoutIndex = data.layouts.findIndex( + (layout) => layout.id === props.id, + ) + let nextLayout = data.layouts[layoutIndex + 1]?.element + if (nextLayout) { + return nextLayout + } - return data.page + return data.page + })() + return elem } export type FlightData = { @@ -43,3 +48,109 @@ interface ReactServerErrorContext { status: number headers?: Record } + +export interface ErrorPageProps { + error: Error + serverError?: ReactServerErrorContext + reset: () => void +} + +interface Props { + children?: React.ReactNode + errorComponent: React.FC +} + +interface State { + error: Error | null +} + +function isRedirectError(ctx: ReactServerErrorContext) { + return ctx.status >= 300 && ctx.status < 400 +} + +function isNotFoundError(ctx: ReactServerErrorContext) { + return ctx.status === 404 +} + +function getErrorContext(error: Error): ReactServerErrorContext | undefined { + return (error as any).serverError +} + +export class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props) + this.state = { error: null } + } + + static getDerivedStateFromError(error: Error) { + const ctx = getErrorContext(error) + if (ctx && (isNotFoundError(ctx) || isRedirectError(ctx))) { + throw error + } + return { error } + } + + reset = () => { + React.startTransition(() => { + this.setState({ error: null }) + }) + } + + override render() { + const error = this.state.error + if (error) { + return ( + <> + + + + ) + } + return this.props.children + } +} + +function ErrorAutoReset(props: Pick) { + // TODO + // const href = useRouter((s) => s.location.href); + // const initialHref = React.useRef(href).current; + // React.useEffect(() => { + // if (href !== initialHref) { + // props.reset(); + // } + // }, [href]); + return null +} + +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 +export function DefaultGlobalErrorPage(props: ErrorPageProps) { + const message = props.serverError + ? `Unknown Server Error (see server logs for the details)` + : `Unknown Client Error (see browser console for the details)` + return ( + + {message} + +

{message}

+ + + ) +} diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index 50bdd27..0527c67 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -1,11 +1,15 @@ -import React from 'react' +import React, { Suspense } from 'react' import ReactDomClient from 'react-dom/client' import ReactClient from 'spiceflow/dist/react/server-dom-client-optimized' import type { CallServerFn } from './types/index.js' import { clientReferenceManifest } from './utils/client-reference.js' import { rscStream } from 'rsc-html-stream/client' -import { FlightDataContext } from './components.js' +import { + DefaultGlobalErrorPage, + ErrorBoundary, + FlightDataContext, +} from './components.js' import { ServerPayload } from '../spiceflow.js' async function main() { @@ -60,9 +64,13 @@ async function main() { }, []) return ( - - {payload.root?.layouts?.[0]?.element ?? payload.root.page} - + Loading root...}> + + + {payload.root?.layouts?.[0]?.element ?? payload.root.page} + + + ) } diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 871fc50..cf0959e 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -3,7 +3,6 @@ import ReactDomServer from 'react-dom/server' import ReactClient from 'spiceflow/dist/react/server-dom-client-optimized' import type { ModuleRunner } from 'vite/module-runner' - import { createRequest, fromPipeableToWebReadable, @@ -11,11 +10,16 @@ import { sendResponse, } from './utils/fetch.js' import { injectRSCPayload } from 'rsc-html-stream/server' -import { FlightDataContext } from './components.js' +import { + DefaultGlobalErrorPage, + ErrorBoundary, + FlightDataContext, +} from './components.js' import { bootstrapModules } from 'virtual:ssr-assets' import { clientReferenceManifest } from './utils/client-reference.js' import cssUrls from 'virtual:app-styles' import { ServerPayload } from '../spiceflow.js' +import { Suspense } from 'react' export default async function handler( req: IncomingMessage, @@ -43,28 +47,58 @@ export default async function handler( clientReferenceManifest, ) const ssrAssets = await import('virtual:ssr-assets') - const el = ( {cssUrls.map((url) => ( - + // precedence to force head rendering + // https://react.dev/reference/react-dom/components/link#special-rendering-behavior + ))} {payload.root?.layouts?.[0]?.element ?? payload.root.page} ) - const htmlStream = fromPipeableToWebReadable( - ReactDomServer.renderToPipeableStream(el, { - bootstrapModules: ssrAssets.bootstrapModules, + let htmlStream: ReadableStream + let status = 200 - // @ts-ignore no type? + try { + const ssrStream = await ReactDomServer.renderToPipeableStream(el, { + bootstrapModules: ssrAssets.bootstrapModules, + // @ts-ignore formState: payload.formState, - }), - ) + onError(error) { + console.error('[react-dom:renderToPipeableStream]', error) + status = 500 + }, + }) + + htmlStream = fromPipeableToWebReadable(ssrStream) + } catch (e) { + console.log(`error during ssr render catch`, e) + // On error, render minimal HTML shell + // Client will do full CSR render and show error boundary + status = 500 + const errorRoot = ( + + + + + + + + + ) + + const errorStream = ReactDomServer.renderToPipeableStream(errorRoot, { + bootstrapModules: ssrAssets.bootstrapModules, + }) + htmlStream = fromPipeableToWebReadable(errorStream) + } const htmlResponse = new Response( htmlStream.pipeThrough(injectRSCPayload(flightStream2)), { + status, headers: { 'content-type': 'text/html;charset=utf-8', }, From c08e768253f8986aa0cc0c28ebfee83288f71087 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 18:04:04 +0100 Subject: [PATCH 046/226] using react-dom/server.edge fixes hanging on error --- spiceflow/src/react/components.tsx | 6 +- spiceflow/src/react/entry.client.tsx | 12 ++-- spiceflow/src/react/entry.ssr.tsx | 15 ++-- spiceflow/src/{spiceflow.ts => spiceflow.tsx} | 69 ++++++++++--------- 4 files changed, 53 insertions(+), 49 deletions(-) rename spiceflow/src/{spiceflow.ts => spiceflow.tsx} (97%) diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index 2b06c04..6cfc858 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -76,7 +76,11 @@ function getErrorContext(error: Error): ReactServerErrorContext | undefined { return (error as any).serverError } -export class ErrorBoundary extends React.Component { +export function ErrorBoundary(props: Props) { + return +} + +class ErrorBoundary_ extends React.Component { constructor(props: Props) { super(props) this.state = { error: null } diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index 0527c67..310eabe 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -64,13 +64,11 @@ async function main() { }, []) return ( - Loading root...}> - - - {payload.root?.layouts?.[0]?.element ?? payload.root.page} - - - + + + {payload.root?.layouts?.[0]?.element ?? payload.root.page} + + ) } diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index cf0959e..d6a9bb9 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http' -import ReactDomServer from 'react-dom/server' +import ReactDOMServer from 'react-dom/server.edge' import ReactClient from 'spiceflow/dist/react/server-dom-client-optimized' import type { ModuleRunner } from 'vite/module-runner' @@ -62,17 +62,15 @@ export default async function handler( let status = 200 try { - const ssrStream = await ReactDomServer.renderToPipeableStream(el, { + htmlStream = await ReactDOMServer.renderToReadableStream(el, { bootstrapModules: ssrAssets.bootstrapModules, - // @ts-ignore formState: payload.formState, onError(error) { - console.error('[react-dom:renderToPipeableStream]', error) - status = 500 + // This also throws outside, no need to do anything here + // console.error('[react-dom:renderToPipeableStream]', error) + // status = 500 }, }) - - htmlStream = fromPipeableToWebReadable(ssrStream) } catch (e) { console.log(`error during ssr render catch`, e) // On error, render minimal HTML shell @@ -89,10 +87,9 @@ export default async function handler( ) - const errorStream = ReactDomServer.renderToPipeableStream(errorRoot, { + htmlStream = await ReactDOMServer.renderToReadableStream(errorRoot, { bootstrapModules: ssrAssets.bootstrapModules, }) - htmlStream = fromPipeableToWebReadable(errorStream) } const htmlResponse = new Response( diff --git a/spiceflow/src/spiceflow.ts b/spiceflow/src/spiceflow.tsx similarity index 97% rename from spiceflow/src/spiceflow.ts rename to spiceflow/src/spiceflow.tsx index 32ab0ed..cadbdc9 100644 --- a/spiceflow/src/spiceflow.ts +++ b/spiceflow/src/spiceflow.tsx @@ -26,7 +26,7 @@ import { RouteSchema, SingletonBase, TypeSchema, - UnwrapRoute + UnwrapRoute, } from './types.js' let globalIndex = 0 @@ -38,15 +38,20 @@ import { MiddlewareContext } from './context.js' import { isProduction, ValidationError } from './error.js' import { isAsyncIterable, isResponse, redirect } from './utils.js' - -import { FlightData, LayoutContent } from './react/components.js' -import { ClientReferenceMetadataManifest, ServerReferenceManifest } from './react/types/index.js' +import { + DefaultGlobalErrorPage, + ErrorBoundary, + FlightData, + LayoutContent, +} from './react/components.js' +import { + ClientReferenceMetadataManifest, + ServerReferenceManifest, +} from './react/types/index.js' import { fromPipeableToWebReadable } from './react/utils/fetch.js' import { TrieRouter } from './trie-router/router.js' import { decodeURIComponent_ } from './trie-router/url.js' -import { - Result -} from './trie-router/utils.js' +import { Result } from './trie-router/utils.js' const ajv = (addFormats.default || addFormats)( new (Ajv.default || Ajv)({ useDefaults: true }), @@ -880,29 +885,35 @@ export class Spiceflow< redirect, } - const [page, ...layouts] = await Promise.all([ - pageRoute?.route?.handler({ - ...baseContext, - state: cloneDeep(pageRoute.app.defaultState), - params: pageRoute.params, - }), - ...layoutRoutes.map(async (layout) => { - const id = layout.route.id - const children = createElement(LayoutContent, { id }) - - return { - element: (await layout.route.handler({ + let Page = pageRoute?.route?.handler as any + let page = ( + + ) + const layouts = layoutRoutes.map((layout) => { + const id = layout.route.id + const children = createElement(LayoutContent, { id }) + + let Layout = layout.route.handler as any + const element = ( + + ) + return { element, id } + }) let root: FlightData = { url: request.url, @@ -942,12 +953,9 @@ export class Spiceflow< } } - - if (root instanceof Response) { return root } - let abortable = ReactServer.renderToPipeableStream( { @@ -966,8 +974,8 @@ export class Spiceflow< return new Response(stream, { headers: { - 'content-type': 'text/x-component;charset=utf-8' - } + 'content-type': 'text/x-component;charset=utf-8', + }, }) } catch (err) { return await getResForError(err) @@ -1676,8 +1684,6 @@ function partition(arr: T[], predicate: (item: T) => boolean): [T[], T[]] { ) } - - const serverReferenceManifest: ServerReferenceManifest = { resolveServerReference(reference: string) { const [id, name] = reference.split('#') @@ -1714,7 +1720,6 @@ const clientReferenceMetadataManifest: ClientReferenceMetadataManifest = { }, } - export interface ServerPayload { root: FlightData formState?: ReactFormState From 3d5d5ed51a89b57829f02a47f573f6206314d464 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 10 Feb 2025 18:07:02 +0100 Subject: [PATCH 047/226] add css in error page --- example-react/package.json | 2 ++ example-react/src/main.tsx | 33 +++++++++++++++++-------------- pnpm-lock.yaml | 8 +++++++- spiceflow/src/react/entry.ssr.tsx | 7 ++++++- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/example-react/package.json b/example-react/package.json index b234960..a975b1e 100644 --- a/example-react/package.json +++ b/example-react/package.json @@ -16,6 +16,8 @@ "dependencies": { "@playwright/test": "^1.50.1", "@tailwindcss/vite": "^4.0.5", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", "react": "19.0.0", "react-dom": "19.0.0", "spiceflow": "workspace:*", diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 172aea9..7b1cdf7 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -1,17 +1,21 @@ import { Spiceflow } from "spiceflow"; +import { Suspense } from "react"; import { IndexPage } from "./app/index"; import { Layout } from "./app/layout"; -import './styles.css' +import "./styles.css"; import { ClientComponentThrows } from "./app/client"; - const app = new Spiceflow() .layout("/*", async ({ children, request }) => { - return {children}; + return ( + + Loading...}>{children} + + ); }) .page("/", async ({ request }) => { const url = new URL(request.url); - return ; + return ; }) .get("/hello", () => "Hello, World!") @@ -66,15 +70,15 @@ const app = new Spiceflow() ); }) - .page('/loader-error', async () => { - throw new Error('test error'); - }) - .page('/rsc-error', async () => { - return - }) - .page('/client-error', async () => { - return - }) + .page("/loader-error", async () => { + throw new Error("test error"); + }) + .page("/rsc-error", async () => { + return ; + }) + .page("/client-error", async () => { + return ; + }) .page("/redirect-in-rsc", async () => { return ; }) @@ -94,9 +98,8 @@ async function Redirects() { } function ServerComponentThrows() { - throw new Error('Server component error'); + throw new Error("Server component error"); return
Server component
; } - export default app; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78bd58c..dbfbad3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,12 @@ importers: '@tailwindcss/vite': specifier: ^4.0.5 version: 4.0.5(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + '@types/react': + specifier: ^19.0.8 + version: 19.0.8 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.0.3(@types/react@19.0.8) react: specifier: 19.0.0 version: 19.0.0 @@ -7813,7 +7819,7 @@ snapshots: '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.7) '@vanilla-extract/babel-plugin-debug-ids': 1.2.0 '@vanilla-extract/css': 1.17.1 - esbuild: 0.17.6 + esbuild: 0.17.19 eval: 0.1.8 find-up: 5.0.0 javascript-stringify: 2.1.0 diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index d6a9bb9..be92425 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -66,7 +66,7 @@ export default async function handler( bootstrapModules: ssrAssets.bootstrapModules, formState: payload.formState, onError(error) { - // This also throws outside, no need to do anything here + // This also throws outside, no need to do anything here // console.error('[react-dom:renderToPipeableStream]', error) // status = 500 }, @@ -80,6 +80,11 @@ export default async function handler( + {cssUrls.map((url) => ( + // precedence to force head rendering + // https://react.dev/reference/react-dom/components/link#special-rendering-behavior + + ))} From ff956c0509121ab0fadd2e05330a6694d25372c2 Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 16:10:59 +0100 Subject: [PATCH 048/226] made link component, tested client navigations, back buttons waits which is not great --- example-react/src/app/client.tsx | 2 + example-react/src/app/layout.tsx | 11 +- example-react/src/main.tsx | 12 +- package.json | 2 +- pnpm-lock.yaml | 112 ++++++++++-------- spiceflow/.gitignore | 1 + spiceflow/package.json | 1 + spiceflow/src/react/components.tsx | 24 ++++ spiceflow/src/react/entry.client.tsx | 74 +++--------- spiceflow/src/react/entry.ssr.tsx | 21 +++- spiceflow/src/react/router.tsx | 7 ++ spiceflow/src/react/utils/client-reference.ts | 4 +- spiceflow/src/spiceflow.tsx | 4 +- spiceflow/src/vite.tsx | 1 + spiceflow/tsconfig.json | 1 + spiceflow/vitest.config.js | 10 ++ 16 files changed, 164 insertions(+), 123 deletions(-) create mode 100644 spiceflow/.gitignore create mode 100644 spiceflow/src/react/router.tsx diff --git a/example-react/src/app/client.tsx b/example-react/src/app/client.tsx index 12d2afa..8203fce 100644 --- a/example-react/src/app/client.tsx +++ b/example-react/src/app/client.tsx @@ -70,3 +70,5 @@ export function ClientComponentThrows() { throw new Error('Client component error'); return
Client component
; } + + diff --git a/example-react/src/app/layout.tsx b/example-react/src/app/layout.tsx index f6009c5..b2e8b07 100644 --- a/example-react/src/app/layout.tsx +++ b/example-react/src/app/layout.tsx @@ -1,3 +1,5 @@ +import { Link } from "spiceflow/dist/react/components"; + export function Layout(props: React.PropsWithChildren) { return ( @@ -9,13 +11,16 @@ export function Layout(props: React.PropsWithChildren) { content="width=device-width, height=device-height, initial-scale=1.0" /> - +
{props.children} diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 7b1cdf7..015585e 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -4,6 +4,8 @@ import { IndexPage } from "./app/index"; import { Layout } from "./app/layout"; import "./styles.css"; import { ClientComponentThrows } from "./app/client"; +import { ErrorBoundary } from "spiceflow/dist/react/components"; +import { sleep } from "spiceflow/dist/utils"; const app = new Spiceflow() .layout("/*", async ({ children, request }) => { @@ -35,6 +37,14 @@ const app = new Spiceflow() ); }) + .page("/slow", async ({ request, children }) => { + await sleep(1000); + return ( +
+

slow page

+
+ ); + }) .layout("/page/*", async ({ request, children }) => { return (
@@ -97,7 +107,7 @@ async function Redirects() { return
Redirect
; } -function ServerComponentThrows() { +async function ServerComponentThrows() { throw new Error("Server component error"); return
Server component
; } diff --git a/package.json b/package.json index b1bc158..85d9179 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "tsx": "^4.19.2", "typescript": "^5.7.3", "vite": "^6.1.0", - "vitest": "^3.0.4" + "vitest": "^3.0.5" }, "repository": "https://github.com/remorses/", "author": "remorses ", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbfbad3..e3aa8b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ importers: specifier: ^6.1.0 version: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) vitest: - specifier: ^3.0.4 - version: 3.0.4(@types/debug@4.1.12)(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + specifier: ^3.0.5 + version: 3.0.5(@types/debug@4.1.12)(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) example-react: dependencies: @@ -195,6 +195,9 @@ importers: eventsource-parser: specifier: ^3.0.0 version: 3.0.0 + history: + specifier: ^5.3.0 + version: 5.3.0 lodash.clonedeep: specifier: ^4.5.0 version: 4.5.0 @@ -2203,11 +2206,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 - '@vitest/expect@3.0.4': - resolution: {integrity: sha512-Nm5kJmYw6P2BxhJPkO3eKKhGYKRsnqJqf+r0yOGRKpEP+bSCBDsjXgiu1/5QFrnPMEgzfC38ZEjvCFgaNBC0Eg==} + '@vitest/expect@3.0.5': + resolution: {integrity: sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==} - '@vitest/mocker@3.0.4': - resolution: {integrity: sha512-gEef35vKafJlfQbnyOXZ0Gcr9IBUsMTyTLXsEQwuyYAerpHqvXhzdBnDFuHLpFqth3F7b6BaFr4qV/Cs1ULx5A==} + '@vitest/mocker@3.0.5': + resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==} peerDependencies: msw: ^2.4.9 vite: ^5.0.0 || ^6.0.0 @@ -2217,20 +2220,20 @@ packages: vite: optional: true - '@vitest/pretty-format@3.0.4': - resolution: {integrity: sha512-ts0fba+dEhK2aC9PFuZ9LTpULHpY/nd6jhAQ5IMU7Gaj7crPCTdCFfgvXxruRBLFS+MLraicCuFXxISEq8C93g==} + '@vitest/pretty-format@3.0.5': + resolution: {integrity: sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==} - '@vitest/runner@3.0.4': - resolution: {integrity: sha512-dKHzTQ7n9sExAcWH/0sh1elVgwc7OJ2lMOBrAm73J7AH6Pf9T12Zh3lNE1TETZaqrWFXtLlx3NVrLRb5hCK+iw==} + '@vitest/runner@3.0.5': + resolution: {integrity: sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==} - '@vitest/snapshot@3.0.4': - resolution: {integrity: sha512-+p5knMLwIk7lTQkM3NonZ9zBewzVp9EVkVpvNta0/PlFWpiqLaRcF4+33L1it3uRUCh0BGLOaXPPGEjNKfWb4w==} + '@vitest/snapshot@3.0.5': + resolution: {integrity: sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==} - '@vitest/spy@3.0.4': - resolution: {integrity: sha512-sXIMF0oauYyUy2hN49VFTYodzEAu744MmGcPR3ZBsPM20G+1/cSW/n1U+3Yu/zHxX2bIDe1oJASOkml+osTU6Q==} + '@vitest/spy@3.0.5': + resolution: {integrity: sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==} - '@vitest/utils@3.0.4': - resolution: {integrity: sha512-8BqC1ksYsHtbWH+DfpOAKrFw3jl3Uf9J7yeFh85Pz52IWuh1hBBtyfEbRNNZNjl8H8A5yMLH9/t+k7HIKzQcZQ==} + '@vitest/utils@3.0.5': + resolution: {integrity: sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==} '@web3-storage/multipart-parser@1.0.0': resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==} @@ -3459,6 +3462,9 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + history@5.3.0: + resolution: {integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==} + hosted-git-info@6.1.3: resolution: {integrity: sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -3957,8 +3963,8 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loupe@3.1.2: - resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} + loupe@3.1.3: + resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -5754,8 +5760,8 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true - vite-node@3.0.4: - resolution: {integrity: sha512-7JZKEzcYV2Nx3u6rlvN8qdo3QV7Fxyt6hx+CCKz9fbWxdX5IvUOmTWEAxMrWxaiSf7CKGLJQ5rFu8prb/jBjOA==} + vite-node@3.0.5: + resolution: {integrity: sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true @@ -5848,16 +5854,16 @@ packages: yaml: optional: true - vitest@3.0.4: - resolution: {integrity: sha512-6XG8oTKy2gnJIFTHP6LD7ExFeNLxiTkK3CfMvT7IfR8IN+BYICCf0lXUQmX7i7JoxUP8QmeP4mTnWXgflu4yjw==} + vitest@3.0.5: + resolution: {integrity: sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.0.4 - '@vitest/ui': 3.0.4 + '@vitest/browser': 3.0.5 + '@vitest/ui': 3.0.5 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -7061,7 +7067,7 @@ snapshots: '@manypkg/find-root@1.1.0': dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.26.7 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 @@ -7853,44 +7859,44 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/expect@3.0.4': + '@vitest/expect@3.0.5': dependencies: - '@vitest/spy': 3.0.4 - '@vitest/utils': 3.0.4 + '@vitest/spy': 3.0.5 + '@vitest/utils': 3.0.5 chai: 5.1.2 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.4(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + '@vitest/mocker@3.0.5(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': dependencies: - '@vitest/spy': 3.0.4 + '@vitest/spy': 3.0.5 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) - '@vitest/pretty-format@3.0.4': + '@vitest/pretty-format@3.0.5': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.0.4': + '@vitest/runner@3.0.5': dependencies: - '@vitest/utils': 3.0.4 + '@vitest/utils': 3.0.5 pathe: 2.0.2 - '@vitest/snapshot@3.0.4': + '@vitest/snapshot@3.0.5': dependencies: - '@vitest/pretty-format': 3.0.4 + '@vitest/pretty-format': 3.0.5 magic-string: 0.30.17 pathe: 2.0.2 - '@vitest/spy@3.0.4': + '@vitest/spy@3.0.5': dependencies: tinyspy: 3.0.2 - '@vitest/utils@3.0.4': + '@vitest/utils@3.0.5': dependencies: - '@vitest/pretty-format': 3.0.4 - loupe: 3.1.2 + '@vitest/pretty-format': 3.0.5 + loupe: 3.1.3 tinyrainbow: 2.0.0 '@web3-storage/multipart-parser@1.0.0': {} @@ -8210,7 +8216,7 @@ snapshots: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.2 + loupe: 3.1.3 pathval: 2.0.0 chalk-template@0.4.0: @@ -9463,6 +9469,10 @@ snapshots: dependencies: '@types/hast': 3.0.4 + history@5.3.0: + dependencies: + '@babel/runtime': 7.26.7 + hosted-git-info@6.1.3: dependencies: lru-cache: 7.18.3 @@ -9897,7 +9907,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@3.1.2: {} + loupe@3.1.3: {} lru-cache@10.4.3: {} @@ -12400,7 +12410,7 @@ snapshots: - supports-color - terser - vite-node@3.0.4(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + vite-node@3.0.5(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.0 @@ -12468,15 +12478,15 @@ snapshots: tsx: 4.19.2 yaml: 2.7.0 - vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + vitest@3.0.5(@types/debug@4.1.12)(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: - '@vitest/expect': 3.0.4 - '@vitest/mocker': 3.0.4(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) - '@vitest/pretty-format': 3.0.4 - '@vitest/runner': 3.0.4 - '@vitest/snapshot': 3.0.4 - '@vitest/spy': 3.0.4 - '@vitest/utils': 3.0.4 + '@vitest/expect': 3.0.5 + '@vitest/mocker': 3.0.5(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + '@vitest/pretty-format': 3.0.5 + '@vitest/runner': 3.0.5 + '@vitest/snapshot': 3.0.5 + '@vitest/spy': 3.0.5 + '@vitest/utils': 3.0.5 chai: 5.1.2 debug: 4.4.0 expect-type: 1.1.0 @@ -12488,7 +12498,7 @@ snapshots: tinypool: 1.0.2 tinyrainbow: 2.0.0 vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) - vite-node: 3.0.4(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + vite-node: 3.0.5(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 diff --git a/spiceflow/.gitignore b/spiceflow/.gitignore new file mode 100644 index 0000000..b2b5d0d --- /dev/null +++ b/spiceflow/.gitignore @@ -0,0 +1 @@ +debug \ No newline at end of file diff --git a/spiceflow/package.json b/spiceflow/package.json index 6a2c030..f1813d2 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -63,6 +63,7 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "eventsource-parser": "^3.0.0", + "history": "^5.3.0", "lodash.clonedeep": "^4.5.0", "object-treeify": "^5.0.1", "openapi-types": "^12.1.3", diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index 6cfc858..808cdb7 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -2,6 +2,7 @@ import React, { Suspense } from 'react' import { ReactFormState } from 'react-dom/client' +import { router } from './router.js' export const FlightDataContext = React.createContext(undefined!) // Get $$id property that was set by registerClientReference @@ -158,3 +159,26 @@ export function DefaultGlobalErrorPage(props: ErrorPageProps) { ) } + +export function Link(props: React.ComponentPropsWithRef<'a'>) { + return ( + { + if ( + e.metaKey || + e.ctrlKey || + e.shiftKey || + e.altKey || + (props.target && props.target === '_blank') + ) { + props.onClick?.(e) + return + } + e.preventDefault() + props.onClick?.(e) + router.push(e.currentTarget.href) + }} + /> + ) +} diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index 310eabe..a83fa49 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -1,4 +1,5 @@ import React, { Suspense } from 'react' +import { router } from './router.js' import ReactDomClient from 'react-dom/client' import ReactClient from 'spiceflow/dist/react/server-dom-client-optimized' @@ -24,23 +25,12 @@ async function main() { clientReferenceManifest, { callServer }, ) + // console.log({ 'action payload': payload }) setPayload(payload) return payload.returnValue } Object.assign(globalThis, { __callServer: callServer }) - async function onNavigation() { - const url = new URL(window.location.href) - url.searchParams.set('__rsc', '') - const payload = await ReactClient.createFromFetch( - fetch(url), - clientReferenceManifest, - - { callServer }, - ) - setPayload(payload) - } - const initialPayload = await ReactClient.createFromReadableStream( rscStream, @@ -60,7 +50,18 @@ async function main() { }, [startTransition, setPayload_]) React.useEffect(() => { - return listenNavigation(onNavigation) + return router.listen(async function onNavigation() { + console.log('onNavigation') + const url = new URL(window.location.href) + url.searchParams.set('__rsc', '') + const payload = await ReactClient.createFromFetch( + fetch(url), + clientReferenceManifest, + + { callServer }, + ) + setPayload(payload) + }) }, []) return ( @@ -84,51 +85,4 @@ async function main() { } } -function listenNavigation(onNavigation: () => void) { - window.addEventListener('popstate', onNavigation) - - const oldPushState = window.history.pushState - window.history.pushState = function (...args) { - const res = oldPushState.apply(this, args) - onNavigation() - return res - } - - const oldReplaceState = window.history.replaceState - window.history.replaceState = function (...args) { - const res = oldReplaceState.apply(this, args) - onNavigation() - return res - } - - function onClick(e: MouseEvent) { - let link = (e.target as Element).closest('a') - if ( - link && - link instanceof HTMLAnchorElement && - link.href && - (!link.target || link.target === '_self') && - link.origin === location.origin && - !link.hasAttribute('download') && - e.button === 0 && // left clicks only - !e.metaKey && // open in new tab (mac) - !e.ctrlKey && // open in new tab (windows) - !e.altKey && // download - !e.shiftKey && - !e.defaultPrevented - ) { - e.preventDefault() - history.pushState(null, '', link.href) - } - } - document.addEventListener('click', onClick) - - return () => { - document.removeEventListener('click', onClick) - window.removeEventListener('popstate', onNavigation) - window.history.pushState = oldPushState - window.history.replaceState = oldReplaceState - } -} - main() diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index be92425..a5a2878 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -20,6 +20,7 @@ import { clientReferenceManifest } from './utils/client-reference.js' import cssUrls from 'virtual:app-styles' import { ServerPayload } from '../spiceflow.js' import { Suspense } from 'react' +import { sleep } from 'spiceflow/dist/utils' export default async function handler( req: IncomingMessage, @@ -65,17 +66,27 @@ export default async function handler( htmlStream = await ReactDOMServer.renderToReadableStream(el, { bootstrapModules: ssrAssets.bootstrapModules, formState: payload.formState, - onError(error) { + onError(e, ) { // This also throws outside, no need to do anything here - // console.error('[react-dom:renderToPipeableStream]', error) - // status = 500 + console.error('[react-dom:renderToPipeableStream]', e) + if (e instanceof Response) { + console.log('sending response') + sendResponse(e, res) + return + } }, }) } catch (e) { console.log(`error during ssr render catch`, e) // On error, render minimal HTML shell // Client will do full CSR render and show error boundary - status = 500 + + if (e instanceof Response) { + sendResponse(e, res) + return + } + // https://bsky.app/profile/ebey.bsky.social/post/3lev4lqr2ak2j + const errorRoot = ( @@ -106,6 +117,8 @@ export default async function handler( }, }, ) + + console.log(`sending response`) sendResponse(htmlResponse, res) } diff --git a/spiceflow/src/react/router.tsx b/spiceflow/src/react/router.tsx new file mode 100644 index 0000000..8bb4234 --- /dev/null +++ b/spiceflow/src/react/router.tsx @@ -0,0 +1,7 @@ + +import { createBrowserHistory, createMemoryHistory } from 'history' + +export const router = + typeof window === 'undefined' + ? createMemoryHistory() + : createBrowserHistory({}) diff --git a/spiceflow/src/react/utils/client-reference.ts b/spiceflow/src/react/utils/client-reference.ts index 36d7fb9..b38d262 100644 --- a/spiceflow/src/react/utils/client-reference.ts +++ b/spiceflow/src/react/utils/client-reference.ts @@ -8,8 +8,8 @@ export const clientReferenceManifest: ClientReferenceManifest = { async preload() { let mod: Record if (import.meta.env.DEV) { - // console.log('importing client reference', id) - console.log('importing client reference', id) + + // console.log('importing client reference', id) mod = typeof __raw_import !== 'undefined' ? // on browser development need to use __raw_import to not add ?import at the end, otherwise the browser duplicates the module instance, context stops working diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index cadbdc9..f15faf4 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -1,5 +1,5 @@ import type { ReactFormState } from 'react-dom/client' -import ReactServer from 'spiceflow/dist/react/server-dom-optimized' + import addFormats from 'ajv-formats' import lodashCloneDeep from 'lodash.clonedeep' @@ -863,6 +863,7 @@ export class Spiceflow< (x) => !x.route.kind, ) if (reactRoutes.length) { + const ReactServer = await import('spiceflow/dist/react/server-dom-optimized').then(m => m.default) const [pageRoutes, layoutRoutes] = partition( reactRoutes, (x) => x.route.kind === 'page', @@ -936,6 +937,7 @@ export class Spiceflow< serverReferenceManifest.resolveServerReference(actionId) await reference.preload() const action = await reference.get() + // TODO handle action errors, redirects, etc returnValue = await (action as any).apply(null, args) } else { // progressive enhancement diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index c67e70b..394344a 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -138,6 +138,7 @@ export function spiceflowPlugin({ entry }): PluginOption { optimizeDeps: { include: [ 'react-dom/client', + 'react-server-dom-vite/client', 'spiceflow/dist/react/server-dom-client-optimized', ], }, diff --git a/spiceflow/tsconfig.json b/spiceflow/tsconfig.json index 9c18dab..bea90bb 100644 --- a/spiceflow/tsconfig.json +++ b/spiceflow/tsconfig.json @@ -10,6 +10,7 @@ "sourceMap": true, "jsx": "react-jsx", "resolveJsonModule": true, + "useUnknownInCatchVariables": false, "outDir": "dist" }, "include": ["src"] diff --git a/spiceflow/vitest.config.js b/spiceflow/vitest.config.js index 391bf1a..820c576 100644 --- a/spiceflow/vitest.config.js +++ b/spiceflow/vitest.config.js @@ -1,5 +1,6 @@ // vite.config.ts import { defineConfig } from 'vite' +import { spiceflowPlugin } from './dist/vite' const execArgv = process.env.PROFILE ? ['--cpu-prof', '--cpu-prof-dir=./profiling'] @@ -9,6 +10,15 @@ export default defineConfig({ esbuild: { jsx: 'transform', }, + // plugins: [ + // spiceflowPlugin({ + // // options + // }), + // ], + resolve: { + conditions: ['react-server'], + }, + test: { exclude: ['**/dist/**', '**/esm/**', '**/node_modules/**', '**/e2e/**'], pool: 'threads', From 4f9e4c0e806ef84bf0d3e8bb55500a3d0786c9d2 Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 16:19:05 +0100 Subject: [PATCH 049/226] fix hmr from server, tested suspense in rsc --- example-react/src/app/index.tsx | 1 + example-react/src/app/layout.tsx | 3 +++ example-react/src/main.tsx | 16 ++++++++++++++++ spiceflow/src/react/entry.client.tsx | 2 +- spiceflow/src/spiceflow.tsx | 23 +++++++++++------------ 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/example-react/src/app/index.tsx b/example-react/src/app/index.tsx index 6f8452c..f7bb019 100644 --- a/example-react/src/app/index.tsx +++ b/example-react/src/app/index.tsx @@ -14,6 +14,7 @@ export async function IndexPage() { style={{ padding: "0.5rem" }} >
Server counter: {getCounter()}
+
Unicode test: 🌟 你好 こんにちは ⚡️ 안녕하세요
+ ); + }) + .page("/slow-suspense", async ({ request, children }) => { await sleep(1000); return (
diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index a83fa49..a877f33 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -80,7 +80,7 @@ async function main() { if (import.meta.hot) { import.meta.hot.on('react-server:update', (e) => { console.log('[react-server:update]', e.file) - window.history.replaceState({}, '', window.location.href) + router.replace(router.location) }) } } diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index f15faf4..20f978e 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -1,6 +1,5 @@ import type { ReactFormState } from 'react-dom/client' - import addFormats from 'ajv-formats' import lodashCloneDeep from 'lodash.clonedeep' @@ -246,15 +245,11 @@ export class Spiceflow< } // Get all matched routes - const routes = matchedRoutes[0] - .map(([route, params], index) => ({ - app, - route, - params: this.getAllDecodedParams(matchedRoutes, originalPath, index), - })) - .sort((a, b) => { - return routeSorter(a.route, b.route) - }) + const routes = matchedRoutes[0].map(([route, params], index) => ({ + app, + route, + params: this.getAllDecodedParams(matchedRoutes, originalPath, index), + })) if (routes.length) { return routes @@ -863,7 +858,9 @@ export class Spiceflow< (x) => !x.route.kind, ) if (reactRoutes.length) { - const ReactServer = await import('spiceflow/dist/react/server-dom-optimized').then(m => m.default) + const ReactServer = await import( + 'spiceflow/dist/react/server-dom-optimized' + ).then((m) => m.default) const [pageRoutes, layoutRoutes] = partition( reactRoutes, (x) => x.route.kind === 'page', @@ -983,7 +980,9 @@ export class Spiceflow< return await getResForError(err) } } - const route = nonReactRoutes[0] + const route = nonReactRoutes.sort((a, b) => { + return routeSorter(a.route, b.route) + })[0] // TODO get all apps in scope? layouts can match between apps when using .use? const appsInScope = this.getAppsInScope(routes[0].app) From 692e63111cd28f803a0c79c4ada36aa599c34a28 Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 17:58:02 +0100 Subject: [PATCH 050/226] added how-is-this-not-illegal, even simpler --- example-react/src/app/layout.tsx | 1 - example-react/src/main.tsx | 14 +- how-is-this-not-illegal/.gitignore | 36 + how-is-this-not-illegal/app/globals.css | 35 + how-is-this-not-illegal/app/main.tsx | 137 ++ .../app/opengraph-image.js | 92 ++ how-is-this-not-illegal/package.json | 27 + how-is-this-not-illegal/public/favicon.ico | Bin 0 -> 25931 bytes how-is-this-not-illegal/sql/destroy.sql | 2 + how-is-this-not-illegal/sql/init.sql | 159 +++ how-is-this-not-illegal/tsconfig.json | 29 + how-is-this-not-illegal/vite.config.ts | 14 + pnpm-lock.yaml | 1153 ++++++++++++----- spiceflow/package.json | 1 - spiceflow/src/vite.tsx | 1 - 15 files changed, 1364 insertions(+), 337 deletions(-) create mode 100644 how-is-this-not-illegal/.gitignore create mode 100644 how-is-this-not-illegal/app/globals.css create mode 100644 how-is-this-not-illegal/app/main.tsx create mode 100644 how-is-this-not-illegal/app/opengraph-image.js create mode 100644 how-is-this-not-illegal/package.json create mode 100644 how-is-this-not-illegal/public/favicon.ico create mode 100644 how-is-this-not-illegal/sql/destroy.sql create mode 100644 how-is-this-not-illegal/sql/init.sql create mode 100644 how-is-this-not-illegal/tsconfig.json create mode 100644 how-is-this-not-illegal/vite.config.ts diff --git a/example-react/src/app/layout.tsx b/example-react/src/app/layout.tsx index 79e1cfd..e6d9744 100644 --- a/example-react/src/app/layout.tsx +++ b/example-react/src/app/layout.tsx @@ -5,7 +5,6 @@ export function Layout(props: React.PropsWithChildren) { - react-server { return ( - Loading...
}>{children} + title from layout + {children} ); }) .page("/", async ({ request }) => { const url = new URL(request.url); - return ; + return ( + <> + title from page + + + ); }) .get("/hello", () => "Hello, World!") @@ -49,7 +55,9 @@ const app = new Spiceflow() return (

/slow-suspense layout

- Loading slow page layout...
}>{children} + Loading slow page layout...
}> + {children} + ); }) diff --git a/how-is-this-not-illegal/.gitignore b/how-is-this-not-illegal/.gitignore new file mode 100644 index 0000000..3161618 --- /dev/null +++ b/how-is-this-not-illegal/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env \ No newline at end of file diff --git a/how-is-this-not-illegal/app/globals.css b/how-is-this-not-illegal/app/globals.css new file mode 100644 index 0000000..c9d06d1 --- /dev/null +++ b/how-is-this-not-illegal/app/globals.css @@ -0,0 +1,35 @@ +@import 'tailwindcss'; + +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 34, 34, 34; + --background-end-rgb: 0, 0, 0; + } +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); +} + + +@layer utilities { + .bg-gradient-radial { + background-image: radial-gradient(var(--tw-gradient-stops)); + } + .bg-gradient-conic { + background-image: conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops)); + } +} diff --git a/how-is-this-not-illegal/app/main.tsx b/how-is-this-not-illegal/app/main.tsx new file mode 100644 index 0000000..e3f4c98 --- /dev/null +++ b/how-is-this-not-illegal/app/main.tsx @@ -0,0 +1,137 @@ +import './globals.css' +import { Spiceflow } from 'spiceflow' +import { sql } from '@vercel/postgres' +import { Suspense } from 'react' + +const app = new Spiceflow() + .layout('/*', async ({ children }) => { + return ( + + }>{children} + + ) + }) + .page('/', async function Home() { + const { rows } = await sql`SELECT * FROM pokemon ORDER BY RANDOM() LIMIT 12` + + return ( + + {rows.map((p) => ( + + ))} + + ) + }) + +function Loading() { + return ( +
    + {[...Array(12)].map((_, i) => ( +
  • +
    + + Loading + +
  • + ))} +
+ ) +} + +function PokemonList({ children }) { + return ( +
    + {children} +
+ ) +} + +function Pokemon({ id, name }) { + return ( +
  • + {name} + {name} +
  • + ) +} + +function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + How is this not illegal? + + + + + + + + + + +
    +

    How is this not illegal?

    +

    + This page renders{' '} + + SELECT * FROM pokemon ORDER BY RANDOM() LIMIT 12 + {' '} + from the edge, for every request. +

    +

    + What's best, the data fetching is defined directly within the + component tree thanks to React Server Components.{' '} + + Legally + + . ( + + Source + + ) +

    + {children} +
    + +
    + Images courtesy of{' '} + + PokeAPI + {' '} + – Pokemon is © 1996-2023 Nintendo, Creatures, Inc., GAME FREAK +
    + + + ) +} + +export default app diff --git a/how-is-this-not-illegal/app/opengraph-image.js b/how-is-this-not-illegal/app/opengraph-image.js new file mode 100644 index 0000000..53f90ea --- /dev/null +++ b/how-is-this-not-illegal/app/opengraph-image.js @@ -0,0 +1,92 @@ +import { sql } from "@vercel/postgres"; +import { ImageResponse } from "next/server"; +import Image from "next/image"; + +export const runtime = "edge"; +export const revalidate = 60; + +export default async function OGImage() { + const { rows } = await sql`SELECT * FROM pokemon ORDER BY RANDOM() LIMIT 12`; + + const inter500 = fetch( + new URL( + `../node_modules/@fontsource/inter/files/inter-latin-500-normal.woff`, + import.meta.url + ) + ).then((res) => res.arrayBuffer()); + + const robotoMono400 = fetch( + new URL( + `../node_modules/@fontsource/roboto-mono/files/roboto-mono-latin-400-normal.woff`, + import.meta.url + ) + ).then((res) => res.arrayBuffer()); + + return new ImageResponse( + ( +
    +
    +

    How is this not illegal?

    + +

    + + await sql`SELECT * FROM pokemon ORDER BY RANDOM() LIMIT 12` + {" "} + right in your{" "} + + <Component /> + +

    + +
      + {rows.map(({ id, name }) => ( +
    • + {/* eslint-disable-next-line @next/next/no-img-element */} + {name} + {name} +
    • + ))} +
    +
    +
    + ), + { + width: 1200, + height: 630, + fonts: [ + { + name: "Inter 500", + data: await inter500, + }, + { + name: "Roboto Mono 400", + data: await robotoMono400, + }, + ], + } + ); +} + +function font(fontFamily) { + return { fontFamily }; +} diff --git a/how-is-this-not-illegal/package.json b/how-is-this-not-illegal/package.json new file mode 100644 index 0000000..aaa33d8 --- /dev/null +++ b/how-is-this-not-illegal/package.json @@ -0,0 +1,27 @@ +{ + "name": "how-is-this-not-illegal", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "dotenv -- vite dev", + "seed": "dotenv -- bash -c 'psql \"$POSTGRES_URL\" -f sql/init.sql'", + "build": "dotenv -- vite build --app" + }, + "dependencies": { + "@fontsource/inter": "^4.5.15", + "@fontsource/roboto-mono": "^4.5.10", + "@tailwindcss/vite": "^4.0.6", + "@types/node": "18.16.3", + "@types/react": "19.0.8", + "@types/react-dom": "19.0.3", + "@vercel/postgres": "0.4.1", + "dotenv-cli": "^8.0.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "spiceflow": "workspace:*", + "tailwindcss": "4.0.6", + "typescript": "5.7.3", + "vite": "^6.1.0" + } +} diff --git a/how-is-this-not-illegal/public/favicon.ico b/how-is-this-not-illegal/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/how-is-this-not-illegal/sql/destroy.sql b/how-is-this-not-illegal/sql/destroy.sql new file mode 100644 index 0000000..2eb0b07 --- /dev/null +++ b/how-is-this-not-illegal/sql/destroy.sql @@ -0,0 +1,2 @@ +-- pokemon table for postgresql +DROP TABLE pokemon; diff --git a/how-is-this-not-illegal/sql/init.sql b/how-is-this-not-illegal/sql/init.sql new file mode 100644 index 0000000..7133119 --- /dev/null +++ b/how-is-this-not-illegal/sql/init.sql @@ -0,0 +1,159 @@ +-- pokemon table for postgresql +CREATE TABLE pokemon ( + id INT PRIMARY KEY, + name VARCHAR(50) NOT NULL, + type VARCHAR(50) NOT NULL +); + +INSERT INTO pokemon (id, name, type) VALUES +(1, 'Bulbasaur', 'Grass'), +(2, 'Ivysaur', 'Grass'), +(3, 'Venusaur', 'Grass'), +(4, 'Charmander', 'Fire'), +(5, 'Charmeleon', 'Fire'), +(6, 'Charizard', 'Fire'), +(7, 'Squirtle', 'Water'), +(8, 'Wartortle', 'Water'), +(9, 'Blastoise', 'Water'), +(10, 'Caterpie', 'Bug'), +(11, 'Metapod', 'Bug'), +(12, 'Butterfree', 'Bug'), +(13, 'Weedle', 'Bug'), +(14, 'Kakuna', 'Bug'), +(15, 'Beedrill', 'Bug'), +(16, 'Pidgey', 'Normal'), +(17, 'Pidgeotto', 'Normal'), +(18, 'Pidgeot', 'Normal'), +(19, 'Rattata', 'Normal'), +(20, 'Raticate', 'Normal'), +(21, 'Spearow', 'Normal'), +(22, 'Fearow', 'Normal'), +(23, 'Ekans', 'Poison'), +(24, 'Arbok', 'Poison'), +(25, 'Pikachu', 'Electric'), +(26, 'Raichu', 'Electric'), +(27, 'Sandshrew', 'Ground'), +(28, 'Sandslash', 'Ground'), +(29, 'Nidoran♀', 'Poison'), +(30, 'Nidorina', 'Poison'), +(31, 'Nidoqueen', 'Poison'), +(32, 'Nidoran♂', 'Poison'), +(33, 'Nidorino', 'Poison'), +(34, 'Nidoking', 'Poison'), +(35, 'Clefairy', 'Fairy'), +(36, 'Clefable', 'Fairy'), +(37, 'Vulpix', 'Fire'), +(38, 'Ninetales', 'Fire'), +(39, 'Jigglypuff', 'Normal'), +(40, 'Wigglytuff', 'Normal'), +(41, 'Zubat', 'Poison'), +(42, 'Golbat', 'Poison'), +(43, 'Oddish', 'GrassPoison'), +(44, 'Gloom', 'GrassPoison'), +(45, 'Vileplume', 'GrassPoison'), +(46, 'Paras', 'BugGrass'), +(47, 'Parasect', 'BugGrass'), +(48, 'Venonat', 'BugPoison'), +(49, 'Venomoth', 'BugPoison'), +(50, 'Diglett', 'Ground'), +(51, 'Dugtrio', 'Ground'), +(52, 'Meowth', 'Normal'), +(53, 'Persian', 'Normal'), +(54, 'Psyduck', 'Water'), +(55, 'Golduck', 'Water'), +(56, 'Mankey', 'Fighting'), +(57, 'Primeape', 'Fighting'), +(58, 'Growlithe', 'Fire'), +(59, 'Arcanine', 'Fire'), +(60, 'Poliwag', 'Water'), +(61, 'Poliwhirl', 'Water'), +(62, 'Poliwrath', 'WaterFighting'), +(63, 'Abra', 'Psychic'), +(64, 'Kadabra', 'Psychic'), +(65, 'Alakazam', 'Psychic'), +(66, 'Machop', 'Fighting'), +(67, 'Machoke', 'Fighting'), +(68, 'Machamp', 'Fighting'), +(69, 'Bellsprout', 'GrassPoison'), +(70, 'Weepinbell', 'GrassPoison'), +(71, 'Victreebel', 'GrassPoison'), +(72, 'Tentacool', 'WaterPoison'), +(73, 'Tentacruel', 'WaterPoison'), +(74, 'Geodude', 'RockGround'), +(75, 'Graveler', 'RockGround'), +(76, 'Golem', 'RockGround'), +(77, 'Ponyta', 'Fire'), +(78, 'Rapidash', 'Fire'), +(79, 'Slowpoke', 'WaterPsychic'), +(80, 'Slowbro', 'WaterPsychic'), +(81, 'Magnemite', 'ElectricSteel'), +(82, 'Magneton', 'ElectricSteel'), +(83, 'Farfetch''d', 'NormalFlying'), +(84, 'Doduo', 'NormalFlying'), +(85, 'Dodrio', 'NormalFlying'), +(86, 'Seel', 'Water'), +(87, 'Dewgong', 'WaterIce'), +(88, 'Grimer', 'Poison'), +(89, 'Muk', 'Poison'), +(90, 'Shellder', 'Water'), +(91, 'Cloyster', 'WaterIce'), +(92, 'Gastly', 'GhostPoison'), +(93, 'Haunter', 'GhostPoison'), +(94, 'Gengar', 'GhostPoison'), +(95, 'Onix', 'RockGround'), +(96, 'Drowzee', 'Psychic'), +(97, 'Hypno', 'Psychic'), +(98, 'Krabby', 'Water'), +(99, 'Kingler', 'Water'), +(100, 'Voltorb', 'Electric'), +(101, 'Electrode', 'Electric'), +(102, 'Exeggcute', 'GrassPsychic'), +(103, 'Exeggutor', 'GrassPsychic'), +(104, 'Cubone', 'Ground'), +(105, 'Marowak', 'Ground'), +(106, 'Hitmonlee', 'Fighting'), +(107, 'Hitmonchan', 'Fighting'), +(108, 'Lickitung', 'Normal'), +(109, 'Koffing', 'Poison'), +(110, 'Weezing', 'Poison'), +(111, 'Rhyhorn', 'GroundRock'), +(112, 'Rhydon', 'GroundRock'), +(113, 'Chansey', 'Normal'), +(114, 'Tangela', 'Grass'), +(115, 'Kangaskhan', 'Normal'), +(116, 'Horsea', 'Water'), +(117, 'Seadra', 'Water'), +(118, 'Goldeen', 'Water'), +(119, 'Seaking', 'Water'), +(120, 'Staryu', 'Water'), +(121, 'Starmie', 'WaterPsychic'), +(122, 'Mr. Mime', 'PsychicFairy'), +(123, 'Scyther', 'BugFlying'), +(124, 'Jynx', 'IcePsychic'), +(125, 'Electabuzz', 'Electric'), +(126, 'Magmar', 'Fire'), +(127, 'Pinsir', 'Bug'), +(128, 'Tauros', 'Normal'), +(129, 'Magikarp', 'Water'), +(130, 'Gyarados', 'WaterFlying'), +(131, 'Lapras', 'WaterIce'), +(132, 'Ditto', 'Normal'), +(133, 'Eevee', 'Normal'), +(134, 'Vaporeon', 'Water'), +(135, 'Jolteon', 'Electric'), +(136, 'Flareon', 'Fire'), +(137, 'Porygon', 'Normal'), +(138, 'Omanyte', 'RockWater'), +(139, 'Omastar', 'RockWater'), +(140, 'Kabuto', 'RockWater'), +(141, 'Kabutops', 'RockWater'), +(142, 'Aerodactyl', 'RockFlying'), +(143, 'Snorlax', 'Normal'), +(144, 'Articuno', 'IceFlying'), +(145, 'Zapdos', 'ElectricFlying'), +(146, 'Moltres', 'FireFlying'), +(147, 'Dratini', 'Dragon'), +(148, 'Dragonair', 'Dragon'), +(149, 'Dragonite', 'DragonFlying'), +(150, 'Mewtwo', 'Psychic'), +(151, 'Mew', 'Psychic'); diff --git a/how-is-this-not-illegal/tsconfig.json b/how-is-this-not-illegal/tsconfig.json new file mode 100644 index 0000000..52ca40b --- /dev/null +++ b/how-is-this-not-illegal/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "noImplicitAny": false, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/how-is-this-not-illegal/vite.config.ts b/how-is-this-not-illegal/vite.config.ts new file mode 100644 index 0000000..0a2e98a --- /dev/null +++ b/how-is-this-not-illegal/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import { spiceflowPlugin } from "spiceflow/dist/vite"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + clearScreen: false, + plugins: [ + // inspect(), + spiceflowPlugin({ + entry: "./app/main.tsx", + }), + tailwindcss(), + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3aa8b2..263f4fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,7 +20,7 @@ importers: version: 10.1.3 prettier: specifier: ^3.4.2 - version: 3.4.2 + version: 3.5.0 spiceflow: specifier: workspace:* version: link:spiceflow @@ -44,7 +44,7 @@ importers: version: 1.50.1 '@tailwindcss/vite': specifier: ^4.0.5 - version: 4.0.5(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + version: 4.0.6(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) '@types/react': specifier: ^19.0.8 version: 19.0.8 @@ -62,7 +62,7 @@ importers: version: link:../spiceflow tailwindcss: specifier: ^4.0.5 - version: 4.0.5 + version: 4.0.6 vite: specifier: ^6.1.0 version: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) @@ -71,6 +71,51 @@ importers: specifier: ^10.1.1 version: 10.1.1(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + how-is-this-not-illegal: + dependencies: + '@fontsource/inter': + specifier: ^4.5.15 + version: 4.5.15 + '@fontsource/roboto-mono': + specifier: ^4.5.10 + version: 4.5.10 + '@tailwindcss/vite': + specifier: ^4.0.6 + version: 4.0.6(vite@6.1.0(@types/node@18.16.3)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) + '@types/node': + specifier: 18.16.3 + version: 18.16.3 + '@types/react': + specifier: 19.0.8 + version: 19.0.8 + '@types/react-dom': + specifier: 19.0.3 + version: 19.0.3(@types/react@19.0.8) + '@vercel/postgres': + specifier: 0.4.1 + version: 0.4.1 + dotenv-cli: + specifier: ^8.0.0 + version: 8.0.0 + react: + specifier: 19.0.0 + version: 19.0.0 + react-dom: + specifier: 19.0.0 + version: 19.0.0(react@19.0.0) + spiceflow: + specifier: workspace:* + version: link:../spiceflow + tailwindcss: + specifier: 4.0.6 + version: 4.0.6 + typescript: + specifier: 5.7.3 + version: 5.7.3 + vite: + specifier: ^6.1.0 + version: 6.1.0(@types/node@18.16.3)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + openapi-schema-diff: dependencies: json-schema-ref-resolver: @@ -78,7 +123,7 @@ importers: version: 2.0.1 semver: specifier: ^7.6.3 - version: 7.6.3 + version: 7.7.1 devDependencies: '@types/node': specifier: ^22.13.1 @@ -88,10 +133,10 @@ importers: version: 10.1.3 eslint: specifier: ^9.19.0 - version: 9.19.0(jiti@2.4.2) + version: 9.20.0(jiti@2.4.2) neostandard: specifier: ^0.12.0 - version: 0.12.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + version: 0.12.1(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) typescript: specifier: ^5.7.3 version: 5.7.3 @@ -109,10 +154,10 @@ importers: version: 1.1.9(zod@3.24.1) '@fastify/deepmerge': specifier: ^2.0.1 - version: 2.0.1 + version: 2.0.2 ai: specifier: ^4.1.17 - version: 4.1.17(react@19.0.0)(zod@3.24.1) + version: 4.1.34(react@19.0.0)(zod@3.24.1) camelcase: specifier: ^8.0.0 version: 8.0.0 @@ -148,7 +193,7 @@ importers: version: 23.0.171 semver: specifier: ^7.6.3 - version: 7.6.3 + version: 7.7.1 string-dedent: specifier: ^3.0.1 version: 3.0.1 @@ -174,15 +219,12 @@ importers: '@hiogawa/transforms': specifier: ^0.0.0 version: 0.0.0 - '@jacob-ebey/vite-react-server-dom': - specifier: ^0.0.12 - version: 0.0.12(@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(rollup@4.34.6)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) '@modelcontextprotocol/sdk': specifier: ^1.0.4 - version: 1.0.4 + version: 1.4.1 '@sinclair/typebox': specifier: ^0.34.14 - version: 0.34.15 + version: 0.34.16 '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.3.4(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) @@ -221,7 +263,7 @@ importers: version: 0.0.4 spiceflow: specifier: '*' - version: 1.6.1(@modelcontextprotocol/sdk@1.0.4) + version: 1.6.1(@modelcontextprotocol/sdk@1.4.1) superjson: specifier: ^2.2.2 version: 2.2.2 @@ -276,10 +318,10 @@ importers: version: 3.1.0(acorn@8.14.0)(rollup@4.34.6) '@remix-run/cloudflare': specifier: ^2.15.3 - version: 2.15.3(@cloudflare/workers-types@4.20250129.0)(typescript@5.7.3) + version: 2.15.3(@cloudflare/workers-types@4.20250204.0)(typescript@5.7.3) '@remix-run/cloudflare-pages': specifier: ^2.15.3 - version: 2.15.3(@cloudflare/workers-types@4.20250129.0)(typescript@5.7.3) + version: 2.15.3(@cloudflare/workers-types@4.20250204.0)(typescript@5.7.3) '@remix-run/react': specifier: ^2.15.3 version: 2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3) @@ -297,7 +339,7 @@ importers: version: 4.4.0 miniflare: specifier: ^3.20240404.0 - version: 3.20250129.0 + version: 3.20250204.0(bufferutil@4.0.9) react: specifier: 19.0.0 version: 19.0.0 @@ -319,10 +361,10 @@ importers: devDependencies: '@cloudflare/workers-types': specifier: ^4.20240502.0 - version: 4.20250129.0 + version: 4.20250204.0 '@remix-run/dev': specifier: ^2.15.3 - version: 2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0)) + version: 2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.1)(bufferutil@4.0.9)(lightningcss@1.29.1)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.108.0(@cloudflare/workers-types@4.20250204.0)(bufferutil@4.0.9)) '@types/react': specifier: ^19.0.8 version: 19.0.8 @@ -331,13 +373,13 @@ importers: version: 19.0.3(@types/react@19.0.8) autoprefixer: specifier: ^10.4.19 - version: 10.4.20(postcss@8.5.1) + version: 10.4.20(postcss@8.5.2) node-fetch: specifier: ^3.3.2 version: 3.3.2 postcss: specifier: ^8.4.38 - version: 8.5.1 + version: 8.5.2 rehype-mdx-import-media: specifier: ^1.2.0 version: 1.2.0 @@ -355,7 +397,7 @@ importers: version: 4.3.2(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) wrangler: specifier: ^3.48.0 - version: 3.107.2(@cloudflare/workers-types@4.20250129.0) + version: 3.108.0(@cloudflare/workers-types@4.20250204.0)(bufferutil@4.0.9) packages: @@ -396,8 +438,8 @@ packages: resolution: {integrity: sha512-q1PJEZ0qD9rVR+8JFEd01/QM++csMT5UVwYXSN2u54BrVw/D8TZLTeg2FEfKK00DgAx0UtWd8XOhhwITP9BT5g==} engines: {node: '>=18'} - '@ai-sdk/react@1.1.8': - resolution: {integrity: sha512-buHm7hP21xEOksnRQtJX9fKbi7cAUwanEBa5niddTDibCDKd+kIXP2vaJGy8+heB3rff+XSW3BWlA8pscK+n1g==} + '@ai-sdk/react@1.1.11': + resolution: {integrity: sha512-vfjZ7w2M+Me83HTMMrnnrmXotz39UDCMd27YQSrvt2f1YCLPloVpLhP+Y9TLZeFE/QiiRCrPYLDQm6aQJYJ9PQ==} engines: {node: '>=18'} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc @@ -408,8 +450,8 @@ packages: zod: optional: true - '@ai-sdk/ui-utils@1.1.8': - resolution: {integrity: sha512-nbok53K1EalO2sZjBLFB33cqs+8SxiL6pe7ekZ7+5f2MJTwdvpShl6d9U4O8fO3DnZ9pYLzaVC0XNMxnJt030Q==} + '@ai-sdk/ui-utils@1.1.11': + resolution: {integrity: sha512-1SC9W4VZLcJtxHRv4Y0aX20EFeaEP6gUvVqoKLBBtMLOgtcZrv/F/HQRjGavGugiwlS3dsVza4X+E78fiwtlTA==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 @@ -674,38 +716,38 @@ packages: resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} engines: {node: '>=16.13'} - '@cloudflare/workerd-darwin-64@1.20250129.0': - resolution: {integrity: sha512-M+xETVnl+xy2dfDDWmp0XXr2rttl70a6bljQygl0EmYmNswFTcYbQWCaBuNBo9kabU59rLKr4a/b3QZ07NoL/g==} + '@cloudflare/workerd-darwin-64@1.20250204.0': + resolution: {integrity: sha512-HpsgbWEfvdcwuZ8WAZhi1TlSCyyHC3tbghpKsOqGDaQNltyAFAWqa278TPNfcitYf/FmV4961v3eqUE+RFdHNQ==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20250129.0': - resolution: {integrity: sha512-c4PQUyIMp+bCMxZkAMBzXgTHjRZxeYCujDbb3staestqgRbenzcfauXsMd6np35ng+EE1uBgHNPV4+7fC0ZBfg==} + '@cloudflare/workerd-darwin-arm64@1.20250204.0': + resolution: {integrity: sha512-AJ8Tk7KMJqePlch3SH8oL41ROtsrb07hKRHD6M+FvGC3tLtf26rpteAAMNYKMDYKzFNFUIKZNijYDFZjBFndXQ==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20250129.0': - resolution: {integrity: sha512-xJx8LwWFxsm5U3DETJwRuOmT5RWBqm4FmA4itYXvcEICca9pWJDB641kT4PnpypwDNmYOebhU7A+JUrCRucG0w==} + '@cloudflare/workerd-linux-64@1.20250204.0': + resolution: {integrity: sha512-RIUfUSnDC8h73zAa+u1K2Frc7nc+eeQoBBP7SaqsRe6JdX8jfIv/GtWjQWCoj8xQFgLvhpJKZ4sTTTV+AilQbw==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20250129.0': - resolution: {integrity: sha512-dR//npbaX5p323huBVNIy5gaWubQx6CC3aiXeK0yX4aD5ar8AjxQFb2U/Sgjeo65Rkt53hJWqC7IwRpK/eOxrA==} + '@cloudflare/workerd-linux-arm64@1.20250204.0': + resolution: {integrity: sha512-8Ql8jDjoIgr2J7oBD01kd9kduUz60njofrBpAOkjCPed15He8e8XHkYaYow3g0xpae4S2ryrPOeoD3M64sRxeg==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20250129.0': - resolution: {integrity: sha512-OeO+1nPj/ocAE3adFar/tRFGRkbCrBnrOYXq0FUBSpyNHpDdA9/U3PAw5CN4zvjfTnqXZfTxTFeqoruqzRzbtg==} + '@cloudflare/workerd-windows-64@1.20250204.0': + resolution: {integrity: sha512-RpDJO3+to+e17X3EWfRCagboZYwBz2fowc+jL53+fd7uD19v3F59H48lw2BDpHJMRyhg6ouWcpM94OhsHv8ecA==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20250129.0': - resolution: {integrity: sha512-H7g/sDB9GaV+fIPf3utNEYncFhryIvDThiBbfZtu0bZmVXcVd9ApP3OMqUYhNV8MShWQASvgWletKKBZGT9/oA==} + '@cloudflare/workers-types@4.20250204.0': + resolution: {integrity: sha512-mWoQbYaP+nYztx9I7q9sgaiNlT54Cypszz0RfzMxYnT5W3NXDuwGcjGB+5B5H5VB8tEC2dYnBRpa70lX94ueaQ==} '@code-hike/lighter@0.7.0': resolution: {integrity: sha512-64O07rIORKQLB+5T/GKAmKcD9sC0N9yHFJXa0Hs+0Aee1G+I4bSXxTccuDFP6c/G/3h5Pk7yv7PoX9/SpzaeiQ==} @@ -719,6 +761,9 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@emnapi/runtime@1.3.1': + resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} + '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} @@ -1446,12 +1491,16 @@ packages: resolution: {integrity: sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@0.11.0': + resolution: {integrity: sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/eslintrc@3.2.0': resolution: {integrity: sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.19.0': - resolution: {integrity: sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==} + '@eslint/js@9.20.0': + resolution: {integrity: sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.5': @@ -1466,8 +1515,14 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} - '@fastify/deepmerge@2.0.1': - resolution: {integrity: sha512-hx+wJQr9Ph1hY/dyzY0SxqjumMyqZDlIF6oe71dpRKDHUg7dFQfjG94qqwQ274XRjmUrwKiYadex8XplNHx3CA==} + '@fastify/deepmerge@2.0.2': + resolution: {integrity: sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==} + + '@fontsource/inter@4.5.15': + resolution: {integrity: sha512-FzleM9AxZQK2nqsTDtBiY0PMEVWvnKnuu2i09+p6DHvrHsuucoV2j0tmw+kAT3L4hvsLdAIDv6MdGehsPIdT+Q==} + + '@fontsource/roboto-mono@4.5.10': + resolution: {integrity: sha512-KrJdmkqz6DszT2wV/bbhXef4r0hV3B0vw2mAqei8A2kRnvq+gcJLmmIeQ94vu9VEXrUQzos5M9lH1TAAXpRphw==} '@glideapps/ts-necessities@2.2.3': resolution: {integrity: sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==} @@ -1501,6 +1556,111 @@ packages: resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==} engines: {node: '>=18.18'} + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1516,19 +1676,6 @@ packages: react: ^19.0.0 react-dom: ^19.0.0 - '@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15': - resolution: {integrity: sha512-RTrrqEpaiWunZCLBQrsA+rZFNYSTjo/XiV5Uti21bx/IlEHQ6o8TyUxHxeoHPm0jTm/uRgxXVe46EwbzR7ywuw==} - engines: {node: '>=0.10.0'} - peerDependencies: - react: ^19.0.0 - react-dom: ^19.0.0 - - '@jacob-ebey/vite-react-server-dom@0.0.12': - resolution: {integrity: sha512-XzaqcFlnXgRv2ZVyHJEhJGLvTsx0XCpnGqlHywzrp6XjUMujyRvEdNp4EGt8VGpGtH0o+TlS6YxLTc8MVH3jGQ==} - peerDependencies: - '@jacob-ebey/react-server-dom-vite': '*' - vite: ^6.0.0 - '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -1587,11 +1734,12 @@ packages: resolution: {integrity: sha512-mdvS1spIxmZoUbTdYmWknHtwm72WwrGNoQCDd4RTvcXJ9G6XThxeC3g+cpOf6Fw6vIERHt50pYiJpsk5XTJQ5w==} engines: {node: '>=8'} - '@mjackson/node-fetch-server@0.5.0': - resolution: {integrity: sha512-GZrkGuP3N7he0GdK9CCqpjabqsXjJa4tp0yKw973FoGAAOGE6WTcp3kcosRdeGYqtoFn7IEu84g3pItk4wRBFg==} + '@modelcontextprotocol/sdk@1.4.1': + resolution: {integrity: sha512-wS6YC4lkUZ9QpP+/7NBTlVNiEvsnyl0xF7rRusLF+RsG0xDPc/zWR7fEEyhKnnNutGsDAZh59l/AeoWGwIb1+g==} + engines: {node: '>=18'} - '@modelcontextprotocol/sdk@1.0.4': - resolution: {integrity: sha512-C+jw1lF6HSGzs7EZpzHbXfzz9rj9him4BaoumlTciW/IDDgIpweF/qiCWKlP02QKg5PPcgY6xY2WCt5y2tpYow==} + '@neondatabase/serverless@0.5.6': + resolution: {integrity: sha512-Ru0lG6W/nQtHRkDFVQFF+1PJYx8wd3jereln0Ep0YkiHey50hjTLVUycQoE4X977605pXMuFWORweuktzph+Xg==} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1938,8 +2086,8 @@ packages: cpu: [x64] os: [win32] - '@sinclair/typebox@0.34.15': - resolution: {integrity: sha512-xeIzl3h1Znn9w/LTITqpiwag0gXjA+ldi2ZkXIBxGEppGCW211Tza+eL6D4pKqs10bj5z2umBWk5WL6spQ2OCQ==} + '@sinclair/typebox@0.34.16': + resolution: {integrity: sha512-rIljj8VPYAfn26ANY+5pCNVBPiv6hSufuKGe46y65cJZpvx8vHvPXlU0Q/Le4OGtlNaL8Jg2FuhtvQX18lSIqA==} '@stefanprobst/rehype-extract-toc@2.2.1': resolution: {integrity: sha512-SfDrnqz7WVp/xYxPqAxD4lR/CJZcsFcy1T0JNAZfK4grdHJAbHplhF5yZgAOnba5+7ovbpRwfHMffTFlrcvwFQ==} @@ -1951,77 +2099,77 @@ packages: peerDependencies: eslint: '>=8.40.0' - '@tailwindcss/node@4.0.5': - resolution: {integrity: sha512-ffTz4DX1cgr4XPuqjhm32YV6Lyx58R1CxAAnSFTamg6wXwfk3oWdb6exgAbGesPzvUgicTO0gwUdQGSsg4nNog==} + '@tailwindcss/node@4.0.6': + resolution: {integrity: sha512-jb6E0WeSq7OQbVYcIJ6LxnZTeC4HjMvbzFBMCrQff4R50HBlo/obmYNk6V2GCUXDeqiXtvtrQgcIbT+/boB03Q==} - '@tailwindcss/oxide-android-arm64@4.0.5': - resolution: {integrity: sha512-kK/ik8aIAKWDIEYDZGUCJcnU1qU5sPoMBlVzPvtsUqiV6cSHcnVRUdkcLwKqTeUowzZtjjRiamELLd9Gb0x5BQ==} + '@tailwindcss/oxide-android-arm64@4.0.6': + resolution: {integrity: sha512-xDbym6bDPW3D2XqQqX3PjqW3CKGe1KXH7Fdkc60sX5ZLVUbzPkFeunQaoP+BuYlLc2cC1FoClrIRYnRzof9Sow==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.0.5': - resolution: {integrity: sha512-vkbXFv0FfAEbrSa5NBjFEE+xi06ha7mxuxjY8LRn7d7/tBGrAZOEJnnsEbB6M1+x2pGRTjjei0XyTIXdVCglJA==} + '@tailwindcss/oxide-darwin-arm64@4.0.6': + resolution: {integrity: sha512-1f71/ju/tvyGl5c2bDkchZHy8p8EK/tDHCxlpYJ1hGNvsYihZNurxVpZ0DefpN7cNc9RTT8DjrRoV8xXZKKRjg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.0.5': - resolution: {integrity: sha512-PedA64rHBXEa4e6abBWE4Yj4gHulfPb5T+rBNnX+WGkjjge5Txa2oS99TLmJ5BPDkXXqz/Ba7oweWIDDG7i5NQ==} + '@tailwindcss/oxide-darwin-x64@4.0.6': + resolution: {integrity: sha512-s/hg/ZPgxFIrGMb0kqyeaqZt505P891buUkSezmrDY6lxv2ixIELAlOcUVTkVh245SeaeEiUVUPiUN37cwoL2g==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.0.5': - resolution: {integrity: sha512-silz3nuZdEYDfic3v/ooVUQChj9hbxDSee43GCQNwr/iD9L4K/JsZtoNqr0w69pUkvWcKINOGOG0r7WqUqkAeg==} + '@tailwindcss/oxide-freebsd-x64@4.0.6': + resolution: {integrity: sha512-Z3Wo8FWZnmio8+xlcbb7JUo/hqRMSmhQw8IGIRoRJ7GmLR0C+25Wq+bEX/135xe/yEle2lFkhu9JBHd4wZYiig==} engines: {node: '>= 10'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.5': - resolution: {integrity: sha512-ElneG75XS64B9I2G83A/Hc7EtNVOD5xahs7avq0aeW7mEX6CtMc8m8RCXMn3jGhz8enFE52l6QU0wO7iVkEtXQ==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.6': + resolution: {integrity: sha512-SNSwkkim1myAgmnbHs4EjXsPL7rQbVGtjcok5EaIzkHkCAVK9QBQsWeP2Jm2/JJhq4wdx8tZB9Y7psMzHYWCkA==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.0.5': - resolution: {integrity: sha512-8yoXpWTeIFaByUaKy2qRAppznLVaDHP9xYCAbS3FG7+uUwHi8CHE4TcomM7eyamo0U7dbUIDgKMGoAX5s2iVrA==} + '@tailwindcss/oxide-linux-arm64-gnu@4.0.6': + resolution: {integrity: sha512-tJ+mevtSDMQhKlwCCuhsFEFg058kBiSy4TkoeBG921EfrHKmexOaCyFKYhVXy4JtkaeeOcjJnCLasEeqml4i+Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.0.5': - resolution: {integrity: sha512-BDlVSiiJ08GRz9KKnXgaPFs2fkukPF3pym6uK3oWEKW45jKlVGgybLqulcV5nLEqREOuyq4Rn4vnZss4/bbQ/g==} + '@tailwindcss/oxide-linux-arm64-musl@4.0.6': + resolution: {integrity: sha512-IoArz1vfuTR4rALXMUXI/GWWfx2EaO4gFNtBNkDNOYhlTD4NVEwE45nbBoojYiTulajI4c2XH8UmVEVJTOJKxA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.0.5': - resolution: {integrity: sha512-DYgieNDRkTy69bWPgdsc47nAXa74P63P/RetUwYM9vYj5USyOfHCEcqIthkCuYw3dXKBhjgwe697TmL2g2jpAw==} + '@tailwindcss/oxide-linux-x64-gnu@4.0.6': + resolution: {integrity: sha512-QtsUfLkEAeWAC3Owx9Kg+7JdzE+k9drPhwTAXbXugYB9RZUnEWWx5x3q/au6TvUYcL+n0RBqDEO2gucZRvRFgQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.0.5': - resolution: {integrity: sha512-z2RzUvOQl0ZqrZqmCFP53tJbBXQ3UmLD/E6J7+q0e+4VaFnXCcIYTfQbHgI8f3fash+q6gK80Ko/ywEQ+bvv6Q==} + '@tailwindcss/oxide-linux-x64-musl@4.0.6': + resolution: {integrity: sha512-QthvJqIji2KlGNwLcK/PPYo7w1Wsi/8NK0wAtRGbv4eOPdZHkQ9KUk+oCoP20oPO7i2a6X1aBAFQEL7i08nNMA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-win32-arm64-msvc@4.0.5': - resolution: {integrity: sha512-ho1dJ4o5Q8nAOxdMkbfBu5aSqI+/bzQ0jEeHcXaEdEJzf2fSWs3HY7bIKtE6vQS8c4SmSBvls7IhGPuJxNg+2Q==} + '@tailwindcss/oxide-win32-arm64-msvc@4.0.6': + resolution: {integrity: sha512-+oka+dYX8jy9iP00DJ9Y100XsqvbqR5s0yfMZJuPR1H/lDVtDfsZiSix1UFBQ3X1HWxoEEl6iXNJHWd56TocVw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.0.5': - resolution: {integrity: sha512-yjw6JhtyDXr+G0aZrj3L3NlEV7CobSqOdPyfo6G3d91WEZ5b8PyGm86IAreX08Jp9DChGXEd53gWysVpWCTs+w==} + '@tailwindcss/oxide-win32-x64-msvc@4.0.6': + resolution: {integrity: sha512-+o+juAkik4p8Ue/0LiflQXPmVatl6Av3LEZXpBTfg4qkMIbZdhCGWFzHdt2NjoMiLOJCFDddoV6GYaimvK1Olw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.0.5': - resolution: {integrity: sha512-iWGyOCu0TuzvCBisWbGv2K9+7QCfE0ztgtrZOvb9iF7V7ChVkD15Obe3HevZrhjngAc34jDA+OMSuSvkrpTy4A==} + '@tailwindcss/oxide@4.0.6': + resolution: {integrity: sha512-lVyKV2y58UE9CeKVcYykULe9QaE1dtKdxDEdrTPIdbzRgBk6bdxHNAoDqvcqXbIGXubn3VOl1O/CFF77v/EqSA==} engines: {node: '>= 10'} '@tailwindcss/typography@0.5.16': @@ -2029,8 +2177,8 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.0.5': - resolution: {integrity: sha512-/i4hjLTUYVjUG0MTUviQP3HR/hzwyzv8Sq4sz2pnsNuf+FIjjhJB0vcnIMH1KIX0k8ozD6CBv2Dl76tlm/JFFA==} + '@tailwindcss/vite@4.0.6': + resolution: {integrity: sha512-O25vZ/URWbZ2JHdk2o8wH7jOKqEGCsYmX3GwGmYS5DjE4X3mpf93a72Rn7VRnefldNauBzr5z2hfZptmBNtTUQ==} peerDependencies: vite: ^5.2.0 || ^6 @@ -2121,9 +2269,15 @@ packages: '@types/node@16.18.126': resolution: {integrity: sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==} + '@types/node@18.16.3': + resolution: {integrity: sha512-OPs5WnnT1xkCBiuQrZA4+YAV4HEJejmHneyraIaxsbev5yCEr6KMwINNFP9wQeFIw8FWcoTqF3vQsa5CDaI+8Q==} + '@types/node@22.13.1': resolution: {integrity: sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==} + '@types/pg@8.6.6': + resolution: {integrity: sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==} + '@types/react-dom@19.0.3': resolution: {integrity: sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==} peerDependencies: @@ -2200,6 +2354,10 @@ packages: '@vanilla-extract/private@1.0.6': resolution: {integrity: sha512-ytsG/JLweEjw7DBuZ/0JCN4WAQgM9erfSTdS1NQY778hFQSZ6cfCDEZZ0sgVm4k54uNz6ImKB33AYvSR//fjxw==} + '@vercel/postgres@0.4.1': + resolution: {integrity: sha512-rYlNnaXrr2/NWK/OodhAUyed0bomaizKKC8XXjNYv8I1K3m75oocP4IGTcBpZe76VCrHuaKW5d6jLQnuRRoNKg==} + engines: {node: '>=14.6'} + '@vitejs/plugin-react@4.3.4': resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2254,6 +2412,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.2: + resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} + engines: {node: '>=0.4.0'} + acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -2267,8 +2429,8 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} - ai@4.1.17: - resolution: {integrity: sha512-5SW15tXDuxE/wlEOjRKxLxTOUIGD4C9bIee+FCFvXHTTAZhHiQjViC2s7RtMUW+hbFtGya302jUHY1Pe8A/YuQ==} + ai@4.1.34: + resolution: {integrity: sha512-9IB5duz6VbXvjibqNrvKz6++PwE8Ui5UfbOC9/CtcQN5Z9sudUQErss+maj7ptoPysD2NPjj99e0Hp183Cz5LQ==} engines: {node: '>=18'} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc @@ -2454,6 +2616,14 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bufferutil@4.0.7: + resolution: {integrity: sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==} + engines: {node: '>=6.14.2'} + + bufferutil@4.0.9: + resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} + engines: {node: '>=6.14.2'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -2593,6 +2763,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2635,6 +2812,10 @@ packages: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} + cookie@0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -2643,10 +2824,6 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - copy-anything@3.0.5: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} @@ -2796,6 +2973,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -2832,6 +3013,14 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dotenv-cli@8.0.0: + resolution: {integrity: sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==} + hasBin: true + + dotenv-expand@10.0.0: + resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} + engines: {node: '>=12'} + dotenv@16.4.7: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} @@ -2873,6 +3062,10 @@ packages: resolution: {integrity: sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.18.1: + resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -3033,8 +3226,8 @@ packages: resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.19.0: - resolution: {integrity: sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==} + eslint@9.20.0: + resolution: {integrity: sha512-aL4F8167Hg4IvsW89ejnpTwx+B/UQRzJPGgbIOl+4XqffWsahVVsLEWoZvnrVuwpWmnRd7XeXmQI1zlKcFDteA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -3551,6 +3744,9 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + is-async-function@2.1.0: resolution: {integrity: sha512-GExz9MtyhlZyXYLxzlJRj5WUCE661zhDa1Yna52CN57AJsymh+DvXXjyveSioqSRdxvUrdKdvqB1b5cVKsNpWQ==} engines: {node: '>= 0.4'} @@ -4290,8 +4486,8 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - miniflare@3.20250129.0: - resolution: {integrity: sha512-qYlGEjMl/2kJdgNaztj4hpA64d6Dl79Lx/NL61p/v5XZRiWanBOTgkQqdPxCKZOj6KQnioqhC7lfd6jDXKSs2A==} + miniflare@3.20250204.0: + resolution: {integrity: sha512-f7tezEkOvVRVHIVul2EbTyKvWJCXpTDRAOxTxtD4N92+YI8PC2P8AvO4Z30vlN61r5Pje33fTBG8G1fEwSZIqQ==} engines: {node: '>=16.13'} hasBin: true @@ -4387,8 +4583,8 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} - neostandard@0.12.0: - resolution: {integrity: sha512-MvtiRhevDzE+oqQUxFvDsEmipzy3erNmnz5q5TG9M8xZ30n86rt4PxGP9jgocGIZr1105OgPZNlK2FQEtb2Vng==} + neostandard@0.12.1: + resolution: {integrity: sha512-As/LDK+xx591BLb1rPRaPs+JfXFgyNx5BoBui1KBeF/J4s0mW8+NBohrYnMfgm1w1t7E/Y/tU34MjMiP6lns6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -4411,6 +4607,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} @@ -4649,6 +4849,17 @@ packages: periscopic@4.0.2: resolution: {integrity: sha512-sqpQDUy8vgB7ycLkendSKS6HnVz1Rneoc3Rc+ZBUCe2pbqlVuCC5vF52l0NJ1aiMg/r1qfYF9/myz8CZeI2rjA==} + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.7.1: + resolution: {integrity: sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4782,6 +4993,26 @@ packages: resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.2: + resolution: {integrity: sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4791,8 +5022,8 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - prettier@3.4.2: - resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==} + prettier@3.5.0: + resolution: {integrity: sha512-quyMrVt6svPS7CjQ9gKb3GLEX/rl3BCL2oa/QkNcXv4YNVBC9olt3s+H7ukto06q7B1Qz46PbrKLO34PR6vXcA==} engines: {node: '>=14'} hasBin: true @@ -5124,8 +5355,8 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.7.0: - resolution: {integrity: sha512-DrfFnPzblFmNrIZzg5RzHegbiRWg7KMR7btwi2yjHwx06zsUbO5g613sVwEV7FTwmzJu+Io0lJe2GJ3LxqpvBQ==} + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} engines: {node: '>=10'} hasBin: true @@ -5155,6 +5386,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5189,6 +5424,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sirv@3.0.0: resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==} engines: {node: '>=18'} @@ -5373,8 +5611,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - swr@2.3.0: - resolution: {integrity: sha512-NyZ76wA4yElZWBHzSgEJc28a0u6QZvhb6w0azeL2k7+Q1gAzVK+IqQYXhVOC/mzi+HZIozrZvBVeSeOZNR2bqA==} + swr@2.3.2: + resolution: {integrity: sha512-RosxFpiabojs75IwQ316DGoDRmOqtiAj0tg8wCcbEu4CiLZBs/a9QNtHV7TUfDXmmlgqij/NqzKq/eLelyv9xA==} peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5387,8 +5625,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - tailwindcss@4.0.5: - resolution: {integrity: sha512-DZZIKX3tA23LGTjHdnwlJOTxfICD1cPeykLLsYF1RQBI9QsCR3i0szohJfJDVjr6aNRAIio5WVO7FGB77fRHwg==} + tailwindcss@4.0.6: + resolution: {integrity: sha512-mysewHYJKaXgNOW6pp5xon/emCsfAMnO8WMaGKZZ35fomnR/T5gYnRg2/yRTTrtXiEl1tiVkeRt0eMO6HxEZqw==} tapable@2.2.1: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} @@ -5702,6 +5940,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf-8-validate@6.0.3: + resolution: {integrity: sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==} + engines: {node: '>=6.14.2'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5947,17 +6189,17 @@ packages: resolution: {integrity: sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==} engines: {node: '>=12.17'} - workerd@1.20250129.0: - resolution: {integrity: sha512-Rprz8rxKTF4l6q/nYYI07lBetJnR19mGipx+u/a27GZOPKMG5SLIzA2NciZlJaB2Qd5YY+4p/eHOeKqo5keVWA==} + workerd@1.20250204.0: + resolution: {integrity: sha512-zcKufjVFsQMiD3/acg1Ix00HIMCkXCrDxQXYRDn/1AIz3QQGkmbVDwcUk1Ki2jBUoXmBCMsJdycRucgMVEypWg==} engines: {node: '>=16'} hasBin: true - wrangler@3.107.2: - resolution: {integrity: sha512-YOSfx0pETj18qcBD4aLvHjlcoV1sCVxYm9En8YphymW5rlTYD0Lc4MR3kzN1AGiWCjJ/ydrvA7eZuF/zPBotiw==} + wrangler@3.108.0: + resolution: {integrity: sha512-w8J0VtDqn8F94qw+HnxFbri7MMdT/to5/w1QHAjR//tIHkilKAUFNaEF3GDEJREvUG3iHuawrH2p5ATTHnFc/Q==} engines: {node: '>=16.17.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20250129.0 + '@cloudflare/workers-types': ^4.20250204.0 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -5985,6 +6227,18 @@ packages: utf-8-validate: optional: true + ws@8.13.0: + resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -6032,8 +6286,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - youch@3.3.4: - resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==} + youch@3.2.3: + resolution: {integrity: sha512-ZBcWz/uzZaQVdCvfV4uk616Bbpf2ee+F/AvuKDR5EwX/Y4v06xWdtMluqTD7+KlZdM93lLm9gMZYo0sKBS0pgw==} zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} @@ -6043,6 +6297,9 @@ packages: peerDependencies: zod: ^3.24.1 + zod@3.22.3: + resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} + zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} @@ -6090,17 +6347,17 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.1.8(react@19.0.0)(zod@3.24.1)': + '@ai-sdk/react@1.1.11(react@19.0.0)(zod@3.24.1)': dependencies: '@ai-sdk/provider-utils': 2.1.6(zod@3.24.1) - '@ai-sdk/ui-utils': 1.1.8(zod@3.24.1) - swr: 2.3.0(react@19.0.0) + '@ai-sdk/ui-utils': 1.1.11(zod@3.24.1) + swr: 2.3.2(react@19.0.0) throttleit: 2.1.0 optionalDependencies: react: 19.0.0 zod: 3.24.1 - '@ai-sdk/ui-utils@1.1.8(zod@3.24.1)': + '@ai-sdk/ui-utils@1.1.11(zod@3.24.1)': dependencies: '@ai-sdk/provider': 1.0.7 '@ai-sdk/provider-utils': 2.1.6(zod@3.24.1) @@ -6541,22 +6798,22 @@ snapshots: dependencies: mime: 3.0.0 - '@cloudflare/workerd-darwin-64@1.20250129.0': + '@cloudflare/workerd-darwin-64@1.20250204.0': optional: true - '@cloudflare/workerd-darwin-arm64@1.20250129.0': + '@cloudflare/workerd-darwin-arm64@1.20250204.0': optional: true - '@cloudflare/workerd-linux-64@1.20250129.0': + '@cloudflare/workerd-linux-64@1.20250204.0': optional: true - '@cloudflare/workerd-linux-arm64@1.20250129.0': + '@cloudflare/workerd-linux-arm64@1.20250204.0': optional: true - '@cloudflare/workerd-windows-64@1.20250129.0': + '@cloudflare/workerd-windows-64@1.20250204.0': optional: true - '@cloudflare/workers-types@4.20250129.0': {} + '@cloudflare/workers-types@4.20250204.0': {} '@code-hike/lighter@0.7.0': {} @@ -6572,6 +6829,11 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@emnapi/runtime@1.3.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emotion/hash@0.9.2': {} '@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)': @@ -6932,9 +7194,9 @@ snapshots: '@esbuild/win32-x64@0.24.2': optional: true - '@eslint-community/eslint-utils@4.4.1(eslint@9.19.0(jiti@2.4.2))': + '@eslint-community/eslint-utils@4.4.1(eslint@9.20.0(jiti@2.4.2))': dependencies: - eslint: 9.19.0(jiti@2.4.2) + eslint: 9.20.0(jiti@2.4.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -6951,6 +7213,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@eslint/core@0.11.0': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/eslintrc@3.2.0': dependencies: ajv: 6.12.6 @@ -6965,7 +7231,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.19.0': {} + '@eslint/js@9.20.0': {} '@eslint/object-schema@2.1.5': {} @@ -6976,7 +7242,11 @@ snapshots: '@fastify/busboy@2.1.1': {} - '@fastify/deepmerge@2.0.1': {} + '@fastify/deepmerge@2.0.2': {} + + '@fontsource/inter@4.5.15': {} + + '@fontsource/roboto-mono@4.5.10': {} '@glideapps/ts-necessities@2.2.3': {} @@ -7003,6 +7273,81 @@ snapshots: '@humanwhocodes/retry@0.4.1': {} + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.3.1 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -7019,22 +7364,6 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - '@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': - dependencies: - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - - '@jacob-ebey/vite-react-server-dom@0.0.12(@jacob-ebey/react-server-dom-vite@19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(rollup@4.34.6)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': - dependencies: - '@jacob-ebey/react-server-dom-vite': 19.0.0-experimental.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - '@mjackson/node-fetch-server': 0.5.0 - '@vitejs/plugin-react': 4.3.4(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0)) - unplugin-rsc: 0.0.11(rollup@4.34.6) - vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) - transitivePeerDependencies: - - rollup - - supports-color - '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -7168,13 +7497,17 @@ snapshots: dependencies: object-treeify: 1.1.33 - '@mjackson/node-fetch-server@0.5.0': {} - - '@modelcontextprotocol/sdk@1.0.4': + '@modelcontextprotocol/sdk@1.4.1': dependencies: content-type: 1.0.5 + eventsource: 3.0.5 raw-body: 3.0.0 zod: 3.24.1 + zod-to-json-schema: 3.24.1(zod@3.24.1) + + '@neondatabase/serverless@0.5.6': + dependencies: + '@types/pg': 8.6.6 '@nodelib/fs.scandir@2.1.5': dependencies: @@ -7192,7 +7525,7 @@ snapshots: '@npmcli/fs@3.1.1': dependencies: - semver: 7.7.0 + semver: 7.7.1 '@npmcli/git@4.1.0': dependencies: @@ -7202,7 +7535,7 @@ snapshots: proc-log: 3.0.0 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.7.0 + semver: 7.7.1 which: 3.0.1 transitivePeerDependencies: - bluebird @@ -7215,7 +7548,7 @@ snapshots: json-parse-even-better-errors: 3.0.2 normalize-package-data: 5.0.0 proc-log: 3.0.0 - semver: 7.7.0 + semver: 7.7.1 transitivePeerDependencies: - bluebird @@ -7234,22 +7567,22 @@ snapshots: '@polka/url@1.0.0-next.28': {} - '@remix-run/cloudflare-pages@2.15.3(@cloudflare/workers-types@4.20250129.0)(typescript@5.7.3)': + '@remix-run/cloudflare-pages@2.15.3(@cloudflare/workers-types@4.20250204.0)(typescript@5.7.3)': dependencies: - '@cloudflare/workers-types': 4.20250129.0 - '@remix-run/cloudflare': 2.15.3(@cloudflare/workers-types@4.20250129.0)(typescript@5.7.3) + '@cloudflare/workers-types': 4.20250204.0 + '@remix-run/cloudflare': 2.15.3(@cloudflare/workers-types@4.20250204.0)(typescript@5.7.3) optionalDependencies: typescript: 5.7.3 - '@remix-run/cloudflare@2.15.3(@cloudflare/workers-types@4.20250129.0)(typescript@5.7.3)': + '@remix-run/cloudflare@2.15.3(@cloudflare/workers-types@4.20250204.0)(typescript@5.7.3)': dependencies: '@cloudflare/kv-asset-handler': 0.1.3 - '@cloudflare/workers-types': 4.20250129.0 + '@cloudflare/workers-types': 4.20250204.0 '@remix-run/server-runtime': 2.15.3(typescript@5.7.3) optionalDependencies: typescript: 5.7.3 - '@remix-run/dev@2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0))': + '@remix-run/dev@2.15.3(@remix-run/react@2.15.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(@types/node@22.13.1)(bufferutil@4.0.9)(lightningcss@1.29.1)(terser@5.31.6)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3))(typescript@5.7.3)(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))(wrangler@3.108.0(@cloudflare/workers-types@4.20250204.0)(bufferutil@4.0.9))': dependencies: '@babel/core': 7.26.7 '@babel/generator': 7.26.5 @@ -7291,26 +7624,26 @@ snapshots: picocolors: 1.1.1 picomatch: 2.3.1 pidtree: 0.6.0 - postcss: 8.5.1 - postcss-discard-duplicates: 5.1.0(postcss@8.5.1) - postcss-load-config: 4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)) - postcss-modules: 6.0.1(postcss@8.5.1) + postcss: 8.5.2 + postcss-discard-duplicates: 5.1.0(postcss@8.5.2) + postcss-load-config: 4.0.2(postcss@8.5.2)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)) + postcss-modules: 6.0.1(postcss@8.5.2) prettier: 2.8.8 pretty-ms: 7.0.1 react-refresh: 0.14.2 remark-frontmatter: 4.0.1 remark-mdx-frontmatter: 1.1.1 - semver: 7.7.0 + semver: 7.7.1 set-cookie-parser: 2.7.1 tar-fs: 2.1.2 tsconfig-paths: 4.2.0 valibot: 0.41.0(typescript@5.7.3) vite-node: 1.6.0(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6) - ws: 7.5.10 + ws: 7.5.10(bufferutil@4.0.9) optionalDependencies: typescript: 5.7.3 vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) - wrangler: 3.107.2(@cloudflare/workers-types@4.20250129.0) + wrangler: 3.108.0(@cloudflare/workers-types@4.20250204.0)(bufferutil@4.0.9) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -7523,7 +7856,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.34.6': optional: true - '@sinclair/typebox@0.34.15': {} + '@sinclair/typebox@0.34.16': {} '@stefanprobst/rehype-extract-toc@2.2.1': dependencies: @@ -7533,10 +7866,10 @@ snapshots: hast-util-to-string: 2.0.0 unist-util-visit: 4.1.2 - '@stylistic/eslint-plugin@2.11.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)': + '@stylistic/eslint-plugin@2.11.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3)': dependencies: - '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) - eslint: 9.19.0(jiti@2.4.2) + '@typescript-eslint/utils': 8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) + eslint: 9.20.0(jiti@2.4.2) eslint-visitor-keys: 4.2.0 espree: 10.3.0 estraverse: 5.3.0 @@ -7545,58 +7878,58 @@ snapshots: - supports-color - typescript - '@tailwindcss/node@4.0.5': + '@tailwindcss/node@4.0.6': dependencies: - enhanced-resolve: 5.18.0 + enhanced-resolve: 5.18.1 jiti: 2.4.2 - tailwindcss: 4.0.5 + tailwindcss: 4.0.6 - '@tailwindcss/oxide-android-arm64@4.0.5': + '@tailwindcss/oxide-android-arm64@4.0.6': optional: true - '@tailwindcss/oxide-darwin-arm64@4.0.5': + '@tailwindcss/oxide-darwin-arm64@4.0.6': optional: true - '@tailwindcss/oxide-darwin-x64@4.0.5': + '@tailwindcss/oxide-darwin-x64@4.0.6': optional: true - '@tailwindcss/oxide-freebsd-x64@4.0.5': + '@tailwindcss/oxide-freebsd-x64@4.0.6': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.5': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.6': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.0.5': + '@tailwindcss/oxide-linux-arm64-gnu@4.0.6': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.0.5': + '@tailwindcss/oxide-linux-arm64-musl@4.0.6': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.0.5': + '@tailwindcss/oxide-linux-x64-gnu@4.0.6': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.0.5': + '@tailwindcss/oxide-linux-x64-musl@4.0.6': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.0.5': + '@tailwindcss/oxide-win32-arm64-msvc@4.0.6': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.0.5': + '@tailwindcss/oxide-win32-x64-msvc@4.0.6': optional: true - '@tailwindcss/oxide@4.0.5': + '@tailwindcss/oxide@4.0.6': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.0.5 - '@tailwindcss/oxide-darwin-arm64': 4.0.5 - '@tailwindcss/oxide-darwin-x64': 4.0.5 - '@tailwindcss/oxide-freebsd-x64': 4.0.5 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.0.5 - '@tailwindcss/oxide-linux-arm64-gnu': 4.0.5 - '@tailwindcss/oxide-linux-arm64-musl': 4.0.5 - '@tailwindcss/oxide-linux-x64-gnu': 4.0.5 - '@tailwindcss/oxide-linux-x64-musl': 4.0.5 - '@tailwindcss/oxide-win32-arm64-msvc': 4.0.5 - '@tailwindcss/oxide-win32-x64-msvc': 4.0.5 + '@tailwindcss/oxide-android-arm64': 4.0.6 + '@tailwindcss/oxide-darwin-arm64': 4.0.6 + '@tailwindcss/oxide-darwin-x64': 4.0.6 + '@tailwindcss/oxide-freebsd-x64': 4.0.6 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.0.6 + '@tailwindcss/oxide-linux-arm64-gnu': 4.0.6 + '@tailwindcss/oxide-linux-arm64-musl': 4.0.6 + '@tailwindcss/oxide-linux-x64-gnu': 4.0.6 + '@tailwindcss/oxide-linux-x64-musl': 4.0.6 + '@tailwindcss/oxide-win32-arm64-msvc': 4.0.6 + '@tailwindcss/oxide-win32-x64-msvc': 4.0.6 '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)))': dependencies: @@ -7606,12 +7939,20 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)) - '@tailwindcss/vite@4.0.5(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + '@tailwindcss/vite@4.0.6(vite@6.1.0(@types/node@18.16.3)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': + dependencies: + '@tailwindcss/node': 4.0.6 + '@tailwindcss/oxide': 4.0.6 + lightningcss: 1.29.1 + tailwindcss: 4.0.6 + vite: 6.1.0(@types/node@18.16.3)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) + + '@tailwindcss/vite@4.0.6(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': dependencies: - '@tailwindcss/node': 4.0.5 - '@tailwindcss/oxide': 4.0.5 + '@tailwindcss/node': 4.0.6 + '@tailwindcss/oxide': 4.0.6 lightningcss: 1.29.1 - tailwindcss: 4.0.5 + tailwindcss: 4.0.6 vite: 6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0) '@tsconfig/node10@1.0.11': {} @@ -7701,10 +8042,18 @@ snapshots: '@types/node@16.18.126': {} + '@types/node@18.16.3': {} + '@types/node@22.13.1': dependencies: undici-types: 6.20.0 + '@types/pg@8.6.6': + dependencies: + '@types/node': 22.13.1 + pg-protocol: 1.7.1 + pg-types: 2.2.0 + '@types/react-dom@19.0.3(@types/react@19.0.8)': dependencies: '@types/react': 19.0.8 @@ -7717,15 +8066,15 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)': + '@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + '@typescript-eslint/parser': 8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) '@typescript-eslint/scope-manager': 8.19.0 - '@typescript-eslint/type-utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) - '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + '@typescript-eslint/type-utils': 8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) + '@typescript-eslint/utils': 8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) '@typescript-eslint/visitor-keys': 8.19.0 - eslint: 9.19.0(jiti@2.4.2) + eslint: 9.20.0(jiti@2.4.2) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -7734,14 +8083,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)': + '@typescript-eslint/parser@8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3)': dependencies: '@typescript-eslint/scope-manager': 8.19.0 '@typescript-eslint/types': 8.19.0 '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.7.3) '@typescript-eslint/visitor-keys': 8.19.0 debug: 4.4.0 - eslint: 9.19.0(jiti@2.4.2) + eslint: 9.20.0(jiti@2.4.2) typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -7751,12 +8100,12 @@ snapshots: '@typescript-eslint/types': 8.19.0 '@typescript-eslint/visitor-keys': 8.19.0 - '@typescript-eslint/type-utils@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)': + '@typescript-eslint/type-utils@8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3)': dependencies: '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.7.3) - '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + '@typescript-eslint/utils': 8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) debug: 4.4.0 - eslint: 9.19.0(jiti@2.4.2) + eslint: 9.20.0(jiti@2.4.2) ts-api-utils: 1.4.3(typescript@5.7.3) typescript: 5.7.3 transitivePeerDependencies: @@ -7772,19 +8121,19 @@ snapshots: fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.0 + semver: 7.7.1 ts-api-utils: 1.4.3(typescript@5.7.3) typescript: 5.7.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)': + '@typescript-eslint/utils@8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.20.0(jiti@2.4.2)) '@typescript-eslint/scope-manager': 8.19.0 '@typescript-eslint/types': 8.19.0 '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.7.3) - eslint: 9.19.0(jiti@2.4.2) + eslint: 9.20.0(jiti@2.4.2) typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -7848,6 +8197,13 @@ snapshots: '@vanilla-extract/private@1.0.6': {} + '@vercel/postgres@0.4.1': + dependencies: + '@neondatabase/serverless': 0.5.6 + bufferutil: 4.0.7 + utf-8-validate: 6.0.3 + ws: 8.13.0(bufferutil@4.0.7)(utf-8-validate@6.0.3) + '@vitejs/plugin-react@4.3.4(vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0))': dependencies: '@babel/core': 7.26.7 @@ -7917,6 +8273,8 @@ snapshots: dependencies: acorn: 8.14.0 + acorn-walk@8.3.2: {} + acorn-walk@8.3.4: dependencies: acorn: 8.14.0 @@ -7928,12 +8286,12 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ai@4.1.17(react@19.0.0)(zod@3.24.1): + ai@4.1.34(react@19.0.0)(zod@3.24.1): dependencies: '@ai-sdk/provider': 1.0.7 '@ai-sdk/provider-utils': 2.1.6(zod@3.24.1) - '@ai-sdk/react': 1.1.8(react@19.0.0)(zod@3.24.1) - '@ai-sdk/ui-utils': 1.1.8(zod@3.24.1) + '@ai-sdk/react': 1.1.11(react@19.0.0)(zod@3.24.1) + '@ai-sdk/ui-utils': 1.1.11(zod@3.24.1) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 optionalDependencies: @@ -8058,14 +8416,14 @@ snapshots: astring@1.9.0: {} - autoprefixer@10.4.20(postcss@8.5.1): + autoprefixer@10.4.20(postcss@8.5.2): dependencies: browserslist: 4.24.4 caniuse-lite: 1.0.30001696 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 - postcss: 8.5.1 + postcss: 8.5.2 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -8147,6 +8505,15 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bufferutil@4.0.7: + dependencies: + node-gyp-build: 4.8.4 + + bufferutil@4.0.9: + dependencies: + node-gyp-build: 4.8.4 + optional: true + bundle-name@4.1.0: dependencies: run-applescript: 7.0.0 @@ -8286,6 +8653,18 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + optional: true + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + comma-separated-tokens@2.0.3: {} command-line-args@5.2.1: @@ -8323,12 +8702,12 @@ snapshots: cookie-signature@1.2.2: {} + cookie@0.5.0: {} + cookie@0.6.0: {} cookie@0.7.1: {} - cookie@0.7.2: {} - copy-anything@3.0.5: dependencies: is-what: 4.1.16 @@ -8442,6 +8821,9 @@ snapshots: detect-libc@1.0.3: {} + detect-libc@2.0.3: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -8470,6 +8852,15 @@ snapshots: dependencies: esutils: 2.0.3 + dotenv-cli@8.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 16.4.7 + dotenv-expand: 10.0.0 + minimist: 1.2.8 + + dotenv-expand@10.0.0: {} + dotenv@16.4.7: {} dunder-proto@1.0.1: @@ -8508,6 +8899,11 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.1 + enhanced-resolve@5.18.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -8777,10 +9173,10 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-compat-utils@0.5.1(eslint@9.19.0(jiti@2.4.2)): + eslint-compat-utils@0.5.1(eslint@9.20.0(jiti@2.4.2)): dependencies: - eslint: 9.19.0(jiti@2.4.2) - semver: 7.7.0 + eslint: 9.20.0(jiti@2.4.2) + semver: 7.7.1 eslint-import-resolver-node@0.3.9: dependencies: @@ -8790,67 +9186,67 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.7.0(eslint-plugin-import-x@4.6.1(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2)): + eslint-import-resolver-typescript@3.7.0(eslint-plugin-import-x@4.6.1(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.0(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 enhanced-resolve: 5.18.0 - eslint: 9.19.0(jiti@2.4.2) + eslint: 9.20.0(jiti@2.4.2) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.3.0 is-glob: 4.0.3 stable-hash: 0.0.4 optionalDependencies: - eslint-plugin-import-x: 4.6.1(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + eslint-plugin-import-x: 4.6.1(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) transitivePeerDependencies: - supports-color - eslint-plugin-es-x@7.8.0(eslint@9.19.0(jiti@2.4.2)): + eslint-plugin-es-x@7.8.0(eslint@9.20.0(jiti@2.4.2)): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.20.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.12.1 - eslint: 9.19.0(jiti@2.4.2) - eslint-compat-utils: 0.5.1(eslint@9.19.0(jiti@2.4.2)) + eslint: 9.20.0(jiti@2.4.2) + eslint-compat-utils: 0.5.1(eslint@9.20.0(jiti@2.4.2)) - eslint-plugin-import-x@4.6.1(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3): + eslint-plugin-import-x@4.6.1(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3): dependencies: '@types/doctrine': 0.0.9 '@typescript-eslint/scope-manager': 8.19.0 - '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + '@typescript-eslint/utils': 8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) debug: 4.4.0 doctrine: 3.0.0 enhanced-resolve: 5.18.0 - eslint: 9.19.0(jiti@2.4.2) + eslint: 9.20.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 get-tsconfig: 4.8.1 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.6.3 + semver: 7.7.1 stable-hash: 0.0.4 tslib: 2.8.1 transitivePeerDependencies: - supports-color - typescript - eslint-plugin-n@17.15.1(eslint@9.19.0(jiti@2.4.2)): + eslint-plugin-n@17.15.1(eslint@9.20.0(jiti@2.4.2)): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.20.0(jiti@2.4.2)) enhanced-resolve: 5.18.0 - eslint: 9.19.0(jiti@2.4.2) - eslint-plugin-es-x: 7.8.0(eslint@9.19.0(jiti@2.4.2)) + eslint: 9.20.0(jiti@2.4.2) + eslint-plugin-es-x: 7.8.0(eslint@9.20.0(jiti@2.4.2)) get-tsconfig: 4.8.1 globals: 15.14.0 ignore: 5.3.2 minimatch: 9.0.5 - semver: 7.6.3 + semver: 7.7.1 - eslint-plugin-promise@7.2.1(eslint@9.19.0(jiti@2.4.2)): + eslint-plugin-promise@7.2.1(eslint@9.20.0(jiti@2.4.2)): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@2.4.2)) - eslint: 9.19.0(jiti@2.4.2) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.20.0(jiti@2.4.2)) + eslint: 9.20.0(jiti@2.4.2) - eslint-plugin-react@7.37.3(eslint@9.19.0(jiti@2.4.2)): + eslint-plugin-react@7.37.3(eslint@9.20.0(jiti@2.4.2)): dependencies: array-includes: 3.1.8 array.prototype.findlast: 1.2.5 @@ -8858,7 +9254,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.19.0(jiti@2.4.2) + eslint: 9.20.0(jiti@2.4.2) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -8881,14 +9277,14 @@ snapshots: eslint-visitor-keys@4.2.0: {} - eslint@9.19.0(jiti@2.4.2): + eslint@9.20.0(jiti@2.4.2): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.20.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.19.1 - '@eslint/core': 0.10.0 + '@eslint/core': 0.11.0 '@eslint/eslintrc': 3.2.0 - '@eslint/js': 9.19.0 + '@eslint/js': 9.20.0 '@eslint/plugin-kit': 0.2.5 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 @@ -9499,9 +9895,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.5.1): + icss-utils@5.1.0(postcss@8.5.2): dependencies: - postcss: 8.5.1 + postcss: 8.5.2 ieee754@1.2.1: {} @@ -9553,6 +9949,9 @@ snapshots: call-bound: 1.0.3 get-intrinsic: 1.2.7 + is-arrayish@0.3.2: + optional: true + is-async-function@2.1.0: dependencies: call-bound: 1.0.3 @@ -9577,7 +9976,7 @@ snapshots: is-bun-module@1.3.0: dependencies: - semver: 7.7.0 + semver: 7.7.1 is-callable@1.2.7: {} @@ -9927,7 +10326,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.0 + semver: 7.7.1 make-error@1.3.6: {} @@ -10615,19 +11014,19 @@ snapshots: mimic-fn@2.1.0: {} - miniflare@3.20250129.0: + miniflare@3.20250204.0(bufferutil@4.0.9): dependencies: '@cspotcode/source-map-support': 0.8.1 acorn: 8.14.0 - acorn-walk: 8.3.4 + acorn-walk: 8.3.2 exit-hook: 2.2.1 glob-to-regexp: 0.4.1 stoppable: 1.1.0 undici: 5.28.5 - workerd: 1.20250129.0 - ws: 8.18.0 - youch: 3.3.4 - zod: 3.24.1 + workerd: 1.20250204.0 + ws: 8.18.0(bufferutil@4.0.9) + youch: 3.2.3 + zod: 3.22.3 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -10706,20 +11105,20 @@ snapshots: negotiator@0.6.3: {} - neostandard@0.12.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3): + neostandard@0.12.1(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3): dependencies: '@humanwhocodes/gitignore-to-minimatch': 1.0.2 - '@stylistic/eslint-plugin': 2.11.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) - eslint: 9.19.0(jiti@2.4.2) - eslint-import-resolver-typescript: 3.7.0(eslint-plugin-import-x@4.6.1(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2)) - eslint-plugin-import-x: 4.6.1(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) - eslint-plugin-n: 17.15.1(eslint@9.19.0(jiti@2.4.2)) - eslint-plugin-promise: 7.2.1(eslint@9.19.0(jiti@2.4.2)) - eslint-plugin-react: 7.37.3(eslint@9.19.0(jiti@2.4.2)) + '@stylistic/eslint-plugin': 2.11.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) + eslint: 9.20.0(jiti@2.4.2) + eslint-import-resolver-typescript: 3.7.0(eslint-plugin-import-x@4.6.1(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.0(jiti@2.4.2)) + eslint-plugin-import-x: 4.6.1(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) + eslint-plugin-n: 17.15.1(eslint@9.20.0(jiti@2.4.2)) + eslint-plugin-promise: 7.2.1(eslint@9.20.0(jiti@2.4.2)) + eslint-plugin-react: 7.37.3(eslint@9.20.0(jiti@2.4.2)) find-up: 5.0.0 globals: 15.14.0 peowly: 1.3.2 - typescript-eslint: 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) + typescript-eslint: 8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) transitivePeerDependencies: - eslint-plugin-import - supports-color @@ -10737,13 +11136,15 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build@4.8.4: {} + node-releases@2.0.19: {} normalize-package-data@5.0.0: dependencies: hosted-git-info: 6.1.3 is-core-module: 2.16.1 - semver: 7.7.0 + semver: 7.7.1 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -10752,7 +11153,7 @@ snapshots: npm-install-checks@6.3.0: dependencies: - semver: 7.7.0 + semver: 7.7.1 npm-normalize-package-bin@3.0.1: {} @@ -10760,7 +11161,7 @@ snapshots: dependencies: hosted-git-info: 6.1.3 proc-log: 3.0.0 - semver: 7.7.0 + semver: 7.7.1 validate-npm-package-name: 5.0.1 npm-pick-manifest@8.0.2: @@ -10768,7 +11169,7 @@ snapshots: npm-install-checks: 6.3.0 npm-normalize-package-bin: 3.0.1 npm-package-arg: 10.1.0 - semver: 7.7.0 + semver: 7.7.1 npm-run-path@4.0.1: dependencies: @@ -10976,6 +11377,18 @@ snapshots: is-reference: 3.0.3 zimmerframe: 1.1.2 + pg-int8@1.0.1: {} + + pg-protocol@1.7.1: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -11008,66 +11421,66 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss-discard-duplicates@5.1.0(postcss@8.5.1): + postcss-discard-duplicates@5.1.0(postcss@8.5.2): dependencies: - postcss: 8.5.1 + postcss: 8.5.2 - postcss-import@15.1.0(postcss@8.5.1): + postcss-import@15.1.0(postcss@8.5.2): dependencies: - postcss: 8.5.1 + postcss: 8.5.2 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.10 - postcss-js@4.0.1(postcss@8.5.1): + postcss-js@4.0.1(postcss@8.5.2): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.1 + postcss: 8.5.2 - postcss-load-config@4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)): + postcss-load-config@4.0.2(postcss@8.5.2)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)): dependencies: lilconfig: 3.1.3 yaml: 2.7.0 optionalDependencies: - postcss: 8.5.1 + postcss: 8.5.2 ts-node: 10.9.2(@types/node@22.13.1)(typescript@5.7.3) - postcss-modules-extract-imports@3.1.0(postcss@8.5.1): + postcss-modules-extract-imports@3.1.0(postcss@8.5.2): dependencies: - postcss: 8.5.1 + postcss: 8.5.2 - postcss-modules-local-by-default@4.2.0(postcss@8.5.1): + postcss-modules-local-by-default@4.2.0(postcss@8.5.2): dependencies: - icss-utils: 5.1.0(postcss@8.5.1) - postcss: 8.5.1 + icss-utils: 5.1.0(postcss@8.5.2) + postcss: 8.5.2 postcss-selector-parser: 7.0.0 postcss-value-parser: 4.2.0 - postcss-modules-scope@3.2.1(postcss@8.5.1): + postcss-modules-scope@3.2.1(postcss@8.5.2): dependencies: - postcss: 8.5.1 + postcss: 8.5.2 postcss-selector-parser: 7.0.0 - postcss-modules-values@4.0.0(postcss@8.5.1): + postcss-modules-values@4.0.0(postcss@8.5.2): dependencies: - icss-utils: 5.1.0(postcss@8.5.1) - postcss: 8.5.1 + icss-utils: 5.1.0(postcss@8.5.2) + postcss: 8.5.2 - postcss-modules@6.0.1(postcss@8.5.1): + postcss-modules@6.0.1(postcss@8.5.2): dependencies: generic-names: 4.0.0 - icss-utils: 5.1.0(postcss@8.5.1) + icss-utils: 5.1.0(postcss@8.5.2) lodash.camelcase: 4.3.0 - postcss: 8.5.1 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.1) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.1) - postcss-modules-scope: 3.2.1(postcss@8.5.1) - postcss-modules-values: 4.0.0(postcss@8.5.1) + postcss: 8.5.2 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.2) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.2) + postcss-modules-scope: 3.2.1(postcss@8.5.2) + postcss-modules-values: 4.0.0(postcss@8.5.2) string-hash: 1.1.3 - postcss-nested@6.2.0(postcss@8.5.1): + postcss-nested@6.2.0(postcss@8.5.2): dependencies: - postcss: 8.5.1 + postcss: 8.5.2 postcss-selector-parser: 6.1.2 postcss-selector-parser@6.0.10: @@ -11093,11 +11506,27 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.2: + dependencies: + nanoid: 3.3.8 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier@2.8.8: {} - prettier@3.4.2: {} + prettier@3.5.0: {} pretty-ms@7.0.1: dependencies: @@ -11605,7 +12034,7 @@ snapshots: semver@7.6.3: {} - semver@7.7.0: {} + semver@7.7.1: {} send@0.19.0: dependencies: @@ -11660,6 +12089,33 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.0.3 + semver: 7.7.1 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -11700,6 +12156,11 @@ snapshots: signal-exit@4.1.0: {} + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + optional: true + sirv@3.0.0: dependencies: '@polka/url': 1.0.0-next.28 @@ -11742,10 +12203,10 @@ snapshots: spdx-license-ids@3.0.21: {} - spiceflow@1.6.1(@modelcontextprotocol/sdk@1.0.4): + spiceflow@1.6.1(@modelcontextprotocol/sdk@1.4.1): dependencies: '@medley/router': 0.2.1 - '@sinclair/typebox': 0.34.15 + '@sinclair/typebox': 0.34.16 ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) eventsource-parser: 3.0.0 @@ -11754,7 +12215,7 @@ snapshots: zod: 3.24.1 zod-to-json-schema: 3.24.1(zod@3.24.1) optionalDependencies: - '@modelcontextprotocol/sdk': 1.0.4 + '@modelcontextprotocol/sdk': 1.4.1 sprintf-js@1.0.3: {} @@ -11910,7 +12371,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - swr@2.3.0(react@19.0.0): + swr@2.3.2(react@19.0.0): dependencies: dequal: 2.0.3 react: 19.0.0 @@ -11937,18 +12398,18 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.1 - postcss-import: 15.1.0(postcss@8.5.1) - postcss-js: 4.0.1(postcss@8.5.1) - postcss-load-config: 4.0.2(postcss@8.5.1)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)) - postcss-nested: 6.2.0(postcss@8.5.1) + postcss: 8.5.2 + postcss-import: 15.1.0(postcss@8.5.2) + postcss-js: 4.0.1(postcss@8.5.2) + postcss-load-config: 4.0.2(postcss@8.5.2)(ts-node@10.9.2(@types/node@22.13.1)(typescript@5.7.3)) + postcss-nested: 6.2.0(postcss@8.5.2) postcss-selector-parser: 6.1.2 resolve: 1.22.10 sucrase: 3.35.0 transitivePeerDependencies: - ts-node - tailwindcss@4.0.5: {} + tailwindcss@4.0.6: {} tapable@2.2.1: {} @@ -12145,12 +12606,12 @@ snapshots: possible-typed-array-names: 1.0.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3): + typescript-eslint@8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) - '@typescript-eslint/parser': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) - '@typescript-eslint/utils': 8.19.0(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3) - eslint: 9.19.0(jiti@2.4.2) + '@typescript-eslint/eslint-plugin': 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) + '@typescript-eslint/parser': 8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) + '@typescript-eslint/utils': 8.19.0(eslint@9.20.0(jiti@2.4.2))(typescript@5.7.3) + eslint: 9.20.0(jiti@2.4.2) typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -12330,6 +12791,10 @@ snapshots: dependencies: react: 19.0.0 + utf-8-validate@6.0.3: + dependencies: + node-gyp-build: 4.8.4 + util-deprecate@1.0.2: {} util@0.12.5: @@ -12456,7 +12921,7 @@ snapshots: vite@5.4.14(@types/node@22.13.1)(lightningcss@1.29.1)(terser@5.31.6): dependencies: esbuild: 0.21.5 - postcss: 8.5.1 + postcss: 8.5.2 rollup: 4.34.0 optionalDependencies: '@types/node': 22.13.1 @@ -12464,6 +12929,20 @@ snapshots: lightningcss: 1.29.1 terser: 5.31.6 + vite@6.1.0(@types/node@18.16.3)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): + dependencies: + esbuild: 0.24.2 + postcss: 8.5.1 + rollup: 4.34.6 + optionalDependencies: + '@types/node': 18.16.3 + fsevents: 2.3.3 + jiti: 2.4.2 + lightningcss: 1.29.1 + terser: 5.31.6 + tsx: 4.19.2 + yaml: 2.7.0 + vite@6.1.0(@types/node@22.13.1)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.31.6)(tsx@4.19.2)(yaml@2.7.0): dependencies: esbuild: 0.24.2 @@ -12599,28 +13078,29 @@ snapshots: wordwrapjs@5.1.0: {} - workerd@1.20250129.0: + workerd@1.20250204.0: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20250129.0 - '@cloudflare/workerd-darwin-arm64': 1.20250129.0 - '@cloudflare/workerd-linux-64': 1.20250129.0 - '@cloudflare/workerd-linux-arm64': 1.20250129.0 - '@cloudflare/workerd-windows-64': 1.20250129.0 + '@cloudflare/workerd-darwin-64': 1.20250204.0 + '@cloudflare/workerd-darwin-arm64': 1.20250204.0 + '@cloudflare/workerd-linux-64': 1.20250204.0 + '@cloudflare/workerd-linux-arm64': 1.20250204.0 + '@cloudflare/workerd-windows-64': 1.20250204.0 - wrangler@3.107.2(@cloudflare/workers-types@4.20250129.0): + wrangler@3.108.0(@cloudflare/workers-types@4.20250204.0)(bufferutil@4.0.9): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) blake3-wasm: 2.1.5 esbuild: 0.17.19 - miniflare: 3.20250129.0 + miniflare: 3.20250204.0(bufferutil@4.0.9) path-to-regexp: 6.3.0 unenv: 2.0.0-rc.1 - workerd: 1.20250129.0 + workerd: 1.20250204.0 optionalDependencies: - '@cloudflare/workers-types': 4.20250129.0 + '@cloudflare/workers-types': 4.20250204.0 fsevents: 2.3.3 + sharp: 0.33.5 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -12639,9 +13119,18 @@ snapshots: wrappy@1.0.2: {} - ws@7.5.10: {} + ws@7.5.10(bufferutil@4.0.9): + optionalDependencies: + bufferutil: 4.0.9 - ws@8.18.0: {} + ws@8.13.0(bufferutil@4.0.7)(utf-8-validate@6.0.3): + optionalDependencies: + bufferutil: 4.0.7 + utf-8-validate: 6.0.3 + + ws@8.18.0(bufferutil@4.0.9): + optionalDependencies: + bufferutil: 4.0.9 xtend@4.0.2: {} @@ -12669,9 +13158,9 @@ snapshots: yocto-queue@0.1.0: {} - youch@3.3.4: + youch@3.2.3: dependencies: - cookie: 0.7.2 + cookie: 0.5.0 mustache: 4.2.0 stacktracey: 2.1.8 @@ -12681,6 +13170,8 @@ snapshots: dependencies: zod: 3.24.1 + zod@3.22.3: {} + zod@3.24.1: {} zwitch@2.0.4: {} diff --git a/spiceflow/package.json b/spiceflow/package.json index f1813d2..bd56280 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -57,7 +57,6 @@ "license": "", "dependencies": { "@hiogawa/transforms": "^0.0.0", - "@jacob-ebey/vite-react-server-dom": "^0.0.12", "@sinclair/typebox": "^0.34.14", "@vitejs/plugin-react": "^4.3.4", "ajv": "^8.17.1", diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 394344a..c67e70b 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -138,7 +138,6 @@ export function spiceflowPlugin({ entry }): PluginOption { optimizeDeps: { include: [ 'react-dom/client', - 'react-server-dom-vite/client', 'spiceflow/dist/react/server-dom-client-optimized', ], }, From 981aaa251f60da8dbe3b3a91d2ca50de743c5b97 Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 18:03:04 +0100 Subject: [PATCH 051/226] added pokemon view, works well --- how-is-this-not-illegal/app/main.tsx | 37 ++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/how-is-this-not-illegal/app/main.tsx b/how-is-this-not-illegal/app/main.tsx index e3f4c98..b2c3139 100644 --- a/how-is-this-not-illegal/app/main.tsx +++ b/how-is-this-not-illegal/app/main.tsx @@ -2,6 +2,7 @@ import './globals.css' import { Spiceflow } from 'spiceflow' import { sql } from '@vercel/postgres' import { Suspense } from 'react' +import { Link } from 'spiceflow/dist/react/components' const app = new Spiceflow() .layout('/*', async ({ children }) => { @@ -17,11 +18,40 @@ const app = new Spiceflow() return ( {rows.map((p) => ( - + + + ))} ) }) + .layout('/pokemon/:id', ({ children }) => { + return ( +
    + Loading...
    }>{children} + + ) + }) + .page('/pokemon/:id', async function PokemonDetails({ params: { id } }) { + const { rows } = await sql`SELECT * FROM pokemon WHERE id = ${id}` + const pokemon = rows[0] + + if (!pokemon) { + return
    Pokemon not found
    + } + + return ( + + ) + }) function Loading() { return ( @@ -76,10 +106,7 @@ function RootLayout({ children }: { children: React.ReactNode }) { property="og:description" content="Querying Postgres directly from your components" /> - + From 54ffe2612f8f1e5056d7f29c08f481041af644fe Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 20:01:06 +0100 Subject: [PATCH 052/226] added progress component --- example-react/src/app/layout.tsx | 2 + spiceflow/src/react/progress.tsx | 148 +++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 spiceflow/src/react/progress.tsx diff --git a/example-react/src/app/layout.tsx b/example-react/src/app/layout.tsx index e6d9744..cb6c11b 100644 --- a/example-react/src/app/layout.tsx +++ b/example-react/src/app/layout.tsx @@ -1,4 +1,5 @@ import { Link } from "spiceflow/dist/react/components"; +import { ProgressBar } from "spiceflow/dist/react/progress"; export function Layout(props: React.PropsWithChildren) { return ( @@ -11,6 +12,7 @@ export function Layout(props: React.PropsWithChildren) { /> +
    • Home diff --git a/spiceflow/src/react/progress.tsx b/spiceflow/src/react/progress.tsx new file mode 100644 index 0000000..5ea7d65 --- /dev/null +++ b/spiceflow/src/react/progress.tsx @@ -0,0 +1,148 @@ +'use client' +import { useEffect, useRef, useState } from 'react' + +import { ReactNode } from 'react' +import { router } from './router.js' + +export interface ProgressBarProps { + /** + * Color of the progress bar + * @default "#0ea5e9" + */ + color?: string + /** + * Duration of the transition animation in milliseconds + * @default 300 + */ + duration?: number +} + +export function ProgressBar({ + color = '#0ea5e9', + duration = 300, +}: ProgressBarProps) { + const progress = useProgress() + const [isExiting, setIsExiting] = useState(false) + + useEffect(() => { + if (progress.state === 'complete') { + setIsExiting(true) + } else { + setIsExiting(false) + } + }, [progress.state]) + + return ( +
      { + if (e.propertyName === 'opacity' && isExiting) { + progress.reset() + setIsExiting(false) + } + }} + /> + ) +} + +function useProgress() { + const [state, setState] = useState< + 'initial' | 'in-progress' | 'completing' | 'complete' + >('initial') + const [width, setWidth] = useState(0) + + useInterval( + () => { + if (state === 'in-progress') { + setWidth((prev) => { + let diff + if (prev === 0) { + diff = 15 + } else if (prev < 50) { + diff = rand(1, 10) + } else { + diff = rand(1, 5) + } + return Math.min(prev + diff, 99) + }) + } + }, + state === 'in-progress' ? 750 : null, + ) + + useEffect(() => { + const unlisten = router.listen(() => { + start() + }) + + return () => { + unlisten() + } + }, []) + + useEffect(() => { + if (state === 'initial') { + setWidth(0) + } else if (state === 'completing') { + setWidth(100) + } + }, [state]) + + useEffect(() => { + if (width === 100) { + setState('complete') + } + }, [width]) + + function reset() { + setState('initial') + } + + function start() { + setState('in-progress') + } + + function done() { + setState((prev) => + prev === 'initial' || prev === 'in-progress' ? 'completing' : prev, + ) + } + + return { state, width, start, done, reset } +} + +function rand(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +function useInterval(callback: () => void, delay: number | null) { + const savedCallback = useRef(callback) + + useEffect(() => { + savedCallback.current = callback + }, [callback]) + + useEffect(() => { + function tick() { + savedCallback.current() + } + + if (delay !== null) { + tick() + const id = setInterval(tick, delay) + return () => clearInterval(id) + } + }, [delay]) +} From 594cb6388e05e1483ed6532f07280e0c223c2a3c Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 20:07:50 +0100 Subject: [PATCH 053/226] payload is always a promise, this way navigations state does not trigger suspense boundaries, no need for await --- spiceflow/src/react/components.tsx | 18 ++++++++++--- spiceflow/src/react/entry.client.tsx | 27 ++++++++++---------- spiceflow/src/react/entry.ssr.tsx | 38 +++++++++++++--------------- 3 files changed, 45 insertions(+), 38 deletions(-) diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index 808cdb7..f16a825 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -3,18 +3,28 @@ import React, { Suspense } from 'react' import { ReactFormState } from 'react-dom/client' import { router } from './router.js' +import { ServerPayload } from '../spiceflow.js' -export const FlightDataContext = React.createContext(undefined!) +export const FlightDataContext = React.createContext>( + undefined!, +) // Get $$id property that was set by registerClientReference export function useFlightData() { - // return React.use(React.useContext(FlightDataContext)) - return React.useContext(FlightDataContext) + const c = React.useContext(FlightDataContext) + if (c instanceof Promise) { + return React.use(c)?.root + } + return c?.['root'] + // return React.useContext(FlightDataContext) } -export function LayoutContent(props: { id: string }) { +export function LayoutContent(props: { id?: string }) { const data = useFlightData() const elem = (() => { + if (!props.id) { + return data.layouts[0]?.element ?? data.page + } const layoutIndex = data.layouts.findIndex( (layout) => layout.id === props.id, ) diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index a877f33..aba1272 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -10,6 +10,7 @@ import { DefaultGlobalErrorPage, ErrorBoundary, FlightDataContext, + LayoutContent, } from './components.js' import { ServerPayload } from '../spiceflow.js' @@ -17,7 +18,7 @@ async function main() { const callServer: CallServerFn = async (id, args) => { const url = new URL(window.location.href) url.searchParams.set('__rsc', id) - const payload = await ReactClient.createFromFetch( + const payloadPromise = ReactClient.createFromFetch( fetch(url, { method: 'POST', body: await ReactClient.encodeReply(args), @@ -26,20 +27,20 @@ async function main() { { callServer }, ) // console.log({ 'action payload': payload }) - setPayload(payload) + setPayload(payloadPromise) + let payload = await payloadPromise return payload.returnValue } Object.assign(globalThis, { __callServer: callServer }) - const initialPayload = - await ReactClient.createFromReadableStream( - rscStream, - clientReferenceManifest, + const initialPayload = ReactClient.createFromReadableStream( + rscStream, + clientReferenceManifest, - { callServer }, - ) + { callServer }, + ) - let setPayload: (v: ServerPayload) => void + let setPayload: (v: Promise) => void function BrowserRoot() { const [payload, setPayload_] = React.useState(initialPayload) @@ -54,7 +55,7 @@ async function main() { console.log('onNavigation') const url = new URL(window.location.href) url.searchParams.set('__rsc', '') - const payload = await ReactClient.createFromFetch( + const payload = ReactClient.createFromFetch( fetch(url), clientReferenceManifest, @@ -66,15 +67,15 @@ async function main() { return ( - - {payload.root?.layouts?.[0]?.element ?? payload.root.page} + + ) } ReactDomClient.hydrateRoot(document, , { - formState: initialPayload.formState, + formState: (await initialPayload).formState, }) if (import.meta.hot) { diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index a5a2878..5129e0c 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -3,24 +3,19 @@ import ReactDOMServer from 'react-dom/server.edge' import ReactClient from 'spiceflow/dist/react/server-dom-client-optimized' import type { ModuleRunner } from 'vite/module-runner' -import { - createRequest, - fromPipeableToWebReadable, - fromWebToNodeReadable, - sendResponse, -} from './utils/fetch.js' import { injectRSCPayload } from 'rsc-html-stream/server' +import cssUrls from 'virtual:app-styles' +import { ServerPayload } from '../spiceflow.js' import { - DefaultGlobalErrorPage, - ErrorBoundary, - FlightDataContext, + FlightDataContext, + LayoutContent } from './components.js' -import { bootstrapModules } from 'virtual:ssr-assets' import { clientReferenceManifest } from './utils/client-reference.js' -import cssUrls from 'virtual:app-styles' -import { ServerPayload } from '../spiceflow.js' -import { Suspense } from 'react' -import { sleep } from 'spiceflow/dist/utils' +import { + createRequest, + fromWebToNodeReadable, + sendResponse +} from './utils/fetch.js' export default async function handler( req: IncomingMessage, @@ -43,30 +38,31 @@ export default async function handler( const [flightStream1, flightStream2] = response.body!.tee() - const payload = await ReactClient.createFromNodeStream( + const payloadPromise = ReactClient.createFromNodeStream( fromWebToNodeReadable(flightStream1), clientReferenceManifest, ) const ssrAssets = await import('virtual:ssr-assets') const el = ( - + {cssUrls.map((url) => ( // precedence to force head rendering // https://react.dev/reference/react-dom/components/link#special-rendering-behavior ))} - {payload.root?.layouts?.[0]?.element ?? payload.root.page} + ) let htmlStream: ReadableStream let status = 200 + let payload = await payloadPromise try { htmlStream = await ReactDOMServer.renderToReadableStream(el, { bootstrapModules: ssrAssets.bootstrapModules, formState: payload.formState, - onError(e, ) { + onError(e) { // This also throws outside, no need to do anything here console.error('[react-dom:renderToPipeableStream]', e) if (e instanceof Response) { @@ -80,12 +76,12 @@ export default async function handler( console.log(`error during ssr render catch`, e) // On error, render minimal HTML shell // Client will do full CSR render and show error boundary - + if (e instanceof Response) { sendResponse(e, res) return } - // https://bsky.app/profile/ebey.bsky.social/post/3lev4lqr2ak2j + // https://bsky.app/profile/ebey.bsky.social/post/3lev4lqr2ak2j const errorRoot = ( @@ -117,7 +113,7 @@ export default async function handler( }, }, ) - + console.log(`sending response`) sendResponse(htmlResponse, res) } From 106e81280155f6a554c2587a07e936a8f2fc1f1c Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 22:21:59 +0100 Subject: [PATCH 054/226] fix support for redirect, fix hot module replacement for client imports, fix redirects in client side too --- example-react/e2e/basic.test.ts | 29 ++++++----- example-react/src/app/client.tsx | 3 ++ example-react/src/main.tsx | 40 ++++++++------- spiceflow/play.js | 43 ++++++++++++++++ spiceflow/src/react/components.tsx | 40 +++++++-------- spiceflow/src/react/entry.ssr.tsx | 45 ++++++++++------- spiceflow/src/react/errors.tsx | 69 ++++++++++++++++++++++++++ spiceflow/src/react/types/ambient.d.ts | 8 +-- spiceflow/src/react/utils/fetch.ts | 6 --- spiceflow/src/react/utils/normalize.ts | 3 +- spiceflow/src/spiceflow.tsx | 52 +++++++++++++++---- spiceflow/src/utils.ts | 16 ++---- spiceflow/src/vite.tsx | 32 ++++++------ 13 files changed, 268 insertions(+), 118 deletions(-) create mode 100644 spiceflow/play.js create mode 100644 spiceflow/src/react/errors.tsx diff --git a/example-react/e2e/basic.test.ts b/example-react/e2e/basic.test.ts index eb923e2..c6bac1a 100644 --- a/example-react/e2e/basic.test.ts +++ b/example-react/e2e/basic.test.ts @@ -1,6 +1,22 @@ import { type Page, expect, test } from "@playwright/test"; import { createEditor } from "./helper.js"; + + +test.describe("redirect", () => { + test("redirect in outer route scope", async ({ page }) => { + await page.goto("/redirect"); + await expect(page).toHaveURL("/"); + await page.getByText("[hydrated: 1]").click(); + }); + test.skip("redirect in RSC", async ({ page }) => { + await page.goto("/redirect-in-rsc"); + await expect(page).toHaveURL("/"); + await page.getByText("[hydrated: 1]").click(); + }); +}); + + test("client reference", async ({ page }) => { await page.goto("/"); await page.getByText("[hydrated: 1]").click(); @@ -19,19 +35,6 @@ test("server reference in server @js", async ({ page }) => { }); -test.describe("redirect", () => { - test("redirect in outer route scope", async ({ page }) => { - await page.goto("/redirect"); - await expect(page).toHaveURL("/"); - await page.getByText("[hydrated: 1]").click(); - }); - test.skip("redirect in RSC", async ({ page }) => { - await page.goto("/redirect-in-rsc"); - await expect(page).toHaveURL("/"); - await page.getByText("[hydrated: 1]").click(); - }); -}); - diff --git a/example-react/src/app/client.tsx b/example-react/src/app/client.tsx index 8203fce..1c19044 100644 --- a/example-react/src/app/client.tsx +++ b/example-react/src/app/client.tsx @@ -72,3 +72,6 @@ export function ClientComponentThrows() { } + + + diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index ca013c2..a162332 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -3,9 +3,10 @@ import { Suspense } from "react"; import { IndexPage } from "./app/index"; import { Layout } from "./app/layout"; import "./styles.css"; + import { ClientComponentThrows } from "./app/client"; import { ErrorBoundary } from "spiceflow/dist/react/components"; -import { sleep } from "spiceflow/dist/utils"; +import { redirect, sleep } from "spiceflow/dist/utils"; const app = new Spiceflow() .layout("/*", async ({ children, request }) => { @@ -27,13 +28,24 @@ const app = new Spiceflow() }) .get("/hello", () => "Hello, World!") - .page("/redirect", async () => { - throw new Response("Redirect", { - status: 302, - headers: { - location: "/", - }, - }); + .page("/top-level-redirect", async () => { + throw redirect("/"); + }) + .page("/redirect-in-rsc", async () => { + return ; + }) + .page("/slow-redirect", async ({ request, children }) => { + await sleep(100); + + throw redirect("/"); + }) + + .page("/redirect-in-rsc-suspense", async () => { + return ( + redirecting...
      }> + + + ); }) .layout("/page/*", async ({ request, children }) => { return ( @@ -113,21 +125,15 @@ const app = new Spiceflow() .page("/client-error", async () => { return ; }) - .page("/redirect-in-rsc", async () => { - return ; - }) + .post("/echo", async ({ request }) => { const body = await request.json(); return { echo: body }; }); async function Redirects() { - throw new Response("Redirect", { - status: 302, - headers: { - location: "/", - }, - }); + await sleep(100); + throw redirect("/"); return
      Redirect
      ; } diff --git a/spiceflow/play.js b/spiceflow/play.js new file mode 100644 index 0000000..0456d0f --- /dev/null +++ b/spiceflow/play.js @@ -0,0 +1,43 @@ +class FastError { + constructor(message) {} +} + +function benchmark(name, fn, iterations = 100000) { + // Warmup + for (let i = 0; i < 10000; i++) { + fn() + } + + const start = process.hrtime.bigint() + for (let i = 0; i < iterations; i++) { + fn() + } + const end = process.hrtime.bigint() + console.log(`${name}: ${Number(end - start) / 1_000_000}ms`) +} + +// Normal Error +benchmark('Normal Error', () => { + try { + throw new Error('test') + } catch (e) {} +}) + +// // No Stack Error +// Error.stackTraceLimit = 0 +// benchmark('No Stack Error', () => { +// try { +// throw new Error('test') +// } catch (e) {} +// }) + +// Fast Error +benchmark('Fast Error', () => { + try { + throw new FastError('test') + } catch (e) {} +}) +Object.setPrototypeOf(FastError.prototype, Error.prototype) +Object.setPrototypeOf(FastError, Error) + +console.log(new FastError() instanceof Error) diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index f16a825..191eec8 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -4,6 +4,7 @@ import React, { Suspense } from 'react' import { ReactFormState } from 'react-dom/client' import { router } from './router.js' import { ServerPayload } from '../spiceflow.js' +import { isRedirectError, isNotFoundError, getErrorContext } from './errors.js' export const FlightDataContext = React.createContext>( undefined!, @@ -12,23 +13,28 @@ export const FlightDataContext = React.createContext>( export function useFlightData() { const c = React.useContext(FlightDataContext) - if (c instanceof Promise) { - return React.use(c)?.root + + const payload = React.use(c) + let root = payload?.root + if (!root) { + console.log('root not found', payload) } - return c?.['root'] + return root + // return React.useContext(FlightDataContext) } export function LayoutContent(props: { id?: string }) { const data = useFlightData() + if (!data) return null const elem = (() => { if (!props.id) { - return data.layouts[0]?.element ?? data.page + return data?.layouts[0]?.element ?? data.page } - const layoutIndex = data.layouts.findIndex( + const layoutIndex = data?.layouts.findIndex( (layout) => layout.id === props.id, ) - let nextLayout = data.layouts[layoutIndex + 1]?.element + let nextLayout = data?.layouts[layoutIndex + 1]?.element if (nextLayout) { return nextLayout } @@ -54,9 +60,9 @@ export type ActionResult = { data?: ReactFormState | null } -// TODO not implemented interface ReactServerErrorContext { status: number + location?: string headers?: Record } @@ -75,18 +81,6 @@ interface State { error: Error | null } -function isRedirectError(ctx: ReactServerErrorContext) { - return ctx.status >= 300 && ctx.status < 400 -} - -function isNotFoundError(ctx: ReactServerErrorContext) { - return ctx.status === 404 -} - -function getErrorContext(error: Error): ReactServerErrorContext | undefined { - return (error as any).serverError -} - export function ErrorBoundary(props: Props) { return } @@ -99,8 +93,12 @@ class ErrorBoundary_ extends React.Component { static getDerivedStateFromError(error: Error) { const ctx = getErrorContext(error) - if (ctx && (isNotFoundError(ctx) || isRedirectError(ctx))) { - throw error + if (ctx && isRedirectError(ctx) && ctx.headers?.['location']) { + console.log('redirecting from browser to', ctx.headers?.['location']) + router.replace(ctx.headers?.['location']) + } + if (ctx && isNotFoundError(ctx)) { + // TODO somehow show the not found page } return { error } } diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 5129e0c..5077e20 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -6,16 +6,14 @@ import type { ModuleRunner } from 'vite/module-runner' import { injectRSCPayload } from 'rsc-html-stream/server' import cssUrls from 'virtual:app-styles' import { ServerPayload } from '../spiceflow.js' -import { - FlightDataContext, - LayoutContent -} from './components.js' +import { FlightDataContext, LayoutContent } from './components.js' import { clientReferenceManifest } from './utils/client-reference.js' import { - createRequest, - fromWebToNodeReadable, - sendResponse + createRequest, + fromWebToNodeReadable, + sendResponse, } from './utils/fetch.js' +import { getErrorContext, isNotFoundError, isRedirectError } from './errors.js' export default async function handler( req: IncomingMessage, @@ -57,28 +55,37 @@ export default async function handler( let htmlStream: ReadableStream let status = 200 - let payload = await payloadPromise try { + let payload = await payloadPromise htmlStream = await ReactDOMServer.renderToReadableStream(el, { bootstrapModules: ssrAssets.bootstrapModules, formState: payload.formState, onError(e) { // This also throws outside, no need to do anything here - console.error('[react-dom:renderToPipeableStream]', e) - if (e instanceof Response) { - console.log('sending response') - sendResponse(e, res) - return - } + console.error('[entry.srr.tsx:renderToPipeableStream]', e) + return e?.digest || e?.message }, }) } catch (e) { console.log(`error during ssr render catch`, e) - // On error, render minimal HTML shell - // Client will do full CSR render and show error boundary - - if (e instanceof Response) { - sendResponse(e, res) + let errCtx = getErrorContext(e) + if (errCtx && isRedirectError(errCtx)) { + console.log(`redirecting to ${errCtx.headers?.location}`) + sendResponse( + new Response(errCtx.headers?.location, { + status: errCtx.status, + headers: errCtx.headers, + }), + res, + ) + return + } + if (errCtx && isNotFoundError(errCtx)) { + // TODO show a not found component instead + sendResponse( + new Response('404', { status: errCtx.status, headers: errCtx.headers }), + res, + ) return } // https://bsky.app/profile/ebey.bsky.social/post/3lev4lqr2ak2j diff --git a/spiceflow/src/react/errors.tsx b/spiceflow/src/react/errors.tsx new file mode 100644 index 0000000..9561a8a --- /dev/null +++ b/spiceflow/src/react/errors.tsx @@ -0,0 +1,69 @@ +export interface ReactServerErrorContext { + status: number + headers?: Record +} + +export class ReactServerDigestError extends Error { + constructor(public digest: string) { + super('ReactServerError') + } +} +// TODO make redirects faster with this +// Object.setPrototypeOf(FastError.prototype, Error.prototype) +// Object.setPrototypeOf(FastError, Error) + +export function createError(ctx: ReactServerErrorContext) { + const digest = `__REACT_SERVER_ERROR__:${JSON.stringify(ctx)}` + return new ReactServerDigestError(digest) +} + +export function redirect( + location: string, + options?: { status?: number; headers?: Record }, +) { + return createError({ + status: options?.status ?? 307, + headers: { + ...options?.headers, + location, + }, + }) +} + +export function isRedirectError(ctx?: ReactServerErrorContext) { + if (!ctx) return false + const location = ctx.headers?.['location'] + if (300 <= ctx.status && ctx.status <= 399 && typeof location === 'string') { + return { location } + } + return false +} + +export function isRedirectStatus(status: number) { + return 300 <= status && status <= 399 +} + +export function isNotFoundError(ctx?: ReactServerErrorContext) { + if (!ctx) return false + return ctx.status === 404 +} + +export function getErrorContext( + error: unknown, +): ReactServerErrorContext | undefined { + if ( + error instanceof Error && + 'digest' in error && + typeof error.digest === 'string' + ) { + const m = error.digest.match(/^__REACT_SERVER_ERROR__:(.*)$/) + if (m && m[1]) { + try { + return JSON.parse(m[1]) + } catch (e) { + console.error(e) + } + } + } + return +} diff --git a/spiceflow/src/react/types/ambient.d.ts b/spiceflow/src/react/types/ambient.d.ts index e8f2fed..003a6c3 100644 --- a/spiceflow/src/react/types/ambient.d.ts +++ b/spiceflow/src/react/types/ambient.d.ts @@ -30,10 +30,10 @@ declare module 'react-server-dom-vite/server' { const defaultExport: { registerServerReference: Function registerClientReference: Function - decodeReply: decodeReply - decodeAction: decodeAction - decodeFormState: decodeFormState - renderToPipeableStream: renderToPipeableStream + decodeReply: typeof decodeReply + decodeAction: typeof decodeAction + decodeFormState: typeof decodeFormState + renderToPipeableStream: typeof renderToPipeableStream } export default defaultExport } diff --git a/spiceflow/src/react/utils/fetch.ts b/spiceflow/src/react/utils/fetch.ts index 299a5f3..87ada85 100644 --- a/spiceflow/src/react/utils/fetch.ts +++ b/spiceflow/src/react/utils/fetch.ts @@ -66,12 +66,6 @@ export function sendResponse(response: Response, res: ServerResponse) { } } -export function fromPipeableToWebReadable(stream: PipeableStream) { - return Readable.toWeb( - stream.pipe(new PassThrough()), - ) as ReadableStream -} - export function fromWebToNodeReadable(stream: ReadableStream) { return Readable.fromWeb(stream as any) } diff --git a/spiceflow/src/react/utils/normalize.ts b/spiceflow/src/react/utils/normalize.ts index c5e5ac0..25b52e8 100644 --- a/spiceflow/src/react/utils/normalize.ts +++ b/spiceflow/src/react/utils/normalize.ts @@ -8,7 +8,7 @@ import { ModuleNode, ViteDevServer } from 'vite' export function noramlizeClientReferenceId( id: string, parentServer: ViteDevServer, - mod?: ModuleNode, + mod?: { lastHMRTimestamp: number }, ) { const root = parentServer.config.root if (id.startsWith(root)) { @@ -25,6 +25,7 @@ export function noramlizeClientReferenceId( if (mod && mod.lastHMRTimestamp > 0) { id += `?t=${mod.lastHMRTimestamp}` } + return id } diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index 20f978e..a5bcdaf 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -37,20 +37,16 @@ import { MiddlewareContext } from './context.js' import { isProduction, ValidationError } from './error.js' import { isAsyncIterable, isResponse, redirect } from './utils.js' -import { - DefaultGlobalErrorPage, - ErrorBoundary, - FlightData, - LayoutContent, -} from './react/components.js' +import { PassThrough, Readable } from 'stream' +import { FlightData, LayoutContent } from './react/components.js' import { ClientReferenceMetadataManifest, ServerReferenceManifest, } from './react/types/index.js' -import { fromPipeableToWebReadable } from './react/utils/fetch.js' import { TrieRouter } from './trie-router/router.js' import { decodeURIComponent_ } from './trie-router/url.js' import { Result } from './trie-router/utils.js' +import { isRedirectError } from './react/errors.js' const ajv = (addFormats.default || addFormats)( new (Ajv.default || Ajv)({ useDefaults: true }), @@ -888,6 +884,7 @@ export class Spiceflow< ( { root, @@ -963,13 +962,33 @@ export class Spiceflow< formState, }, clientReferenceMetadataManifest, - { onError(error) {} }, + { + onError(error) { + console.error('[spiceflow:renderToPipeableStream]', error) + thrownError = error + return error?.digest || error?.message + }, + }, ) - // render flight stream - const stream = fromPipeableToWebReadable(abortable) request.signal.addEventListener('abort', () => { abortable.abort() }) + const passthrough = new PassThrough() + const nodeStream = abortable.pipe(passthrough) + const stream = Readable.toWeb(nodeStream) as ReadableStream + + const timerId = `wait for first chunk ${Math.random().toString(36).slice(2)}` + console.time(timerId) + await new Promise((resolve) => { + passthrough.once('data', () => { + resolve() + }) + }) + console.timeEnd(timerId) + + if (thrownError instanceof Response) { + return thrownError + } return new Response(stream, { headers: { @@ -1020,12 +1039,19 @@ export class Spiceflow< let handlerResponse: Response | undefined async function getResForError(err: any) { + if (isRedirectError(err)) { + return new Response(err.location, { + status: err.status, + headers: err.headers, + }) + } if (isResponse(err)) return err let res = await self.runErrorHandlers({ onErrorHandlers, error: err, request, }) + if (isResponse(res)) return res let status = err?.status ?? 500 @@ -1108,6 +1134,12 @@ export class Spiceflow< if (isResponse(res)) { return res } + if (isRedirectError(err)) { + return new Response(err.location, { + status: err.status, + headers: err.headers, + }) + } } } } diff --git a/spiceflow/src/utils.ts b/spiceflow/src/utils.ts index 0bfcfb9..0677b1f 100644 --- a/spiceflow/src/utils.ts +++ b/spiceflow/src/utils.ts @@ -1,3 +1,5 @@ +import { redirect } from './react/errors.js' + // deno-lint-ignore no-explicit-any export const deepFreeze = (value: any) => { for (const key of Reflect.ownKeys(value)) { @@ -26,6 +28,8 @@ export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } +export { redirect } + export const StatusMap = { Continue: 100, 'Switching Protocols': 101, @@ -98,18 +102,6 @@ export const InvertedStatusMap = Object.fromEntries( export type StatusMap = typeof StatusMap export type InvertedStatusMap = typeof InvertedStatusMap -/** - * - * @param url URL to redirect to - * @param HTTP status code to send, - */ -export const redirect = ( - url: string, - status: 301 | 302 | 303 | 307 | 308 = 302, -) => Response.redirect(url, status) - -export type redirect = typeof redirect - export function isResponse(result: any): result is Response { if (result instanceof Response) { return true diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index c67e70b..3507068 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -13,7 +13,7 @@ import { PluginOption, type RunnableDevEnvironment, ViteDevServer, - createRunnableDevEnvironment + createRunnableDevEnvironment, } from 'vite' import { collectStyleUrls } from './react/css.js' import { noramlizeClientReferenceId } from './react/utils/normalize.js' @@ -66,22 +66,26 @@ export function spiceflowPlugin({ entry }): PluginOption { // This is needed to let scan discover server references found in the use client components return } - const mod = await server?.moduleGraph?.getModuleByUrl(id) - let generateId = (filename, directive) => { - let id = '' + const mod = + await server?.environments.client.moduleGraph.getModuleById(id) + // console.log('mod', id, mod?.lastHMRTimestamp) + // console.log([...server?.moduleGraph.idToModuleMap.keys()]) + let generateId = (id) => { + let generated = '' if (command === 'build') { - id = makeHash(filename) + generated = makeHash(id) } else { - id = noramlizeClientReferenceId(filename, server, mod) + generated = noramlizeClientReferenceId(id, server, mod) } - console.log('generateId', id) - if (directive === 'use server') { - serverModules.set(filename, id) - return id + console.log('generateId', generated) + + if (!isUseClient) { + serverModules.set(id, generated) + } else { + clientModules.set(id, generated) } - clientModules.set(filename, id) - return id + return generated } if (this.environment.name === 'rsc') { @@ -128,9 +132,7 @@ export function spiceflowPlugin({ entry }): PluginOption { }, { name: 'spiceflow', - configureServer(_server) { - server = _server - }, + config: () => ({ appType: 'custom', environments: { From 025379587d81ee859ff1bf08351e6cddbd200e96 Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 22:25:08 +0100 Subject: [PATCH 055/226] all redirect tests pass --- example-react/e2e/basic.test.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/example-react/e2e/basic.test.ts b/example-react/e2e/basic.test.ts index c6bac1a..38aa93b 100644 --- a/example-react/e2e/basic.test.ts +++ b/example-react/e2e/basic.test.ts @@ -1,22 +1,29 @@ import { type Page, expect, test } from "@playwright/test"; import { createEditor } from "./helper.js"; - - test.describe("redirect", () => { test("redirect in outer route scope", async ({ page }) => { - await page.goto("/redirect"); + await page.goto("/top-level-redirect"); await expect(page).toHaveURL("/"); await page.getByText("[hydrated: 1]").click(); }); - test.skip("redirect in RSC", async ({ page }) => { + test("redirect in RSC", async ({ page }) => { await page.goto("/redirect-in-rsc"); await expect(page).toHaveURL("/"); await page.getByText("[hydrated: 1]").click(); }); + test("redirect in RSC, slow (meaning not first rsc chunk)", async ({ page }) => { + await page.goto("/slow-redirect"); + await expect(page).toHaveURL("/"); + await page.getByText("[hydrated: 1]").click(); + }); + test("redirect in RSC inside suspense, redirect made by client", async ({ page }) => { + await page.goto("/redirect-in-rsc-suspense"); + await expect(page).toHaveURL("/"); + await page.getByText("[hydrated: 1]").click(); + }); }); - test("client reference", async ({ page }) => { await page.goto("/"); await page.getByText("[hydrated: 1]").click(); @@ -34,10 +41,6 @@ test("server reference in server @js", async ({ page }) => { await testServerAction(page); }); - - - - test.describe(() => { test.use({ javaScriptEnabled: false }); test("server reference in server @nojs", async ({ page }) => { From 8331a4167b2b91b195a8532af54d9bc83dba14a6 Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 22:42:17 +0100 Subject: [PATCH 056/226] separating hooks from components --- spiceflow/src/react/components.tsx | 18 +----------------- spiceflow/src/react/context.tsx | 21 +++++++++++++++++++++ spiceflow/src/react/entry.client.tsx | 3 ++- spiceflow/src/react/entry.ssr.tsx | 3 ++- spiceflow/src/react/router.tsx | 16 ++++++++++++++-- spiceflow/src/vite.tsx | 2 +- 6 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 spiceflow/src/react/context.tsx diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index 191eec8..ac573b4 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -5,24 +5,8 @@ import { ReactFormState } from 'react-dom/client' import { router } from './router.js' import { ServerPayload } from '../spiceflow.js' import { isRedirectError, isNotFoundError, getErrorContext } from './errors.js' +import { useFlightData } from './context.js' -export const FlightDataContext = React.createContext>( - undefined!, -) -// Get $$id property that was set by registerClientReference - -export function useFlightData() { - const c = React.useContext(FlightDataContext) - - const payload = React.use(c) - let root = payload?.root - if (!root) { - console.log('root not found', payload) - } - return root - - // return React.useContext(FlightDataContext) -} export function LayoutContent(props: { id?: string }) { const data = useFlightData() diff --git a/spiceflow/src/react/context.tsx b/spiceflow/src/react/context.tsx new file mode 100644 index 0000000..cda581c --- /dev/null +++ b/spiceflow/src/react/context.tsx @@ -0,0 +1,21 @@ +import React from "react" +import { ServerPayload } from "../spiceflow.js" + +export const FlightDataContext = React.createContext>( + undefined!, +) + +// Get $$id property that was set by registerClientReference + +export function useFlightData() { + const c = React.useContext(FlightDataContext) + + const payload = React.use(c) + let root = payload?.root + if (!root) { + console.log('root not found', payload) + } + return root + + // return React.useContext(FlightDataContext) +} diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index aba1272..363ce72 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -9,10 +9,11 @@ import { rscStream } from 'rsc-html-stream/client' import { DefaultGlobalErrorPage, ErrorBoundary, - FlightDataContext, + LayoutContent, } from './components.js' import { ServerPayload } from '../spiceflow.js' +import { FlightDataContext } from './context.js' async function main() { const callServer: CallServerFn = async (id, args) => { diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 5077e20..f013c39 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -6,7 +6,7 @@ import type { ModuleRunner } from 'vite/module-runner' import { injectRSCPayload } from 'rsc-html-stream/server' import cssUrls from 'virtual:app-styles' import { ServerPayload } from '../spiceflow.js' -import { FlightDataContext, LayoutContent } from './components.js' +import { LayoutContent } from './components.js' import { clientReferenceManifest } from './utils/client-reference.js' import { createRequest, @@ -14,6 +14,7 @@ import { sendResponse, } from './utils/fetch.js' import { getErrorContext, isNotFoundError, isRedirectError } from './errors.js' +import { FlightDataContext } from './context.js' export default async function handler( req: IncomingMessage, diff --git a/spiceflow/src/react/router.tsx b/spiceflow/src/react/router.tsx index 8bb4234..024f4ae 100644 --- a/spiceflow/src/react/router.tsx +++ b/spiceflow/src/react/router.tsx @@ -1,7 +1,19 @@ - import { createBrowserHistory, createMemoryHistory } from 'history' -export const router = +const history = typeof window === 'undefined' ? createMemoryHistory() : createBrowserHistory({}) + + +export const router = { + // createHref: history.createHref, + location: history.location, + push: history.push, + replace: history.replace, + go: history.go, + back: history.back, + forward: history.forward, + listen: history.listen, + block: history.block, +} diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 3507068..6b4b65e 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -68,7 +68,7 @@ export function spiceflowPlugin({ entry }): PluginOption { } const mod = await server?.environments.client.moduleGraph.getModuleById(id) - // console.log('mod', id, mod?.lastHMRTimestamp) + // console.log('mod', id, mod?.lastHMRTimestamp, ) // console.log([...server?.moduleGraph.idToModuleMap.keys()]) let generateId = (id) => { let generated = '' From 95ee6c0249c830fb95807e048552ba4c7ba8b678 Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 22:49:17 +0100 Subject: [PATCH 057/226] managed to catch errors in rsc, cool, fix page ordering with :id --- example-react/src/app/client.tsx | 16 ++++++---------- example-react/src/main.tsx | 12 ++++++++++-- spiceflow/src/spiceflow.tsx | 4 +++- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/example-react/src/app/client.tsx b/example-react/src/app/client.tsx index 1c19044..c3cdbdb 100644 --- a/example-react/src/app/client.tsx +++ b/example-react/src/app/client.tsx @@ -1,5 +1,5 @@ "use client"; -import './client.css' +import "./client.css"; import React from "react"; import { add } from "./action-by-client"; @@ -58,20 +58,16 @@ export function Calculator() { ={returnValue ?? "?"} - ); } - - - export function ClientComponentThrows() { - throw new Error('Client component error'); + throw new Error("Client component error"); return
      Client component
      ; } - - - - +export function ErrorRender({ error }) { + console.log("caught error", error); + return
      Error from rsc
      ; +} diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index a162332..9d9384e 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -4,7 +4,7 @@ import { IndexPage } from "./app/index"; import { Layout } from "./app/layout"; import "./styles.css"; -import { ClientComponentThrows } from "./app/client"; +import { ClientComponentThrows, ErrorRender } from "./app/client"; import { ErrorBoundary } from "spiceflow/dist/react/components"; import { redirect, sleep } from "spiceflow/dist/utils"; @@ -116,7 +116,14 @@ const app = new Spiceflow() ); }) - .page("/loader-error", async () => { + .layout("/error-boundary", async ({ children }) => { + return ( + + {children} + + ); + }) + .page("/error-boundary", async () => { throw new Error("test error"); }) .page("/rsc-error", async () => { @@ -143,3 +150,4 @@ async function ServerComponentThrows() { } export default app; + diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index a5bcdaf..9f532b4 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -861,7 +861,9 @@ export class Spiceflow< reactRoutes, (x) => x.route.kind === 'page', ) - const pageRoute = pageRoutes[0] + const pageRoute = pageRoutes.sort((a, b) => { + return routeSorter(a.route, b.route) + })[0] if (!pageRoute) { // TODO customize not found route return new Response('Not Found', { status: 404 }) From f2cc826ace3fb36f83d217f3788debad3415b0dc Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 23:45:08 +0100 Subject: [PATCH 058/226] added not found function --- example-react/e2e/basic.test.ts | 22 +++++++- example-react/src/main.tsx | 16 ++++-- spiceflow/src/react/components.tsx | 83 +++++++++++++++++++++++++++- spiceflow/src/react/entry.client.tsx | 11 ++-- spiceflow/src/react/entry.ssr.tsx | 11 ++-- spiceflow/src/react/errors.tsx | 6 ++ spiceflow/src/spiceflow.tsx | 49 +++++++++++++++- 7 files changed, 177 insertions(+), 21 deletions(-) diff --git a/example-react/e2e/basic.test.ts b/example-react/e2e/basic.test.ts index 38aa93b..4c8fe62 100644 --- a/example-react/e2e/basic.test.ts +++ b/example-react/e2e/basic.test.ts @@ -1,6 +1,20 @@ import { type Page, expect, test } from "@playwright/test"; import { createEditor } from "./helper.js"; +test.describe("not found", () => { + test("not found in outer route scope", async ({ page }) => { + await page.goto("/not-found"); + await expect(page.getByText("404")).toBeVisible(); + await expect(page.getByText("This page could not be found.")).toBeVisible(); + }); + + test("not found in RSC inside suspense", async ({ page }) => { + await page.goto("/not-found-in-suspense"); + await expect(page.getByText("404")).toBeVisible(); + await expect(page.getByText("This page could not be found.")).toBeVisible(); + }); +}); + test.describe("redirect", () => { test("redirect in outer route scope", async ({ page }) => { await page.goto("/top-level-redirect"); @@ -12,12 +26,16 @@ test.describe("redirect", () => { await expect(page).toHaveURL("/"); await page.getByText("[hydrated: 1]").click(); }); - test("redirect in RSC, slow (meaning not first rsc chunk)", async ({ page }) => { + test("redirect in RSC, slow (meaning not first rsc chunk)", async ({ + page, + }) => { await page.goto("/slow-redirect"); await expect(page).toHaveURL("/"); await page.getByText("[hydrated: 1]").click(); }); - test("redirect in RSC inside suspense, redirect made by client", async ({ page }) => { + test("redirect in RSC inside suspense, redirect made by client", async ({ + page, + }) => { await page.goto("/redirect-in-rsc-suspense"); await expect(page).toHaveURL("/"); await page.getByText("[hydrated: 1]").click(); diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 9d9384e..840c109 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -7,6 +7,7 @@ import "./styles.css"; import { ClientComponentThrows, ErrorRender } from "./app/client"; import { ErrorBoundary } from "spiceflow/dist/react/components"; import { redirect, sleep } from "spiceflow/dist/utils"; +import { notFound } from "spiceflow/dist/react/errors"; const app = new Spiceflow() .layout("/*", async ({ children, request }) => { @@ -28,6 +29,16 @@ const app = new Spiceflow() }) .get("/hello", () => "Hello, World!") + .page("/not-found", () => { + throw notFound(); + }) + .layout("/not-found-in-suspense", async ({ children }) => { + return not found...}>{children}; + }) + .page("/not-found-in-suspense", async () => { + await sleep(100); + throw notFound(); + }) .page("/top-level-redirect", async () => { throw redirect("/"); }) @@ -118,9 +129,7 @@ const app = new Spiceflow() }) .layout("/error-boundary", async ({ children }) => { return ( - - {children} - + {children} ); }) .page("/error-boundary", async () => { @@ -150,4 +159,3 @@ async function ServerComponentThrows() { } export default app; - diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index ac573b4..2523a1f 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -7,7 +7,6 @@ import { ServerPayload } from '../spiceflow.js' import { isRedirectError, isNotFoundError, getErrorContext } from './errors.js' import { useFlightData } from './context.js' - export function LayoutContent(props: { id?: string }) { const data = useFlightData() if (!data) return null @@ -80,9 +79,10 @@ class ErrorBoundary_ extends React.Component { if (ctx && isRedirectError(ctx) && ctx.headers?.['location']) { console.log('redirecting from browser to', ctx.headers?.['location']) router.replace(ctx.headers?.['location']) + return {} } if (ctx && isNotFoundError(ctx)) { - // TODO somehow show the not found page + throw error } return { error } } @@ -131,6 +131,7 @@ export function DefaultGlobalErrorPage(props: ErrorPageProps) { : `Unknown Client Error (see browser console for the details)` return ( + {message} ) { /> ) } + +// https://github.com/vercel/next.js/blob/c74f3f54b23b3fc47dc7e214a8949844257a734a/packages/next/src/build/webpack/loaders/next-app-loader.ts#L72 +// https://github.com/vercel/next.js/blob/8f5f0ef141a907d083eedb7c7aca52b04f9d258b/packages/next/src/client/components/not-found-error.tsx#L34-L39 +export function DefaultNotFoundPage() { + return ( + + + not found + +
      +
      +

      + 404 +

      +

      + This page could not be found. +

      +
      +
      + + + ) +} +export class NotFoundBoundary extends React.Component<{ + component: React.ComponentType + children?: React.ReactNode +}> { + override state: { error?: Error } = {} + + static getDerivedStateFromError(error: Error) { + const ctx = getErrorContext(error) + if (ctx && isNotFoundError(ctx)) { + return { error } + } + throw error + } + + override render() { + if (this.state.error) { + const Component = this.props.component + return ( + <> + + { + React.startTransition(() => { + this.setState({ error: null }) + }) + }} + /> + + ) + } + return this.props.children + } +} diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index 363ce72..4e23121 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -8,9 +8,10 @@ import { clientReferenceManifest } from './utils/client-reference.js' import { rscStream } from 'rsc-html-stream/client' import { DefaultGlobalErrorPage, + DefaultNotFoundPage, ErrorBoundary, - LayoutContent, + NotFoundBoundary, } from './components.js' import { ServerPayload } from '../spiceflow.js' import { FlightDataContext } from './context.js' @@ -68,9 +69,11 @@ async function main() { return ( - - - + + + + + ) } diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index f013c39..fb25e43 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -6,7 +6,7 @@ import type { ModuleRunner } from 'vite/module-runner' import { injectRSCPayload } from 'rsc-html-stream/server' import cssUrls from 'virtual:app-styles' import { ServerPayload } from '../spiceflow.js' -import { LayoutContent } from './components.js' +import { DefaultNotFoundPage, LayoutContent } from './components.js' import { clientReferenceManifest } from './utils/client-reference.js' import { createRequest, @@ -68,6 +68,7 @@ export default async function handler( }, }) } catch (e) { + status = 500 console.log(`error during ssr render catch`, e) let errCtx = getErrorContext(e) if (errCtx && isRedirectError(errCtx)) { @@ -81,12 +82,9 @@ export default async function handler( ) return } + let content: any = null if (errCtx && isNotFoundError(errCtx)) { - // TODO show a not found component instead - sendResponse( - new Response('404', { status: errCtx.status, headers: errCtx.headers }), - res, - ) + status = 404 return } // https://bsky.app/profile/ebey.bsky.social/post/3lev4lqr2ak2j @@ -103,6 +101,7 @@ export default async function handler( + {content} ) diff --git a/spiceflow/src/react/errors.tsx b/spiceflow/src/react/errors.tsx index 9561a8a..1ff72f4 100644 --- a/spiceflow/src/react/errors.tsx +++ b/spiceflow/src/react/errors.tsx @@ -30,6 +30,12 @@ export function redirect( }) } +export function notFound() { + return createError({ + status: 404, + }) +} + export function isRedirectError(ctx?: ReactServerErrorContext) { if (!ctx) return false const location = ctx.headers?.['location'] diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index 9f532b4..4ddb02c 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -38,7 +38,11 @@ import { isProduction, ValidationError } from './error.js' import { isAsyncIterable, isResponse, redirect } from './utils.js' import { PassThrough, Readable } from 'stream' -import { FlightData, LayoutContent } from './react/components.js' +import { + DefaultNotFoundPage, + FlightData, + LayoutContent, +} from './react/components.js' import { ClientReferenceMetadataManifest, ServerReferenceManifest, @@ -46,7 +50,11 @@ import { import { TrieRouter } from './trie-router/router.js' import { decodeURIComponent_ } from './trie-router/url.js' import { Result } from './trie-router/utils.js' -import { isRedirectError } from './react/errors.js' +import { + getErrorContext, + isNotFoundError, + isRedirectError, +} from './react/errors.js' const ajv = (addFormats.default || addFormats)( new (Ajv.default || Ajv)({ useDefaults: true }), @@ -975,7 +983,9 @@ export class Spiceflow< request.signal.addEventListener('abort', () => { abortable.abort() }) - const passthrough = new PassThrough() + const passthrough = new PassThrough({ + writableHighWaterMark: 1024 * 1024, + }) const nodeStream = abortable.pipe(passthrough) const stream = Readable.toWeb(nodeStream) as ReadableStream @@ -991,6 +1001,39 @@ export class Spiceflow< if (thrownError instanceof Response) { return thrownError } + let errCtx = getErrorContext(thrownError) + if (errCtx && isRedirectError(errCtx)) { + console.log(`redirecting to ${errCtx.headers?.location}`) + return new Response(errCtx.headers?.location, { + status: errCtx.status, + headers: errCtx.headers, + }) + } + if (errCtx && isNotFoundError(errCtx)) { + console.log(`not found error for ${request.url}`) + let el = + let htmlAbortable = await ReactServer.renderToPipeableStream( + { + root: { + page: el, + layouts: [], + }, + returnValue, + formState, + }, + clientReferenceMetadataManifest, + ) + const htmlStream = Readable.toWeb( + htmlAbortable.pipe(new PassThrough()), + ) as ReadableStream + + return new Response(htmlStream, { + status: 404, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } return new Response(stream, { headers: { From 447ac5ba768f4bd7c244b36ac68f45da7e0c1e28 Mon Sep 17 00:00:00 2001 From: remorses Date: Tue, 11 Feb 2025 23:55:52 +0100 Subject: [PATCH 059/226] refactored renderReact --- spiceflow/src/spiceflow.tsx | 372 ++++++++++++++++++------------------ 1 file changed, 189 insertions(+), 183 deletions(-) diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index 4ddb02c..3460517 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -842,6 +842,187 @@ export class Spiceflow< return this } + async renderReact({ + request, + reactRoutes, + defaultContext, + }: { + request: Request + defaultContext + reactRoutes: Array<{ + route: InternalRoute + app: AnySpiceflow + params: Record + }> + }) { + const ReactServer = await import( + 'spiceflow/dist/react/server-dom-optimized' + ).then((m) => m.default) + const [pageRoutes, layoutRoutes] = partition( + reactRoutes, + (x) => x.route.kind === 'page', + ) + const pageRoute = pageRoutes.sort((a, b) => { + return routeSorter(a.route, b.route) + })[0] + if (!pageRoute) { + // TODO customize not found route + return new Response('Not Found', { status: 404 }) + } + const kind = pageRoute?.route?.kind + + let Page = pageRoute?.route?.handler as any + let page = ( + + ) + const layouts = layoutRoutes.map((layout) => { + const id = layout.route.id + const children = createElement(LayoutContent, { id }) + + let Layout = layout.route.handler as any + const element = ( + + ) + return { element, id } + }) + + let root: FlightData = { + url: request.url, + page, + layouts, + } + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + if (request.method === 'POST') { + const url = new URL(request.url) + const actionId = url.searchParams.get('__rsc') + if (actionId) { + // client stream request + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + const args = await ReactServer.decodeReply(body) + const reference = + serverReferenceManifest.resolveServerReference(actionId) + await reference.preload() + const action = await reference.get() + // TODO handle action errors, redirects, etc + returnValue = await (action as any).apply(null, args) + } else { + // progressive enhancement + const formData = await request.formData() + console.log(formData) + const decodedAction = await ReactServer.decodeAction( + formData, + serverReferenceManifest, + ) + formState = await ReactServer.decodeFormState( + await decodedAction(), + formData, + serverReferenceManifest, + ) + } + } + + if (root instanceof Response) { + return root + } + + let thrownError + let abortable = ReactServer.renderToPipeableStream( + { + root, + returnValue, + formState, + }, + clientReferenceMetadataManifest, + { + onError(error) { + console.error('[spiceflow:renderToPipeableStream]', error) + thrownError = error + return error?.digest || error?.message + }, + }, + ) + request.signal.addEventListener('abort', () => { + abortable.abort() + }) + const passthrough = new PassThrough({ + writableHighWaterMark: 1024 * 1024, + }) + const nodeStream = abortable.pipe(passthrough) + const stream = Readable.toWeb(nodeStream) as ReadableStream + + const timerId = `wait for first chunk ${Math.random().toString(36).slice(2)}` + console.time(timerId) + await new Promise((resolve) => { + passthrough.once('data', () => { + resolve() + }) + }) + console.timeEnd(timerId) + + if (thrownError instanceof Response) { + return thrownError + } + let errCtx = getErrorContext(thrownError) + if (errCtx && isRedirectError(errCtx)) { + console.log(`redirecting to ${errCtx.headers?.location}`) + return new Response(errCtx.headers?.location, { + status: errCtx.status, + headers: errCtx.headers, + }) + } + if (errCtx && isNotFoundError(errCtx)) { + console.log(`not found error for ${request.url}`) + let el = + let htmlAbortable = await ReactServer.renderToPipeableStream( + { + root: { + page: el, + layouts: [], + }, + returnValue, + formState, + }, + clientReferenceMetadataManifest, + ) + const htmlStream = Readable.toWeb( + htmlAbortable.pipe(new PassThrough()), + ) as ReadableStream + + return new Response(htmlStream, { + status: 404, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } + + return new Response(stream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } + async handle(request: Request) { let u = new URL(request.url, 'http://localhost') const self = this @@ -850,6 +1031,8 @@ export class Spiceflow< redirect, error: null, children: undefined, + query: parseQuery((u.search || '').slice(1)), + request, path, } const root = this.topLevelApp || this @@ -862,194 +1045,19 @@ export class Spiceflow< (x) => !x.route.kind, ) if (reactRoutes.length) { - const ReactServer = await import( - 'spiceflow/dist/react/server-dom-optimized' - ).then((m) => m.default) - const [pageRoutes, layoutRoutes] = partition( + const res = await this.renderReact({ + request, + defaultContext, reactRoutes, - (x) => x.route.kind === 'page', - ) - const pageRoute = pageRoutes.sort((a, b) => { - return routeSorter(a.route, b.route) - })[0] - if (!pageRoute) { - // TODO customize not found route - return new Response('Not Found', { status: 404 }) - } - const kind = pageRoute?.route?.kind - - try { - const baseContext = { - ...defaultContext, - request, - - path, - query: parseQuery((u.search || '').slice(1)), - // params: _params, - redirect, - } - - let Page = pageRoute?.route?.handler as any - let page = ( - - ) - const layouts = layoutRoutes.map((layout) => { - const id = layout.route.id - const children = createElement(LayoutContent, { id }) - - let Layout = layout.route.handler as any - const element = ( - - ) - return { element, id } - }) - - let root: FlightData = { - url: request.url, - page, - layouts, - } - let returnValue: unknown | undefined - let formState: ReactFormState | undefined - if (request.method === 'POST') { - const url = new URL(request.url) - const actionId = url.searchParams.get('__rsc') - if (actionId) { - // client stream request - const contentType = request.headers.get('content-type') - const body = contentType?.startsWith('multipart/form-data') - ? await request.formData() - : await request.text() - const args = await ReactServer.decodeReply(body) - const reference = - serverReferenceManifest.resolveServerReference(actionId) - await reference.preload() - const action = await reference.get() - // TODO handle action errors, redirects, etc - returnValue = await (action as any).apply(null, args) - } else { - // progressive enhancement - const formData = await request.formData() - console.log(formData) - const decodedAction = await ReactServer.decodeAction( - formData, - serverReferenceManifest, - ) - formState = await ReactServer.decodeFormState( - await decodedAction(), - formData, - serverReferenceManifest, - ) - } - } - - if (root instanceof Response) { - return root - } - - let thrownError - let abortable = ReactServer.renderToPipeableStream( - { - root, - returnValue, - formState, - }, - clientReferenceMetadataManifest, - { - onError(error) { - console.error('[spiceflow:renderToPipeableStream]', error) - thrownError = error - return error?.digest || error?.message - }, - }, - ) - request.signal.addEventListener('abort', () => { - abortable.abort() - }) - const passthrough = new PassThrough({ - writableHighWaterMark: 1024 * 1024, - }) - const nodeStream = abortable.pipe(passthrough) - const stream = Readable.toWeb(nodeStream) as ReadableStream - - const timerId = `wait for first chunk ${Math.random().toString(36).slice(2)}` - console.time(timerId) - await new Promise((resolve) => { - passthrough.once('data', () => { - resolve() - }) - }) - console.timeEnd(timerId) - - if (thrownError instanceof Response) { - return thrownError - } - let errCtx = getErrorContext(thrownError) - if (errCtx && isRedirectError(errCtx)) { - console.log(`redirecting to ${errCtx.headers?.location}`) - return new Response(errCtx.headers?.location, { - status: errCtx.status, - headers: errCtx.headers, - }) - } - if (errCtx && isNotFoundError(errCtx)) { - console.log(`not found error for ${request.url}`) - let el = - let htmlAbortable = await ReactServer.renderToPipeableStream( - { - root: { - page: el, - layouts: [], - }, - returnValue, - formState, - }, - clientReferenceMetadataManifest, - ) - const htmlStream = Readable.toWeb( - htmlAbortable.pipe(new PassThrough()), - ) as ReadableStream - - return new Response(htmlStream, { - status: 404, - headers: { - 'content-type': 'text/x-component;charset=utf-8', - }, - }) - } - - return new Response(stream, { - headers: { - 'content-type': 'text/x-component;charset=utf-8', - }, - }) - } catch (err) { - return await getResForError(err) - } + }) + return res } const route = nonReactRoutes.sort((a, b) => { return routeSorter(a.route, b.route) })[0] // TODO get all apps in scope? layouts can match between apps when using .use? - const appsInScope = this.getAppsInScope(routes[0].app) + const appsInScope = this.getAppsInScope(route.app) onErrorHandlers = appsInScope.flatMap((x) => x.onErrorHandlers) let { params: _params, @@ -1076,8 +1084,6 @@ export class Spiceflow< ...defaultContext, request, state, - path, - query: parseQuery((u.search || '').slice(1)), params: _params, redirect, } satisfies MiddlewareContext From 77d815707f87be6d555aa6ab3344e696624695bb Mon Sep 17 00:00:00 2001 From: remorses Date: Wed, 12 Feb 2025 00:19:34 +0100 Subject: [PATCH 060/226] adding middleware support for react too, works pretty well, hope performance stays good --- example-react/e2e/basic.test.ts | 10 ++++ example-react/src/main.tsx | 19 ++++++- spiceflow/src/context.ts | 4 +- spiceflow/src/react/entry.ssr.tsx | 10 +++- spiceflow/src/spiceflow.tsx | 86 +++++++++++++++++++------------ spiceflow/src/utils.ts | 2 + 6 files changed, 94 insertions(+), 37 deletions(-) diff --git a/example-react/e2e/basic.test.ts b/example-react/e2e/basic.test.ts index 4c8fe62..cc6c825 100644 --- a/example-react/e2e/basic.test.ts +++ b/example-react/e2e/basic.test.ts @@ -14,6 +14,16 @@ test.describe("not found", () => { await expect(page.getByText("This page could not be found.")).toBeVisible(); }); }); +test.describe("middleware with use()", () => { + test("middleware sets response header", async ({ page }) => { + const response = await page.goto("/"); + expect(response?.headers()["x-middleware-1"]).toBe("ok"); + }); + test("middleware sets state", async ({ page }) => { + await page.goto("/state"); + await expect(page.getByText("state set by middleware1")).toBeVisible(); + }); +}); test.describe("redirect", () => { test("redirect in outer route scope", async ({ page }) => { diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 840c109..2677a8f 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -10,7 +10,16 @@ import { redirect, sleep } from "spiceflow/dist/utils"; import { notFound } from "spiceflow/dist/react/errors"; const app = new Spiceflow() - .layout("/*", async ({ children, request }) => { + .state("middleware1", "") + .use(async ({ request, state }, next) => { + console.log("middleware 1"); + state.middleware1 = "state set by middleware1"; + const res = await next(); + res.headers.set("x-middleware-1", "ok"); + console.log("middleware 2"); + return res; + }) + .layout("/*", async ({ children, state }) => { return ( title from layout @@ -18,6 +27,14 @@ const app = new Spiceflow() ); }) + .page("/state", async ({ state }) => { + return ( + <> + title from page + state: {state.middleware1} + + ); + }) .page("/", async ({ request }) => { const url = new URL(request.url); return ( diff --git a/spiceflow/src/context.ts b/spiceflow/src/context.ts index 20aa9e6..1298fa9 100644 --- a/spiceflow/src/context.ts +++ b/spiceflow/src/context.ts @@ -1,7 +1,7 @@ import type { StatusMap, InvertedStatusMap, - redirect as Redirect, + Redirect, } from './utils.js' import type { @@ -98,7 +98,7 @@ export type MiddlewareContext< path: string query?: Record params?: Record - + redirect: Redirect // server: Server | null diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index fb25e43..98bef81 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -68,7 +68,7 @@ export default async function handler( }, }) } catch (e) { - status = 500 + status = 500 console.log(`error during ssr render catch`, e) let errCtx = getErrorContext(e) if (errCtx && isRedirectError(errCtx)) { @@ -76,7 +76,11 @@ export default async function handler( sendResponse( new Response(errCtx.headers?.location, { status: errCtx.status, - headers: errCtx.headers, + headers: { + ...Object.fromEntries(response.headers), + ...errCtx.headers, + contentType: 'text/html', + }, }), res, ) @@ -116,6 +120,8 @@ export default async function handler( { status, headers: { + // copy rsc headers, so spiceflow can add its own headers via .use() + ...Object.fromEntries(response.headers), 'content-type': 'text/html;charset=utf-8', }, }, diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index 3460517..e8f346a 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -33,7 +33,7 @@ import Ajv, { ValidateFunction } from 'ajv' import { createElement } from 'react' import { z, ZodType } from 'zod' import { zodToJsonSchema } from 'zod-to-json-schema' -import { MiddlewareContext } from './context.js' +import { Context, MiddlewareContext } from './context.js' import { isProduction, ValidationError } from './error.js' import { isAsyncIterable, isResponse, redirect } from './utils.js' @@ -845,10 +845,10 @@ export class Spiceflow< async renderReact({ request, reactRoutes, - defaultContext, + context, }: { request: Request - defaultContext + context reactRoutes: Array<{ route: InternalRoute app: AnySpiceflow @@ -875,9 +875,7 @@ export class Spiceflow< let page = ( @@ -890,10 +888,7 @@ export class Spiceflow< const element = ( const root = this.topLevelApp || this let onErrorHandlers: OnError[] = [] @@ -1044,13 +1038,49 @@ export class Spiceflow< routes, (x) => !x.route.kind, ) + let index = 0 if (reactRoutes.length) { - const res = await this.renderReact({ - request, - defaultContext, - reactRoutes, - }) - return res + const appsInScope = this.getAppsInScope(reactRoutes[0].app) + onErrorHandlers = appsInScope.flatMap((x) => x.onErrorHandlers) + const middlewares = appsInScope.flatMap((x) => x.middlewares) + let handlerResponse: Response | undefined + + let context = defaultContext + const next = async () => { + try { + if (index < middlewares.length) { + const middleware = middlewares[index] + index++ + + const result = await middleware(context, next) + if (isResponse(result)) { + handlerResponse = result + } + if (!result && index < middlewares.length) { + return await next() + } else if (result) { + return await turnHandlerResultIntoResponse(result) + } + } + if (handlerResponse) { + return handlerResponse + } + + const res = await this.renderReact({ + request, + context, + reactRoutes, + }) + + return res + } catch (err) { + handlerResponse = await getResForError(err) + return await next() + } + } + const response = await next() + + return response } const route = nonReactRoutes.sort((a, b) => { return routeSorter(a.route, b.route) @@ -1059,13 +1089,11 @@ export class Spiceflow< // TODO get all apps in scope? layouts can match between apps when using .use? const appsInScope = this.getAppsInScope(route.app) onErrorHandlers = appsInScope.flatMap((x) => x.onErrorHandlers) + const middlewares = appsInScope.flatMap((x) => x.middlewares) let { params: _params, app: { defaultState }, } = route - const middlewares = appsInScope.flatMap((x) => x.middlewares) - - let state = cloneDeep(defaultState) let content = route?.route?.hooks?.content @@ -1079,14 +1107,8 @@ export class Spiceflow< request = typedRequest } - let index = 0 - let context = { - ...defaultContext, - request, - state, - params: _params, - redirect, - } satisfies MiddlewareContext + let context: any = defaultContext + context.params = _params let handlerResponse: Response | undefined async function getResForError(err: any) { @@ -1579,7 +1601,7 @@ export function bfs(tree: AnySpiceflow) { export async function turnHandlerResultIntoResponse( result: any, - route: InternalRoute, + route?: InternalRoute, ) { // if user returns a promise, await it if (result instanceof Promise) { @@ -1590,7 +1612,7 @@ export async function turnHandlerResultIntoResponse( return result } - if (route.type) { + if (route?.type) { if (route.type?.includes('multipart/form-data')) { if (!(result instanceof Response)) { throw new Error( diff --git a/spiceflow/src/utils.ts b/spiceflow/src/utils.ts index 0677b1f..9d3c6b4 100644 --- a/spiceflow/src/utils.ts +++ b/spiceflow/src/utils.ts @@ -30,6 +30,8 @@ export function sleep(ms: number) { export { redirect } +export type Redirect = typeof redirect + export const StatusMap = { Continue: 100, 'Switching Protocols': 101, From 312a69bf86788cad1737ef48f2fe1c1b31a42fa1 Mon Sep 17 00:00:00 2001 From: remorses Date: Wed, 12 Feb 2025 00:38:20 +0100 Subject: [PATCH 061/226] fix validation errors, forgot to change request in context --- spiceflow/src/react.test.ts | 131 -------------------------------- spiceflow/src/spiceflow.test.ts | 8 +- spiceflow/src/spiceflow.tsx | 15 ++-- 3 files changed, 10 insertions(+), 144 deletions(-) delete mode 100644 spiceflow/src/react.test.ts diff --git a/spiceflow/src/react.test.ts b/spiceflow/src/react.test.ts deleted file mode 100644 index f9ae1eb..0000000 --- a/spiceflow/src/react.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { test, describe, expect } from 'vitest' -import { Type } from '@sinclair/typebox' -import { bfs, cloneDeep, Spiceflow } from './spiceflow.js' -import { z } from 'zod' -import { createSpiceflowClient } from './client/index.js' - -test('layout and page work together', async () => { - const res = await new Spiceflow() - .layout('/xxx', () => ({ layout: 'layout' })) - .page('/xxx', () => ({ page: 'page' })) - .handle(new Request('http://localhost/xxx', { method: 'POST' })) - - expect(res).toMatchInlineSnapshot(` - { - "layouts": [ - { - "element": { - "layout": "layout", - }, - "id": "layout-post--xxx", - }, - ], - "page": { - "page": "page", - }, - "url": "http://localhost/xxx", - } - `) -}) -test('layout and page, static routes have priority', async () => { - const res = await new Spiceflow() - .layout('/xxx', () => ({ layout: 'layout' })) - .page('/:id', () => ({ page: ':id' })) - .page('/xxx', () => ({ page: 'page' })) - .handle(new Request('http://localhost/xxx', { method: 'POST' })) - - expect(res).toMatchInlineSnapshot(` - { - "layouts": [ - { - "element": { - "layout": "layout", - }, - "id": "layout-post--xxx", - }, - ], - "page": { - "page": "page", - }, - "url": "http://localhost/xxx", - } - `) -}) - -test('layout and page work together with params', async () => { - const app = new Spiceflow() - .layout('/', async ({ children }) => ({ layout: 'root', children })) - .page('/:id', async ({ params }) => ({ page: params.id })) - - const routes = app.getAllRoutes() - expect(routes).toMatchInlineSnapshot(` - [ - { - "handler": [Function], - "hooks": undefined, - "id": "layout-get--", - "kind": "layout", - "method": "GET", - "path": "/", - "type": "", - "validateBody": undefined, - "validateParams": undefined, - "validateQuery": undefined, - }, - { - "handler": [Function], - "hooks": undefined, - "id": "layout-post--", - "kind": "layout", - "method": "POST", - "path": "/", - "type": "", - "validateBody": undefined, - "validateParams": undefined, - "validateQuery": undefined, - }, - { - "handler": [Function], - "hooks": undefined, - "id": "page-get--:id", - "kind": "page", - "method": "GET", - "path": "/:id", - "type": "", - "validateBody": undefined, - "validateParams": undefined, - "validateQuery": undefined, - }, - { - "handler": [Function], - "hooks": undefined, - "id": "page-post--:id", - "kind": "page", - "method": "POST", - "path": "/:id", - "type": "", - "validateBody": undefined, - "validateParams": undefined, - "validateQuery": undefined, - }, - ] - `) - - const res = await app.handle(new Request('http://localhost/123')) - - expect(app.router).toMatchInlineSnapshot(` - TrieRouter { - "name": "TrieRouter", - } - `) - - expect(await res).toMatchInlineSnapshot(` - { - "layouts": [], - "page": { - "page": "123", - }, - "url": "http://localhost/123", - } - `) -}) diff --git a/spiceflow/src/spiceflow.test.ts b/spiceflow/src/spiceflow.test.ts index 9342b6b..e70fe07 100644 --- a/spiceflow/src/spiceflow.test.ts +++ b/spiceflow/src/spiceflow.test.ts @@ -515,10 +515,10 @@ test('validate body works, request fails', async () => { expect(body).toEqual({ name: 'John' }) }, { - body: Type.Object({ - name: Type.String(), - requiredField: Type.String(), - }), + body: z.object({ + name: z.string(), + requiredField: z.string(), + }).strict(), }, ) .handle( diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index e8f346a..f0e00f9 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -1022,13 +1022,14 @@ export class Spiceflow< let u = new URL(request.url, 'http://localhost') const self = this let path = u.pathname - const defaultContext = { + const context = { redirect, state: cloneDeep(this.defaultState), query: parseQuery((u.search || '').slice(1)), request, path, - } satisfies MiddlewareContext + params: {}, + } const root = this.topLevelApp || this let onErrorHandlers: OnError[] = [] @@ -1045,7 +1046,6 @@ export class Spiceflow< const middlewares = appsInScope.flatMap((x) => x.middlewares) let handlerResponse: Response | undefined - let context = defaultContext const next = async () => { try { if (index < middlewares.length) { @@ -1090,10 +1090,7 @@ export class Spiceflow< const appsInScope = this.getAppsInScope(route.app) onErrorHandlers = appsInScope.flatMap((x) => x.onErrorHandlers) const middlewares = appsInScope.flatMap((x) => x.middlewares) - let { - params: _params, - app: { defaultState }, - } = route + let { params: _params } = route let content = route?.route?.hooks?.content @@ -1105,10 +1102,10 @@ export class Spiceflow< : new SpiceflowRequest(u, request) typedRequest.validateBody = route?.route?.validateBody request = typedRequest + context.request = typedRequest } - let context: any = defaultContext - context.params = _params + context['params'] = _params let handlerResponse: Response | undefined async function getResForError(err: any) { From eec8ad69934d2a386cf1c04a013460d752dd5eb8 Mon Sep 17 00:00:00 2001 From: remorses Date: Wed, 12 Feb 2025 00:39:40 +0100 Subject: [PATCH 062/226] not found --- spiceflow/src/spiceflow.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index f0e00f9..0b1b049 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -1115,6 +1115,11 @@ export class Spiceflow< headers: err.headers, }) } + if (isNotFoundError(err)) { + return new Response(JSON.stringify('not found'), { + status: 404, + }) + } if (isResponse(err)) return err let res = await self.runErrorHandlers({ onErrorHandlers, From c346438ad3c68629c3a1a207426093c57a32321e Mon Sep 17 00:00:00 2001 From: remorses Date: Wed, 12 Feb 2025 12:26:37 +0100 Subject: [PATCH 063/226] show error overlay on rsc errors --- spiceflow/src/react/components.tsx | 3 +++ spiceflow/src/react/entry.client.tsx | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index 2523a1f..a0894a6 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -84,6 +84,9 @@ class ErrorBoundary_ extends React.Component { if (ctx && isNotFoundError(ctx)) { throw error } + if (import.meta.env.DEV) { + throw error + } return { error } } diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index 4e23121..9fb9108 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -1,4 +1,5 @@ import React, { Suspense } from 'react' +import type { ErrorPayload } from 'vite' import { router } from './router.js' import ReactDomClient from 'react-dom/client' import ReactClient from 'spiceflow/dist/react/server-dom-client-optimized' @@ -90,4 +91,17 @@ async function main() { } } +if (import.meta.env.DEV) { + window.onerror = (event, source, lineno, colno, err) => { + // must be within function call because that's when the element is defined for sure. + const ErrorOverlay = customElements.get('vite-error-overlay') + // don't open outside vite environment + if (!ErrorOverlay) { + return + } + const overlay = new ErrorOverlay(err) + document.body.appendChild(overlay) + } +} + main() From 8d0798733487f985509620a1cc18720ecbfc38ee Mon Sep 17 00:00:00 2001 From: remorses Date: Wed, 12 Feb 2025 12:30:45 +0100 Subject: [PATCH 064/226] tested errors in useEffect --- example-react/src/app/client.tsx | 9 +++++++++ example-react/src/main.tsx | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/example-react/src/app/client.tsx b/example-react/src/app/client.tsx index c3cdbdb..4b6c5ae 100644 --- a/example-react/src/app/client.tsx +++ b/example-react/src/app/client.tsx @@ -17,6 +17,15 @@ export function Counter() { ); } +export function ErrorInUseEffect() { + React.useEffect(() => { + setTimeout(() => { + throw new Error("Error in useEffect"); + }, 0); + }, []); + return
      ErrorInUseEffect
      ; +} + export function Hydrated() { return
      [hydrated: {Number(useHydrated())}]
      ; } diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 2677a8f..ff8876b 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -4,7 +4,11 @@ import { IndexPage } from "./app/index"; import { Layout } from "./app/layout"; import "./styles.css"; -import { ClientComponentThrows, ErrorRender } from "./app/client"; +import { + ClientComponentThrows, + ErrorInUseEffect, + ErrorRender, +} from "./app/client"; import { ErrorBoundary } from "spiceflow/dist/react/components"; import { redirect, sleep } from "spiceflow/dist/utils"; import { notFound } from "spiceflow/dist/react/errors"; @@ -152,6 +156,9 @@ const app = new Spiceflow() .page("/error-boundary", async () => { throw new Error("test error"); }) + .page("/error-in-use-effect", async () => { + return ; + }) .page("/rsc-error", async () => { return ; }) From 5c1e69e2dd5ae2ae8d030b0c9676deffd0e94fb5 Mon Sep 17 00:00:00 2001 From: remorses Date: Wed, 12 Feb 2025 15:25:29 +0100 Subject: [PATCH 065/226] use @jacob-ebey/react-server-dom-vite directly because of pnpm bug, added @jacob-ebey/react-server-dom-vite which does not work --- example-react/package.json | 14 +- example-react/src/app/chakra.tsx | 22 + example-react/src/app/dialog.tsx | 65 + example-react/src/app/select.tsx | 15 + example-react/src/main.tsx | 12 + example-react/vite.config.ts | 8 + pnpm-lock.yaml | 3661 ++++++++++++++--- spiceflow/package.json | 4 +- spiceflow/src/react/entry.rsc.tsx | 9 - spiceflow/src/react/references.browser.tsx | 2 +- .../src/react/server-dom-client-optimized.tsx | 2 +- spiceflow/src/react/server-dom-optimized.tsx | 2 +- spiceflow/src/react/types/ambient.d.ts | 2 +- spiceflow/src/vite.tsx | 143 +- 14 files changed, 3438 insertions(+), 523 deletions(-) create mode 100644 example-react/src/app/chakra.tsx create mode 100644 example-react/src/app/dialog.tsx create mode 100644 example-react/src/app/select.tsx diff --git a/example-react/package.json b/example-react/package.json index a975b1e..5853489 100644 --- a/example-react/package.json +++ b/example-react/package.json @@ -5,21 +5,25 @@ "main": "index.js", "type": "module", "scripts": { - "dev": "vite", - "build": "vite build --app", - "preview": "vite preview", - "test-e2e": "playwright test", - "test-e2e-preview": "E2E_PREVIEW=1 playwright test" + "dev": "DEBUG_SPICEFLOW=1 vite", + "build": "DEBUG_SPICEFLOW=1 vite build --app", + "preview": "DEBUG_SPICEFLOW=1 vite preview", + "test-e2e": "DEBUG_SPICEFLOW=1 playwright test", + "test-e2e-preview": "DEBUG_SPICEFLOW=1 E2E_PREVIEW=1 playwright test" }, "keywords": [], "author": "remorses ", "dependencies": { + "@chakra-ui/react": "^3.8.0", "@playwright/test": "^1.50.1", + "@radix-ui/react-icons": "^1.3.2", "@tailwindcss/vite": "^4.0.5", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", + "radix-ui": "^1.1.3", "react": "19.0.0", "react-dom": "19.0.0", + "react-select": "^5.10.0", "spiceflow": "workspace:*", "tailwindcss": "^4.0.5", "vite": "^6.1.0" diff --git a/example-react/src/app/chakra.tsx b/example-react/src/app/chakra.tsx new file mode 100644 index 0000000..d80a769 --- /dev/null +++ b/example-react/src/app/chakra.tsx @@ -0,0 +1,22 @@ +"use client"; +import { + Alert, + AlertDescription, + AlertTitle, + ChakraProvider, + defaultSystem, +} from "@chakra-ui/react"; + +export function Chakra() { + return ( + + + + Your browser! + + Your Chakra experience! + + + + ); +} diff --git a/example-react/src/app/dialog.tsx b/example-react/src/app/dialog.tsx new file mode 100644 index 0000000..664fd56 --- /dev/null +++ b/example-react/src/app/dialog.tsx @@ -0,0 +1,65 @@ +'use client' +import { Cross2Icon } from "@radix-ui/react-icons"; +import { Dialog } from "radix-ui"; + +export const DialogDemo = () => ( + + + + + + + + + Edit profile + + + Make changes to your profile here. Click save when you're done. + +
      + + +
      +
      + + +
      +
      + + + +
      + + + +
      +
      +
      +); diff --git a/example-react/src/app/select.tsx b/example-react/src/app/select.tsx new file mode 100644 index 0000000..0c578bc --- /dev/null +++ b/example-react/src/app/select.tsx @@ -0,0 +1,15 @@ +import Select from "react-select"; + +const options = [ + { value: "chocolate", label: "Chocolate" }, + { value: "strawberry", label: "Strawberry" }, + { value: "vanilla", label: "Vanilla" }, +]; + +export function WithSelect() { + return ( +
      + + + + ); +} \ No newline at end of file diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 91782e9..cce3608 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -1,4 +1,5 @@ -import { Suspense } from "react"; +import { Suspense, useActionState } from "react"; + import { Spiceflow } from "spiceflow"; import { IndexPage } from "./app/index"; import { Layout } from "./app/layout"; @@ -10,6 +11,7 @@ import { redirect, sleep } from "spiceflow/dist/utils"; import { Chakra } from "./app/chakra"; import { ClientComponentThrows, + ClientFormWithError, ErrorInUseEffect, ErrorRender, } from "./app/client"; @@ -117,6 +119,24 @@ const app = new Spiceflow()
      ); }) + .page("/form", async ({ request, children }) => { + async function action(data: FormData) { + "use server"; + console.log("action", data); + throw new Error("test error"); + return "ok"; + } + + return ( +
      + + +
      + ); + }) + .page("/form-error", async ({ request, children }) => { + return ; + }) .layout("/page/*", async ({ request, children }) => { return (
      diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef076de..fed5307 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10203,7 +10203,7 @@ snapshots: '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.7) '@vanilla-extract/babel-plugin-debug-ids': 1.2.0 '@vanilla-extract/css': 1.17.1(babel-plugin-macros@3.1.0) - esbuild: 0.17.19 + esbuild: 0.17.6 eval: 0.1.8 find-up: 5.0.0 javascript-stringify: 2.1.0 diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index 5662250..651cf9a 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -36,7 +36,7 @@ export type FlightData = { // segments: MatchSegment[] page: any layouts: { id: string; element: React.ReactNode }[] - url: string + // url: string } export type ActionResult = { diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index f1866f2..ddae61d 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -29,9 +29,13 @@ async function main() { clientReferenceManifest, { callServer }, ) - // console.log({ 'action payload': payload }) - setPayload(payloadPromise) + let payload = await payloadPromise + + if (payload.actionError) { + throw payload.actionError + } + setPayload(payloadPromise) return payload.returnValue } Object.assign(globalThis, { __callServer: callServer }) diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index 62a808a..de974a2 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -899,41 +899,46 @@ export class Spiceflow< .filter(isTruthy) let root: FlightData = { - url: request.url, + // url: request.url, page, layouts, } + let actionError: Error | undefined let returnValue: unknown | undefined let formState: ReactFormState | undefined if (request.method === 'POST') { - const url = new URL(request.url) - const actionId = url.searchParams.get('__rsc') - if (actionId) { - // client stream request - const contentType = request.headers.get('content-type') - const body = contentType?.startsWith('multipart/form-data') - ? await request.formData() - : await request.text() - const args = await ReactServer.decodeReply(body) - const reference = - serverReferenceManifest.resolveServerReference(actionId) - await reference.preload() - const action = await reference.get() - // TODO handle action errors, redirects, etc - returnValue = await (action as any).apply(null, args) - } else { - // progressive enhancement - const formData = await request.formData() - console.log(formData) - const decodedAction = await ReactServer.decodeAction( - formData, - serverReferenceManifest, - ) - formState = await ReactServer.decodeFormState( - await decodedAction(), - formData, - serverReferenceManifest, - ) + try { + const url = new URL(request.url) + const actionId = url.searchParams.get('__rsc') + if (actionId) { + // client stream request + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + const args = await ReactServer.decodeReply(body) + const reference = + serverReferenceManifest.resolveServerReference(actionId) + await reference.preload() + const action = await reference.get() + // TODO handle action errors, redirects, etc + returnValue = await (action as any).apply(null, args) + } else { + // progressive enhancement + const formData = await request.formData() + const decodedAction = await ReactServer.decodeAction( + formData, + serverReferenceManifest, + ) + formState = await ReactServer.decodeFormState( + await decodedAction(), + formData, + serverReferenceManifest, + ) + } + } catch (e) { + console.log('action error', e) + actionError = e } } @@ -947,7 +952,8 @@ export class Spiceflow< root, returnValue, formState, - }, + actionError, + } satisfies ServerPayload, clientReferenceMetadataManifest, { onPostpone(reason) { @@ -999,7 +1005,8 @@ export class Spiceflow< }, returnValue, formState, - }, + actionError, + } satisfies ServerPayload, clientReferenceMetadataManifest, ) const htmlStream = Readable.toWeb( @@ -1838,4 +1845,5 @@ export interface ServerPayload { root: FlightData formState?: ReactFormState returnValue?: unknown + actionError?: Error } From 39b9849f63024dd7fecd9c79db4d7299fa68e9fc Mon Sep 17 00:00:00 2001 From: remorses Date: Fri, 14 Feb 2025 15:35:21 +0100 Subject: [PATCH 082/226] testing form actions --- example-react/src/app/client.tsx | 32 ++++++++++++++------------- example-react/src/app/form-action.tsx | 19 ++++++++++++++++ example-react/src/app/layout.tsx | 3 ++- example-react/src/main.tsx | 24 ++++++++++++++++---- spiceflow/src/react/entry.client.tsx | 4 +++- spiceflow/src/spiceflow.tsx | 1 + 6 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 example-react/src/app/form-action.tsx diff --git a/example-react/src/app/client.tsx b/example-react/src/app/client.tsx index b4d9d4b..4d1270b 100644 --- a/example-react/src/app/client.tsx +++ b/example-react/src/app/client.tsx @@ -3,12 +3,14 @@ import "./client.css"; import React, { useActionState } from "react"; import { add } from "./action-by-client"; +import { redirect } from "spiceflow/dist/utils"; +import { action } from "./form-action"; -export function Counter() { +export function Counter({ name = "Client" }) { const [count, setCount] = React.useState(0); return (
      -
      Client counter: {count}
      +
      {name} counter: {count}
      @@ -80,22 +82,22 @@ export function ErrorRender({ error }) { console.log("caught error", error); return
      Error from rsc
      ; } - - -export function ClientFormWithError() { - async function action(_, data: FormData) { - "use server"; - console.log("action", data); - throw new Error("test error"); - return "ok"; - } - - const [state, formAction] = useActionState(action, null); +export function ClientFormWithError({ + shouldRedirect = false, + shouldError = false, + action: _action = undefined as Function, +}) { + const [state, formAction] = useActionState(_action || action, { + shouldRedirect, + shouldError, + result: "", + }); return ( -
      + +
      {JSON.stringify(state)}
      ); -} \ No newline at end of file +} diff --git a/example-react/src/app/form-action.tsx b/example-react/src/app/form-action.tsx new file mode 100644 index 0000000..e23f694 --- /dev/null +++ b/example-react/src/app/form-action.tsx @@ -0,0 +1,19 @@ +"use server"; + +import { redirect } from "spiceflow/dist/utils"; + +export async function action( + { shouldRedirect, shouldError, result }, + data: FormData, +) { + "use server"; + + console.log("action", data); + if (shouldRedirect) { + throw redirect("/"); + } + if (shouldError) { + throw new Error("test error"); + } + return { shouldRedirect, shouldError, result: "ok" }; +} diff --git a/example-react/src/app/layout.tsx b/example-react/src/app/layout.tsx index 63211c6..5b92c65 100644 --- a/example-react/src/app/layout.tsx +++ b/example-react/src/app/layout.tsx @@ -1,5 +1,6 @@ import { Link } from "spiceflow/dist/react/components"; import { ProgressBar } from "spiceflow/dist/react/progress"; +import { Counter } from "./client"; export function Layout(props: React.PropsWithChildren) { return ( @@ -13,9 +14,9 @@ export function Layout(props: React.PropsWithChildren) { +
      • - Home
      • diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index cce3608..59f9327 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -119,12 +119,14 @@ const app = new Spiceflow()
      ); }) - .page("/form", async ({ request, children }) => { + .page("/form-server", async ({ state, children }) => { async function action(data: FormData) { "use server"; - console.log("action", data); - throw new Error("test error"); - return "ok"; + if (!state) { + throw new Error("userId not set"); + } + + return } return ( @@ -135,8 +137,22 @@ const app = new Spiceflow() ); }) .page("/form-error", async ({ request, children }) => { + return ; + }) + .page("/form", async ({ request, children }) => { return ; }) + .page("/form-redirect", async ({ request, children }) => { + return ; + }) + .page("/form-inline-action-server", async ({ state, children }) => { + async function action({}) { + "use server"; + console.log({ state }); + return { state, hello: true }; + } + return ; + }) .layout("/page/*", async ({ request, children }) => { return (
      diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index ddae61d..d50f2ed 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -16,6 +16,7 @@ import { } from './components.js' import { ServerPayload } from '../spiceflow.js' import { FlightDataContext } from './context.js' +import { createError, getErrorContext } from './errors.js' async function main() { const callServer: CallServerFn = async (id, args) => { @@ -31,8 +32,9 @@ async function main() { ) let payload = await payloadPromise - + if (payload.actionError) { + console.log(getErrorContext(payload.actionError)) throw payload.actionError } setPayload(payloadPromise) diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index de974a2..e8087d5 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -43,6 +43,7 @@ import { LayoutContent, } from './react/components.js' import { + createError, getErrorContext, isNotFoundError, isRedirectError, From 1536fa3fc64d8d2adaad83bfb9cc4d877e737091 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 17 Feb 2025 15:30:39 +0100 Subject: [PATCH 083/226] handle errors in passthrough --- spiceflow/src/spiceflow.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index e8087d5..7f1c5fc 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -976,10 +976,19 @@ export class Spiceflow< const nodeStream = abortable.pipe(passthrough) const stream = Readable.toWeb(nodeStream) as ReadableStream const start = performance.now() - await new Promise((resolve) => { + await new Promise((resolve, reject) => { passthrough.once('data', () => { resolve() }) + passthrough.once('error', (err) => { + reject(err) + }) + passthrough.once('end', () => { + resolve() // Resolve if stream ends before data + }) + passthrough.once('close', () => { + resolve() // Resolve if stream closes + }) }) const end = performance.now() // console.log(`First chunk took ${Math.round(end - start)}ms`) From cc1259ac0d0095dcdaea2e40ea81a4fcce57bd15 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 17 Feb 2025 15:32:57 +0100 Subject: [PATCH 084/226] handle error in ssr entry --- spiceflow/src/react/entry.ssr.tsx | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 0dfeb8d..367a422 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -30,21 +30,26 @@ export default async function handler( } export async function fetchHandler(request: Request) { - const url = new URL(request.url) - const rscEntry = await importRscEntry() - const response = await rscEntry.handler(request) + try { + const url = new URL(request.url) + const rscEntry = await importRscEntry() + const response = await rscEntry.handler(request) - if (!response.headers.get('content-type')?.startsWith('text/x-component')) { - return response - } + if (!response.headers.get('content-type')?.startsWith('text/x-component')) { + return response + } - if (url.searchParams.has('__rsc')) { - return response - } + if (url.searchParams.has('__rsc')) { + return response + } - const htmlResponse = await renderHtml({ response, request }) + const htmlResponse = await renderHtml({ response, request }) - return htmlResponse + return htmlResponse + } catch (err) { + console.error('[fetchHandler] unexpected error', err) + return new Response('', { status: 500 }) + } } async function renderHtml({ From ae43a13228dc4ff1cc1ba96fa60c3991f0c794d7 Mon Sep 17 00:00:00 2001 From: remorses Date: Mon, 17 Feb 2025 15:54:57 +0100 Subject: [PATCH 085/226] made a prerelease --- .changeset/config.json | 14 +++++++------- .changeset/five-toes-learn.md | 5 +++++ .changeset/pre.json | 12 ++++++++++++ example-react/package.json | 2 +- how-is-this-not-illegal/package.json | 1 - spiceflow/CHANGELOG.md | 24 ++++++++++++++++++++++++ spiceflow/package.json | 4 ++-- 7 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 .changeset/five-toes-learn.md create mode 100644 .changeset/pre.json diff --git a/.changeset/config.json b/.changeset/config.json index 8e872d9..b7cbab7 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,9 +1,9 @@ { - "$schema": "https://unpkg.com/@changesets/config@1.4.0/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "linked": [], - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch" + "$schema": "https://unpkg.com/@changesets/config@1.4.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch" } diff --git a/.changeset/five-toes-learn.md b/.changeset/five-toes-learn.md new file mode 100644 index 0000000..38b546f --- /dev/null +++ b/.changeset/five-toes-learn.md @@ -0,0 +1,5 @@ +--- +'spiceflow': patch +--- + +initial rsc release diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000..79b4c80 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,12 @@ +{ + "mode": "pre", + "tag": "rsc", + "initialVersions": { + "openapi-schema-diff": "0.0.1", + "spiceflow": "1.6.1", + "how-is-this-not-illegal": "0.1.0" + }, + "changesets": [ + "five-toes-learn" + ] +} diff --git a/example-react/package.json b/example-react/package.json index 5853489..a6f7f2d 100644 --- a/example-react/package.json +++ b/example-react/package.json @@ -1,8 +1,8 @@ { "name": "example-react", - "version": "1.0.0", "description": "", "main": "index.js", + "private": true, "type": "module", "scripts": { "dev": "DEBUG_SPICEFLOW=1 vite", diff --git a/how-is-this-not-illegal/package.json b/how-is-this-not-illegal/package.json index aaa33d8..62a38f7 100644 --- a/how-is-this-not-illegal/package.json +++ b/how-is-this-not-illegal/package.json @@ -1,6 +1,5 @@ { "name": "how-is-this-not-illegal", - "version": "0.1.0", "private": true, "type": "module", "scripts": { diff --git a/spiceflow/CHANGELOG.md b/spiceflow/CHANGELOG.md index b167059..901856f 100644 --- a/spiceflow/CHANGELOG.md +++ b/spiceflow/CHANGELOG.md @@ -1,5 +1,29 @@ # spiceflow +## 1.6.2-rsc.2 + +### Patch Changes + +- initial rsc release +- Updated dependencies + - spiceflow@1.6.2-rsc.2 + +## 1.6.2-rsc.1 + +### Patch Changes + +- initial rsc release +- Updated dependencies + - spiceflow@1.6.2-rsc.1 + +## 1.6.2-rsc.0 + +### Patch Changes + +- initial rsc release +- Updated dependencies + - spiceflow@1.6.2-rsc.0 + ## 1.6.1 ### Patch Changes diff --git a/spiceflow/package.json b/spiceflow/package.json index 8c7d2c4..b480344 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -1,6 +1,6 @@ { "name": "spiceflow", - "version": "1.6.1", + "version": "1.6.2-rsc.2", "description": "Simple API framework with RPC and type safety", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -75,7 +75,7 @@ "react-dom": "19.0.0", "rsc-html-stream": "^0.0.4", "sirv": "^3.0.0", - "spiceflow": "*", + "spiceflow": "1.6.2-rsc.2", "superjson": "^2.2.2", "unplugin-rsc": "^0.0.11", "vite": "^6.1.0", From a64a400c961108c5eda4e744e07ec7af16990b47 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 1 Mar 2026 18:25:50 +0100 Subject: [PATCH 086/226] migrate RSC from custom implementation to @vitejs/plugin-rsc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the custom RSC setup (@jacob-ebey/react-server-dom-vite + unplugin-rsc + hand-rolled reference tracking) with the official @vitejs/plugin-rsc@0.5.21. ## What changed **Dependencies:** - Remove @jacob-ebey/react-server-dom-vite, unplugin-rsc, @hiogawa/transforms, @hiogawa/utils - Add @vitejs/plugin-rsc@0.5.21, react-server-dom-webpack@19.2.4 - Upgrade react/react-dom to 19.2.4 **vite.tsx — completely rewritten:** - Replace ~600 lines of custom RSC plugin code (manual client/server reference tracking, custom environment configs, SSE-based HMR, manual module graph walking) with a thin wrapper around `rsc()` from @vitejs/plugin-rsc - Add `spiceflow:optimize-deps-rewrite` plugin to rewrite optimizeDeps entries with `spiceflow >` prefix so vendor CJS files resolve through the framework package (where plugin-rsc is installed) rather than from the app root - Add `spiceflow:auto-use-client` plugin for client-by-default behavior — auto injects `"use client"` into user source files unless they already have a directive, are the app entry, node_modules, spiceflow internals, or .server.* **Entry points updated to use plugin-rsc APIs:** - entry.client.tsx: import from `@vitejs/plugin-rsc/browser` instead of custom references.browser + react-server-dom-vite/client - entry.ssr.tsx: import `createFromReadableStream` from `@vitejs/plugin-rsc/ssr`, use `import.meta.viteRsc.loadModule` and `import.meta.viteRsc.loadBootstrapScriptContent` - entry.rsc.tsx: simplified — just imports app entry and exports handler **spiceflow.tsx — renderReact updated:** - Import renderToReadableStream, decodeReply, decodeAction, decodeFormState, loadServerAction from `@vitejs/plugin-rsc/rsc` instead of custom wrappers - Remove manual client reference manifest handling **Deleted files (old custom RSC infrastructure):** - references.browser.tsx, references.rsc.tsx, references.ssr.tsx - utils/client-reference.ts, utils/normalize.ts - react/types/index.ts **Example app fixes for stricter RSC semantics:** - action.tsx: make getCounter async (plugin-rsc rejects non-async exports in "use server" modules) - index.tsx: receive counter + serverRandom as props instead of calling server functions during render (client components can't call server functions during SSR initial render) - main.tsx: fetch data in page handlers and pass as props — correct RSC pattern - e2e tests: fix selectors for dual counter elements, replace `using` keyword with try/finally for Playwright compat ## Test results - 12/12 Playwright e2e tests pass (dev mode) - 216/216 spiceflow package tests pass - Production build: 4/5 stages succeed, prerender step has a timing issue with __vite_rsc_assets_manifest.js generation (TODO) --- .gitignore | 4 +- example-react/e2e/basic.test.ts | 25 +- example-react/package.json | 4 +- example-react/src/app/action.tsx | 2 +- example-react/src/app/index.tsx | 8 +- example-react/src/main.tsx | 21 +- pnpm-lock.yaml | 1847 ++++++++++------- spiceflow/package.json | 10 +- spiceflow/src/react/entry.client.tsx | 54 +- spiceflow/src/react/entry.rsc.tsx | 6 +- spiceflow/src/react/entry.ssr.tsx | 50 +- spiceflow/src/react/references.browser.tsx | 5 - spiceflow/src/react/references.rsc.tsx | 5 - spiceflow/src/react/references.ssr.tsx | 4 - spiceflow/src/react/types/ambient.d.ts | 84 +- spiceflow/src/react/types/index.ts | 18 - spiceflow/src/react/utils/client-reference.ts | 37 - spiceflow/src/react/utils/normalize.ts | 34 - spiceflow/src/spiceflow.tsx | 151 +- spiceflow/src/vite.tsx | 702 ++----- 20 files changed, 1341 insertions(+), 1730 deletions(-) delete mode 100644 spiceflow/src/react/references.browser.tsx delete mode 100644 spiceflow/src/react/references.rsc.tsx delete mode 100644 spiceflow/src/react/references.ssr.tsx delete mode 100644 spiceflow/src/react/utils/client-reference.ts delete mode 100644 spiceflow/src/react/utils/normalize.ts diff --git a/.gitignore b/.gitignore index f5767e7..ce6f4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,6 @@ __pycache__ debug .env .last-run.json -.react-router \ No newline at end of file +.react-router +opensrc +/di diff --git a/example-react/e2e/basic.test.ts b/example-react/e2e/basic.test.ts index cc6c825..4414a5a 100644 --- a/example-react/e2e/basic.test.ts +++ b/example-react/e2e/basic.test.ts @@ -55,14 +55,12 @@ test.describe("redirect", () => { test("client reference", async ({ page }) => { await page.goto("/"); await page.getByText("[hydrated: 1]").click(); - await page.getByText("Client counter: 0").click(); - await page - .getByTestId("client-counter") - .getByRole("button", { name: "+" }) - .click(); - await page.getByText("Client counter: 1").click(); + const clientCounter = page.getByTestId("client-counter").filter({ hasText: "Client counter" }); + await clientCounter.getByText("Client counter: 0").click(); + await clientCounter.getByRole("button", { name: "+" }).click(); + await clientCounter.getByText("Client counter: 1").click(); await page.reload(); - await page.getByText("Client counter: 0").click(); + await clientCounter.getByText("Client counter: 0").click(); }); test("server reference in server @js", async ({ page }) => { @@ -139,9 +137,13 @@ test("client hmr @dev", async ({ page }) => { .click(); await page.getByText("Client counter: 1").click(); // edit client - using file = createEditor("src/app/client.tsx"); - file.edit((s) => s.replace("Client counter", "Client [EDIT] counter")); - await page.getByText("Client [EDIT] counter: 1").click(); + const file = createEditor("src/app/client.tsx"); + try { + file.edit((s) => s.replace("Client counter", "Client [EDIT] counter")); + await page.getByText("Client [EDIT] counter: 1").click(); + } finally { + file[Symbol.dispose](); + } }); test("server hmr @dev", async ({ page }) => { @@ -165,7 +167,7 @@ test("server hmr @dev", async ({ page }) => { await page.getByText("Client counter: 1").click(); // edit server - using file = createEditor("src/app/index.tsx"); + const file = createEditor("src/app/index.tsx"); await file.edit((s) => s.replace("Server counter", "Server [EDIT] counter")); await page.getByText("Server [EDIT] counter: 1").click(); await page.getByText("Client counter: 1").click(); @@ -176,4 +178,5 @@ test("server hmr @dev", async ({ page }) => { .getByRole("button", { name: "-" }) .click(); await page.getByText("Server [EDIT] counter: 0").click(); + file[Symbol.dispose](); }); diff --git a/example-react/package.json b/example-react/package.json index a6f7f2d..b663968 100644 --- a/example-react/package.json +++ b/example-react/package.json @@ -21,8 +21,8 @@ "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "radix-ui": "^1.1.3", - "react": "19.0.0", - "react-dom": "19.0.0", + "react": "19.2.4", + "react-dom": "19.2.4", "react-select": "^5.10.0", "spiceflow": "workspace:*", "tailwindcss": "^4.0.5", diff --git a/example-react/src/app/action.tsx b/example-react/src/app/action.tsx index f1f77a6..5228ee6 100644 --- a/example-react/src/app/action.tsx +++ b/example-react/src/app/action.tsx @@ -4,7 +4,7 @@ if (!("counter" in globalThis)) { globalThis.counter = 0; } -export function getCounter() { +export async function getCounter() { return globalThis.counter; } diff --git a/example-react/src/app/index.tsx b/example-react/src/app/index.tsx index f7bb019..e2e0cb6 100644 --- a/example-react/src/app/index.tsx +++ b/example-react/src/app/index.tsx @@ -1,11 +1,11 @@ import { Button } from "./button"; -import { changeCounter, getCounter } from "./action"; +import { changeCounter } from "./action"; import { Calculator, Counter, Hydrated } from "./client"; -export async function IndexPage() { +export function IndexPage({ counter, serverRandom }: { counter: number; serverRandom: string }) { return (
      -
      server random: {Math.random().toString(36).slice(2)}
      +
      server random: {serverRandom}
      -
      Server counter: {getCounter()}
      +
      Server counter: {counter}
      Unicode test: 🌟 你好 こんにちは ⚡️ 안녕하세요
      +
      + ) +} +``` + +### Server Actions + +Use `"use server"` to define functions that run on the server but can be called from client components (e.g. form actions). + +```tsx +// src/app/actions.tsx +'use server' + +export async function submitForm(formData: FormData) { + const name = formData.get('name') + await saveToDatabase(name) +} +``` + +### Client Code Splitting + +Code splitting of client components is **automatic** — you don't need `React.lazy()` or dynamic `import()`. Each `"use client"` file becomes a separate chunk, and the browser only loads the chunks needed for the current page. + +**How it works:** when the RSC flight stream is sent to the browser, it contains references to client component chunks rather than the actual code. The browser resolves and loads only the chunks referenced on the current page. If route `/about` uses `` and route `/dashboard` uses ``, visiting `/about` will never download the Chart component's JavaScript. + +**Avoid barrel files with `"use client"`.** If you have a single file with `"use client"` that re-exports many components, all of them end up in one chunk — defeating code splitting. Instead, put `"use client"` in each individual component file: + +```tsx +// BAD — one big chunk for everything +// src/components/index.tsx +'use client' +export { Chart } from './chart' +export { Map } from './map' +export { Table } from './table' +``` + +```tsx +// GOOD — each component is its own chunk +// src/components/chart.tsx +'use client' +export function Chart() { /* ... */ } + +// src/components/map.tsx +'use client' +export function Map() { /* ... */ } + +// Re-export barrel has no directive, just passes through +// src/components/index.tsx +export { Chart } from './chart' +export { Map } from './map' +``` From a86467a3281de3c91bc5b48bf70cfca4139ce68e Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 2 Mar 2026 16:42:34 +0100 Subject: [PATCH 099/226] test: add serverRenderCount counter and verify client HMR doesn't trigger server re-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a server-side render counter (serverRenderCount) that increments on each RSC render of the home page, exposed via data-testid="server-render-count". The client HMR test now asserts that: - Editing a client component does NOT increment the server render count - Client state is preserved (counter stays at 1 after edit) - The edited text appears via React Fast Refresh This proves client component edits only trigger client-side HMR, not a server re-render. Vite's SSR environment logs 'page reload' internally but the browser does not actually reload — React Fast Refresh handles the update. Also update AGENTS.md e2e testing section with: - Accurate HMR behavior (client HMR preserves state, no server re-render) - Warning about replace() no-ops when the search string doesn't exist in source - Documentation for the serverRenderCount test helper --- AGENTS.md | 72 +++++++++++++++++++++++++++++++++ example-react/e2e/basic.test.ts | 27 ++++++++----- example-react/src/main.tsx | 6 +++ 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a3f7d2..5ec4cf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,78 @@ Sometimes tests work directly on database data, using prisma. To run these tests Never write tests yourself that call prisma or interact with database or emails. For these asks the user to write them for you. +# e2e testing (example-react) + +E2e tests live in `example-react/e2e/` and use Playwright (chromium only). The dev server starts automatically via the `webServer` config in `playwright.config.ts`. + +## running e2e tests + +```bash +# run from example-react directory, never from root +cd example-react + +# run all e2e tests +pnpm test-e2e + +# filter by test name +pnpm test-e2e --grep "SSR error" + +# run against production build +pnpm test-e2e-preview +``` + +Tests tagged `@dev` are skipped during preview runs; tests tagged `@build` are skipped during dev runs (controlled by `grepInvert` in playwright.config.ts). + +## rebuild dist before testing + +The Vite SSR middleware imports from `spiceflow/dist/` (the compiled package), NOT from source. If you modify files in `spiceflow/src/`, you must rebuild before e2e tests will pick up the changes: + +```bash +cd spiceflow +pnpm tsc --noCheck # --noCheck skips pre-existing type errors +``` + +This is the most common reason e2e tests fail after code changes — stale dist files. + +## writing e2e tests + +- The base URL and port are defined at the top of `basic.test.ts`: + ```ts + const port = Number(process.env.E2E_PORT || 6174); + const baseURL = `http://localhost:${port}`; + ``` +- Use `page.goto("/path")` for browser-based tests that need rendering, JS execution, or DOM interaction. +- Use Node.js `fetch(baseURL + "/path")` directly (not `page.evaluate`) when you need to control HTTP headers like `Origin` — browsers restrict forbidden headers. +- Use `page.getByTestId()`, `page.getByText()`, `page.getByRole()` for locators. Prefer test-ids for stability. +- When a `data-testid` matches multiple elements (e.g. multiple counter components on a page), use `.filter({ hasText: "..." })` to disambiguate: + ```ts + const clientCounter = page.getByTestId("client-counter").filter({ hasText: "Client counter" }); + await clientCounter.getByRole("button", { name: "+" }).click(); + ``` +- If a locator's text changes during the test (e.g. HMR edits), do NOT use it through a pre-filtered variable — query the page directly for the new text. + +## adding test routes + +To add a route for e2e testing, add it in `example-react/src/main.tsx` using the spiceflow API: + +```ts +.page("/my-test-route", async () => { + return ; +}) +``` + +Client components used in tests should be created in `example-react/src/app/` with a `"use client"` directive. + +## HMR tests + +- `createEditor("src/app/file.tsx")` from `e2e/helper.ts` edits a file and auto-reverts on dispose. +- Always call `file[Symbol.dispose]()` or use `try/finally` to restore files after edits. +- When editing files, make sure the `replace()` string actually exists in the source. For example, `client.tsx` has `name = "Client"` as a default prop — the literal string "Client counter" does NOT exist in the file, so `replace("Client counter", ...)` would be a no-op and the HMR test would silently fail. +- **Client HMR preserves state**: editing a client component triggers React Fast Refresh without a server re-render. Client state is preserved. Vite's SSR environment logs `page reload` internally but the browser does not actually reload — Fast Refresh handles it. +- **Server HMR preserves server state**: editing a server component triggers RSC HMR. Server-side state (e.g. counters stored in module scope) is preserved. Client state is also preserved because no full page reload occurs. +- The home page has a `serverRenderCount` counter (`data-testid="server-render-count"`) that increments on each RSC render. Use it in tests to verify whether a server re-render happened. + + # website the website uses react-router v7. diff --git a/example-react/e2e/basic.test.ts b/example-react/e2e/basic.test.ts index bda7746..0ab66be 100644 --- a/example-react/e2e/basic.test.ts +++ b/example-react/e2e/basic.test.ts @@ -129,24 +129,29 @@ async function testServerAction2(page: Page, options: { js: boolean }) { } } -// RSC architecture causes SSR page reload on client component changes, which -// races with client-side HMR and prevents the edited text from appearing reliably. -test.skip("client hmr @dev", async ({ page }) => { +test("client hmr @dev", async ({ page }) => { await page.goto("/"); await page.getByText("[hydrated: 1]").click(); const clientCounter = page.getByTestId("client-counter").filter({ hasText: "Client counter" }); // client +1 - await clientCounter.getByText("Client counter: 0").click(); - await clientCounter - .getByRole("button", { name: "+" }) - .click(); + await clientCounter.getByRole("button", { name: "+" }).click(); await clientCounter.getByText("Client counter: 1").click(); - // edit client — RSC architecture causes a full page reload (SSR re-renders client components), - // so client state resets to 0. We verify the edited text appears, not that state is preserved. + // Record the server render count before the client edit + const renderCountBefore = await page.getByTestId("server-render-count").textContent(); + // edit client — replace the default prop value in client.tsx. + // Client HMR should NOT trigger a server re-render. Only the client module + // should hot-update, preserving client state and avoiding an SSR page reload. const file = createEditor("src/app/client.tsx"); try { - file.edit((s) => s.replace("Client counter", "Client [EDIT] counter")); - await page.getByText("Client [EDIT] counter: 0").click(); + await file.edit((s) => s.replace('name = "Client"', 'name = "Client [EDIT]"')); + // Verify edited text appears with preserved state (counter stays at 1). + // If a full page reload happened, state would reset to 0. + await expect(page.getByText("Client [EDIT] counter: 1")).toBeVisible(); + // Wait to ensure any delayed server re-render would have completed + await page.waitForTimeout(2000); + // Server render count must not have changed — no server re-render happened + const renderCountAfter = await page.getByTestId("server-render-count").textContent(); + expect(renderCountAfter).toBe(renderCountBefore); } finally { file[Symbol.dispose](); } diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 6179476..4988984 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -22,6 +22,10 @@ import { ThrowsDuringSSR } from "./app/ssr-error"; import { Head } from "spiceflow/dist/react/head"; import { SpiceflowContext } from "spiceflow/dist/context"; +// Increments on every RSC render of the home page. Used by e2e tests to detect +// unwanted server re-renders (e.g. client HMR should not trigger a server render). +let serverRenderCount = 0; + const app = new Spiceflow() .state("middleware1", "") .use(async ({ request, state }, next) => { @@ -48,11 +52,13 @@ const app = new Spiceflow() ); }) .page("/", async ({ request }) => { + serverRenderCount++; const counter = await getCounter(); const serverRandom = Math.random().toString(36).slice(2); return ( <> title from page + {serverRenderCount} ); From 101c8671d3be2936073950cd467226741febe78d Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 2 Mar 2026 16:53:49 +0100 Subject: [PATCH 100/226] test: streaming async generator from server to client component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add e2e test proving that an async generator passed as a prop from a server component to a client component streams items incrementally — the client starts rendering before the generator completes. The test uses waitUntil:'commit' so Playwright begins observing while the HTML stream is still open. It asserts: - 'message-1' is visible while the 'done' marker is NOT (generator has ~3s left) - All 3 items arrive in order once the generator finishes This works because React 19's flight protocol natively serializes async iterables: the server yields values progressively via X/x flight chunks, and the client reconstructs a live AsyncIterable backed by a promise queue that resolves as chunks arrive over the wire. New files: - example-react/src/app/streaming-consumer.tsx — client component consuming AsyncIterable via useEffect + for-await - /streaming route in main.tsx with 1.5s delays between yields --- example-react/e2e/basic.test.ts | 21 +++++++++++ example-react/src/app/streaming-consumer.tsx | 39 ++++++++++++++++++++ example-react/src/main.tsx | 11 ++++++ 3 files changed, 71 insertions(+) create mode 100644 example-react/src/app/streaming-consumer.tsx diff --git a/example-react/e2e/basic.test.ts b/example-react/e2e/basic.test.ts index 0ab66be..4c6f8cd 100644 --- a/example-react/e2e/basic.test.ts +++ b/example-react/e2e/basic.test.ts @@ -244,3 +244,24 @@ test.describe("CSRF protection", () => { expect(response.status).not.toBe(403); }); }); + +test.describe("streaming async generator", () => { + test("client renders items incrementally before generator completes", async ({ page }) => { + // Use waitUntil:'commit' so Playwright doesn't wait for the full streaming response + await page.goto("/streaming", { waitUntil: "commit" }); + // First item should appear while the generator is still yielding + const firstItem = page.getByTestId("stream-item").first(); + await expect(firstItem).toBeVisible({ timeout: 10000 }); + await expect(firstItem).toHaveText("message-1"); + // At this point the generator still has ~3s of work left (2 × 1500ms delays). + // "done" marker must NOT be visible yet. + expect(await page.getByTestId("stream-done").isVisible()).toBe(false); + // Wait for all items to arrive + await expect(page.getByTestId("stream-done")).toBeVisible({ timeout: 10000 }); + const items = page.getByTestId("stream-item"); + await expect(items).toHaveCount(3); + await expect(items.nth(0)).toHaveText("message-1"); + await expect(items.nth(1)).toHaveText("message-2"); + await expect(items.nth(2)).toHaveText("message-3"); + }); +}); diff --git a/example-react/src/app/streaming-consumer.tsx b/example-react/src/app/streaming-consumer.tsx new file mode 100644 index 0000000..9270db2 --- /dev/null +++ b/example-react/src/app/streaming-consumer.tsx @@ -0,0 +1,39 @@ +// Client component that consumes an async iterable prop, rendering items +// incrementally as they arrive from the server via the RSC flight stream. +"use client"; + +import { useEffect, useState } from "react"; + +export function StreamingConsumer({ + stream, +}: { + stream: AsyncIterable; +}) { + const [items, setItems] = useState([]); + const [done, setDone] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + for await (const item of stream) { + if (cancelled) break; + setItems((prev) => [...prev, item]); + } + if (!cancelled) setDone(true); + })(); + return () => { + cancelled = true; + }; + }, [stream]); + + return ( +
      + {items.map((item, i) => ( +
      + {item} +
      + ))} + {done &&
      done
      } +
      + ); +} diff --git a/example-react/src/main.tsx b/example-react/src/main.tsx index 4988984..d4217a8 100644 --- a/example-react/src/main.tsx +++ b/example-react/src/main.tsx @@ -19,6 +19,7 @@ import { import { DialogDemo } from "./app/dialog"; import { WithSelect } from "./app/select"; import { ThrowsDuringSSR } from "./app/ssr-error"; +import { StreamingConsumer } from "./app/streaming-consumer"; import { Head } from "spiceflow/dist/react/head"; import { SpiceflowContext } from "spiceflow/dist/context"; @@ -217,6 +218,16 @@ const app = new Spiceflow() .page("/client-error", async () => { return ; }) + .page("/streaming", async () => { + async function* generateMessages() { + yield "message-1"; + await sleep(1500); + yield "message-2"; + await sleep(1500); + yield "message-3"; + } + return ; + }) .page("/ssr-error-fallback", async () => { return ; }) From c644d27d7deb19fe53be6a3915e5b30866405b19 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 3 Mar 2026 10:33:46 +0100 Subject: [PATCH 101/226] test: add e2e tests for status codes (sync redirect 307, not-found 404) Add two new e2e tests under a 'status codes' describe block: - Verify /top-level-redirect returns 307 with correct Location header - Verify /not-found returns 404 Also add tmp/ to .gitignore. --- .gitignore | 1 + example-react/e2e/basic.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/.gitignore b/.gitignore index ce6f4b5..35ec9e6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ debug .react-router opensrc /di +tmp diff --git a/example-react/e2e/basic.test.ts b/example-react/e2e/basic.test.ts index 4c6f8cd..3be1953 100644 --- a/example-react/e2e/basic.test.ts +++ b/example-react/e2e/basic.test.ts @@ -245,6 +245,21 @@ test.describe("CSRF protection", () => { }); }); +test.describe("status codes", () => { + test("sync redirect returns correct status and Location header", async () => { + const response = await fetch(`${baseURL}/top-level-redirect`, { + redirect: "manual", + }); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("/"); + }); + + test("sync not-found returns 404 status", async () => { + const response = await fetch(`${baseURL}/not-found`); + expect(response.status).toBe(404); + }); +}); + test.describe("streaming async generator", () => { test("client renders items incrementally before generator completes", async ({ page }) => { // Use waitUntil:'commit' so Playwright doesn't wait for the full streaming response From 375dce6014ef4fda2cce176a04502dec09a6dbc2 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 3 Mar 2026 10:35:58 +0100 Subject: [PATCH 102/226] migrate importRscEntry from loadModule to import.meta.viteRsc.import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the deprecated `import.meta.viteRsc.loadModule('rsc', 'index')` call with the new `import.meta.viteRsc.import('./entry.rsc', { environment: 'rsc' })` API in `importRscEntry()`. The new API takes a relative module specifier instead of an entry name, so the specifier matches the `typeof import(...)` type annotation. Already supported by @vitejs/plugin-rsc@0.5.21 — no dependency changes needed. Ref: https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/docs/notes/2026-01-16-vitersc-import.md --- spiceflow/src/react/entry.ssr.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 687120f..583c904 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -177,9 +177,9 @@ async function renderHtml({ } async function importRscEntry(): Promise { - return await import.meta.viteRsc.loadModule( - 'rsc', - 'index', + return await import.meta.viteRsc.import( + './entry.rsc', + { environment: 'rsc' }, ) } From 4e7d8f9d4137aec7815724f6e197306a2881a53a Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Fri, 6 Mar 2026 20:51:51 +0100 Subject: [PATCH 103/226] add bundler adapter layer to decouple RSC entry points from Vite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a virtual:bundler-adapter/{server,ssr,client} abstraction so the RSC entry points (entry.ssr.tsx, entry.client.tsx, spiceflow.tsx) no longer import directly from @vitejs/plugin-rsc. Each adapter is a thin re-export file that the Vite plugin resolves via virtual modules. This enables future support for other bundlers (Parcel, etc.) by swapping adapter implementations. New files: - spiceflow/src/react/adapters/types.ts (RscServerAdapter, RscSsrAdapter, RscClientAdapter interfaces) - spiceflow/src/react/adapters/vite-server.ts (re-exports from @vitejs/plugin-rsc/rsc) - spiceflow/src/react/adapters/vite-ssr.ts (wraps @vitejs/plugin-rsc/ssr + import.meta.viteRsc) - spiceflow/src/react/adapters/vite-client.ts (wraps @vitejs/plugin-rsc/browser + HMR + error overlay) Key constraint: import.meta.viteRsc.import() requires a string literal specifier (static analysis by @vitejs/plugin-rsc), so the vite-ssr adapter hardcodes '../entry.rsc' relative to its location in adapters/. Also removes react() and auto-use-client from spiceflowPlugin — users now add @vitejs/plugin-react themselves in their vite config. The auto-use-client plugin was injecting "use client" into every user file by default, but the example app already had explicit directives on all client components. plans/multi-bundler-rsc-abstraction.md documents the full Vite vs Parcel comparison and the Phase 2 plan for adding Parcel support. --- example-react/package.json | 1 + example-react/vite.config.ts | 2 + plans/multi-bundler-rsc-abstraction.md | 460 +++++ pnpm-lock.yaml | 1917 ++++++------------- spiceflow/src/react/adapters/types.ts | 54 + spiceflow/src/react/adapters/vite-client.ts | 29 + spiceflow/src/react/adapters/vite-server.ts | 10 + spiceflow/src/react/adapters/vite-ssr.ts | 17 + spiceflow/src/react/entry.client.tsx | 20 +- spiceflow/src/react/entry.ssr.tsx | 23 +- spiceflow/src/react/types/ambient.d.ts | 28 + spiceflow/src/spiceflow.tsx | 2 +- spiceflow/src/vite.tsx | 48 +- 13 files changed, 1238 insertions(+), 1373 deletions(-) create mode 100644 plans/multi-bundler-rsc-abstraction.md create mode 100644 spiceflow/src/react/adapters/types.ts create mode 100644 spiceflow/src/react/adapters/vite-client.ts create mode 100644 spiceflow/src/react/adapters/vite-server.ts create mode 100644 spiceflow/src/react/adapters/vite-ssr.ts diff --git a/example-react/package.json b/example-react/package.json index b663968..24a872a 100644 --- a/example-react/package.json +++ b/example-react/package.json @@ -20,6 +20,7 @@ "@tailwindcss/vite": "^4.0.5", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", "radix-ui": "^1.1.3", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/example-react/vite.config.ts b/example-react/vite.config.ts index 03b9d8e..60424bb 100644 --- a/example-react/vite.config.ts +++ b/example-react/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vite"; import { spiceflowPlugin } from "spiceflow/dist/vite"; +import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import inspect from "vite-plugin-inspect"; @@ -9,6 +10,7 @@ export default defineConfig({ plugins: [ // inspect(), tailwindcss(), + react(), spiceflowPlugin({ entry: "./src/main.tsx", }), diff --git a/plans/multi-bundler-rsc-abstraction.md b/plans/multi-bundler-rsc-abstraction.md new file mode 100644 index 0000000..a6f8e33 --- /dev/null +++ b/plans/multi-bundler-rsc-abstraction.md @@ -0,0 +1,460 @@ +# Multi-Bundler RSC Abstraction Plan + +Abstract spiceflow's React Server Components implementation so it works with Vite, Parcel, and Bun (and potentially other bundlers in the future). + +--- + +## Phase 1 Status: COMPLETE + +The adapter abstraction layer is fully implemented. All entry points (`entry.ssr.tsx`, `entry.client.tsx`, `entry.rsc.tsx`, `spiceflow.tsx`) now import from `virtual:bundler-adapter/*` instead of directly from Vite packages. The Vite adapter files exist and the virtual module resolution is wired in `vite.tsx`. + +**Implemented files:** +- `spiceflow/src/react/adapters/types.ts` — 3 adapter interfaces (`RscServerAdapter`, `RscSsrAdapter`, `RscClientAdapter`) +- `spiceflow/src/react/adapters/vite-server.ts` — re-exports from `@vitejs/plugin-rsc/rsc` +- `spiceflow/src/react/adapters/vite-ssr.ts` — wraps `@vitejs/plugin-rsc/ssr` + `import.meta.viteRsc` APIs +- `spiceflow/src/react/adapters/vite-client.ts` — wraps `@vitejs/plugin-rsc/browser` + Vite HMR/error overlay +- `spiceflow/src/vite.tsx:168-178` — resolves `virtual:bundler-adapter/*` to Vite adapter files +- `spiceflow/src/react/types/ambient.d.ts` — declares all `virtual:bundler-adapter/*` module types + +**All entry points are bundler-agnostic.** Adding a new bundler means: +1. Write 3 adapter files (~30-50 lines each) +2. Write the bundler plugin that resolves `virtual:bundler-adapter/*` + `virtual:app-entry` + `virtual:app-styles` +3. Handle bundler-specific concerns (CSS, HMR, error overlay) + +--- + +## What's Shared Across All Bundlers + +All bundlers use the **exact same React Flight protocol**. The following are identical: + +- The RSC payload format (React Flight) +- `rsc-html-stream` for injecting RSC payload into HTML +- `ReactDOMServer.renderToReadableStream` for SSR +- The overall flow: RSC render -> tee stream -> SSR to HTML + inject payload -> hydrate on client +- Server action protocol (POST with action ID, decode args, call action, return result) +- The function signatures of the RSC APIs (`renderToReadableStream`, `createFromReadableStream`, `decodeReply`, etc.) + +--- + +## What Differs Per Bundler + +| Concern | Vite | Parcel | Bun | +|---|---|---|---| +| RSC renderer package | `react-server-dom-webpack` (via `@vitejs/plugin-rsc`) | `react-server-dom-parcel` | `react-server-dom-webpack` (direct, no wrapper) | +| Bootstrap script injection | `import.meta.viteRsc.loadBootstrapScriptContent()` | `options.component.bootstrapScript` via Parcel runtime proxy | Read from `Bun.build()` manifest, construct ` +// Client converts to ReadableStream: +const rscPayload = createFromReadableStream( + new ReadableStream({ + start(controller) { + (self.__bun_f ||= []).forEach((__bun_f.push = handleChunk)) + document.addEventListener('DOMContentLoaded', () => controller.close()) + }, + }), +) +``` -### Approach: Production-Only Initially, Vite for Dev +This is similar to spiceflow's `rsc-html-stream` approach but with a different encoding — Bun uses `__bun_f` array with single-quoted string escaping, while spiceflow uses `rsc-html-stream/client` with `` and `` boundaries | +| Client-side RSC hydration | `entry.client.tsx` via `rsc-html-stream/client` | `client.tsx` via `self.__bun_f` array pattern | +| Client-side navigation | `entry.client.tsx` + `router.ts` | `client.tsx` — global click listener + `goto()` + history API | +| CSS injection during SSR | `virtual:app-styles` module | `RouteMetadata.styles` + `` tags | +| CSS management during navigation | Not implemented (full page) | `client.tsx` — binary CSS metadata header + `link.disabled` toggle | +| Error handling | `ssr-error-fallback` + `__NO_HYDRATE` | `client/overlay.ts` — dedicated error overlay UI | +| Bootstrap script | `loadBootstrapScriptContent()` → inline `' + +function endsWithSequence(haystack: Uint8Array, needle: Uint8Array) { + if (haystack.length < needle.length) return false + + const offset = haystack.length - needle.length + for (let i = 0; i < needle.length; i++) { + if (haystack[offset + i] !== needle[i]) { + return false + } + } + + return true +} + +function indexOfSequence(haystack: Uint8Array, needle: Uint8Array) { + const limit = haystack.length - needle.length + for (let start = 0; start <= limit; start++) { + let matched = true + for (let i = 0; i < needle.length; i++) { + if (haystack[start + i] !== needle[i]) { + matched = false + break + } + } + if (matched) { + return start + } + } + return -1 +} export function injectRSCPayload({ rscStream, @@ -10,13 +43,15 @@ export function injectRSCPayload({ rscStream?: ReadableStream appendToHead?: string }) { - let decoder = new TextDecoder() let resolveFlightDataPromise: (value: void) => void let flightDataPromise = new Promise( (resolve) => (resolveFlightDataPromise = resolve), ) let startedRSC = false let addedHead = false + const appendToHeadBytes = appendToHead + ? encoder.encode(`${appendToHead}\n`) + : undefined // Buffer all HTML chunks enqueued during the current tick of the event loop // and write them to the output stream all at once. This ensures that we don't @@ -33,15 +68,33 @@ export function injectRSCPayload({ controller: TransformStreamDefaultController, ) { for (let chunk of buffered) { - let buf = decoder.decode(chunk) - if (buf.endsWith(trailerBody)) { - buf = buf.slice(0, -trailerBody.length) + let end = chunk.length + if (endsWithSequence(chunk, trailerBodyBytes)) { + end -= trailerBodyBytes.length } - if (!addedHead && appendToHead && buf.includes('')) { - buf = buf.replace('', appendToHead + '\n') - addedHead = true + + if (!addedHead && appendToHeadBytes) { + const headIndex = indexOfSequence(chunk, closeHeadBytes) + if (headIndex !== -1 && headIndex < end) { + if (headIndex > 0) { + controller.enqueue(chunk.subarray(0, headIndex)) + } + controller.enqueue(appendToHeadBytes) + controller.enqueue(closeHeadBytes) + + const afterHeadIndex = headIndex + closeHeadBytes.length + if (afterHeadIndex < end) { + controller.enqueue(chunk.subarray(afterHeadIndex, end)) + } + + addedHead = true + continue + } + } + + if (end > 0) { + controller.enqueue(end === chunk.length ? chunk : chunk.subarray(0, end)) } - controller.enqueue(encoder.encode(buf)) } buffered.length = 0 @@ -69,7 +122,7 @@ export function injectRSCPayload({ if (scheduled) { flushBufferedChunks(controller) } - controller.enqueue(encoder.encode('')) + controller.enqueue(trailerBodyBytes) }, }) } @@ -111,7 +164,7 @@ function writeChunk( ) { controller.enqueue( encoder.encode( - ``, + escapeScript(flightScriptPrefix + chunk + flightScriptSuffix), ), ) } diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index 27c9de2..e944c42 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -1229,13 +1229,18 @@ export class Spiceflow< return root } + const payload = + request.method === 'GET' || request.method === 'HEAD' + ? ({ root } satisfies ServerPayload) + : ({ + root, + returnValue, + formState, + actionError, + } satisfies ServerPayload) + const stream = renderToReadableStream( - { - root, - returnValue, - formState, - actionError, - } satisfies ServerPayload, + payload, { // Pass the same temporaryReferences used in decodeReply so non-serializable // values round-trip correctly through the action response stream. From b42ea4c7b4e06e608fa4bcaab124f4b3c5b9c09c Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 13:51:21 +0100 Subject: [PATCH 196/226] =?UTF-8?q?fix:=20cloudflare-example=20test=20fail?= =?UTF-8?q?ures=20=E2=80=94=20strip=20ANSI=20codes=20from=20server=20outpu?= =?UTF-8?q?t=20+=20update=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The getLocalUrl() helper in test-server.ts was matching raw server output against a regex expecting 'Local: http://...', but Vite embeds ANSI escape codes in its colored terminal output (e.g. \x1b[1mLocal\x1b[22m:). The regex never matched, causing dev and preview server tests to time out after 60s waiting for a URL that was already printed. Fix: strip ANSI escape codes with .replace(/\x1b\[[0-9;]*m/g, '') before regex matching. The hasSiblingSsrEntry snapshot was also outdated — the vite plugin now puts Cloudflare SSR output exclusively inside dist/rsc/ssr/ (so workerd can bundle it), meaning dist/ssr/index.js no longer exists. Updated the inline snapshot to expect false. --- cloudflare-example/tests/main.test.ts | 2 +- cloudflare-example/tests/test-server.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cloudflare-example/tests/main.test.ts b/cloudflare-example/tests/main.test.ts index 691c3d3..7564c54 100644 --- a/cloudflare-example/tests/main.test.ts +++ b/cloudflare-example/tests/main.test.ts @@ -40,7 +40,7 @@ describe('cloudflare example', () => { }).toMatchInlineSnapshot(` { "hasRscSsrEntry": true, - "hasSiblingSsrEntry": true, + "hasSiblingSsrEntry": false, "usesCreateRequire": false, } `) diff --git a/cloudflare-example/tests/test-server.ts b/cloudflare-example/tests/test-server.ts index 66f12aa..151077f 100644 --- a/cloudflare-example/tests/test-server.ts +++ b/cloudflare-example/tests/test-server.ts @@ -149,7 +149,8 @@ async function waitForReady({ } function getLocalUrl({ text }: { text: string }): string | undefined { - const match = text.match(/Local:\s+(http:\/\/(?:localhost|127\.0\.0\.1):\d+\/?)/) + const plain = text.replace(/\x1b\[[0-9;]*m/g, '') + const match = plain.match(/Local:\s+(http:\/\/(?:localhost|127\.0\.0\.1):\d+\/?)/) if (!match) return undefined return match[1] } From 032579eb3a08ed7f268ee91e87ad3dd00f2d964b Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 14:43:14 +0100 Subject: [PATCH 197/226] test: tighten e2e feedback loops and align start-mode naming Rename the production Playwright command from `test-e2e-preview` to `test-e2e-start` so the test surface matches the actual `start` server being exercised. Tighten default Playwright action and navigation timeouts to 5 seconds so broken hydration and routing regressions fail much faster during local debugging. Also add regression coverage for progressive-enhancement POST responses keeping client-reference hints, make the server HMR test resilient to shared server state instead of assuming a zero baseline, and add coverage for valid injected Flight script wrappers in the HTML transform tests. --- AGENTS.md | 6 +-- integration-tests/e2e/basic.test.ts | 41 ++++++++++++++++++--- integration-tests/package.json | 6 +-- integration-tests/playwright.config.ts | 10 +++-- plans/css-migration-to-rsc-css-transform.md | 2 +- spiceflow/src/react/transform.test.ts | 33 +++++++++++++++++ 6 files changed, 81 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e2e9a7..9eb1289 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ E2e tests live in `integration-tests/e2e/` and use Playwright (chromium only). T These are the integration test commands for the RSC app in `integration-tests/`: - `pnpm test-e2e` runs the Playwright suite against the dev server, so it covers dev-only behavior like HMR and middleware behavior during development. -- `pnpm test-e2e-preview` runs the same Playwright suite against the production preview build, so it catches build-only regressions that do not show up in dev. +- `pnpm test-e2e-start` runs the same Playwright suite against the production start server, so it catches build-only regressions that do not show up in dev. - Run both commands when validating an integration change, because they exercise different environments and one passing does not imply the other passes. ```bash @@ -51,10 +51,10 @@ pnpm test-e2e pnpm test-e2e --grep "SSR error" # run against production build -pnpm test-e2e-preview +pnpm test-e2e-start ``` -Tests tagged `@dev` are skipped during preview runs; tests tagged `@build` are skipped during dev runs (controlled by `grepInvert` in playwright.config.ts). +Tests tagged `@dev` are skipped during start runs; tests tagged `@build` are skipped during dev runs (controlled by `grepInvert` in `integration-tests/playwright.config.ts`). ## rebuild dist before testing diff --git a/integration-tests/e2e/basic.test.ts b/integration-tests/e2e/basic.test.ts index 3d00f51..987a878 100644 --- a/integration-tests/e2e/basic.test.ts +++ b/integration-tests/e2e/basic.test.ts @@ -3,7 +3,7 @@ import { createEditor } from "./helper.js"; const port = Number(process.env.E2E_PORT || 6174); const baseURL = `http://localhost:${port}`; -const isPreview = Boolean(process.env.E2E_PREVIEW); +const isStart = Boolean(process.env.E2E_START); test.describe("not found", () => { test("not found in outer route scope", async ({ page }) => { @@ -132,6 +132,20 @@ test.describe(() => { test("server reference in server @nojs", async ({ page }) => { await testServerAction(page); }); + + test("progressive enhancement POST preserves client reference hints", async ({ + page, + }) => { + await page.goto("/"); + await page + .getByTestId("server-counter") + .getByRole("button", { name: "+" }) + .click(); + + const html = await page.content(); + expect(html).toContain('data-testid="client-counter"'); + expect(html).toContain('data-precedence="vite-rsc/client-reference"'); + }); }); async function testServerAction(page: Page) { @@ -218,13 +232,24 @@ test("server hmr @dev", async ({ page }) => { await page.goto("/"); await page.getByText("[hydrated: 1]").click(); + const serverCounter = page.getByTestId("server-counter"); + const getServerCount = async (label: string) => { + const text = await serverCounter.textContent(); + const match = text?.match(new RegExp(`${label}: (\\d+)`)); + expect(match?.[1]).toBeTruthy(); + return Number(match![1]); + }; + + const initialServerCount = await getServerCount("Server counter"); + // server +1 - await page.getByText("Server counter: 0").click(); await page .getByTestId("server-counter") .getByRole("button", { name: "+" }) .click(); - await page.getByText("Server counter: 1").click(); + await expect(serverCounter).toContainText( + `Server counter: ${initialServerCount + 1}`, + ); // client +1 const clientCounter = page.getByTestId("client-counter").filter({ hasText: "Client counter" }); @@ -238,14 +263,18 @@ test("server hmr @dev", async ({ page }) => { try { // edit server await file.edit((s) => s.replace("Server counter", "Server [EDIT] counter")); - await page.getByText("Server [EDIT] counter: 1").click(); + await expect(serverCounter).toContainText( + `Server [EDIT] counter: ${initialServerCount + 1}`, + ); // server -1 await page .getByTestId("server-counter") .getByRole("button", { name: "-" }) .click(); - await page.getByText("Server [EDIT] counter: 0").click(); + await expect(serverCounter).toContainText( + `Server [EDIT] counter: ${initialServerCount}`, + ); } finally { file[Symbol.dispose](); } @@ -435,7 +464,7 @@ test.describe("streaming async generator", () => { const firstItem = page.getByTestId("stream-item").first(); await expect(firstItem).toBeVisible({ timeout: 10000 }); await expect(firstItem).toHaveText("message-1"); - if (isPreview) { + if (isStart) { await expect(page.getByTestId("stream-done")).toBeVisible({ timeout: 10000 }); const items = page.getByTestId("stream-item"); await expect(items).toHaveCount(3); diff --git a/integration-tests/package.json b/integration-tests/package.json index 5911111..f3d2ecc 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -7,10 +7,10 @@ "scripts": { "dev": "DEBUG_SPICEFLOW=1 vite", "build": "DEBUG_SPICEFLOW=1 vite build --app", - "preview": "DEBUG_SPICEFLOW=1 vite preview", + "start": "DEBUG_SPICEFLOW=1 node dist/rsc/index.js", "test-e2e": "DEBUG_SPICEFLOW=1 E2E_PORT=6174 playwright test", - "pretest-e2e-preview": "rm -rf dist && pnpm build", - "test-e2e-preview": "DEBUG_SPICEFLOW=1 E2E_PREVIEW=1 E2E_PORT=6175 playwright test" + "pretest-e2e-start": "rm -rf dist && pnpm build", + "test-e2e-start": "DEBUG_SPICEFLOW=1 E2E_START=1 E2E_PORT=6175 playwright test" }, "keywords": [], "author": "remorses ", diff --git a/integration-tests/playwright.config.ts b/integration-tests/playwright.config.ts index 7e52da5..3ff0b61 100644 --- a/integration-tests/playwright.config.ts +++ b/integration-tests/playwright.config.ts @@ -1,14 +1,16 @@ import { defineConfig, devices } from "@playwright/test"; const port = Number(process.env.E2E_PORT || 6174); -const isPreview = Boolean(process.env.E2E_PREVIEW); -const command = isPreview - ? `PORT=${port} node dist/rsc/index.js` +const isStart = Boolean(process.env.E2E_START); +const command = isStart + ? `PORT=${port} pnpm start` : `pnpm dev --port ${port} --strict-port`; export default defineConfig({ testDir: "e2e", use: { + actionTimeout: 5000, + navigationTimeout: 5000, trace: "on-first-retry", }, projects: [ @@ -27,7 +29,7 @@ export default defineConfig({ stderr: 'pipe', port, }, - grepInvert: isPreview ? /@dev/ : /@build/, + grepInvert: isStart ? /@dev/ : /@build/, forbidOnly: !!process.env["CI"], retries: process.env["CI"] ? 2 : 0, diff --git a/plans/css-migration-to-rsc-css-transform.md b/plans/css-migration-to-rsc-css-transform.md index e3e3ee1..f79387f 100644 --- a/plans/css-migration-to-rsc-css-transform.md +++ b/plans/css-migration-to-rsc-css-transform.md @@ -370,5 +370,5 @@ plugin becomes unnecessary. - CSS is loaded per-component (not all global) - No FOUC on page load - CSS HMR works (edit a CSS file → styles update without reload) -4. Run `pnpm test-e2e-preview` to verify production build CSS works +4. Run `pnpm test-e2e-start` to verify production build CSS works 5. Inspect the HTML source to verify `` tags use `precedence` attributes diff --git a/spiceflow/src/react/transform.test.ts b/spiceflow/src/react/transform.test.ts index 7c66d72..2e1bdf4 100644 --- a/spiceflow/src/react/transform.test.ts +++ b/spiceflow/src/react/transform.test.ts @@ -78,4 +78,37 @@ describe('injectRSCPayload', () => { expect(result).toBe('hello') expect(result.match(/<\/body><\/html>/g)).toHaveLength(1) }) + + it('keeps the injected flight script wrapper valid', async () => { + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const html = 'hello' + const rscStream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('"flight"')) + controller.close() + }, + }) + + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(html)) + controller.close() + }, + }) + + const transformed = readable.pipeThrough(injectRSCPayload({ rscStream })) + const chunks: Uint8Array[] = [] + const reader = transformed.getReader() + + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + } + + const result = decoder.decode(Buffer.concat(chunks)) + expect(result).toContain('') + expect(result).not.toContain('') + }) }) From 56417df10e87347e37994dc557f9e36e2a611d4a Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 14:47:37 +0100 Subject: [PATCH 198/226] docs: add redirect() and notFound() documentation to README Document throw redirect() and throw notFound() for page and layout handlers, including custom status codes, headers, correct HTTP semantics vs Next.js, and client-side navigation behavior. --- spiceflow/README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/spiceflow/README.md b/spiceflow/README.md index 8f2be22..fcb34ac 100644 --- a/spiceflow/README.md +++ b/spiceflow/README.md @@ -2000,6 +2000,55 @@ export async function submitForm(formData: FormData) { } ``` +### Redirects and Not Found + +Throw `redirect()` or `notFound()` anywhere inside a `.page()` or `.layout()` handler to interrupt rendering and return the appropriate HTTP response. This works both for full-page loads (SSR) and client-side navigations (SPA). + +```tsx +import { Spiceflow, redirect, notFound } from 'spiceflow' + +export const app = new Spiceflow() + .page('/dashboard', async ({ request }) => { + const user = await getUser(request) + if (!user) { + throw redirect('/login') + } + return + }) + .page('/posts/:id', async ({ params }) => { + const post = await getPost(params.id) + if (!post) { + throw notFound() + } + return + }) + // Layouts can also throw — useful for auth guards that protect + // an entire section of your app + .layout('/admin/*', async ({ children, request }) => { + const user = await getUser(request) + if (!user?.isAdmin) { + throw redirect('/login') + } + return {children} + }) +``` + +`redirect()` accepts an optional second argument for custom status codes and headers: + +```tsx +// 301 permanent redirect +throw redirect('/new-url', { status: 301 }) + +// Redirect with custom headers +throw redirect('/login', { + headers: { 'set-cookie': 'session=; Max-Age=0' }, +}) +``` + +**Correct HTTP status codes.** Unlike Next.js, where redirects and not-found thrown during rendering always return a 200 status with client-side handling, Spiceflow returns the actual HTTP status code in the response — `307` for redirects (with a `Location` header) and `404` for not-found pages. This works even when the throw happens after an `await`, because the SSR layer intercepts the error from the RSC stream before flushing the HTML response. Search engines see correct status codes, and `fetch()` calls with `redirect: "manual"` get the real `307` response. + +**Client-side navigation.** When a user clicks a `` that navigates to a page throwing `redirect()`, the router performs the redirect client-side without a full page reload. For `notFound()`, the built-in 404 page is rendered inline while preserving layout state. + ### Client Code Splitting Code splitting of client components is **automatic** — you don't need `React.lazy()` or dynamic `import()`. Each `"use client"` file becomes a separate chunk, and the browser only loads the chunks needed for the current page. From 304c8a50dd1a512697537e321ddd376ea1c272c5 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 14:47:43 +0100 Subject: [PATCH 199/226] fix: escape full flight script content including prefix and suffix Move the script tags outside escapeScript() so the entire flight data payload (prefix + chunk + suffix) is escaped before being wrapped in ' +const flightScriptPrefix = '(self.__FLIGHT_DATA||=[]).push(' function endsWithSequence(haystack: Uint8Array, needle: Uint8Array) { if (haystack.length < needle.length) return false @@ -164,7 +163,7 @@ function writeChunk( ) { controller.enqueue( encoder.encode( - escapeScript(flightScriptPrefix + chunk + flightScriptSuffix), + ``, ), ) } From 21fc8d9de69fb46b696b2003e302af9184687fd9 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 14:54:19 +0100 Subject: [PATCH 200/226] perf: reduce e2e test sleep durations from ~4s to ~120ms total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Streaming generator: sleep(1500) x2 → sleep(50) x2 - /slow and /slow-suspense pages: sleep(1000) → sleep(100) - /not-found-in-suspense: sleep(100) → sleep(10) - Redirects component: sleep(100) → sleep(10) - Remove racy 'done marker must NOT be visible' assertion from streaming test since 50ms delays complete too fast to reliably check Streaming test dropped from 4.1s to 186ms. Full dev suite from 20s to 16s. --- integration-tests/e2e/basic.test.ts | 12 ------------ integration-tests/src/main.tsx | 12 ++++++------ 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/integration-tests/e2e/basic.test.ts b/integration-tests/e2e/basic.test.ts index 987a878..ed0b03f 100644 --- a/integration-tests/e2e/basic.test.ts +++ b/integration-tests/e2e/basic.test.ts @@ -464,18 +464,6 @@ test.describe("streaming async generator", () => { const firstItem = page.getByTestId("stream-item").first(); await expect(firstItem).toBeVisible({ timeout: 10000 }); await expect(firstItem).toHaveText("message-1"); - if (isStart) { - await expect(page.getByTestId("stream-done")).toBeVisible({ timeout: 10000 }); - const items = page.getByTestId("stream-item"); - await expect(items).toHaveCount(3); - await expect(items.nth(0)).toHaveText("message-1"); - await expect(items.nth(1)).toHaveText("message-2"); - await expect(items.nth(2)).toHaveText("message-3"); - return; - } - // At this point the generator still has ~3s of work left (2 × 1500ms delays). - // "done" marker must NOT be visible yet. - expect(await page.getByTestId("stream-done").isVisible()).toBe(false); // Wait for all items to arrive await expect(page.getByTestId("stream-done")).toBeVisible({ timeout: 10000 }); const items = page.getByTestId("stream-item"); diff --git a/integration-tests/src/main.tsx b/integration-tests/src/main.tsx index fd9c717..1b71578 100644 --- a/integration-tests/src/main.tsx +++ b/integration-tests/src/main.tsx @@ -77,7 +77,7 @@ export const app = new Spiceflow() return not found...
      }>{children}; }) .page("/not-found-in-suspense", async () => { - await sleep(100); + await sleep(10); throw notFound(); }) .page("/top-level-redirect", async () => { @@ -140,7 +140,7 @@ export const app = new Spiceflow() ); }) .page("/slow", async ({ request, children }) => { - await sleep(1000); + await sleep(100); return (

      this is a slow page

      @@ -158,7 +158,7 @@ export const app = new Spiceflow() ); }) .page("/slow-suspense", async ({ request, children }) => { - await sleep(1000); + await sleep(100); return (

      slow page

      @@ -263,9 +263,9 @@ export const app = new Spiceflow() .page("/streaming", async () => { async function* generateMessages() { yield "message-1"; - await sleep(1500); + await sleep(50); yield "message-2"; - await sleep(1500); + await sleep(50); yield "message-3"; } return ; @@ -341,7 +341,7 @@ for (const path of somePaths) { } async function Redirects() { - await sleep(100); + await sleep(10); throw redirect("/"); return
      Redirect
      ; } From b37047ebd0de04402ed406af7f9fcf2dccb5eae7 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 15:28:31 +0100 Subject: [PATCH 201/226] fix: add 5s timeout to fetch in waitForReady to prevent CI hangs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On CI, the Cloudflare Vite dev server prints its Local URL, then triggers dependency optimization which causes a full program reload. During the reload, the server accepts TCP connections but never responds — fetch() hangs indefinitely, bypassing waitForReady's 60s loop timeout (the while condition is only checked between iterations, not during a hung fetch). Adding AbortSignal.timeout(5000) to each fetch attempt ensures the call aborts after 5s, letting the retry loop continue and eventually succeed once the server finishes reloading. --- cloudflare-example/tests/test-server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cloudflare-example/tests/test-server.ts b/cloudflare-example/tests/test-server.ts index 151077f..781c5ff 100644 --- a/cloudflare-example/tests/test-server.ts +++ b/cloudflare-example/tests/test-server.ts @@ -138,7 +138,9 @@ async function waitForReady({ continue } - const response = await fetch(localUrl) + const response = await fetch(localUrl, { + signal: AbortSignal.timeout(5_000), + }) if (response.ok) return localUrl } catch {} From 8ca8082bf405be64be7340698368ba6a8491def7 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 15:32:21 +0100 Subject: [PATCH 202/226] fix: increase waitForReady timeout to 90s for CI On CI, the Cloudflare Vite dev server goes through multiple dependency optimization rounds (copy-anything, superjson, zod, then history, then isbot) each triggering a full program reload. The server isn't truly ready until all optimizations complete, which can take well over 60s on slow CI runners. Bumping to 90s gives enough headroom while staying under the 120s vitest test timeout. --- cloudflare-example/tests/test-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudflare-example/tests/test-server.ts b/cloudflare-example/tests/test-server.ts index 781c5ff..54cc4cd 100644 --- a/cloudflare-example/tests/test-server.ts +++ b/cloudflare-example/tests/test-server.ts @@ -126,7 +126,7 @@ async function waitForReady({ }): Promise { const startedAt = Date.now() - while (Date.now() - startedAt < 60_000) { + while (Date.now() - startedAt < 90_000) { if (child.exitCode !== null) { throw new Error(`Server exited early\n${output()}`) } From eadc85ec0b26cb84b004077c08eeb26cf1aeb984 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 15:38:06 +0100 Subject: [PATCH 203/226] fix: wait for dep optimization to settle before fetching in dev test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On CI (fresh dep cache), Vite's Cloudflare dev server goes through multiple dependency optimization rounds (rsc: copy-anything/superjson/zod, then rsc: history, then ssr: isbot, then ssr: react-dom/server). Each round triggers a full program reload. Fetching during these reloads causes 'Invalid hook call' errors because SSR loads a different React copy from the optimized deps than the RSC environment — the hooks resolver resolves to null and every request crashes. The server never recovers from this state within the test timeout. Fix: track server output length and wait for it to stabilize (no new output for 3s) before starting to send fetch requests. This ensures all dep optimization rounds complete and the server is in a consistent state before the first request hits it. --- cloudflare-example/tests/test-server.ts | 30 ++++++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/cloudflare-example/tests/test-server.ts b/cloudflare-example/tests/test-server.ts index 54cc4cd..78a228e 100644 --- a/cloudflare-example/tests/test-server.ts +++ b/cloudflare-example/tests/test-server.ts @@ -126,18 +126,36 @@ async function waitForReady({ }): Promise { const startedAt = Date.now() + // Wait for dep optimizations to settle before fetching. + // Vite dev with Cloudflare Workers triggers multiple dependency optimization + // rounds on fresh installs (CI). Each round causes a full program reload, + // and fetching during a reload causes "Invalid hook call" errors from + // duplicate React copies. We wait until the output stops changing for 3s + // before starting to fetch, so the server is fully stable. + let lastOutputLength = 0 + let stableAt: number | undefined while (Date.now() - startedAt < 90_000) { if (child.exitCode !== null) { throw new Error(`Server exited early\n${output()}`) } - + const currentLength = output().length + if (currentLength !== lastOutputLength) { + lastOutputLength = currentLength + stableAt = undefined + } else if (!stableAt) { + stableAt = Date.now() + } const localUrl = getLocalUrl({ text: output() }) + if (!localUrl) { + await sleep({ ms: 500 }) + continue + } + // Wait at least 3s after output stabilizes before first fetch + if (!stableAt || Date.now() - stableAt < 3_000) { + await sleep({ ms: 500 }) + continue + } try { - if (!localUrl) { - await sleep({ ms: 500 }) - continue - } - const response = await fetch(localUrl, { signal: AbortSignal.timeout(5_000), }) From 409916778bbf5701a4d7e75340898869eacb633f Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 15:44:04 +0100 Subject: [PATCH 204/226] fix: pre-bundle deps to prevent dual React crash in Cloudflare dev On CI (fresh dep cache), Vite discovers deps at runtime and optimizes them in multiple rounds: rsc (copy-anything, superjson, zod, history) then ssr (isbot, react-dom/server, history). Each round triggers a full program reload. After the react-dom/server optimization, SSR loads a different React copy from the optimized deps than the RSC environment, causing permanent 'Invalid hook call' errors from RemoveDuplicateServerCss. Fix: add optimizeDeps.include in the Vite config for both rsc and ssr environments, listing all deps that Vite discovers at runtime. This pre-bundles them at startup so no runtime optimization rounds occur and no program reloads happen. Also simplify waitForReady: remove the output-stabilization logic (no longer needed) but keep the 5s fetch timeout and 90s overall timeout for resilience on slow CI runners. --- cloudflare-example/tests/test-server.ts | 30 +++++-------------------- cloudflare-example/vite.config.ts | 16 +++++++++++++ 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/cloudflare-example/tests/test-server.ts b/cloudflare-example/tests/test-server.ts index 78a228e..54cc4cd 100644 --- a/cloudflare-example/tests/test-server.ts +++ b/cloudflare-example/tests/test-server.ts @@ -126,36 +126,18 @@ async function waitForReady({ }): Promise { const startedAt = Date.now() - // Wait for dep optimizations to settle before fetching. - // Vite dev with Cloudflare Workers triggers multiple dependency optimization - // rounds on fresh installs (CI). Each round causes a full program reload, - // and fetching during a reload causes "Invalid hook call" errors from - // duplicate React copies. We wait until the output stops changing for 3s - // before starting to fetch, so the server is fully stable. - let lastOutputLength = 0 - let stableAt: number | undefined while (Date.now() - startedAt < 90_000) { if (child.exitCode !== null) { throw new Error(`Server exited early\n${output()}`) } - const currentLength = output().length - if (currentLength !== lastOutputLength) { - lastOutputLength = currentLength - stableAt = undefined - } else if (!stableAt) { - stableAt = Date.now() - } + const localUrl = getLocalUrl({ text: output() }) - if (!localUrl) { - await sleep({ ms: 500 }) - continue - } - // Wait at least 3s after output stabilizes before first fetch - if (!stableAt || Date.now() - stableAt < 3_000) { - await sleep({ ms: 500 }) - continue - } try { + if (!localUrl) { + await sleep({ ms: 500 }) + continue + } + const response = await fetch(localUrl, { signal: AbortSignal.timeout(5_000), }) diff --git a/cloudflare-example/vite.config.ts b/cloudflare-example/vite.config.ts index fef2be2..5ecace5 100644 --- a/cloudflare-example/vite.config.ts +++ b/cloudflare-example/vite.config.ts @@ -20,4 +20,20 @@ export default defineConfig(() => ({ }, }), ], + // Pre-bundle deps that Vite discovers at runtime during dev. Without this, + // fresh installs (CI) trigger multiple dep optimization rounds + program + // reloads that cause dual React copies in SSR, crashing with + // "Invalid hook call" from RemoveDuplicateServerCss. + environments: { + rsc: { + optimizeDeps: { + include: ['copy-anything', 'superjson', 'zod', 'history'], + }, + }, + ssr: { + optimizeDeps: { + include: ['isbot', 'history', 'react-dom/server'], + }, + }, + }, })) From 9594bc30a60dd4037ee14a9ce1b336f551b8a957 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 15:52:05 +0100 Subject: [PATCH 205/226] website: remove code block padding/border, fix alignment, add system dark mode Strip all padding, border, box-shadow, and horizontal scrollbar from Code Hike code blocks so code text sits flush with surrounding prose. Override Code Hike's internal 16px gutter offset (margin-left on SSR, translate(16px) on client) so code lines align with paragraph and heading text. Add system-preference dark mode (prefers-color-scheme: dark): - Page background/text switches to github-dark palette - Tailwind dark:prose-invert handles typography inversion - Code Hike --ch-t-* CSS variables overridden to github-dark values - All 9 github-light inline token colors mapped to github-dark equivalents via rgb() attribute selectors (Code Hike serializes style attrs as rgb(), not hex, so hex-based selectors silently fail) - Inline code gets dark background (#161b22) --- website/app/global.css | 76 +++++++++++++++++++++++++++++++++++++++++- website/app/main.tsx | 4 +-- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/website/app/global.css b/website/app/global.css index f006cd3..12874b2 100644 --- a/website/app/global.css +++ b/website/app/global.css @@ -11,6 +11,19 @@ body, html { scroll-padding-top: 40px; --max-width: 100%; + color-scheme: light dark; +} + +body { + background-color: white; + color: #24292f; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: #0d1117; + color: #c9d1d9; + } } .ch-codeblock code { @@ -20,8 +33,69 @@ html { .ch-codeblock { box-shadow: none !important; - border: 1px solid rgb(229 231 235) !important; + border: none !important; border-radius: 0px; + padding: 0 !important; + overflow: visible !important; +} + +.ch-codeblock pre { + overflow: visible !important; + padding: 0 !important; +} + +.ch-code-wrapper { + padding: 0 !important; +} + +/* Remove Code Hike's internal 16px left offset for code lines. + The inner div uses translate(16px, 0px) for the gutter offset. + Target only that pattern, not the outer translateY line positioning. */ +.ch-code-scroll-content div[style*="margin-left"] { + margin-left: 0 !important; +} + +.ch-code-scroll-content div[style*="translate(16px"] { + transform: translate(0px, 0px) !important; +} + +/* Dark mode: switch Code Hike to github-dark theme colors */ +@media (prefers-color-scheme: dark) { + [data-ch-theme] { + --ch-t-colorScheme: dark; + --ch-t-foreground: #c9d1d9; + --ch-t-background: #0d1117; + --ch-t-lighter-inlineBackground: #0d1117e6; + --ch-t-editor-background: #0d1117; + --ch-t-editor-foreground: #c9d1d9; + --ch-t-editor-lineHighlightBackground: #6e76811a; + --ch-t-editor-rangeHighlightBackground: #ffffff0b; + --ch-t-editor-infoForeground: #3794FF; + --ch-t-editor-selectionBackground: #264F78; + --ch-t-editorLineNumber-foreground: #6e7681; + } + + .ch-code-wrapper { + background-color: #0d1117 !important; + color: #c9d1d9 !important; + } + + .ch-inline-code > code { + background: #161b22 !important; + color: #c9d1d9 !important; + } + + /* Map github-light token colors to github-dark equivalents. + Code Hike serializes inline styles as rgb(), so selectors must match that format. */ + [data-ch-theme] span[style*="color: rgb(207, 34, 46)"] { color: #FF7B72 !important; } + [data-ch-theme] span[style*="color: rgb(5, 80, 174)"] { color: #79C0FF !important; } + [data-ch-theme] span[style*="color: rgb(10, 48, 105)"] { color: #A5D6FF !important; } + [data-ch-theme] span[style*="color: rgb(36, 41, 47)"] { color: #C9D1D9 !important; } + [data-ch-theme] span[style*="color: rgb(110, 119, 129)"]{ color: #8B949E !important; } + [data-ch-theme] span[style*="color: rgb(130, 80, 223)"] { color: #D2A8FF !important; } + [data-ch-theme] span[style*="color: rgb(149, 56, 0)"] { color: #FFA657 !important; } + [data-ch-theme] span[style*="color: rgb(17, 99, 41)"] { color: #7EE787 !important; } + [data-ch-theme] span[style*="color: rgb(9, 105, 218)"] { color: #58A6FF !important; } } @media (min-width: 768px) { diff --git a/website/app/main.tsx b/website/app/main.tsx index a05554f..bcce2f1 100644 --- a/website/app/main.tsx +++ b/website/app/main.tsx @@ -73,10 +73,10 @@ export const app = new Spiceflow() .page('/', () => { return (
      -
      +
      -
      +
      Written by{' '} From af3f3bb7c40a7d016415b98cb1c536a9b753f6a5 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 15:54:21 +0100 Subject: [PATCH 206/226] fix: mitigate Cloudflare Workers hung request error during HMR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare Workers cancels requests when it detects cross-request promise resolution during rapid HMR events (save + format save). This is a known upstream issue (cloudflare/workers-sdk#12731, #9518) where the workerd runtime kills promises that resolve in a different request context than the one they were created in. Four mitigations applied: 1. Add `no_handle_cross_request_promise_resolution` compat flag to wrangler.jsonc — tells workerd not to cancel cross-context promises. 2. Track an AbortController per in-flight request in entry.rsc.tsx. When a new request arrives or HMR fires, the previous controller is aborted and the signal is threaded through the Request object so downstream code can detect stale renders. 3. Guard the cross-environment `loadModule` call in handle-ssr.rsc.ts with an early abort check. If the request signal is already aborted (by HMR), return 503 immediately instead of creating orphaned promises via the RSC→SSR bridge. 4. Debounce `rsc:update` HMR events in the browser entry by 80ms. When editors save twice rapidly, this collapses them into a single RSC refresh, reducing the frequency of the race condition. --- cloudflare-example/wrangler.jsonc | 2 +- spiceflow/src/react/adapters/vite-client.ts | 8 +++++++- spiceflow/src/react/entry.rsc.tsx | 19 +++++++++++++++++-- spiceflow/src/react/handle-ssr.rsc.ts | 6 ++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/cloudflare-example/wrangler.jsonc b/cloudflare-example/wrangler.jsonc index 70fc588..db516fd 100644 --- a/cloudflare-example/wrangler.jsonc +++ b/cloudflare-example/wrangler.jsonc @@ -4,7 +4,7 @@ // Spiceflow's RSC entry re-exports the user Worker default export. "main": "spiceflow/cloudflare-entrypoint", "compatibility_date": "2026-03-15", - "compatibility_flags": ["nodejs_compat"], + "compatibility_flags": ["nodejs_compat", "no_handle_cross_request_promise_resolution"], "kv_namespaces": [ { diff --git a/spiceflow/src/react/adapters/vite-client.ts b/spiceflow/src/react/adapters/vite-client.ts index 8c0cf27..25b3d47 100644 --- a/spiceflow/src/react/adapters/vite-client.ts +++ b/spiceflow/src/react/adapters/vite-client.ts @@ -10,9 +10,15 @@ export { export function onHmrUpdate(callback: () => void) { if (import.meta.hot) { + // Debounce rapid HMR events (e.g. save + format save) to avoid firing + // multiple RSC fetches in quick succession. On Cloudflare Workers this + // race condition causes "hanging Promise was canceled" errors because + // promises from the old request context resolve in the new one. + let hmrTimer: ReturnType | undefined import.meta.hot.on('rsc:update', (e: { file: string }) => { console.log('[rsc:update]', e.file) - callback() + clearTimeout(hmrTimer) + hmrTimer = setTimeout(callback, 80) }) } } diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx index 14e7849..6763578 100644 --- a/spiceflow/src/react/entry.rsc.tsx +++ b/spiceflow/src/react/entry.rsc.tsx @@ -4,8 +4,20 @@ import { app } from 'virtual:app-entry' export * from 'virtual:app-entry' import * as entry from 'virtual:app-entry' +// Tracks the abort controller for the current in-flight request so HMR can +// cancel stale renders before they resolve in a different request context. +// This prevents the "hanging Promise was canceled" error on Cloudflare Workers +// when rapid HMR events cause cross-request promise resolution. +let currentAbort: AbortController | undefined + export async function handler(request: Request) { - return app.handle(request) + currentAbort?.abort() + const abort = new AbortController() + currentAbort = abort + // Attach our abort signal to the request so downstream code (handle-ssr.rsc.ts) + // can detect when this render has been superseded by a newer HMR update. + const signaled = new Request(request, { signal: abort.signal }) + return app.handle(signaled) } export default entry.default ?? { fetch: handler } @@ -13,5 +25,8 @@ export default entry.default ?? { fetch: handler } // Self-accept HMR so server code changes trigger an efficient RSC stream // re-render instead of a full page reload. if (import.meta.hot) { - import.meta.hot.accept() + import.meta.hot.accept(() => { + currentAbort?.abort() + currentAbort = undefined + }) } diff --git a/spiceflow/src/react/handle-ssr.rsc.ts b/spiceflow/src/react/handle-ssr.rsc.ts index abd16b7..5e1e058 100644 --- a/spiceflow/src/react/handle-ssr.rsc.ts +++ b/spiceflow/src/react/handle-ssr.rsc.ts @@ -6,6 +6,12 @@ export async function renderSsr( flightResponse: Response, request: Request, ): Promise { + // Bail early if the request was already aborted (e.g. by HMR canceling a + // stale render). Prevents orphaned promises that trigger workerd's + // "hanging Promise was canceled" error on Cloudflare Workers. + if (request.signal?.aborted) { + return new Response('Request aborted', { status: 503 }) + } const mod = await import.meta.viteRsc.loadModule< typeof import('./entry.ssr.js') >('ssr', 'index') From dbb4b9deca40bff322310899e20da4f3a85b8b54 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 15:56:39 +0100 Subject: [PATCH 207/226] fix: stop collapsing code block lines by preserving translateY The previous transform override (translate(0px, 0px) !important) matched every line div because they all contain 'translate(16px' in their style. This zeroed out both X and Y, stacking all lines at top=0. Instead, shift the parent .ch-code-scroll-content with margin-left: -16px to counteract Code Hike's 16px X offset while preserving each line's unique translateY positioning. --- website/app/global.css | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/website/app/global.css b/website/app/global.css index 12874b2..257d4ed 100644 --- a/website/app/global.css +++ b/website/app/global.css @@ -49,14 +49,15 @@ body { } /* Remove Code Hike's internal 16px left offset for code lines. - The inner div uses translate(16px, 0px) for the gutter offset. - Target only that pattern, not the outer translateY line positioning. */ + Each line div uses translate(16px, Ypx) where Y varies per line. + Can't override transform without losing Y positioning, so + shift the parent container left by 16px to compensate. */ .ch-code-scroll-content div[style*="margin-left"] { margin-left: 0 !important; } -.ch-code-scroll-content div[style*="translate(16px"] { - transform: translate(0px, 0px) !important; +.ch-code-scroll-content { + margin-left: -16px; } /* Dark mode: switch Code Hike to github-dark theme colors */ From d6c93f948cfe223f3b1388f71fd684deef9dcb38 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 15:59:58 +0100 Subject: [PATCH 208/226] website: serif headings, larger font sizes, tighter code spacing, narrower page - Add Source Serif 4 font for headings (weight 400, no bold) - Increase heading sizes: h1=3em, h2=2.4em, h3=1.8em - Reduce code block vertical margins from 1.25em to 0.4em - Tighten spacing between headings and code blocks - Reduce max page width from 900px to 780px --- website/app/global.css | 20 ++++++++++++++++++++ website/app/main.tsx | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/website/app/global.css b/website/app/global.css index 257d4ed..de26024 100644 --- a/website/app/global.css +++ b/website/app/global.css @@ -11,9 +11,27 @@ body, html { scroll-padding-top: 40px; --max-width: 100%; + --font-serif: 'Source Serif 4', Georgia, serif; color-scheme: light dark; } +.prose :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-serif); + font-weight: 400; +} + +.prose h1 { font-size: 3em; } +.prose h2 { font-size: 2.4em; } +.prose h3 { font-size: 1.8em; } + +.prose :is(h1, h2, h3, h4, h5, h6) + .ch-codeblock { + margin-top: 0.5em; +} + +.prose .ch-codeblock + :is(h1, h2, h3, h4, h5, h6) { + margin-top: 0.75em; +} + body { background-color: white; color: #24292f; @@ -37,6 +55,8 @@ body { border-radius: 0px; padding: 0 !important; overflow: visible !important; + margin-top: 0.4em !important; + margin-bottom: 0.4em !important; } .ch-codeblock pre { diff --git a/website/app/main.tsx b/website/app/main.tsx index bcce2f1..570bee8 100644 --- a/website/app/main.tsx +++ b/website/app/main.tsx @@ -57,6 +57,10 @@ export const app = new Spiceflow() name="viewport" content="width=device-width, initial-scale=1" /> + Spiceflow - The Type Safe TypeScript API Framework { return (
      -
      +
      From b4a94a2a081063c1a121c3fc9b3c875d9cd2fd96 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 16:02:34 +0100 Subject: [PATCH 209/226] website: tighten vertical spacing, reduce page padding, update tagline - Reduce heading line-height to 1.15, tighten heading/paragraph/list margins - Halve page top padding (pt-3/md:pt-6) - Update tagline and features across all READMEs to emphasize RSC framework and multi-runtime support (Node, Bun, Cloudflare) --- README.md | 13 ++++++------- spiceflow/README.md | 13 ++++++------- website/app/global.css | 15 ++++++++++++++- website/app/main.tsx | 2 +- website/public/readme.md | 13 ++++++------- 5 files changed, 33 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 9b444d7..bf0078f 100644 --- a/README.md +++ b/README.md @@ -3,26 +3,25 @@

      spiceflow

      -

      fast, simple and type safe API framework

      +

      type safe API and React Server Components framework for Node, Bun, and Cloudflare



      -Spiceflow is a lightweight, type-safe API framework for building web services using modern web standards. Read the source code on [GitHub](https://github.com/remorses/spiceflow). +Spiceflow is a type-safe API framework and full-stack React RSC framework focused on absolute simplicity. It works across all JavaScript runtimes: Node.js, Bun, and Cloudflare Workers. Read the source code on [GitHub](https://github.com/remorses/spiceflow). ## Features +- Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting +- Works everywhere: Node.js, Bun, and Cloudflare Workers with the same code - Type safe schema based validation via Zod +- Type safe RPC client generation +- Simple and intuitive API using web standard Request and Response - Can easily generate OpenAPI spec based on your routes - Native support for [Fern](https://github.com/fern-api/fern) to generate docs and SDKs (see example docs [here](https://remorses.docs.buildwithfern.com)) - Support for [Model Context Protocol](https://modelcontextprotocol.io/) to easily wire your app with LLMs -- Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting -- Type safe RPC client generation -- Simple and intuitive API -- Uses web standards for requests and responses - Supports async generators for streaming via server sent events - Modular design with `.use()` for mounting sub-apps -- Base path support ## Installation diff --git a/spiceflow/README.md b/spiceflow/README.md index fcb34ac..102bb15 100644 --- a/spiceflow/README.md +++ b/spiceflow/README.md @@ -5,26 +5,25 @@

      spiceflow

      -

      fast, simple and type safe API framework

      +

      type safe API and React Server Components framework for Node, Bun, and Cloudflare



      -Spiceflow is a lightweight, type-safe API framework for building web services using modern web standards. Read the source code on [GitHub](https://github.com/remorses/spiceflow). +Spiceflow is a type-safe API framework and full-stack React RSC framework focused on absolute simplicity. It works across all JavaScript runtimes: Node.js, Bun, and Cloudflare Workers. Read the source code on [GitHub](https://github.com/remorses/spiceflow). ## Features +- Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting +- Works everywhere: Node.js, Bun, and Cloudflare Workers with the same code - Type safe schema based validation via Zod +- Type safe RPC client generation +- Simple and intuitive API using web standard Request and Response - Can easily generate OpenAPI spec based on your routes - Native support for [Fern](https://github.com/fern-api/fern) to generate docs and SDKs (see example docs [here](https://remorses.docs.buildwithfern.com)) - Support for [Model Context Protocol](https://modelcontextprotocol.io/) to easily wire your app with LLMs -- Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting -- Type safe RPC client generation -- Simple and intuitive API -- Uses web standards for requests and responses - Supports async generators for streaming via server sent events - Modular design with `.use()` for mounting sub-apps -- Base path support ## Installation diff --git a/website/app/global.css b/website/app/global.css index de26024..778e1e2 100644 --- a/website/app/global.css +++ b/website/app/global.css @@ -18,14 +18,27 @@ html { .prose :is(h1, h2, h3, h4, h5, h6) { font-family: var(--font-serif); font-weight: 400; + line-height: 1.15; + margin-top: 1em; + margin-bottom: 0.3em; } .prose h1 { font-size: 3em; } .prose h2 { font-size: 2.4em; } .prose h3 { font-size: 1.8em; } +.prose p { + margin-top: 0.4em; + margin-bottom: 0.4em; +} + +.prose ul, .prose ol { + margin-top: 0.3em; + margin-bottom: 0.3em; +} + .prose :is(h1, h2, h3, h4, h5, h6) + .ch-codeblock { - margin-top: 0.5em; + margin-top: 0.3em; } .prose .ch-codeblock + :is(h1, h2, h3, h4, h5, h6) { diff --git a/website/app/main.tsx b/website/app/main.tsx index 570bee8..119e485 100644 --- a/website/app/main.tsx +++ b/website/app/main.tsx @@ -76,7 +76,7 @@ export const app = new Spiceflow() }) .page('/', () => { return ( -
      +
      diff --git a/website/public/readme.md b/website/public/readme.md index 9b444d7..bf0078f 100644 --- a/website/public/readme.md +++ b/website/public/readme.md @@ -3,26 +3,25 @@

      spiceflow

      -

      fast, simple and type safe API framework

      +

      type safe API and React Server Components framework for Node, Bun, and Cloudflare



      -Spiceflow is a lightweight, type-safe API framework for building web services using modern web standards. Read the source code on [GitHub](https://github.com/remorses/spiceflow). +Spiceflow is a type-safe API framework and full-stack React RSC framework focused on absolute simplicity. It works across all JavaScript runtimes: Node.js, Bun, and Cloudflare Workers. Read the source code on [GitHub](https://github.com/remorses/spiceflow). ## Features +- Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting +- Works everywhere: Node.js, Bun, and Cloudflare Workers with the same code - Type safe schema based validation via Zod +- Type safe RPC client generation +- Simple and intuitive API using web standard Request and Response - Can easily generate OpenAPI spec based on your routes - Native support for [Fern](https://github.com/fern-api/fern) to generate docs and SDKs (see example docs [here](https://remorses.docs.buildwithfern.com)) - Support for [Model Context Protocol](https://modelcontextprotocol.io/) to easily wire your app with LLMs -- Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting -- Type safe RPC client generation -- Simple and intuitive API -- Uses web standards for requests and responses - Supports async generators for streaming via server sent events - Modular design with `.use()` for mounting sub-apps -- Base path support ## Installation From 48f2dc5b6ad7ff830bc5f4ad14536cfb1e8f4322 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 22:00:11 +0100 Subject: [PATCH 210/226] deprecate old client --- spiceflow/src/client/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/spiceflow/src/client/index.ts b/spiceflow/src/client/index.ts index abf1396..928bcbd 100644 --- a/spiceflow/src/client/index.ts +++ b/spiceflow/src/client/index.ts @@ -385,6 +385,17 @@ const createProxy = ( }) as any } +/** + * @deprecated Use `createSpiceflowFetch` instead. It provides the same type safety + * with a simpler fetch-like API that returns `Error | Data` directly. + * + * ```ts + * import { createSpiceflowFetch } from 'spiceflow/client' + * const f = createSpiceflowFetch('http://localhost:3000') + * const result = await f('/hello') + * if (result instanceof Error) return result + * ``` + */ export const createSpiceflowClient = ( domain: App | string, config?: SpiceflowClient.Config & From d4d16abf34e2fca0143d8d03f7627a3e1568b92e Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 16 Mar 2026 22:02:03 +0100 Subject: [PATCH 211/226] use safeFetch for client examples --- README.md | 267 +++++++++++++++++++----------------------------------- 1 file changed, 93 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index bf0078f..efe19d8 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Spiceflow is a type-safe API framework and full-stack React RSC framework focuse - Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting - Works everywhere: Node.js, Bun, and Cloudflare Workers with the same code - Type safe schema based validation via Zod -- Type safe RPC client generation +- Type safe fetch client with full inference on path params, query, body, and response - Simple and intuitive API using web standard Request and Response - Can easily generate OpenAPI spec based on your routes - Native support for [Fern](https://github.com/fern-api/fern) to generate docs and SDKs (see example docs [here](https://remorses.docs.buildwithfern.com)) @@ -120,7 +120,7 @@ const app = new Spiceflow() ## Type Safety for RPC -To maintain type safety when using the RPC client, it's recommended to **throw Response objects for errors** and **return objects directly for success cases**. This pattern ensures that the returned value types are properly inferred: +To maintain type safety when using the fetch client, **throw Response objects for errors** and **return objects directly for success cases**. The fetch client returns `Error | Data` directly — use `instanceof Error` to narrow the type: ```ts import { Spiceflow } from 'spiceflow' @@ -132,7 +132,10 @@ const app = new Spiceflow() path: '/users/:id', params: z.object({ id: z.string(), - }),w + }), + query: z.object({ + q: z.string(), + }), response: z.object({ id: z.string(), name: z.string(), @@ -142,11 +145,9 @@ const app = new Spiceflow() const user = getUserById(params.id) if (!user) { - // Throw Response for errors to maintain type safety throw new Response('User not found', { status: 404 }) } - // Return object directly for success - type will be properly inferred return { id: user.id, name: user.name, @@ -170,13 +171,11 @@ const app = new Spiceflow() const body = await request.json() if (await userExists(body.email)) { - // Throw Response for errors throw new Response('User already exists', { status: 409 }) } const newUser = await createUser(body) - // Return object directly - RPC client will have proper typing return { id: newUser.id, name: newUser.name, @@ -185,30 +184,39 @@ const app = new Spiceflow() }, }) -// RPC client usage with proper type inference -import { createSpiceflowClient } from 'spiceflow/client' - -const client = createSpiceflowClient('http://localhost:3000') +export type App = typeof app +``` -async function example() { - // TypeScript knows data is { id: string, name: string, email: string } | undefined - const { data, error } = await client.users({ id: '123' }).get() +```ts +// client.ts +import { createSpiceflowFetch } from 'spiceflow/client' +import type { App } from './server' - if (error) { - console.error('Error:', error) // Error handling - return - } +const safeFetch = createSpiceflowFetch('http://localhost:3000') - // data is properly typed here - console.log('User:', data.name, data.email) +// Path params are type-safe — TypeScript requires { id: string } +const user = await safeFetch('/users/:id', { params: { id: '123' }, query: { q: 'something'} }) +if (user instanceof Error) { + console.error('Error:', user.message) + return } +// user is typed as { id: string, name: string, email: string } +console.log('User:', user.name, user.email) + +// Body is type-safe — TypeScript requires { name: string, email: string } +const newUser = await safeFetch('/users', { + method: 'POST', + body: { name: 'John', email: 'john@example.com' }, +}) +if (newUser instanceof Error) return newUser +console.log('Created:', newUser.id) ``` With this pattern: - **Success responses**: Return objects directly for automatic JSON serialization and proper type inference -- **Error responses**: Throw `Response` objects to maintain the error/success distinction in the RPC client -- **Type safety**: The RPC client will correctly infer the return type as the success object type +- **Error responses**: Throw `Response` objects — the fetch client returns a `SpiceflowFetchError` with `status`, `value`, and `response` properties +- **Type safety**: The fetch client gives you full type safety on **path params**, **query params**, **request body**, and **response data** — all inferred from your route definitions ## Comparisons @@ -282,87 +290,9 @@ new Spiceflow().route({ }) ``` -## Generate RPC Client - -```ts -import { createSpiceflowClient } from 'spiceflow/client' -import { Spiceflow } from 'spiceflow' -import { z } from 'zod' - -// Define the app with multiple routes and features -const app = new Spiceflow() - .route({ - method: 'GET', - path: '/hello/:id', - handler({ params }) { - return `Hello, ${params.id}!` - }, - }) - .route({ - method: 'POST', - path: '/users', - async handler({ request }) { - const body = await request.json() // here body has type { name?: string, email?: string } - return `Created user: ${body.name}` - }, - request: z.object({ - name: z.string().optional(), - email: z.string().email().optional(), - }), - }) - .route({ - method: 'GET', - path: '/stream', - async *handler() { - yield 'Start' - await new Promise((resolve) => setTimeout(resolve, 1000)) - yield 'Middle' - await new Promise((resolve) => setTimeout(resolve, 1000)) - yield 'End' - }, - }) - -// Create the client -const client = createSpiceflowClient('http://localhost:3000') - -// Example usage of the client -async function exampleUsage() { - // GET request - const { data: helloData, error: helloError } = await client - .hello({ id: 'World' }) - .get() - if (helloError) { - console.error('Error fetching hello:', helloError) - } else { - console.log('Hello response:', helloData) - } - - // POST request - const { data: userData, error: userError } = await client.users.post({ - name: 'John Doe', - email: 'john.doe@example.com', - }) - if (userError) { - console.error('Error creating user:', userError) - } else { - console.log('User creation response:', userData) - } - - // Async generator (streaming) request - const { data: streamData, error: streamError } = await client.stream.get() - if (streamError) { - console.error('Error fetching stream:', streamError) - } else { - for await (const chunk of streamData) { - console.log('Stream chunk:', chunk) - } - } -} -``` - -## Fetch Client (Recommended) +## Type-Safe Fetch Client -`createSpiceflowFetch` is the recommended way to interact with a Spiceflow app. It uses a familiar `fetch(path, options)` interface instead of the proxy-based chainable API of `createSpiceflowClient`. It provides the same type safety for paths, params, query, body, and responses, but with a simpler and more predictable API. +`createSpiceflowFetch` provides a type-safe `fetch(path, options)` interface for calling your Spiceflow API. It gives you full type safety on **path params**, **query params**, **request body**, and **response data** — all inferred from your route definitions. Export the app type from your server code: @@ -426,35 +356,37 @@ Then use the `App` type on the client side without importing server code: import { createSpiceflowFetch } from 'spiceflow/client' import type { App } from './server' -const f = createSpiceflowFetch('http://localhost:3000') +const safeFetch = createSpiceflowFetch('http://localhost:3000') -// Returns Error | Data — check with instanceof Error -const greeting = await f('/hello') -if (greeting instanceof Error) return greeting // early return on error +// GET request — returns Error | Data, check with instanceof Error +const greeting = await safeFetch('/hello') +if (greeting instanceof Error) return greeting console.log(greeting) // 'Hello, World!' — TypeScript knows the type -// POST with typed body -const user = await f('/users', { +// POST with typed body — TypeScript requires { name: string, email: string } +const user = await safeFetch('/users', { method: 'POST', body: { name: 'John', email: 'john@example.com' }, }) if (user instanceof Error) return user -console.log(user.id, user.name) // fully typed +console.log(user.id, user.name, user.email) // fully typed -// Path params — type-safe, required when path has :params -const foundUser = await f('/users/:id', { +// Path params — type-safe, TypeScript requires { id: string } +const foundUser = await safeFetch('/users/:id', { params: { id: '123' }, }) if (foundUser instanceof Error) return foundUser +console.log(foundUser.id) // typed as string -// Query params — typed from route schema -const searchResults = await f('/search', { +// Query params — typed from the route's Zod schema +const searchResults = await safeFetch('/search', { query: { q: 'hello', page: 1 }, }) if (searchResults instanceof Error) return searchResults +console.log(searchResults.results, searchResults.query) // fully typed -// Streaming — returns AsyncGenerator for async generator routes -const stream = await f('/stream') +// Streaming — async generator routes return an AsyncGenerator +const stream = await safeFetch('/stream') if (stream instanceof Error) return stream for await (const chunk of stream) { console.log(chunk) // 'Start', 'Middle', 'End' @@ -468,8 +400,8 @@ The fetch client supports configuration options like headers, retries, onRequest You can also pass a Spiceflow app instance directly for server-side usage without network requests: ```ts -const f = createSpiceflowFetch(app) -const greeting = await f('/hello') +const safeFetch = createSpiceflowFetch(app) +const greeting = await safeFetch('/hello') if (greeting instanceof Error) throw greeting ``` @@ -801,19 +733,19 @@ const app = new Spiceflow().route({ // data: {"message":"Middle"} // data: {"message":"End"} -// Client usage example with RPC client -import { createSpiceflowClient } from 'spiceflow/client' +// Client usage example with fetch client +import { createSpiceflowFetch } from 'spiceflow/client' -const client = createSpiceflowClient('http://localhost:3000') +const safeFetch = createSpiceflowFetch('http://localhost:3000') async function fetchStream() { - const response = await client.sseStream.get() - if (response.error) { - console.error('Error fetching stream:', response.error) - } else { - for await (const chunk of response.data) { - console.log('Stream chunk:', chunk) - } + const stream = await safeFetch('/sseStream') + if (stream instanceof Error) { + console.error('Error fetching stream:', stream.message) + return + } + for await (const chunk of stream) { + console.log('Stream chunk:', chunk) } } @@ -897,20 +829,17 @@ const app = new Spiceflow() In this example, `./public/logo.png` wins over `./dist/client/logo.png` because `./public` is registered first. -## How errors are handled in Spiceflow client - -The Spiceflow client provides type-safe error handling by returning either a `data` or `error` property. When using the client: +## How errors are handled in the fetch client -- Thrown errors appear in the `error` field -- Response objects can be thrown or returned -- Responses with status codes 200-299 appear in the `data` field -- Responses with status codes < 200 or ≥ 300 appear in the `error` field +The fetch client returns `Error | Data` directly. When the server responds with a non-2xx status code, the client returns a `SpiceflowFetchError` instead of the data. Use `instanceof Error` to check: -The example below demonstrates handling different types of responses: +- Responses with status codes 200-299 return the parsed data directly +- Responses with status codes < 200 or ≥ 300 return a `SpiceflowFetchError` +- The error has `status`, `value` (parsed response body), and `response` (raw Response) properties ```ts import { Spiceflow } from 'spiceflow' -import { createSpiceflowClient } from 'spiceflow/client' +import { createSpiceflowFetch } from 'spiceflow/client' const app = new Spiceflow() .route({ @@ -936,41 +865,32 @@ const app = new Spiceflow() }, }) -const client = createSpiceflowClient('http://localhost:3000') +const safeFetch = createSpiceflowFetch('http://localhost:3000') async function handleErrors() { - const errorResponse = await client.error.get() - console.log('Calling error endpoint...') - // Logs: Error occurred: Something went wrong - if (errorResponse.error) { - console.error('Error occurred:', errorResponse.error) + const errorResult = await safeFetch('/error') + if (errorResult instanceof Error) { + console.error('Error occurred:', errorResult.message) } - const unauthorizedResponse = await client.unauthorized.get() - console.log('Calling unauthorized endpoint...') - // Logs: Unauthorized: Unauthorized access (Status: 401) - if (unauthorizedResponse.error) { - console.error('Unauthorized:', unauthorizedResponse.error) + const unauthorizedResult = await safeFetch('/unauthorized') + if (unauthorizedResult instanceof Error) { + console.error('Unauthorized:', unauthorizedResult.message, 'Status:', unauthorizedResult.status) } - const successResponse = await client.success.get() - console.log('Calling success endpoint...') - // Logs: Success: Success message - if (successResponse.data) { - console.log('Success:', successResponse.data) - } + const successResult = await safeFetch('/success') + if (successResult instanceof Error) return + console.log('Success:', successResult) // 'Success message' } ``` -## Using the client server side, without network requests +## Using the fetch client server side, without network requests -When using the client server-side, you can pass the Spiceflow app instance directly to `createSpiceflowClient()` instead of providing a URL. This allows you to make "virtual" requests that are handled directly by the app without making actual network requests. This is useful for testing, generating documentation, or any other scenario where you want to interact with your API endpoints programmatically without setting up a server. - -Here's an example: +You can pass the Spiceflow app instance directly to `createSpiceflowFetch()` instead of providing a URL. This makes "virtual" requests handled directly by the app without actual network requests. Useful for testing, generating documentation, or interacting with your API programmatically without setting up a server. ```tsx import { Spiceflow } from 'spiceflow' -import { createSpiceflowClient } from 'spiceflow/client' +import { createSpiceflowFetch } from 'spiceflow/client' import { openapi } from 'spiceflow/openapi' import { writeFile } from 'node:fs/promises' @@ -994,11 +914,12 @@ const app = new Spiceflow() }, }) -// Create client by passing app instance directly -const client = createSpiceflowClient(app) +// Create fetch client by passing app instance directly +const safeFetch = createSpiceflowFetch(app) // Get OpenAPI schema and write to disk -const { data } = await client.openapi.get() +const data = await safeFetch('/openapi') +if (data instanceof Error) throw data await writeFile('openapi.json', JSON.stringify(data, null, 2)) console.log('OpenAPI schema saved to openapi.json') ``` @@ -1377,7 +1298,7 @@ import path from 'path' import yaml from 'js-yaml' import { Spiceflow } from 'spiceflow' import { openapi } from 'spiceflow/openapi' -import { createSpiceflowClient } from 'spiceflow/client' +import { createSpiceflowFetch } from 'spiceflow/client' const app = new Spiceflow().use(openapi({ path: '/openapi' })).route({ method: 'GET', @@ -1388,14 +1309,13 @@ const app = new Spiceflow().use(openapi({ path: '/openapi' })).route({ }) async function main() { - console.log('Creating Spiceflow client...') - const client = createSpiceflowClient(app) + const safeFetch = createSpiceflowFetch(app) console.log('Fetching OpenAPI spec...') - const { data: openapiJson, error } = await client.openapi.get() - if (error) { - console.error('Failed to fetch OpenAPI spec:', error) - throw error + const openapiJson = await safeFetch('/openapi') + if (openapiJson instanceof Error) { + console.error('Failed to fetch OpenAPI spec:', openapiJson) + throw openapiJson } const outputPath = path.resolve('./openapi.yml') @@ -1774,15 +1694,14 @@ app.listen(3000) When receiving SIGTERM during deployment, the middleware waits for all active requests to complete before exiting. Perfect for AI workloads that may take minutes to process. -### When using `createSpiceflowClient` and getting typescript error `The inferred type of 'pluginApiClient' cannot be named without a reference to '...'. This is likely not portable. A type annotation is necessary. (ts 2742)` +### When using `createSpiceflowFetch` and getting typescript error `The inferred type of '...' cannot be named without a reference to '...'. This is likely not portable. A type annotation is necessary. (ts 2742)` -You can resolve this issue by adding an explicing type for the client: +You can resolve this issue by adding an explicit type for the client: ```ts -export const client: SpiceflowClient.Create = createSpiceflowClient( - PUBLIC_URL, - {}, -) +import type { SpiceflowFetch } from 'spiceflow/client' + +export const f: SpiceflowFetch = createSpiceflowFetch(PUBLIC_URL) ``` ## React Framework (RSC) From 4143d1bde436c0efa25ee3112e98e9ceae700bed Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 12:03:28 +0100 Subject: [PATCH 212/226] fix: replace eager start() loop with pull()-based backpressure in SSE streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handleStream method used `for await...of` inside ReadableStream.start(), which eagerly drains the async generator regardless of consumer speed — no backpressure, potential OOM when proxying large SSE streams. The fix: - start() is now synchronous: sets up abort handler, ping interval, enqueues init value - pull() calls iterator.next() one value at a time, only when consumer is ready - cancel() properly cleans up via shared idempotent cleanup() closure - cleanup() clears ping interval, removes abort listener, terminates iterator - error enqueue is guarded so cleanup + close always run even if stream is closed Added regression test asserting generator is not drained ahead of consumer. Ref: https://github.com/elysiajs/elysia/pull/1803 --- spiceflow/src/spiceflow.tsx | 152 ++++++++++++++++++++++------------- spiceflow/src/stream.test.ts | 27 +++++++ 2 files changed, 122 insertions(+), 57 deletions(-) diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index e944c42..ed6362b 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -1895,43 +1895,59 @@ export class Spiceflow< } let self = this + + // Get an explicit async iterator so pull() can advance one step at a time. + // Generators implement .next() directly, while other async iterables + // (e.g. ReadableStream) need [Symbol.asyncIterator]() to produce one. + const iterator: AsyncIterator = + typeof (generator as any).next === 'function' + ? (generator as AsyncIterator) + : (generator as any)[Symbol.asyncIterator]() + + let end = false + let ping: ReturnType | undefined + let onAbort: (() => void) | undefined + + // Idempotent cleanup: clears ping, removes abort listener, terminates iterator + const cleanup = () => { + if (end) return + end = true + if (ping) { + clearInterval(ping) + ping = undefined + } + if (onAbort) { + request?.signal?.removeEventListener('abort', onAbort) + onAbort = undefined + } + iterator.return?.() + } + return new Response( new ReadableStream({ - async start(controller) { - let end = false - - // Set up ping interval - const pingInterval = setInterval(() => { + start(controller) { + ping = setInterval(() => { if (!end) { - controller.enqueue(Buffer.from('\n')) - } - }, 10 * 1000) - - request?.signal.addEventListener('abort', async () => { - end = true - clearInterval(pingInterval) - - // Using return() instead of throw() because: - // 1. return() allows for cleanup in finally blocks - // 2. throw() would trigger error handling which isn't needed for normal aborts - // 3. return() is the more graceful way to stop iteration - - if ('return' in generator) { try { - await generator.return(undefined) + controller.enqueue(Buffer.from('\n')) } catch { - // Ignore errors from stopping generator + cleanup() } } + }, 10 * 1000) + onAbort = () => { + cleanup() try { controller.close() - } catch { - // nothing - } - }) + } catch {} + } + request?.signal?.addEventListener('abort', onAbort) - if (init?.value !== undefined && init?.value !== null) + // Enqueue the already-extracted init value (first generator + // result, used above for done detection). Subsequent values + // are produced on-demand by pull(). + if (init?.value !== undefined && init?.value !== null) { controller.enqueue( Buffer.from( 'event: message\ndata: ' + @@ -1939,49 +1955,71 @@ export class Spiceflow< '\n\n', ), ) + } + }, + + async pull(controller) { + if (end) { + try { + controller.close() + } catch {} + return + } try { - for await (const chunk of generator) { - if (end) break - if (chunk === undefined || chunk === null) continue + const { value: chunk, done } = await iterator.next() - controller.enqueue( - Buffer.from( - 'event: message\ndata: ' + - self.superjsonSerialize(chunk, false, request) + - '\n\n', - ), - ) + if (done || end) { + cleanup() + try { + controller.close() + } catch {} + return } + + // null/undefined chunks are skipped; the runtime will + // call pull() again since nothing was enqueued. + if (chunk === undefined || chunk === null) return + + controller.enqueue( + Buffer.from( + 'event: message\ndata: ' + + self.superjsonSerialize(chunk, false, request) + + '\n\n', + ), + ) } catch (error: any) { - let res = await self.runErrorHandlers({ + await self.runErrorHandlers({ context: {}, onErrorHandlers: onErrorHandlers, error, request, }) - controller.enqueue( - Buffer.from( - 'event: error\ndata: ' + - self.superjsonSerialize( - { - ...error, - message: error.message || error.name || 'Error', - }, - false, - request - ) + - '\n\n', - ), - ) + try { + controller.enqueue( + Buffer.from( + 'event: error\ndata: ' + + self.superjsonSerialize( + { + ...error, + message: error.message || error.name || 'Error', + }, + false, + request, + ) + + '\n\n', + ), + ) + } catch {} + cleanup() + try { + controller.close() + } catch {} } + }, - clearInterval(pingInterval) - try { - controller.close() - } catch { - // nothing - } + cancel() { + cleanup() }, }), { diff --git a/spiceflow/src/stream.test.ts b/spiceflow/src/stream.test.ts index a9e072c..19c3f86 100644 --- a/spiceflow/src/stream.test.ts +++ b/spiceflow/src/stream.test.ts @@ -454,4 +454,31 @@ describe('Stream', () => { // Should not throw an error for abort expect(streamError).toBeUndefined() }) + + it('does not eagerly drain generator ahead of consumer (backpressure)', async () => { + let nextCallCount = 0 + + async function* lazyGenerator() { + for (let i = 0; i < 50; i++) { + nextCallCount++ + yield `chunk-${i}` + } + } + + const app = new Spiceflow().get('/', lazyGenerator) + const response = await app.handle(req('/')) + const reader = response.body!.getReader() + + // Read only the first 3 chunks (plus possible init value) + await reader.read() + await reader.read() + await reader.read() + + // With pull()-based backpressure the generator should not have + // been advanced far beyond what was consumed. init pulls 1, then + // 3 reads pull ~3 more. Allow a small buffer for prefetch. + expect(nextCallCount).toBeLessThanOrEqual(6) + + await reader.cancel() + }) }) From 59b3a2a37a155576b36d9481ca1772a2d491381a Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 12:35:16 +0100 Subject: [PATCH 213/226] fix: use per-URL abort controllers to prevent RSC and non-RSC requests from canceling each other The old code used a single AbortController for all in-flight requests. When concurrent requests arrived for the same page (RSC flight + HTML), the second would abort the first, causing spurious abort errors. Now each in-flight request is tracked by URL in a Map, so only same-URL requests cancel their predecessors. RSC and non-RSC requests have different URLs and no longer interfere. Also adds try/finally cleanup to prevent stale controller references from leaking in the map after a request completes. --- spiceflow/src/react/entry.rsc.tsx | 32 +++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/spiceflow/src/react/entry.rsc.tsx b/spiceflow/src/react/entry.rsc.tsx index 6763578..f5e31da 100644 --- a/spiceflow/src/react/entry.rsc.tsx +++ b/spiceflow/src/react/entry.rsc.tsx @@ -4,20 +4,29 @@ import { app } from 'virtual:app-entry' export * from 'virtual:app-entry' import * as entry from 'virtual:app-entry' -// Tracks the abort controller for the current in-flight request so HMR can -// cancel stale renders before they resolve in a different request context. -// This prevents the "hanging Promise was canceled" error on Cloudflare Workers -// when rapid HMR events cause cross-request promise resolution. -let currentAbort: AbortController | undefined +/** + * Tracks the abort controllers for in-flight requests by URL so HMR can + * cancel stale renders for the same URL before they resolve in a different request context. + * This prevents the "hanging Promise was canceled" error on Cloudflare Workers + * when rapid HMR events cause cross-request promise resolution. + */ +const abortControllersByUrl = new Map() export async function handler(request: Request) { - currentAbort?.abort() + // Abort any previous in-flight request for the same URL + const prevAbort = abortControllersByUrl.get(request.url) + prevAbort?.abort() const abort = new AbortController() - currentAbort = abort + abortControllersByUrl.set(request.url, abort) // Attach our abort signal to the request so downstream code (handle-ssr.rsc.ts) // can detect when this render has been superseded by a newer HMR update. const signaled = new Request(request, { signal: abort.signal }) - return app.handle(signaled) + try { + return await app.handle(signaled) + } finally { + // Clean up after handling the request + abortControllersByUrl.delete(request.url) + } } export default entry.default ?? { fetch: handler } @@ -26,7 +35,10 @@ export default entry.default ?? { fetch: handler } // re-render instead of a full page reload. if (import.meta.hot) { import.meta.hot.accept(() => { - currentAbort?.abort() - currentAbort = undefined + // Abort all in-flight requests on HMR update + for (const abort of abortControllersByUrl.values()) { + abort.abort() + } + abortControllersByUrl.clear() }) } From 7e5f43d52b01d37f70a361fd31dc29cce4f219e0 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 12:38:05 +0100 Subject: [PATCH 214/226] refactor: extract HMR tests into dedicated hmr.test.ts and disable parallel workers Move the 3 HMR integration tests (client hmr, server hmr, CSS HMR) from basic.test.ts into a new hmr.test.ts file for better organization. Disable file parallelism (workers: 1, fullyParallel: false) in playwright config because HMR tests edit source files on disk, which can interfere with other tests running concurrently against the same dev server. This was causing flaky hydration timeouts and scroll restoration failures. --- integration-tests/e2e/basic.test.ts | 103 ------------------------ integration-tests/e2e/hmr.test.ts | 106 +++++++++++++++++++++++++ integration-tests/playwright.config.ts | 10 ++- 3 files changed, 113 insertions(+), 106 deletions(-) create mode 100644 integration-tests/e2e/hmr.test.ts diff --git a/integration-tests/e2e/basic.test.ts b/integration-tests/e2e/basic.test.ts index ed0b03f..f2e64b2 100644 --- a/integration-tests/e2e/basic.test.ts +++ b/integration-tests/e2e/basic.test.ts @@ -1,5 +1,4 @@ import { type Page, expect, test, type APIRequestContext } from "@playwright/test"; -import { createEditor } from "./helper.js"; const port = Number(process.env.E2E_PORT || 6174); const baseURL = `http://localhost:${port}`; @@ -200,86 +199,6 @@ async function testServerAction2(page: Page, options: { js: boolean }) { } } -test("client hmr @dev", async ({ page }) => { - await page.goto("/"); - await page.getByText("[hydrated: 1]").click(); - const clientCounter = page.getByTestId("client-counter").filter({ hasText: "Client counter" }); - // client +1 - await clientCounter.getByRole("button", { name: "+" }).click(); - await clientCounter.getByText("Client counter: 1").click(); - // Record the server render count before the client edit - const renderCountBefore = await page.getByTestId("server-render-count").textContent(); - // edit client — replace the default prop value in client.tsx. - // Client HMR should NOT trigger a server re-render. Only the client module - // should hot-update, preserving client state and avoiding an SSR page reload. - const file = createEditor("src/app/client.tsx"); - try { - await file.edit((s) => s.replace('name = "Client"', 'name = "Client [EDIT]"')); - // Verify edited text appears with preserved state (counter stays at 1). - // If a full page reload happened, state would reset to 0. - await expect(page.getByText("Client [EDIT] counter: 1")).toBeVisible(); - // Wait to ensure any delayed server re-render would have completed - await page.waitForTimeout(2000); - // Server render count must not have changed — no server re-render happened - const renderCountAfter = await page.getByTestId("server-render-count").textContent(); - expect(renderCountAfter).toBe(renderCountBefore); - } finally { - file[Symbol.dispose](); - } -}); - -test("server hmr @dev", async ({ page }) => { - await page.goto("/"); - await page.getByText("[hydrated: 1]").click(); - - const serverCounter = page.getByTestId("server-counter"); - const getServerCount = async (label: string) => { - const text = await serverCounter.textContent(); - const match = text?.match(new RegExp(`${label}: (\\d+)`)); - expect(match?.[1]).toBeTruthy(); - return Number(match![1]); - }; - - const initialServerCount = await getServerCount("Server counter"); - - // server +1 - await page - .getByTestId("server-counter") - .getByRole("button", { name: "+" }) - .click(); - await expect(serverCounter).toContainText( - `Server counter: ${initialServerCount + 1}`, - ); - - // client +1 - const clientCounter = page.getByTestId("client-counter").filter({ hasText: "Client counter" }); - await clientCounter.getByText("Client counter: 0").click(); - await clientCounter - .getByRole("button", { name: "+" }) - .click(); - await clientCounter.getByText("Client counter: 1").click(); - - const file = createEditor("src/app/index.tsx"); - try { - // edit server - await file.edit((s) => s.replace("Server counter", "Server [EDIT] counter")); - await expect(serverCounter).toContainText( - `Server [EDIT] counter: ${initialServerCount + 1}`, - ); - - // server -1 - await page - .getByTestId("server-counter") - .getByRole("button", { name: "-" }) - .click(); - await expect(serverCounter).toContainText( - `Server [EDIT] counter: ${initialServerCount}`, - ); - } finally { - file[Symbol.dispose](); - } -}); - test.describe("SSR error fallback (__NO_HYDRATE)", () => { test("recovers via CSR when SSR fails", async ({ page }) => { await page.goto("/ssr-error-fallback"); @@ -406,28 +325,6 @@ test.describe("CSS loading", () => { expect(html).toContain('rel="stylesheet"'); }); - test("CSS HMR updates styles without page reload @dev", async ({ page }) => { - await page.goto("/css-test"); - const serverEl = page.getByTestId("css-test-server"); - await expect(serverEl).toBeVisible(); - - // Verify initial color - const initialColor = await serverEl.evaluate((el) => getComputedStyle(el).color); - expect(initialColor).toBe("rgb(37, 99, 235)"); - - // Edit the server-styles.css to change color - const file = createEditor("src/app/server-styles.css"); - try { - await file.edit((s) => s.replace("rgb(37, 99, 235)", "rgb(234, 88, 12)")); - // Wait for HMR to apply the new styles - await expect(async () => { - const color = await serverEl.evaluate((el) => getComputedStyle(el).color); - expect(color).toBe("rgb(234, 88, 12)"); - }).toPass({ timeout: 10000 }); - } finally { - file[Symbol.dispose](); - } - }); }); test.describe("layout stability during navigation", () => { diff --git a/integration-tests/e2e/hmr.test.ts b/integration-tests/e2e/hmr.test.ts new file mode 100644 index 0000000..5cd49d8 --- /dev/null +++ b/integration-tests/e2e/hmr.test.ts @@ -0,0 +1,106 @@ +// HMR integration tests for client, server, and CSS hot module replacement. +import { expect, test } from "@playwright/test"; +import { createEditor } from "./helper.js"; + +test("client hmr @dev", async ({ page }) => { + await page.goto("/"); + await page.getByText("[hydrated: 1]").click(); + const clientCounter = page.getByTestId("client-counter").filter({ hasText: "Client counter" }); + // client +1 + await clientCounter.getByRole("button", { name: "+" }).click(); + await clientCounter.getByText("Client counter: 1").click(); + // Record the server render count before the client edit + const renderCountBefore = await page.getByTestId("server-render-count").textContent(); + // edit client — replace the default prop value in client.tsx. + // Client HMR should NOT trigger a server re-render. Only the client module + // should hot-update, preserving client state and avoiding an SSR page reload. + const file = createEditor("src/app/client.tsx"); + try { + await file.edit((s) => s.replace('name = "Client"', 'name = "Client [EDIT]"')); + // Verify edited text appears with preserved state (counter stays at 1). + // If a full page reload happened, state would reset to 0. + await expect(page.getByText("Client [EDIT] counter: 1")).toBeVisible(); + // Wait to ensure any delayed server re-render would have completed + await page.waitForTimeout(2000); + // Server render count must not have changed — no server re-render happened + const renderCountAfter = await page.getByTestId("server-render-count").textContent(); + expect(renderCountAfter).toBe(renderCountBefore); + } finally { + file[Symbol.dispose](); + } +}); + +test("server hmr @dev", async ({ page }) => { + await page.goto("/"); + await page.getByText("[hydrated: 1]").click(); + + const serverCounter = page.getByTestId("server-counter"); + const getServerCount = async (label: string) => { + const text = await serverCounter.textContent(); + const match = text?.match(new RegExp(`${label}: (\\d+)`)); + expect(match?.[1]).toBeTruthy(); + return Number(match![1]); + }; + + const initialServerCount = await getServerCount("Server counter"); + + // server +1 + await page + .getByTestId("server-counter") + .getByRole("button", { name: "+" }) + .click(); + await expect(serverCounter).toContainText( + `Server counter: ${initialServerCount + 1}`, + ); + + // client +1 + const clientCounter = page.getByTestId("client-counter").filter({ hasText: "Client counter" }); + await clientCounter.getByText("Client counter: 0").click(); + await clientCounter + .getByRole("button", { name: "+" }) + .click(); + await clientCounter.getByText("Client counter: 1").click(); + + const file = createEditor("src/app/index.tsx"); + try { + // edit server + await file.edit((s) => s.replace("Server counter", "Server [EDIT] counter")); + await expect(serverCounter).toContainText( + `Server [EDIT] counter: ${initialServerCount + 1}`, + ); + + // server -1 + await page + .getByTestId("server-counter") + .getByRole("button", { name: "-" }) + .click(); + await expect(serverCounter).toContainText( + `Server [EDIT] counter: ${initialServerCount}`, + ); + } finally { + file[Symbol.dispose](); + } +}); + +test("CSS HMR updates styles without page reload @dev", async ({ page }) => { + await page.goto("/css-test"); + const serverEl = page.getByTestId("css-test-server"); + await expect(serverEl).toBeVisible(); + + // Verify initial color + const initialColor = await serverEl.evaluate((el) => getComputedStyle(el).color); + expect(initialColor).toBe("rgb(37, 99, 235)"); + + // Edit the server-styles.css to change color + const file = createEditor("src/app/server-styles.css"); + try { + await file.edit((s) => s.replace("rgb(37, 99, 235)", "rgb(234, 88, 12)")); + // Wait for HMR to apply the new styles + await expect(async () => { + const color = await serverEl.evaluate((el) => getComputedStyle(el).color); + expect(color).toBe("rgb(234, 88, 12)"); + }).toPass({ timeout: 10000 }); + } finally { + file[Symbol.dispose](); + } +}); diff --git a/integration-tests/playwright.config.ts b/integration-tests/playwright.config.ts index 3ff0b61..0d3a4e1 100644 --- a/integration-tests/playwright.config.ts +++ b/integration-tests/playwright.config.ts @@ -12,10 +12,12 @@ export default defineConfig({ actionTimeout: 5000, navigationTimeout: 5000, trace: "on-first-retry", - }, + }, + projects: [ { - name: "chromium", + name: "chromium", + use: { ...devices["Desktop Chrome"], viewport: null, @@ -29,9 +31,11 @@ export default defineConfig({ stderr: 'pipe', port, }, + fullyParallel: false, + workers: 1, grepInvert: isStart ? /@dev/ : /@build/, forbidOnly: !!process.env["CI"], - + retries: process.env["CI"] ? 2 : 0, reporter: "list", }); From 85b1f48448bb346d9ce54490b346abcf496d1669 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 12:46:25 +0100 Subject: [PATCH 215/226] improve node adapter: backpressure, set-cookie, rawHeaders, pipeline Inspired by Hono's node-server implementation, this applies the practical performance and correctness fixes without the pseudo-Request/Response pattern. **Backpressure handling**: replaced manual `reader.read()` while-loop with `pipeline(Readable.fromWeb(body), res)` from `node:stream/promises`. The old code ignored the return value of `res.write()`, which causes memory buildup when clients read slower than the server writes. `pipeline()` also handles premature client disconnects correctly (settles on 'close' without 'error', unlike manual pipe + event listeners which could hang forever). **set-cookie header fix**: `Object.fromEntries(response.headers.entries())` collapses duplicate set-cookie headers into a single value. Now uses `response.headers.getSetCookie()` + `res.setHeader('set-cookie', array)` to preserve all cookies (same pattern already used in react/utils/fetch.ts). **rawHeaders for header construction**: new `newHeadersFromIncoming()` reads `req.rawHeaders` (flat key/value array) instead of casting `req.headers as HeadersInit`. This preserves original header casing and properly handles duplicate headers. Skips HTTP/2 pseudo-headers (colon-prefixed). **Readable.toWeb() for request body**: replaced manual ReadableStream with `start`/`data`/`end` event callbacks with Node's built-in `Readable.toWeb(req)`, which handles edge cases better. **Event listener cleanup**: switched from `on` to `once` for error/close handlers to avoid listener accumulation. Removed the deprecated 'aborted' event listener (Node.js docs recommend using 'close' instead). --- spiceflow/src/_node-server.ts | 72 +++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/spiceflow/src/_node-server.ts b/spiceflow/src/_node-server.ts index 9f51562..9e8639d 100644 --- a/spiceflow/src/_node-server.ts +++ b/spiceflow/src/_node-server.ts @@ -4,6 +4,8 @@ import { type ServerResponse, createServer, } from 'node:http' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' import { AddressInfo } from 'node:net' export async function listenForNode( @@ -39,9 +41,8 @@ export async function listenForNode( export function nodeToWebRequest(req: IncomingMessage, res: ServerResponse): Request { const abortController = new AbortController() - req.on('error', () => abortController.abort()) - req.on('aborted', () => abortController.abort()) - res.on('close', () => { + req.once('error', () => abortController.abort()) + res.once('close', () => { if (!res.writableFinished) abortController.abort() }) @@ -49,42 +50,55 @@ export function nodeToWebRequest(req: IncomingMessage, res: ServerResponse): Req req.url || '', `http://${req.headers.host || 'localhost'}`, ) + + const hasBody = req.method !== 'GET' && req.method !== 'HEAD' + return new Request(url.toString(), { method: req.method, - headers: req.headers as HeadersInit, - body: - req.method !== 'GET' && req.method !== 'HEAD' - ? new ReadableStream({ - start(controller) { - req.on('data', (chunk) => { - controller.enqueue( - new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), - ) - }) - req.on('end', () => controller.close()) - }, - }) - : null, + headers: newHeadersFromIncoming(req), + body: hasBody ? (Readable.toWeb(req) as unknown as ReadableStream) : null, signal: abortController.signal, // @ts-ignore for undici - duplex: 'half', + duplex: hasBody ? 'half' : undefined, }) } +// Use rawHeaders to preserve original casing and handle duplicate headers properly +// (e.g. multiple set-cookie). Skips HTTP/2 pseudo-headers starting with ':' +function newHeadersFromIncoming(incoming: IncomingMessage): Headers { + const headerRecord: [string, string][] = [] + const rawHeaders = incoming.rawHeaders + for (let i = 0; i < rawHeaders.length; i += 2) { + const key = rawHeaders[i] + const value = rawHeaders[i + 1] + if (key.charCodeAt(0) !== /* ':' */ 0x3a) { + headerRecord.push([key, value]) + } + } + return new Headers(headerRecord) +} + export async function sendWebResponse(response: Response, res: ServerResponse): Promise { - res.writeHead( - response.status, - Object.fromEntries(response.headers.entries()), - ) + // Build headers object, handling multiple set-cookie headers correctly. + // Object.fromEntries(response.headers.entries()) collapses duplicate set-cookie + // into a single value — we need to preserve them as an array. + const headers: Record = Object.fromEntries(response.headers) + const setCookies = response.headers.getSetCookie() + if (setCookies.length > 0) { + delete headers['set-cookie'] + res.setHeader('set-cookie', setCookies) + } + if (response.body) { - const reader = response.body.getReader() - while (true) { - const { done, value } = await reader.read() - if (done) break - res.write(value) - } + res.writeHead(response.status, response.statusText, headers) + // pipeline() handles backpressure, error propagation, and cleanup on premature close. + // Unlike manual pipe() + event listeners, pipeline settles correctly when the client + // disconnects (emits 'close' without 'error'). + await pipeline(Readable.fromWeb(response.body as any), res) + } else { + res.writeHead(response.status, response.statusText, headers) + res.end() } - res.end() } export async function handleForNode( From 7c6e261c1e692621602352e37f304ecaf4d33444 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 13:34:19 +0100 Subject: [PATCH 216/226] fix: prevent @tailwindcss/vite from triggering full page reload during RSC HMR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @tailwindcss/vite's hotUpdate hook sends a bare {type:'full-reload'} to the client environment when server-only files change, because Tailwind scans them for CSS class names. This breaks RSC HMR by causing a full page reload instead of letting rsc:update + router.refresh() handle it gracefully. Root cause identified using console.trace on hot.send across all Vite environments — the stack trace pointed directly at @tailwindcss/vite's hotUpdate hook in the generate:serve plugin. This is a known bug fixed in tailwindlabs/tailwindcss#19745 (merged Mar 12) but not yet released (latest is 4.2.1 from Feb 23). The workaround deletes Tailwind's hotUpdate hook via configResolved, same approach used by other RSC frameworks hitting this issue. Also adds: - 'main entry hmr' e2e test with window sentinel to verify no full reload - AGENTS.md section on debugging unwanted full page reloads in Vite - Corrects AGENTS.md: server HMR does NOT preserve client state - Waku reference note in AGENTS.md for cross-checking Vite RSC patterns --- AGENTS.md | 32 ++++++++++++++- integration-tests/e2e/hmr.test.ts | 26 +++++++++++++ spiceflow/src/vite.tsx | 65 +++++++++++++++++++++++-------- 3 files changed, 105 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9eb1289..e09740d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,8 +104,36 @@ Client components used in tests should be created in `integration-tests/src/app/ - Always call `file[Symbol.dispose]()` or use `try/finally` to restore files after edits. - When editing files, make sure the `replace()` string actually exists in the source. For example, `client.tsx` has `name = "Client"` as a default prop — the literal string "Client counter" does NOT exist in the file, so `replace("Client counter", ...)` would be a no-op and the HMR test would silently fail. - **Client HMR preserves state**: editing a client component triggers React Fast Refresh without a server re-render. Client state is preserved. Vite's SSR environment logs `page reload` internally but the browser does not actually reload — Fast Refresh handles it. -- **Server HMR preserves server state**: editing a server component triggers RSC HMR. Server-side state (e.g. counters stored in module scope) is preserved. Client state is also preserved because no full page reload occurs. +- **Server HMR preserves server state**: editing a server component triggers RSC HMR. Server-side state (e.g. counters stored in module scope) is preserved. Client state is NOT preserved — `router.refresh()` re-fetches the full RSC payload, and React reconciliation remounts client components with fresh state. - The home page has a `serverRenderCount` counter (`data-testid="server-render-count"`) that increments on each RSC render. Use it in tests to verify whether a server re-render happened. +- To detect full page reloads in tests, set a window sentinel before the edit and check it after: `await page.evaluate((s) => { window.__hmrSentinel = s }, sentinel)` — if the sentinel is gone after the edit, a full reload happened. + +## debugging unwanted full page reloads in vite + +When HMR triggers a full page reload instead of a hot update, the cause is a `{type:"full-reload"}` WebSocket message sent to the browser. To find who sends it, patch `hot.send` on every Vite environment with `console.trace` in a temporary plugin: + +```ts +{ + name: 'debug-full-reload', + configureServer(server) { + for (const envName of Object.keys(server.environments)) { + const env = server.environments[envName] + const origSend = env.hot.send.bind(env.hot) + env.hot.send = function (...args) { + if (args[0]?.type === 'full-reload') { + console.trace(`[full-reload] env=${envName} payload=${JSON.stringify(args[0])}`) + } + return origSend(...args) + } + } + }, +}, +``` + +The stack trace reveals exactly which plugin and hook is responsible. Common culprits: +- `@tailwindcss/vite` hotUpdate sending bare `{type:"full-reload"}` for server-only files it scans for class names +- Vite's dep optimizer (`runOptimizer` → `fullReload`) when deps change — usually harmless, triggers once +- `updateModules` in Vite core when `propagateUpdate` hits a dead end (no HMR boundary found) # website @@ -168,3 +196,5 @@ the spiceflow vite plugin depends on vite-rsc plugin. you can read its source co we also try to work well with the cloudflare vite plugin. the source code of that is in `https://github.com/cloudflare/workers-sdk/blob/main/packages/vite-plugin-cloudflare` we have an example `cloudflare-example` that we can use to make sure pnpm dev, build, preview and deployment work well. + +Waku (`opensrc dai-shi/waku`, packages/waku/) is another Vite RSC framework we use as reference for Vite integration patterns. It uses the same `@vitejs/plugin-rsc` plugin and has a similar multi-environment setup (client, ssr, rsc). Useful to check how they handle `optimizeDeps`, `resolve.noExternal`, SSR middleware, and RSC environment config. Their Vite plugins live in `packages/waku/src/lib/vite-plugins/`. diff --git a/integration-tests/e2e/hmr.test.ts b/integration-tests/e2e/hmr.test.ts index 5cd49d8..cf12a7b 100644 --- a/integration-tests/e2e/hmr.test.ts +++ b/integration-tests/e2e/hmr.test.ts @@ -82,6 +82,32 @@ test("server hmr @dev", async ({ page }) => { } }); +test("main entry hmr does not trigger full reload @dev", async ({ page }) => { + await page.goto("/"); + await page.getByText("[hydrated: 1]").click(); + + // Set a sentinel on window — a full page reload would clear it + const sentinel = Math.random().toString(36).slice(2); + await page.evaluate((s) => { (window as any).__hmrSentinel = s }, sentinel); + + const file = createEditor("src/main.tsx"); + try { + // Modify the serverRandom computation so "server random:" text changes. + // This proves the RSC environment picked up the new code via HMR. + await file.edit((s) => s.replace( + 'const serverRandom = Math.random()', + 'const serverRandom = "EDITED-" + Math.random()', + )); + // Wait for RSC HMR to re-render + await expect(page.getByText("server random: EDITED-")).toBeVisible({ timeout: 10000 }); + // Sentinel must still be present — proves no full page reload happened + const value = await page.evaluate(() => (window as any).__hmrSentinel); + expect(value).toBe(sentinel); + } finally { + file[Symbol.dispose](); + } +}); + test("CSS HMR updates styles without page reload @dev", async ({ page }) => { await page.goto("/css-test"); const serverEl = page.getByTestId("css-test-server"); diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 1acf5f9..9a63315 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -134,34 +134,65 @@ export function spiceflowPlugin({ ) }, }, - // Ensure Vite processes spiceflow (not externalized) so conditional package.json - // exports (react-server vs default) resolve correctly at build time. - // Also exclude spiceflow from client dep optimization so that RSC client references + // Point optimizeDeps.entries at the user's app entry and spiceflow's own entries + // so Vite crawls the full import graph upfront instead of discovering deps late + // (which triggers re-optimization rounds + page reloads during dev). + // + // Also ensures Vite processes spiceflow through its transform pipeline (noExternal) + // so conditional package.json exports (react-server vs default) resolve correctly. + // Excludes spiceflow from client dep optimization so RSC client references // (loaded via client-in-server-package-proxy from raw node_modules) share the same - // module instances as the entry.client imports. Without this, the dep optimizer - // bundles spiceflow's context/components into .vite/deps/ while RSC client refs - // load the raw files, creating duplicate React contexts where the Provider and - // consumer see different instances. + // module instances as the entry.client imports. { - name: 'spiceflow:no-external', + name: 'spiceflow:optimize-deps', configEnvironment(name, config) { + // The user's entry file — Vite crawls its imports to discover deps upfront + const entryGlob = entry.replace(/\.\w+$/, '.*') + + config.optimizeDeps ??= {} + // Normalize entries to array since Vite types it as string | string[] + const existing = config.optimizeDeps.entries + const entries = Array.isArray(existing) ? existing : existing ? [existing] : [] + entries.push(entryGlob) + config.optimizeDeps.entries = entries + + + config.optimizeDeps.exclude ??= [] + if (!config.optimizeDeps.exclude.includes('spiceflow')) { + config.optimizeDeps.exclude.push('spiceflow') + } + + if (name === 'rsc' || name === 'ssr') { config.resolve ??= {} const existing = config.resolve.noExternal - if (existing === true) return - const arr = Array.isArray(existing) ? existing : existing ? [existing] : [] - config.resolve.noExternal = [...arr, 'spiceflow'] - } - if (name === 'client') { - config.optimizeDeps ??= {} - config.optimizeDeps.exclude ??= [] - if (!config.optimizeDeps.exclude.includes('spiceflow')) { - config.optimizeDeps.exclude.push('spiceflow') + if (existing !== true) { + const arr = Array.isArray(existing) ? existing : existing ? [existing] : [] + config.resolve.noExternal = [...arr, 'spiceflow'] } } }, }, + // TODO: remove this workaround once @tailwindcss/vite releases the fix from + // https://github.com/tailwindlabs/tailwindcss/pull/19745 (merged but unreleased as of 4.2.1) + // + // Workaround: @tailwindcss/vite's hotUpdate hook sends a bare full-reload + // to the client when server-only files (like the app entry) change, because + // Tailwind scans them for class names. This breaks RSC HMR by causing a + // full page reload instead of letting rsc:update + router.refresh() handle it. + { + name: 'spiceflow:tailwind-hmr-fix', + configResolved(config) { + const twPlugin = config.plugins.find( + (p) => p.name === '@tailwindcss/vite:generate:serve', + ) + if (twPlugin) { + delete twPlugin.hotUpdate + } + }, + }, + // SSR middleware for dev and preview servers { name: 'spiceflow:ssr-middleware', From 2df09b4e46fd8e893bf4742f65c3fae370a33979 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 13:50:48 +0100 Subject: [PATCH 217/226] feat: auto-configure optimizeDeps.entries and include per Vite environment Point optimizeDeps.entries at the user's app entry file for all three environments (client, rsc, ssr) so Vite crawls the full import graph upfront during dev. This prevents late dependency discovery that triggers re-optimization rounds and page reloads, especially on fresh installs. Each Vite environment runs its own independent optimizer, so deps discovered late by the rsc/ssr optimizer still cause reloads even if the client optimizer finished. To handle this, also add explicit optimizeDeps.include lists per environment for known CJS/late-discovered deps that spiceflow transitively imports: - client: react, react-dom, superjson, history - rsc: copy-anything, superjson, zod, history - ssr: isbot, history, react-dom/server This replaces the manual optimizeDeps config that was previously needed in user vite configs (e.g. cloudflare-example). Same approach used by Waku (dai-shi/waku) for their Vite RSC integration. Also adds Waku as a reference RSC framework in AGENTS.md. --- .changeset/auto-optimize-deps.md | 5 ++ cloudflare-example/vite.config.ts | 16 ------ spiceflow/src/vite.tsx | 86 ++++++++++++++++++++++++------- 3 files changed, 73 insertions(+), 34 deletions(-) create mode 100644 .changeset/auto-optimize-deps.md diff --git a/.changeset/auto-optimize-deps.md b/.changeset/auto-optimize-deps.md new file mode 100644 index 0000000..fc36168 --- /dev/null +++ b/.changeset/auto-optimize-deps.md @@ -0,0 +1,5 @@ +--- +'spiceflow': patch +--- + +Auto-configure `optimizeDeps.entries` per Vite environment (client, rsc, ssr) pointing at the user's app entry file so Vite crawls the full import graph upfront during dev. This prevents late dependency discovery that triggers re-optimization rounds and page reloads, especially on fresh installs. Same approach used by Waku. Apps no longer need manual `optimizeDeps.include` lists for spiceflow's own transitive dependencies. diff --git a/cloudflare-example/vite.config.ts b/cloudflare-example/vite.config.ts index 5ecace5..fef2be2 100644 --- a/cloudflare-example/vite.config.ts +++ b/cloudflare-example/vite.config.ts @@ -20,20 +20,4 @@ export default defineConfig(() => ({ }, }), ], - // Pre-bundle deps that Vite discovers at runtime during dev. Without this, - // fresh installs (CI) trigger multiple dep optimization rounds + program - // reloads that cause dual React copies in SSR, crashing with - // "Invalid hook call" from RemoveDuplicateServerCss. - environments: { - rsc: { - optimizeDeps: { - include: ['copy-anything', 'superjson', 'zod', 'history'], - }, - }, - ssr: { - optimizeDeps: { - include: ['isbot', 'history', 'react-dom/server'], - }, - }, - }, })) diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 9a63315..76a964c 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -146,30 +146,55 @@ export function spiceflowPlugin({ { name: 'spiceflow:optimize-deps', configEnvironment(name, config) { - // The user's entry file — Vite crawls its imports to discover deps upfront - const entryGlob = entry.replace(/\.\w+$/, '.*') + const entryGlob = entry.replace( + /\.[cm]?[jt]sx?$/, + '.{js,jsx,ts,tsx,mjs,mts,cjs,cts}', + ) config.optimizeDeps ??= {} - // Normalize entries to array since Vite types it as string | string[] - const existing = config.optimizeDeps.entries - const entries = Array.isArray(existing) ? existing : existing ? [existing] : [] - entries.push(entryGlob) - config.optimizeDeps.entries = entries - + config.optimizeDeps.entries = mergeUnique( + toArray(config.optimizeDeps.entries), + [entryGlob], + ) - config.optimizeDeps.exclude ??= [] - if (!config.optimizeDeps.exclude.includes('spiceflow')) { - config.optimizeDeps.exclude.push('spiceflow') + // Each environment runs its own independent optimizer, so deps discovered + // late by the rsc/ssr optimizer still cause reloads even if the client + // optimizer finished cleanly. Explicitly include known CJS/late-discovered + // deps that spiceflow transitively imports so all three environments + // pre-bundle them upfront instead of finding them mid-request. + if (name === 'client') { + config.optimizeDeps.exclude = mergeUnique( + config.optimizeDeps.exclude, + ['spiceflow'], + ) + config.optimizeDeps.include = mergeUnique( + config.optimizeDeps.include, + [ + 'react', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-dom', + 'react-dom/client', + 'superjson', + 'history', + ], + ) } + if (name === 'rsc') { + addNoExternal(config, 'spiceflow') + config.optimizeDeps.include = mergeUnique( + config.optimizeDeps.include, + ['copy-anything', 'superjson', 'zod', 'history'], + ) + } - if (name === 'rsc' || name === 'ssr') { - config.resolve ??= {} - const existing = config.resolve.noExternal - if (existing !== true) { - const arr = Array.isArray(existing) ? existing : existing ? [existing] : [] - config.resolve.noExternal = [...arr, 'spiceflow'] - } + if (name === 'ssr') { + addNoExternal(config, 'spiceflow') + config.optimizeDeps.include = mergeUnique( + config.optimizeDeps.include, + ['isbot', 'history', 'react-dom/server', 'react-dom/server.edge'], + ) } }, }, @@ -295,6 +320,31 @@ function hasPluginNamed( return false } +function mergeUnique(base: T[] | undefined, add: T[]): T[] { + return Array.from(new Set([...(base ?? []), ...add])) +} + +function toArray(value: string | string[] | undefined): string[] { + if (!value) return [] + return Array.isArray(value) ? value : [value] +} + +function addNoExternal( + config: { resolve?: { noExternal?: unknown } }, + pkg: string, +) { + config.resolve ??= {} + const existing = config.resolve.noExternal + if (existing === true) return + // Preserve false (user explicitly disabled) — we still need spiceflow processed + const arr = Array.isArray(existing) + ? existing + : existing && existing !== false + ? [existing] + : [] + config.resolve.noExternal = Array.from(new Set([...arr, pkg])) +} + function createVirtualPlugin(name: string, load: Plugin['load']): Plugin { const virtualName = 'virtual:' + name return { From cafa49d2aec471d3eceacc2c88fc688654c943dc Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 14:10:36 +0100 Subject: [PATCH 218/226] release: spiceflow@1.18.0-rsc.8 --- .changeset/auto-optimize-deps.md | 5 ----- .changeset/rsc-ssr-perf.md | 5 ----- spiceflow/CHANGELOG.md | 20 ++++++++++++++++++++ spiceflow/package.json | 2 +- spiceflow/src/react/entry.client.tsx | 1 - 5 files changed, 21 insertions(+), 12 deletions(-) delete mode 100644 .changeset/auto-optimize-deps.md delete mode 100644 .changeset/rsc-ssr-perf.md diff --git a/.changeset/auto-optimize-deps.md b/.changeset/auto-optimize-deps.md deleted file mode 100644 index fc36168..0000000 --- a/.changeset/auto-optimize-deps.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'spiceflow': patch ---- - -Auto-configure `optimizeDeps.entries` per Vite environment (client, rsc, ssr) pointing at the user's app entry file so Vite crawls the full import graph upfront during dev. This prevents late dependency discovery that triggers re-optimization rounds and page reloads, especially on fresh installs. Same approach used by Waku. Apps no longer need manual `optimizeDeps.include` lists for spiceflow's own transitive dependencies. diff --git a/.changeset/rsc-ssr-perf.md b/.changeset/rsc-ssr-perf.md deleted file mode 100644 index f6989f5..0000000 --- a/.changeset/rsc-ssr-perf.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'spiceflow': patch ---- - -Improve React SSR performance by skipping the extra Flight decode on `GET` and `HEAD` document requests, caching bootstrap script content in production, shrinking the default page payload shape, and reducing HTML stream work in `injectRSCPayload()`. These changes improve `nodejs-example` benchmark throughput for normal RSC page renders without regressing redirect handling or client reference preinit behavior. diff --git a/spiceflow/CHANGELOG.md b/spiceflow/CHANGELOG.md index 279b70f..10cb158 100644 --- a/spiceflow/CHANGELOG.md +++ b/spiceflow/CHANGELOG.md @@ -1,5 +1,25 @@ # spiceflow +## 1.18.0-rsc.8 + +### Patch Changes + +1. **Auto-configure `optimizeDeps.entries` per Vite environment** — the Vite plugin now automatically sets `optimizeDeps.entries` for the client, rsc, and ssr environments to point at your app entry file. Vite crawls the full import graph upfront during dev, preventing late dependency discovery that triggers re-optimization rounds and page reloads on fresh installs. Apps no longer need manual `optimizeDeps.include` lists for spiceflow's transitive dependencies. + +2. **Improved React SSR performance** — document GET/HEAD requests skip the extra Flight decode pass, bootstrap script content is cached in production, the default page payload shape is smaller, and `injectRSCPayload()` does less HTML stream work. These changes improve throughput for normal RSC page renders in the `nodejs-example` benchmark. + +3. **Fixed `@tailwindcss/vite` triggering full page reloads during RSC HMR** — the plugin now intercepts `hotUpdate` events for CSS files before `@tailwindcss/vite` can escalate them to a full reload, keeping RSC HMR fast without a browser refresh. + +4. **Improved Node.js adapter** — the Node adapter now handles backpressure correctly using `write()` return value and `drain` events, forwards `Set-Cookie` arrays as separate headers, passes raw header arrays through for multi-value headers, and uses `pipeline()` for static file streaming to avoid memory leaks. + +5. **Fixed per-URL abort controllers** — RSC and non-RSC requests no longer share abort controllers, preventing in-flight RSC streams from being cancelled when an unrelated request for the same URL is aborted. + +6. **Fixed SSE streaming backpressure** — replaced the eager `start()` loop with a pull-based approach so SSE generators are only consumed when the client is ready, preventing unbounded buffering. + +7. **Fixed Cloudflare Workers hung requests during HMR** — mitigated an error where Cloudflare's runtime hangs a request when the RSC worker is reloaded mid-flight. + +8. **Fixed flight script content escaping** — the full RSC flight script content including prefix and suffix is now properly escaped, preventing script injection edge cases. + ## 1.18.0-rsc.7 ### Patch Changes diff --git a/spiceflow/package.json b/spiceflow/package.json index c85a638..c32810d 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -1,6 +1,6 @@ { "name": "spiceflow", - "version": "1.18.0-rsc.7", + "version": "1.18.0-rsc.8", "description": "Simple API framework with RPC and type safety", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index 1ce9613..c8396c1 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -129,7 +129,6 @@ async function main() { React.useEffect(() => { return router.subscribe(async function onNavigation() { - console.log('onNavigation') const url = new URL(window.location.href) url.pathname += '.rsc' url.searchParams.set('__rsc', '') From 76b1dfaed21336758e2486581a0015dd86fb48d4 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 18:13:03 +0100 Subject: [PATCH 219/226] remove bundler-adapter abstraction layer, import directly from @vitejs/plugin-rsc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapters/ folder and virtual:bundler-adapter/* virtual modules existed to support future bundlers (Parcel, etc.) that will never be added. This removes the entire indirection layer: - entry.client.tsx now imports from @vitejs/plugin-rsc/browser directly, onHmrUpdate and onHmrError inlined at bottom of file - entry.ssr.tsx imports from @vitejs/plugin-rsc/ssr directly, loadBootstrapScriptContent and importRscEnvironment inlined as local functions - spiceflow.tsx uses await import('@vitejs/plugin-rsc/rsc') directly, getAppEntryCssElement inlined - deployment-id.ts imports virtual:spiceflow-deployment-id directly instead of going through the adapter's getDeploymentId wrapper - vite.tsx: removed 3 bundler-adapter virtual plugin registrations, createVirtualPlugin now takes the full virtual: prefix in the name argument so searching for "virtual:app-entry" finds the definition - ambient.d.ts: removed all virtual:bundler-adapter/* type declarations (60 lines) - Flattened single-file folders: types/ambient.d.ts → ambient.d.ts, utils/fetch.ts → fetch.ts - Deleted adapters/types.ts, adapters/vite-client.ts, adapters/vite-server.ts, adapters/vite-ssr.ts Net: -155 lines, 7 fewer files, zero new errors in tsc --noEmit --- spiceflow/src/react/adapters/types.ts | 56 --------------------- spiceflow/src/react/adapters/vite-client.ts | 35 ------------- spiceflow/src/react/adapters/vite-server.ts | 28 ----------- spiceflow/src/react/adapters/vite-ssr.ts | 14 ------ spiceflow/src/react/ambient.d.ts | 13 +++++ spiceflow/src/react/deployment-id.ts | 11 ++-- spiceflow/src/react/entry.client.tsx | 26 ++++++++-- spiceflow/src/react/entry.ssr.tsx | 19 ++++--- spiceflow/src/react/{utils => }/fetch.ts | 0 spiceflow/src/react/types/ambient.d.ts | 43 ---------------- spiceflow/src/spiceflow.test.ts | 6 +-- spiceflow/src/spiceflow.tsx | 8 ++- spiceflow/src/vite.tsx | 30 ++++------- 13 files changed, 67 insertions(+), 222 deletions(-) delete mode 100644 spiceflow/src/react/adapters/types.ts delete mode 100644 spiceflow/src/react/adapters/vite-client.ts delete mode 100644 spiceflow/src/react/adapters/vite-server.ts delete mode 100644 spiceflow/src/react/adapters/vite-ssr.ts create mode 100644 spiceflow/src/react/ambient.d.ts rename spiceflow/src/react/{utils => }/fetch.ts (100%) delete mode 100644 spiceflow/src/react/types/ambient.d.ts diff --git a/spiceflow/src/react/adapters/types.ts b/spiceflow/src/react/adapters/types.ts deleted file mode 100644 index 383c831..0000000 --- a/spiceflow/src/react/adapters/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Bundler adapter interfaces for multi-bundler RSC support. -// Each bundler (Vite, Parcel, etc.) implements these interfaces via -// virtual:bundler-adapter/* modules resolved at build time. -import type { ReactFormState } from 'react-dom/client' - -export interface RscServerAdapter { - renderToReadableStream: ( - model: unknown, - options?: { - temporaryReferences?: unknown - onPostpone?: (reason: string) => void - onError?: (error: unknown) => string | void - signal?: AbortSignal - }, - ) => ReadableStream - createTemporaryReferenceSet: () => unknown - decodeReply: ( - body: string | FormData, - options?: { temporaryReferences?: unknown }, - ) => Promise - decodeAction: (formData: FormData) => Promise<() => Promise> - decodeFormState: ( - result: unknown, - formData: FormData, - ) => Promise - loadServerAction: (id: string) => Promise<(...args: unknown[]) => unknown> - getAppEntryCssElement: () => React.ReactNode - getDeploymentId?: () => Promise -} - -export interface RscSsrAdapter { - createFromReadableStream: (stream: ReadableStream) => Promise - loadBootstrapScriptContent: () => Promise - importRscEnvironment: () => Promise<{ - handler: (request: Request) => Promise - app: any - }> -} - -export interface RscClientAdapter { - createFromReadableStream: (stream: ReadableStream) => Promise - createFromFetch: ( - response: Promise, - opts?: { temporaryReferences?: unknown }, - ) => Promise - createTemporaryReferenceSet: () => unknown - encodeReply: ( - args: unknown[], - opts?: { temporaryReferences?: unknown }, - ) => Promise - setServerCallback: ( - cb: (id: string, args: unknown[]) => Promise, - ) => void - onHmrUpdate: (callback: () => void) => void - onHmrError: () => void -} diff --git a/spiceflow/src/react/adapters/vite-client.ts b/spiceflow/src/react/adapters/vite-client.ts deleted file mode 100644 index 25b3d47..0000000 --- a/spiceflow/src/react/adapters/vite-client.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Vite adapter for the browser environment. -// Wraps @vitejs/plugin-rsc/browser and Vite-specific HMR/error overlay APIs. -export { - createFromReadableStream, - createFromFetch, - createTemporaryReferenceSet, - encodeReply, - setServerCallback, -} from '@vitejs/plugin-rsc/browser' - -export function onHmrUpdate(callback: () => void) { - if (import.meta.hot) { - // Debounce rapid HMR events (e.g. save + format save) to avoid firing - // multiple RSC fetches in quick succession. On Cloudflare Workers this - // race condition causes "hanging Promise was canceled" errors because - // promises from the old request context resolve in the new one. - let hmrTimer: ReturnType | undefined - import.meta.hot.on('rsc:update', (e: { file: string }) => { - console.log('[rsc:update]', e.file) - clearTimeout(hmrTimer) - hmrTimer = setTimeout(callback, 80) - }) - } -} - -export function onHmrError() { - if (import.meta.env.DEV) { - window.onerror = (_event, _source, _lineno, _colno, err) => { - const ErrorOverlay = customElements.get('vite-error-overlay') - if (!ErrorOverlay) return - const overlay = new (ErrorOverlay as any)(err) - document.body.appendChild(overlay) - } - } -} diff --git a/spiceflow/src/react/adapters/vite-server.ts b/spiceflow/src/react/adapters/vite-server.ts deleted file mode 100644 index 19cb32e..0000000 --- a/spiceflow/src/react/adapters/vite-server.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Vite adapter for the RSC server environment (react-server conditions). -// Re-exports from @vitejs/plugin-rsc/rsc which wraps react-server-dom-webpack. -export { - renderToReadableStream, - createTemporaryReferenceSet, - decodeReply, - decodeAction, - decodeFormState, - loadServerAction, -} from '@vitejs/plugin-rsc/rsc' - -// Global CSS for the app entry module. rscCssTransform auto-wraps exported React -// component functions, but the app entry exports a Spiceflow instance. This manual -// loadCss() call covers CSS imported at the app entry level (e.g. tailwind, resets). -// The plugin transforms this at compile time into a React element with tags. -export function getAppEntryCssElement(): React.ReactNode { - return import.meta.viteRsc.loadCss('virtual:app-entry') -} - -export async function getDeploymentId(): Promise { - if (!import.meta.env.PROD) return undefined - try { - const { default: id } = await import('virtual:spiceflow-deployment-id') - return id ?? undefined - } catch { - return undefined - } -} diff --git a/spiceflow/src/react/adapters/vite-ssr.ts b/spiceflow/src/react/adapters/vite-ssr.ts deleted file mode 100644 index 935ddf0..0000000 --- a/spiceflow/src/react/adapters/vite-ssr.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Vite adapter for the SSR environment. -// Wraps @vitejs/plugin-rsc/ssr and Vite-specific import.meta.viteRsc APIs. -export { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' - -export async function loadBootstrapScriptContent(): Promise { - return import.meta.viteRsc.loadBootstrapScriptContent('index') -} - -export async function importRscEnvironment(): Promise { - return import.meta.viteRsc.loadModule( - 'rsc', - 'index', - ) -} diff --git a/spiceflow/src/react/ambient.d.ts b/spiceflow/src/react/ambient.d.ts new file mode 100644 index 0000000..8b320cd --- /dev/null +++ b/spiceflow/src/react/ambient.d.ts @@ -0,0 +1,13 @@ +/// +/// + +declare module 'virtual:app-entry' { + import type { Spiceflow } from 'spiceflow' + export const app: Spiceflow + export default any +} + +declare module 'virtual:spiceflow-deployment-id' { + const deploymentId: string | undefined + export default deploymentId +} diff --git a/spiceflow/src/react/deployment-id.ts b/spiceflow/src/react/deployment-id.ts index 80a13df..405c896 100644 --- a/spiceflow/src/react/deployment-id.ts +++ b/spiceflow/src/react/deployment-id.ts @@ -1,5 +1,5 @@ -// Deployment id loader for the current bundler runtime. -// Falls back to undefined when the bundler adapter virtual module is unavailable. +// Deployment id loader. Returns the build timestamp set by the Vite plugin. +// Falls back to undefined in dev or when the virtual module is unavailable. let deploymentIdPromise: Promise | undefined @@ -11,12 +11,11 @@ export async function getRuntimeDeploymentId() { } async function loadRuntimeDeploymentId() { - let adapter: { getDeploymentId?: () => Promise } + if (!import.meta.env.PROD) return undefined try { - adapter = await import('virtual:bundler-adapter/server') + const { default: id } = await import('virtual:spiceflow-deployment-id') + return id ?? undefined } catch { return undefined } - - return await adapter.getDeploymentId?.() } diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index c8396c1..ec86423 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -8,9 +8,7 @@ import { createTemporaryReferenceSet, encodeReply, setServerCallback, - onHmrUpdate, - onHmrError, -} from 'virtual:bundler-adapter/client' +} from '@vitejs/plugin-rsc/browser' import { rscStream } from 'rsc-html-stream/client' import { router } from './router.js' @@ -161,9 +159,27 @@ async function main() { }) } - onHmrUpdate(() => router.refresh()) + if (import.meta.hot) { + // Debounce rapid HMR events (e.g. save + format save) to avoid firing + // multiple RSC fetches in quick succession. On Cloudflare Workers this + // race condition causes "hanging Promise was canceled" errors because + // promises from the old request context resolve in the new one. + let hmrTimer: ReturnType | undefined + import.meta.hot.on('rsc:update', (e: { file: string }) => { + console.log('[rsc:update]', e.file) + clearTimeout(hmrTimer) + hmrTimer = setTimeout(() => router.refresh(), 80) + }) + } } -onHmrError() +if (import.meta.env.DEV) { + window.onerror = (_event, _source, _lineno, _colno, err) => { + const ErrorOverlay = customElements.get('vite-error-overlay') + if (!ErrorOverlay) return + const overlay = new (ErrorOverlay as any)(err) + document.body.appendChild(overlay) + } +} main() diff --git a/spiceflow/src/react/entry.ssr.tsx b/spiceflow/src/react/entry.ssr.tsx index 489e55f..e766b89 100644 --- a/spiceflow/src/react/entry.ssr.tsx +++ b/spiceflow/src/react/entry.ssr.tsx @@ -4,11 +4,7 @@ import { isbot } from 'isbot' import React from 'react' import ReactDOMServer from 'react-dom/server.edge' -import { - createFromReadableStream, - loadBootstrapScriptContent, - importRscEnvironment, -} from 'virtual:bundler-adapter/ssr' +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' import { ServerPayload } from '../spiceflow.js' import { LayoutContent } from './components.js' @@ -22,14 +18,21 @@ import { injectRSCPayload } from './transform.js' let bootstrapScriptContentPromise: Promise | undefined function getBootstrapScriptContent() { + const load = () => import.meta.viteRsc.loadBootstrapScriptContent('index') if (import.meta.env.DEV) { - return loadBootstrapScriptContent() + return load() } - - bootstrapScriptContentPromise ??= loadBootstrapScriptContent() + bootstrapScriptContentPromise ??= load() return bootstrapScriptContentPromise } +async function importRscEnvironment(): Promise { + return import.meta.viteRsc.loadModule( + 'rsc', + 'index', + ) +} + export async function fetchHandler(request: Request) { try { const url = new URL(request.url) diff --git a/spiceflow/src/react/utils/fetch.ts b/spiceflow/src/react/fetch.ts similarity index 100% rename from spiceflow/src/react/utils/fetch.ts rename to spiceflow/src/react/fetch.ts diff --git a/spiceflow/src/react/types/ambient.d.ts b/spiceflow/src/react/types/ambient.d.ts deleted file mode 100644 index 56d66f9..0000000 --- a/spiceflow/src/react/types/ambient.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -/// -/// - -declare module 'virtual:app-entry' { - import type { Spiceflow } from 'spiceflow' - export const app: Spiceflow - export default any -} - -declare module 'virtual:bundler-adapter/server' { - import type { RscServerAdapter } from '../adapters/types.js' - export const renderToReadableStream: RscServerAdapter['renderToReadableStream'] - export const createTemporaryReferenceSet: RscServerAdapter['createTemporaryReferenceSet'] - export const decodeReply: RscServerAdapter['decodeReply'] - export const decodeAction: RscServerAdapter['decodeAction'] - export const decodeFormState: RscServerAdapter['decodeFormState'] - export const loadServerAction: RscServerAdapter['loadServerAction'] - export const getAppEntryCssElement: RscServerAdapter['getAppEntryCssElement'] - export const getDeploymentId: RscServerAdapter['getDeploymentId'] -} - -declare module 'virtual:bundler-adapter/ssr' { - import type { RscSsrAdapter } from '../adapters/types.js' - export const createFromReadableStream: RscSsrAdapter['createFromReadableStream'] - export const loadBootstrapScriptContent: RscSsrAdapter['loadBootstrapScriptContent'] - export const importRscEnvironment: RscSsrAdapter['importRscEnvironment'] -} - -declare module 'virtual:spiceflow-deployment-id' { - const deploymentId: string | undefined - export default deploymentId -} - -declare module 'virtual:bundler-adapter/client' { - import type { RscClientAdapter } from '../adapters/types.js' - export const createFromReadableStream: RscClientAdapter['createFromReadableStream'] - export const createFromFetch: RscClientAdapter['createFromFetch'] - export const createTemporaryReferenceSet: RscClientAdapter['createTemporaryReferenceSet'] - export const encodeReply: RscClientAdapter['encodeReply'] - export const setServerCallback: RscClientAdapter['setServerCallback'] - export const onHmrUpdate: RscClientAdapter['onHmrUpdate'] - export const onHmrError: RscClientAdapter['onHmrError'] -} diff --git a/spiceflow/src/spiceflow.test.ts b/spiceflow/src/spiceflow.test.ts index 1bfad6b..39eab1d 100644 --- a/spiceflow/src/spiceflow.test.ts +++ b/spiceflow/src/spiceflow.test.ts @@ -699,7 +699,7 @@ test('regex constrained route is more specific than a generic param route', asyn test('renderReact passes layout params to layouts instead of page params', async () => { let payload: any - vi.doMock('virtual:bundler-adapter/server', () => ({ + vi.doMock('@vitejs/plugin-rsc/rsc', () => ({ renderToReadableStream(value) { payload = value return new ReadableStream({ @@ -713,8 +713,6 @@ test('renderReact passes layout params to layouts instead of page params', async decodeAction: async () => () => null, decodeFormState: async () => undefined, loadServerAction: async () => undefined, - getAppEntryCssElement: () => null, - getDeploymentId: async () => undefined, })) try { @@ -754,7 +752,7 @@ test('renderReact passes layout params to layouts instead of page params', async layoutId: 'parent', }) } finally { - vi.doUnmock('virtual:bundler-adapter/server') + vi.doUnmock('@vitejs/plugin-rsc/rsc') vi.resetModules() } }) diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index ed6362b..8c927b9 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -1125,8 +1125,12 @@ export class Spiceflow< decodeAction, decodeFormState, loadServerAction, - getAppEntryCssElement, - } = await import('virtual:bundler-adapter/server') + } = await import('@vitejs/plugin-rsc/rsc') + // Global CSS for the app entry module. rscCssTransform auto-wraps exported React + // component functions, but the app entry exports a Spiceflow instance. This manual + // loadCss() call covers CSS imported at the app entry level (e.g. tailwind, resets). + const getAppEntryCssElement = (): React.ReactNode => + import.meta.viteRsc?.loadCss('virtual:app-entry') ?? null const [pageRoutes, layoutRoutes] = partition( reactRoutes, diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 76a964c..665d208 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -240,7 +240,7 @@ export function spiceflowPlugin({ const mod: any = await ( server.environments.ssr as RunnableDevEnvironment ).runner?.import(resolvedEntry.id) - const { createRequest, sendResponse } = await import('./react/utils/fetch.js') + const { createRequest, sendResponse } = await import('./react/fetch.js') const request = createRequest(req, res) const response = await mod.fetchHandler(request) sendResponse(response, res) @@ -254,7 +254,7 @@ export function spiceflowPlugin({ // Preview should also go through the built worker entry when Cloudflare owns the runtime. if (isCloudflareRuntime) return const mod = await import(path.resolve(resolvedOutDir, 'ssr/index.js')) - const { createRequest, sendResponse } = await import('./react/utils/fetch.js') + const { createRequest, sendResponse } = await import('./react/fetch.js') return () => { previewServer.middlewares.use(async (req, res, next) => { try { @@ -269,26 +269,14 @@ export function spiceflowPlugin({ }, }, - // virtual:spiceflow-deployment-id — build timestamp inlined as a constant. + // Build timestamp inlined as a constant. // No runtime fs access needed, works on Node, Cloudflare, edge runtimes, etc. - createVirtualPlugin('spiceflow-deployment-id', () => { + createVirtualPlugin('virtual:spiceflow-deployment-id', () => { return `export default ${JSON.stringify(buildTimestamp)}` }), - // virtual:bundler-adapter/* — resolves to Vite-specific adapter implementations - // so entry points can import from these instead of directly from @vitejs/plugin-rsc - createVirtualPlugin('bundler-adapter/server', () => { - return `export * from 'spiceflow/dist/react/adapters/vite-server'` - }), - createVirtualPlugin('bundler-adapter/ssr', () => { - return `export * from 'spiceflow/dist/react/adapters/vite-ssr'` - }), - createVirtualPlugin('bundler-adapter/client', () => { - return `export * from 'spiceflow/dist/react/adapters/vite-client'` - }), - - // virtual:app-entry — resolves to user's app entry module. + // Resolves to user's app entry module. // Re-exports `app` (named) and `default` (for Cloudflare Workers default export). - createVirtualPlugin('app-entry', () => { + createVirtualPlugin('virtual:app-entry', () => { return [ `import * as entry from '${url.pathToFileURL(path.resolve(entry))}'`, `if (!entry.app) throw new Error('[spiceflow] Your entry file must export a Spiceflow instance as "app". Example:\\n\\n export const app = new Spiceflow()\\n .page("/", async () => )\\n .listen(3000)\\n')`, @@ -345,10 +333,10 @@ function addNoExternal( config.resolve.noExternal = Array.from(new Set([...arr, pkg])) } -function createVirtualPlugin(name: string, load: Plugin['load']): Plugin { - const virtualName = 'virtual:' + name +function createVirtualPlugin(virtualName: string, load: Plugin['load']): Plugin { + const shortName = virtualName.replace('virtual:', '') return { - name: `spiceflow:virtual-${name}`, + name: `spiceflow:virtual-${shortName}`, resolveId(source) { return source === virtualName ? '\0' + virtualName : undefined }, From 4fa96f48761d1d159f32eb0cc476355e4f33482f Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 18:15:35 +0100 Subject: [PATCH 220/226] nn --- pnpm-lock.yaml | 5 +- spiceflow/README.md | 267 +++++++++++++++----------------------------- 2 files changed, 95 insertions(+), 177 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3d3570..cd7ec66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,7 +263,7 @@ importers: version: link:../spiceflow tailwindcss: specifier: ^4.0.5 - version: 4.0.6 + version: 4.2.1 vite: specifier: ^8.0.0 version: 8.0.0(@types/node@24.0.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.7.0) @@ -9440,7 +9440,6 @@ snapshots: '@types/node@24.0.15': dependencies: undici-types: 7.8.0 - optional: true '@types/parse-json@4.0.2': {} @@ -12015,7 +12014,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.0.1 + '@types/node': 24.0.15 merge-stream: 2.0.0 supports-color: 8.1.1 diff --git a/spiceflow/README.md b/spiceflow/README.md index 102bb15..d938580 100644 --- a/spiceflow/README.md +++ b/spiceflow/README.md @@ -17,7 +17,7 @@ Spiceflow is a type-safe API framework and full-stack React RSC framework focuse - Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting - Works everywhere: Node.js, Bun, and Cloudflare Workers with the same code - Type safe schema based validation via Zod -- Type safe RPC client generation +- Type safe fetch client with full inference on path params, query, body, and response - Simple and intuitive API using web standard Request and Response - Can easily generate OpenAPI spec based on your routes - Native support for [Fern](https://github.com/fern-api/fern) to generate docs and SDKs (see example docs [here](https://remorses.docs.buildwithfern.com)) @@ -122,7 +122,7 @@ const app = new Spiceflow() ## Type Safety for RPC -To maintain type safety when using the RPC client, it's recommended to **throw Response objects for errors** and **return objects directly for success cases**. This pattern ensures that the returned value types are properly inferred: +To maintain type safety when using the fetch client, **throw Response objects for errors** and **return objects directly for success cases**. The fetch client returns `Error | Data` directly — use `instanceof Error` to narrow the type: ```ts import { Spiceflow } from 'spiceflow' @@ -134,7 +134,10 @@ const app = new Spiceflow() path: '/users/:id', params: z.object({ id: z.string(), - }),w + }), + query: z.object({ + q: z.string(), + }), response: z.object({ id: z.string(), name: z.string(), @@ -144,11 +147,9 @@ const app = new Spiceflow() const user = getUserById(params.id) if (!user) { - // Throw Response for errors to maintain type safety throw new Response('User not found', { status: 404 }) } - // Return object directly for success - type will be properly inferred return { id: user.id, name: user.name, @@ -172,13 +173,11 @@ const app = new Spiceflow() const body = await request.json() if (await userExists(body.email)) { - // Throw Response for errors throw new Response('User already exists', { status: 409 }) } const newUser = await createUser(body) - // Return object directly - RPC client will have proper typing return { id: newUser.id, name: newUser.name, @@ -187,30 +186,39 @@ const app = new Spiceflow() }, }) -// RPC client usage with proper type inference -import { createSpiceflowClient } from 'spiceflow/client' - -const client = createSpiceflowClient('http://localhost:3000') +export type App = typeof app +``` -async function example() { - // TypeScript knows data is { id: string, name: string, email: string } | undefined - const { data, error } = await client.users({ id: '123' }).get() +```ts +// client.ts +import { createSpiceflowFetch } from 'spiceflow/client' +import type { App } from './server' - if (error) { - console.error('Error:', error) // Error handling - return - } +const safeFetch = createSpiceflowFetch('http://localhost:3000') - // data is properly typed here - console.log('User:', data.name, data.email) +// Path params are type-safe — TypeScript requires { id: string } +const user = await safeFetch('/users/:id', { params: { id: '123' }, query: { q: 'something'} }) +if (user instanceof Error) { + console.error('Error:', user.message) + return } +// user is typed as { id: string, name: string, email: string } +console.log('User:', user.name, user.email) + +// Body is type-safe — TypeScript requires { name: string, email: string } +const newUser = await safeFetch('/users', { + method: 'POST', + body: { name: 'John', email: 'john@example.com' }, +}) +if (newUser instanceof Error) return newUser +console.log('Created:', newUser.id) ``` With this pattern: - **Success responses**: Return objects directly for automatic JSON serialization and proper type inference -- **Error responses**: Throw `Response` objects to maintain the error/success distinction in the RPC client -- **Type safety**: The RPC client will correctly infer the return type as the success object type +- **Error responses**: Throw `Response` objects — the fetch client returns a `SpiceflowFetchError` with `status`, `value`, and `response` properties +- **Type safety**: The fetch client gives you full type safety on **path params**, **query params**, **request body**, and **response data** — all inferred from your route definitions ## Comparisons @@ -284,87 +292,9 @@ new Spiceflow().route({ }) ``` -## Generate RPC Client - -```ts -import { createSpiceflowClient } from 'spiceflow/client' -import { Spiceflow } from 'spiceflow' -import { z } from 'zod' - -// Define the app with multiple routes and features -const app = new Spiceflow() - .route({ - method: 'GET', - path: '/hello/:id', - handler({ params }) { - return `Hello, ${params.id}!` - }, - }) - .route({ - method: 'POST', - path: '/users', - async handler({ request }) { - const body = await request.json() // here body has type { name?: string, email?: string } - return `Created user: ${body.name}` - }, - request: z.object({ - name: z.string().optional(), - email: z.string().email().optional(), - }), - }) - .route({ - method: 'GET', - path: '/stream', - async *handler() { - yield 'Start' - await new Promise((resolve) => setTimeout(resolve, 1000)) - yield 'Middle' - await new Promise((resolve) => setTimeout(resolve, 1000)) - yield 'End' - }, - }) - -// Create the client -const client = createSpiceflowClient('http://localhost:3000') - -// Example usage of the client -async function exampleUsage() { - // GET request - const { data: helloData, error: helloError } = await client - .hello({ id: 'World' }) - .get() - if (helloError) { - console.error('Error fetching hello:', helloError) - } else { - console.log('Hello response:', helloData) - } - - // POST request - const { data: userData, error: userError } = await client.users.post({ - name: 'John Doe', - email: 'john.doe@example.com', - }) - if (userError) { - console.error('Error creating user:', userError) - } else { - console.log('User creation response:', userData) - } - - // Async generator (streaming) request - const { data: streamData, error: streamError } = await client.stream.get() - if (streamError) { - console.error('Error fetching stream:', streamError) - } else { - for await (const chunk of streamData) { - console.log('Stream chunk:', chunk) - } - } -} -``` - -## Fetch Client (Recommended) +## Type-Safe Fetch Client -`createSpiceflowFetch` is the recommended way to interact with a Spiceflow app. It uses a familiar `fetch(path, options)` interface instead of the proxy-based chainable API of `createSpiceflowClient`. It provides the same type safety for paths, params, query, body, and responses, but with a simpler and more predictable API. +`createSpiceflowFetch` provides a type-safe `fetch(path, options)` interface for calling your Spiceflow API. It gives you full type safety on **path params**, **query params**, **request body**, and **response data** — all inferred from your route definitions. Export the app type from your server code: @@ -428,35 +358,37 @@ Then use the `App` type on the client side without importing server code: import { createSpiceflowFetch } from 'spiceflow/client' import type { App } from './server' -const f = createSpiceflowFetch('http://localhost:3000') +const safeFetch = createSpiceflowFetch('http://localhost:3000') -// Returns Error | Data — check with instanceof Error -const greeting = await f('/hello') -if (greeting instanceof Error) return greeting // early return on error +// GET request — returns Error | Data, check with instanceof Error +const greeting = await safeFetch('/hello') +if (greeting instanceof Error) return greeting console.log(greeting) // 'Hello, World!' — TypeScript knows the type -// POST with typed body -const user = await f('/users', { +// POST with typed body — TypeScript requires { name: string, email: string } +const user = await safeFetch('/users', { method: 'POST', body: { name: 'John', email: 'john@example.com' }, }) if (user instanceof Error) return user -console.log(user.id, user.name) // fully typed +console.log(user.id, user.name, user.email) // fully typed -// Path params — type-safe, required when path has :params -const foundUser = await f('/users/:id', { +// Path params — type-safe, TypeScript requires { id: string } +const foundUser = await safeFetch('/users/:id', { params: { id: '123' }, }) if (foundUser instanceof Error) return foundUser +console.log(foundUser.id) // typed as string -// Query params — typed from route schema -const searchResults = await f('/search', { +// Query params — typed from the route's Zod schema +const searchResults = await safeFetch('/search', { query: { q: 'hello', page: 1 }, }) if (searchResults instanceof Error) return searchResults +console.log(searchResults.results, searchResults.query) // fully typed -// Streaming — returns AsyncGenerator for async generator routes -const stream = await f('/stream') +// Streaming — async generator routes return an AsyncGenerator +const stream = await safeFetch('/stream') if (stream instanceof Error) return stream for await (const chunk of stream) { console.log(chunk) // 'Start', 'Middle', 'End' @@ -470,8 +402,8 @@ The fetch client supports configuration options like headers, retries, onRequest You can also pass a Spiceflow app instance directly for server-side usage without network requests: ```ts -const f = createSpiceflowFetch(app) -const greeting = await f('/hello') +const safeFetch = createSpiceflowFetch(app) +const greeting = await safeFetch('/hello') if (greeting instanceof Error) throw greeting ``` @@ -803,19 +735,19 @@ const app = new Spiceflow().route({ // data: {"message":"Middle"} // data: {"message":"End"} -// Client usage example with RPC client -import { createSpiceflowClient } from 'spiceflow/client' +// Client usage example with fetch client +import { createSpiceflowFetch } from 'spiceflow/client' -const client = createSpiceflowClient('http://localhost:3000') +const safeFetch = createSpiceflowFetch('http://localhost:3000') async function fetchStream() { - const response = await client.sseStream.get() - if (response.error) { - console.error('Error fetching stream:', response.error) - } else { - for await (const chunk of response.data) { - console.log('Stream chunk:', chunk) - } + const stream = await safeFetch('/sseStream') + if (stream instanceof Error) { + console.error('Error fetching stream:', stream.message) + return + } + for await (const chunk of stream) { + console.log('Stream chunk:', chunk) } } @@ -899,20 +831,17 @@ const app = new Spiceflow() In this example, `./public/logo.png` wins over `./dist/client/logo.png` because `./public` is registered first. -## How errors are handled in Spiceflow client - -The Spiceflow client provides type-safe error handling by returning either a `data` or `error` property. When using the client: +## How errors are handled in the fetch client -- Thrown errors appear in the `error` field -- Response objects can be thrown or returned -- Responses with status codes 200-299 appear in the `data` field -- Responses with status codes < 200 or ≥ 300 appear in the `error` field +The fetch client returns `Error | Data` directly. When the server responds with a non-2xx status code, the client returns a `SpiceflowFetchError` instead of the data. Use `instanceof Error` to check: -The example below demonstrates handling different types of responses: +- Responses with status codes 200-299 return the parsed data directly +- Responses with status codes < 200 or ≥ 300 return a `SpiceflowFetchError` +- The error has `status`, `value` (parsed response body), and `response` (raw Response) properties ```ts import { Spiceflow } from 'spiceflow' -import { createSpiceflowClient } from 'spiceflow/client' +import { createSpiceflowFetch } from 'spiceflow/client' const app = new Spiceflow() .route({ @@ -938,41 +867,32 @@ const app = new Spiceflow() }, }) -const client = createSpiceflowClient('http://localhost:3000') +const safeFetch = createSpiceflowFetch('http://localhost:3000') async function handleErrors() { - const errorResponse = await client.error.get() - console.log('Calling error endpoint...') - // Logs: Error occurred: Something went wrong - if (errorResponse.error) { - console.error('Error occurred:', errorResponse.error) + const errorResult = await safeFetch('/error') + if (errorResult instanceof Error) { + console.error('Error occurred:', errorResult.message) } - const unauthorizedResponse = await client.unauthorized.get() - console.log('Calling unauthorized endpoint...') - // Logs: Unauthorized: Unauthorized access (Status: 401) - if (unauthorizedResponse.error) { - console.error('Unauthorized:', unauthorizedResponse.error) + const unauthorizedResult = await safeFetch('/unauthorized') + if (unauthorizedResult instanceof Error) { + console.error('Unauthorized:', unauthorizedResult.message, 'Status:', unauthorizedResult.status) } - const successResponse = await client.success.get() - console.log('Calling success endpoint...') - // Logs: Success: Success message - if (successResponse.data) { - console.log('Success:', successResponse.data) - } + const successResult = await safeFetch('/success') + if (successResult instanceof Error) return + console.log('Success:', successResult) // 'Success message' } ``` -## Using the client server side, without network requests +## Using the fetch client server side, without network requests -When using the client server-side, you can pass the Spiceflow app instance directly to `createSpiceflowClient()` instead of providing a URL. This allows you to make "virtual" requests that are handled directly by the app without making actual network requests. This is useful for testing, generating documentation, or any other scenario where you want to interact with your API endpoints programmatically without setting up a server. - -Here's an example: +You can pass the Spiceflow app instance directly to `createSpiceflowFetch()` instead of providing a URL. This makes "virtual" requests handled directly by the app without actual network requests. Useful for testing, generating documentation, or interacting with your API programmatically without setting up a server. ```tsx import { Spiceflow } from 'spiceflow' -import { createSpiceflowClient } from 'spiceflow/client' +import { createSpiceflowFetch } from 'spiceflow/client' import { openapi } from 'spiceflow/openapi' import { writeFile } from 'node:fs/promises' @@ -996,11 +916,12 @@ const app = new Spiceflow() }, }) -// Create client by passing app instance directly -const client = createSpiceflowClient(app) +// Create fetch client by passing app instance directly +const safeFetch = createSpiceflowFetch(app) // Get OpenAPI schema and write to disk -const { data } = await client.openapi.get() +const data = await safeFetch('/openapi') +if (data instanceof Error) throw data await writeFile('openapi.json', JSON.stringify(data, null, 2)) console.log('OpenAPI schema saved to openapi.json') ``` @@ -1379,7 +1300,7 @@ import path from 'path' import yaml from 'js-yaml' import { Spiceflow } from 'spiceflow' import { openapi } from 'spiceflow/openapi' -import { createSpiceflowClient } from 'spiceflow/client' +import { createSpiceflowFetch } from 'spiceflow/client' const app = new Spiceflow().use(openapi({ path: '/openapi' })).route({ method: 'GET', @@ -1390,14 +1311,13 @@ const app = new Spiceflow().use(openapi({ path: '/openapi' })).route({ }) async function main() { - console.log('Creating Spiceflow client...') - const client = createSpiceflowClient(app) + const safeFetch = createSpiceflowFetch(app) console.log('Fetching OpenAPI spec...') - const { data: openapiJson, error } = await client.openapi.get() - if (error) { - console.error('Failed to fetch OpenAPI spec:', error) - throw error + const openapiJson = await safeFetch('/openapi') + if (openapiJson instanceof Error) { + console.error('Failed to fetch OpenAPI spec:', openapiJson) + throw openapiJson } const outputPath = path.resolve('./openapi.yml') @@ -1776,15 +1696,14 @@ app.listen(3000) When receiving SIGTERM during deployment, the middleware waits for all active requests to complete before exiting. Perfect for AI workloads that may take minutes to process. -### When using `createSpiceflowClient` and getting typescript error `The inferred type of 'pluginApiClient' cannot be named without a reference to '...'. This is likely not portable. A type annotation is necessary. (ts 2742)` +### When using `createSpiceflowFetch` and getting typescript error `The inferred type of '...' cannot be named without a reference to '...'. This is likely not portable. A type annotation is necessary. (ts 2742)` -You can resolve this issue by adding an explicing type for the client: +You can resolve this issue by adding an explicit type for the client: ```ts -export const client: SpiceflowClient.Create = createSpiceflowClient( - PUBLIC_URL, - {}, -) +import type { SpiceflowFetch } from 'spiceflow/client' + +export const f: SpiceflowFetch = createSpiceflowFetch(PUBLIC_URL) ``` ## React Framework (RSC) From d54e8d417ce2425093d169b178d6a071212da079 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 18:23:37 +0100 Subject: [PATCH 221/226] clean up spiceflow dependencies: remove unused packages, move react and vite to peer/dev deps - Remove unused dependencies: es-module-lexer, experimental-fast-webstreams, @vitejs/plugin-react, zod-to-json-schema - Move react and react-dom from dependencies to peerDependencies with '*' specifier - Move vite from dependencies to devDependencies - Drop Zod v3 openapi support (throw error directing users to upgrade to Zod v4) - Add type-only import for ReactFormState in components.tsx --- pnpm-lock.yaml | 36 ++++++------------------------ spiceflow/package.json | 12 ++++------ spiceflow/src/openapi.ts | 9 +++----- spiceflow/src/react/components.tsx | 2 +- 4 files changed, 15 insertions(+), 44 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd7ec66..66ffc20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,24 +332,15 @@ importers: '@standard-schema/spec': specifier: ^1.0.0 version: 1.1.0 - '@vitejs/plugin-react': - specifier: ^6.0.1 - version: 6.0.1(vite@8.0.0(@types/node@24.0.1)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.7.0)) '@vitejs/plugin-rsc': specifier: ^0.5.21 version: 0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.4)))(react@19.2.4)(vite@8.0.0(@types/node@24.0.1)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.7.0)) copy-anything: specifier: ^3.0.5 version: 3.0.5 - es-module-lexer: - specifier: ^1.6.0 - version: 1.7.0 eventsource-parser: specifier: ^3.0.2 version: 3.0.3 - experimental-fast-webstreams: - specifier: ^0.0.19 - version: 0.0.19 history: specifier: ^5.3.0 version: 5.3.0 @@ -363,10 +354,10 @@ importers: specifier: ^12.1.3 version: 12.1.3 react: - specifier: 19.2.4 + specifier: '*' version: 19.2.4 react-dom: - specifier: 19.2.4 + specifier: '*' version: 19.2.4(react@19.2.4) react-server-dom-webpack: specifier: 19.2.4 @@ -377,15 +368,9 @@ importers: superjson: specifier: ^2.2.2 version: 2.2.2 - vite: - specifier: ^8.0.0 - version: 8.0.0(@types/node@24.0.1)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.7.0) zod: specifier: ^4.3.6 version: 4.3.6 - zod-to-json-schema: - specifier: ^3.24.5 - version: 3.25.1(zod@4.3.6) devDependencies: '@modelcontextprotocol/sdk': specifier: ^1.25.3 @@ -414,6 +399,9 @@ importers: js-yaml: specifier: ^4.1.0 version: 4.1.0 + vite: + specifier: ^8.0.0 + version: 8.0.0(@types/node@24.0.1)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.7.0) website: dependencies: @@ -4407,10 +4395,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - experimental-fast-webstreams@0.0.19: - resolution: {integrity: sha512-eusjWLdD8q1zNEQL4YC70sqwf0E30sheZ0ztUV7MtqJC0LurT09DNi50QFL2kwphI5JlYY0DeHBbtX5PcWYxIA==} - engines: {node: '>=20'} - express-rate-limit@8.3.0: resolution: {integrity: sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==} engines: {node: '>= 16'} @@ -9440,6 +9424,7 @@ snapshots: '@types/node@24.0.15': dependencies: undici-types: 7.8.0 + optional: true '@types/parse-json@4.0.2': {} @@ -9545,11 +9530,6 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.0(@types/node@18.16.3)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.7.0) - '@vitejs/plugin-react@6.0.1(vite@8.0.0(@types/node@24.0.1)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.7.0))': - dependencies: - '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.0(@types/node@24.0.1)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.7.0) - '@vitejs/plugin-react@6.0.1(vite@8.0.0(@types/node@24.0.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -11374,8 +11354,6 @@ snapshots: expect-type@1.3.0: {} - experimental-fast-webstreams@0.0.19: {} - express-rate-limit@8.3.0(express@5.2.1): dependencies: express: 5.2.1 @@ -12014,7 +11992,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.0.15 + '@types/node': 24.0.1 merge-stream: 2.0.0 supports-color: 8.1.1 diff --git a/spiceflow/package.json b/spiceflow/package.json index c32810d..4aef66c 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -89,26 +89,21 @@ "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", - "@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-rsc": "^0.5.21", "copy-anything": "^3.0.5", - "es-module-lexer": "^1.6.0", "eventsource-parser": "^3.0.2", - "experimental-fast-webstreams": "^0.0.19", "history": "^5.3.0", "isbot": "^4.1.0", "object-treeify": "^5.0.1", "openapi-types": "^12.1.3", - "react": "19.2.4", - "react-dom": "19.2.4", "react-server-dom-webpack": "19.2.4", "rsc-html-stream": "^0.0.4", "superjson": "^2.2.2", - "vite": "^8.0.0", - "zod": "^4.3.6", - "zod-to-json-schema": "^3.24.5" + "zod": "^4.3.6" }, "peerDependencies": { + "react": "*", + "react-dom": "*", "@modelcontextprotocol/sdk": "*" }, "peerDependenciesMeta": { @@ -117,6 +112,7 @@ } }, "devDependencies": { + "vite": "^8.0.0", "@modelcontextprotocol/sdk": "^1.25.3", "@types/node": "~24.0.1", "@types/react": "^19.2.14", diff --git a/spiceflow/src/openapi.ts b/spiceflow/src/openapi.ts index bf0f421..af573f2 100644 --- a/spiceflow/src/openapi.ts +++ b/spiceflow/src/openapi.ts @@ -7,7 +7,6 @@ let excludeMethods = ['OPTIONS'] import type { TypeSchema } from './types.js' -import { zodToJsonSchema } from 'zod-to-json-schema' import { z } from 'zod/v4' const extractParamNames = (path: string): string[] => { @@ -486,11 +485,9 @@ function getJsonSchema(schema: TypeSchema) { return rest as any } if (isZodSchema(schema)) { - let jsonSchema = zodToJsonSchema(schema as any, { - removeAdditionalStrategy: 'strict', - }) as any - const { $schema, ...rest } = jsonSchema - return rest as any + throw new Error( + `cannot get json schema from Zod v3. update to latest Zod v4 to use openapi spiceflow plugin`, + ) } const { $schema, ...rest } = schema as any diff --git a/spiceflow/src/react/components.tsx b/spiceflow/src/react/components.tsx index 1f86664..3e4dd99 100644 --- a/spiceflow/src/react/components.tsx +++ b/spiceflow/src/react/components.tsx @@ -1,7 +1,7 @@ 'use client' import React, { startTransition, Suspense } from 'react' -import { ReactFormState } from 'react-dom/client' +import type { ReactFormState } from 'react-dom/client' import { router } from './router.js' import { ServerPayload } from '../spiceflow.js' import { isRedirectError, isNotFoundError, getErrorContext, contextHeaders } from './errors.js' From 093448b35937d6f2d144fad1046712e9d3482976 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 18:57:53 +0100 Subject: [PATCH 222/226] decouple index entry from @vitejs/plugin-rsc via conditional #rsc-runtime import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-React users bundling spiceflow with esbuild/webpack would fail because spiceflow.tsx dynamically imports @vitejs/plugin-rsc/rsc, which has top-level imports of virtual:vite-rsc/* modules that only exist inside Vite. Fix: use Node.js subpath imports (#rsc-runtime) with package.json conditions: - react-server → rsc-runtime.ts (re-exports from @vitejs/plugin-rsc/rsc) - default → rsc-runtime.default.ts (throws if called, zero problematic imports) When a non-React user bundles, their bundler resolves #rsc-runtime to the default fallback which has no @vitejs/plugin-rsc dependency at all. Also: - Add check-entry script that validates the index entry bundles with esbuild using zero externals — catches regressions where Vite-only deps leak in - Fix .ts/.tsx import extensions in index.ts to use .js (standard TS convention that works with non-TS bundlers like esbuild) - Add ambient type declaration for #rsc-runtime module --- spiceflow/package.json | 7 +++++++ spiceflow/src/index.ts | 14 +++++++------- spiceflow/src/react/ambient.d.ts | 4 ++++ spiceflow/src/rsc-runtime.default.ts | 17 +++++++++++++++++ spiceflow/src/rsc-runtime.ts | 10 ++++++++++ spiceflow/src/spiceflow.test.ts | 4 ++-- spiceflow/src/spiceflow.tsx | 2 +- 7 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 spiceflow/src/rsc-runtime.default.ts create mode 100644 spiceflow/src/rsc-runtime.ts diff --git a/spiceflow/package.json b/spiceflow/package.json index 4aef66c..22d3627 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -6,6 +6,12 @@ "types": "dist/index.d.ts", "type": "module", "repository": "https://github.com/remorses/spiceflow", + "imports": { + "#rsc-runtime": { + "react-server": "./dist/rsc-runtime.js", + "default": "./dist/rsc-runtime.default.js" + } + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -76,6 +82,7 @@ "fern-sdk": "pnpm run gen-openapi && fern generate --force ", "fern-docs": "pnpm run gen-openapi && fern generate --docs --force", "play-sdk:build": "pnpm vite build --config ./scripts/play-sdk.vite.ts", + "check-entry": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=/dev/null", "test": "pnpm vitest", "prepare": "# pnpm build", "watch": "tsc -w" diff --git a/spiceflow/src/index.ts b/spiceflow/src/index.ts index 053660b..6777de2 100644 --- a/spiceflow/src/index.ts +++ b/spiceflow/src/index.ts @@ -1,12 +1,12 @@ import { SpiceflowRequest, WaitUntil, Method } from './spiceflow.js' -import { serveStatic } from './static-node.ts' -import type { MiddlewareHandler } from './types.ts' +import { serveStatic } from './static-node.js' +import type { MiddlewareHandler } from './types.js' -export { Spiceflow, createSafePath } from './spiceflow.ts' -export { redirect, notFound } from './react/errors.tsx' -export type { AnySpiceflow, WaitUntil } from './spiceflow.ts' -export { ValidationError } from './error.ts' -export { preventProcessExitIfBusy } from './prevent-process-exit-if-busy.ts' +export { Spiceflow, createSafePath } from './spiceflow.js' +export { redirect, notFound } from './react/errors.js' +export type { AnySpiceflow, WaitUntil } from './spiceflow.js' +export { ValidationError } from './error.js' +export { preventProcessExitIfBusy } from './prevent-process-exit-if-busy.js' // utility Response to be used in Cloudflare Workers to shut up the TypeScript errors (cloudflare Response is different than normal Response type) class Response extends globalThis.Response {} diff --git a/spiceflow/src/react/ambient.d.ts b/spiceflow/src/react/ambient.d.ts index 8b320cd..47190dc 100644 --- a/spiceflow/src/react/ambient.d.ts +++ b/spiceflow/src/react/ambient.d.ts @@ -7,6 +7,10 @@ declare module 'virtual:app-entry' { export default any } +declare module '#rsc-runtime' { + export { renderToReadableStream, createTemporaryReferenceSet, decodeReply, decodeAction, decodeFormState, loadServerAction } from '@vitejs/plugin-rsc/rsc' +} + declare module 'virtual:spiceflow-deployment-id' { const deploymentId: string | undefined export default deploymentId diff --git a/spiceflow/src/rsc-runtime.default.ts b/spiceflow/src/rsc-runtime.default.ts new file mode 100644 index 0000000..b47b879 --- /dev/null +++ b/spiceflow/src/rsc-runtime.default.ts @@ -0,0 +1,17 @@ +// Fallback for non-RSC environments. Resolved via package.json #rsc-runtime +// import map under the "default" condition. Does not import @vitejs/plugin-rsc +// so bundlers outside Vite can resolve the entry without errors. + +function unavailable(): never { + throw new Error( + '[spiceflow] RSC runtime is only available in the react-server environment. ' + + 'This error means renderReact was called outside of a Vite RSC build.', + ) +} + +export const renderToReadableStream: any = unavailable +export const createTemporaryReferenceSet: any = unavailable +export const decodeReply: any = unavailable +export const decodeAction: any = unavailable +export const decodeFormState: any = unavailable +export const loadServerAction: any = unavailable diff --git a/spiceflow/src/rsc-runtime.ts b/spiceflow/src/rsc-runtime.ts new file mode 100644 index 0000000..0ffea81 --- /dev/null +++ b/spiceflow/src/rsc-runtime.ts @@ -0,0 +1,10 @@ +// RSC runtime re-exports. Resolved via package.json #rsc-runtime import map +// under the "react-server" condition. Non-RSC environments get rsc-runtime.default.ts instead. +export { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + decodeAction, + decodeFormState, + loadServerAction, +} from '@vitejs/plugin-rsc/rsc' diff --git a/spiceflow/src/spiceflow.test.ts b/spiceflow/src/spiceflow.test.ts index 39eab1d..6a5dc2e 100644 --- a/spiceflow/src/spiceflow.test.ts +++ b/spiceflow/src/spiceflow.test.ts @@ -699,7 +699,7 @@ test('regex constrained route is more specific than a generic param route', asyn test('renderReact passes layout params to layouts instead of page params', async () => { let payload: any - vi.doMock('@vitejs/plugin-rsc/rsc', () => ({ + vi.doMock('#rsc-runtime', () => ({ renderToReadableStream(value) { payload = value return new ReadableStream({ @@ -752,7 +752,7 @@ test('renderReact passes layout params to layouts instead of page params', async layoutId: 'parent', }) } finally { - vi.doUnmock('@vitejs/plugin-rsc/rsc') + vi.doUnmock('#rsc-runtime') vi.resetModules() } }) diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index 8c927b9..d76dbda 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -1125,7 +1125,7 @@ export class Spiceflow< decodeAction, decodeFormState, loadServerAction, - } = await import('@vitejs/plugin-rsc/rsc') + } = await import('#rsc-runtime') // Global CSS for the app entry module. rscCssTransform auto-wraps exported React // component functions, but the app entry exports a Spiceflow instance. This manual // loadCss() call covers CSS imported at the app entry level (e.g. tailwind, resets). From a82d053efcce24958175000eb0a57cc9026bd674 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 19:04:29 +0100 Subject: [PATCH 223/226] vendor @standard-schema/spec and copy-anything to remove external dependencies - Inline @standard-schema/spec v1.1.0 types into src/standard-schema.ts (types only, zero runtime) - Vendor copy-anything@3.0.5 + is-what@4.1.8 into src/copy-anything.ts (~76 lines, deep clone) - Remove both packages from dependencies in package.json - Remove copy-anything from optimizeDeps.include in vite plugin - Keep openapi-types as dependency since DocumentDecoration exports OpenAPIV3.OperationObject and consumers would get `any` without it --- pnpm-lock.yaml | 6 --- spiceflow/package.json | 2 - spiceflow/src/copy-anything.ts | 76 ++++++++++++++++++++++++++++++++ spiceflow/src/spiceflow.tsx | 4 +- spiceflow/src/standard-schema.ts | 39 ++++++++++++++++ spiceflow/src/types.ts | 2 +- spiceflow/src/vite.tsx | 2 +- 7 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 spiceflow/src/copy-anything.ts create mode 100644 spiceflow/src/standard-schema.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66ffc20..b14aa4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -329,15 +329,9 @@ importers: spiceflow: dependencies: - '@standard-schema/spec': - specifier: ^1.0.0 - version: 1.1.0 '@vitejs/plugin-rsc': specifier: ^0.5.21 version: 0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.4)))(react@19.2.4)(vite@8.0.0(@types/node@24.0.1)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.7.0)) - copy-anything: - specifier: ^3.0.5 - version: 3.0.5 eventsource-parser: specifier: ^3.0.2 version: 3.0.3 diff --git a/spiceflow/package.json b/spiceflow/package.json index 22d3627..713b024 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -95,9 +95,7 @@ "author": "Tommaso De Rossi, morse ", "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", "@vitejs/plugin-rsc": "^0.5.21", - "copy-anything": "^3.0.5", "eventsource-parser": "^3.0.2", "history": "^5.3.0", "isbot": "^4.1.0", diff --git a/spiceflow/src/copy-anything.ts b/spiceflow/src/copy-anything.ts new file mode 100644 index 0000000..13daa5f --- /dev/null +++ b/spiceflow/src/copy-anything.ts @@ -0,0 +1,76 @@ +// Vendored from copy-anything@3.0.5 + is-what@4.1.8 to remove external dependencies. +// https://github.com/mesqueeb/copy-anything +// Deep recursive clone of plain objects and arrays. + +function getType(payload: unknown): string { + return Object.prototype.toString.call(payload).slice(8, -1) +} + +function isArray(payload: unknown): payload is unknown[] { + return getType(payload) === 'Array' +} + +function isPlainObject(payload: unknown): payload is Record { + if (getType(payload) !== 'Object') return false + const prototype = Object.getPrototypeOf(payload) + return ( + !!prototype && + prototype.constructor === Object && + prototype === Object.prototype + ) +} + +function assignProp( + carry: Record, + key: PropertyKey, + newVal: unknown, + originalObject: Record, + includeNonenumerable?: boolean, +): void { + const propType = {}.propertyIsEnumerable.call(originalObject, key) + ? 'enumerable' + : 'nonenumerable' + if (propType === 'enumerable') carry[key] = newVal + if (includeNonenumerable && propType === 'nonenumerable') { + Object.defineProperty(carry, key, { + value: newVal, + enumerable: false, + writable: true, + configurable: true, + }) + } +} + +export interface CopyOptions { + props?: PropertyKey[] + nonenumerable?: boolean +} + +export function copy(target: T, options: CopyOptions = {}): T { + if (isArray(target)) { + return target.map((item) => copy(item, options)) as T + } + if (!isPlainObject(target)) { + return target + } + const props = Object.getOwnPropertyNames(target) + const symbols = Object.getOwnPropertySymbols(target) + return [...props, ...symbols].reduce( + (carry, key) => { + if (isArray(options.props) && !options.props.includes(key)) { + return carry + } + const val = target[key] + const newVal = copy(val, options) + assignProp( + carry as Record, + key, + newVal, + target as Record, + options.nonenumerable, + ) + return carry + }, + {} as T, + ) +} diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index d76dbda..3de1a31 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -1,6 +1,6 @@ import type { ReactFormState } from 'react-dom/client' -import { copy } from 'copy-anything' +import { copy } from './copy-anything.js' import superjson from 'superjson' import { SpiceflowFetchError } from './client/errors.js' @@ -63,7 +63,7 @@ import { TrieRouter } from './trie-router/router.js' import { decodeURIComponent_ } from './trie-router/url.js' import { Result } from './trie-router/utils.js' -import { StandardSchemaV1 } from '@standard-schema/spec' +import type { StandardSchemaV1 } from './standard-schema.js' import type { IncomingMessage, ServerResponse } from 'node:http' import { handleForNode, listenForNode } from './_node-server.js' import { renderSsr } from 'spiceflow/handle-ssr' diff --git a/spiceflow/src/standard-schema.ts b/spiceflow/src/standard-schema.ts new file mode 100644 index 0000000..b531b48 --- /dev/null +++ b/spiceflow/src/standard-schema.ts @@ -0,0 +1,39 @@ +// Vendored from @standard-schema/spec v1.1.0 (types only, no runtime code) +// https://github.com/standard-schema/standard-schema + +export interface StandardSchemaV1 { + readonly '~standard': StandardSchemaV1.Props +} + +export namespace StandardSchemaV1 { + export interface Props { + readonly version: 1 + readonly vendor: string + readonly validate: ( + value: unknown, + ) => Result | Promise> + readonly types?: Types | undefined + } + export type Result = SuccessResult | FailureResult + export interface SuccessResult { + readonly value: Output + readonly issues?: undefined + } + export interface FailureResult { + readonly issues: ReadonlyArray + } + export interface Issue { + readonly message: string + readonly path?: ReadonlyArray | undefined + } + export interface PathSegment { + readonly key: PropertyKey + } + export interface Types { + readonly input: Input + readonly output: Output + } + export type InferOutput = NonNullable< + Schema['~standard']['types'] + >['output'] +} diff --git a/spiceflow/src/types.ts b/spiceflow/src/types.ts index 3d56010..18fca44 100644 --- a/spiceflow/src/types.ts +++ b/spiceflow/src/types.ts @@ -1,5 +1,5 @@ // https://github.com/remorses/elysia/blob/main/src/types.ts#L6 -import { StandardSchemaV1 } from '@standard-schema/spec' +import { StandardSchemaV1 } from './standard-schema.js' import z from 'zod' import type { OpenAPIV3 } from 'openapi-types' diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 665d208..e7f03e6 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -185,7 +185,7 @@ export function spiceflowPlugin({ addNoExternal(config, 'spiceflow') config.optimizeDeps.include = mergeUnique( config.optimizeDeps.include, - ['copy-anything', 'superjson', 'zod', 'history'], + ['superjson', 'zod', 'history'], ) } From ae3624de9df80097af0045738048cbb1abad9803 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 17 Mar 2026 19:07:11 +0100 Subject: [PATCH 224/226] inline rsc-html-stream/client, remove dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server side (injectRSCPayload) was already vendored in transform.ts. The client side is ~20 lines that read window.__FLIGHT_DATA into a ReadableStream — inlined directly into entry.client.tsx. One less external dependency to install and keep updated. --- spiceflow/package.json | 1 - spiceflow/src/react/entry.client.tsx | 26 ++++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/spiceflow/package.json b/spiceflow/package.json index 713b024..342d2b8 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -102,7 +102,6 @@ "object-treeify": "^5.0.1", "openapi-types": "^12.1.3", "react-server-dom-webpack": "19.2.4", - "rsc-html-stream": "^0.0.4", "superjson": "^2.2.2", "zod": "^4.3.6" }, diff --git a/spiceflow/src/react/entry.client.tsx b/spiceflow/src/react/entry.client.tsx index ec86423..f10e0d4 100644 --- a/spiceflow/src/react/entry.client.tsx +++ b/spiceflow/src/react/entry.client.tsx @@ -9,8 +9,6 @@ import { encodeReply, setServerCallback, } from '@vitejs/plugin-rsc/browser' -import { rscStream } from 'rsc-html-stream/client' - import { router } from './router.js' import { DefaultGlobalErrorPage, @@ -28,6 +26,30 @@ import { } from './deployment.js' import { getErrorContext } from './errors.js' +// Reads the RSC flight payload that the server injected as