experimental-a2 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +29 -0
  4. package/dist/actor-client.d.ts +46 -0
  5. package/dist/actor-client.d.ts.map +1 -0
  6. package/dist/actor-client.js +54 -0
  7. package/dist/actor-client.js.map +1 -0
  8. package/dist/actor-react.d.ts +54 -0
  9. package/dist/actor-react.d.ts.map +1 -0
  10. package/dist/actor-react.js +79 -0
  11. package/dist/actor-react.js.map +1 -0
  12. package/dist/actor-shared-BACubf4x.d.ts +136 -0
  13. package/dist/actor-shared-BACubf4x.d.ts.map +1 -0
  14. package/dist/actor-shared-DI7J5upy.js +127 -0
  15. package/dist/actor-shared-DI7J5upy.js.map +1 -0
  16. package/dist/actor.browser.d.ts +1 -0
  17. package/dist/actor.browser.js +13 -0
  18. package/dist/actor.browser.js.map +1 -0
  19. package/dist/actor.d.ts +176 -0
  20. package/dist/actor.d.ts.map +1 -0
  21. package/dist/actor.js +437 -0
  22. package/dist/actor.js.map +1 -0
  23. package/dist/ai-server.d.ts +2 -2
  24. package/dist/ai-server.js +2 -2
  25. package/dist/ai.d.ts +2 -2
  26. package/dist/client.d.ts +1 -1
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +4 -4
  29. package/dist/client.js.map +1 -1
  30. package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
  31. package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
  32. package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
  33. package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
  34. package/dist/index.d.ts +16 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/react.d.ts +1 -1
  38. package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
  39. package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
  40. package/dist/scheduler-qstash.d.ts +2 -2
  41. package/dist/scheduler-qstash.js +2 -2
  42. package/dist/scheduler-vercel.d.ts +2 -2
  43. package/dist/scheduler-vercel.js +1 -1
  44. package/dist/{server-B2XNevQA.js → server-CBET-jSz.js} +6 -6
  45. package/dist/server-CBET-jSz.js.map +1 -0
  46. package/dist/{server-DjPhHnbI.d.ts → server-CKY3_lbw.d.ts} +3 -3
  47. package/dist/{server-DjPhHnbI.d.ts.map → server-CKY3_lbw.d.ts.map} +1 -1
  48. package/dist/server.d.ts +3 -3
  49. package/dist/server.js +1 -1
  50. package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
  51. package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
  52. package/dist/store-memory.d.ts +1 -1
  53. package/dist/store-memory.js +2 -2
  54. package/dist/store-postgres.d.ts +1 -1
  55. package/dist/store-postgres.js +2 -2
  56. package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
  57. package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
  58. package/dist/store-redis-http.d.ts +1 -1
  59. package/dist/store-redis-http.js +2 -2
  60. package/dist/store-redis.d.ts +1 -1
  61. package/dist/store-redis.js +2 -2
  62. package/dist/store-sqlite.d.ts +1 -1
  63. package/dist/store-sqlite.js +2 -2
  64. package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
  65. package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
  66. package/docs/actors/01-introduction.mdx +189 -0
  67. package/docs/actors/02-concurrency.mdx +154 -0
  68. package/docs/actors/03-timers.mdx +120 -0
  69. package/docs/actors/04-routes.mdx +352 -0
  70. package/docs/actors/meta.ts +1 -0
  71. package/docs/concepts/meta.ts +1 -0
  72. package/docs/guides/07-examples.mdx +56 -0
  73. package/docs/guides/meta.ts +1 -0
  74. package/docs/index.mdx +16 -0
  75. package/docs/reference/02-errors.mdx +33 -0
  76. package/docs/reference/meta.ts +1 -0
  77. package/examples/README.md +15 -0
  78. package/examples/playground/AGENTS.md +11 -0
  79. package/examples/playground/DEPLOY.md +106 -0
  80. package/examples/playground/README.md +19 -0
  81. package/examples/playground/activity-feed.test.ts +10 -0
  82. package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
  83. package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
  84. package/examples/playground/app/agent/events/route.ts +4 -0
  85. package/examples/playground/app/agent/model.ts +3 -0
  86. package/examples/playground/app/agent/new-agent-session.tsx +98 -0
  87. package/examples/playground/app/agent/page.tsx +25 -0
  88. package/examples/playground/app/agent/scheduler/route.ts +5 -0
  89. package/examples/playground/app/agent/server.ts +153 -0
  90. package/examples/playground/app/agent/session.ts +13 -0
  91. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
  92. package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
  93. package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
  94. package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
  95. package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
  96. package/examples/playground/app/canvas/events/route.ts +13 -0
  97. package/examples/playground/app/canvas/model.ts +94 -0
  98. package/examples/playground/app/canvas/open-canvas.tsx +40 -0
  99. package/examples/playground/app/canvas/page.tsx +20 -0
  100. package/examples/playground/app/canvas/server.ts +9 -0
  101. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
  102. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
  103. package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
  104. package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
  105. package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
  106. package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
  107. package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
  108. package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
  109. package/examples/playground/app/chat/[chatId]/session.ts +7 -0
  110. package/examples/playground/app/chat/events/route.ts +7 -0
  111. package/examples/playground/app/chat/model.test.ts +155 -0
  112. package/examples/playground/app/chat/model.ts +310 -0
  113. package/examples/playground/app/chat/new-conversation.tsx +16 -0
  114. package/examples/playground/app/chat/page.tsx +25 -0
  115. package/examples/playground/app/chat/scheduler/route.ts +5 -0
  116. package/examples/playground/app/chat/server.ts +184 -0
  117. package/examples/playground/app/components/activity-feed.tsx +54 -0
  118. package/examples/playground/app/components/connection-pill.tsx +29 -0
  119. package/examples/playground/app/counter/counter-client.tsx +72 -0
  120. package/examples/playground/app/counter/events/route.ts +4 -0
  121. package/examples/playground/app/counter/model.test.ts +36 -0
  122. package/examples/playground/app/counter/model.ts +31 -0
  123. package/examples/playground/app/counter/page.tsx +24 -0
  124. package/examples/playground/app/counter/server.ts +9 -0
  125. package/examples/playground/app/counter/session.ts +13 -0
  126. package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
  127. package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
  128. package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
  129. package/examples/playground/app/documents/[documentId]/session.ts +7 -0
  130. package/examples/playground/app/documents/events/route.ts +4 -0
  131. package/examples/playground/app/documents/model.ts +55 -0
  132. package/examples/playground/app/documents/open-document.tsx +40 -0
  133. package/examples/playground/app/documents/page.tsx +22 -0
  134. package/examples/playground/app/documents/server.ts +9 -0
  135. package/examples/playground/app/globals.css +2078 -0
  136. package/examples/playground/app/layout.tsx +44 -0
  137. package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
  138. package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
  139. package/examples/playground/app/orders/[orderId]/session.ts +11 -0
  140. package/examples/playground/app/orders/create/route.ts +30 -0
  141. package/examples/playground/app/orders/events/route.ts +4 -0
  142. package/examples/playground/app/orders/model.ts +79 -0
  143. package/examples/playground/app/orders/new-order-form.tsx +98 -0
  144. package/examples/playground/app/orders/page.tsx +22 -0
  145. package/examples/playground/app/orders/scheduler/route.ts +5 -0
  146. package/examples/playground/app/orders/server.ts +50 -0
  147. package/examples/playground/app/page.tsx +111 -0
  148. package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
  149. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
  150. package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
  151. package/examples/playground/app/recovery/events/route.ts +3 -0
  152. package/examples/playground/app/recovery/model.ts +55 -0
  153. package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
  154. package/examples/playground/app/recovery/page.tsx +22 -0
  155. package/examples/playground/app/recovery/scheduler/route.ts +7 -0
  156. package/examples/playground/app/recovery/server.ts +53 -0
  157. package/examples/playground/app/recovery/start/route.ts +41 -0
  158. package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
  159. package/examples/playground/app/vault/page.tsx +12 -0
  160. package/examples/playground/app/vault/server.ts +9 -0
  161. package/examples/playground/app/vault/vault-client.tsx +124 -0
  162. package/examples/playground/app/vault/vault.test.ts +147 -0
  163. package/examples/playground/app/vault/vault.ts +119 -0
  164. package/examples/playground/css.d.ts +4 -0
  165. package/examples/playground/lib/store.ts +15 -0
  166. package/examples/playground/next-env.d.ts +5 -0
  167. package/examples/playground/next.config.ts +10 -0
  168. package/examples/playground/package.json +46 -0
  169. package/examples/playground/tsconfig.json +37 -0
  170. package/examples/playground/vercel.json +40 -0
  171. package/package.json +11 -2
  172. package/src/actor-client.ts +132 -0
  173. package/src/actor-react.ts +143 -0
  174. package/src/actor-shared.ts +356 -0
  175. package/src/actor.browser.ts +12 -0
  176. package/src/actor.ts +914 -0
  177. package/src/client.ts +9 -1
  178. package/src/errors.ts +15 -0
  179. package/src/index.ts +1 -1
  180. package/src/server.ts +13 -3
  181. package/dist/contract-jIfaR085.d.ts.map +0 -1
  182. package/dist/server-B2XNevQA.js.map +0 -1
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The vault actor — a durable mailbox with memory, and muscle.
3
+ *
4
+ * Defined over a protocol: state and events, as types. Handlers are
5
+ * serial by default (the brain: hold the lane, decide against fresh
6
+ * state, one atomic commit); `{ concurrent: true }` opts a handler out
7
+ * of the lane (the muscle: slow I/O in parallel, results sent home as
8
+ * events). A withdrawal is the classic saga — reserve (serial) →
9
+ * transfer (concurrent) → settle or refund (serial) — each step one
10
+ * atomic commit, every crash landing between named states. The reserve
11
+ * and its transfer trigger commit together: `ctx.send` is buffered
12
+ * into the same atomic operation as the state change, so the saga can
13
+ * never half-start.
14
+ *
15
+ * A factory, deliberately: the store and the transfer latency are
16
+ * injected, so tests build isolated vaults on a memory store with a
17
+ * fast clock (vault.test.ts) while server.ts builds the real one.
18
+ */
19
+ import { NonRetriableError } from 'experimental-a2'
20
+ import { actor, concurrent } from 'experimental-a2/actor'
21
+ import type { A2Store } from 'experimental-a2/server'
22
+
23
+ interface Vault {
24
+ state: {
25
+ balance: number
26
+ deposits: number
27
+ withdrawals: number
28
+ /** In-flight transfers: ref → reserved amount. */
29
+ pending: Record<string, number>
30
+ }
31
+ events: {
32
+ deposit: { amount: number }
33
+ withdraw: { amount: number }
34
+ settle: { ref: string }
35
+ refund: { ref: string }
36
+ transfer: { ref: string; amount: number }
37
+ }
38
+ /** Ephemeral audience state — never in the log, invisible to handlers. */
39
+ presence: { viewing: boolean }
40
+ }
41
+
42
+ /** For client props — type-only imports of this module are erased. */
43
+ export type VaultState = Vault['state']
44
+
45
+ // Types are compile-time claims — the route is public, so wire input
46
+ // gets a first-line runtime guard (bring zod and `.parse` if you
47
+ // prefer schemas; it's userland either way).
48
+ function guardAmount(input: { amount: number }): number {
49
+ const amount = input?.amount
50
+ if (typeof amount !== 'number' || !Number.isInteger(amount)) {
51
+ throw new NonRetriableError('amount must be an integer')
52
+ }
53
+ if (amount <= 0 || amount > 1_000_000) {
54
+ throw new NonRetriableError('amount must be between 1 and 1,000,000')
55
+ }
56
+ return amount
57
+ }
58
+
59
+ export function createVault(options?: {
60
+ store?: A2Store
61
+ /** The fake bank's latency — tests inject a fast one. */
62
+ transferMs?: number
63
+ }) {
64
+ const transferMs = options?.transferMs ?? 2_000
65
+ const store = options?.store
66
+ return actor<Vault>({
67
+ name: 'vault',
68
+ state: { balance: 0, deposits: 0, withdrawals: 0, pending: {} },
69
+ presence: true,
70
+
71
+ handlers: {
72
+ deposit: (ctx, input) => {
73
+ const amount = guardAmount(input)
74
+ ctx.state.balance += amount
75
+ ctx.state.deposits += 1
76
+ },
77
+
78
+ withdraw: (ctx, input) => {
79
+ ctx.state.pending ??= {}
80
+ const amount = guardAmount(input)
81
+ if (ctx.state.balance < amount) {
82
+ throw new NonRetriableError(
83
+ `insufficient funds: the balance is ${ctx.state.balance}`,
84
+ )
85
+ }
86
+ ctx.state.balance -= amount
87
+ ctx.state.pending[ctx.id] = amount
88
+ ctx.send.transfer({ ref: ctx.id, amount })
89
+ },
90
+
91
+ settle: (ctx, input) => {
92
+ ctx.state.pending ??= {}
93
+ if (ctx.state.pending[input.ref] === undefined) return
94
+ delete ctx.state.pending[input.ref]
95
+ ctx.state.withdrawals += 1
96
+ },
97
+
98
+ refund: (ctx, input) => {
99
+ ctx.state.pending ??= {}
100
+ const amount = ctx.state.pending[input.ref]
101
+ if (amount === undefined) return
102
+ delete ctx.state.pending[input.ref]
103
+ ctx.state.balance += amount
104
+ },
105
+
106
+ transfer: concurrent(async (ctx, input) => {
107
+ await new Promise((resolve) => setTimeout(resolve, transferMs))
108
+ // demo bank policy: amounts ending in 9 bounce → compensation
109
+ if (input.amount % 10 === 9) {
110
+ ctx.send.refund({ ref: input.ref })
111
+ return
112
+ }
113
+ ctx.send.settle({ ref: input.ref })
114
+ }),
115
+ },
116
+
117
+ ...(store ? { store } : {}),
118
+ })
119
+ }
@@ -0,0 +1,4 @@
1
+ // Next's shipped types only declare '*.module.css'; plain global
2
+ // stylesheets need this for `tsc --noEmit` to accept the side-effect
3
+ // import in app/layout.tsx.
4
+ declare module '*.css'
@@ -0,0 +1,15 @@
1
+ import 'server-only'
2
+ import { postgres, type PostgresStore } from 'experimental-a2/store-postgres'
3
+ import { redis, type RedisStore } from 'experimental-a2/store-redis'
4
+
5
+ const redisUrl = process.env.REDIS_URL
6
+ const postgresUrl = process.env.DATABASE_URL?.replace(
7
+ /sslmode=(require|prefer|verify-ca)/,
8
+ 'sslmode=verify-full',
9
+ )
10
+
11
+ export const store: RedisStore | PostgresStore | undefined = redisUrl
12
+ ? redis({ url: redisUrl })
13
+ : postgresUrl
14
+ ? postgres({ connectionString: postgresUrl })
15
+ : undefined
@@ -0,0 +1,5 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,10 @@
1
+ // No workarounds needed: experimental-a2/server is a separate server-only entry
2
+ // point, so client bundles never reach a store backend; the sqlite dev
3
+ // default carries its own turbopackIgnore annotation in a2's dist, so
4
+ // output tracing stays scoped without externalizing anything; and
5
+ // Next 16 transpiles this file with the TS7 (tsgo) compiler.
6
+ import type { NextConfig } from 'next'
7
+
8
+ const nextConfig: NextConfig = {}
9
+
10
+ export default nextConfig
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "a2-playground",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "Complete Next.js examples for experimental-a2.",
6
+ "type": "module",
7
+ "packageManager": "pnpm@11.20.0",
8
+ "engines": {
9
+ "node": ">=22.18"
10
+ },
11
+ "scripts": {
12
+ "dev": "next dev",
13
+ "build": "next build",
14
+ "test": "vitest run",
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "dependencies": {
18
+ "@codemirror/state": "^6.7.1",
19
+ "@codemirror/view": "^6.43.8",
20
+ "@vercel/functions": "^3.9.3",
21
+ "@vercel/queue": "^0.2.0",
22
+ "@vercel/sandbox": "^3.0.0",
23
+ "ai": "^7.0.58",
24
+ "codemirror": "^6.0.2",
25
+ "experimental-a2": "0.9.0",
26
+ "ioredis": "^5.9.0",
27
+ "next": "^16.3.0",
28
+ "pg": "^8.16.0",
29
+ "react": "^19.0.0",
30
+ "react-dom": "^19.0.0",
31
+ "server-only": "^0.0.1",
32
+ "ws": "^8.21.3",
33
+ "y-codemirror.next": "^0.3.5",
34
+ "y-protocols": "^1.0.7",
35
+ "yjs": "^13.6.32",
36
+ "zod": "^4.4.3"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^24.0.0",
40
+ "@types/react": "^19.0.0",
41
+ "@types/react-dom": "^19.0.0",
42
+ "@types/ws": "^8.18.1",
43
+ "typescript": "^7.0.2",
44
+ "vitest": "^4.1.10"
45
+ }
46
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "module": "esnext",
6
+ "moduleResolution": "bundler",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "strict": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "noFallthroughCasesInSwitch": true,
12
+ "verbatimModuleSyntax": true,
13
+ "isolatedModules": true,
14
+ "esModuleInterop": true,
15
+ "resolveJsonModule": true,
16
+ "skipLibCheck": true,
17
+ "noEmit": true,
18
+ "allowJs": true,
19
+ "incremental": true,
20
+ "plugins": [
21
+ {
22
+ "name": "next"
23
+ }
24
+ ],
25
+ "paths": {
26
+ "@/*": ["./*"]
27
+ }
28
+ },
29
+ "include": [
30
+ "next-env.d.ts",
31
+ "**/*.ts",
32
+ "**/*.tsx",
33
+ ".next/types/**/*.ts",
34
+ ".next/dev/types/**/*.ts"
35
+ ],
36
+ "exclude": ["node_modules"]
37
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "functions": {
3
+ "app/orders/scheduler/route.ts": {
4
+ "experimentalTriggers": [
5
+ {
6
+ "type": "queue/v2beta",
7
+ "topic": "a2-orders",
8
+ "retryAfterSeconds": 30
9
+ }
10
+ ]
11
+ },
12
+ "app/chat/scheduler/route.ts": {
13
+ "experimentalTriggers": [
14
+ {
15
+ "type": "queue/v2beta",
16
+ "topic": "a2-chat",
17
+ "retryAfterSeconds": 30
18
+ }
19
+ ]
20
+ },
21
+ "app/agent/scheduler/route.ts": {
22
+ "experimentalTriggers": [
23
+ {
24
+ "type": "queue/v2beta",
25
+ "topic": "a2-agent",
26
+ "retryAfterSeconds": 30
27
+ }
28
+ ]
29
+ },
30
+ "app/recovery/scheduler/route.ts": {
31
+ "experimentalTriggers": [
32
+ {
33
+ "type": "queue/v2beta",
34
+ "topic": "a2-recovery",
35
+ "retryAfterSeconds": 30
36
+ }
37
+ ]
38
+ }
39
+ }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Durable sync and reactions for things with a lifecycle: one event log, derived state, and live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,8 +20,11 @@
20
20
  "files": [
21
21
  "dist",
22
22
  "docs",
23
+ "examples",
23
24
  "src",
24
- "CHANGELOG.md"
25
+ "AGENTS.md",
26
+ "CHANGELOG.md",
27
+ "README.md"
25
28
  ],
