diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2130f22d3d..e67ba0c9be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4199,34 +4199,37 @@ importers: rivetkit-typescript/packages/svelte: dependencies: - '@rivetkit/framework-base': - specifier: 2.1.10 - version: 2.1.10 esm-env: specifier: ^1.2.2 version: 1.2.2 + fast-deep-equal: + specifier: ^3.1.3 + version: 3.1.3 rivetkit: specifier: workspace:* version: link:../rivetkit devDependencies: '@sveltejs/package': specifier: ^2.5.7 - version: 2.5.7(svelte@5.55.1)(typescript@5.9.3) + version: 2.5.7(svelte@5.57.0(@typescript-eslint/types@8.58.0))(typescript@5.9.3) + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 jsdom: specifier: ^28.0.0 version: 28.1.0(@noble/hashes@1.8.0) svelte: - specifier: ^5.0.0 - version: 5.55.1 + specifier: ^5.57.0 + version: 5.57.0(@typescript-eslint/types@8.58.0) svelte-check: specifier: ^4.4.3 - version: 4.4.5(picomatch@4.0.3)(svelte@5.55.1)(typescript@5.9.3) + version: 4.4.5(picomatch@4.0.3)(svelte@5.57.0(@typescript-eslint/types@8.58.0))(typescript@5.9.3) typescript: specifier: ^5.5.0 version: 5.9.3 vitest: specifier: ^4.0.0 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.7)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.0.7)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) rivetkit-typescript/packages/traces: dependencies: @@ -5686,6 +5689,7 @@ packages: '@clerk/types@4.101.12': resolution: {integrity: sha512-ePXOla3B1qgPtV0AzrLx2PVC3s/lsjOSYnuIFAxaIlRNT2+eb/BjeoqtTOcezwbdQ00jQ2RvXahdfZRSEuvZ7A==} engines: {node: '>=18.17.0'} + deprecated: 'This package is no longer supported. Please import types from @clerk/shared/types instead. See the upgrade guide for more info: https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3' '@cloudflare/kv-asset-handler@0.4.0': resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} @@ -5835,6 +5839,7 @@ packages: '@daytonaio/sdk@0.150.0': resolution: {integrity: sha512-JmNulFaLhmpjVVFtaRDZa84fxPuy0axQYVLrj1lvRgcZzcrwJRdHv9FZPMLbKdrbicMh3D7GYA9XeBMYVZBTIg==} + deprecated: 'Moved to @daytona/sdk, same API, no breaking changes. Please update: npm uninstall @daytonaio/sdk && npm i @daytona/sdk' '@daytonaio/toolbox-api-client@0.150.0': resolution: {integrity: sha512-7MCbD1FrzYjOaOmqpMDQe7cyoQTSImEOjQ+6Js4NlBOwPlz2PMi352XuG9qrBp9ngNpo8fpduYr35iDOjrpIVg==} @@ -5922,11 +5927,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} @@ -7127,6 +7132,7 @@ packages: '@hono/node-ws@1.3.0': resolution: {integrity: sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==} engines: {node: '>=18.14.1'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: '@hono/node-server': ^1.19.2 hono: ^4.6.0 @@ -7663,18 +7669,22 @@ packages: '@modelcontextprotocol/inspector-cli@0.14.3': resolution: {integrity: sha512-cAjCfwJUfN1WHc/sGgY/yAQ7K02WOKIso+LzVoKzEr50Nf4R+WKEuq6lhnLfG3f61sU823V8TxRscc8NTYTgww==} + deprecated: 'v1 is deprecated. Upgrade to v2: npm i @modelcontextprotocol/inspector@latest. v1 gets security fixes only, published under the v1-latest tag.' hasBin: true '@modelcontextprotocol/inspector-client@0.14.3': resolution: {integrity: sha512-kbpUYzImbB3VOolyzASfmz08m6kDQvFC0iYv4bF/ZZiwJHaqAPi/i/BIZSrpfRGbAnH+ECqjeRsxRjD6S8pr+g==} + deprecated: 'v1 is deprecated. Upgrade to v2: npm i @modelcontextprotocol/inspector@latest. v1 gets security fixes only, published under the v1-latest tag.' hasBin: true '@modelcontextprotocol/inspector-server@0.14.3': resolution: {integrity: sha512-nstKV26OUHj0Dh4S+M44JsU8UGfCrxfChmczR3GZ1658t6cBLBvw2EeqUgQa4BJ0X/KbDdIgZMX0oYKEE1hYcA==} + deprecated: 'v1 is deprecated. Upgrade to v2: npm i @modelcontextprotocol/inspector@latest. v1 gets security fixes only, published under the v1-latest tag.' hasBin: true '@modelcontextprotocol/inspector@0.14.3': resolution: {integrity: sha512-WtEDqVwXnICveGd39BOF0Q3WxCPQg3MhBzMEDDZp2AZn+pO+eTiRR4bqxTiGkqHUjHODckHkSpu2FQ/A+x5mnQ==} + deprecated: 'v1 is deprecated. Upgrade to v2: npm i @modelcontextprotocol/inspector@latest. v1 gets security fixes only, published under the v1-latest tag.' hasBin: true '@modelcontextprotocol/sdk@1.25.3': @@ -9148,9 +9158,6 @@ packages: '@rivetkit/fast-json-patch@3.1.2': resolution: {integrity: sha512-CtA50xgsSSzICQduF/NDShPRzvucnNvsW/lQO0WgMTT1XAj9Lfae4pm7r3llFwilgG+9iq76Hv1LUqNy72v6yw==} - '@rivetkit/framework-base@2.1.10': - resolution: {integrity: sha512-ZU1gsdyeu5jEf1OR3MevKzZyU6G+z2bR127Nxqo3mLcg+JQIEqxQN/szzQmaqEx5uhjhsZTKSwglhcgnRr25aQ==} - '@rivetkit/on-change@6.0.2-rc.1': resolution: {integrity: sha512-5RC9Ze/wTKqSlJvopdCgr+EfyV93+iiH8Thog0QXrl8PT1unuBNw/jadXNMtwgAxrIaCJL+JLaHQH9w7rqpMDw==} engines: {node: '>=20'} @@ -9927,8 +9934,8 @@ packages: resolution: {integrity: sha512-w8CEY73X/7tw2KKlL3iOk679V9bWseE4GzNz3zlaYxcTjmcmWOathRb0emgo/QQ3eoNzmq68+2Y2gxluAv3xGw==} engines: {node: '>=12.16'} - '@sveltejs/acorn-typescript@1.0.9': - resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} peerDependencies: acorn: ^8.9.0 @@ -10614,6 +10621,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@urql/core@5.2.0': resolution: {integrity: sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==} @@ -11959,6 +11967,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -12327,8 +12336,8 @@ packages: devalue@5.6.1: resolution: {integrity: sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==} - devalue@5.6.4: - resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} + devalue@5.9.2: + resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -12791,6 +12800,7 @@ packages: eslint@9.39.1: resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -12814,8 +12824,13 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} - esrap@2.2.4: - resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} + esrap@2.3.7: + resolution: {integrity: sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} @@ -13304,9 +13319,11 @@ packages: freestyle-sandboxes@0.0.66: resolution: {integrity: sha512-3rgkjq5bU2pwe2kfX58gcuHtCoP9RfHx3Zbh4BCPxlzJntyPG/VTtXR1e9z12JLt3lnGdUsEqs/sfhMso1Lslw==} + deprecated: This package has been deprecated. Please use freestyle instead. freestyle-sandboxes@0.0.95: resolution: {integrity: sha512-Wsw8i8EE6f1vfJVZNsS/5c/Lp5Cx2DUiOy+3HjU3R+mA8296fIQs+6pNMg0T0nNZaEgqTNEDQqMoBckNZz+gWA==} + deprecated: This package has been deprecated. Please use freestyle instead. fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} @@ -16231,6 +16248,7 @@ packages: recharts@2.15.4: resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: 19.1.0 react-dom: 19.1.0 @@ -17007,8 +17025,8 @@ packages: svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 typescript: ^4.9.4 || ^5.0.0 - svelte@5.55.1: - resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==} + svelte@5.57.0: + resolution: {integrity: sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==} engines: {node: '>=18'} svgo@4.0.0: @@ -17264,6 +17282,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -17699,6 +17718,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.0: @@ -17711,6 +17731,7 @@ packages: uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -21635,20 +21656,20 @@ snapshots: '@types/node': 22.19.10 optional: true - '@inquirer/confirm@5.1.21(@types/node@24.7.1)': + '@inquirer/confirm@5.1.21(@types/node@22.19.15)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.7.1) - '@inquirer/type': 3.0.10(@types/node@24.7.1) + '@inquirer/core': 10.3.2(@types/node@22.19.15) + '@inquirer/type': 3.0.10(@types/node@22.19.15) optionalDependencies: - '@types/node': 24.7.1 + '@types/node': 22.19.15 optional: true - '@inquirer/confirm@5.1.21(@types/node@25.0.7)': + '@inquirer/confirm@5.1.21(@types/node@24.7.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.0.7) - '@inquirer/type': 3.0.10(@types/node@25.0.7) + '@inquirer/core': 10.3.2(@types/node@24.7.1) + '@inquirer/type': 3.0.10(@types/node@24.7.1) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.7.1 optional: true '@inquirer/core@10.3.2(@types/node@20.19.13)': @@ -21678,32 +21699,32 @@ snapshots: '@types/node': 22.19.10 optional: true - '@inquirer/core@10.3.2(@types/node@24.7.1)': + '@inquirer/core@10.3.2(@types/node@22.19.15)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.7.1) + '@inquirer/type': 3.0.10(@types/node@22.19.15) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.7.1 + '@types/node': 22.19.15 optional: true - '@inquirer/core@10.3.2(@types/node@25.0.7)': + '@inquirer/core@10.3.2(@types/node@24.7.1)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.0.7) + '@inquirer/type': 3.0.10(@types/node@24.7.1) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.7.1 optional: true '@inquirer/figures@1.0.15': {} @@ -21717,14 +21738,14 @@ snapshots: '@types/node': 22.19.10 optional: true - '@inquirer/type@3.0.10(@types/node@24.7.1)': + '@inquirer/type@3.0.10(@types/node@22.19.15)': optionalDependencies: - '@types/node': 24.7.1 + '@types/node': 22.19.15 optional: true - '@inquirer/type@3.0.10(@types/node@25.0.7)': + '@inquirer/type@3.0.10(@types/node@24.7.1)': optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.7.1 optional: true '@isaacs/balanced-match@4.0.1': {} @@ -23946,12 +23967,6 @@ snapshots: '@rivetkit/fast-json-patch@3.1.2': {} - '@rivetkit/framework-base@2.1.10': - dependencies: - '@tanstack/store': 0.7.5 - fast-deep-equal: 3.1.3 - rivetkit: link:rivetkit-typescript/packages/rivetkit - '@rivetkit/on-change@6.0.2-rc.1': {} '@rivetkit/sqlite@0.1.1': {} @@ -25027,18 +25042,18 @@ snapshots: '@stripe/stripe-js@5.6.0': {} - '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': + '@sveltejs/acorn-typescript@1.0.13(acorn@8.16.0)': dependencies: acorn: 8.16.0 - '@sveltejs/package@2.5.7(svelte@5.55.1)(typescript@5.9.3)': + '@sveltejs/package@2.5.7(svelte@5.57.0(@typescript-eslint/types@8.58.0))(typescript@5.9.3)': dependencies: chokidar: 5.0.0 kleur: 4.1.5 sade: 1.8.1 semver: 7.7.4 - svelte: 5.55.1 - svelte2tsx: 0.7.52(svelte@5.55.1)(typescript@5.9.3) + svelte: 5.57.0(@typescript-eslint/types@8.58.0) + svelte2tsx: 0.7.52(svelte@5.57.0(@typescript-eslint/types@8.58.0))(typescript@5.9.3) transitivePeerDependencies: - typescript @@ -25592,7 +25607,7 @@ snapshots: '@types/node-fetch@2.6.11': dependencies: - '@types/node': 22.19.10 + '@types/node': 22.19.15 form-data: 4.0.5 '@types/node@17.0.33': {} @@ -25662,7 +25677,7 @@ snapshots: '@types/readable-stream@4.0.21': dependencies: - '@types/node': 22.19.10 + '@types/node': 22.19.15 '@types/reconnectingwebsocket@1.0.10': {} @@ -25703,7 +25718,8 @@ snapshots: fflate: 0.8.2 meshoptimizer: 0.18.1 - '@types/trusted-types@2.0.7': {} + '@types/trusted-types@2.0.7': + optional: true '@types/unist@2.0.11': {} @@ -25715,7 +25731,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 22.19.10 + '@types/node': 22.19.15 '@types/yargs-parser@21.0.3': {} @@ -25727,7 +25743,8 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/types@8.58.0': {} + '@typescript-eslint/types@8.58.0': + optional: true '@uiw/codemirror-extensions-basic-setup@4.25.1(@codemirror/autocomplete@6.18.7)(@codemirror/commands@6.8.1)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.38.2)': dependencies: @@ -26093,14 +26110,14 @@ snapshots: msw: 2.12.10(@types/node@20.19.13)(typescript@5.9.3) vite: 6.4.1(@types/node@20.19.13)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@25.0.7)(typescript@5.9.3))(vite@6.4.1(@types/node@25.0.7)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(vite@6.4.1(@types/node@22.19.15)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.12.10(@types/node@25.0.7)(typescript@5.9.3) - vite: 6.4.1(@types/node@25.0.7)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + msw: 2.12.10(@types/node@22.19.15)(typescript@5.9.3) + vite: 6.4.1(@types/node@22.19.15)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@2.1.9': dependencies: @@ -26440,10 +26457,6 @@ snapshots: acorn: 8.16.0 optional: true - acorn-jsx@5.3.2(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -26452,7 +26465,7 @@ snapshots: acorn-walk@8.3.4: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn@8.14.0: {} @@ -27961,7 +27974,7 @@ snapshots: devalue@5.6.1: {} - devalue@5.6.4: {} + devalue@5.9.2: {} devlop@1.1.0: dependencies: @@ -28454,9 +28467,10 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.2.4: + esrap@2.3.7(@typescript-eslint/types@8.58.0): dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + optionalDependencies: '@typescript-eslint/types': 8.58.0 esrecurse@4.3.0: @@ -31035,8 +31049,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) micromark-extension-mdx-expression: 3.0.1 micromark-extension-mdx-jsx: 3.0.2 micromark-extension-mdx-md: 2.0.0 @@ -31293,7 +31307,7 @@ snapshots: mlly@1.8.0: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.1 @@ -31409,9 +31423,9 @@ snapshots: - '@types/node' optional: true - msw@2.12.10(@types/node@24.7.1)(typescript@5.9.3): + msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@24.7.1) + '@inquirer/confirm': 5.1.21(@types/node@22.19.15) '@mswjs/interceptors': 0.41.2 '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6 @@ -31435,9 +31449,9 @@ snapshots: - '@types/node' optional: true - msw@2.12.10(@types/node@25.0.7)(typescript@5.9.3): + msw@2.12.10(@types/node@24.7.1)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@25.0.7) + '@inquirer/confirm': 5.1.21(@types/node@24.7.1) '@mswjs/interceptors': 0.41.2 '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6 @@ -33643,43 +33657,44 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.55.1)(typescript@5.9.3): + svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.57.0(@typescript-eslint/types@8.58.0))(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.55.1 + svelte: 5.57.0(@typescript-eslint/types@8.58.0) typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte2tsx@0.7.52(svelte@5.55.1)(typescript@5.9.3): + svelte2tsx@0.7.52(svelte@5.57.0(@typescript-eslint/types@8.58.0))(typescript@5.9.3): dependencies: dedent-js: 1.0.1 scule: 1.3.0 - svelte: 5.55.1 + svelte: 5.57.0(@typescript-eslint/types@8.58.0) typescript: 5.9.3 - svelte@5.55.1: + svelte@5.57.0(@typescript-eslint/types@8.58.0): dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.16.0) '@types/estree': 1.0.8 - '@types/trusted-types': 2.0.7 acorn: 8.16.0 aria-query: 5.3.1 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.6.4 + devalue: 5.9.2 esm-env: 1.2.2 - esrap: 2.2.4 + esrap: 2.3.7(@typescript-eslint/types@8.58.0) is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' svgo@4.0.0: dependencies: @@ -34423,7 +34438,7 @@ snapshots: unplugin@1.0.1: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 chokidar: 3.6.0 webpack-sources: 3.3.3 webpack-virtual-modules: 0.5.0 @@ -34436,7 +34451,7 @@ snapshots: unplugin@2.3.10: dependencies: '@jridgewell/remapping': 2.3.5 - acorn: 8.15.0 + acorn: 8.16.0 picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 @@ -34870,6 +34885,26 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vite@6.4.1(@types/node@22.19.15)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + jiti: 1.21.7 + less: 4.4.1 + lightningcss: 1.32.0 + sass: 1.93.2 + stylus: 0.62.0 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + vite@6.4.1(@types/node@25.0.7)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -35186,10 +35221,10 @@ snapshots: - tsx - yaml - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.7)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.0.7)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(less@4.4.1)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@25.0.7)(typescript@5.9.3))(vite@6.4.1(@types/node@25.0.7)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(vite@6.4.1(@types/node@22.19.15)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -35206,11 +35241,11 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@25.0.7)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@22.19.15)(jiti@1.21.7)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 25.0.7 + '@types/node': 22.19.15 jsdom: 28.1.0(@noble/hashes@1.8.0) transitivePeerDependencies: - jiti diff --git a/rivetkit-typescript/packages/svelte/AGENTS.md b/rivetkit-typescript/packages/svelte/AGENTS.md index b28a99660c..230c25fa80 100644 --- a/rivetkit-typescript/packages/svelte/AGENTS.md +++ b/rivetkit-typescript/packages/svelte/AGENTS.md @@ -1,269 +1,9 @@ -# Packages / rivetkit-svelte - -**Parent:** [Root](../../AGENTS.md) - -Official Svelte 5 adapter for RivetKit actors. Thin adapter over `@rivetkit/framework-base`, with Svelte-first ergonomics for app-owned typed context, shared clients, and reactive actor handles. - ---- - -## Workspace - -| | | -| ----------- | -------------------------------------------------------------------- | -| Package | `@rivetkit/svelte` | -| Scripts | `build`, `check-types`, `test` | -| Depends on | `@rivetkit/framework-base`, `rivetkit`, `esm-env` | -| Peer deps | `svelte` ^5.0.0 | -| Dev deps | `vitest`, `jsdom`, `@sveltejs/package`, `svelte-check`, `typescript` | -| Consumed by | `apps/web`, `apps/connectivity-source`, and external SvelteKit apps | - -## Architecture - -The package supports two primary shapes: - -```text -Provider pattern: - app-local module - → createRivetContext() - → layout calls context.set(...) or context.setup(...) - → descendants call appLocalContext.get().useActor(...) - -Shared-client pattern: - app-local createClient(...) - → createSharedRivetKit(() => client) - → shared wrapper reused by ViewModels and provider setup -``` - -Reactive actor state is still powered by framework-base subscriptions bridged into Svelte runes: - -```text -useActor(opts | () => opts) - → extract(MaybeGetter) - → framework-base getOrCreateActor() - → $effect subscription - → getter-backed object + Proxy-forwarded actor methods - -createReactiveActor(opts) - → framework-base getOrCreateActor() - → manual subscription lifecycle - → getter-backed object + Proxy-forwarded actor methods - -preloadActor(opts) - → BROWSER guard (no-op during SSR) - → dedup check (Set keyed by name:key) - → client Proxy accessor → getOrCreate(key) - → handle.resolve() (single HTTP PUT, no WebSocket) - → fire-and-forget (catch removes from dedup on failure) -``` - -## Structure - -```text -packages/rivetkit-svelte/ -├── package.json -├── tsconfig.json -├── AGENTS.md -├── README.md -└── src/ - ├── check-types/ - │ └── noop.svelte # Keeps svelte-check happy for the package workspace - └── lib/ - ├── index.ts # Main barrel exports - ├── rivetkit.svelte.ts # createRivetKit, createReactiveActor, useActor, ActionDefaults - ├── shared.svelte.ts # createSharedRivetKit, withActorParams, createReactiveConnection - ├── context.ts # createRivetContext - ├── connection-health.svelte.ts # createConnectionHealth aggregate health - ├── internal/ - │ ├── types.ts # Getter, MaybeGetter - │ └── extract.ts # extract(MaybeGetter) - ├── testing/ - │ ├── index.ts - │ └── test-helpers.svelte.ts - └── __tests__/ - ├── action-middleware.test.ts # Action middleware interceptor tests (12 tests) - ├── context.test.ts - ├── reactive-actor.test.ts - ├── shared.test.ts - ├── helpers.ts - └── runes-shim.ts -``` - -## Public API - -### Factory Functions - -- `createRivetKit(endpoint?, opts?)` -- `createRivetKitWithClient(client, opts?)` - -Both return `{ useActor, createReactiveActor, preloadActor }`. - -### Context Helper - -- `createRivetContext()` — typed context helper with `set`, `get`, `has`, `setup`, `setupWithClient` - -Apps are expected to create and own their own context instance. The package no longer exports default-context helpers. - -### Shared-Client / Mixed-Mode Helpers - -- `createSharedRivetKit(getClient, opts?)` — lazily reuse one RivetKit wrapper for a shared client factory -- `withActorParams(base, params)` — merge actor options with static or reactive params -- `createReactiveConnection(source)` — bridge raw connection handling into reactive `connStatus` / `error` state - -### Action Middleware (`actionDefaults`) - -Both `useActor` and `createReactiveActor` accept an `actionDefaults` option (also configurable at the client level via `SvelteRivetKitOptions`). When provided, every proxied action call is wrapped with built-in middleware: - -- **Timeout** — configurable per-actor or per-client -- **Error capture** — errors captured to `lastActionError` reactive state (not thrown by default) -- **Loading tracking** — `isMutating`, `pendingActions` counters updated automatically -- **Connection guard** — rejects immediately if disconnected (configurable) -- **Lifecycle callbacks** — `onActionStart`, `onActionSuccess`, `onActionError`, `onActionSettled` - -```typescript -const actor = rivet.createReactiveActor({ - name: "user", - key: ["user", userId], - actionDefaults: { - timeout: 30_000, - throwOnError: false, // default — errors captured reactively - onActionError: (err, name) => console.error(name, err), - }, -}); - -// Direct action call — no manual wrapping needed -await actor.updateProfile({ name: "New" }); - -// Reactive tracking (all $state-backed) -actor.isMutating; // boolean -actor.pendingActions; // number -actor.lastActionError; // Error | null -actor.lastAction; // string | null -actor.resetActionState(); // clear error state -``` - -**Cascade:** Client-level `actionDefaults` are shallow-merged with actor-level overrides. Actor-level wins. - -**Types:** `ActionDefaults`, `SvelteRivetKitOptions` exported from `@rivetkit/svelte`. - -### Reactive Actor Primitives - -- `useActor(opts: MaybeGetter)` - - component initialization only - - accepts static options or a getter thunk - - returns getter-backed reactive metadata plus proxied actor methods - - exposes `lastError` and `hasEverConnected` in addition to `connection`, `handle`, `connStatus`, `error`, `isConnected`, `hash`, `onEvent` - - when `actionDefaults` provided: also exposes `isMutating`, `pendingActions`, `lastActionError`, `lastAction`, `resetActionState()` - -- `createReactiveActor(opts)` - - safe in modules and `.svelte.ts` classes - - manual lifecycle via `mount()` and `dispose()` - - `onEvent()` rebinds listeners when the underlying connection changes - - proxied actor methods are cached per connection instance for stable repeated reads - - when `actionDefaults` provided: also exposes `isMutating`, `pendingActions`, `lastActionError`, `lastAction`, `resetActionState()` - -### Actor Preloading - -- `preloadActor(opts: PreloadActorOptions)` - - wakes an actor via a single HTTP `resolve()` call — no WebSocket connection - - analogous to SvelteKit's `data-sveltekit-preload-data` for routes - - deduplicates: same actor (name + key) is only resolved once per RivetKit lifetime - - fire-and-forget: errors are silently caught; failed attempts removed from dedup set for retry - - SSR-safe: no-ops when `BROWSER` is false (via `esm-env`) - - intended for hover-based preloading to eliminate cold-start latency - -```typescript -// Preload a document actor on hover -rivet.preloadActor({ name: "document", key: ["doc", docId] }); -``` - -### Other Exports - -- `createConnectionHealth(getSources)` -- `extract()` -- `Getter` / `MaybeGetter` -- `PreloadActorOptions` type -- `ActionDefaults` type -- `SvelteRivetKitOptions` type -- `createClient` re-export from `rivetkit/client` -- `ActorConnStatus`, `ActorOptions`, `AnyActorRegistry` types - -### Testing Subpath - -`@rivetkit/svelte/testing` exports: - -- `testWithEffect(name, fn)` -- `effectRootScope(fn)` - -## Design Notes - -### App-Owned Typed Context - -The preferred provider-level API is `createRivetContext()`. Each app should own a local context instance rather than depending on a package-global default context. - -### Shared Client Ownership Is Explicit - -The package does not hide the raw `rivetkit/client` model. Apps that want a single transport should own that client locally and wrap it with `createSharedRivetKit()`. - -### App-Owned Auth - -Auth stays outside the package. `withActorParams()` exists to make token/org/session params ergonomic without baking Better Auth, Layerr token refresh, or framework-specific session logic into the adapter. - -### Familiar Svelte Conventions - -- `Getter` / `MaybeGetter` follow the same ergonomic direction teams will recognize from Runed and Bits UI -- provider/shared-client setup maps well to the mental model teams already have from TanStack Query -- composable primitives are preferred over monolithic app-framework wrappers - -### Closure-Based Rune State - -`createReactiveActor()` uses closure-based `$state` instead of class-field state so Proxy forwarding works correctly. Svelte class-field runes compile to private fields, and private fields do not cooperate with JS `Proxy`. - -### Action Middleware Architecture - -The action interceptor is built from `ActionDefaults` and passed to `proxyWithConnection()`. Every proxied method call flows through the interceptor, which: - -1. Checks connection guard (fail-fast if disconnected) -2. Increments `pendingActions` / sets `isMutating` -3. Races the action against timeout (if configured) -4. On success: clears `lastActionError`, fires `onActionSuccess` -5. On failure: captures error to `lastActionError`, fires `onActionError`. With `throwOnError: false` (default), resolves to `undefined` instead of rejecting. -6. Decrements `pendingActions` / clears `isMutating` when all actions complete - -The interceptor is a closure that captures `$state` variables directly — same pattern as the existing connection state tracking. No class fields, no double-proxy. - -### SSR Safety - -- `useActor()` is SSR-safe because `$effect` is the browser-only lifecycle boundary -- `createReactiveActor()` can be constructed during SSR, but `mount()` still belongs in a browser lifecycle -- `preloadActor()` is SSR-safe — guarded by `BROWSER` from `esm-env`; no-ops during SSR to avoid wasteful HTTP calls -- prefer app-local typed context and browser-owned singletons over request-time mutable globals in SvelteKit code - -## Integration With apps/web - -The web app now uses: - -- `apps/web/src/lib/context/actor-rivet-context.ts` for the app-local typed Rivet context -- `apps/web/src/lib/clients/actor-client.ts` for shared raw client and shared wrapper ownership - -That composition point owns: - -- `getActorClient(endpoint)` — shared raw client per endpoint -- `getActorRivet(endpoint)` — shared wrapper via `createSharedRivetKit()` -- `setupActorRivetContext(endpoint)` — typed-context provider setup - -`BaseActorViewModel` consumes `getActorRivet(endpoint)` and `withActorParams(...)`, so primary reactive actors and any lazy actor handles continue sharing one transport while Layerr-specific token refresh remains in app code. - -`BaseActorViewModel._createAndMount()` passes `actionDefaults` with `timeout: 30_000`, `throwOnError: false`, `guardConnection: true`, and callback bridges that sync the package's action tracking state to the ViewModel's reactive `isMutating` and `error` fields. Subclass ViewModels call `this.actor.someAction()` directly — no `callAction()` wrapper needed. The `callAction` method still exists as a thin adapter for legacy code that expects the `T | null` return shape. - -## Verification Expectations - -When changing this package, verify at minimum: - -- `bun run --filter @rivetkit/svelte check-types` -- `bun run --filter @rivetkit/svelte test` -- `bun run --filter @rivetkit/svelte build` when public exports or generated declarations changed -- consumer typecheck for `apps/web` and any other in-repo Svelte consumer if package surface or inferred types changed - -## Related - -[README.md](./README.md) | [Root AGENTS.md](../../AGENTS.md) | [Web App](../../apps/web/AGENTS.md) | [Real-Time Architecture](../../docs/architecture/Real-Time%20Architecture.md) +# Svelte adapter + +- Authentication and shared-client ownership belong to the consuming application; do not add application-specific session or authorization behavior. +- Use a package-local framework bridge until the shared framework package forwards dynamic connection parameters; standalone installs must not require root patches. +- The framework bridge's reactivity core is a minimal imperative observable (a `Map` of per-actor entries with per-entry listener sets). Do not reintroduce `@tanstack/store` or use Svelte's reactive collections (`SvelteMap`, `createSubscriber`) for the registry itself: their writes re-run the reading effect, but a framework push must reach `applyState` and the `$state` slots WITHOUT re-running the `useActor()` effect. Plain objects stay invisible to Svelte dependency tracking, which is the contract the adapter needs. `createSubscriber` is used only where a reactive read is genuinely wanted (inspector `revision`/`snapshot()`). +- Construction is inert; only a mounted browser lifecycle may acquire a connection, and every mount reference must be released independently. +- Preserve `preloadActor` as the deprecated alias of `warmUp`, and preserve reusable raw-connection disposal. +- Inspector snapshots must use opaque IDs and must never expose raw framework hashes or connection parameters. +- Validate from this directory with `pnpm test`, `pnpm check-types`, and `pnpm build`; verify the packed package against public dependencies when updating the SDK contract. diff --git a/rivetkit-typescript/packages/svelte/README.md b/rivetkit-typescript/packages/svelte/README.md index 01a95a2e0a..55601654b0 100644 --- a/rivetkit-typescript/packages/svelte/README.md +++ b/rivetkit-typescript/packages/svelte/README.md @@ -9,7 +9,7 @@ Official Svelte 5 adapter for [RivetKit](https://rivet.gg) actors. - reactive actor state via Svelte runes - mixed reactive/raw connection handling when low-level control still matters -Built on `@rivetkit/framework-base`, alongside the React adapter, but shaped for Svelte patterns that feel familiar if you already use TanStack Query for shared clients, Runed for getter ergonomics, Bits UI for composable primitives, or Better Auth for app-owned auth wiring. +Built on a small package-local, Apache-2.0-derived Rivet framework bridge and shaped for Svelte patterns that feel familiar if you already use TanStack Query for shared clients, Runed for getter ergonomics, Bits UI for composable primitives, or Better Auth for app-owned auth wiring. Shipping the bridge with the adapter guarantees that `getParams` reaches every connect/reconnect in standalone installs. ## Install @@ -95,12 +95,12 @@ That shared-client mental model mirrors how TanStack Query centralizes one clien ## Picking The Right Primitive -| Primitive | Best for | Lifecycle | -| ---------------------------------- | --------------------------------------------------------------------- | ----------------------- | -| `useActor()` | Components that render live actor state | Automatic via `$effect` | -| `createReactiveActor()` | ViewModels, singletons, manual connection ownership | `mount()` / `dispose()` | -| shared raw client (`createClient`) | One-off actions, low-level handles, custom orchestration | App-owned | -| `createReactiveConnection()` | Bridging a raw connection into reactive connection status/error state | App-owned | +| Primitive | Best for | Lifecycle | +| ---------------------------------- | --------------------------------------------------------------------- | ---------------------------------------- | +| `useActor()` | Components that render live actor state | Automatic via `$effect` | +| `createReactiveActor()` | ViewModels, singletons, manual connection ownership | Cheap construction; connect on `mount()` | +| shared raw client (`createClient`) | One-off actions, low-level handles, custom orchestration | App-owned | +| `createReactiveConnection()` | Bridging a raw connection into reactive connection status/error state | App-owned | A good rule of thumb: @@ -168,7 +168,28 @@ reactive.error; reactive.isConnected; ``` -This is useful when a low-level handle should stay low-level, but the UI still wants Svelte-friendly `connStatus` and `error` reads. +This is useful when a low-level handle should stay low-level, but the UI still wants Svelte-friendly `connStatus` and `error` reads. `disconnect()` closes the current socket but retains event registrations for a later `connect()`. For backward compatibility, `dispose()` remains an alias of `disconnect()` rather than permanently invalidating the wrapper. Both detach reactive connection state and settle connection waiters immediately, even if transport teardown is slow or fails; concurrent teardown calls share the same promise. + +### `connectionInspector` (opt-in) + +A RivetKit instance can expose a live registry of **distinct package-managed sockets** — the connections opened by `useActor()`, `createReactiveActor()`, and `preConnect()`. `warmUp()` is HTTP-only and is not listed. `createReactiveConnection()` is a raw-path bridge and is also not listed. + +The registry is **off by default**. Enable it only for local-dev overlays: + +```ts +const rivet = createRivetKitWithClient(client, { + connectionInspector: true, +}); + +rivet.connectionInspector?.snapshot(); +// [{ name: "page", key: ["page-1"], hash, connStatus: "connected", hasConnection: true }] +rivet.connectionInspector?.connectedCount(); +rivet.connectionInspector?.revision; // read inside $derived to refresh +``` + +Rows use opaque identifiers scoped to the inspector; framework hashes and connection parameters are never exposed. Two handles that share a socket share one row; disposing one handle cannot drop the other handle's row. A snapshot copies only `name`, `key`, `hash`, `connStatus`, and `hasConnection`. + +Enable the inspector in a development-only client configuration when building a connection status overlay. ## Core Factories And Utilities @@ -179,6 +200,7 @@ These APIs remain part of the public surface: - `useActor()` - `createReactiveActor()` - `createConnectionHealth()` +- `createConnectionInspector()` — opt-in live registry of package-managed sockets - `extract()`, `Getter`, `MaybeGetter` ## `useActor()` @@ -252,10 +274,34 @@ export class ChatViewModel { `createReactiveActor()` is the right primitive when the app wants ref counting, token refresh, lazy secondary connections, or other orchestration on top. +Construction is side-effect-light: it creates the Svelte-facing proxy, but does not subscribe to the framework core or open a connection until `mount()` runs. `dispose()` releases active `mount()` refs, removes package subscriptions/listeners, resolves pending `whenConnected()` waiters with `false`, and immediately resets connection/action state so retained handlers cannot call a detached socket. + +### `reconnect()` — replace a zombie socket + +```ts +// Recovery sweep (online / tab-focus / watchdog): a liveness probe failed, so +// force a brand-new connection even though connStatus still reads "connected". +if (!(await probeLiveness())) actor.reconnect(); +``` + +`reconnect()` tears down the current connection — even a half-open **zombie** socket (NAT/LB idle cull, half-open TCP) that still reports `connStatus === "connected"` — and opens a fresh one, re-running `getParams` for a new auth token. Event subscriptions registered via `onEvent()` are automatically re-bound onto the new connection. + +It drives the framework core's `enabled` toggle (disable → dispose + reset to `idle` → re-enable → create from `idle`). The adapter removes `enabled` before invoking a custom `hashFunction`, so both phases always address the same framework entry. A plain `dispose()` + `mount()` cannot do this: the core only creates a connection from `idle`, and a zombie never leaves `"connected"`, so the dead socket would be reused. `reconnect()` is a no-op if the actor was never mounted. + ## Action Middleware +Use `timeoutByAction: { getSnapshot: 15_000 }` alongside a longer default +`timeout` when reads and mutations have different budgets. Finite positive +overrides settle adapter counters on their own deadline. Timeout and disposal +settle the adapter caller and forward an abort signal when supported. RivetKit +2.3.17 does not cancel its internal action waiter from that signal; server work +also continues. Disposed in-flight actions follow `throwOnError` rather than +returning late results. + Both `useActor()` and `createReactiveActor()` accept an `actionDefaults` option that wraps every proxied action call with built-in middleware — timeout, error capture, loading tracking, and connection guard. No manual wrapping needed. +The action hot path keeps non-reactive mirrors of connection status and pending action count. That avoids accidental Svelte effect subscriptions when a proxied method is called from an effect, without wrapping every action dispatch in `untrack()`. Action functions are stable, recursively proxied, and resolve the current connection only when invoked, so destructured event handlers survive reconnects and nested actions such as `actor.admin.reset()` work. The actor proxy is explicitly non-thenable. + ### Quick Start ```ts @@ -321,49 +367,46 @@ const actor = rivet.createReactiveActor({ }); ``` -### ViewModel Pattern (Direct Action Calls) +### Direct Action Calls -With `actionDefaults` wired in the base class, ViewModel methods call actor actions directly. No wrapping needed. +Configure middleware on an actor handle and call its actions directly: ```ts -class NotificationsVM extends BaseActorViewModel { - // Before — every action required callAction wrapping: - // async markAsRead(ids: string[]) { - // const result = await this.callAction( - // () => this.actor.markAsRead({ ids }), - // "Failed to mark as read", - // ); - // if (result) this.toastSuccess("Marked as read"); - // return result !== null; - // } - - // After — direct call, package handles the rest: - async markAsRead(ids: string[]): Promise { - const result = await this.actor.markAsRead({ ids }); - if (result != null) { - this.toastSuccess("Marked as read"); - return true; - } - return false; - } +const notifications = rivet.createReactiveActor({ + name: "notifications", + key: [userId], + actionDefaults: { timeout: 30_000, throwOnError: true }, +}); + +const unmount = notifications.mount(); +try { + await notifications.markAsRead({ ids: ["message-1"] }); +} finally { + unmount(); + notifications.dispose(); } ``` ### Optimistic UI With Rollback -Optimistic updates work naturally. The `undefined` return signals failure for rollback. +Use `throwOnError: true` for optimistic actions, since a successful void action +can also return `undefined`: ```ts -async togglePin(conversationId: string): Promise { - // Optimistic update - const prev = this.conversations.find((c) => c.id === conversationId); - if (prev) prev.pinned = !prev.pinned; - - const result = await this.actor.togglePin({ conversationId }); +const actor = rivet.createReactiveActor({ + name: "document", + key: [documentId], + actionDefaults: { throwOnError: true }, +}); - // Rollback on failure (result is undefined when the interceptor catches an error) - if (result == null && prev) { - prev.pinned = !prev.pinned; +async function togglePin() { + const previous = pinned; + pinned = !pinned; + try { + await actor.setPinned(pinned); + } catch (error) { + pinned = previous; + throw error; } } ``` @@ -425,19 +468,21 @@ const actor = rivet.createReactiveActor({ ### Connection Guard -By default, actions called while disconnected fail immediately instead of hanging. The error is captured to `lastActionError`. +By default, a **lost** socket (`disconnected`) fails immediately. A first-paint handshake (`idle` / `connecting` after token mint) **waits** for `whenConnected` (capped at 30s) and then dispatches — a 200 mint is not a connected actor. The error is captured to `lastActionError` if the wait times out or the socket is gone. ```ts -// guardConnection: true (default) — immediate failure +// guardConnection: true (default) — wait through handshake, fail if lost const actor = rivet.createReactiveActor({ name: "counter", key: ["main"], actionDefaults: { guardConnection: true }, }); -// If disconnected, resolves to undefined immediately -// actor.lastActionError.message === 'Action "increment" called while disconnected' +// During connecting: waits, then increment runs await actor.increment(5); +// After disconnect: resolves to undefined immediately +// actor.lastActionError.code === 'ACTOR_NOT_YET_CONNECTED' +// or actor.lastActionError.code === 'ACTOR_DISCONNECTED' // Disable guard — let the action attempt even when disconnected // (useful if you want the WebSocket queue to handle it) @@ -508,15 +553,17 @@ await actor.increment(5); // raw pass-through ### `ActionDefaults` Reference -| Option | Type | Default | Description | -| ----------------- | ----------------------------------- | ------- | ---------------------------------------- | -| `timeout` | `number` | none | Action timeout in milliseconds | -| `throwOnError` | `boolean \| (err, name) => boolean` | `false` | Whether to re-throw captured errors | -| `guardConnection` | `boolean` | `true` | Reject immediately if disconnected | -| `onActionStart` | `(name, args) => void` | — | Fires when an action call starts | -| `onActionSuccess` | `(name, data) => void` | — | Fires on successful completion | -| `onActionError` | `(error, name) => void` | — | Fires on failure (timeout, network, etc) | -| `onActionSettled` | `(name) => void` | — | Fires after success or failure | +| Option | Type | Default | Description | +| ----------------- | ----------------------------------- | ------- | ------------------------------------------------------- | +| `timeout` | `number` | none | Action timeout in milliseconds | +| `throwOnError` | `boolean \| (err, name) => boolean` | `false` | Whether to re-throw captured errors | +| `guardConnection` | `boolean` | `true` | Wait through idle/connecting, then reject if still down | +| `onActionStart` | `(name, args) => void` | — | Fires when an action call starts | +| `onActionSuccess` | `(name, data) => void` | — | Fires on successful completion | +| `onActionError` | `(error, name) => void` | — | Fires on failure (timeout, network, etc) | +| `onActionSettled` | `(name) => void` | — | Fires after success or failure | + +The timeout bounds the local caller promise; it does not cancel work already running in the actor. Waiting for the initial connection counts toward `pendingActions`/`isMutating`. If disposal or a reactive `useActor()` key change cancels that wait, the cancellation runs through `onActionError`, `onActionSettled`, and the configured `throwOnError` policy (`false` resolves `undefined`; `true` rejects). Lifecycle callbacks are observational: if one throws, internal counters are still settled and the returned promise rejects with the callback error regardless of `throwOnError`. ### Reactive State Reference @@ -528,6 +575,49 @@ await actor.increment(5); // raw pass-through | `lastAction` | `string \| null` | Name of the last action called | | `resetActionState` | `() => void` | Clear `lastActionError` and `lastAction` (return to clean state) | +## `whenConnected()` + +Both `useActor()` and `createReactiveActor()` expose a promise-based ready signal that eliminates manual polling loops: + +```ts +const actor = rivet.createReactiveActor({ + name: "chatRoom", + key: ["room-123"], +}); + +actor.mount(); + +// Wait up to 10 seconds for the connection +const connected = await actor.whenConnected(10_000); +if (!connected) { + console.warn("Connection timed out"); + return; +} + +// Safe to call actions +await actor.sendMessage({ text: "Hello" }); +``` + +`whenConnected()` resolves immediately if already connected. The default timeout is 30 seconds. Returns `false` on timeout — never rejects. + +Also available on `createReactiveConnection()` for raw connection wrappers. + +## `getActionError()` + +Structured error extraction for any actor handle's `lastActionError`: + +```ts +import { getActionError } from "@rivetkit/svelte"; + +const error = getActionError(threadHandle); +if (error) { + showToast(error.message ?? "Something went wrong"); + if (error.code === "RATE_LIMITED") retryLater(); +} +``` + +Returns `{ message, code, isActorError }` or `null` when there is no error. Detection uses RivetKit's structural guard, so modern `__type: "RivetError"`, legacy `__type: "ActorError"`, and serialized cross-realm error shapes are supported. + ## Auth And Params Guidance Keep framework-specific auth rules in your app, not in the package. @@ -551,19 +641,27 @@ const chat = rivet.createReactiveActor(getChatActorOptions()); That pattern stays flexible whether your token came from Better Auth, a custom server session, or another auth system entirely. +If connection params contain short-lived values such as actor tokens, provide a `hashFunction` that hashes actor identity (`name`, `key`, `noCreate`) but excludes volatile params. The adapter shares actor connections by hash; including an expiring token can split one actor instance into multiple WebSockets after refresh. A reactive options refresh with the same hash preserves the current actor state and pending initial-connection actions. + ## Connection Sharing And Performance `@rivetkit/svelte` is optimized for the common “one shared transport, many actor consumers” shape: -- multiple `useActor()` calls with the same actor identity share the underlying connection through framework-base +- multiple `useActor()` calls with the same actor identity share the underlying connection through the ref-counted framework core - `createSharedRivetKit()` prevents duplicate wrapper creation when the app already centralizes a raw client -- proxied actor methods are cached per connection instance, so repeated reads like `actor.sendMessage` do not allocate a fresh bound function every time -- `lastError` and `hasEverConnected` make reconnect UX easier without forcing app code to track extra flags +- proxied actor methods are cached per actor handle, keep stable identity across reconnects, and lazily cache only the current connection's nested invoker; repeated reads and reconnect-safe destructured handlers do not allocate fresh bound functions +- `createReactiveActor()` construction does not subscribe or connect until `mount()`, keeping module-level ViewModel construction cheap +- `warmUp()` uses raw Rivet client `getOrCreate(key, opts).resolve()` to warm actor resolution without a WebSocket, forwards `createWithInput`/`createInRegion`, supports `noCreate` via `get(key).resolve()`, and uses a collision-safe length-prefixed hash for the common no-input path instead of `JSON.stringify()`. Cyclic and BigInt-containing initialization values fall back to a non-throwing identity hash. Concurrent resolves for one identity are deduplicated, but completion clears that in-flight key so a later hover can wake an actor that has slept again. `createInRegion` is Rivet datacenter selection for newly created actors only; it does not move existing actors. (`preloadActor()` is a deprecated alias.) For the heavier tier that opens a real WebSocket ahead of time, `preConnect()` returns a caller-disposed `{ dispose() }` handle and forces `enabled: true` — use it only for high-intent signals; broad hover should prefer `warmUp()`. +- `lastError` and `hasEverConnected` make reconnect UX easier without forcing app code to track extra flags; internal nonreactive mirrors prevent those public runes from feeding lifecycle effects back into themselves +- concurrent action settlements are invocation-ordered, so an older completion cannot erase or replace the newest action error +- one action timeout deadline covers both the initial connection wait and dispatch instead of granting each phase a fresh budget + +Run `bun run --filter @rivetkit/svelte bench` to benchmark proxy reads, forwarded action calls, 32-actor fan-out reads, construct/mount/unmount, subscription pushes, preload hashing, concurrent actions, and `whenConnected()`. ## SSR Safety - `useActor()` is SSR-safe by default because `$effect` is the browser lifecycle boundary -- `createReactiveActor()` can be created anywhere, but `mount()` should still happen in a browser lifecycle +- `createReactiveActor()` can be created anywhere because construction does not subscribe or connect, but `mount()` should still happen in a browser lifecycle - prefer app-local typed context over mutable request-time globals in SvelteKit code that can run during SSR ## Testing @@ -582,20 +680,32 @@ describe("runes", () => { }); ``` +## Type Safety Note + +Actor method calls (e.g. `actor.sendMessage(...)`) are **untyped at the package level**. The `ProxiedActorMethods` type uses `Record any>` because RivetKit's deeply nested conditional types inside `ActorConn` exceed TypeScript's instantiation depth limit (TS2589) when wrapped in `Omit` or mapped types. + +All reactive state properties (`connStatus`, `error`, `isMutating`, etc.) remain fully typed. For type-safe actor method calls, use typed client interfaces from your actor registry at the call site. + ## Familiar Mental Models -The package does not depend on these libraries, but its DX intentionally lines up with patterns Svelte teams already know: +The package does not depend on these libraries, but its DX intentionally lines up with maintained Svelte adapters at similar state/lifecycle seams: -- TanStack Query: shared-client/provider setup for app-level ownership +- [TanStack Svelte Query](https://tanstack.com/query/latest/docs/framework/svelte/overview): shared-client/provider ownership, reactive result state, and stable mutation functions that dispatch through the current observer +- [XState Svelte](https://stately.ai/docs/xstate-svelte): subscription-owned snapshots with a stable `send` function and selector-oriented updates +- [urql Svelte](https://urql.dev/docs/basics/svelte/): a deliberately thin Svelte binding over a framework-neutral core client - Runed: `Getter` and `MaybeGetter` ergonomics for reactive inputs - Bits UI: composable primitives instead of rigid framework wrappers - Better Auth: auth stays app-owned, while the package only consumes resolved params +The connection lifecycle also follows Rivet's JavaScript client contract: stateful handles own a connection that must be disposed when no longer needed. See the [Rivet JavaScript client](https://rivet.dev/actors/docs/clients/javascript/), [actor lifecycle](https://rivet.dev/actors/docs/lifecycle/), and [production checklist](https://rivet.dev/actors/docs/general/production-checklist/). + ## Requirements -- Svelte 5+ -- RivetKit 2.1+ +- Svelte `^5.57.0` +- RivetKit `2.3.17` + +The published artifact is self-contained: its internal framework bridge forwards `getParams` through both `get()` and `getOrCreate()`. It does not rely on a consuming repository's `patchedDependencies` configuration. ## License -Apache-2.0 +Apache-2.0. See the repository [LICENSE](../../../LICENSE). diff --git a/rivetkit-typescript/packages/svelte/package.json b/rivetkit-typescript/packages/svelte/package.json index f938b647c5..2fbd89ffab 100644 --- a/rivetkit-typescript/packages/svelte/package.json +++ b/rivetkit-typescript/packages/svelte/package.json @@ -19,28 +19,32 @@ } }, "files": [ - "dist" + "dist", + "!dist/__tests__", + "!dist/__bench__" ], "scripts": { "build": "svelte-package -i src/lib", "check-types": "svelte-check --tsconfig ./tsconfig.json", - "test": "vitest run --environment jsdom src/lib/__tests__/*.test.ts" + "test": "vitest run --environment jsdom src/lib/__tests__/*.test.ts", + "bench": "vitest bench --run --environment jsdom src/lib/__bench__/*.bench.ts" }, "peerDependencies": { - "svelte": "^5.0.0" + "svelte": "^5.57.0" }, "dependencies": { - "@rivetkit/framework-base": "2.1.10", "esm-env": "^1.2.2", - "rivetkit": "2.1.10" + "fast-deep-equal": "^3.1.3", + "rivetkit": "2.3.17" }, "devDependencies": { "@sveltejs/package": "^2.5.7", "jsdom": "^28.0.0", - "svelte": "^5.0.0", + "svelte": "^5.57.0", "svelte-check": "^4.4.3", "typescript": "^5.5.0", - "vitest": "^4.0.0" + "vitest": "^4.0.0", + "@types/node": "^22.0.0" }, "keywords": [ "rivetkit", @@ -50,5 +54,6 @@ "actors", "realtime", "websocket" - ] + ], + "sideEffects": false } diff --git a/rivetkit-typescript/packages/svelte/src/check-types/noop.svelte b/rivetkit-typescript/packages/svelte/src/check-types/noop.svelte index 223bca9a6b..b8cc22dd84 100644 --- a/rivetkit-typescript/packages/svelte/src/check-types/noop.svelte +++ b/rivetkit-typescript/packages/svelte/src/check-types/noop.svelte @@ -1,5 +1,5 @@
{message}
diff --git a/rivetkit-typescript/packages/svelte/src/check-types/public-options.ts b/rivetkit-typescript/packages/svelte/src/check-types/public-options.ts new file mode 100644 index 0000000000..b95a6c2ac6 --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/check-types/public-options.ts @@ -0,0 +1,64 @@ +import type { + ActionDefaults, + ActorErrorLike, + AnyActorRegistry, + RivetContext, + RivetKit, + SvelteRivetKitOptions, +} from "../lib/index.js"; +import { createRivetKitWithClient, withActorParams } from "../lib/index.js"; +import type { Client } from "rivetkit/client"; + +type IsAny = 0 extends 1 & T ? true : false; +type ExpectFalse = T; + +const clientOptions = { + actionDefaults: { timeout: 5_000, timeoutByAction: { getSnapshot: 1_000 } }, + connectionInspector: true, +} satisfies SvelteRivetKitOptions; + +export function configureContext( + context: RivetContext, +): RivetKit { + return context.setup(undefined, clientOptions); +} + +export function configureActor(rivet: RivetKit): void { + rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { guardConnection: true }, + }); +} + +const client = {} as Client; +const directRivet = createRivetKitWithClient(client); +type DirectFactoryResultIsNotAny = ExpectFalse>; + +// A direct factory call must expose the declared RivetKit surface, not `any`. +directRivet.connectionInspector; +// @ts-expect-error Unknown properties must not pass through an `any` result. +directRivet.notPartOfRivetKit; + +const getActorOptions = withActorParams( + { + name: "chat" as never, + key: ["room-1"], + actionDefaults: { guardConnection: true }, + }, + { token: "secret" }, +); +const preservedActionDefaults: ActionDefaults | undefined = + getActorOptions().actionDefaults; + +// Serialized cross-realm actor errors are structural and need not be Error instances. +const serializedActorError: ActorErrorLike = { + __type: "RivetError", + group: "user", + code: "FORBIDDEN", + message: "Not allowed", +}; + +void (0 as unknown as DirectFactoryResultIsNotAny); +void preservedActionDefaults; +void serializedActorError; diff --git a/rivetkit-typescript/packages/svelte/src/lib/__bench__/hot-paths.bench.ts b/rivetkit-typescript/packages/svelte/src/lib/__bench__/hot-paths.bench.ts new file mode 100644 index 0000000000..3b783b016b --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/lib/__bench__/hot-paths.bench.ts @@ -0,0 +1,357 @@ +/** + * Hot-path microbenchmarks for @rivetkit/svelte. + * + * Measures the JS-level cost of the most frequently exercised paths: + * - Proxy `get` trap (every actor.method access) + * - Method cache lookup chain + * - applyState (subscribe firing) + * - createReactiveActor cold-start + * - Action interceptor wrapping + * - extract(MaybeGetter) + * - mergeActionDefaults + * - preloadActor hash generation + * + * Reactivity itself is shimmed (runes-shim) — we are measuring the JS + * overhead the adapter adds on top of Svelte's reactivity primitives. + * + * Run with: `bun run --filter @rivetkit/svelte bench` + */ +import "../__tests__/runes-shim.js"; +import { bench, describe, vi, beforeAll } from "vitest"; + +// Benchmarks intentionally mount handles in jsdom-like test context. Suppress +// the development SSR warning so stderr does not dominate benchmark output. +vi.spyOn(console, "warn").mockImplementation(() => {}); + +// --------------------------------------------------------------------------- +// Framework-base mock — minimal, lifted from existing test mock but tuned +// for benchmark consistency. Every actor shares one mutable state object. +// --------------------------------------------------------------------------- + +type MockListener = (...args: unknown[]) => void; + +function createMockConnection(id: string) { + const listeners = new Map>(); + return { + id, + // Methods return promises because rivetkit actor actions are always async; + // the interceptor calls `.catch` on the result and would otherwise blow up. + ping: () => Promise.resolve(`pong:${id}`), + increment: (n: number) => Promise.resolve(n + 1), + sendMessage: (payload: unknown) => Promise.resolve(payload), + on(eventName: string, handler: MockListener) { + let bucket = listeners.get(eventName); + if (!bucket) { + bucket = new Set(); + listeners.set(eventName, bucket); + } + bucket.add(handler); + return () => bucket?.delete(handler); + }, + emit(eventName: string, ...args: unknown[]) { + listeners.get(eventName)?.forEach((l) => l(...args)); + }, + connStatus: "connected" as const, + onStatusChange: () => () => {}, + onError: () => () => {}, + async dispose() {}, + }; +} + +const baseConnection = createMockConnection("base"); +const mockState = { + connection: baseConnection, + handle: { id: "handle-base" }, + connStatus: "connected" as const, + error: null as Error | null, + hash: "hash-base", +}; + +const subscribers = new Set<(v: { currentVal: typeof mockState }) => void>(); + +vi.mock("../internal/framework-base.js", () => ({ + createRivetKit: () => ({ + getOrCreateActor: () => ({ + mount: () => () => {}, + state: { + get state() { + return mockState; + }, + subscribe(cb: (v: { currentVal: typeof mockState }) => void) { + subscribers.add(cb); + return () => subscribers.delete(cb); + }, + }, + }), + }), +})); + +// Imports must come AFTER vi.mock — vi.hoisted guarantees it normally, but +// dynamic import keeps this benchmark file straightforward. +const mod = await import("../rivetkit.svelte.js"); +const extractMod = await import("../internal/extract.js"); +const { createRivetKitWithClient } = mod; +const { extract } = extractMod; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const rivet = createRivetKitWithClient({} as never); +const rivetWithDefaults = createRivetKitWithClient({} as never, { + actionDefaults: { timeout: 30_000, guardConnection: false }, +}); +const rivetNoTimeout = createRivetKitWithClient({} as never, { + // Interceptor with no timeout — isolates the Promise.race/setTimeout cost + // from the rest of the wrapper. + actionDefaults: { guardConnection: false }, +}); +const rivetWithCallbacks = createRivetKitWithClient({} as never, { + actionDefaults: { + guardConnection: false, + onActionStart: () => {}, + onActionSuccess: () => {}, + onActionSettled: () => {}, + }, +}); + +const plainActor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["bench-plain"], +}); +const interceptedActor = rivetWithDefaults.createReactiveActor({ + name: "chat" as never, + key: ["bench-intercepted"], +}); +const noTimeoutActor = rivetNoTimeout.createReactiveActor({ + name: "chat" as never, + key: ["bench-no-timeout"], +}); +const callbackActor = rivetWithCallbacks.createReactiveActor({ + name: "chat" as never, + key: ["bench-callbacks"], +}); + +// Many actors for "many simultaneous consumers" benchmarks +const fanOutActors = Array.from({ length: 32 }, (_, i) => + rivet.createReactiveActor({ + name: "chat" as never, + key: [`bench-fan-${i}`], + }), +); + +const mountedActors = [ + plainActor, + interceptedActor, + noTimeoutActor, + callbackActor, + ...fanOutActors, +]; +let applyStateCb: ((v: { currentVal: typeof mockState }) => void) | undefined; + +// Push subscribers once so applyState/Proxy methods exercise the connected path +beforeAll(() => { + for (const actor of mountedActors) actor.mount(); + applyStateCb = [...subscribers][0]; + subscribers.forEach((cb) => + cb({ currentVal: { ...mockState, connStatus: "connected" } }), + ); +}); + +// --------------------------------------------------------------------------- +// Benchmarks +// --------------------------------------------------------------------------- + +describe("proxy.get — known property (state getter)", () => { + bench("plainActor.connStatus", () => { + void plainActor.connStatus; + }); + bench("plainActor.isConnected", () => { + void plainActor.isConnected; + }); + bench("interceptedActor.isMutating", () => { + void interceptedActor.isMutating; + }); +}); + +describe("proxy.get — unknown property (forwarded actor method)", () => { + bench("plainActor.ping (cached method)", () => { + void plainActor.ping; + }); + bench("interceptedActor.ping (cached, wrapped)", () => { + void interceptedActor.ping; + }); + bench("plainActor.ping() call", () => { + plainActor.ping(); + }); + bench("interceptedActor.ping() call (await)", async () => { + await interceptedActor.ping(); + }); +}); + +describe("proxy.get — fan-out reads across 32 actors", () => { + bench("read connStatus on 32 actors", () => { + for (let i = 0; i < fanOutActors.length; i++) { + void fanOutActors[i].connStatus; + } + }); + bench("read ping on 32 actors", () => { + for (let i = 0; i < fanOutActors.length; i++) { + void fanOutActors[i].ping; + } + }); +}); + +describe("createReactiveActor construction and mount", () => { + let i = 0; + bench("construct handle only", () => { + rivet.createReactiveActor({ + name: "chat" as never, + key: [`cold-${i++}`], + }); + }); + bench("construct handle only, with actionDefaults", () => { + rivetWithDefaults.createReactiveActor({ + name: "chat" as never, + key: [`cold-int-${i++}`], + }); + }); + bench("construct + mount", () => { + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: [`cold-mount-${i++}`], + }); + const unmount = actor.mount(); + unmount(); + actor.dispose(); + }); + bench("construct + mount, with actionDefaults", () => { + const actor = rivetWithDefaults.createReactiveActor({ + name: "chat" as never, + key: [`cold-int-mount-${i++}`], + }); + const unmount = actor.mount(); + unmount(); + actor.dispose(); + }); + const stableActor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["stable-mount"], + }); + bench("mount + unmount existing handle", () => { + const unmount = stableActor.mount(); + unmount(); + }); +}); + +describe("applyState — subscribe firing", () => { + // Cast to the subscriber's mockState shape — the rotating variants + // have `connStatus` literals that widen incompatibly without this. + const tickStates: Array = [ + { ...mockState, connStatus: "connected" } as typeof mockState, + { + ...mockState, + connStatus: "disconnected" as never, + error: new Error("x"), + } as typeof mockState, + { ...mockState, connStatus: "reconnecting" as never } as typeof mockState, + { + ...mockState, + connStatus: "connected", + error: null, + } as typeof mockState, + ]; + let idx = 0; + bench("subscribe push (rotating statuses)", () => { + applyStateCb?.({ currentVal: tickStates[idx++ & 3]! }); + }); + bench("subscribe push (identical value)", () => { + applyStateCb?.({ currentVal: tickStates[0]! }); + }); +}); + +describe("extract(MaybeGetter)", () => { + const direct = { name: "chat", key: ["k"] }; + const getter = () => ({ name: "chat", key: ["k"] }); + bench("static value", () => { + extract(direct); + }); + bench("getter thunk", () => { + extract(getter); + }); +}); + +describe("preloadActor hash", () => { + // Direct probe of the JSON.stringify approach the source uses. + function lengthPrefixedHash(name: string, keyArray: string[]) { + let hash = `${name.length}:${name}`; + for (const part of keyArray) hash += `|${part.length}:${part}`; + return hash; + } + + bench("JSON.stringify hash (string key)", () => { + JSON.stringify(["doc", ["abc-123"], null]); + }); + bench("JSON.stringify hash (compound key)", () => { + JSON.stringify(["doc", ["org-1", "user-2", "thread-3"], null]); + }); + bench("length-prefixed hash (string key)", () => { + lengthPrefixedHash("doc", ["abc-123"]); + }); + bench("length-prefixed hash (compound key)", () => { + lengthPrefixedHash("doc", ["org-1", "user-2", "thread-3"]); + }); + bench("manual hash (string key)", () => { + "doc\x00abc-123"; + }); +}); + +describe("mergeActionDefaults equivalent", () => { + const a = { timeout: 30_000, throwOnError: false as boolean }; + const b = { timeout: 60_000 }; + bench("shallow spread", () => { + void { ...a, ...b }; + }); +}); + +describe("interceptedActor — concurrent actions", () => { + bench("Promise.all(8 × ping)", async () => { + await Promise.all([ + interceptedActor.ping(), + interceptedActor.ping(), + interceptedActor.ping(), + interceptedActor.ping(), + interceptedActor.ping(), + interceptedActor.ping(), + interceptedActor.ping(), + interceptedActor.ping(), + ]); + }); +}); + +describe("whenConnected (immediate-resolve path)", () => { + bench("already-connected fast path", async () => { + await plainActor.whenConnected(); + }); +}); + +// Floor benchmarks: establish the minimum cost of `await Promise.resolve` +// at the engine level so we can subtract that from the interceptor numbers +// and see how much overhead is intrinsic to async/await vs added by us. +describe("async/await floor", () => { + bench("bare await Promise.resolve()", async () => { + await Promise.resolve(42); + }); + bench("await baseConnection.ping() (no proxy)", async () => { + await baseConnection.ping(); + }); + bench("await plainActor.ping() (proxy, no interceptor)", async () => { + await plainActor.ping(); + }); + bench("await noTimeoutActor.ping() (interceptor, no timeout)", async () => { + await noTimeoutActor.ping(); + }); + bench("await callbackActor.ping() (interceptor + callbacks)", async () => { + await callbackActor.ping(); + }); +}); diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/action-middleware.test.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/action-middleware.test.ts index f98735fe41..c8e151bb0f 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/__tests__/action-middleware.test.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/action-middleware.test.ts @@ -1,115 +1,138 @@ import "./runes-shim.js"; +import { afterEach, describe, expect, test, vi, beforeEach } from "vitest"; import type { ActorConnStatus } from "rivetkit/client"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { rerunEffects, resetEffects } from "./runes-shim.js"; // --------------------------------------------------------------------------- // Mock — identical shape to reactive-actor.test.ts, but with async actions // --------------------------------------------------------------------------- const frameworkMock = vi.hoisted(() => { - type Listener = (...args: unknown[]) => void; - type Subscriber = (value: { currentVal: MockActorState }) => void; - type MockConnection = { - id: string; - ping: () => string; - increment: (amount: number) => Promise; - failAction: () => Promise; - slowAction: () => Promise; - on: (eventName: string, handler: Listener) => () => void; - emit: (eventName: string, ...args: unknown[]) => void; - }; - type MockActorState = { - connection: MockConnection; - handle: { id: string }; - connStatus: ActorConnStatus; - error: Error | null; - hash: string; - }; - - const subscribers = new Set(); - - function createConnection(id: string): MockConnection { - const listeners = new Map>(); - - return { - id, - ping: () => `pong:${id}`, - increment: vi.fn(async (amount: number) => amount + 1), - failAction: vi.fn(async () => { - throw new Error("action failed"); - }), - slowAction: vi.fn( - () => - new Promise((resolve) => - setTimeout(() => resolve("done"), 5_000), - ), - ), - on(eventName: string, handler: Listener) { - let eventListeners = listeners.get(eventName); - if (!eventListeners) { - eventListeners = new Set(); - listeners.set(eventName, eventListeners); - } - eventListeners.add(handler); - return () => eventListeners?.delete(handler); - }, - emit(eventName: string, ...args: unknown[]) { - for (const listener of listeners.get(eventName) ?? []) { - listener(...args); - } - }, - }; - } - - let currentState: MockActorState; - - const getOrCreateActor = vi.fn(() => ({ - mount: vi.fn(() => vi.fn()), - state: { - get state() { - return currentState; - }, - subscribe(callback: Subscriber) { - subscribers.add(callback); - return () => subscribers.delete(callback); - }, - }, - })); - - function push(next: Partial): void { - currentState = { ...currentState, ...next }; - for (const subscriber of subscribers) { - subscriber({ currentVal: currentState }); - } - } - - function reset(): void { - subscribers.clear(); - currentState = { - connection: createConnection("one"), - handle: { id: "handle-one" }, - connStatus: "connected", - error: null, - hash: "hash-one", - }; - getOrCreateActor.mockClear(); - } - - reset(); - - return { - getOrCreateActor, - currentState: () => currentState, - push, - reset, - createConnection, - }; + type Listener = (...args: unknown[]) => void; + type Subscriber = (value: { currentVal: MockActorState }) => void; + type MockConnection = { + id: string; + ping: () => string; + increment: (amount: number) => Promise; + failAction: () => Promise; + slowAction: () => Promise; + syncValue: () => number; + syncThrow: () => never; + on: (eventName: string, handler: Listener) => () => void; + emit: (eventName: string, ...args: unknown[]) => void; + }; + type MockActorState = { + connection: MockConnection; + handle: { id: string }; + connStatus: ActorConnStatus; + error: Error | null; + hash: string; + }; + type HashFunction = (opts: Record) => string; + + const subscribers = new Set(); + const defaultHash: HashFunction = ({ name, key, params, noCreate }) => + JSON.stringify({ name, key, params, noCreate }); + let hashFunction: HashFunction = defaultHash; + + function createConnection(id: string): MockConnection { + const listeners = new Map>(); + + return { + id, + ping: () => `pong:${id}`, + increment: vi.fn(async (amount: number) => amount + 1), + failAction: vi.fn(async () => { + throw new Error("action failed"); + }), + slowAction: vi.fn( + () => + new Promise((resolve) => + setTimeout(() => resolve("done"), 5_000), + ), + ), + syncValue: vi.fn(() => 42), + syncThrow: vi.fn(() => { + throw new Error("sync action failed"); + }), + on(eventName: string, handler: Listener) { + let eventListeners = listeners.get(eventName); + if (!eventListeners) { + eventListeners = new Set(); + listeners.set(eventName, eventListeners); + } + eventListeners.add(handler); + return () => eventListeners?.delete(handler); + }, + emit(eventName: string, ...args: unknown[]) { + listeners.get(eventName)?.forEach((listener) => listener(...args)); + }, + }; + } + + let currentState: MockActorState; + + const getOrCreateActor = vi.fn((actorOpts: Record) => { + const normalizedOpts = { + ...actorOpts, + enabled: actorOpts.enabled ?? true, + }; + return { + key: hashFunction(normalizedOpts), + mount: vi.fn(() => vi.fn()), + state: { + get state() { + return currentState; + }, + subscribe(callback: Subscriber) { + subscribers.add(callback); + return () => subscribers.delete(callback); + }, + }, + }; + }); + + function configure(opts?: { hashFunction?: HashFunction }): void { + hashFunction = opts?.hashFunction ?? defaultHash; + } + + function push(next: Partial): void { + currentState = { ...currentState, ...next }; + subscribers.forEach((subscriber) => + subscriber({ currentVal: currentState }), + ); + } + + function reset(): void { + subscribers.clear(); + hashFunction = defaultHash; + currentState = { + connection: createConnection("one"), + handle: { id: "handle-one" }, + connStatus: "connected", + error: null, + hash: "hash-one", + }; + getOrCreateActor.mockClear(); + } + + reset(); + + return { + getOrCreateActor, + currentState: () => currentState, + push, + reset, + createConnection, + configure, + }; }); -vi.mock("@rivetkit/framework-base", () => ({ - createRivetKit: vi.fn(() => ({ - getOrCreateActor: frameworkMock.getOrCreateActor, - })), +vi.mock("../internal/framework-base.js", () => ({ + createRivetKit: vi.fn((_client, opts) => { + frameworkMock.configure(opts); + return { getOrCreateActor: frameworkMock.getOrCreateActor }; + }), })); import { createRivetKitWithClient } from "../rivetkit.svelte.js"; @@ -119,274 +142,782 @@ import { createRivetKitWithClient } from "../rivetkit.svelte.js"; // --------------------------------------------------------------------------- describe("action middleware (createReactiveActor)", () => { - beforeEach(() => { - frameworkMock.reset(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - test("without actionDefaults, actions are plain pass-through (no tracking)", async () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - }); - - // Action tracking state has defaults but no interceptor - expect(actor.isMutating).toBe(false); - expect(actor.pendingActions).toBe(0); - expect(actor.lastActionError).toBe(null); - expect(actor.lastAction).toBe(null); - - // Actions pass through directly — no interception - const result = await actor.increment(5); - expect(result).toBe(6); - - // No tracking occurred (no actionDefaults configured) - expect(actor.isMutating).toBe(false); - expect(actor.lastAction).toBe(null); - }); - - test("with actionDefaults, tracks isMutating and pendingActions", async () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - actionDefaults: {}, - }); - - expect(actor.isMutating).toBe(false); - expect(actor.pendingActions).toBe(0); - - const promise = actor.increment(5); - // Synchronously after calling, state is updated - expect(actor.isMutating).toBe(true); - expect(actor.pendingActions).toBe(1); - expect(actor.lastAction).toBe("increment"); - - await promise; - - expect(actor.isMutating).toBe(false); - expect(actor.pendingActions).toBe(0); - }); - - test("captures errors to lastActionError (throwOnError: false default)", async () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - actionDefaults: {}, - }); - - // Call an action that throws - const result = await actor.failAction(); - - // Error captured reactively, not thrown - expect(result).toBeUndefined(); - expect(actor.lastActionError).toBeInstanceOf(Error); - expect(actor.lastActionError?.message).toBe("action failed"); - expect(actor.lastAction).toBe("failAction"); - expect(actor.isMutating).toBe(false); - }); - - test("clears lastActionError on next successful action", async () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - actionDefaults: {}, - }); - - await actor.failAction(); - expect(actor.lastActionError).not.toBe(null); - - await actor.increment(1); - expect(actor.lastActionError).toBe(null); - }); - - test("throwOnError: true re-throws the error", async () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - actionDefaults: { throwOnError: true }, - }); - - await expect(actor.failAction()).rejects.toThrow("action failed"); - // Error is still captured reactively even when thrown - expect(actor.lastActionError?.message).toBe("action failed"); - }); - - test("throwOnError as function — called per error to decide", async () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - actionDefaults: { - throwOnError: (_err: Error, actionName: string) => - actionName === "failAction", - }, - }); - - // failAction should throw (function returns true for it) - await expect(actor.failAction()).rejects.toThrow("action failed"); - }); - - test("timeout causes action to fail", async () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - actionDefaults: { timeout: 100 }, - }); - - const promise = actor.slowAction(); - - // Advance past the timeout - vi.advanceTimersByTime(150); - - const result = await promise; - expect(result).toBeUndefined(); - expect(actor.lastActionError?.message).toContain("timed out"); - expect(actor.isMutating).toBe(false); - }); - - test("resetActionState clears error and lastAction", async () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - actionDefaults: {}, - }); - - await actor.failAction(); - expect(actor.lastActionError).not.toBe(null); - expect(actor.lastAction).toBe("failAction"); - - actor.resetActionState(); - expect(actor.lastActionError).toBe(null); - expect(actor.lastAction).toBe(null); - }); - - test("lifecycle callbacks fire in order", async () => { - const log: string[] = []; - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - actionDefaults: { - onActionStart: (name: string) => log.push(`start:${name}`), - onActionSuccess: (name: string) => log.push(`success:${name}`), - onActionError: (_err: Error, name: string) => - log.push(`error:${name}`), - onActionSettled: (name: string) => log.push(`settled:${name}`), - }, - }); - - await actor.increment(5); - expect(log).toEqual([ - "start:increment", - "success:increment", - "settled:increment", - ]); - - log.length = 0; - await actor.failAction(); - expect(log).toEqual([ - "start:failAction", - "error:failAction", - "settled:failAction", - ]); - }); - - test("connection guard rejects when disconnected", async () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - actionDefaults: { guardConnection: true }, - }); - - // Simulate disconnection - frameworkMock.push({ - connection: null as never, - connStatus: "disconnected", - }); - - const result = await actor.increment(5); - expect(result).toBeUndefined(); - expect(actor.lastActionError?.message).toContain("disconnected"); - }); - - test("client-level actionDefaults cascade to actor-level", async () => { - const clientLog: string[] = []; - const rivet = createRivetKitWithClient({} as never, { - actionDefaults: { - onActionStart: (name: string) => - clientLog.push(`client:${name}`), - timeout: 60_000, - }, - }); - - const actorLog: string[] = []; - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - actionDefaults: { - // Override onActionStart (actor-level wins) - onActionStart: (name: string) => actorLog.push(`actor:${name}`), - }, - }); - - await actor.increment(5); - - // Actor-level overrode onActionStart - expect(clientLog).toEqual([]); - expect(actorLog).toEqual(["actor:increment"]); - }); - - test("concurrent actions track pendingActions correctly", async () => { - const rivet = createRivetKitWithClient({} as never); - - // Replace increment with a delayed mock - const conn = frameworkMock.currentState().connection; - let resolveFirst: ((v: number) => void) | undefined; - let resolveSecond: ((v: number) => void) | undefined; - let callCount = 0; - - conn.increment = vi.fn( - () => - new Promise((resolve) => { - callCount++; - if (callCount === 1) resolveFirst = resolve; - else resolveSecond = resolve; - }), - ); - - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - actionDefaults: {}, - }); - - const p1 = actor.increment(1); - expect(actor.pendingActions).toBe(1); - - const p2 = actor.increment(2); - expect(actor.pendingActions).toBe(2); - expect(actor.isMutating).toBe(true); - - resolveFirst?.(2); - await p1; - expect(actor.pendingActions).toBe(1); - expect(actor.isMutating).toBe(true); - - resolveSecond?.(3); - await p2; - expect(actor.pendingActions).toBe(0); - expect(actor.isMutating).toBe(false); - }); + beforeEach(() => { + resetEffects(); + frameworkMock.reset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + resetEffects(); + vi.useRealTimers(); + }); + + test("without actionDefaults, actions are plain pass-through (no tracking)", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + actor.mount(); + + // Action tracking state has defaults but no interceptor + expect(actor.isMutating).toBe(false); + expect(actor.pendingActions).toBe(0); + expect(actor.lastActionError).toBe(null); + expect(actor.lastAction).toBe(null); + + // Actions pass through directly — no interception + const result = await actor.increment(5); + expect(result).toBe(6); + + // No tracking occurred (no actionDefaults configured) + expect(actor.isMutating).toBe(false); + expect(actor.lastAction).toBe(null); + }); + + test("with actionDefaults, tracks isMutating and pendingActions", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: {}, + }); + actor.mount(); + + expect(actor.isMutating).toBe(false); + expect(actor.pendingActions).toBe(0); + + const promise = actor.increment(5); + // Synchronously after calling, state is updated + expect(actor.isMutating).toBe(true); + expect(actor.pendingActions).toBe(1); + expect(actor.lastAction).toBe("increment"); + + await promise; + + expect(actor.isMutating).toBe(false); + expect(actor.pendingActions).toBe(0); + }); + + test("captures errors to lastActionError (throwOnError: false default)", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: {}, + }); + actor.mount(); + + // Call an action that throws + const result = await actor.failAction(); + + // Error captured reactively, not thrown + expect(result).toBeUndefined(); + expect(actor.lastActionError).toBeInstanceOf(Error); + expect(actor.lastActionError?.message).toBe("action failed"); + expect(actor.lastAction).toBe("failAction"); + expect(actor.isMutating).toBe(false); + }); + + test("captures synchronous action throws and clears pending state", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: {}, + }); + actor.mount(); + + const result = await actor.syncThrow(); + + expect(result).toBeUndefined(); + expect(actor.lastActionError?.message).toBe("sync action failed"); + expect(actor.pendingActions).toBe(0); + expect(actor.isMutating).toBe(false); + }); + + test("supports synchronous non-Promise action results", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: {}, + }); + actor.mount(); + + const result = await actor.syncValue(); + + expect(result).toBe(42); + expect(actor.pendingActions).toBe(0); + expect(actor.isMutating).toBe(false); + }); + + test("clears lastActionError on next successful action", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: {}, + }); + actor.mount(); + + await actor.failAction(); + expect(actor.lastActionError).not.toBe(null); + + await actor.increment(1); + expect(actor.lastActionError).toBe(null); + }); + + test("throwOnError: true re-throws the error", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { throwOnError: true }, + }); + actor.mount(); + + await expect(actor.failAction()).rejects.toThrow("action failed"); + // Error is still captured reactively even when thrown + expect(actor.lastActionError?.message).toBe("action failed"); + }); + + test("throwOnError as function — called per error to decide", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { + throwOnError: (_err: Error, actionName: string) => + actionName === "failAction", + }, + }); + actor.mount(); + + // failAction should throw (function returns true for it) + await expect(actor.failAction()).rejects.toThrow("action failed"); + }); + + test("timeout causes action to fail", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { timeout: 100 }, + }); + actor.mount(); + + const promise = actor.slowAction(); + + // Advance past the timeout + vi.advanceTimersByTime(150); + + const result = await promise; + expect(result).toBeUndefined(); + expect(actor.lastActionError?.message).toContain("timed out"); + expect(actor.isMutating).toBe(false); + }); + + test("synchronous disposal during non-abort-aware dispatch settles without waiting for timeout", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["sync-dispose"], + actionDefaults: { timeout: 3_600_000 }, + }); + actor.mount(); + frameworkMock.currentState().connection.slowAction = vi.fn(() => { + actor.dispose(); + return new Promise(() => {}); + }); + await expect(actor.slowAction()).resolves.toBeUndefined(); + expect(actor.pendingActions).toBe(0); + expect(actor.isMutating).toBe(false); + }); + + test("per-action read deadline settles counters without lowering mutation timeout", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-read-policy"], + actionDefaults: { + timeout: 3_600_000, + timeoutByAction: { slowAction: 15 }, + }, + }); + actor.mount(); + const read = actor.slowAction(); + expect(actor.pendingActions).toBe(1); + await vi.advanceTimersByTimeAsync(20); + expect(actor.pendingActions).toBe(0); + expect(actor.isMutating).toBe(false); + await expect(read).resolves.toBeUndefined(); + expect(actor.lastActionError?.message).toContain("15ms"); + }); + + test("read deadline and disposal abort the raw SDK action with its correct receiver", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["abort-read"], + actionDefaults: { + timeout: 3_600_000, + timeoutByAction: { slowAction: 15 }, + }, + }); + actor.mount(); + const conn = frameworkMock.currentState().connection; + const signals: AbortSignal[] = []; + Object.assign(conn, { + action( + this: unknown, + opts: { name: string; args: unknown[]; signal: AbortSignal }, + ) { + expect(this).toBe(conn); + signals.push(opts.signal); + return new Promise((_, reject) => + opts.signal.addEventListener( + "abort", + () => reject(new Error("SDK aborted")), + { once: true }, + ), + ); + }, + }); + const read = actor.slowAction(); + await vi.advanceTimersByTimeAsync(20); + await expect(read).resolves.toBeUndefined(); + expect(signals[0]!.aborted).toBe(true); + expect(actor.pendingActions).toBe(0); + const mutation = actor.increment(1); + await vi.advanceTimersByTimeAsync(20); + expect(actor.pendingActions).toBe(1); + expect(signals[1]!.aborted).toBe(false); + actor.dispose(); + await expect(mutation).resolves.toBeUndefined(); + expect(signals[1]!.aborted).toBe(true); + expect(actor.pendingActions).toBe(0); + }); + + test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + "invalid named timeout %s falls back to default", + async (timeout) => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["invalid-timeout"], + actionDefaults: { + timeout: 20, + timeoutByAction: { slowAction: timeout }, + }, + }); + actor.mount(); + const read = actor.slowAction(); + await vi.advanceTimersByTimeAsync(5); + expect(actor.pendingActions).toBe(1); + await vi.advanceTimersByTimeAsync(20); + await expect(read).resolves.toBeUndefined(); + expect(actor.pendingActions).toBe(0); + }, + ); + + test("uses one timeout deadline across connection wait and dispatch", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { timeout: 1_000 }, + }); + actor.mount(); + + frameworkMock.push({ connStatus: "connecting" }); + const pending = actor.slowAction(); + + await vi.advanceTimersByTimeAsync(600); + frameworkMock.push({ connStatus: "connected" }); + await vi.advanceTimersByTimeAsync(0); + expect( + frameworkMock.currentState().connection.slowAction, + ).toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(399); + expect(actor.isMutating).toBe(true); + await vi.advanceTimersByTimeAsync(1); + + await expect(pending).resolves.toBeUndefined(); + expect(actor.lastActionError?.message).toContain("timed out after 1000ms"); + expect(actor.pendingActions).toBe(0); + }); + + test("resetActionState clears error and lastAction", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: {}, + }); + actor.mount(); + + await actor.failAction(); + expect(actor.lastActionError).not.toBe(null); + expect(actor.lastAction).toBe("failAction"); + + actor.resetActionState(); + expect(actor.lastActionError).toBe(null); + expect(actor.lastAction).toBe(null); + }); + + test("lifecycle callbacks fire in order", async () => { + const log: string[] = []; + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { + onActionStart: (name: string) => log.push(`start:${name}`), + onActionSuccess: (name: string) => log.push(`success:${name}`), + onActionError: (_err: Error, name: string) => log.push(`error:${name}`), + onActionSettled: (name: string) => log.push(`settled:${name}`), + }, + }); + actor.mount(); + + await actor.increment(5); + expect(log).toEqual([ + "start:increment", + "success:increment", + "settled:increment", + ]); + + log.length = 0; + await actor.failAction(); + expect(log).toEqual([ + "start:failAction", + "error:failAction", + "settled:failAction", + ]); + }); + + test("cleans up tracking when onActionStart throws", async () => { + const onSettled = vi.fn(); + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { + onActionStart: () => { + throw new Error("start callback failed"); + }, + onActionSettled: onSettled, + }, + }); + actor.mount(); + + await expect(actor.increment(5)).rejects.toThrow("start callback failed"); + expect(actor.pendingActions).toBe(0); + expect(actor.isMutating).toBe(false); + expect(onSettled).toHaveBeenCalledWith("increment"); + }); + + test("cleans up tracking and settles when success callbacks throw", async () => { + const onSettled = vi.fn(); + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { + onActionSuccess: () => { + throw new Error("success callback failed"); + }, + onActionSettled: onSettled, + }, + }); + actor.mount(); + + await expect(actor.increment(5)).rejects.toThrow("success callback failed"); + expect(actor.pendingActions).toBe(0); + expect(actor.isMutating).toBe(false); + expect(onSettled).toHaveBeenCalledWith("increment"); + }); + + test("cleans up tracking and settles when error callbacks throw", async () => { + const onSettled = vi.fn(); + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { + onActionError: () => { + throw new Error("error callback failed"); + }, + onActionSettled: onSettled, + }, + }); + actor.mount(); + + await expect(actor.failAction()).rejects.toThrow("error callback failed"); + expect(actor.pendingActions).toBe(0); + expect(actor.isMutating).toBe(false); + expect(onSettled).toHaveBeenCalledWith("failAction"); + }); + + test("connection guard rejects when disconnected", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { guardConnection: true }, + }); + actor.mount(); + + // Simulate disconnection + frameworkMock.push({ + connection: null as never, + connStatus: "disconnected", + }); + + const result = await actor.increment(5); + expect(result).toBeUndefined(); + expect(actor.lastActionError?.message).toContain("disconnected"); + }); + + test("connection guard waits for connecting then dispatches", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { guardConnection: true }, + }); + actor.mount(); + + frameworkMock.push({ connStatus: "connecting" }); + const pending = actor.increment(5); + expect(actor.pendingActions).toBe(1); + expect(actor.isMutating).toBe(true); + frameworkMock.push({ connStatus: "connected" }); + await vi.advanceTimersByTimeAsync(0); + const result = await pending; + expect(result).toBe(6); + expect(actor.lastActionError).toBeNull(); + expect(actor.pendingActions).toBe(0); + }); + + test("connection guard times out if the handshake never lands", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { guardConnection: true, timeout: 1_000 }, + }); + actor.mount(); + + frameworkMock.push({ connStatus: "connecting" }); + const pending = actor.increment(5); + await vi.advanceTimersByTimeAsync(1_000); + const result = await pending; + expect(result).toBeUndefined(); + expect(actor.lastActionError?.message).toContain("not yet connected"); + expect((actor.lastActionError as { code?: string } | null)?.code).toBe( + "ACTOR_NOT_YET_CONNECTED", + ); + expect(actor.pendingActions).toBe(0); + expect(actor.isMutating).toBe(false); + }); + + test("cleans up tracking when onActionSettled throws", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { + onActionSettled: () => { + throw new Error("settled callback failed"); + }, + }, + }); + actor.mount(); + + await expect(actor.increment(5)).rejects.toThrow("settled callback failed"); + expect(actor.pendingActions).toBe(0); + expect(actor.isMutating).toBe(false); + }); + + test("cleans up tracking when throwOnError predicate throws", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { + throwOnError: () => { + throw new Error("predicate failed"); + }, + }, + }); + actor.mount(); + + await expect(actor.failAction()).rejects.toThrow("predicate failed"); + expect(actor.pendingActions).toBe(0); + expect(actor.isMutating).toBe(false); + }); + + test("client-level actionDefaults cascade to actor-level", async () => { + const clientLog: string[] = []; + const rivet = createRivetKitWithClient({} as never, { + actionDefaults: { + onActionStart: (name: string) => clientLog.push(`client:${name}`), + timeout: 60_000, + }, + }); + + const actorLog: string[] = []; + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { + // Override onActionStart (actor-level wins) + onActionStart: (name: string) => actorLog.push(`actor:${name}`), + }, + }); + actor.mount(); + + await actor.increment(5); + + // Actor-level overrode onActionStart + expect(clientLog).toEqual([]); + expect(actorLog).toEqual(["actor:increment"]); + }); + + test("concurrent actions track pendingActions correctly", async () => { + const rivet = createRivetKitWithClient({} as never); + + // Replace increment with a delayed mock + const conn = frameworkMock.currentState().connection; + let resolveFirst: ((v: number) => void) | undefined; + let resolveSecond: ((v: number) => void) | undefined; + let callCount = 0; + + conn.increment = vi.fn( + () => + new Promise((resolve) => { + callCount++; + if (callCount === 1) resolveFirst = resolve; + else resolveSecond = resolve; + }), + ); + + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: {}, + }); + actor.mount(); + + const p1 = actor.increment(1); + expect(actor.pendingActions).toBe(1); + + const p2 = actor.increment(2); + expect(actor.pendingActions).toBe(2); + expect(actor.isMutating).toBe(true); + + resolveFirst!(2); + await p1; + expect(actor.pendingActions).toBe(1); + expect(actor.isMutating).toBe(true); + + resolveSecond!(3); + await p2; + expect(actor.pendingActions).toBe(0); + expect(actor.isMutating).toBe(false); + }); + + test("older failure cannot overwrite a newer successful invocation", async () => { + const conn = frameworkMock.currentState().connection; + let rejectOlder: ((error: Error) => void) | undefined; + let resolveNewer: ((value: number) => void) | undefined; + conn.failAction = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectOlder = reject; + }), + ); + conn.increment = vi.fn( + () => + new Promise((resolve) => { + resolveNewer = resolve; + }), + ); + + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: {}, + }); + actor.mount(); + + const older = actor.failAction(); + const newer = actor.increment(1); + resolveNewer!(2); + await expect(newer).resolves.toBe(2); + expect(actor.lastActionError).toBeNull(); + + rejectOlder!(new Error("older failure")); + await expect(older).resolves.toBeUndefined(); + expect(actor.lastActionError).toBeNull(); + expect(actor.pendingActions).toBe(0); + }); + + test("older success cannot clear a newer failed invocation", async () => { + const conn = frameworkMock.currentState().connection; + let resolveOlder: ((value: number) => void) | undefined; + let rejectNewer: ((error: Error) => void) | undefined; + conn.increment = vi.fn( + () => + new Promise((resolve) => { + resolveOlder = resolve; + }), + ); + conn.failAction = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectNewer = reject; + }), + ); + + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: {}, + }); + actor.mount(); + + const older = actor.increment(1); + const newer = actor.failAction(); + rejectNewer!(new Error("newer failure")); + await expect(newer).resolves.toBeUndefined(); + expect(actor.lastActionError?.message).toBe("newer failure"); + + resolveOlder!(2); + await expect(older).resolves.toBe(2); + expect(actor.lastActionError?.message).toBe("newer failure"); + expect(actor.pendingActions).toBe(0); + }); + + test("late action completion cannot repopulate disposed state", async () => { + let resolveAction: ((value: number) => void) | undefined; + frameworkMock.currentState().connection.increment = vi.fn( + () => + new Promise((resolve) => { + resolveAction = resolve; + }), + ); + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: {}, + }); + actor.mount(); + + const pending = actor.increment(1); + expect(actor.pendingActions).toBe(1); + actor.dispose(); + expect(actor.pendingActions).toBe(0); + expect(actor.lastAction).toBeNull(); + + resolveAction!(2); + await expect(pending).resolves.toBeUndefined(); + expect(actor.pendingActions).toBe(0); + expect(actor.lastAction).toBeNull(); + expect(actor.lastActionError).toBeNull(); + }); + + test("dispose settles an initial action through throwOnError: false", async () => { + frameworkMock.push({ connStatus: "connecting" }); + const onError = vi.fn(); + const onSettled = vi.fn(); + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + actionDefaults: { + onActionError: onError, + onActionSettled: onSettled, + throwOnError: false, + }, + }); + actor.mount(); + + const pending = actor.increment(1); + expect(actor.pendingActions).toBe(1); + actor.dispose(); + + await expect(pending).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "ACTOR_IDENTITY_CHANGED" }), + "increment", + ); + expect(onSettled).toHaveBeenCalledWith("increment"); + expect(actor.pendingActions).toBe(0); + expect(actor.lastActionError).toBeNull(); + }); + + test("re-key settles an initial useActor action through throwOnError: false", async () => { + frameworkMock.push({ connStatus: "connecting" }); + let roomId = "room-1"; + const onError = vi.fn(); + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.useActor(() => ({ + name: "chat" as never, + key: [roomId], + actionDefaults: { onActionError: onError, throwOnError: false }, + })); + + const pending = actor.increment(1); + expect(actor.pendingActions).toBe(1); + roomId = "room-2"; + rerunEffects(); + + await expect(pending).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "ACTOR_IDENTITY_CHANGED" }), + "increment", + ); + expect(actor.pendingActions).toBe(0); + expect(actor.lastActionError).toBeNull(); + expect(frameworkMock.getOrCreateActor).toHaveBeenLastCalledWith( + expect.objectContaining({ key: ["room-2"] }), + ); + }); + + test("same-hash reactive option refresh preserves an initial action waiter", async () => { + frameworkMock.push({ connStatus: "connecting" }); + let token = "token-1"; + const rivet = createRivetKitWithClient({} as never, { + hashFunction: (opts) => + JSON.stringify({ name: opts.name, key: opts.key }), + }); + const actor = rivet.useActor(() => ({ + name: "chat" as never, + key: ["room-1"], + params: { token }, + actionDefaults: {}, + })); + + const pending = actor.increment(1); + expect(actor.pendingActions).toBe(1); + token = "token-2"; + rerunEffects(); + + expect(actor.pendingActions).toBe(1); + expect(actor.lastAction).toBe("increment"); + frameworkMock.push({ connStatus: "connected" }); + await vi.advanceTimersByTimeAsync(0); + + await expect(pending).resolves.toBe(2); + expect(actor.pendingActions).toBe(0); + expect(actor.lastActionError).toBeNull(); + }); }); diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/connection-health.test.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/connection-health.test.ts new file mode 100644 index 0000000000..7aec171695 --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/connection-health.test.ts @@ -0,0 +1,40 @@ +import "./runes-shim.js"; +import { describe, expect, test } from "vitest"; +import { createConnectionHealth } from "../connection-health.svelte.js"; + +describe("createConnectionHealth", () => { + test("reads each source getter once per snapshot", () => { + let statusReads = 0; + let errorReads = 0; + const source = { + get connStatus() { + statusReads += 1; + return "connected"; + }, + get error() { + errorReads += 1; + return null; + }, + }; + + const health = createConnectionHealth(() => ({ chat: source })); + + expect(health.status).toBe("connected"); + expect(health.actors.chat.status).toBe("connected"); + expect(statusReads).toBe(1); + expect(errorReads).toBe(1); + }); + + test("distinguishes connected, degraded, connecting, and offline", () => { + const statusFor = (chat: string, inbox: string) => + createConnectionHealth(() => ({ + chat: { connStatus: chat, error: null }, + inbox: { connStatus: inbox, error: null }, + })).status; + + expect(statusFor("connected", "connected")).toBe("connected"); + expect(statusFor("connected", "disconnected")).toBe("degraded"); + expect(statusFor("connecting", "disconnected")).toBe("connecting"); + expect(statusFor("disconnected", "disconnected")).toBe("offline"); + }); +}); diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/connection-inspector.test.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/connection-inspector.test.ts new file mode 100644 index 0000000000..7c385ea50a --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/connection-inspector.test.ts @@ -0,0 +1,354 @@ +/** + * Pins the opt-in connection inspector: per-handle ownership, hash + * deduplication, status replacement, privacy (no params), and the + * factory wiring through createReactiveActor applyState / dispose. + */ +import "./runes-shim.js"; +import { describe, expect, test, vi, beforeEach } from "vitest"; +import type { ActorConnStatus } from "rivetkit/client"; +import { + CONNECTION_INSPECTOR_SAMPLE_KEYS, + createConnectionInspector, + fallbackInspectorHash, + normalizeActorKey, +} from "../connection-inspector.svelte.js"; + +const frameworkMock = vi.hoisted(() => { + type Subscriber = (value: { currentVal: MockActorState }) => void; + type MockActorState = { + connection: { id: string } | null; + handle: { id: string }; + connStatus: ActorConnStatus; + error: Error | null; + hash: string; + }; + + const subscribers = new Set(); + const statesByHash = new Map(); + + function identityHash(opts: { name?: string; key?: string | string[] }): string { + return JSON.stringify({ name: opts.name, key: opts.key }); + } + + function makeState(hash: string): MockActorState { + return { + connection: { id: `conn:${hash}` }, + handle: { id: `handle:${hash}` }, + connStatus: "idle", + error: null, + hash, + }; + } + + const getOrCreateActor = vi.fn((actorOpts: { name?: string; key?: string | string[] }) => { + const hash = identityHash(actorOpts); + let current = statesByHash.get(hash); + if (!current) { + current = makeState(hash); + statesByHash.set(hash, current); + } + + return { + mount: vi.fn(() => vi.fn()), + state: { + get state() { + return statesByHash.get(hash) ?? current; + }, + subscribe(callback: Subscriber) { + subscribers.add(callback); + return () => subscribers.delete(callback); + }, + }, + }; + }); + + function push(hash: string, next: Partial) { + const current = statesByHash.get(hash) ?? makeState(hash); + const updated = { ...current, ...next, hash }; + statesByHash.set(hash, updated); + subscribers.forEach((subscriber) => subscriber({ currentVal: updated })); + } + + function reset() { + subscribers.clear(); + statesByHash.clear(); + getOrCreateActor.mockClear(); + } + + return { getOrCreateActor, push, identityHash, reset }; +}); + +vi.mock("../internal/framework-base.js", () => ({ + createRivetKit: vi.fn(() => ({ + getOrCreateActor: frameworkMock.getOrCreateActor, + })), +})); + +vi.mock("esm-env", () => ({ + BROWSER: true, + DEV: false, +})); + +import { createRivetKitWithClient } from "../rivetkit.svelte.js"; + +describe("normalizeActorKey / fallbackInspectorHash", () => { + test("normalizes a string key and leaves empty keys as an empty array", () => { + expect(normalizeActorKey("room-1")).toEqual(["room-1"]); + expect(normalizeActorKey(["org", "page"])).toEqual(["org", "page"]); + expect(normalizeActorKey(undefined)).toEqual([]); + expect(normalizeActorKey("")).toEqual([]); + }); + + test("fallback hash is name + key only", () => { + expect(fallbackInspectorHash("page", ["p1"])).toBe( + JSON.stringify({ name: "page", key: ["p1"] }), + ); + }); +}); + +describe("createConnectionInspector", () => { + test("reports a sample, updates status, and counts connected sockets", () => { + const inspector = createConnectionInspector(); + expect(inspector.enabled).toBe(true); + expect(inspector.snapshot()).toEqual([]); + + inspector.report({ + ownerId: "h1", + name: "page", + key: ["page-1"], + hash: "hash-page", + connStatus: "connecting", + hasConnection: false, + }); + + expect(inspector.snapshot()).toEqual([ + { + name: "page", + key: ["page-1"], + hash: expect.any(String), + connStatus: "connecting", + hasConnection: false, + }, + ]); + expect(inspector.connectedCount()).toBe(0); + + const revisionAfterRegister = inspector.revision; + inspector.report({ + ownerId: "h1", + name: "page", + key: ["page-1"], + hash: "hash-page", + connStatus: "connected", + hasConnection: true, + }); + + expect(inspector.revision).toBeGreaterThan(revisionAfterRegister); + expect(inspector.connectedCount()).toBe(1); + expect(inspector.snapshot()[0]?.connStatus).toBe("connected"); + }); + + test("deduplicates shared consumers by hash and keeps the row until the last owner unregisters", () => { + const inspector = createConnectionInspector(); + inspector.report({ + ownerId: "a", + name: "chat", + key: ["ws-1"], + hash: "hash-ws", + connStatus: "connected", + hasConnection: true, + }); + inspector.report({ + ownerId: "b", + name: "chat", + key: ["ws-1"], + hash: "hash-ws", + connStatus: "connected", + hasConnection: true, + }); + + expect(inspector.snapshot()).toHaveLength(1); + expect(inspector.connectedCount()).toBe(1); + + inspector.unregister("a"); + expect(inspector.snapshot()).toHaveLength(1); + expect(inspector.snapshot()[0]?.name).toBe("chat"); + + inspector.unregister("b"); + expect(inspector.snapshot()).toEqual([]); + expect(inspector.connectedCount()).toBe(0); + }); + + test("moving a handle to a new hash drops the old row when it has no remaining owners", () => { + const inspector = createConnectionInspector(); + inspector.report({ + ownerId: "h1", + name: "page", + key: ["old"], + hash: "hash-old", + connStatus: "connected", + hasConnection: true, + }); + inspector.report({ + ownerId: "h1", + name: "page", + key: ["new"], + hash: "hash-new", + connStatus: "connecting", + hasConnection: false, + }); + + const hashes = inspector.snapshot().map((row) => row.hash).sort(); + expect(hashes).toHaveLength(1); + expect(hashes[0]).not.toBe("hash-new"); + expect(inspector.snapshot()[0]?.key).toEqual(["new"]); + }); + + test("discards params, tokens, and any extra report fields from the snapshot", () => { + const inspector = createConnectionInspector(); + inspector.report({ + ownerId: "h1", + name: "user", + key: ["user@example.com"], + hash: "hash-user", + connStatus: "connected", + hasConnection: true, + params: { authToken: "secret-jwt", token: "also-secret" }, + getParams: () => ({ authToken: "secret-jwt" }), + payload: { email: "user@example.com" }, + } as never); + + const [row] = inspector.snapshot(); + expect(row).toBeDefined(); + expect(Object.keys(row!).sort()).toEqual( + [...CONNECTION_INSPECTOR_SAMPLE_KEYS].sort(), + ); + expect(JSON.stringify(row)).not.toContain("secret"); + expect(JSON.stringify(row)).not.toContain("authToken"); + expect(JSON.stringify(row)).not.toContain("params"); + }); + + test("unregister is a no-op for an unknown owner", () => { + const inspector = createConnectionInspector(); + inspector.unregister("missing"); + expect(inspector.snapshot()).toEqual([]); + expect(inspector.revision).toBe(0); + }); +}); + +describe("createRivetKitWithClient connectionInspector option", () => { + beforeEach(() => { + frameworkMock.reset(); + }); + + test("leaves the inspector null when the option is omitted", () => { + const rivet = createRivetKitWithClient({} as never); + expect(rivet.connectionInspector).toBeNull(); + + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + actor.mount(); + expect(rivet.connectionInspector).toBeNull(); + actor.dispose(); + }); + + test("registers, updates, and removes a reactive actor through applyState", () => { + const rivet = createRivetKitWithClient({} as never, { + connectionInspector: true, + }); + const inspector = rivet.connectionInspector; + expect(inspector?.enabled).toBe(true); + + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + expect(inspector?.snapshot()).toEqual([]); + + actor.mount(); + const hash = frameworkMock.identityHash({ + name: "chat", + key: ["room-1"], + }); + expect(inspector?.snapshot()).toEqual([ + { + name: "chat", + key: ["room-1"], + hash: expect.any(String), + connStatus: "idle", + hasConnection: true, + }, + ]); + + frameworkMock.push(hash, { connStatus: "connected" }); + expect(inspector?.connectedCount()).toBe(1); + expect(inspector?.snapshot()[0]?.connStatus).toBe("connected"); + + actor.dispose(); + expect(inspector?.snapshot()).toEqual([]); + }); + + test("keeps one row when two handles share an identity and survive a single dispose", () => { + const rivet = createRivetKitWithClient({} as never, { + connectionInspector: true, + }); + const inspector = rivet.connectionInspector; + + const a = rivet.createReactiveActor({ + name: "page" as never, + key: ["p1"], + }); + const b = rivet.createReactiveActor({ + name: "page" as never, + key: ["p1"], + }); + a.mount(); + b.mount(); + + expect(inspector?.snapshot()).toHaveLength(1); + a.dispose(); + expect(inspector?.snapshot()).toHaveLength(1); + expect(inspector?.snapshot()[0]?.name).toBe("page"); + b.dispose(); + expect(inspector?.snapshot()).toEqual([]); + }); + + test("unmount without dispose drops the row so leaked handles do not linger", () => { + const rivet = createRivetKitWithClient({} as never, { + connectionInspector: true, + }); + const actor = rivet.createReactiveActor({ + name: "tile" as never, + key: ["t1"], + }); + const release = actor.mount(); + expect(rivet.connectionInspector?.snapshot()).toHaveLength(1); + release(); + expect(rivet.connectionInspector?.snapshot()).toEqual([]); + }); +}); + + +test("redacts credential-bearing framework hashes without merging distinct sockets", () => { + const inspector = createConnectionInspector(); + const report = (ownerId: string, token: string) => inspector.report({ + ownerId, name: "counter", key: ["same"], + hash: JSON.stringify({ name: "counter", key: ["same"], params: { token } }), + connStatus: "connected", hasConnection: true, + }); + report("a", "secret-one"); + report("b", "secret-one"); + report("c", "secret-two"); + const rows = inspector.snapshot(); + expect(rows).toHaveLength(2); + expect(new Set(rows.map(row => row.hash)).size).toBe(2); + expect(JSON.stringify(rows)).not.toContain("secret"); + expect(JSON.stringify(rows)).not.toContain("params"); + report("a", "secret-one"); + expect(inspector.snapshot()).toEqual(rows); + inspector.unregister("a"); + expect(inspector.snapshot()).toEqual(rows); + inspector.unregister("b"); + expect(inspector.snapshot()).toEqual([rows[1]]); +}); diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/context.test.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/context.test.ts index c69def8c54..ac5a8e30d0 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/__tests__/context.test.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/context.test.ts @@ -1,62 +1,59 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; const svelteMock = vi.hoisted(() => { - const markerContexts = new Map(); - const valueContexts = new Map(); - - return { - markerContexts, - valueContexts, - reset() { - markerContexts.clear(); - valueContexts.clear(); - }, - }; + const markerContexts = new Map(); + const valueContexts = new Map(); + + return { + markerContexts, + valueContexts, + reset() { + markerContexts.clear(); + valueContexts.clear(); + }, + }; }); vi.mock("svelte", () => ({ - createContext: () => { - const key = Symbol("rivet-context"); - return [ - () => svelteMock.valueContexts.get(key), - (value: unknown) => { - svelteMock.valueContexts.set(key, value); - return value; - }, - ] as const; - }, - hasContext: (key: symbol) => svelteMock.markerContexts.has(key), - setContext: (key: symbol, value: unknown) => { - svelteMock.markerContexts.set(key, value); - return value; - }, + createContext: () => { + const key = Symbol("rivet-context"); + return [ + () => svelteMock.valueContexts.get(key), + (value: unknown) => { + svelteMock.valueContexts.set(key, value); + return value; + }, + ] as const; + }, + hasContext: (key: symbol) => svelteMock.markerContexts.has(key), + setContext: (key: symbol, value: unknown) => { + svelteMock.markerContexts.set(key, value); + return value; + }, })); import { createRivetContext } from "../context.js"; describe("createRivetContext", () => { - beforeEach(() => { - svelteMock.reset(); - }); - - test("supports set/get/has for typed contexts", () => { - const context = createRivetContext("TestRivet"); - const rivet = { - useActor: vi.fn(), - createReactiveActor: vi.fn(), - } as never; - - expect(context.has()).toBe(false); - expect(context.set(rivet)).toBe(rivet); - expect(context.has()).toBe(true); - expect(context.get()).toBe(rivet); - }); - - test("reports missing context with a descriptive error", () => { - const context = createRivetContext("TestRivet"); - - expect(() => context.get()).toThrow( - 'Context "TestRivet" not found. Create an app-local Rivet context and call TestRivet.set(...) or TestRivet.setup(...) in a parent layout.', - ); - }); + beforeEach(() => { + svelteMock.reset(); + }); + + test("supports set/get/has for typed contexts", () => { + const context = createRivetContext("TestRivet"); + const rivet = { useActor: vi.fn(), createReactiveActor: vi.fn() } as never; + + expect(context.has()).toBe(false); + expect(context.set(rivet)).toBe(rivet); + expect(context.has()).toBe(true); + expect(context.get()).toBe(rivet); + }); + + test("reports missing context with a descriptive error", () => { + const context = createRivetContext("TestRivet"); + + expect(() => context.get()).toThrow( + 'Context "TestRivet" not found. Create an app-local Rivet context and call TestRivet.set(...) or TestRivet.setup(...) in a parent layout.', + ); + }); }); diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/framework-base-getparams.test.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/framework-base-getparams.test.ts new file mode 100644 index 0000000000..1b4549ae1f --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/framework-base-getparams.test.ts @@ -0,0 +1,78 @@ +/** + * Runtime contract for the package-local framework bridge. + * + * Expiring actor credentials must reach the Rivet client as `getParams` for + * both existing-only and create-if-missing access. This test exercises the + * actual bridge shipped inside `@rivetkit/svelte`; it deliberately does not + * inspect a workspace package-manager patch. + */ + +import { describe, expect, test, vi } from "vitest"; +import { createRivetKit } from "../internal/framework-base.js"; + +function connection() { + return { + connStatus: "connecting", + onStatusChange: () => () => {}, + onError: () => () => {}, + dispose: () => {}, + }; +} + +function clientHarness() { + const connect = vi.fn(() => connection()); + const handle = { connect }; + const get = vi.fn(() => handle); + const getOrCreate = vi.fn(() => handle); + return { + client: { get, getOrCreate }, + connect, + get, + getOrCreate, + }; +} + +describe("package-local framework getParams forwarding", () => { + test("forwards a fresh-params resolver through getOrCreate", () => { + const harness = clientHarness(); + const getParams = vi.fn(async () => ({ token: "fresh" })); + const framework = createRivetKit(harness.client as never); + const actor = framework.getOrCreateActor({ + name: "document" as never, + key: ["document", "doc-1"], + getParams, + }); + const unmount = actor.mount(); + + expect(harness.getOrCreate).toHaveBeenCalledWith( + "document", + ["document", "doc-1"], + expect.objectContaining({ getParams }), + ); + expect(harness.connect).toHaveBeenCalledTimes(1); + + unmount(); + }); + + test("forwards a fresh-params resolver through existing-only get", () => { + const harness = clientHarness(); + const getParams = vi.fn(async () => ({ token: "fresh" })); + const framework = createRivetKit(harness.client as never); + const actor = framework.getOrCreateActor({ + name: "document" as never, + key: ["document", "doc-2"], + noCreate: true, + getParams, + }); + const unmount = actor.mount(); + + expect(harness.get).toHaveBeenCalledWith( + "document", + ["document", "doc-2"], + expect.objectContaining({ getParams }), + ); + expect(harness.connect).toHaveBeenCalledTimes(1); + + unmount(); + }); +}); diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/helpers.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/helpers.ts index c85303ee99..1a4008b877 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/__tests__/helpers.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/helpers.ts @@ -1,63 +1,60 @@ -import type { ActorConn, AnyActorDefinition } from "rivetkit/client"; - export type Status = "idle" | "connecting" | "connected" | "disconnected"; -export function createMockConnection() { - let status: Status = "idle"; - const statusListeners = new Set<(status: Status) => void>(); - const errorListeners = new Set<(error: Error) => void>(); - const eventListeners = new Map void>>(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createMockConnection(): { + connection: any; + setStatus(next: Status): void; + emitError(message: string): void; + emit(eventName: string, ...args: unknown[]): void; +} { + let status: Status = "idle"; + const statusListeners = new Set<(status: Status) => void>(); + const errorListeners = new Set<(error: Error) => void>(); + const eventListeners = new Map void>>(); - const connection = { - get connStatus() { - return status; - }, - onStatusChange(callback: (next: Status) => void) { - statusListeners.add(callback); - return () => statusListeners.delete(callback); - }, - onError(callback: (error: Error) => void) { - errorListeners.add(callback); - return () => errorListeners.delete(callback); - }, - on(eventName: string, callback: (...args: unknown[]) => void) { - let listeners = eventListeners.get(eventName); - if (!listeners) { - listeners = new Set(); - eventListeners.set(eventName, listeners); - } - listeners.add(callback); - return () => listeners?.delete(callback); - }, - async dispose() { - status = "disconnected"; - for (const listener of statusListeners) { - listener(status); - } - }, - ping() { - return "pong"; - }, - } as unknown as ActorConn & { ping(): string }; + const connection = { + get connStatus() { + return status; + }, + onStatusChange(callback: (next: Status) => void) { + statusListeners.add(callback); + return () => statusListeners.delete(callback); + }, + onError(callback: (error: Error) => void) { + errorListeners.add(callback); + return () => errorListeners.delete(callback); + }, + on(eventName: string, callback: (...args: unknown[]) => void) { + let listeners = eventListeners.get(eventName); + if (!listeners) { + listeners = new Set(); + eventListeners.set(eventName, listeners); + } + listeners.add(callback); + return () => listeners?.delete(callback); + }, + async dispose() { + status = "disconnected"; + statusListeners.forEach((listener) => listener(status)); + }, + ping() { + return "pong"; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; - return { - connection, - setStatus(next: Status) { - status = next; - for (const listener of statusListeners) { - listener(status); - } - }, - emitError(message: string) { - const error = new Error(message); - for (const listener of errorListeners) { - listener(error); - } - }, - emit(eventName: string, ...args: unknown[]) { - for (const listener of eventListeners.get(eventName) ?? []) { - listener(...args); - } - }, - }; + return { + connection, + setStatus(next: Status) { + status = next; + statusListeners.forEach((listener) => listener(status)); + }, + emitError(message: string) { + const error = new Error(message); + errorListeners.forEach((listener) => listener(error)); + }, + emit(eventName: string, ...args: unknown[]) { + eventListeners.get(eventName)?.forEach((listener) => listener(...args)); + }, + }; } diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/reactive-actor.test.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/reactive-actor.test.ts index c9f7ed39e2..dcc8e21695 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/__tests__/reactive-actor.test.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/reactive-actor.test.ts @@ -1,195 +1,435 @@ import "./runes-shim.js"; +import { describe, expect, test, vi, beforeEach } from "vitest"; import type { ActorConnStatus } from "rivetkit/client"; -import { beforeEach, describe, expect, test, vi } from "vitest"; const frameworkMock = vi.hoisted(() => { - type Listener = (...args: unknown[]) => void; - type Subscriber = (value: { currentVal: MockActorState }) => void; - type MockConnection = { - id: string; - ping: () => string; - on: (eventName: string, handler: Listener) => () => void; - emit: (eventName: string, ...args: unknown[]) => void; - }; - type MockActorState = { - connection: MockConnection; - handle: { id: string }; - connStatus: ActorConnStatus; - error: Error | null; - hash: string; - }; - - const subscribers = new Set(); - - function createConnection(id: string): MockConnection { - const listeners = new Map>(); - - return { - id, - ping: () => `pong:${id}`, - on(eventName: string, handler: Listener) { - let eventListeners = listeners.get(eventName); - if (!eventListeners) { - eventListeners = new Set(); - listeners.set(eventName, eventListeners); - } - - eventListeners.add(handler); - return () => eventListeners?.delete(handler); - }, - emit(eventName: string, ...args: unknown[]) { - for (const listener of listeners.get(eventName) ?? []) { - listener(...args); - } - }, - }; - } - - let currentState: MockActorState; - - const getOrCreateActor = vi.fn(() => ({ - mount: vi.fn(() => vi.fn()), - state: { - get state() { - return currentState; - }, - subscribe(callback: Subscriber) { - subscribers.add(callback); - return () => subscribers.delete(callback); - }, - }, - })); - - function push(next: Partial) { - currentState = { ...currentState, ...next }; - for (const subscriber of subscribers) { - subscriber({ currentVal: currentState }); - } - } - - function reset() { - subscribers.clear(); - currentState = { - connection: createConnection("one"), - handle: { id: "handle-one" }, - connStatus: "idle", - error: null, - hash: "hash-one", - }; - getOrCreateActor.mockClear(); - } - - reset(); - - return { - getOrCreateActor, - currentState: () => currentState, - push, - replaceConnection(id: string) { - const connection = createConnection(id); - push({ - connection, - handle: { id: `handle-${id}` }, - hash: `hash-${id}`, - }); - return connection; - }, - reset, - }; + type Listener = (...args: unknown[]) => void; + type Subscriber = (value: { currentVal: MockActorState }) => void; + type MockConnection = { + id: string; + ping: () => string; + admin: { ping: () => string }; + on: (eventName: string, handler: Listener) => () => void; + emit: (eventName: string, ...args: unknown[]) => void; + }; + type MockActorState = { + connection: MockConnection; + handle: { id: string }; + connStatus: ActorConnStatus; + error: Error | null; + hash: string; + }; + + const subscribers = new Set(); + + function createConnection(id: string): MockConnection { + const listeners = new Map>(); + + return { + id, + ping: () => `pong:${id}`, + admin: { ping: () => `admin-pong:${id}` }, + on(eventName: string, handler: Listener) { + let eventListeners = listeners.get(eventName); + if (!eventListeners) { + eventListeners = new Set(); + listeners.set(eventName, eventListeners); + } + + eventListeners.add(handler); + return () => eventListeners?.delete(handler); + }, + emit(eventName: string, ...args: unknown[]) { + listeners.get(eventName)?.forEach((listener) => listener(...args)); + }, + }; + } + + let currentState: MockActorState; + let lastMount: ReturnType | null = null; + let lastUnmount: ReturnType | null = null; + + const getOrCreateActor = vi.fn(() => { + lastUnmount = vi.fn(); + lastMount = vi.fn(() => lastUnmount!); + return { + mount: lastMount, + state: { + get state() { + return currentState; + }, + subscribe(callback: Subscriber) { + subscribers.add(callback); + return () => subscribers.delete(callback); + }, + }, + }; + }); + + function push(next: Partial) { + currentState = { ...currentState, ...next }; + subscribers.forEach((subscriber) => + subscriber({ currentVal: currentState }), + ); + } + + function reset() { + subscribers.clear(); + currentState = { + connection: createConnection("one"), + handle: { id: "handle-one" }, + connStatus: "idle", + error: null, + hash: "hash-one", + }; + getOrCreateActor.mockClear(); + lastMount = null; + lastUnmount = null; + } + + reset(); + + return { + getOrCreateActor, + currentState: () => currentState, + push, + replaceConnection(id: string) { + const connection = createConnection(id); + push({ + connection, + handle: { id: `handle-${id}` }, + hash: `hash-${id}`, + }); + return connection; + }, + lastMount: () => lastMount, + lastUnmount: () => lastUnmount, + reset, + }; }); -vi.mock("@rivetkit/framework-base", () => ({ - createRivetKit: vi.fn(() => ({ - getOrCreateActor: frameworkMock.getOrCreateActor, - })), +vi.mock("../internal/framework-base.js", () => ({ + createRivetKit: vi.fn(() => ({ + getOrCreateActor: frameworkMock.getOrCreateActor, + })), +})); + +// preConnect() short-circuits to an inert handle under SSR (BROWSER=false). +// Pin BROWSER=true so it actually mounts; createReactiveActor's own tests are +// unaffected (they only branch on BROWSER for a dev-time SSR warning). +vi.mock("esm-env", () => ({ + BROWSER: true, + DEV: false, })); import { createRivetKitWithClient } from "../rivetkit.svelte.js"; describe("createReactiveActor", () => { - beforeEach(() => { - frameworkMock.reset(); - }); - - test("caches proxied actor methods until the connection changes", () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - }); - - const firstPing = actor.ping; - const secondPing = actor.ping; - - expect(firstPing).toBe(secondPing); - expect(firstPing()).toBe("pong:one"); - - frameworkMock.replaceConnection("two"); - - const thirdPing = actor.ping; - expect(thirdPing).not.toBe(firstPing); - expect(thirdPing()).toBe("pong:two"); - }); - - test("preserves lastError and tracks hasEverConnected", () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - }); - - expect(actor.lastError).toBe(null); - expect(actor.hasEverConnected).toBe(false); - - frameworkMock.push({ - connStatus: "disconnected", - error: new Error("boom"), - }); - - expect(actor.error?.message).toBe("boom"); - expect(actor.lastError?.message).toBe("boom"); - expect(actor.hasEverConnected).toBe(false); - - frameworkMock.push({ - connStatus: "connected", - error: null, - }); - - expect(actor.isConnected).toBe(true); - expect(actor.hasEverConnected).toBe(true); - expect(actor.lastError?.message).toBe("boom"); - - frameworkMock.push({ - connStatus: "disconnected", - error: null, - }); - - expect(actor.error).toBe(null); - expect(actor.lastError?.message).toBe("boom"); - }); - - test("rebinds event listeners when the connection changes", () => { - const rivet = createRivetKitWithClient({} as never); - const actor = rivet.createReactiveActor({ - name: "chat" as never, - key: ["room-1"], - }); - - const firstConnection = frameworkMock.currentState().connection; - const received: string[] = []; - - actor.onEvent("message", (payload: unknown) => { - received.push(String(payload)); - }); - - firstConnection.emit("message", "one"); - expect(received).toEqual(["one"]); - - const secondConnection = frameworkMock.replaceConnection("two"); - - firstConnection.emit("message", "stale"); - secondConnection.emit("message", "two"); - - expect(received).toEqual(["one", "two"]); - }); + beforeEach(() => { + frameworkMock.reset(); + }); + + test("defers framework subscription until mount", () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + + expect(frameworkMock.getOrCreateActor).not.toHaveBeenCalled(); + + const unmount = actor.mount(); + expect(frameworkMock.getOrCreateActor).toHaveBeenCalledTimes(1); + expect(frameworkMock.lastMount()).toHaveBeenCalledTimes(1); + + unmount(); + }); + + test("keeps proxied actor methods stable across connection changes", () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + actor.mount(); + + const firstPing = actor.ping; + const secondPing = actor.ping; + + expect(firstPing).toBe(secondPing); + expect(firstPing()).toBe("pong:one"); + + frameworkMock.replaceConnection("two"); + + const thirdPing = actor.ping; + expect(thirdPing).toBe(firstPing); + expect(firstPing()).toBe("pong:two"); + expect(thirdPing()).toBe("pong:two"); + }); + + test("supports handlers captured before mount and nested Rivet actions", () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + const ping = actor.ping; + // This runtime-only test intentionally uses an erased registry. Describe + // the nested fake action locally now that the public factory no longer + // leaks `any` into consumers. + const adminPing = (actor as unknown as { admin: { ping: () => string } }) + .admin.ping; + + actor.mount(); + + expect(ping()).toBe("pong:one"); + expect(adminPing()).toBe("admin-pong:one"); + + frameworkMock.replaceConnection("two"); + expect(adminPing()).toBe("admin-pong:two"); + }); + + test("is not Promise-like", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + actor.mount(); + + await expect(Promise.resolve(actor)).resolves.toBe(actor); + }); + + test("detaches captured handlers from the connection on dispose", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + actor.mount(); + const ping = actor.ping; + + actor.dispose(); + + expect(actor.connection).toBeNull(); + expect(actor.connStatus).toBe("idle"); + await expect(ping()).rejects.toMatchObject({ + code: "ACTOR_NOT_YET_CONNECTED", + connStatus: "idle", + }); + }); + + test("preserves lastError and tracks hasEverConnected", () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + actor.mount(); + + expect(actor.lastError).toBe(null); + expect(actor.hasEverConnected).toBe(false); + + frameworkMock.push({ + connStatus: "disconnected", + error: new Error("boom"), + }); + + expect(actor.error?.message).toBe("boom"); + expect(actor.lastError?.message).toBe("boom"); + expect(actor.hasEverConnected).toBe(false); + + frameworkMock.push({ + connStatus: "connected", + error: null, + }); + + expect(actor.isConnected).toBe(true); + expect(actor.hasEverConnected).toBe(true); + expect(actor.lastError?.message).toBe("boom"); + + frameworkMock.push({ + connStatus: "disconnected", + error: null, + }); + + expect(actor.error).toBe(null); + expect(actor.lastError?.message).toBe("boom"); + }); + + test("whenConnected resolves immediately when already connected", async () => { + frameworkMock.push({ connStatus: "connected" }); + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + actor.mount(); + + const result = await actor.whenConnected(); + expect(result).toBe(true); + }); + + test("whenConnected resolves when connection is established", async () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + actor.mount(); + + expect(actor.isConnected).toBe(false); + + const promise = actor.whenConnected(5_000); + + // Simulate connection after a tick + frameworkMock.push({ connStatus: "connected" }); + + const result = await promise; + expect(result).toBe(true); + }); + + test("whenConnected resolves false on timeout", async () => { + vi.useFakeTimers(); + + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + actor.mount(); + + const promise = actor.whenConnected(100); + + // Advance past timeout without connecting + vi.advanceTimersByTime(150); + + const result = await promise; + expect(result).toBe(false); + + vi.useRealTimers(); + }); + + test("dispose cancels pending whenConnected with false", async () => { + vi.useFakeTimers(); + + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + actor.mount(); + + const promise = actor.whenConnected(30_000); + + // Dispose before connection is established + actor.dispose(); + + const result = await promise; + expect(result).toBe(false); + + vi.useRealTimers(); + }); + + test("rebinds event listeners when the connection changes", () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + actor.mount(); + + const firstConnection = frameworkMock.currentState().connection; + const received: string[] = []; + + actor.onEvent("message", (payload: unknown) => { + received.push(String(payload)); + }); + + firstConnection.emit("message", "one"); + expect(received).toEqual(["one"]); + + const secondConnection = frameworkMock.replaceConnection("two"); + + firstConnection.emit("message", "stale"); + secondConnection.emit("message", "two"); + + expect(received).toEqual(["one", "two"]); + }); + + test("dispose unmounts active framework refs", () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + + actor.mount(); + actor.dispose(); + + expect(frameworkMock.lastUnmount()).toHaveBeenCalledTimes(1); + }); + + test("binds event listeners registered before mount", () => { + const rivet = createRivetKitWithClient({} as never); + const actor = rivet.createReactiveActor({ + name: "chat" as never, + key: ["room-1"], + }); + const received: string[] = []; + + actor.onEvent("message", (payload: unknown) => { + received.push(String(payload)); + }); + + actor.mount(); + frameworkMock.currentState().connection.emit("message", "mounted"); + + expect(received).toEqual(["mounted"]); + }); +}); + +describe("preConnect", () => { + beforeEach(() => { + frameworkMock.reset(); + }); + + test("opens a connection eagerly and disposes on demand", async () => { + const rivet = createRivetKitWithClient({} as never); + + const handle = rivet.preConnect({ name: "chat" as never, key: ["room-1"] }); + + // Unlike createReactiveActor (which defers until mount), preConnect mounts + // immediately so the socket is live before any component takes over. + expect(frameworkMock.getOrCreateActor).toHaveBeenCalledTimes(1); + expect(frameworkMock.lastMount()).toHaveBeenCalledTimes(1); + + await handle.dispose(); + expect(frameworkMock.lastUnmount()).toHaveBeenCalledTimes(1); + }); + + test("dispose is idempotent", async () => { + const rivet = createRivetKitWithClient({} as never); + + const handle = rivet.preConnect({ name: "chat" as never, key: ["room-1"] }); + await handle.dispose(); + await handle.dispose(); + + expect(frameworkMock.lastUnmount()).toHaveBeenCalledTimes(1); + }); + + test("forces enabled for an explicit eager connection", async () => { + const rivet = createRivetKitWithClient({} as never); + + const handle = rivet.preConnect({ + name: "chat" as never, + key: ["room-1"], + enabled: false, + }); + + expect(frameworkMock.getOrCreateActor).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }), + ); + await handle.dispose(); + }); }); diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/reconnect.test.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/reconnect.test.ts new file mode 100644 index 0000000000..c9f911a1e9 --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/reconnect.test.ts @@ -0,0 +1,262 @@ +// Guard for ReactiveActorHandle.reconnect() — the zombie-socket recovery +// primitive (rivetkit.svelte.ts, createReactiveActor's `inner.reconnect`). +// +// THE PROBLEM it solves: a half-open "zombie" WebSocket (NAT/LB idle cull, +// half-open TCP) keeps reporting connStatus === "connected", so a plain +// dispose() + mount() REUSES it — the framework core only creates a fresh +// connection from "idle", and the zombie never reaches "idle". Recovery +// requires an explicit socket replacement. +// +// THE FIX: reconnect() drives framework-base's enabled toggle — +// `getOrCreateActor({enabled:false})` makes framework-base's effect call +// `connection.dispose()` and reset the actor to "idle"; a follow-up +// `getOrCreateActor({enabled:true})` (queued one microtask later, after the +// disable has flushed) re-creates from "idle", opening a brand-new socket. +// +// WHY THIS TEST IS NOT VACUOUS: the discriminating behavior lives entirely +// inside the framework core's effect/idle-gate machinery, so the test +// runs the REAL framework-base (NOT vi.mock'd) over a fake rivetkit client whose +// connections are zombies (report "connected" forever, record dispose()). The +// "control" test proves that WITHOUT reconnect() the zombie persists and no new +// socket opens — so the assertions in the main test can only pass because +// reconnect() actually swapped the socket. + +import "./runes-shim.js"; +import { describe, expect, test } from "vitest"; +import { createRivetKitWithClient } from "../rivetkit.svelte.js"; + +// Minimal handle surface used by these tests. Casting through this avoids +// `createReactiveActor`'s deep generic instantiation (TS2589, the same +// rivetkit-2.1.10 conditional-type depth the package erases internally) — the +// test exercises runtime behavior, not client types. +interface TestHandle { + mount: () => () => void; + reconnect: () => void; + dispose: () => void; + onEvent: (event: string, handler: (...args: unknown[]) => void) => () => void; +} +interface TestRivet { + createReactiveActor: (opts: unknown) => TestHandle; +} +const makeRivet = (client: unknown, opts?: unknown): TestRivet => + ( + createRivetKitWithClient as unknown as ( + c: unknown, + options?: unknown, + ) => TestRivet + )(client, opts); + +interface FakeConn { + id: number; + disposed: boolean; + onStatusChange: (cb: (status: string) => void) => () => void; + onError: (cb: (err: Error) => void) => () => void; + on: (event: string, handler: (...args: unknown[]) => void) => () => void; + emit: (event: string, ...args: unknown[]) => void; + dispose: () => void; +} + +// A fake rivetkit client that hands out ZOMBIE connections: each one reports +// "connected" immediately and never moves — exactly the half-open socket the +// fix targets. Every connection is recorded so a test can assert which were +// disposed and how many distinct sockets were opened. +function makeZombieClient(): { client: unknown; conns: FakeConn[] } { + const conns: FakeConn[] = []; + + function makeConn(): FakeConn { + const listeners = new Map void>>(); + const conn: FakeConn = { + id: conns.length, + disposed: false, + // Report "connected" so framework-base sees a live conn — the zombie. + onStatusChange: (cb) => { + cb("connected"); + return () => {}; + }, + onError: () => () => {}, + on(event, handler) { + let set = listeners.get(event); + if (!set) { + set = new Set(); + listeners.set(event, set); + } + set.add(handler); + return () => set!.delete(handler); + }, + emit(event, ...args) { + listeners.get(event)?.forEach((handler) => handler(...args)); + }, + dispose() { + this.disposed = true; + }, + }; + conns.push(conn); + return conn; + } + + const handle = { connect: () => makeConn() }; + const client = { + get: () => handle, + getOrCreate: () => handle, + }; + return { client, conns }; +} + +// Drain the microtask chain (framework-base queues opts-updates + create() on +// queueMicrotask; commitState flushes entry state and listeners synchronously). +// A handful of macrotask boundaries deterministically drains it, so this is +// not flaky. +const flush = async (): Promise => { + for (let i = 0; i < 5; i++) { + await new Promise((r) => setTimeout(r, 0)); + } +}; + +function mountActor() { + const { client, conns } = makeZombieClient(); + const rivet = makeRivet(client); + // A compound key identifies this actor instance. + const handle = rivet.createReactiveActor({ + name: "chat", + key: ["chat", "agent-1"], + }); + const unmount = handle.mount(); + return { handle, conns, unmount }; +} + +describe("ReactiveActorHandle.reconnect()", () => { + test("disposes the zombie connection and opens a brand-new socket", async () => { + const { handle, conns } = mountActor(); + await flush(); + + // Initial connect produced exactly one (zombie) connection. + expect(conns.length).toBe(1); + expect(conns[0].disposed).toBe(false); + + handle.reconnect(); + await flush(); + + // The zombie was actually torn down... + expect(conns[0].disposed).toBe(true); + // ...and a fresh, distinct connection replaced it. + expect(conns.length).toBe(2); + expect(conns[1].disposed).toBe(false); + }); + + test("control: WITHOUT reconnect() the zombie persists — proves the guard is not vacuous", async () => { + const { conns } = mountActor(); + await flush(); + // Same flushing, no reconnect() call: the zombie is never disposed and no + // second socket is opened. The only difference from the test above is the + // reconnect() call — so that call is what causes the dispose + replace. + await flush(); + expect(conns[0].disposed).toBe(false); + expect(conns.length).toBe(1); + }); + + test("rebinds onEvent listeners onto the fresh connection", async () => { + const { handle, conns } = mountActor(); + await flush(); + + let calls = 0; + handle.onEvent("ping", () => { + calls++; + }); + + handle.reconnect(); + await flush(); + expect(conns.length).toBe(2); + + // The listener registered on the original (now-disposed) connection is + // re-bound onto the new socket... + conns[1].emit("ping"); + expect(calls).toBe(1); + // ...and the old connection no longer drives it. + conns[0].emit("ping"); + expect(calls).toBe(1); + }); + + test("is a no-op before the actor is ever mounted", () => { + const { client } = makeZombieClient(); + const rivet = makeRivet(client); + const handle = rivet.createReactiveActor({ + name: "chat", + key: ["chat", "agent-1"], + }); + // Never mounted → nothing to reconnect; must not throw. + expect(() => handle.reconnect()).not.toThrow(); + }); + + test("keeps lifecycle-only enabled out of custom identity hashes", async () => { + const { client, conns } = makeZombieClient(); + const hashInputs: Array> = []; + const rivet = makeRivet(client, { + hashFunction: (opts: Record) => { + hashInputs.push(opts); + return JSON.stringify({ name: opts.name, key: opts.key }); + }, + }); + const handle = rivet.createReactiveActor({ + name: "chat", + key: ["chat", "agent-1"], + }); + handle.mount(); + await flush(); + + handle.reconnect(); + await flush(); + + expect(conns).toHaveLength(2); + expect(hashInputs.length).toBeGreaterThanOrEqual(3); + expect(hashInputs.every((opts) => !("enabled" in opts))).toBe(true); + }); +}); + + +test("remounts after the last owner releases and framework cleanup completes", async () => { + const { handle, conns, unmount } = mountActor(); + await flush(); + let events = 0; + handle.onEvent("ping", () => { events++; }); + unmount(); + await flush(); + expect(conns[0].disposed).toBe(true); + const release = handle.mount(); + await flush(); + expect(conns).toHaveLength(2); + conns[1].emit("ping"); + conns[0].emit("ping"); + expect(events).toBe(1); + release(); + handle.dispose(); + await flush(); +}); + + +test("retains the shared binding until this handle's final mount releases", async () => { + const { handle, conns, unmount } = mountActor(); + const releaseSecond = handle.mount(); + await flush(); + let events = 0; + handle.onEvent("ping", () => { events++; }); + unmount(); + await flush(); + expect(conns).toHaveLength(1); + expect(conns[0].disposed).toBe(false); + conns[0].emit("ping"); + expect(events).toBe(1); + releaseSecond(); + const releaseThird = handle.mount(); + await flush(); + expect(conns).toHaveLength(1); + expect(conns[0].disposed).toBe(false); + releaseSecond(); // A stale release cannot release the new mount. + await flush(); + expect(conns[0].disposed).toBe(false); + conns[0].emit("ping"); + expect(events).toBe(2); + releaseThird(); + handle.dispose(); + await flush(); + expect(conns[0].disposed).toBe(true); +}); diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/rivetkit-real-runes.test.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/rivetkit-real-runes.test.ts new file mode 100644 index 0000000000..dbab3fe87f --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/rivetkit-real-runes.test.ts @@ -0,0 +1,230 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { compileModule } from "svelte/compiler"; +import { flushSync } from "svelte"; +import * as ts from "typescript"; +import { describe, expect, test } from "vitest"; + +const require = createRequire(import.meta.url); +const svelteInternalUrl = pathToFileURL( + require.resolve("svelte/internal/client"), +).href; +const frameworkHarnessKey = "__rivetkit_svelte_real_runes_framework__"; + +function dataModule(source: string): string { + return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; +} + +function compileRunesModule(source: string, filename: string): string { + const { js } = compileModule(source, { + filename, + generate: "client", + dev: false, + }); + return js.code.replaceAll("svelte/internal/client", svelteInternalUrl); +} + +function replaceModule( + source: string, + specifier: string, + replacement: string, +): string { + return source.replaceAll(`"${specifier}"`, JSON.stringify(replacement)); +} + +async function loadRealRunesHarness() { + const frameworkStub = dataModule(` + export function createRivetKit(_client, opts = {}) { + const harness = globalThis[${JSON.stringify(frameworkHarnessKey)}]; + harness.configure(opts); + return { getOrCreateActor: harness.getOrCreateActor }; + } + `); + const clientStub = dataModule(` + export function createClient(input) { return input ?? {}; } + `); + const envStub = dataModule(` + export const BROWSER = true; + export const DEV = false; + `); + const extractStub = dataModule(` + export function extract(value) { + return typeof value === "function" ? value() : value; + } + `); + const inspectorStub = dataModule(` + export function createConnectionInspector() { + throw new Error("connection inspector is disabled in this harness"); + } + `); + + const sourcePath = resolve(process.cwd(), "src/lib/rivetkit.svelte.ts"); + const source = readFileSync(sourcePath, "utf8"); + let stripped = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + verbatimModuleSyntax: true, + }, + fileName: sourcePath, + }).outputText; + stripped = replaceModule( + stripped, + "./internal/framework-base.js", + frameworkStub, + ); + stripped = replaceModule(stripped, "rivetkit/client", clientStub); + stripped = replaceModule(stripped, "esm-env", envStub); + stripped = replaceModule(stripped, "./internal/extract.js", extractStub); + stripped = replaceModule( + stripped, + "./connection-inspector.svelte.js", + inspectorStub, + ); + + const adapterUrl = dataModule(compileRunesModule(stripped, sourcePath)); + const harnessSource = ` + import { createRivetKitWithClient } from ${JSON.stringify(adapterUrl)}; + + export function createHarness() { + let token = $state("token-1"); + let actor; + const dispose = $effect.root(() => { + const rivet = createRivetKitWithClient({}, { + hashFunction: ({ name, key }) => JSON.stringify({ name, key }) + }); + actor = rivet.useActor(() => ({ + name: "chat", + key: ["room-1"], + params: { token }, + actionDefaults: {} + })); + }); + return { + get actor() { return actor; }, + setToken(value) { token = value; }, + dispose + }; + } + `; + const harnessUrl = dataModule( + compileRunesModule(harnessSource, "rivetkit-real-runes-harness.svelte.js"), + ); + return import(harnessUrl); +} + +describe("useActor with the real Svelte rune runtime", () => { + test("connected state does not feed the lifecycle effect back into itself", async () => { + let getOrCreateCalls = 0; + const connection = { + increment: async (amount: number) => amount + 1, + on: () => () => {}, + }; + let hashFunction = (opts: Record) => JSON.stringify(opts); + const state = { + connection, + handle: {}, + connStatus: "connected", + error: null, + hash: "stable", + }; + const harness = { + configure(opts: { + hashFunction?: (value: Record) => string; + }) { + hashFunction = opts.hashFunction ?? hashFunction; + }, + getOrCreateActor(opts: Record) { + getOrCreateCalls += 1; + return { + key: hashFunction({ ...opts, enabled: opts.enabled ?? true }), + mount: () => () => {}, + state: { state, subscribe: () => () => {} }, + }; + }, + }; + Object.assign(globalThis, { [frameworkHarnessKey]: harness }); + + try { + const { createHarness } = await loadRealRunesHarness(); + const instance = createHarness(); + await Promise.resolve(); + flushSync(); + + expect(getOrCreateCalls).toBe(1); + expect(instance.actor.hasEverConnected).toBe(true); + + instance.dispose(); + await Promise.resolve(); + } finally { + delete (globalThis as Record)[frameworkHarnessKey]; + } + }); + + test("same-hash option refresh retains an in-flight connection waiter", async () => { + type Subscriber = (value: { currentVal: typeof state }) => void; + const subscribers = new Set(); + const connection = { + increment: async (amount: number) => amount + 1, + on: () => () => {}, + }; + let state = { + connection, + handle: {}, + connStatus: "connecting", + error: null, + hash: "stable", + }; + let hashFunction = (opts: Record) => JSON.stringify(opts); + const harness = { + configure(opts: { + hashFunction?: (value: Record) => string; + }) { + hashFunction = opts.hashFunction ?? hashFunction; + }, + getOrCreateActor(opts: Record) { + return { + key: hashFunction({ ...opts, enabled: opts.enabled ?? true }), + mount: () => () => {}, + state: { + get state() { + return state; + }, + subscribe(callback: Subscriber) { + subscribers.add(callback); + return () => subscribers.delete(callback); + }, + }, + }; + }, + }; + Object.assign(globalThis, { [frameworkHarnessKey]: harness }); + + try { + const { createHarness } = await loadRealRunesHarness(); + const instance = createHarness(); + await Promise.resolve(); + flushSync(); + + const pending = instance.actor.increment(1); + expect(instance.actor.pendingActions).toBe(1); + instance.setToken("token-2"); + flushSync(); + expect(instance.actor.pendingActions).toBe(1); + + state = { ...state, connStatus: "connected" }; + for (const subscriber of subscribers) { + subscriber({ currentVal: state }); + } + await expect(pending).resolves.toBe(2); + expect(instance.actor.pendingActions).toBe(0); + + instance.dispose(); + await Promise.resolve(); + } finally { + delete (globalThis as Record)[frameworkHarnessKey]; + } + }); +}); diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/runes-shim.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/runes-shim.ts index 2159790c17..1ee437fff1 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/__tests__/runes-shim.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/runes-shim.ts @@ -1,12 +1,50 @@ -const effect = ((fn?: () => unknown) => fn?.()) as unknown as { - (fn?: () => unknown): unknown; - root: (fn: () => undefined | (() => void)) => () => void; +type EffectEntry = { + run: () => unknown; + cleanup?: () => void; +}; + +const effects = new Set(); + +function runEffect(entry: EffectEntry): void { + entry.cleanup?.(); + const cleanup = entry.run(); + entry.cleanup = + typeof cleanup === "function" ? (cleanup as () => void) : undefined; +} + +const effect = ((fn?: () => unknown) => { + if (!fn) return; + const entry: EffectEntry = { run: fn }; + effects.add(entry); + runEffect(entry); +}) as unknown as { + (fn?: () => unknown): unknown; + root: (fn: () => void | (() => void)) => () => void; }; effect.root = (fn) => { - const cleanup = fn(); - return typeof cleanup === "function" ? cleanup : () => {}; + const cleanup = fn(); + return typeof cleanup === "function" ? cleanup : () => {}; }; -(globalThis as Record).$state = (value: T) => value; +const state = ((value?: unknown) => value) as unknown as typeof $state; +(state as unknown as { raw: (value: T) => T }).raw = (value: T) => value; + +const derived = { + by: (fn: () => T): T => fn(), +}; + +(globalThis as Record).$state = state; (globalThis as Record).$effect = effect; +(globalThis as Record).$derived = derived; + +/** Re-run registered effects after their cleanup, simulating a dependency change. */ +export function rerunEffects(): void { + for (const entry of effects) runEffect(entry); +} + +/** Dispose and forget effects registered by the previous test. */ +export function resetEffects(): void { + for (const entry of effects) entry.cleanup?.(); + effects.clear(); +} diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/sdk-contract.test.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/sdk-contract.test.ts new file mode 100644 index 0000000000..5dc88c7e5a --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/sdk-contract.test.ts @@ -0,0 +1,108 @@ +import { ActorError, createClient, UserError } from "rivetkit/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + actorErrorCode, + actorErrorMessage, + getActionError, + isActorError, +} from "../errors.js"; + +// Keep the SDK unmocked: these contracts must hold in standalone installs. +afterEach(() => vi.unstubAllGlobals()); + +describe("public RivetKit SDK contract", () => { + test("recognizes real SDK errors and preserves structured details", () => { + const error = new ActorError("user", "RATE_LIMITED", "Try again later", { + public: true, + metadata: { retryAfterMs: 100 }, + }); + + expect(isActorError(error)).toBe(true); + expect(actorErrorCode(error)).toBe("RATE_LIMITED"); + expect(actorErrorMessage(error)).toBe("Try again later"); + expect(error.metadata).toEqual({ retryAfterMs: 100 }); + expect(getActionError({ lastActionError: error })).toEqual({ + message: "Try again later", + code: "RATE_LIMITED", + isActorError: true, + }); + expect(isActorError(new UserError("Invalid input", { code: "INVALID" }))).toBe(true); + }); + + test.each([undefined, "ActorError", "RivetError"])( + "recognizes a serialized SDK error with discriminator %s", + (__type) => { + const error = { + ...(__type === undefined ? {} : { __type }), + group: "user", + code: "INVALID", + message: "Invalid input", + }; + expect(ActorError.isActorError(error)).toBe(true); + expect(isActorError(error)).toBe(true); + expect(actorErrorCode(error)).toBe("INVALID"); + }, + ); + + test.each([null, undefined, new Error("Network failed"), {}, { + __type: "RivetError", group: "user", code: 42, message: "Invalid", + }])("rejects non-actor errors safely: %s", (error) => { + expect(isActorError(error)).toBe(false); + expect(actorErrorCode(error)).toBeUndefined(); + }); + + test("accepts raw action options through the public HTTP transport", async () => { + const requests: Request[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(new Request(input, init)); + return Response.json({ output: 7 }); + }); + const client = createClient({ + endpoint: "http://localhost:6420", + encoding: "json", + disableMetadataLookup: true, + }); + const handle = client.getForId("counter", "counter-1"); + const controller = new AbortController(); + + await expect(handle.action({ + name: "increment", + args: [6], + signal: controller.signal, + })).resolves.toBe(7); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("POST"); + expect(requests[0].url).toContain("/action/increment"); + expect(await requests[0].json()).toEqual({ args: [6] }); + controller.abort(); + expect(requests[0].signal.aborted).toBe(true); + }); + + test("extracts an error decoded by the real SDK from an action response", async () => { + vi.stubGlobal("fetch", async () => Response.json({ + group: "user", + code: "INVALID_INPUT", + message: "A positive number is required", + metadata: { field: "amount" }, + }, { status: 400, headers: { "x-rivet-ray-id": "request-1" } })); + const client = createClient({ + endpoint: "http://localhost:6420", + encoding: "json", + disableMetadataLookup: true, + }); + const error = await client.getForId("counter", "counter-1") + .action({ name: "increment", args: [-1] }) + .catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(ActorError); + expect(getActionError({ lastActionError: error })).toEqual({ + message: "A positive number is required", + code: "INVALID_INPUT", + isActorError: true, + }); + expect(error).toMatchObject({ + metadata: { field: "amount" }, + rayId: "request-1", + }); + }); +}); diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/shared.test.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/shared.test.ts index 505fa43fb5..a4f4aa914f 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/__tests__/shared.test.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/shared.test.ts @@ -1,101 +1,343 @@ import "./runes-shim.js"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import type { AnyActorRegistry } from "../index.js"; +import { ActorError } from "rivetkit/client"; import { - createReactiveConnection, - createSharedRivetKit, - withActorParams, + createReactiveConnection, + createSharedRivetKit, + withActorParams, + getActionError, } from "../index.js"; import { createMockConnection } from "./helpers.js"; describe("shared helpers", () => { - test("createSharedRivetKit reuses a single wrapper", () => { - const client = { id: "client" } as never; - let clientCalls = 0; - - const getRivet = createSharedRivetKit(() => { - clientCalls += 1; - return client; - }); - - const a = getRivet(); - const b = getRivet(); - - expect(a).toBe(b); - expect(clientCalls).toBe(1); - }); - - test("withActorParams merges static and getter params", () => { - let token = "first"; - - const getOpts = withActorParams( - { - name: "chat" as never, - key: ["room-1"], - params: { organizationId: "org-1" }, - }, - () => ({ token }), - ); - - expect(getOpts()).toEqual({ - name: "chat", - key: ["room-1"], - params: { organizationId: "org-1", token: "first" }, - }); - - token = "second"; - - expect(getOpts().params).toEqual({ - organizationId: "org-1", - token: "second", - }); - }); - - test("withActorParams omits params when both inputs are undefined", () => { - const getOpts = withActorParams( - { - name: "chat" as never, - key: ["room-1"], - }, - () => undefined, - ); - - expect(getOpts()).toEqual({ - name: "chat", - key: ["room-1"], - }); - }); - - test("createReactiveConnection reflects status, errors, and events", async () => { - const mock = createMockConnection(); - const reactive = createReactiveConnection({ - connect: () => mock.connection, - }); - - expect(reactive.connStatus).toBe("idle"); - expect(reactive.isConnected).toBe(false); - - reactive.connect(); - mock.setStatus("connected"); - - expect(reactive.connStatus).toBe("connected"); - expect(reactive.isConnected).toBe(true); - - let payload: string | null = null; - const unsubscribe = reactive.onEvent("message", (value) => { - payload = value as string; - }); - - mock.emit("message", "hello"); - expect(payload).toBe("hello"); - - mock.emitError("boom"); - expect(reactive.error?.message).toBe("boom"); - - unsubscribe(); - await reactive.dispose(); - - expect(reactive.connStatus).toBe("disconnected"); - expect(reactive.connection).toBe(null); - }); + test("createSharedRivetKit reuses a single wrapper", () => { + const client = { id: "client" } as never; + let clientCalls = 0; + + const getRivet = createSharedRivetKit(() => { + clientCalls += 1; + return client; + }); + + const a = getRivet(); + const b = getRivet(); + + expect(a).toBe(b); + expect(clientCalls).toBe(1); + }); + + test("withActorParams merges static and getter params", () => { + let token = "first"; + + const getOpts = withActorParams( + { + name: "chat" as never, + key: ["room-1"], + params: { organizationId: "org-1" }, + }, + () => ({ token }), + ); + + expect(getOpts()).toEqual({ + name: "chat", + key: ["room-1"], + params: { organizationId: "org-1", token: "first" }, + }); + + token = "second"; + + expect(getOpts().params).toEqual({ + organizationId: "org-1", + token: "second", + }); + }); + + test("withActorParams omits params when both inputs are undefined", () => { + const getOpts = withActorParams( + { + name: "chat" as never, + key: ["room-1"], + }, + () => undefined, + ); + + expect(getOpts()).toEqual({ + name: "chat", + key: ["room-1"], + }); + }); + + test("createReactiveConnection reflects status, errors, and events", async () => { + const mock = createMockConnection(); + const reactive = createReactiveConnection({ + connect: () => mock.connection, + }); + + expect(reactive.connStatus).toBe("idle"); + expect(reactive.isConnected).toBe(false); + + reactive.connect(); + mock.setStatus("connected"); + + expect(reactive.connStatus).toBe("connected"); + expect(reactive.isConnected).toBe(true); + + let payload: string | null = null; + const unsubscribe = reactive.onEvent("message", (value) => { + payload = value as string; + }); + + mock.emit("message", "hello"); + expect(payload).toBe("hello"); + + mock.emitError("boom"); + expect(reactive.error?.message).toBe("boom"); + + unsubscribe(); + await reactive.dispose(); + + expect(reactive.connStatus).toBe("disconnected"); + expect(reactive.connection).toBe(null); + }); + + test("whenConnected resolves true when status becomes connected", async () => { + const mock = createMockConnection(); + const reactive = createReactiveConnection({ + connect: () => mock.connection, + }); + + reactive.connect(); + + const promise = reactive.whenConnected(5_000); + mock.setStatus("connected"); + + const result = await promise; + expect(result).toBe(true); + }); + + test("connect resolves existing waiters when the source is already connected", async () => { + const mock = createMockConnection(); + mock.setStatus("connected"); + const reactive = createReactiveConnection({ + connect: () => mock.connection, + }); + + const promise = reactive.whenConnected(5_000); + reactive.connect(); + + await expect(promise).resolves.toBe(true); + }); + + test("whenConnected resolves false on timeout", async () => { + vi.useFakeTimers(); + + const mock = createMockConnection(); + const reactive = createReactiveConnection({ + connect: () => mock.connection, + }); + + reactive.connect(); + + const promise = reactive.whenConnected(100); + vi.advanceTimersByTime(150); + + const result = await promise; + expect(result).toBe(false); + + vi.useRealTimers(); + }); + + test("disconnect cancels pending whenConnected with false", async () => { + const mock = createMockConnection(); + const reactive = createReactiveConnection({ + connect: () => mock.connection, + }); + + reactive.connect(); + + const promise = reactive.whenConnected(30_000); + await reactive.disconnect(); + + const result = await promise; + expect(result).toBe(false); + }); + + test("disconnect cancels a waiter registered before connect", async () => { + const reactive = createReactiveConnection({ + connect: () => createMockConnection().connection, + }); + + const pending = reactive.whenConnected(30_000); + await reactive.disconnect(); + + await expect(pending).resolves.toBe(false); + }); + + test("clears connection state even when transport disposal fails", async () => { + const mock = createMockConnection(); + mock.connection.dispose = vi.fn(async () => { + throw new Error("dispose failed"); + }); + const reactive = createReactiveConnection({ + connect: () => mock.connection, + }); + reactive.connect(); + + await expect(reactive.disconnect()).rejects.toThrow("dispose failed"); + expect(reactive.connection).toBeNull(); + expect(reactive.connStatus).toBe("disconnected"); + expect(reactive.error).toBeNull(); + }); + + test("disconnect retains event registrations for reconnect", async () => { + const first = createMockConnection(); + const second = createMockConnection(); + const connect = vi + .fn() + .mockReturnValueOnce(first.connection) + .mockReturnValueOnce(second.connection); + const reactive = createReactiveConnection({ + connect, + }); + const handler = vi.fn(); + + reactive.onEvent("message", handler); + reactive.connect(); + await reactive.disconnect(); + reactive.connect(); + second.emit("message", "after-reconnect"); + + expect(handler).toHaveBeenCalledWith("after-reconnect"); + }); + + test("dispose stays reusable and shares a slow in-flight teardown", async () => { + const first = createMockConnection(); + const second = createMockConnection(); + let finishDispose: (() => void) | undefined; + first.connection.dispose = vi.fn( + () => + new Promise((resolve) => { + finishDispose = resolve; + }), + ); + const connect = vi + .fn() + .mockReturnValueOnce(first.connection) + .mockReturnValueOnce(second.connection); + const reactive = createReactiveConnection({ connect }); + const handler = vi.fn(); + reactive.onEvent("message", handler); + reactive.connect(); + + const disposing = reactive.dispose(); + expect(reactive.dispose()).toBe(disposing); + + expect(reactive.connection).toBeNull(); + expect(reactive.connStatus).toBe("disconnected"); + expect(connect).toHaveBeenCalledTimes(1); + + finishDispose!(); + await disposing; + + reactive.connect(); + second.emit("message", "after-dispose"); + expect(handler).toHaveBeenCalledWith("after-dispose"); + expect(connect).toHaveBeenCalledTimes(2); + }); + + test("a failed disposal still permits reconnect and retained events", async () => { + const first = createMockConnection(); + const second = createMockConnection(); + first.connection.dispose = vi.fn(async () => { + throw new Error("dispose failed"); + }); + const connect = vi + .fn() + .mockReturnValueOnce(first.connection) + .mockReturnValueOnce(second.connection); + const reactive = createReactiveConnection({ + connect, + }); + const handler = vi.fn(); + reactive.onEvent("message", handler); + reactive.connect(); + + await expect(reactive.dispose()).rejects.toThrow("dispose failed"); + + reactive.connect(); + second.emit("message", "after-failure"); + expect(handler).toHaveBeenCalledWith("after-failure"); + expect(connect).toHaveBeenCalledTimes(2); + }); + + test("cleanup failures reject asynchronously and do not skip socket disposal", async () => { + const mock = createMockConnection(); + const closeSocket = vi.fn(async () => {}); + mock.connection.on = vi.fn(() => () => { + throw new Error("unsubscribe failed"); + }); + mock.connection.dispose = closeSocket; + const reactive = createReactiveConnection({ + connect: () => mock.connection, + }); + reactive.onEvent("message", vi.fn()); + reactive.connect(); + + const disposal = reactive.dispose(); + + await expect(disposal).rejects.toThrow("unsubscribe failed"); + expect(closeSocket).toHaveBeenCalledTimes(1); + expect(reactive.connection).toBeNull(); + }); +}); + +describe("getActionError", () => { + test("returns null when no error", () => { + const result = getActionError({ lastActionError: null }); + expect(result).toBe(null); + }); + + test("returns null for undefined error", () => { + const result = getActionError({ lastActionError: undefined }); + expect(result).toBe(null); + }); + + test("extracts message from plain Error", () => { + const result = getActionError({ + lastActionError: new Error("something broke"), + }); + expect(result).not.toBe(null); + expect(result!.message).toBe("something broke"); + expect(result!.code).toBeUndefined(); + expect(result!.isActorError).toBe(false); + }); + + test("extracts code and message from a real Rivet ActorError", () => { + const err = new ActorError("client", "RATE_LIMITED", "rate limited"); + const result = getActionError({ lastActionError: err }); + expect(result).not.toBe(null); + expect(result!.message).toBe("rate limited"); + expect(result!.code).toBe("RATE_LIMITED"); + expect(result!.isActorError).toBe(true); + }); + + test("recognizes serialized modern and legacy actor errors", () => { + for (const __type of ["RivetError", "ActorError"] as const) { + const result = getActionError({ + lastActionError: { + __type, + group: "user", + code: "FORBIDDEN", + message: "not allowed", + }, + }); + + expect(result).toEqual({ + message: "not allowed", + code: "FORBIDDEN", + isActorError: true, + }); + } + }); }); diff --git a/rivetkit-typescript/packages/svelte/src/lib/__tests__/warm-up.test.ts b/rivetkit-typescript/packages/svelte/src/lib/__tests__/warm-up.test.ts new file mode 100644 index 0000000000..4444c88bf9 --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/lib/__tests__/warm-up.test.ts @@ -0,0 +1,187 @@ +import "./runes-shim.js"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("../internal/framework-base.js", () => ({ + createRivetKit: vi.fn(() => ({ + getOrCreateActor: vi.fn(), + })), +})); + +vi.mock("esm-env", () => ({ + BROWSER: true, + DEV: false, +})); + +import { createRivetKitWithClient } from "../rivetkit.svelte.js"; + +function createClient( + resolveImpl: () => Promise = () => Promise.resolve("actor-id"), +) { + const resolve = vi.fn(resolveImpl); + const handle = { resolve }; + const get = vi.fn(() => handle); + const getOrCreate = vi.fn(() => handle); + const client = { + document: { get, getOrCreate }, + }; + + return { client, get, getOrCreate, resolve }; +} + +describe("warmUp", () => { + test("resolves actor with getOrCreate without opening a connection", () => { + const { client, get, getOrCreate, resolve } = createClient(); + const rivet = createRivetKitWithClient(client as never); + + rivet.warmUp({ name: "document" as never, key: "doc-1" }); + + expect(getOrCreate).toHaveBeenCalledWith(["doc-1"], {}); + expect(get).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + test("passes null createWithInput to Rivet", () => { + const { client, getOrCreate } = createClient(); + const rivet = createRivetKitWithClient(client as never); + + rivet.warmUp({ + name: "document" as never, + key: ["doc-1"], + createWithInput: null, + }); + + expect(getOrCreate).toHaveBeenCalledWith(["doc-1"], { + createWithInput: null, + }); + }); + + test("passes createInRegion to getOrCreate", () => { + const { client, getOrCreate } = createClient(); + const rivet = createRivetKitWithClient(client as never); + + rivet.warmUp({ + name: "document" as never, + key: ["doc-1"], + createInRegion: "atl", + }); + + expect(getOrCreate).toHaveBeenCalledWith(["doc-1"], { + createInRegion: "atl", + }); + }); + + test("uses get when noCreate is requested", () => { + const { client, get, getOrCreate, resolve } = createClient(); + const rivet = createRivetKitWithClient(client as never); + + rivet.warmUp({ + name: "document" as never, + key: ["doc-1"], + noCreate: true, + }); + + expect(get).toHaveBeenCalledWith(["doc-1"]); + expect(getOrCreate).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + test("deduplicates concurrent warm-ups by actor identity", () => { + const { client, resolve } = createClient( + () => new Promise(() => undefined), + ); + const rivet = createRivetKitWithClient(client as never); + + rivet.warmUp({ name: "document" as never, key: ["doc-1"] }); + rivet.warmUp({ name: "document" as never, key: ["doc-1"] }); + + expect(resolve).toHaveBeenCalledTimes(1); + }); + + test("allows a later warm-up after the previous resolve completes", async () => { + const { client, resolve } = createClient(); + const rivet = createRivetKitWithClient(client as never); + + rivet.warmUp({ name: "document" as never, key: ["doc-1"] }); + // Let the resolved promise's completion handler clear the in-flight key. + await Promise.resolve(); + rivet.warmUp({ name: "document" as never, key: ["doc-1"] }); + + expect(resolve).toHaveBeenCalledTimes(2); + }); + + test("allows retry after resolve failure", async () => { + let rejectResolve: ((error: Error) => void) | undefined; + const { client, resolve } = createClient( + () => + new Promise((_resolve, reject) => { + rejectResolve = reject; + }), + ); + const rivet = createRivetKitWithClient(client as never); + + rivet.warmUp({ name: "document" as never, key: ["doc-1"] }); + expect(resolve).toHaveBeenCalledTimes(1); + + rejectResolve!(new Error("resolve failed")); + await Promise.resolve(); + rivet.warmUp({ name: "document" as never, key: ["doc-1"] }); + expect(resolve).toHaveBeenCalledTimes(2); + }); + + test("supports cyclic warm-up input without leaking hashing failures", () => { + const { client, resolve } = createClient(); + const rivet = createRivetKitWithClient(client as never); + const circular: { self?: unknown } = {}; + circular.self = circular; + + expect(() => + rivet.warmUp({ + name: "document" as never, + key: ["doc-1"], + createWithInput: circular, + }), + ).not.toThrow(); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + test("supports BigInt warm-up input", () => { + const { client, resolve } = createClient(); + const rivet = createRivetKitWithClient(client as never); + + expect(() => + rivet.warmUp({ + name: "document" as never, + key: ["doc-1"], + createWithInput: { cursor: 1n }, + }), + ).not.toThrow(); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + test("contains synchronous resolve failures and permits retry", () => { + let shouldThrow = true; + const { client, resolve } = createClient(() => { + if (shouldThrow) throw new Error("sync resolve failed"); + return Promise.resolve("actor-id"); + }); + const rivet = createRivetKitWithClient(client as never); + + expect(() => + rivet.warmUp({ name: "document" as never, key: ["doc-1"] }), + ).not.toThrow(); + shouldThrow = false; + rivet.warmUp({ name: "document" as never, key: ["doc-1"] }); + + expect(resolve).toHaveBeenCalledTimes(2); + }); + + test("deprecated preloadActor alias still resolves the actor", () => { + const { client, getOrCreate, resolve } = createClient(); + const rivet = createRivetKitWithClient(client as never); + + rivet.preloadActor({ name: "document" as never, key: "doc-1" }); + + expect(getOrCreate).toHaveBeenCalledWith(["doc-1"], {}); + expect(resolve).toHaveBeenCalledTimes(1); + }); +}); diff --git a/rivetkit-typescript/packages/svelte/src/lib/connection-health.svelte.ts b/rivetkit-typescript/packages/svelte/src/lib/connection-health.svelte.ts index 5b4bebf7be..48983fccfc 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/connection-health.svelte.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/connection-health.svelte.ts @@ -43,10 +43,10 @@ * the `"offline"` state). All other values are treated as unhealthy. */ export interface ConnectionSource { - /** Current connection status string. */ - readonly connStatus: string; - /** Current error, or `null`. */ - readonly error: string | null; + /** Current connection status string. */ + readonly connStatus: string; + /** Current error, or `null`. Accepts both `Error` objects and plain strings. */ + readonly error: Error | string | null; } /** Aggregate health status across all monitored actors. */ @@ -54,26 +54,26 @@ export type HealthStatus = "connected" | "degraded" | "offline" | "connecting"; /** Per-actor health snapshot. */ export interface ActorHealth { - /** Whether this specific actor is connected. */ - readonly connected: boolean; - /** The raw connection status string from the source. */ - readonly status: string; - /** Current error message, or `null`. */ - readonly error: string | null; + /** Whether this specific actor is connected. */ + readonly connected: boolean; + /** The raw connection status string from the source. */ + readonly status: string; + /** Current error message, or `null`. */ + readonly error: Error | string | null; } /** The reactive health object returned by {@link createConnectionHealth}. */ export interface ConnectionHealth { - /** Aggregate status: all connected, some, none, or still connecting. */ - readonly status: HealthStatus; - /** Number of actors currently connected. */ - readonly connected: number; - /** Total number of monitored actors. */ - readonly total: number; - /** Per-actor health breakdown, keyed by the names you provided. */ - readonly actors: Readonly>; - /** Names of actors that are currently disconnected or errored. */ - readonly unhealthy: readonly K[]; + /** Aggregate status: all connected, some, none, or still connecting. */ + readonly status: HealthStatus; + /** Number of actors currently connected. */ + readonly connected: number; + /** Total number of monitored actors. */ + readonly total: number; + /** Per-actor health breakdown, keyed by the names you provided. */ + readonly actors: Readonly>; + /** Names of actors that are currently disconnected or errored. */ + readonly unhealthy: readonly K[]; } // --------------------------------------------------------------------------- @@ -110,77 +110,81 @@ export interface ConnectionHealth { * ``` */ export function createConnectionHealth( - getSources: () => Record, + getSources: () => Record, ): ConnectionHealth { - // $derived.by() is required here because the computation is a multi-statement - // block (loop, conditionals). $derived only accepts a single expression. - // Svelte tracks all reactive reads inside the callback — connStatus and error - // on each source are read here, so any change re-runs this derivation. - const _health = $derived.by(() => { - const sources = getSources(); - // Object.keys() returns string[] — the cast to K[] is safe as long as - // getSources() returns exactly the keys declared in K. - const keys = Object.keys(sources) as K[]; - const total = keys.length; + // $derived.by() is required here because the computation is a multi-statement + // block (loop, conditionals). $derived only accepts a single expression. + // Svelte tracks all reactive reads inside the callback — connStatus and error + // on each source are read here, so any change re-runs this derivation. + const _health = $derived.by(() => { + const sources = getSources(); + // Object.keys() returns string[] — the cast to K[] is safe as long as + // getSources() returns exactly the keys declared in K. + const keys = Object.keys(sources) as K[]; + const total = keys.length; - const actors = {} as Record; - const unhealthy: K[] = []; - let connectedCount = 0; - let connectingCount = 0; + const actors = {} as Record; + const unhealthy: K[] = []; + let connectedCount = 0; + let connectingCount = 0; - for (const key of keys) { - const src = sources[key]; - const isConnected = src.connStatus === "connected"; - const isConnecting = - src.connStatus === "connecting" || - src.connStatus === "reconnecting"; + for (const key of keys) { + const src = sources[key]; + // Snapshot each getter once so expensive ViewModel accessors are not + // repeated and every per-actor row represents one coherent read. + const sourceStatus = src.connStatus; + const sourceError = src.error; + const isConnected = sourceStatus === "connected"; + const isConnecting = + sourceStatus === "connecting" || sourceStatus === "reconnecting"; - actors[key] = { - connected: isConnected, - status: src.connStatus, - error: src.error, - }; + actors[key] = { + connected: isConnected, + status: sourceStatus, + error: sourceError, + }; - if (isConnected) { - connectedCount++; - } else { - unhealthy.push(key); - if (isConnecting) connectingCount++; - } - } + if (isConnected) { + connectedCount++; + } else { + unhealthy.push(key); + if (isConnecting) connectingCount++; + } + } - let status: HealthStatus; - if (connectedCount === total && total > 0) { - status = "connected"; - } else if (connectedCount > 0) { - status = "degraded"; - } else if (connectingCount > 0 || total === 0) { - // No actors connected yet but some are in-progress, or no sources - // registered at all (treat empty registry as "not ready yet"). - status = "connecting"; - } else { - status = "offline"; - } + let status: HealthStatus; + if (connectedCount === total && total > 0) { + status = "connected"; + } else if (connectedCount > 0) { + status = "degraded"; + } else if (connectingCount > 0 || total === 0) { + // No actors connected yet but some are in-progress, or no sources + // registered at all (treat empty registry as "not ready yet"). + status = "connecting"; + } else { + status = "offline"; + } - return { status, connected: connectedCount, total, actors, unhealthy }; - }); + return { status, connected: connectedCount, total, actors, unhealthy }; + }); - // Return an object with getters so destructuring preserves reactivity. - return { - get status() { - return _health.status; - }, - get connected() { - return _health.connected; - }, - get total() { - return _health.total; - }, - get actors() { - return _health.actors; - }, - get unhealthy() { - return _health.unhealthy; - }, - }; + // Return property getters so direct property reads stay reactive. As with + // other Svelte reactive objects, destructuring snapshots the current values. + return { + get status() { + return _health.status; + }, + get connected() { + return _health.connected; + }, + get total() { + return _health.total; + }, + get actors() { + return _health.actors; + }, + get unhealthy() { + return _health.unhealthy; + }, + }; } diff --git a/rivetkit-typescript/packages/svelte/src/lib/connection-inspector.svelte.ts b/rivetkit-typescript/packages/svelte/src/lib/connection-inspector.svelte.ts new file mode 100644 index 0000000000..fb55ea1e79 --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/lib/connection-inspector.svelte.ts @@ -0,0 +1,234 @@ +/** + * Opt-in registry of package-managed Rivet actor connections. + * + * Distinct sockets are grouped internally by framework identity. Snapshots + * expose opaque identifiers because framework hashes may contain credentials. + * Multiple consumers of one identity share a row until its last owner leaves. + * + * Disabled by default — create a registry only when + * `SvelteRivetKitOptions.connectionInspector` is true. + * + * @module + */ + +import { createSubscriber } from "svelte/reactivity"; +import type { ActorConnStatus } from "rivetkit/client"; + +/** Public snapshot fields. Anything else on a report is discarded. */ +export const CONNECTION_INSPECTOR_SAMPLE_KEYS = [ + "name", + "key", + "hash", + "connStatus", + "hasConnection", +] as const; + +/** One distinct package-managed actor socket. */ +export interface ConnectionInspectorSample { + /** Registry actor name (`counter`, `chat`, …). */ + name: string; + /** Normalized compound key identifying the actor instance. */ + key: string[]; + /** Opaque inspector identifier, stable while this socket has owners. */ + hash: string; + /** Last observed connection status. */ + connStatus: ActorConnStatus; + /** Whether a connection object is currently bound (not necessarily live). */ + hasConnection: boolean; +} + +/** + * Per-handle report written from `applyState`. + * + * Extra properties (params, tokens, payloads) are ignored — only the + * documented fields are copied into the registry. + */ +export interface ConnectionInspectorReport { + /** Stable id for this `useActor` / `createReactiveActor` handle. */ + ownerId: string; + name: string; + key: string | string[]; + hash: string; + connStatus: ActorConnStatus; + hasConnection: boolean; +} + +/** Live connection registry returned on a RivetKit instance when opted in. */ +export interface ConnectionInspector { + /** Always `true` for a created inspector. Absent / `null` when disabled. */ + readonly enabled: boolean; + /** + * Reactive revision. Read inside `$derived` / `$effect` so an overlay + * can re-snapshot without polling. Bumped on report/unregister only. + */ + readonly revision: number; + /** Distinct sockets currently owned by at least one handle. */ + snapshot(): ConnectionInspectorSample[]; + /** Count of distinct sockets whose status is `"connected"`. */ + connectedCount(): number; + /** Upsert this handle's row. Safe to call from the applyState hot path. */ + report(entry: ConnectionInspectorReport): void; + /** Drop this handle. The row stays if another handle still owns the hash. */ + unregister(ownerId: string): void; +} + +/** Normalize a Rivet actor key to a string array without allocating on arrays. */ +export function normalizeActorKey( + key: string | string[] | undefined, +): string[] { + if (Array.isArray(key)) return key.map(String); + if (key == null || key === "") return []; + return [String(key)]; +} + +/** + * Fallback identity when framework-base has not assigned a hash yet. + * Name + key only — never params. + */ +export function fallbackInspectorHash(name: string, key: string[]): string { + return JSON.stringify({ name, key }); +} + +function keysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function copySample( + sample: ConnectionInspectorSample, +): ConnectionInspectorSample { + return { + name: sample.name, + key: sample.key.slice(), + hash: sample.hash, + connStatus: sample.connStatus, + hasConnection: sample.hasConnection, + }; +} + +type OwnerRecord = { hash: string }; +type HashBucket = { + owners: Set; + sample: ConnectionInspectorSample; +}; + +/** + * Create an empty live connection registry. + * + * Production callers should only construct this when the factory option + * is explicitly enabled. The default RivetKit path leaves the inspector + * `null` so applyState stays a no-op. + */ +export function createConnectionInspector(): ConnectionInspector { + const owners = new Map(); + const byHash = new Map(); + let _revision = 0; + let nextSocketId = 0; + // Svelte's documented bridge for externally-mutated state: + // reading `revision` / `snapshot()` inside a `$derived` / `$effect` + // registers that effect, and `publish()` re-runs it. The registry is + // in-memory, so `start` only captures `update` for later publishes. + let publish: (() => void) | undefined; + const subscribe = createSubscriber((update) => { + publish = update; + return () => { + publish = undefined; + }; + }); + + function bump(): void { + // Reports arrive from applyState, which can run inside a Svelte + // effect. The write below contains no reactive reads, so it cannot + // loop that effect; only effects reading revision/snapshot re-run. + _revision += 1; + publish?.(); + } + + function dropOwnerFromHash(hash: string, ownerId: string): void { + const bucket = byHash.get(hash); + if (!bucket) return; + bucket.owners.delete(ownerId); + if (bucket.owners.size === 0) { + byHash.delete(hash); + } + } + + function report(entry: ConnectionInspectorReport): void { + const ownerId = entry.ownerId; + const name = String(entry.name ?? ""); + if (!ownerId || !name) return; + + const key = normalizeActorKey(entry.key); + const hash = entry.hash || fallbackInspectorHash(name, key); + const existingBucket = byHash.get(hash); + // Framework hashes can include params or arbitrary custom-hash input. + // Preserve grouping internally, but never publish those values. + const publicHash = existingBucket?.sample.hash ?? `connection:${++nextSocketId}`; + const next: ConnectionInspectorSample = { + name, + key, + hash: publicHash, + connStatus: entry.connStatus, + hasConnection: Boolean(entry.hasConnection), + }; + + const prev = owners.get(ownerId); + if (prev && prev.hash !== hash) { + dropOwnerFromHash(prev.hash, ownerId); + } + + let bucket = byHash.get(hash); + if (!bucket) { + bucket = { owners: new Set(), sample: next }; + byHash.set(hash, bucket); + } else { + const same = + bucket.sample.name === next.name && + bucket.sample.connStatus === next.connStatus && + bucket.sample.hasConnection === next.hasConnection && + bucket.sample.hash === next.hash && + keysEqual(bucket.sample.key, next.key); + if (same && bucket.owners.has(ownerId)) { + owners.set(ownerId, { hash }); + return; + } + bucket.sample = next; + } + + bucket.owners.add(ownerId); + owners.set(ownerId, { hash }); + bump(); + } + + function unregister(ownerId: string): void { + const prev = owners.get(ownerId); + if (!prev) return; + owners.delete(ownerId); + dropOwnerFromHash(prev.hash, ownerId); + bump(); + } + + return { + enabled: true, + get revision() { + subscribe(); + return _revision; + }, + snapshot() { + subscribe(); + return [...byHash.values()].map((bucket) => copySample(bucket.sample)); + }, + connectedCount() { + let n = 0; + for (const bucket of byHash.values()) { + if (bucket.sample.connStatus === "connected") n++; + } + return n; + }, + report, + unregister, + }; +} diff --git a/rivetkit-typescript/packages/svelte/src/lib/context.ts b/rivetkit-typescript/packages/svelte/src/lib/context.ts index e156cf7f78..a8ad3c1b86 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/context.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/context.ts @@ -7,78 +7,76 @@ * @module */ -import type { - AnyActorRegistry, - CreateRivetKitOptions, -} from "@rivetkit/framework-base"; -import type { Client } from "rivetkit/client"; import { createContext, hasContext, setContext } from "svelte"; +import type { AnyActorRegistry } from "./internal/framework-base.js"; import { - type createClient, - createRivetKit, - createRivetKitWithClient, - type RivetKit, + type RivetKit, + type SvelteRivetKitOptions, + createRivetKit, + createRivetKitWithClient, + createClient, } from "./rivetkit.svelte.js"; +import type { Client } from "rivetkit/client"; export interface RivetContext { - set(rivet: RivetKit): RivetKit; - get(): RivetKit; - has(): boolean; - setup( - clientInput?: Parameters>[0], - opts?: CreateRivetKitOptions, - ): RivetKit; - setupWithClient( - client: Client, - opts?: CreateRivetKitOptions, - ): RivetKit; + set(rivet: RivetKit): RivetKit; + get(): RivetKit; + has(): boolean; + setup( + clientInput?: Parameters>[0], + opts?: SvelteRivetKitOptions, + ): RivetKit; + setupWithClient( + client: Client, + opts?: SvelteRivetKitOptions, + ): RivetKit; } export function createRivetContext( - name = "RivetKit", + name = "RivetKit", ): RivetContext { - const markerKey = Symbol(name); - const [unsafeGetContext, unsafeSetContext] = - createContext>(); + const markerKey = Symbol(name); + const [unsafeGetContext, unsafeSetContext] = + createContext>(); - function has(): boolean { - return hasContext(markerKey); - } + function has(): boolean { + return hasContext(markerKey); + } - function get(): RivetKit { - if (!has()) { - throw new Error( - `Context "${name}" not found. Create an app-local Rivet context and call ${name}.set(...) or ${name}.setup(...) in a parent layout.`, - ); - } + function get(): RivetKit { + if (!has()) { + throw new Error( + `Context "${name}" not found. Create an app-local Rivet context and call ${name}.set(...) or ${name}.setup(...) in a parent layout.`, + ); + } - return unsafeGetContext(); - } + return unsafeGetContext(); + } - function set(rivet: RivetKit): RivetKit { - setContext(markerKey, true); - return unsafeSetContext(rivet); - } + function set(rivet: RivetKit): RivetKit { + setContext(markerKey, true); + return unsafeSetContext(rivet); + } - function setup( - clientInput?: Parameters>[0], - opts?: CreateRivetKitOptions, - ): RivetKit { - return set(createRivetKit(clientInput, opts)); - } + function setup( + clientInput?: Parameters>[0], + opts?: SvelteRivetKitOptions, + ): RivetKit { + return set(createRivetKit(clientInput, opts)); + } - function setupWithClient( - client: Client, - opts?: CreateRivetKitOptions, - ): RivetKit { - return set(createRivetKitWithClient(client, opts)); - } + function setupWithClient( + client: Client, + opts?: SvelteRivetKitOptions, + ): RivetKit { + return set(createRivetKitWithClient(client, opts)); + } - return { - set, - get, - has, - setup, - setupWithClient, - }; + return { + set, + get, + has, + setup, + setupWithClient, + }; } diff --git a/rivetkit-typescript/packages/svelte/src/lib/errors.ts b/rivetkit-typescript/packages/svelte/src/lib/errors.ts new file mode 100644 index 0000000000..898e41ec37 --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/lib/errors.ts @@ -0,0 +1,118 @@ +/** + * Actor Error Utilities + * + * Helpers for inspecting errors from actor actions. The `@rivetkit/svelte` + * action middleware normalizes all errors to `Error` instances, but at runtime + * errors from `UserError` throws arrive as `ActorError` from `rivetkit/client` + * with `.code`, `.group`, and `.metadata` intact. + * + * These utilities let consumers discriminate `ActorError` from generic `Error` + * without importing `rivetkit/client` directly. + * + * @module + */ + +import { ActorError } from "rivetkit/client"; + +/** Serializable shape recognized by RivetKit's actor-error guard. */ +export interface ActorErrorLike { + /** Optional runtime discriminator; modern RivetKit uses `RivetError`. */ + __type?: "RivetError" | "ActorError"; + /** Error family (`user`, `actor`, `client`, and so on). */ + group: string; + /** Machine-readable failure code. */ + code: string; + /** Human-readable failure message. */ + message: string; + /** Optional structured details safe for the caller. */ + metadata?: unknown; + /** Optional request identifier used to correlate the error with engine logs. */ + rayId?: string; + /** Whether the error is safe to expose outside the actor runtime. */ + public?: boolean; + /** Optional HTTP status override associated with the error. */ + statusCode?: number; + /** Actor generation that was handling work when the error was produced. */ + actor?: { + actorId: string; + generation: number; + key?: string; + }; +} + +/** + * Type guard: is the error an `ActorError` from rivetkit/client? + * + * Delegates to RivetKit's structural guard so modern `RivetError` instances, + * legacy `ActorError` tags, and serialized errors all work across realms. The + * `Error` intersection preserves the package's historical narrowing contract + * for existing callers; use {@link ActorErrorLike} when typing a serialized + * value before it reaches this guard. + */ +export function isActorError(err: unknown): err is Error & ActorErrorLike { + return ActorError.isActorError(err); +} + +/** + * Extract the machine-readable error code from an error, if it's an ActorError. + * Returns `undefined` for non-ActorError instances. + */ +export function actorErrorCode(err: unknown): string | undefined { + if (!err) return undefined; + if (isActorError(err)) return err.code; + return undefined; +} + +/** + * Extract the human-readable error message from an error. + * Works for both `ActorError` (message = UserError's first argument) + * and generic `Error` instances. + * + * Returns `undefined` for null/undefined input. + */ +export function actorErrorMessage(err: unknown): string | undefined { + if (!err) return undefined; + if (typeof err === "object" && "message" in err) { + const message = (err as { message?: unknown }).message; + return typeof message === "string" && message ? message : undefined; + } + return undefined; +} + +/** + * Structured error info extracted from an actor handle's `lastActionError`. + */ +export interface ActionErrorInfo { + /** Human-readable error message. */ + message: string | undefined; + /** Machine-readable error code (only present for `ActorError`). */ + code: string | undefined; + /** Whether this is an `ActorError` (from a `UserError` throw on the server). */ + isActorError: boolean; +} + +/** + * Extract structured error info from any actor handle's `lastActionError`. + * + * Useful for presenting structured action failures in application UI. Returns `null` when there is no error. + * + * @example + * ```typescript + * const error = getActionError(threadHandle); + * if (error) { + * showToast(error.message ?? "Something went wrong"); + * if (error.code === "RATE_LIMITED") retryLater(); + * } + * ``` + */ +export function getActionError(handle: { + lastActionError: unknown; +}): ActionErrorInfo | null { + const err = handle.lastActionError; + if (!err) return null; + return { + message: actorErrorMessage(err), + code: actorErrorCode(err), + isActorError: isActorError(err), + }; +} diff --git a/rivetkit-typescript/packages/svelte/src/lib/index.ts b/rivetkit-typescript/packages/svelte/src/lib/index.ts index 92b2224ede..a20316a5e6 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/index.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/index.ts @@ -1,38 +1,65 @@ // Core API - -// Connection health export { - type ActorHealth, - type ConnectionHealth, - type ConnectionSource, - createConnectionHealth, - type HealthStatus, -} from "./connection-health.svelte.js"; + createRivetKit, + createRivetKitWithClient, + createClient, + type RivetKit, + type ActorState, + type ReactiveActorHandle, + type ActorConnStatus, + type ActorOptions, + type AnyActorRegistry, + type WarmUpActorOptions, + type PreConnectHandle, + // Deprecated: renamed to WarmUpActorOptions. + type PreloadActorOptions, + type ActionDefaults, + type SvelteRivetKitOptions, + type SvelteActorOptions, +} from "./rivetkit.svelte.js"; // Context helpers export { createRivetContext, type RivetContext } from "./context.js"; -export { extract } from "./internal/extract.js"; -// Ecosystem-standard types (runed / melt-ui / bits-ui convention) -export type { Getter, MaybeGetter } from "./internal/types.js"; -export { - type ActionDefaults, - type ActorConnStatus, - type ActorOptions, - type ActorState, - type AnyActorRegistry, - createClient, - createRivetKit, - createRivetKitWithClient, - type PreloadActorOptions, - type ReactiveActorHandle, - type RivetKit, - type SvelteRivetKitOptions, -} from "./rivetkit.svelte.js"; + // Shared client / mixed-mode helpers export { - createReactiveConnection, - createSharedRivetKit, - type ReactiveConnection, - type ReactiveConnectionSource, - withActorParams, + createSharedRivetKit, + withActorParams, + createReactiveConnection, + type ReactiveConnection, + type ReactiveConnectionSource, } from "./shared.svelte.js"; + +// Connection health +export { + createConnectionHealth, + type ConnectionSource, + type ConnectionHealth, + type ActorHealth, + type HealthStatus, +} from "./connection-health.svelte.js"; + +// Opt-in live connection registry (local-dev overlays) +export { + createConnectionInspector, + normalizeActorKey, + fallbackInspectorHash, + CONNECTION_INSPECTOR_SAMPLE_KEYS, + type ConnectionInspector, + type ConnectionInspectorSample, + type ConnectionInspectorReport, +} from "./connection-inspector.svelte.js"; + +// Error utilities +export { + isActorError, + actorErrorCode, + actorErrorMessage, + getActionError, + type ActorErrorLike, + type ActionErrorInfo, +} from "./errors.js"; + +// Ecosystem-standard types (runed / melt-ui / bits-ui convention) +export type { Getter, MaybeGetter } from "./internal/types.js"; +export { extract } from "./internal/extract.js"; diff --git a/rivetkit-typescript/packages/svelte/src/lib/internal/extract.ts b/rivetkit-typescript/packages/svelte/src/lib/internal/extract.ts index 448f6a99a9..bc079690ae 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/internal/extract.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/internal/extract.ts @@ -12,12 +12,8 @@ import type { MaybeGetter } from "./types.js"; export function extract(value: MaybeGetter): T; -export function extract( - value: MaybeGetter, - defaultValue: T, -): T; +export function extract(value: MaybeGetter, defaultValue: T): T; export function extract(value: unknown, defaultValue?: unknown): unknown { - const resolved = - typeof value === "function" ? (value as () => unknown)() : value; + const resolved = typeof value === "function" ? (value as () => unknown)() : value; return resolved === undefined ? defaultValue : resolved; } diff --git a/rivetkit-typescript/packages/svelte/src/lib/internal/framework-base.ts b/rivetkit-typescript/packages/svelte/src/lib/internal/framework-base.ts new file mode 100644 index 0000000000..6be870426b --- /dev/null +++ b/rivetkit-typescript/packages/svelte/src/lib/internal/framework-base.ts @@ -0,0 +1,479 @@ +/** + * Package-local Rivet framework bridge. + * + * Derived from `@rivetkit/framework-base` 2.3.13 (Apache-2.0). Keeping this + * small bridge in the package is intentional: the published framework-base + * currently drops `getParams`, while actor credentials must be resolved again + * for every initial connection and reconnect. A workspace-level package-manager + * patch cannot provide that guarantee to downstream `@rivetkit/svelte` users. + * + * The reactivity core is a minimal imperative observable: one `Map` of actor + * entries plus per-entry listener sets: replacing the upstream + * `@tanstack/store` `Store`/`Derived`/`Effect` trio. Two reasons: + * + * 1. This package is Svelte-only. The adapter already bridges state into + * Svelte runes (`$state` slots fed by `applyState`), so the framework- + * agnostic interop machinery of a portable store bought nothing here. + * 2. Svelte's own reactive collections (`SvelteMap`, `createSubscriber`) + * are deliberately NOT used for this registry: their reads register the + * surrounding Svelte effect as a subscriber and their writes re-run that + * effect body. A framework push must update `$state` slots via listeners + * WITHOUT re-running the `useActor()` effect (which would churn + * mount/unmount lifecycles). Plain, untracked objects are invisible to + * Svelte's dependency tracking: exactly the guarantee the adapter needs. + * + * Observable semantics preserved from the TanStack-based core: + * - `getOrCreateActor` returns `{ key, mount, state }` where `state` exposes + * `.state` (current snapshot) and `.subscribe(cb)` delivering + * `{ currentVal }` payloads: the adapter consumes this shape unchanged. + * - Option updates are deferred via `queueMicrotask` so nothing publishes + * framework state synchronously during a Svelte render. + * - State writes notify listeners synchronously (matching the previous + * synchronous `setState` flush that `reconnect()`'s disable/enable + * microtask ordering depends on). + * - The connect/dispose lifecycle transitions (the previous `Effect` body) + * run inline after each write, gated on `refCount > 0`: the TanStack + * `Effect` was only subscribed between the first mount and the cleanup + * timeout, so unmounted entries never self-connect. + * - First mount connects synchronously when idle + enabled. + * - Final unmount schedules a `setTimeout(0)` cleanup that disposes the + * connection and evicts the entry; a remount before it fires cancels it. + */ + +import equal from "fast-deep-equal"; +import type { AnyActorDefinition, Registry } from "rivetkit"; +import type { + ActorConn, + ActorConnStatus, + ActorHandle, + Client, + ExtractActorsFromRegistry, +} from "rivetkit/client"; + +export type AnyActorRegistry = Registry; + +export type { ActorConnStatus }; + +interface ActorStateReference { + /** Identity hash generated from the actor options. */ + hash: string; + /** Current typed actor handle, when one has been resolved. */ + handle: ActorHandle | null; + /** Current realtime actor connection. */ + connection: ActorConn | null; + /** Current connection lifecycle status. */ + connStatus: ActorConnStatus; + /** Most recent connection error. */ + error: Error | null; + /** Normalized options retained for reconnects. */ + opts: AnyActorOptions; +} + +/** Options for one actor connection managed by the framework bridge. */ +export interface ActorOptions< + Registry extends AnyActorRegistry, + ActorName extends keyof ExtractActorsFromRegistry & string, +> { + /** Typesafe actor name from the registry. */ + name: ActorName; + /** Stable actor key. */ + key: string | string[]; + /** Static connection parameters. */ + params?: ExtractActorsFromRegistry[ActorName]["params"]; + /** + * Resolve parameters immediately before every connect or reconnect. + * Prefer this for expiring credentials. + */ + getParams?: () => Promise; + /** Region used only when creating a missing actor. */ + createInRegion?: string; + /** Input used only when creating a missing actor. */ + createWithInput?: unknown; + /** Whether this actor entry should hold a live connection. */ + enabled?: boolean; + /** Resolve an existing actor without creating it. */ + noCreate?: boolean; +} + +/** + * Public snapshot shape consumed by the Svelte adapter: the previous core's + * `Derived` value. A fresh immutable object is produced on every write. + */ +export type ActorStateSnapshot< + Registry extends AnyActorRegistry, + ActorName extends keyof ExtractActorsFromRegistry & string, +> = Omit & { + handle: ActorHandle[ActorName]> | null; + connection: ActorConn< + ExtractActorsFromRegistry[ActorName] + > | null; + /** @deprecated Use `connStatus === "connected"` instead. */ + isConnected: boolean; +}; + +/** + * Reactive-state handle returned from {@link getOrCreateActor}. Mirrors the + * upstream `Derived` surface the adapter was built against: read the current + * snapshot from `.state`, observe pushes through `.subscribe`. + */ +export interface ActorStateHandle< + Registry extends AnyActorRegistry, + ActorName extends keyof ExtractActorsFromRegistry & string, +> { + /** Current actor snapshot. Fresh object identity after every write. */ + readonly state: ActorStateSnapshot; + /** + * Observe snapshot pushes. The listener receives `{ currentVal }` (upstream + * `Derived.subscribe` payload shape). Notifications are synchronous with + * the state write. + */ + subscribe( + listener: (payload: { + currentVal: ActorStateSnapshot; + }) => void, + ): () => void; +} + +export type AnyActorOptions = ActorOptions; + +/** Framework bridge configuration. */ +export interface CreateRivetKitOptions { + /** Return the cache identity for one normalized actor option set. */ + hashFunction?: (opts: ActorOptions) => string; +} + +type ComputedActorState = ActorStateReference & { + /** @deprecated Use `connStatus === "connected"` instead. */ + isConnected: boolean; +}; + +type StateListener = (payload: { currentVal: ComputedActorState }) => void; + +/** + * Fully-erased handle type for internal use. Applying the public + * `ActorStateHandle` forces evaluation of rivetkit's + * `ExtractActorsFromRegistry` conditional types and trips TS2589: the same + * instantiation-depth limit the Svelte adapter documents. The public generic + * handle stays at the `createRivetKit` boundary via cast. + */ +type ErasedStateHandle = { + readonly state: ComputedActorState; + subscribe(listener: StateListener): () => void; +}; + +/** + * Structural client type for internal use. Rivetkit's `Client` + * conditional types exceed TypeScript's instantiation depth limit when + * erased to `Registry` (TS2589): the same constraint the Svelte + * adapter documents for its own internals. Only the two calls this bridge + * makes are declared; the public `createRivetKit` signature stays fully + * typed. + */ +type BridgeClient = { + get( + name: string, + key: string | string[], + opts: { + params?: unknown; + getParams?: () => Promise; + }, + ): { connect(): ActorConn }; + getOrCreate( + name: string, + key: string | string[], + opts: { + params?: unknown; + getParams?: () => Promise; + createInRegion?: string; + createWithInput?: unknown; + }, + ): { connect(): ActorConn }; +}; + +/** + * One shared actor entry. The Map key is the identity hash; multiple + * consumers of the same hash ref-count a single entry (and socket). + */ +type ActorEntry = { + key: string; + /** Current public snapshot. Replaced (never mutated) on every write. */ + state: ComputedActorState; + /** Adapter-side push listeners (`useActor` / `createReactiveActor`). */ + listeners: Set; + /** Active mounts. The entry (and its socket) is cleaned up at zero. */ + refCount: number; + /** Pending zero-ref eviction timer; cleared by a remount. */ + cleanupTimeout: ReturnType | null; + mount: () => () => void; + stateHandle: ErasedStateHandle; +}; + +/** Create the ref-counted actor registry consumed by the Svelte adapter. */ +export function createRivetKit( + client: Client, + createOpts: CreateRivetKitOptions = {}, +) { + const cache = new Map(); + const bridgeClient = client as unknown as BridgeClient; + + return { + getOrCreateActor: < + ActorName extends keyof ExtractActorsFromRegistry & string, + >( + actorOpts: ActorOptions, + ): { + mount: () => () => void; + state: ActorStateHandle; + key: string; + } => + getOrCreateActor(bridgeClient, createOpts as any, cache, actorOpts as AnyActorOptions) as { + mount: () => () => void; + state: ActorStateHandle; + key: string; + }, + }; +} + +function createStateHandle(entry: ActorEntry): ErasedStateHandle { + return { + get state() { + return entry.state; + }, + subscribe(listener: StateListener): () => void { + entry.listeners.add(listener); + return () => { + entry.listeners.delete(listener); + }; + }, + }; +} + +function getOrCreateActor( + client: BridgeClient, + createOpts: CreateRivetKitOptions, + cache: Map, + actorOpts: AnyActorOptions, +): { mount: () => () => void; state: ErasedStateHandle; key: string } { + const hash = createOpts.hashFunction ?? defaultHashFunction; + const normalizedOpts = { + ...actorOpts, + enabled: actorOpts.enabled ?? true, + } as AnyActorOptions; + const key = hash(normalizedOpts); + const existing = cache.get(key); + + if (!existing) { + const entry: ActorEntry = { + key, + state: { + hash: key, + connStatus: "idle", + connection: null, + handle: null, + error: null, + opts: normalizedOpts, + isConnected: false, + }, + listeners: new Set(), + refCount: 0, + cleanupTimeout: null, + mount: undefined as unknown as () => () => void, + stateHandle: undefined as unknown as ActorStateHandle, + }; + entry.stateHandle = createStateHandle(entry); + entry.mount = createMount(client, cache, key, entry); + cache.set(key, entry); + return { mount: entry.mount, state: entry.stateHandle, key }; + } + + if (!equal(existing.state.opts, normalizedOpts)) { + // Avoid synchronously publishing framework state during a Svelte render. + // The liveness guard skips the write if this entry was evicted (and + // possibly replaced) before the microtask runs. + queueMicrotask(() => { + if (cache.get(key) !== existing) return; + commitState(client, cache, key, existing, { opts: normalizedOpts }); + }); + } + + return { mount: existing.mount, state: existing.stateHandle, key }; +} + +function createMount( + client: BridgeClient, + cache: Map, + key: string, + entry: ActorEntry, +): () => () => void { + return () => { + if (entry.cleanupTimeout !== null) { + clearTimeout(entry.cleanupTimeout); + entry.cleanupTimeout = null; + } + + entry.refCount += 1; + if (entry.refCount === 1) { + const state = entry.state; + if (state.opts.enabled && state.connStatus === "idle") { + // First mount connects synchronously: consumers (and the + // getParams forwarding contract) rely on the client being called + // before mount() returns. + createConnection(client, cache, key, entry); + } + } + + let mounted = true; + return () => { + if (!mounted) return; + mounted = false; + entry.refCount -= 1; + if (entry.refCount !== 0) return; + + entry.cleanupTimeout = setTimeout(() => { + entry.cleanupTimeout = null; + if (entry.refCount > 0) return; + + const connection = entry.state.connection; + if (connection) void connection.dispose(); + cache.delete(key); + }, 0); + }; + }; +} + +/** + * Write a partial state update: replace the snapshot, run the connect/dispose + * lifecycle reconciliation (the previous core's `Effect` body), then notify + * listeners. Reconciliation runs before notification so consumers never + * observe a snapshot the lifecycle has already superseded. + */ +function commitState( + client: BridgeClient, + cache: Map, + key: string, + entry: ActorEntry, + updates: Partial, +): void { + const next: ComputedActorState = { ...entry.state, ...updates }; + next.isConnected = next.connStatus === "connected"; + entry.state = next; + reconcile(client, cache, key, entry); + if (entry.listeners.size === 0) return; + const snapshot = entry.state; + for (const listener of [...entry.listeners]) { + listener({ currentVal: snapshot }); + } +} + +/** + * Connect/dispose lifecycle transitions, run inline after every state write. + * Mirrors the previous core's `Effect`: its subscription was bound to the + * ref count (mounted between first mount and the cleanup timeout), so both + * branches are gated on an active mount. + */ +function reconcile( + client: BridgeClient, + cache: Map, + key: string, + entry: ActorEntry, +): void { + if (entry.refCount <= 0) return; + + const state = entry.state; + if (!state.opts.enabled && state.connection) { + void state.connection.dispose(); + commitState(client, cache, key, entry, { + connection: null, + handle: null, + connStatus: "idle", + }); + return; + } + + if (state.connStatus === "idle" && state.opts.enabled) { + queueMicrotask(() => { + // The entry may have been evicted (or replaced under the same key) + // since this transition was queued: a stale create would leak a + // socket nobody references. + if (cache.get(key) !== entry) return; + const current = entry.state; + if ( + entry.refCount > 0 && + current.connStatus === "idle" && + current.opts.enabled + ) { + createConnection(client, cache, key, entry); + } + }); + } +} + +function createConnection( + client: BridgeClient, + cache: Map, + key: string, + entry: ActorEntry, +): void { + // Evicted mid-flight (zero refs cleaned up while a queued create was + // pending). Nothing references this entry: opening a socket would leak. + if (cache.get(key) !== entry) return; + + const opts = entry.state.opts; + + commitState(client, cache, key, entry, { + connStatus: "connecting", + error: null, + }); + + try { + const handle = opts.noCreate + ? client.get(opts.name, opts.key, { + params: opts.params, + getParams: opts.getParams, + }) + : client.getOrCreate(opts.name, opts.key, { + params: opts.params, + getParams: opts.getParams, + createInRegion: opts.createInRegion, + createWithInput: opts.createWithInput, + }); + const connection = handle.connect(); + + commitState(client, cache, key, entry, { + handle: handle as ActorHandle, + connection: connection as ActorConn, + }); + + connection.onStatusChange((status) => { + // Stale-socket guard: a notification from a connection this entry has + // since replaced (or dropped) must not overwrite newer state. + if (cache.get(key) !== entry || entry.state.connection !== connection) + return; + commitState(client, cache, key, entry, { + connStatus: status, + ...(status === "connected" ? { error: null } : {}), + }); + }); + + connection.onError((error) => { + if (cache.get(key) !== entry || entry.state.connection !== connection) + return; + commitState(client, cache, key, entry, { error }); + }); + } catch (error) { + console.error("Failed to create actor connection", error); + commitState(client, cache, key, entry, { + connStatus: "disconnected", + error: error instanceof Error ? error : new Error(String(error)), + }); + } +} + +function defaultHashFunction({ + name, + key, + params, + noCreate, +}: AnyActorOptions): string { + return JSON.stringify({ name, key, params, noCreate }); +} diff --git a/rivetkit-typescript/packages/svelte/src/lib/internal/index.ts b/rivetkit-typescript/packages/svelte/src/lib/internal/index.ts index 733153f33b..0ea8a99973 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/internal/index.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/internal/index.ts @@ -1,2 +1,2 @@ -export { extract } from "./extract.js"; export type { Getter, MaybeGetter } from "./types.js"; +export { extract } from "./extract.js"; diff --git a/rivetkit-typescript/packages/svelte/src/lib/rivetkit.svelte.ts b/rivetkit-typescript/packages/svelte/src/lib/rivetkit.svelte.ts index e32f191fef..b3d11289c3 100644 --- a/rivetkit-typescript/packages/svelte/src/lib/rivetkit.svelte.ts +++ b/rivetkit-typescript/packages/svelte/src/lib/rivetkit.svelte.ts @@ -1,66 +1,112 @@ /** * @rivetkit/svelte — Svelte 5 runes integration for RivetKit actors. * - * Thin adapter over `@rivetkit/framework-base` that bridges actor state - * into Svelte 5 reactive primitives (`$state`, `$effect`). + * Bridges Rivet actor state into Svelte 5 reactive primitives (`$state`, + * `$effect`) through the package-local ref-counted framework core. * * @module */ import { - type ActorOptions, - type AnyActorRegistry, - type CreateRivetKitOptions, - createRivetKit as createVanillaRivetKit, -} from "@rivetkit/framework-base"; -import { BROWSER } from "esm-env"; + createRivetKit as createVanillaRivetKit, + type ActorOptions, + type AnyActorRegistry, + type CreateRivetKitOptions, +} from "./internal/framework-base.js"; import { - type ActorConn, - type ActorConnStatus, - type ActorHandle, - type AnyActorDefinition, - type Client, - createClient, - type ExtractActorsFromRegistry, + type Client, + createClient, + type ExtractActorsFromRegistry, + type ActorConn, + type ActorHandle, + type ActorConnStatus, + type AnyActorDefinition, } from "rivetkit/client"; -import { extract } from "./internal/extract.js"; +import { BROWSER, DEV } from "esm-env"; import type { MaybeGetter } from "./internal/types.js"; +import { extract } from "./internal/extract.js"; +import { + createConnectionInspector, + type ConnectionInspector, +} from "./connection-inspector.svelte.js"; -export type { - ActorConnStatus, - ActorOptions, - AnyActorRegistry, -} from "@rivetkit/framework-base"; +export type { ActorConnStatus } from "./internal/framework-base.js"; export { createClient } from "rivetkit/client"; +export type { + ActorOptions, + AnyActorRegistry, +} from "./internal/framework-base.js"; // --------------------------------------------------------------------------- -// Preload types +// Warm-up types // --------------------------------------------------------------------------- /** - * Options for preloading (warming) an actor without establishing a WebSocket - * connection. + * Options for warming an actor without establishing a WebSocket connection. * * Only `name` and `key` are required — these identify the actor instance. * `createWithInput` is optional and only needed if the actor may not exist yet * and requires initialization data. * + * Shared by both {@link RivetKit.warmUp | warmUp} (HTTP resolve, no WS) and + * {@link RivetKit.preConnect | preConnect} (opens a real WS). + * * @typeParam Registry - The actor registry type. * @typeParam ActorName - The specific actor name within the registry. */ -export interface PreloadActorOptions< - Registry extends AnyActorRegistry = AnyActorRegistry, - ActorName extends keyof ExtractActorsFromRegistry & - string = keyof ExtractActorsFromRegistry & string, +export interface WarmUpActorOptions< + Registry extends AnyActorRegistry = AnyActorRegistry, + ActorName extends keyof ExtractActorsFromRegistry & string = + keyof ExtractActorsFromRegistry & string, > { - /** Actor name in the registry. */ - name: ActorName; - /** Compound key identifying the actor instance. */ - key: string | string[]; - /** Optional initialization input (only used if actor doesn't exist yet). */ - createWithInput?: unknown; + /** Actor name in the registry. */ + name: ActorName; + /** Compound key identifying the actor instance. */ + key: string | string[]; + /** If true, resolve only an existing actor and do not create it. */ + noCreate?: boolean; + /** + * Datacenter/region to create the actor in if it doesn't exist yet. + * Existing actors stay in their current region; local dev drivers may ignore it. + */ + createInRegion?: string; + /** Optional initialization input (only used if actor doesn't exist yet). */ + createWithInput?: unknown; } +/** + * @deprecated Renamed to {@link WarmUpActorOptions}. Kept as an alias for + * back-compat; will be removed in a future major. + */ +export type PreloadActorOptions< + Registry extends AnyActorRegistry = AnyActorRegistry, + ActorName extends keyof ExtractActorsFromRegistry & string = + keyof ExtractActorsFromRegistry & string, +> = WarmUpActorOptions; + +/** + * Handle returned by {@link RivetKit.preConnect | preConnect}. The caller owns + * the connection lifecycle — call {@link PreConnectHandle.dispose | dispose} to + * tear down the WebSocket once it is no longer needed (or once a component has + * taken over the connection). + */ +export interface PreConnectHandle { + /** Tear down the pre-opened WebSocket connection. Safe to call more than once. */ + dispose: () => Promise; +} + +// Precomputed, immutable consumer-own knownProps Sets — these contain +// only the props that live on the consumer's `inner` object (mount, +// dispose, onEvent). Reactive state props are switched inline in the +// proxy.get trap, so they don't need to be in the Set. +const USE_ACTOR_OWN_PROPS: ReadonlySet = new Set(["onEvent"]); +const REACTIVE_ACTOR_OWN_PROPS: ReadonlySet = new Set([ + "mount", + "dispose", + "onEvent", + "reconnect", +]); + // --------------------------------------------------------------------------- // Action middleware types // --------------------------------------------------------------------------- @@ -79,56 +125,66 @@ export interface PreloadActorOptions< * at the definition level — always, regardless of component mount state */ export interface ActionDefaults { - /** - * Timeout in milliseconds for action calls. - * - * When an action exceeds this duration, the promise resolves to `undefined` - * (or rejects if `throwOnError` is enabled) and `lastActionError` is set - * to a timeout error. - * - * Default: none (actions run until the actor responds or the connection - * drops — Rivet's server-side `actionTimeout` is the ultimate backstop). - */ - timeout?: number; - - /** - * Controls whether action errors reject the returned promise. - * - * - `false` (default): Errors are captured to `lastActionError` reactive - * state. The promise resolves to `undefined`. This is the "safe" mode — - * no try/catch needed at the call site. - * - `true`: Errors are captured to `lastActionError` AND re-thrown. - * The caller must handle the rejection. - * - `(error, actionName) => boolean`: Called per-error to decide. - * - * Follows TanStack Query's mutation convention where reactive error state - * is the primary error channel in UI frameworks. - */ - throwOnError?: boolean | ((error: Error, actionName: string) => boolean); - - /** - * Guard against calling actions while disconnected. - * - * When `true` (default), actions called while the WebSocket connection is - * not established will immediately fail with a connection error instead of - * queuing or hanging. - */ - guardConnection?: boolean; - - /** Called when any action call starts. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onActionStart?: (actionName: string, args: any[]) => void; - /** Called when an action completes successfully. */ - onActionSuccess?: (actionName: string, data: unknown) => void; - /** Called when an action fails (timeout, network, or actor error). */ - onActionError?: (error: Error, actionName: string) => void; - /** Called after an action completes (success or failure). */ - onActionSettled?: (actionName: string) => void; + /** + * Timeout in milliseconds for action calls. + * + * When an action exceeds this duration, the promise resolves to `undefined` + * (or rejects if `throwOnError` is enabled) and `lastActionError` is set + * to a timeout error. This bounds the local caller only; actor-side work + * already in progress is not cancelled. + * + * Default: none (actions run until the actor responds or the connection + * drops — Rivet's server-side `actionTimeout` is the ultimate backstop). + */ + timeout?: number; + /** Finite positive per-action deadlines; invalid entries fall back to timeout. */ + timeoutByAction?: Readonly>; + + /** + * Controls whether action errors reject the returned promise. + * + * - `false` (default): Errors are captured to `lastActionError` reactive + * state. The promise resolves to `undefined`. This is the "safe" mode — + * no try/catch needed at the call site. + * - `true`: Errors are captured to `lastActionError` AND re-thrown. + * The caller must handle the rejection. + * - `(error, actionName) => boolean`: Called per-error to decide. + * + * Follows TanStack Query's mutation convention where reactive error state + * is the primary error channel in UI frameworks. + */ + throwOnError?: boolean | ((error: Error, actionName: string) => boolean); + + /** + * Guard against calling actions while disconnected. + * + * When `true` (default), a lost connection (`disconnected` / error-like) + * fails immediately. A first-paint handshake (`idle` / `connecting`) waits + * for {@link ReactiveActorHandle.whenConnected} and then dispatches. + * The readiness wait shares the configured action timeout deadline. + */ + guardConnection?: boolean; + + /** + * Called when any action call starts. + * + * Lifecycle callbacks are observational. If one throws, the returned action + * promise rejects with that callback error after internal tracking is cleaned + * up, regardless of `throwOnError`. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onActionStart?: (actionName: string, args: any[]) => void; + /** Called when an action completes successfully. */ + onActionSuccess?: (actionName: string, data: unknown) => void; + /** Called when an action fails (timeout, network, or actor error). */ + onActionError?: (error: Error, actionName: string) => void; + /** Called after an action completes (success or failure). */ + onActionSettled?: (actionName: string) => void; } /** * Internal interceptor function type. Built from {@link ActionDefaults} - * and passed to {@link proxyWithConnection}. + * and applied by the stable recursive action proxy. * * @param actionName - The name of the actor action being called. * @param args - Arguments passed to the action. @@ -137,9 +193,9 @@ export interface ActionDefaults { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any type ActionInterceptor = ( - actionName: string, - args: any[], - call: () => Promise, + actionName: string, + args: any[], + call: (signal?: AbortSignal) => any, ) => Promise; // --------------------------------------------------------------------------- @@ -149,11 +205,15 @@ type ActionInterceptor = ( /** * Proxied actor methods forwarded from the underlying connection at runtime. * - * rivetkit 2.1.10 introduced deeply nested conditional types inside - * `ActorConn` that exceed TypeScript's instantiation depth limit when + * RivetKit's deeply nested conditional types inside `ActorConn` exceed + * TypeScript's instantiation depth limit when * wrapped in `Omit`. This permissive index signature preserves the * "call any actor action on the object" DX while avoiding TS2589. * All reactive state properties above remain fully typed. + * + * **Note:** Actor method calls are untyped at the package level due to + * this TypeScript constraint. Consumers should use typed client interfaces + * from their actor registry for type safety at the call site. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any type ProxiedActorMethods = Record any>; @@ -165,16 +225,16 @@ type ProxiedActorMethods = Record any>; * or template expressions are automatically tracked by Svelte 5. */ interface ActionTrackingState { - /** `true` when any action call is in-flight. */ - readonly isMutating: boolean; - /** Number of concurrent in-flight action calls. */ - readonly pendingActions: number; - /** Most recent action error. Cleared on next successful action or {@link resetActionState}. */ - readonly lastActionError: Error | null; - /** Name of the last action that was called. */ - readonly lastAction: string | null; - /** Clear `lastActionError` and `lastAction` (return to clean state). */ - resetActionState(): void; + /** `true` when any action call is in-flight. */ + readonly isMutating: boolean; + /** Number of concurrent in-flight action calls. */ + readonly pendingActions: number; + /** Most recent action error. Cleared on next successful action or {@link resetActionState}. */ + readonly lastActionError: Error | null; + /** Name of the last action that was called. */ + readonly lastAction: string | null; + /** Clear `lastActionError` and `lastAction` (return to clean state). */ + resetActionState(): void; } /** @@ -190,39 +250,49 @@ interface ActionTrackingState { * @typeParam ActorName - The specific actor name within the registry. */ export type ActorState< - Registry extends AnyActorRegistry = AnyActorRegistry, - _ActorName extends keyof ExtractActorsFromRegistry & - string = keyof ExtractActorsFromRegistry & string, + Registry extends AnyActorRegistry = AnyActorRegistry, + ActorName extends keyof ExtractActorsFromRegistry & string = + keyof ExtractActorsFromRegistry & string, > = { - /** The active WebSocket connection, or `null` when not connected. */ - readonly connection: ActorConn | null; - /** The actor handle used to create the connection. */ - readonly handle: ActorHandle | null; - /** Current connection lifecycle status (`"idle"` | `"connecting"` | `"connected"` | `"reconnecting"` | `"disconnected"`). */ - readonly connStatus: ActorConnStatus; - /** Last connection error, or `null`. */ - readonly error: Error | null; - /** Most recent non-null connection error observed for this actor. */ - readonly lastError: Error | null; - /** `true` when `connStatus === "connected"`. */ - readonly isConnected: boolean; - /** `true` once this actor has connected successfully at least once. */ - readonly hasEverConnected: boolean; - /** Internal hash identifying this actor instance. */ - readonly hash: string; - /** - * Subscribe to a named event broadcast by the actor. - * - * The subscription is automatically cleaned up when the component unmounts. - * Must be called during component initialization (alongside `useActor`). - * - * @param eventName - The event name to listen for. - * @param handler - Callback invoked when the event fires. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onEvent: (eventName: string, handler: (...args: any[]) => void) => void; + /** The active WebSocket connection, or `null` when not connected. */ + readonly connection: ActorConn | null; + /** The actor handle used to create the connection. */ + readonly handle: ActorHandle | null; + /** Current connection lifecycle status (`"idle"` | `"connecting"` | `"connected"` | `"reconnecting"` | `"disconnected"`). */ + readonly connStatus: ActorConnStatus; + /** Last connection error, or `null`. */ + readonly error: Error | null; + /** Most recent non-null connection error observed for this actor. */ + readonly lastError: Error | null; + /** `true` when `connStatus === "connected"`. */ + readonly isConnected: boolean; + /** `true` once this actor has connected successfully at least once. */ + readonly hasEverConnected: boolean; + /** Internal hash identifying this actor instance. */ + readonly hash: string; + /** + * Subscribe to a named event broadcast by the actor. + * + * The subscription is automatically cleaned up when the component unmounts. + * Must be called during component initialization (alongside `useActor`). + * + * @param eventName - The event name to listen for. + * @param handler - Callback invoked when the event fires. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onEvent: (eventName: string, handler: (...args: any[]) => void) => void; + /** + * Returns a promise that resolves to `true` when the actor connects, + * or `false` if the timeout elapses first. + * + * Resolves immediately if already connected. Eliminates the need for + * manual `setInterval` polling loops when waiting for connection readiness. + * + * @param timeout - Maximum time to wait in milliseconds (default: 30000). + */ + whenConnected: (timeout?: number) => Promise; } & ActionTrackingState & - ProxiedActorMethods; + ProxiedActorMethods; /** * Reactive actor handle returned by {@link RivetKit.createReactiveActor | createReactiveActor}. @@ -234,52 +304,78 @@ export type ActorState< * @typeParam ActorName - The specific actor name within the registry. */ export type ReactiveActorHandle< - Registry extends AnyActorRegistry, - _ActorName extends keyof ExtractActorsFromRegistry & string, + Registry extends AnyActorRegistry, + ActorName extends keyof ExtractActorsFromRegistry & string, > = { - /** The active WebSocket connection, or `null` when not connected. */ - readonly connection: ActorConn | null; - /** The actor handle used to create the connection. */ - readonly handle: ActorHandle | null; - /** Current connection lifecycle status. */ - readonly connStatus: ActorConnStatus; - /** Last connection error, or `null`. */ - readonly error: Error | null; - /** Most recent non-null connection error observed for this actor. */ - readonly lastError: Error | null; - /** `true` when `connStatus === "connected"`. */ - readonly isConnected: boolean; - /** `true` once this actor has connected successfully at least once. */ - readonly hasEverConnected: boolean; - /** Internal hash identifying this actor instance. */ - readonly hash: string; - /** - * Start the connection lifecycle. - * - * Framework-base handles ref counting internally — multiple mounts - * to the same actor share one WebSocket. - * - * @returns An unmount function to decrement the ref count. - */ - mount(): () => void; - /** - * Clean up all event subscriptions and the framework-base state subscription. - * Call this when the reactive actor is no longer needed. - */ - dispose(): void; - /** - * Subscribe to an actor broadcast event. - * - * Automatically re-binds when the connection changes (e.g. after reconnect). - * - * @param eventName - The event name to listen for. - * @param handler - Callback invoked when the event fires. - * @returns An unsubscribe function. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onEvent(eventName: string, handler: (...args: any[]) => void): () => void; + /** The active WebSocket connection, or `null` when not connected. */ + readonly connection: ActorConn | null; + /** The actor handle used to create the connection. */ + readonly handle: ActorHandle | null; + /** Current connection lifecycle status. */ + readonly connStatus: ActorConnStatus; + /** Last connection error, or `null`. */ + readonly error: Error | null; + /** Most recent non-null connection error observed for this actor. */ + readonly lastError: Error | null; + /** `true` when `connStatus === "connected"`. */ + readonly isConnected: boolean; + /** `true` once this actor has connected successfully at least once. */ + readonly hasEverConnected: boolean; + /** Internal hash identifying this actor instance. */ + readonly hash: string; + /** + * Start the connection lifecycle. + * + * Framework-base handles ref counting internally — multiple mounts + * to the same actor share one WebSocket. + * + * @returns An unmount function to decrement the ref count. + */ + mount(): () => void; + /** + * Clean up all event subscriptions and the framework-base state subscription. + * Call this when the reactive actor is no longer needed. + */ + dispose(): void; + /** + * Force a brand-new underlying connection, tearing down the current one even + * when it is a half-open "zombie" socket still reporting `connected` (NAT/LB + * idle cull, half-open TCP). Drives framework-base's `enabled` toggle — + * disabling disposes the live connection and resets the actor to `idle`, + * re-enabling re-creates from `idle` — so a stale socket is replaced rather + * than reused (`getParams` re-runs, minting a fresh auth token). + * + * A plain `dispose()` + `mount()` cannot do this: framework-base only creates + * a connection from `idle`, and a zombie still reports `connected`, so the dead + * socket would be reused. No-op when the actor was never mounted. + * + * Event subscriptions registered via {@link onEvent} are automatically + * re-bound onto the fresh connection. + */ + reconnect(): void; + /** + * Subscribe to an actor broadcast event. + * + * Automatically re-binds when the connection changes (e.g. after reconnect). + * + * @param eventName - The event name to listen for. + * @param handler - Callback invoked when the event fires. + * @returns An unsubscribe function. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onEvent(eventName: string, handler: (...args: any[]) => void): () => void; + /** + * Returns a promise that resolves to `true` when the actor connects, + * or `false` if the timeout elapses first. + * + * Resolves immediately if already connected. Eliminates the need for + * manual `setInterval` polling loops when waiting for connection readiness. + * + * @param timeout - Maximum time to wait in milliseconds (default: 30000). + */ + whenConnected(timeout?: number): Promise; } & ActionTrackingState & - ProxiedActorMethods; + ProxiedActorMethods; // --------------------------------------------------------------------------- // Proxy helper — wraps a getter-based inner object so unknown props @@ -292,74 +388,10 @@ export type ReactiveActorHandle< // capture, and reactive loading state tracking without manual wrapping. // --------------------------------------------------------------------------- -function proxyWithConnection( - inner: T, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getConnection: () => ActorConn | null, - interceptAction?: ActionInterceptor, -): T { - const methodCache = new WeakMap>(); - - return new Proxy(inner, { - get(target, prop, receiver) { - if (Reflect.has(target, prop)) { - return Reflect.get(target, prop, receiver); - } - const conn = getConnection(); - if (conn && typeof prop === "string") { - const val = conn[prop as keyof typeof conn]; - if (typeof val === "function") { - let connMethods = methodCache.get(conn); - if (!connMethods) { - connMethods = new Map(); - methodCache.set(conn, connMethods); - } - - const cached = connMethods.get(prop); - if (cached) return cached; - - // When an interceptor is configured, wrap the call through it - // so action middleware (timeout, error capture, loading tracking) - // applies automatically to every proxied action call. - const bound = interceptAction - ? // eslint-disable-next-line @typescript-eslint/no-explicit-any - (...args: any[]) => - interceptAction(prop, args, () => - // biome-ignore lint/complexity/noBannedTypes: val is guaranteed to be a function here since it's proxied from the connection methods - (val as Function).apply(conn, args), - ) - : // eslint-disable-next-line @typescript-eslint/no-explicit-any - (...args: any[]) => - // biome-ignore lint/complexity/noBannedTypes: val is guaranteed to be a function here since it's proxied from the connection methods - (val as Function).apply(conn, args); - connMethods.set(prop, bound); - return bound; - } - - return val; - } - - // When an interceptor is configured and the connection is null, - // return a function that routes through the interceptor so the - // connection guard can fire and capture the error reactively. - // Without this, calling actor.someAction() when disconnected - // would throw TypeError: undefined is not a function. - if (interceptAction && !conn && typeof prop === "string") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (...args: any[]) => - interceptAction(prop, args, () => - Promise.reject( - new Error( - `Action "${prop}" called while disconnected`, - ), - ), - ); - } - - return undefined; - }, - }); -} +// The per-actor proxy is now built inside `createActorCoreState.createProxy` +// so its `get` trap reads `_connection` directly without going through an +// indirection function. This eliminates a function-call frame on every +// `actor.someProp` access. // --------------------------------------------------------------------------- // Action defaults merge helper @@ -372,13 +404,13 @@ function proxyWithConnection( * if clearing is needed in the future). */ function mergeActionDefaults( - clientLevel: ActionDefaults | undefined, - actorLevel: ActionDefaults | undefined, + clientLevel: ActionDefaults | undefined, + actorLevel: ActionDefaults | undefined, ): ActionDefaults | undefined { - if (!clientLevel && !actorLevel) return undefined; - if (!clientLevel) return actorLevel; - if (!actorLevel) return clientLevel; - return { ...clientLevel, ...actorLevel }; + if (!clientLevel && !actorLevel) return undefined; + if (!clientLevel) return actorLevel; + if (!actorLevel) return clientLevel; + return { ...clientLevel, ...actorLevel }; } // --------------------------------------------------------------------------- @@ -396,75 +428,127 @@ function mergeActionDefaults( * @typeParam Registry - The actor registry type. */ export interface RivetKit { - /** - * Connect to an actor and receive reactive state with auto-proxied methods. - * - * Must be called during component initialization (inside `