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
package/src/actor.ts ADDED
@@ -0,0 +1,914 @@
1
+ /**
2
+ * experimental-a2/actor — durable actors over the log. Server-only
3
+ * (handlers are server code); the browser condition resolves to a
4
+ * throwing stub, and the browser surfaces are experimental-a2/actor/client
5
+ * and experimental-a2/actor/react.
6
+ *
7
+ * An actor is defined over a protocol — its state shape and event
8
+ * vocabulary, as types — and one complete `handlers` bag:
9
+ *
10
+ * ```ts
11
+ * interface Counter {
12
+ * state: { count: number }
13
+ * events: { increment: object; add: { by: number } }
14
+ * }
15
+ * const counter = actor<Counter>({
16
+ * name: 'counter',
17
+ * state: { count: 0 },
18
+ * handlers: {
19
+ * increment: (ctx) => {
20
+ * ctx.state.count++
21
+ * },
22
+ * add: (ctx, input) => {
23
+ * ctx.state.count += input.by
24
+ * },
25
+ * },
26
+ * })
27
+ * ```
28
+ *
29
+ * Handlers are serial by default: one at a time per instance (a lane —
30
+ * the state's write lock), `ctx.state` a mutable draft committed
31
+ * atomically with completion. A handler holding slow I/O opts out of
32
+ * the lane with `{ concurrent: true, handle }`: it runs in parallel,
33
+ * reads state as a snapshot, and mutates by sending events whose
34
+ * serial handlers decide against fresh state. `ctx.send.eventName(...)`
35
+ * is typed by the protocol and buffered — committed atomically with
36
+ * the handler's completion, so a reserve and its trigger can never
37
+ * half-happen.
38
+ *
39
+ * A thrown `NonRetriableError` is an answer (settled, delivered to a
40
+ * waiting caller); any other error retries like every A2 handler
41
+ * failure; a crash re-runs against the same pre-state. Everything
42
+ * compiles to contract + reducer + laned handlers + returned events —
43
+ * no new store operations, no new wire.
44
+ */
45
+
46
+ // oxlint-disable no-await-in-loop -- a call awaits its answer: drain, read
47
+ // the tail, repeat until the message settles. Sequential by nature.
48
+
49
+ import {
50
+ ACTOR_FAILED_EVENT,
51
+ ACTOR_LANE,
52
+ ACTOR_STATE_EVENT,
53
+ ActorRefusedError,
54
+ actorFailedSchema,
55
+ actorReducer,
56
+ actorStateSchema,
57
+ isJsonTree,
58
+ openPresenceDefs,
59
+ } from './actor-shared.ts'
60
+ import type {
61
+ ActorClientEventDefs,
62
+ ActorConcurrentContext,
63
+ ActorContext,
64
+ ActorFailedPayload,
65
+ ActorPresenceOf,
66
+ ActorProtocol,
67
+ ActorScheduleOptions,
68
+ ActorScheduleSend,
69
+ ActorSend,
70
+ ActorStatePayload,
71
+ } from './actor-shared.ts'
72
+ import { parsePresenceSibling } from './push-envelope.ts'
73
+ import type { ParsedPushPresence } from './push-envelope.ts'
74
+ import { contract as createContract } from './contract.ts'
75
+ import type { PresencePatch, PresenceSnapshot } from './contract.ts'
76
+ import type { Contract, EventDefs } from './contract.ts'
77
+ import type { Reducer } from './reducer.ts'
78
+ import { A2Error, NonRetriableError } from './errors.ts'
79
+ import { createServer } from './server.ts'
80
+ import { setServerFetchHooks } from './server-fetch.ts'
81
+ import { sseResponse } from './sse.ts'
82
+ import type { A2Scheduler, A2Server, HandlerEntry, Session } from './server.ts'
83
+ import type { A2Store } from './store.ts'
84
+ import type { StandardSchemaV1 } from './standard-schema.ts'
85
+ import type { A2Telemetry } from './telemetry.ts'
86
+
87
+ export type {
88
+ ActorConcurrentContext,
89
+ ActorContext,
90
+ ActorPresenceMap,
91
+ ActorPresenceOf,
92
+ ActorPresenceValues,
93
+ ActorProtocol,
94
+ ActorScheduleOptions,
95
+ ActorScheduleSend,
96
+ ActorSend,
97
+ } from './actor-shared.ts'
98
+ export { ActorRefusedError } from './actor-shared.ts'
99
+
100
+ /**
101
+ * One event's handler: ordinary server code. Serial by default — read
102
+ * `ctx.state`, perform I/O, mutate `ctx.state`, one atomic commit at
103
+ * return. `{ concurrent: true, handle }` opts the handler out of the
104
+ * lane for slow I/O: state becomes a snapshot read and mutations
105
+ * happen by sending events. The input is typed by the protocol; the
106
+ * wire hands handlers parsed JSON, so a wire-exposed event guards its
107
+ * input in its first line (`schema.parse(input)` if you like schemas —
108
+ * userland either way).
109
+ */
110
+ export type ActorHandler<D extends ActorProtocol, K extends keyof D['events']> =
111
+ | ((ctx: ActorContext<D>, input: D['events'][K]) => void | Promise<void>)
112
+ | {
113
+ concurrent: true
114
+ handle: (
115
+ ctx: ActorConcurrentContext<D>,
116
+ input: D['events'][K],
117
+ ) => void | Promise<void>
118
+ }
119
+
120
+ /**
121
+ * Mark a handler concurrent — off the lane, in parallel with the lane
122
+ * and with other concurrent handlers, `ctx.state()` a snapshot read.
123
+ * Sugar for the structural `{ concurrent: true, handle }` form:
124
+ *
125
+ * ```ts
126
+ * handlers: {
127
+ * transfer: concurrent(async (ctx, input) => {
128
+ * await bank.transfer(input, { idempotencyKey: input.ref })
129
+ * ctx.send.settle({ ref: input.ref })
130
+ * }),
131
+ * }
132
+ * ```
133
+ */
134
+ export function concurrent<Ctx, I>(
135
+ handle: (ctx: Ctx, input: I) => void | Promise<void>,
136
+ ): { concurrent: true; handle: (ctx: Ctx, input: I) => void | Promise<void> } {
137
+ return { concurrent: true, handle }
138
+ }
139
+
140
+ export type ActorOptions<D extends ActorProtocol> = {
141
+ /** Identity — prefixes storage keys; one contract per actor definition. */
142
+ name: string
143
+ /** The initial state — checked against the protocol, a plain JSON tree. */
144
+ state: D['state']
145
+ /**
146
+ * One handler per declared event — completeness and payload shapes
147
+ * are compile-checked against the protocol.
148
+ */
149
+ handlers: { [K in keyof D['events']]: ActorHandler<D, K> }
150
+ store?: A2Store
151
+ scheduler?: A2Scheduler
152
+ telemetry?: A2Telemetry
153
+ } & ([ActorPresenceOf<D>] extends [never]
154
+ ? { presence?: never }
155
+ : {
156
+ /**
157
+ * The wire bit for the protocol's `presence` vocabulary — types
158
+ * erase, so the runtime needs one value to arm the presence
159
+ * lanes (frames on GET, envelopes on POST). Required exactly
160
+ * when the protocol declares `presence`; the vocabulary itself
161
+ * is typed there. Values are validated to the JSON floor and
162
+ * pass through `authorize` as `{ type: 'presence' }` operations;
163
+ * meaning stays with your renderers — treat values as user
164
+ * input.
165
+ */
166
+ presence: true
167
+ })
168
+
169
+ /**
170
+ * One HTTP operation `handle.fetch` is about to serve — the actor
171
+ * mirror of core's `A2Operation`. `authorize` sees it before any
172
+ * write or subscription; returning `false` answers 403.
173
+ */
174
+ export type ActorOperation =
175
+ | {
176
+ readonly type: 'stream'
177
+ readonly id: string
178
+ readonly startAfter: number
179
+ }
180
+ | {
181
+ readonly type: 'call'
182
+ readonly id: string
183
+ readonly event: string
184
+ readonly input: unknown
185
+ readonly messageId?: string
186
+ }
187
+ | {
188
+ readonly type: 'presence'
189
+ readonly id: string
190
+ readonly participant: string
191
+ readonly values: Readonly<Record<string, unknown>>
192
+ }
193
+
194
+ export type ActorFetchOptions<D extends ActorProtocol> = {
195
+ /** Per-operation policy — authentication happened in your route. */
196
+ authorize?: (operation: ActorOperation) => boolean | Promise<boolean>
197
+ /**
198
+ * Request-time projection: what this mount's audience sees. Applied
199
+ * to every state frame on the stream AND to call answers (the two
200
+ * untrusted lanes must agree, or the call lane leaks what the
201
+ * stream hides). The definition stays audience-agnostic — different
202
+ * routes project the same actor differently, and per-viewer views
203
+ * are just closures over the route's auth. Output is held to the
204
+ * JSON-tree floor like everything else. Transport-agnostic: applied
205
+ * at frame emission, so any wire the door speaks emits projected
206
+ * frames. Absent, the full state ships (the trusted default).
207
+ */
208
+ view?: (state: D['state']) => unknown
209
+ }
210
+
211
+ export type ActorSendOptions = {
212
+ /** Explicit message id — makes retries of this send idempotent. */
213
+ id?: string
214
+ }
215
+
216
+ export type ActorCallOptions = ActorSendOptions & {
217
+ /** How long to await the answer before rejecting the wait (default 30s). */
218
+ timeoutMs?: number
219
+ }
220
+
221
+ export type ActorCallResult<S> = {
222
+ /** The actor's state after this message was processed. */
223
+ state: S
224
+ /** Log index of the state commit that answered this message. */
225
+ index: number
226
+ }
227
+
228
+ type CallMethod<
229
+ D extends ActorProtocol,
230
+ K extends keyof D['events'],
231
+ > = {} extends D['events'][K]
232
+ ? (
233
+ input?: D['events'][K],
234
+ options?: ActorCallOptions,
235
+ ) => Promise<ActorCallResult<D['state']>>
236
+ : (
237
+ input: D['events'][K],
238
+ options?: ActorCallOptions,
239
+ ) => Promise<ActorCallResult<D['state']>>
240
+
241
+ type SendMethod<
242
+ D extends ActorProtocol,
243
+ K extends keyof D['events'],
244
+ > = {} extends D['events'][K]
245
+ ? (
246
+ input?: D['events'][K],
247
+ options?: ActorSendOptions,
248
+ ) => Promise<{ id: string; index: number }>
249
+ : (
250
+ input: D['events'][K],
251
+ options?: ActorSendOptions,
252
+ ) => Promise<{ id: string; index: number }>
253
+
254
+ /** One instance: typed calls and sends, reads, the managed door, the escape hatch. */
255
+ export type ActorHandle<D extends ActorProtocol> = {
256
+ readonly id: string
257
+ /**
258
+ * Send the event and await its answer: the state after the handler
259
+ * ran, or a rejection with `ActorRefusedError`. Serial events only —
260
+ * concurrent handlers produce no answer to await; `send` them.
261
+ */
262
+ readonly call: { readonly [K in keyof D['events']]: CallMethod<D, K> }
263
+ /** Cast: validate, append the event durably, return without waiting. */
264
+ readonly send: { readonly [K in keyof D['events']]: SendMethod<D, K> }
265
+ /** Snapshot read — never queues behind pending messages. */
266
+ readonly state: () => Promise<ActorCallResult<D['state']>>
267
+ /**
268
+ * The managed door — the actor mirror of `server.fetch`, bound to
269
+ * this instance. GET streams the state plane (`a2.actor.state`
270
+ * commits only, `?index` resume, heartbeat, deadline rotation);
271
+ * POST is the call lane `createActorClient` speaks (`{ event,
272
+ * input, messageId? }` → the answer, 409 for a refusal). Your route
273
+ * authenticates and picks the instance; `authorize` sees every
274
+ * operation. Custom endpoints are route code around this call.
275
+ */
276
+ readonly fetch: (
277
+ request: Request,
278
+ options?: ActorFetchOptions<D>,
279
+ ) => Promise<Response>
280
+ /** Escape hatch: the raw A2 session (history, stream, schedule). */
281
+ readonly session: Session<EventDefs>
282
+ }
283
+
284
+ export type ActorDefinition<D extends ActorProtocol> = {
285
+ readonly name: string
286
+ /**
287
+ * Type-only carrier of the protocol, so clients deriving from
288
+ * `typeof def` can see the vocabularies (`experimental-a2/actor/react`
289
+ * types `presence` from it). Never a runtime value.
290
+ */
291
+ readonly protocol?: D
292
+ /** The assembled contract — declared events plus the reserved a2.actor.* pair. */
293
+ readonly contract: Contract<EventDefs>
294
+ /** The underlying A2 server — `server.fetch` mounts the full session wire. */
295
+ readonly server: A2Server<EventDefs>
296
+ /** The state fold — same identity as the client's follower fold. */
297
+ readonly reducer: Reducer<ActorClientEventDefs<D['state']>, D['state']>
298
+ actor(id: string): ActorHandle<D>
299
+ }
300
+
301
+ // Object-mechanics names, not surface names: events live in their own
302
+ // bags, so nothing else is reserved.
303
+ const UNSAFE_EVENT_NAMES = new Set(['__proto__', 'constructor', 'prototype'])
304
+
305
+ const forbidden = (): Response =>
306
+ Response.json({ error: 'the operation is not authorized' }, { status: 403 })
307
+
308
+ const CALL_TIMEOUT_MS = 30_000
309
+ const CALL_POLL_MS = 25
310
+
311
+ /** Declared events accept any JSON tree; `undefined` normalizes to `{}`. */
312
+ const jsonInputSchema: StandardSchemaV1<unknown> = {
313
+ '~standard': {
314
+ version: 1,
315
+ vendor: 'a2',
316
+ validate(value) {
317
+ if (value === undefined || value === null) return { value: {} }
318
+ if (!isJsonTree(value)) {
319
+ return {
320
+ issues: [{ message: 'event input must be a plain JSON tree' }],
321
+ }
322
+ }
323
+ return { value }
324
+ },
325
+ },
326
+ }
327
+
328
+ const describeError = (error: unknown): string =>
329
+ error instanceof Error ? error.message : String(error)
330
+
331
+ const sleep = (ms: number): Promise<void> =>
332
+ new Promise((resolve) => setTimeout(resolve, ms))
333
+
334
+ type NormalizedHandler<D extends ActorProtocol> =
335
+ | {
336
+ kind: 'serial'
337
+ handle: (ctx: ActorContext<D>, input: unknown) => void | Promise<void>
338
+ }
339
+ | {
340
+ kind: 'concurrent'
341
+ handle: (
342
+ ctx: ActorConcurrentContext<D>,
343
+ input: unknown,
344
+ ) => void | Promise<void>
345
+ }
346
+
347
+ /**
348
+ * Define and serve an actor over its protocol: a named, durable
349
+ * instance-per-id with serialized state handlers, concurrent I/O
350
+ * handlers, and typed self-sends.
351
+ */
352
+ export function actor<D extends ActorProtocol>(
353
+ options: ActorOptions<D>,
354
+ ): ActorDefinition<D> {
355
+ const { name } = options
356
+
357
+ const handlersByEvent = new Map<string, NormalizedHandler<D>>()
358
+ const events: Record<string, StandardSchemaV1> = {
359
+ [ACTOR_STATE_EVENT]: actorStateSchema(),
360
+ [ACTOR_FAILED_EVENT]: actorFailedSchema,
361
+ }
362
+ for (const [eventName, def] of Object.entries(
363
+ options.handlers as Record<string, ActorHandler<D, keyof D['events']>>,
364
+ )) {
365
+ if (UNSAFE_EVENT_NAMES.has(eventName)) {
366
+ throw new TypeError(
367
+ `actor '${name}': '${eventName}' collides with object plumbing and cannot name an event`,
368
+ )
369
+ }
370
+ if (eventName.startsWith('a2.')) {
371
+ throw new TypeError(
372
+ `actor '${name}': event names starting with 'a2.' are reserved`,
373
+ )
374
+ }
375
+ handlersByEvent.set(
376
+ eventName,
377
+ typeof def === 'function'
378
+ ? {
379
+ kind: 'serial',
380
+ handle: def as (
381
+ ctx: ActorContext<D>,
382
+ input: unknown,
383
+ ) => void | Promise<void>,
384
+ }
385
+ : {
386
+ kind: 'concurrent',
387
+ handle: def.handle as (
388
+ ctx: ActorConcurrentContext<D>,
389
+ input: unknown,
390
+ ) => void | Promise<void>,
391
+ },
392
+ )
393
+ events[eventName] = jsonInputSchema
394
+ }
395
+
396
+ const reducer = actorReducer({ name, state: options.state })
397
+ // The server's contract carries every event type; the reducer only
398
+ // folds state commits. Same fold, same identity — the widening is a
399
+ // typing formality.
400
+ const serverReducer = reducer as unknown as Reducer<EventDefs, D['state']>
401
+
402
+ // The typed durable-timer surface: immediate handoff to the
403
+ // configured scheduler through the handler session, so timer
404
+ // identity is scoped to the triggering message and re-runs address
405
+ // the same timer (core §6). Not buffered — see ActorScheduleSend.
406
+ const makeSchedule = (
407
+ schedule: (
408
+ name: string,
409
+ timing: { delay?: string; at?: Date },
410
+ event: { type: string; payload: unknown },
411
+ ) => Promise<void>,
412
+ ): ActorScheduleSend<D['events']> => {
413
+ const surface: Record<string, unknown> = {}
414
+ for (const eventName of handlersByEvent.keys()) {
415
+ Object.defineProperty(surface, eventName, {
416
+ value: (input: unknown, scheduleOptions: ActorScheduleOptions) => {
417
+ const { name: taskName, ...timing } = scheduleOptions
418
+ return schedule(taskName ?? eventName, timing, {
419
+ type: eventName,
420
+ payload: input ?? {},
421
+ })
422
+ },
423
+ enumerable: true,
424
+ configurable: true,
425
+ writable: false,
426
+ })
427
+ }
428
+ return surface as ActorScheduleSend<D['events']>
429
+ }
430
+
431
+ // The typed self-send surface: per-invocation buffers behind one
432
+ // shared method record shape. defineProperty so event names like
433
+ // 'constructor'-adjacent identifiers can never touch prototypes.
434
+ const makeSend = (
435
+ buffer: { type: string; payload: unknown }[],
436
+ ): ActorSend<D['events']> => {
437
+ const send: Record<string, unknown> = {}
438
+ for (const eventName of handlersByEvent.keys()) {
439
+ Object.defineProperty(send, eventName, {
440
+ value: (input?: unknown) => {
441
+ buffer.push({ type: eventName, payload: input ?? {} })
442
+ },
443
+ enumerable: true,
444
+ configurable: true,
445
+ writable: false,
446
+ })
447
+ }
448
+ return send as ActorSend<D['events']>
449
+ }
450
+
451
+ const handlers: Record<string, HandlerEntry<EventDefs>> = {}
452
+ for (const [eventName, def] of handlersByEvent) {
453
+ if (def.kind === 'concurrent') {
454
+ // No lane: concurrent handlers run in parallel — with the lane
455
+ // and with each other. They cannot write state (no draft to
456
+ // commit), so no serialization is needed for correctness; slow
457
+ // I/O lives here without blocking the actor.
458
+ handlers[eventName] = async (ctx) => {
459
+ const sends: { type: string; payload: unknown }[] = []
460
+ try {
461
+ await def.handle(
462
+ {
463
+ id: ctx.event.id,
464
+ attempt: ctx.attempt,
465
+ signal: ctx.signal,
466
+ send: makeSend(sends),
467
+ schedule: makeSchedule((taskName, timing, event) =>
468
+ ctx.session.schedule(
469
+ taskName,
470
+ timing as Parameters<typeof ctx.session.schedule>[1],
471
+ event,
472
+ ),
473
+ ),
474
+ state: async () => {
475
+ const { state, index } = await ctx.session.state(
476
+ serverReducer,
477
+ { through: 'latest' },
478
+ )
479
+ return { state, index }
480
+ },
481
+ },
482
+ ctx.event.payload,
483
+ )
484
+ } catch (error) {
485
+ if (!(error instanceof NonRetriableError)) throw error
486
+ // A terminal failure is recorded for audit; nothing awaits a
487
+ // concurrent handler, so the marker is history, not an
488
+ // answer. Buffered sends are discarded — refusals are total,
489
+ // so compensate by completing (send then return), never by
490
+ // throwing.
491
+ const payload: ActorFailedPayload = {
492
+ message: ctx.event.id,
493
+ event: eventName,
494
+ error: describeError(error),
495
+ }
496
+ return { type: ACTOR_FAILED_EVENT, payload }
497
+ }
498
+ // Sends commit atomically with settlement; re-runs converge on
499
+ // the same deterministic message ids.
500
+ return sends
501
+ }
502
+ continue
503
+ }
504
+ handlers[eventName] = {
505
+ // One lane for every serial handler of one instance: the next
506
+ // message starts only after the previous one completed, and
507
+ // completion commits the state event atomically —
508
+ // read-modify-write on `ctx.state` is safe by exclusion, not by
509
+ // liveness.
510
+ lane: ACTOR_LANE,
511
+ handler: async (ctx) => {
512
+ // Explicitly 'latest', not the event-relative default: prior
513
+ // lane messages commit *after* this message was appended, so
514
+ // their state commits carry higher indexes than the trigger —
515
+ // an event-relative read would miss them and lose updates.
516
+ // 'latest' is deterministic here because the lane makes this
517
+ // attempt the only possible writer while it runs.
518
+ const { state } = await ctx.session.state(serverReducer, {
519
+ through: 'latest',
520
+ })
521
+ const draft = structuredClone(state)
522
+ const sends: { type: string; payload: unknown }[] = []
523
+ try {
524
+ await def.handle(
525
+ {
526
+ state: draft,
527
+ id: ctx.event.id,
528
+ attempt: ctx.attempt,
529
+ signal: ctx.signal,
530
+ send: makeSend(sends),
531
+ schedule: makeSchedule((taskName, timing, event) =>
532
+ ctx.session.schedule(
533
+ taskName,
534
+ timing as Parameters<typeof ctx.session.schedule>[1],
535
+ event,
536
+ ),
537
+ ),
538
+ },
539
+ ctx.event.payload,
540
+ )
541
+ } catch (error) {
542
+ // A NonRetriableError is an answer: settle the message,
543
+ // record the refusal, deliver it to the waiting caller —
544
+ // no retry, no failure budget spent (buffered sends are
545
+ // discarded with the draft: refusals are total). Every
546
+ // other throw is an ordinary A2 handler failure: rethrown
547
+ // into core's retry machinery (backoff, ten-failure
548
+ // budget, dead letter) — including surrendered lease
549
+ // aborts, which core recognizes by identity.
550
+ if (!(error instanceof NonRetriableError)) throw error
551
+ const payload: ActorFailedPayload = {
552
+ message: ctx.event.id,
553
+ event: eventName,
554
+ error: describeError(error),
555
+ }
556
+ return { type: ACTOR_FAILED_EVENT, payload }
557
+ }
558
+ // One returned batch: buffered sends plus the state commit —
559
+ // committed atomically with completion, so a reserve and its
560
+ // trigger can never half-happen. The state event stays the
561
+ // outcome marker a waiting call resolves on. (Unconditional
562
+ // for now; a dirty check is a compatible later optimization.)
563
+ const payload: ActorStatePayload<D['state']> = {
564
+ state: draft,
565
+ event: eventName,
566
+ message: ctx.event.id,
567
+ }
568
+ return [...sends, { type: ACTOR_STATE_EVENT, payload }]
569
+ },
570
+ }
571
+ }
572
+
573
+ const presenceEnabled = (options as { presence?: unknown }).presence === true
574
+ // The '*' catch-all arms an open server-side vocabulary: the
575
+ // protocol types field names at compile time, the floor validates
576
+ // every value, and `authorize` sees every set. See openPresenceDefs.
577
+ const contract = presenceEnabled
578
+ ? createContract({ name, events, presence: openPresenceDefs })
579
+ : createContract({ name, events })
580
+ const server = createServer({
581
+ contract,
582
+ handlers,
583
+ ...(options.store ? { store: options.store } : {}),
584
+ ...(options.scheduler ? { scheduler: options.scheduler } : {}),
585
+ ...(options.telemetry ? { telemetry: options.telemetry } : {}),
586
+ })
587
+ // Single-writer discipline on the underlying session wire too: if an
588
+ // application mounts `def.server.fetch`, browsers may push declared
589
+ // events, never authored state or failure records.
590
+ setServerFetchHooks(server, {
591
+ validateIngress: ({ events: pushed }) => {
592
+ for (const event of pushed) {
593
+ if (event.type.startsWith('a2.actor.')) {
594
+ throw new TypeError(
595
+ `'${event.type}' is reserved — only a handler's completion can author it`,
596
+ )
597
+ }
598
+ }
599
+ },
600
+ })
601
+
602
+ const makeHandle = (id: string): ActorHandle<D> => {
603
+ const session = server.session(id)
604
+
605
+ const sendEvent = async (
606
+ eventName: string,
607
+ input: unknown,
608
+ sendOptions?: ActorSendOptions,
609
+ ): Promise<{ id: string; index: number }> => {
610
+ const [message] = await session.append({
611
+ type: eventName,
612
+ payload: input ?? {},
613
+ ...(sendOptions?.id ? { id: sendOptions.id } : {}),
614
+ })
615
+ if (!message) throw new Error('append returned no event')
616
+ return { id: message.id, index: message.index }
617
+ }
618
+
619
+ const callEvent = async (
620
+ eventName: string,
621
+ input: unknown,
622
+ callOptions?: ActorCallOptions,
623
+ ): Promise<ActorCallResult<D['state']>> => {
624
+ if (handlersByEvent.get(eventName)?.kind === 'concurrent') {
625
+ throw new TypeError(
626
+ `actor '${name}': '${eventName}' is concurrent — it produces no answer to await; send it instead`,
627
+ )
628
+ }
629
+ const message = await sendEvent(eventName, input, callOptions)
630
+ const deadline = Date.now() + (callOptions?.timeoutMs ?? CALL_TIMEOUT_MS)
631
+ let cursor = message.index + 1
632
+ for (;;) {
633
+ // Drain participates in processing (usually answering inline in
634
+ // this invocation); claims arbitrate if another process runs.
635
+ await server.drain(id)
636
+ const tail = await session.history({ gte: cursor })
637
+ for (const event of tail) {
638
+ if (event.type === ACTOR_STATE_EVENT) {
639
+ const payload = event.payload as ActorStatePayload<D['state']>
640
+ if (payload.message === message.id) {
641
+ return { state: payload.state, index: event.index }
642
+ }
643
+ }
644
+ if (event.type === ACTOR_FAILED_EVENT) {
645
+ const payload = event.payload as ActorFailedPayload
646
+ if (payload.message === message.id) {
647
+ throw new ActorRefusedError(eventName, message.id, payload.error)
648
+ }
649
+ }
650
+ }
651
+ const last = tail[tail.length - 1]
652
+ if (last) cursor = last.index + 1
653
+ if (Date.now() >= deadline) {
654
+ throw new Error(
655
+ `actor '${name}': timed out waiting for '${eventName}' (message ${message.id})`,
656
+ )
657
+ }
658
+ await sleep(CALL_POLL_MS)
659
+ }
660
+ }
661
+
662
+ const readState = async (): Promise<ActorCallResult<D['state']>> => {
663
+ const { state, index } = await session.state(serverReducer)
664
+ return { state, index }
665
+ }
666
+
667
+ const applyView = (
668
+ view: (state: D['state']) => unknown,
669
+ state: D['state'],
670
+ ): unknown => {
671
+ const viewed = view(state)
672
+ if (!isJsonTree(viewed)) {
673
+ throw new TypeError(
674
+ `actor '${name}': the view returned a value that is not a plain JSON tree`,
675
+ )
676
+ }
677
+ return viewed
678
+ }
679
+
680
+ const streamStates = async function* (
681
+ startAfter: number,
682
+ view?: (state: D['state']) => unknown,
683
+ ) {
684
+ // With presence armed, the session interleaves presence items
685
+ // (snapshot, then patches) between log events. They are
686
+ // ephemeral audience data, not actor state: they pass through
687
+ // untouched — views project state, never peers — and they never
688
+ // advance the resume frontier.
689
+ const source: AsyncIterable<
690
+ | Awaited<ReturnType<typeof session.history>>[number]
691
+ | PresencePatch
692
+ | PresenceSnapshot
693
+ > = presenceEnabled
694
+ ? (
695
+ session as unknown as {
696
+ stream(options: {
697
+ startAfter: number
698
+ presence: true
699
+ }): AsyncIterable<PresencePatch | PresenceSnapshot>
700
+ }
701
+ ).stream({ startAfter, presence: true })
702
+ : session.stream({ startAfter })
703
+ for await (const item of source) {
704
+ if (!('type' in item)) {
705
+ yield item
706
+ continue
707
+ }
708
+ const event = item
709
+ // The state plane only: subscribers see what the actor is,
710
+ // never other callers' inputs. Refusals answer only their
711
+ // caller (the POST 409); the full mailbox stays server-side.
712
+ if (event.type !== ACTOR_STATE_EVENT) continue
713
+ if (!view) {
714
+ yield event
715
+ continue
716
+ }
717
+ const payload = event.payload as ActorStatePayload<D['state']>
718
+ yield {
719
+ ...event,
720
+ payload: { ...payload, state: applyView(view, payload.state) },
721
+ }
722
+ }
723
+ }
724
+
725
+ // The managed door — the actor mirror of `server.fetch`: GET is
726
+ // the state-plane SSE (`a2.actor.state` commits only, `?index`
727
+ // resume), POST is the call lane (`{ event, input, messageId? }`
728
+ // → the answer, 409 for a refusal). Authentication happens in the
729
+ // surrounding route; per-operation policy in `authorize`.
730
+ const handleFetch = async (
731
+ request: Request,
732
+ fetchOptions?: ActorFetchOptions<D>,
733
+ ): Promise<Response> => {
734
+ const authorized = async (operation: ActorOperation): Promise<boolean> =>
735
+ (await fetchOptions?.authorize?.(Object.freeze(operation))) !== false
736
+
737
+ if (request.method === 'GET') {
738
+ const url = new URL(request.url)
739
+ const raw = Number(url.searchParams.get('index'))
740
+ const startAfter = Number.isSafeInteger(raw) && raw >= 0 ? raw : 0
741
+ if (!(await authorized({ type: 'stream', id, startAfter }))) {
742
+ return forbidden()
743
+ }
744
+ return sseResponse(streamStates(startAfter, fetchOptions?.view))
745
+ }
746
+ if (request.method !== 'POST') {
747
+ return new Response(null, {
748
+ status: 405,
749
+ headers: { allow: 'GET, POST' },
750
+ })
751
+ }
752
+ let body: unknown
753
+ try {
754
+ body = await request.json()
755
+ } catch {
756
+ return Response.json({ error: 'invalid JSON body' }, { status: 400 })
757
+ }
758
+ const record =
759
+ typeof body === 'object' && body !== null
760
+ ? (body as Record<string, unknown>)
761
+ : {}
762
+ if ('presence' in record) {
763
+ // A presence envelope — what the follower client's
764
+ // `setPresence` posts. Events never ride this lane: writes to
765
+ // the actor are calls.
766
+ if (!presenceEnabled) {
767
+ return Response.json(
768
+ { error: `actor '${name}' declares no presence` },
769
+ { status: 400 },
770
+ )
771
+ }
772
+ const pushedEvents = record['events']
773
+ if (
774
+ pushedEvents !== undefined &&
775
+ (!Array.isArray(pushedEvents) || pushedEvents.length > 0)
776
+ ) {
777
+ return Response.json(
778
+ { error: 'the actor wire takes calls, not event pushes' },
779
+ { status: 400 },
780
+ )
781
+ }
782
+ let patch: ParsedPushPresence
783
+ try {
784
+ const parsed = parsePresenceSibling(record['presence'])
785
+ if (!parsed) throw new TypeError('presence must be an object')
786
+ patch = parsed
787
+ } catch (error) {
788
+ return Response.json({ error: describeError(error) }, { status: 400 })
789
+ }
790
+ if (
791
+ !(await authorized({
792
+ type: 'presence',
793
+ id,
794
+ participant: patch.participant,
795
+ values: patch.values,
796
+ }))
797
+ ) {
798
+ return forbidden()
799
+ }
800
+ try {
801
+ await (
802
+ session as unknown as {
803
+ setPresence(patch: ParsedPushPresence): Promise<void>
804
+ }
805
+ ).setPresence(patch)
806
+ } catch (error) {
807
+ if (
808
+ error instanceof A2Error &&
809
+ (error.code === 'INVALID_PAYLOAD' ||
810
+ error.code === 'UNKNOWN_PRESENCE_FIELD')
811
+ ) {
812
+ return Response.json({ error: error.message }, { status: 400 })
813
+ }
814
+ throw error
815
+ }
816
+ return Response.json([])
817
+ }
818
+ const eventName = record['event']
819
+ if (
820
+ typeof eventName !== 'string' ||
821
+ handlersByEvent.get(eventName)?.kind !== 'serial'
822
+ ) {
823
+ return Response.json({ error: 'unknown event' }, { status: 400 })
824
+ }
825
+ const messageId = record['messageId']
826
+ if (messageId !== undefined && typeof messageId !== 'string') {
827
+ return Response.json({ error: 'invalid messageId' }, { status: 400 })
828
+ }
829
+ const input = record['input']
830
+ if (
831
+ !(await authorized({
832
+ type: 'call',
833
+ id,
834
+ event: eventName,
835
+ input,
836
+ ...(messageId === undefined ? {} : { messageId }),
837
+ }))
838
+ ) {
839
+ return forbidden()
840
+ }
841
+ try {
842
+ const result = await callEvent(
843
+ eventName,
844
+ input,
845
+ messageId === undefined ? undefined : { id: messageId },
846
+ )
847
+ return Response.json(
848
+ fetchOptions?.view
849
+ ? {
850
+ state: applyView(fetchOptions.view, result.state),
851
+ index: result.index,
852
+ }
853
+ : result,
854
+ )
855
+ } catch (error) {
856
+ if (error instanceof ActorRefusedError) {
857
+ return Response.json(
858
+ {
859
+ error: error.message,
860
+ event: error.event,
861
+ messageId: error.messageId,
862
+ },
863
+ { status: 409 },
864
+ )
865
+ }
866
+ if (
867
+ error instanceof A2Error &&
868
+ (error.code === 'INVALID_PAYLOAD' ||
869
+ error.code === 'UNKNOWN_EVENT_TYPE')
870
+ ) {
871
+ return Response.json({ error: error.message }, { status: 400 })
872
+ }
873
+ throw error
874
+ }
875
+ }
876
+
877
+ const call: Record<string, unknown> = {}
878
+ const send: Record<string, unknown> = {}
879
+ for (const eventName of handlersByEvent.keys()) {
880
+ Object.defineProperty(call, eventName, {
881
+ value: (input?: unknown, callOptions?: ActorCallOptions) =>
882
+ callEvent(eventName, input, callOptions),
883
+ enumerable: true,
884
+ configurable: true,
885
+ writable: false,
886
+ })
887
+ Object.defineProperty(send, eventName, {
888
+ value: (input?: unknown, sendOptions?: ActorSendOptions) =>
889
+ sendEvent(eventName, input, sendOptions),
890
+ enumerable: true,
891
+ configurable: true,
892
+ writable: false,
893
+ })
894
+ }
895
+
896
+ const handle: ActorHandle<D> = {
897
+ id,
898
+ session,
899
+ call: call as ActorHandle<D>['call'],
900
+ send: send as ActorHandle<D>['send'],
901
+ state: readState,
902
+ fetch: handleFetch,
903
+ }
904
+ return handle
905
+ }
906
+
907
+ return {
908
+ name,
909
+ contract,
910
+ server,
911
+ reducer,
912
+ actor: makeHandle,
913
+ }
914
+ }