26
29
  "exports": {
27
30
  ".": "./dist/index.js",
@@ -36,6 +39,12 @@
36
39
  "browser": "./dist/ai-server.browser.js",
37
40
  "default": "./dist/ai-server.js"
38
41
  },
42
+ "./actor": {
43
+ "browser": "./dist/actor.browser.js",
44
+ "default": "./dist/actor.js"
45
+ },
46
+ "./actor/client": "./dist/actor-client.js",
47
+ "./actor/react": "./dist/actor-react.js",
39
48
  "./store-memory": "./dist/store-memory.js",
40
49
  "./store-sqlite": "./dist/store-sqlite.js",
41
50
  "./store-postgres": "./dist/store-postgres.js",
@@ -0,0 +1,132 @@
1
+ /**
2
+ * experimental-a2/actor/client — typed calls to an actor over HTTP.
3
+ * Framework-agnostic: no React, no framework machinery; works in any
4
+ * browser code and server-to-server.
5
+ *
6
+ * `client.call` mirrors the server handle's `call` record over the
7
+ * call lane `handle.fetch` serves — POST `{ event, input, messageId? }`
8
+ * → the answer (post-state), a 409 refusal (revived as
9
+ * `ActorRefusedError`), or a 400. Both ends of that wire are library
10
+ * code: the types here are backed by `handle.fetch`, not by a
11
+ * hand-written convention.
12
+ *
13
+ * ```ts
14
+ * import type { vault } from '@/app/vault/server' // type-only, erased
15
+ * const client = createActorClient<typeof vault>({ api: `/vault/${id}` })
16
+ * await client.call.withdraw({ amount: 60 }) // ActorRefusedError on refusal
17
+ * ```
18
+ */
19
+
20
+ import { ActorRefusedError } from './actor-shared.ts'
21
+ import type { ActorCallOptions, ActorCallResult } from './actor.ts'
22
+
23
+ export { ActorRefusedError }
24
+
25
+ /** The loose shape of an `actor()` definition — carried type-only. */
26
+ export type AnyActorDefinition = {
27
+ actor(id: string): {
28
+ state(): Promise<{ state: unknown; index: number }>
29
+ call: object
30
+ }
31
+ }
32
+
33
+ type HandleOf<D extends AnyActorDefinition> = ReturnType<D['actor']>
34
+
35
+ export type ActorStateOf<D extends AnyActorDefinition> = Awaited<
36
+ ReturnType<HandleOf<D>['state']>
37
+ >['state']
38
+
39
+ /**
40
+ * The server handle's `call` record mirrored to the client, with
41
+ * answers carrying `View` — the projected shape when the route mounts
42
+ * `fetch(req, { view })`, the full state otherwise (the default).
43
+ */
44
+ export type ActorClientCall<
45
+ D extends AnyActorDefinition,
46
+ View = ActorStateOf<D>,
47
+ > = {
48
+ readonly [K in keyof HandleOf<D>['call']]: HandleOf<D>['call'][K] extends (
49
+ ...args: infer P
50
+ ) => Promise<{ state: unknown; index: number }>
51
+ ? (...args: P) => Promise<{ state: View; index: number }>
52
+ : never
53
+ }
54
+
55
+ export type ActorClient<
56
+ D extends AnyActorDefinition,
57
+ View = ActorStateOf<D>,
58
+ > = {
59
+ /** Typed calls over POST — the response is the answer. */
60
+ readonly call: ActorClientCall<D, View>
61
+ }
62
+
63
+ export type CreateActorClientOptions = {
64
+ /** The route whose handler delegates to `handle.fetch` for this instance. */
65
+ api: string
66
+ /** Injectable transport (tests, custom auth headers). */
67
+ fetch?: typeof globalThis.fetch
68
+ }
69
+
70
+ const CALL_TIMEOUT_MS = 30_000
71
+
72
+ const callOverWire = async (
73
+ fetchImpl: typeof globalThis.fetch,
74
+ api: string,
75
+ event: string,
76
+ input: unknown,
77
+ options?: ActorCallOptions,
78
+ ): Promise<ActorCallResult<unknown>> => {
79
+ const response = await fetchImpl(api, {
80
+ method: 'POST',
81
+ headers: { 'content-type': 'application/json' },
82
+ body: JSON.stringify({
83
+ event,
84
+ ...(input === undefined ? {} : { input }),
85
+ ...(options?.id === undefined ? {} : { messageId: options.id }),
86
+ }),
87
+ signal: AbortSignal.timeout(options?.timeoutMs ?? CALL_TIMEOUT_MS),
88
+ })
89
+ const body = (await response.json().catch(() => ({}))) as Record<
90
+ string,
91
+ unknown
92
+ >
93
+ if (response.status === 409 && typeof body['error'] === 'string') {
94
+ throw new ActorRefusedError(
95
+ typeof body['event'] === 'string' ? body['event'] : event,
96
+ typeof body['messageId'] === 'string' ? body['messageId'] : '',
97
+ body['error'],
98
+ )
99
+ }
100
+ if (!response.ok) {
101
+ const message =
102
+ typeof body['error'] === 'string'
103
+ ? body['error']
104
+ : `actor call failed (${response.status})`
105
+ throw new Error(message)
106
+ }
107
+ return body as ActorCallResult<unknown>
108
+ }
109
+
110
+ /**
111
+ * A typed call surface for one actor instance's route. When the route
112
+ * mounts a `view`, pass its shape as the second type argument so call
113
+ * answers carry the projected state:
114
+ * `createActorClient<typeof vault, PublicVault>({ api })`.
115
+ */
116
+ export function createActorClient<
117
+ D extends AnyActorDefinition,
118
+ View = ActorStateOf<D>,
119
+ >(options: CreateActorClientOptions): ActorClient<D, View> {
120
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis)
121
+ const call = new Proxy(
122
+ {},
123
+ {
124
+ get: (_target, event) =>
125
+ typeof event === 'string'
126
+ ? (input?: unknown, callOptions?: ActorCallOptions) =>
127
+ callOverWire(fetchImpl, options.api, event, input, callOptions)
128
+ : undefined,
129
+ },
130
+ ) as ActorClientCall<D, View>
131
+ return { call }
132
+ }
@@ -0,0 +1,143 @@
1
+ 'use client'
2
+ /**
3
+ * experimental-a2/actor/react — use an actor from React: live state
4
+ * plus typed calls, one hook. A thin wrapper: the state pump follows
5
+ * the SSE that `handle.fetch` serves (frontier resume, reconnection,
6
+ * heartbeat watchdog — both ends library code), and `call` is
7
+ * `createActorClient`'s typed surface (the framework-agnostic
8
+ * primitive for everything that isn't React).
9
+ *
10
+ * ```tsx
11
+ * // server component
12
+ * const initial = await vault.actor(id).state()
13
+ * // client component — typed by the server module, erased at build
14
+ * const { state, call, connection } = useActor<typeof vault>({
15
+ * api: `/vault/${id}`, // your route: auth, then handle.fetch
16
+ * id,
17
+ * initial,
18
+ * })
19
+ * await call.withdraw({ amount: 60 }) // ActorRefusedError on refusal
20
+ * ```
21
+ *
22
+ * The fold is library code (last write wins over state commits), so no
23
+ * reducers, schemas, or contracts appear in browser code. Live, not
24
+ * optimistic: handlers are server code deciding against serialized
25
+ * fresh state, so a local guess would be wrong exactly when the actor
26
+ * matters. On a view-projected mount, pass the projected shape as the
27
+ * second type argument — `useActor<typeof vault, PublicVault>` — and
28
+ * `initial`, `state`, and call answers all carry it.
29
+ */
30
+
31
+ import { ACTOR_STATE_EVENT, openPresenceDefs } from './actor-shared.ts'
32
+ import type { ActorPresenceMap, ActorPresenceValues } from './actor-shared.ts'
33
+ import {
34
+ createActorClient,
35
+ type ActorClientCall,
36
+ type ActorStateOf,
37
+ type AnyActorDefinition,
38
+ } from './actor-client.ts'
39
+ import { createClient } from './client.ts'
40
+ import type { A2Client, Connection } from './client.ts'
41
+ import type { ContractEvent, EventDefs, PresenceDefs } from './contract.ts'
42
+ import type { Reducer } from './reducer.ts'
43
+ import { useSession } from './react.ts'
44
+
45
+ /** The server-to-client handoff: `await def.actor(id).state()`. */
46
+ export type ActorSnapshot<S> = { state: S; index: number }
47
+
48
+ export type UseActorOptions<View> = {
49
+ /** The route whose handler delegates to `handle.fetch` for this instance. */
50
+ api: string
51
+ /** The instance id. */
52
+ id: string
53
+ /** The server-rendered fold — the stream resumes after `index`. */
54
+ initial: ActorSnapshot<View>
55
+ /** This client's presence identity — required to call `setPresence`. */
56
+ participant?: string
57
+ }
58
+
59
+ /** The definition's presence vocabulary, `never` when undeclared. */
60
+ type PresenceOf<D extends AnyActorDefinition> = D extends {
61
+ protocol?: infer P
62
+ }
63
+ ? NonNullable<P> extends { presence: infer Values extends object }
64
+ ? Values
65
+ : never
66
+ : never
67
+
68
+ export type UseActorResult<D extends AnyActorDefinition, View> = {
69
+ /** The actor's state, live: every commit streams in and replaces it. */
70
+ state: View
71
+ /** The stream frontier. */
72
+ index: number
73
+ connection: Connection
74
+ /** Typed calls over POST — the response is the answer. */
75
+ call: ActorClientCall<D, View>
76
+ /** The state-commit feed this browser has observed. */
77
+ events: ContractEvent<EventDefs>[]
78
+ /**
79
+ * The replicated presence map: participant → field → latest value.
80
+ * Peer-authored — render values like user input. Empty (and
81
+ * `setPresence` untyped-callable-with-nothing) when the protocol
82
+ * declares no presence.
83
+ */
84
+ presence: ActorPresenceMap<PresenceOf<D>>
85
+ /**
86
+ * Announce this client's presence fields (throttled, resent on
87
+ * reconnect; `null` clears a field). Requires `participant`.
88
+ */
89
+ setPresence: (values: ActorPresenceValues<PresenceOf<D>>) => void
90
+ }
91
+
92
+ /**
93
+ * The follower fold, untyped on purpose: the state type comes from the
94
+ * caller's `initial` handoff, and the fold never inspects state.
95
+ * `initialState` is never rendered — `initial` is required, so every
96
+ * session hydrates before it connects.
97
+ */
98
+ const followerReducer: Reducer<EventDefs, unknown, PresenceDefs> = {
99
+ name: 'a2.actor.follower.v1',
100
+ events: {},
101
+ // The open vocabulary ('*' catch-all): the browser cannot know a
102
+ // server-defined field list, so the server stays the authority and
103
+ // the protocol types honest callers at compile time.
104
+ presence: openPresenceDefs,
105
+ fold: (state, event) =>
106
+ event.type === ACTOR_STATE_EVENT
107
+ ? (event.payload as { state: unknown }).state
108
+ : state,
109
+ initialState: undefined,
110
+ stateSchema: undefined,
111
+ }
112
+
113
+ const clients = new Map<string, A2Client<EventDefs, unknown, PresenceDefs>>()
114
+
115
+ /** Use one actor instance: live state plus typed calls. No provider. */
116
+ export function useActor<D extends AnyActorDefinition, View = ActorStateOf<D>>(
117
+ options: UseActorOptions<View>,
118
+ ): UseActorResult<D, View> {
119
+ const { api } = options
120
+ let client = clients.get(api)
121
+ if (!client) {
122
+ client = createClient({ reducer: followerReducer, api })
123
+ clients.set(api, client)
124
+ }
125
+ const session = useSession(client, options.id, {
126
+ hydrate: options.initial,
127
+ ...(options.participant === undefined
128
+ ? {}
129
+ : { participant: options.participant }),
130
+ })
131
+ const { call } = createActorClient<D, View>({ api })
132
+ return {
133
+ state: session.state as View,
134
+ index: session.index,
135
+ connection: session.connection,
136
+ call,
137
+ events: session.events,
138
+ presence: session.presence as ActorPresenceMap<PresenceOf<D>>,
139
+ setPresence: session.setPresence as (
140
+ values: ActorPresenceValues<PresenceOf<D>>,
141
+ ) => void,
142
+ }
143
+ }