experimental-a2 0.3.0 → 0.5.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 (205) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/dist/ai-CFNeCrRl.d.ts +334 -0
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +49 -9
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +123 -49
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +2 -303
  11. package/dist/ai.js +233 -86
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +599 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.d.ts +1 -0
  16. package/dist/cli-bin.js +7 -0
  17. package/dist/cli-bin.js.map +1 -0
  18. package/dist/cli.d.ts +20 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +2 -0
  21. package/dist/client-BKlyLiOU.js +1008 -0
  22. package/dist/client-BKlyLiOU.js.map +1 -0
  23. package/dist/client-D7mvIXrF.d.ts +191 -0
  24. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  25. package/dist/client.d.ts +2 -90
  26. package/dist/client.js +1 -409
  27. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
  28. package/dist/contract-48bUMgcL.js.map +1 -0
  29. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
  30. package/dist/contract-jIfaR085.d.ts.map +1 -0
  31. package/dist/devtools-J_jZ2vQf.d.ts +152 -0
  32. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  33. package/dist/devtools-kJJaORn-.js +340 -0
  34. package/dist/devtools-kJJaORn-.js.map +1 -0
  35. package/dist/devtools-server.browser.js +3 -1
  36. package/dist/devtools-server.browser.js.map +1 -0
  37. package/dist/devtools-server.d.ts +4 -3
  38. package/dist/devtools-server.d.ts.map +1 -0
  39. package/dist/devtools-server.js +226 -43
  40. package/dist/devtools-server.js.map +1 -0
  41. package/dist/devtools.d.ts +2 -0
  42. package/dist/devtools.js +2 -0
  43. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
  44. package/dist/errors-BQuJpe82.js.map +1 -0
  45. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
  46. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  47. package/dist/http.d.ts +135 -28
  48. package/dist/http.d.ts.map +1 -0
  49. package/dist/http.js +647 -61
  50. package/dist/http.js.map +1 -0
  51. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
  52. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  53. package/dist/index.d.ts +5 -5
  54. package/dist/index.js +2 -2
  55. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
  56. package/dist/inspection-DaxB5jM2.js.map +1 -0
  57. package/dist/internal-DstsI6Re.js +211 -0
  58. package/dist/internal-DstsI6Re.js.map +1 -0
  59. package/dist/otel.d.ts +3 -2
  60. package/dist/otel.d.ts.map +1 -0
  61. package/dist/otel.js +2 -0
  62. package/dist/otel.js.map +1 -0
  63. package/dist/platform-B4TnJtWu.js +34 -0
  64. package/dist/platform-B4TnJtWu.js.map +1 -0
  65. package/dist/react.d.ts +47 -15
  66. package/dist/react.d.ts.map +1 -0
  67. package/dist/react.js +30 -15
  68. package/dist/react.js.map +1 -0
  69. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  70. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  71. package/dist/scheduler-qstash.d.ts +79 -0
  72. package/dist/scheduler-qstash.d.ts.map +1 -0
  73. package/dist/scheduler-qstash.js +501 -0
  74. package/dist/scheduler-qstash.js.map +1 -0
  75. package/dist/scheduler-task-BpzhPnRS.js +56 -0
  76. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  77. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
  78. package/dist/scheduler-vercel.d.ts.map +1 -0
  79. package/dist/scheduler-vercel.js +228 -0
  80. package/dist/scheduler-vercel.js.map +1 -0
  81. package/dist/server-C72KOw51.js +1457 -0
  82. package/dist/server-C72KOw51.js.map +1 -0
  83. package/dist/server-DUF9pjsx.d.ts +261 -0
  84. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  85. package/dist/server.browser.js +2 -0
  86. package/dist/server.browser.js.map +1 -0
  87. package/dist/server.d.ts +4 -155
  88. package/dist/server.js +2 -2
  89. package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
  90. package/dist/store-DysUkTH3.d.ts.map +1 -0
  91. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
  92. package/dist/store-N8PXxDAS.js.map +1 -0
  93. package/dist/store-codec-DTG0Ftek.js +10 -0
  94. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  95. package/dist/store-memory.d.ts +12 -0
  96. package/dist/store-memory.d.ts.map +1 -0
  97. package/dist/{log-memory.js → store-memory.js} +145 -34
  98. package/dist/store-memory.js.map +1 -0
  99. package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
  100. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  101. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
  102. package/dist/store-postgres.d.ts.map +1 -0
  103. package/dist/{log-postgres.js → store-postgres.js} +214 -24
  104. package/dist/store-postgres.js.map +1 -0
  105. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
  106. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  107. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
  108. package/dist/store-redis-http.d.ts.map +1 -0
  109. package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
  110. package/dist/store-redis-http.js.map +1 -0
  111. package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
  112. package/dist/store-redis.d.ts.map +1 -0
  113. package/dist/{log-redis.js → store-redis.js} +101 -39
  114. package/dist/store-redis.js.map +1 -0
  115. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
  116. package/dist/store-sqlite.d.ts.map +1 -0
  117. package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
  118. package/dist/store-sqlite.js.map +1 -0
  119. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
  120. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  121. package/dist/testing.browser.d.ts +1 -0
  122. package/dist/testing.browser.js +6 -0
  123. package/dist/testing.browser.js.map +1 -0
  124. package/dist/testing.d.ts +32 -0
  125. package/dist/testing.d.ts.map +1 -0
  126. package/dist/testing.js +103 -0
  127. package/dist/testing.js.map +1 -0
  128. package/dist/validate-XKT4FSNn.js +2 -0
  129. package/dist/validate-XKT4FSNn.js.map +1 -0
  130. package/dist/wire-BFQmSJ-9.js +284 -0
  131. package/dist/wire-BFQmSJ-9.js.map +1 -0
  132. package/docs/01-quickstart.mdx +3 -3
  133. package/docs/concepts/01-contracts.mdx +18 -13
  134. package/docs/concepts/02-handlers.mdx +7 -7
  135. package/docs/concepts/03-durability.mdx +21 -21
  136. package/docs/concepts/04-state.mdx +18 -21
  137. package/docs/guides/01-timers.mdx +154 -49
  138. package/docs/guides/02-cancellation.mdx +30 -4
  139. package/docs/guides/03-react.mdx +76 -57
  140. package/docs/guides/04-local-first.mdx +1 -1
  141. package/docs/guides/05-production.mdx +310 -61
  142. package/docs/guides/06-ai-agents.mdx +252 -74
  143. package/docs/guides/07-devtools.mdx +137 -12
  144. package/docs/guides/08-application-data.mdx +1 -1
  145. package/docs/guides/09-presence.mdx +263 -0
  146. package/docs/guides/10-transports.mdx +140 -0
  147. package/docs/index.mdx +18 -14
  148. package/docs/reference/01-api.mdx +839 -110
  149. package/docs/reference/02-errors.mdx +13 -6
  150. package/package.json +24 -7
  151. package/src/ai-coordinator.ts +358 -0
  152. package/src/ai-projector.ts +524 -0
  153. package/src/ai-sdk-step.ts +261 -0
  154. package/src/ai-server.browser.ts +5 -0
  155. package/src/ai-server.ts +1719 -0
  156. package/src/ai.ts +2155 -0
  157. package/src/cache-indexeddb.ts +10 -0
  158. package/src/cli-bin.ts +5 -0
  159. package/src/cli.ts +1046 -0
  160. package/src/client.ts +1826 -0
  161. package/src/contract.ts +206 -0
  162. package/src/deterministic-id.ts +72 -0
  163. package/src/devtools-app.ts +989 -0
  164. package/src/devtools-server.browser.ts +5 -0
  165. package/src/devtools-server.ts +604 -0
  166. package/src/devtools.ts +716 -0
  167. package/src/errors.ts +50 -0
  168. package/src/http.ts +394 -0
  169. package/src/idempotent-replay.ts +53 -0
  170. package/src/index.ts +37 -0
  171. package/src/inspection.ts +39 -0
  172. package/src/internal.ts +426 -0
  173. package/src/otel.ts +59 -0
  174. package/src/platform.ts +60 -0
  175. package/src/push-envelope.ts +137 -0
  176. package/src/react.ts +284 -0
  177. package/src/reducer.ts +108 -0
  178. package/src/retryable-lazy.ts +27 -0
  179. package/src/scheduler-qstash.ts +915 -0
  180. package/src/scheduler-task.ts +106 -0
  181. package/src/scheduler-vercel.ts +437 -0
  182. package/src/server.browser.ts +12 -0
  183. package/src/server.ts +2700 -0
  184. package/src/session-socket.ts +548 -0
  185. package/src/sse.ts +141 -0
  186. package/src/standard-schema.ts +77 -0
  187. package/src/store-codec.ts +10 -0
  188. package/src/store-memory.ts +788 -0
  189. package/src/store-polling.ts +102 -0
  190. package/src/store-postgres.ts +1212 -0
  191. package/src/store-redis-core.ts +1494 -0
  192. package/src/store-redis-http.ts +116 -0
  193. package/src/store-redis.ts +458 -0
  194. package/src/store-sqlite.ts +1108 -0
  195. package/src/store.ts +385 -0
  196. package/src/telemetry.ts +47 -0
  197. package/src/testing.browser.ts +5 -0
  198. package/src/testing.ts +185 -0
  199. package/src/validate.ts +39 -0
  200. package/src/wire.ts +454 -0
  201. package/dist/internal-gCd5qMry.js +0 -44
  202. package/dist/log-memory.d.ts +0 -11
  203. package/dist/recovery-vercel.js +0 -119
  204. package/dist/server-BcLa4RFL.js +0 -877
  205. package/dist/wire-BVsgR8o9.js +0 -62
package/src/errors.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * a2 errors — one class, discriminated by `code`.
3
+ *
4
+ * See specs/a2-implementation.md §10 and docs/reference/02-errors.mdx.
5
+ * A single class serializes cleanly across the push-route boundary and
6
+ * keeps `instanceof` checks working after bundling.
7
+ */
8
+
9
+ export type A2ErrorCode =
10
+ | 'INVALID_PAYLOAD' // schema validation failed — thrown before anything is written
11
+ | 'UNKNOWN_EVENT_TYPE' // event type not in the machine's `events` map
12
+ | 'PARTIAL_DUPLICATE_BATCH' // batch mixed already-appended and fresh events
13
+ | 'SUPERSEDED_ATTEMPT' // handler append from an attempt a recovery claim replaced
14
+ | 'CLAIM_EXPIRED' // ctx.signal abort reason: the attempt's lease lapsed without renewal
15
+ | 'STORE_UNAVAILABLE' // store backend failure — retryable, nothing was written
16
+ | 'STORE_NOT_CONFIGURED' // production boot with no `store` passed
17
+ | 'UNKNOWN_PRESENCE_FIELD' // presence field not in the contract's `presence` map
18
+ | 'PRESENCE_NOT_SUPPORTED' // contract declares presence but the store lacks the capability
19
+
20
+ export class A2Error extends Error {
21
+ readonly code: A2ErrorCode
22
+ /** e.g. the Standard Schema issues for INVALID_PAYLOAD */
23
+ readonly details: unknown
24
+
25
+ constructor(
26
+ code: A2ErrorCode,
27
+ message: string,
28
+ options?: { details?: unknown; cause?: unknown },
29
+ ) {
30
+ super(
31
+ message,
32
+ options && 'cause' in options ? { cause: options.cause } : undefined,
33
+ )
34
+ this.name = 'A2Error'
35
+ this.code = code
36
+ this.details = options?.details
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Wrap an arbitrary thrown value as STORE_UNAVAILABLE, preserving the
42
+ * original as `cause`. A2Errors pass through untouched — they already
43
+ * carry their own meaning.
44
+ */
45
+ export function asStoreUnavailable(err: unknown): A2Error {
46
+ if (err instanceof A2Error) return err
47
+ return new A2Error('STORE_UNAVAILABLE', 'the store backend failed', {
48
+ cause: err,
49
+ })
50
+ }
package/src/http.ts ADDED
@@ -0,0 +1,394 @@
1
+ /**
2
+ * experimental-a2/http — the wire's server half (specs/a2-api.md §9–10, §13).
3
+ *
4
+ * A thin public assembly. The surface: `handle` — GET, POST, and the
5
+ * socket's frames parsed into intents (stream, history, push,
6
+ * ws-upgrade) with `before`/`after` hooks around them for policy —
7
+ * plus `A2Intent`/`A2Outcome`/`UpgradeFn`/`A2Socket` (its argument
8
+ * types), `errorResponse`/`deserializeError` (the A2Error wire pair
9
+ * both sides branch on), and `schedulerHandler` (the scheduler
10
+ * callback mount). The machinery lives in internal modules — the SSE
11
+ * framing in sse.ts, the push-envelope trust boundary in
12
+ * push-envelope.ts, the socket shell and multiplexed handler in
13
+ * session-socket.ts — because nothing outside `handle` is sanctioned
14
+ * to compose them. Policy — auth, caching, quotas — never enters this
15
+ * module: the hooks own it.
16
+ */
17
+
18
+ import { A2Error } from './errors.ts'
19
+ import {
20
+ assertServerSchedulerBindings,
21
+ assertSchedulerTargets,
22
+ schedulerForServer,
23
+ } from './internal.ts'
24
+ import type { ContractEvent, EventDefs, PresenceDefs } from './contract.ts'
25
+ import type {
26
+ A2Server,
27
+ DrainableServer,
28
+ PushedEvent,
29
+ PushedPresence,
30
+ } from './server.ts'
31
+ import { sseResponse } from './sse.ts'
32
+ import { invalidPushBody, parsePushBody } from './push-envelope.ts'
33
+ import {
34
+ sessionsSocket,
35
+ type A2Socket,
36
+ type SocketSession,
37
+ } from './session-socket.ts'
38
+ import {
39
+ asA2Error,
40
+ errorFromWire,
41
+ errorStatus,
42
+ errorToWire,
43
+ eventToWire,
44
+ } from './wire.ts'
45
+
46
+ export type { A2Socket } from './session-socket.ts'
47
+
48
+ /**
49
+ * Create one scheduler callback route for servers sharing the same configured
50
+ * scheduler. Configuration mistakes throw immediately while the route module
51
+ * is evaluated, before a delivery can be acknowledged by the wrong adapter.
52
+ */
53
+ export function schedulerHandler(
54
+ firstServer: DrainableServer,
55
+ ...servers: DrainableServer[]
56
+ ): (req: Request) => Promise<Response> {
57
+ if (firstServer === undefined) {
58
+ throw new TypeError('schedulerHandler() requires at least one A2 server')
59
+ }
60
+ const scheduler = schedulerForServer(firstServer)
61
+ const targets = [firstServer, ...servers]
62
+ assertSchedulerTargets(targets, { allowStructural: false })
63
+ assertServerSchedulerBindings(scheduler, targets)
64
+ return scheduler.handler(...targets)
65
+ }
66
+
67
+ /**
68
+ * Serialize an error into the documented wire shape,
69
+ * `{ error: { code, message, details } }`, with the mapped status
70
+ * (400 for caller bugs, 503 for STORE_UNAVAILABLE). Non-A2Errors become a
71
+ * 503 STORE_UNAVAILABLE — from the client's perspective an unknown server
72
+ * failure is retryable-once, not a protocol contract. The natural
73
+ * return value of a `before` hook that wants the wire's own error
74
+ * shapes.
75
+ */
76
+ export function errorResponse(error: unknown): Response {
77
+ const a2error = asA2Error(error)
78
+ return Response.json(errorToWire(a2error), {
79
+ status: errorStatus(a2error.code),
80
+ })
81
+ }
82
+
83
+ /**
84
+ * The other half of the wire pair: rebuild an `A2Error` from a response
85
+ * body, or null if the body isn't one. Client `push` uses it so both
86
+ * sides branch on identical codes.
87
+ */
88
+ export function deserializeError(body: unknown): A2Error | null {
89
+ return errorFromWire(body)
90
+ }
91
+
92
+ // ── handle: the request protocol, parsed ────────────────────────
93
+
94
+ /**
95
+ * What a request (or socket frame) asks of a session, parsed — the
96
+ * argument policy hooks branch on. `stream` covers the SSE GET and the
97
+ * socket's subscribe frames; `push` covers the HTTP POST and the
98
+ * socket's push frames; `history` is the bounded cold read the
99
+ * client's `loadHistory` rides; `ws-upgrade` is the upgrade GET before
100
+ * any frame flows.
101
+ */
102
+ export type A2Intent<D extends EventDefs = EventDefs> =
103
+ | { type: 'ws-upgrade' }
104
+ | {
105
+ type: 'stream'
106
+ sessionId: string
107
+ startAfter: number
108
+ transport: 'sse' | 'ws'
109
+ }
110
+ | { type: 'history'; sessionId: string; gte: number; lte: number }
111
+ | {
112
+ type: 'push'
113
+ sessionId: string
114
+ /** Parsed and branded, payloads not yet validated — `type`
115
+ * autocompletes the contract's names but is any string off the
116
+ * wire; `append` rejects unknown types after the hooks run. */
117
+ events: Array<PushedEvent & { type: SuggestedEventType<D> }>
118
+ presence?: PushedPresence
119
+ transport: 'http' | 'ws'
120
+ }
121
+
122
+ /** The contract's event names for editor completion, without excluding
123
+ * the wire's reality (any string can arrive; validation is later). */
124
+ type SuggestedEventType<D extends EventDefs> =
125
+ (keyof D & string) | (string & Record<never, never>)
126
+
127
+ /** What the library did with an allowed intent — `after`'s view.
128
+ * `covered` = the closed range is fully present (an immutable slice of
129
+ * the append-only log), the one caching-relevant fact the library
130
+ * owns; policy over it belongs to the app's `after`. */
131
+ export type A2Outcome<D extends EventDefs = EventDefs> =
132
+ | { type: 'stream' }
133
+ | { type: 'history'; covered: boolean; events: ContractEvent<D>[] }
134
+ | { type: 'push'; appended: ContractEvent<D>[] }
135
+
136
+ /**
137
+ * The platform's socket-upgrade seam: given the attach callback, return
138
+ * the 101 response. On Vercel:
139
+ * `upgrade: (attach) => experimental_upgradeWebSocket(attach)`.
140
+ */
141
+ export type UpgradeFn = (
142
+ attach: (socket: A2Socket) => void,
143
+ ) => Response | Promise<Response>
144
+
145
+ const parseHistoryBounds = (
146
+ gte: string | null,
147
+ lte: string | null,
148
+ ): { gte: number; lte: number } => {
149
+ if (gte === null || lte === null) {
150
+ throw new A2Error(
151
+ 'INVALID_PAYLOAD',
152
+ 'a history read takes both gte and lte',
153
+ )
154
+ }
155
+ const bounds = { gte: Number(gte), lte: Number(lte) }
156
+ for (const [name, value] of Object.entries(bounds)) {
157
+ if (!Number.isSafeInteger(value) || value < 0) {
158
+ throw new A2Error(
159
+ 'INVALID_PAYLOAD',
160
+ `history ${name} must be a non-negative safe integer`,
161
+ )
162
+ }
163
+ }
164
+ if (bounds.gte > bounds.lte) {
165
+ throw new A2Error(
166
+ 'INVALID_PAYLOAD',
167
+ 'history gte must be less than or equal to lte',
168
+ )
169
+ }
170
+ return bounds
171
+ }
172
+
173
+ const parseResumeIndex = (raw: string | null): number => {
174
+ if (raw === null) return 0
175
+ const index = Number(raw)
176
+ if (!Number.isSafeInteger(index) || index < 0) {
177
+ throw new A2Error(
178
+ 'INVALID_PAYLOAD',
179
+ 'stream index must be a non-negative safe integer',
180
+ )
181
+ }
182
+ return index
183
+ }
184
+
185
+ /** One bag: the policy hooks and the capability options — the names
186
+ * distinguish them, so `handle(server, { presence: true })` and
187
+ * `handle(server, { before, upgrade })` both read naturally. */
188
+ export type HandleOptions<D extends EventDefs = EventDefs> = {
189
+ /** Return a Response to deny; thrown errors are treated as
190
+ * infrastructure failures and answer with the retryable wire shape. */
191
+ before?(args: {
192
+ request: Request
193
+ intent: A2Intent<D>
194
+ }): Response | undefined | void | Promise<Response | undefined | void>
195
+ after?(args: {
196
+ request: Request
197
+ intent: A2Intent<D>
198
+ outcome: A2Outcome<D>
199
+ response: Response
200
+ }): Response | undefined | void | Promise<Response | undefined | void>
201
+ upgrade?: UpgradeFn
202
+ presence?: boolean
203
+ deadline?: number
204
+ }
205
+
206
+ /**
207
+ * The session route pair as one call: `export const { GET, POST } =
208
+ * handle(server, options?)` serves the live stream (SSE GET, resumed
209
+ * at `index`), the history slice (GET with `gte`/`lte`), the push
210
+ * envelope (POST), and — with `options.upgrade` — the multiplexed
211
+ * WebSocket, all through one pair of policy hooks.
212
+ *
213
+ * Parsing is protocol, hooks are policy: a request that fails to parse
214
+ * (missing sessionId, malformed bounds, bad push envelope) answers
215
+ * INVALID_PAYLOAD on the wire before any hook runs. `before` sees
216
+ * every parsed intent — HTTP requests and socket frames alike, with
217
+ * `request` always the original (for socket frames: upgrade) Request —
218
+ * and short-circuits by returning a Response: over HTTP it is the
219
+ * response verbatim; over the socket it is translated to the wire's
220
+ * own vocabulary (subscribe → `unsubscribed { reason: 'subscribe
221
+ * rejected' }`, push → an INVALID_PAYLOAD error ack), because a
222
+ * Response body cannot cross a socket. `after` runs only where the
223
+ * library produced an HTTP response — never after a short-circuit,
224
+ * never for socket frames — and may mutate `response.headers` in place
225
+ * or return a replacement (history caching policy lives here, on
226
+ * `outcome.covered`). `options.deadline` (epoch ms) closes sockets
227
+ * cleanly ahead of a known platform deadline, so clients reconnect on
228
+ * our schedule.
229
+ */
230
+ export function handle<
231
+ D extends EventDefs,
232
+ P extends PresenceDefs = Record<never, never>,
233
+ >(
234
+ server: A2Server<D, P>,
235
+ options?: HandleOptions<D>,
236
+ ): {
237
+ GET(req: Request): Promise<Response>
238
+ POST(req: Request): Promise<Response>
239
+ } {
240
+ const presence = options?.presence === true
241
+
242
+ const before = async (
243
+ request: Request,
244
+ intent: A2Intent<D>,
245
+ ): Promise<Response | null> => {
246
+ const result = await options?.before?.({ request, intent })
247
+ return result instanceof Response ? result : null
248
+ }
249
+
250
+ const decorate = async (
251
+ request: Request,
252
+ intent: A2Intent<D>,
253
+ outcome: A2Outcome<D>,
254
+ response: Response,
255
+ ): Promise<Response> => {
256
+ const replaced = await options?.after?.({
257
+ request,
258
+ intent,
259
+ outcome,
260
+ response,
261
+ })
262
+ return replaced instanceof Response ? replaced : response
263
+ }
264
+
265
+ // Socket frames re-enter `before` with the upgrade Request — one
266
+ // policy point for both wires, and the fix for pushes that were
267
+ // gated only at subscribe time.
268
+ const attach =
269
+ (request: Request) =>
270
+ (socket: A2Socket): void => {
271
+ sessionsSocket<D>(
272
+ async (sessionId, startAfter) => {
273
+ const short = await before(request, {
274
+ type: 'stream',
275
+ sessionId,
276
+ startAfter,
277
+ transport: 'ws',
278
+ })
279
+ return short === null ? server.session(sessionId) : null
280
+ },
281
+ socket,
282
+ {
283
+ presence,
284
+ ...(options?.deadline === undefined
285
+ ? {}
286
+ : { deadline: options.deadline }),
287
+ gatePush: async (sessionId, events) => {
288
+ const short = await before(request, {
289
+ type: 'push',
290
+ sessionId,
291
+ events,
292
+ transport: 'ws',
293
+ })
294
+ // The denial Response cannot cross the socket; INVALID_PAYLOAD
295
+ // is the one caller-fault code the client never retries — a
296
+ // retryable code would spin retries against a standing denial.
297
+ return short === null
298
+ ? null
299
+ : new A2Error('INVALID_PAYLOAD', 'push rejected')
300
+ },
301
+ },
302
+ )
303
+ }
304
+
305
+ return {
306
+ async GET(req: Request): Promise<Response> {
307
+ try {
308
+ if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
309
+ const short = await before(req, { type: 'ws-upgrade' })
310
+ if (short !== null) return short
311
+ if (options?.upgrade === undefined) {
312
+ return new Response(
313
+ "WebSocket upgrade requested, but handle() has no upgrade implementation — pass options.upgrade, e.g. { upgrade: (attach) => experimental_upgradeWebSocket(attach) } from '@vercel/functions'",
314
+ { status: 426 },
315
+ )
316
+ }
317
+ return await options.upgrade(attach(req))
318
+ }
319
+ const { searchParams } = new URL(req.url)
320
+ const sessionId = searchParams.get('sessionId')
321
+ if (sessionId === null || sessionId.length === 0) {
322
+ throw new A2Error('INVALID_PAYLOAD', 'missing sessionId')
323
+ }
324
+ const gteRaw = searchParams.get('gte')
325
+ const lteRaw = searchParams.get('lte')
326
+ if (gteRaw !== null || lteRaw !== null) {
327
+ const bounds = parseHistoryBounds(gteRaw, lteRaw)
328
+ const intent: A2Intent<D> = { type: 'history', sessionId, ...bounds }
329
+ const short = await before(req, intent)
330
+ if (short !== null) return short
331
+ const events = await server.session(sessionId).history(bounds)
332
+ const covered = events.length === bounds.lte - bounds.gte + 1
333
+ return await decorate(
334
+ req,
335
+ intent,
336
+ { type: 'history', covered, events },
337
+ Response.json(events.map(eventToWire)),
338
+ )
339
+ }
340
+ const startAfter = parseResumeIndex(searchParams.get('index'))
341
+ const intent: A2Intent<D> = {
342
+ type: 'stream',
343
+ sessionId,
344
+ startAfter,
345
+ transport: 'sse',
346
+ }
347
+ const short = await before(req, intent)
348
+ if (short !== null) return short
349
+ const target = server.session(sessionId) as unknown as SocketSession
350
+ const response = sseResponse(
351
+ presence
352
+ ? target.stream({ startAfter, presence: true })
353
+ : target.stream({ startAfter }),
354
+ )
355
+ return await decorate(req, intent, { type: 'stream' }, response)
356
+ } catch (err) {
357
+ return errorResponse(err)
358
+ }
359
+ },
360
+
361
+ async POST(req: Request): Promise<Response> {
362
+ try {
363
+ const body = await parsePushBody(req)
364
+ const intent: A2Intent<D> = {
365
+ type: 'push',
366
+ sessionId: body.sessionId,
367
+ events: body.events,
368
+ ...(body.presence === undefined ? {} : { presence: body.presence }),
369
+ transport: 'http',
370
+ }
371
+ const short = await before(req, intent)
372
+ if (short !== null) return short
373
+ const session = server.session(body.sessionId)
374
+ if (body.presence !== undefined) {
375
+ const target = session as unknown as SocketSession
376
+ if (typeof target.setPresence !== 'function') {
377
+ throw invalidPushBody('presence is not declared by this contract')
378
+ }
379
+ await target.setPresence(body.presence)
380
+ }
381
+ const appended =
382
+ body.events.length === 0 ? [] : await session.append(...body.events)
383
+ return await decorate(
384
+ req,
385
+ intent,
386
+ { type: 'push', appended },
387
+ Response.json(appended),
388
+ )
389
+ } catch (err) {
390
+ return errorResponse(err)
391
+ }
392
+ },
393
+ }
394
+ }
@@ -0,0 +1,53 @@
1
+ import { A2Error } from './errors.ts'
2
+ import type { AppendEvent, StoredEvent } from './store.ts'
3
+
4
+ const canonicalJson = (value: unknown): string =>
5
+ JSON.stringify(value, (_key, candidate: unknown) => {
6
+ if (
7
+ candidate === null ||
8
+ typeof candidate !== 'object' ||
9
+ Array.isArray(candidate)
10
+ ) {
11
+ return candidate
12
+ }
13
+ return Object.fromEntries(
14
+ Object.entries(candidate).toSorted(([left], [right]) =>
15
+ left < right ? -1 : left > right ? 1 : 0,
16
+ ),
17
+ )
18
+ }) ?? 'null'
19
+
20
+ export const idempotentReplay = (
21
+ events: readonly AppendEvent[],
22
+ existing: readonly StoredEvent[],
23
+ ): StoredEvent[] => {
24
+ const byId = new Map(existing.map((event) => [event.id, event]))
25
+ const ordered = events.map((event) =>
26
+ event.id === undefined ? undefined : byId.get(event.id),
27
+ )
28
+ const firstIndex = ordered[0]?.index
29
+ const batchSize = events[0]?.cause?.batchSize
30
+
31
+ if (
32
+ firstIndex === undefined ||
33
+ (batchSize !== undefined && batchSize !== events.length) ||
34
+ ordered.some((stored, index) => {
35
+ const event = events[index]!
36
+ return (
37
+ stored === undefined ||
38
+ stored.index !== firstIndex + index ||
39
+ stored.type !== event.type ||
40
+ stored.lane !== (event.lane ?? null) ||
41
+ stored.cause?.batchSize !== event.cause?.batchSize ||
42
+ canonicalJson(stored.payload) !== canonicalJson(event.payload)
43
+ )
44
+ })
45
+ ) {
46
+ throw new A2Error(
47
+ 'PARTIAL_DUPLICATE_BATCH',
48
+ 'already-appended events do not match the retried batch',
49
+ )
50
+ }
51
+
52
+ return ordered as StoredEvent[]
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * a2 — a durable event log for serverless apps.
3
+ *
4
+ * This entry point is isomorphic by construction: the contract (the
5
+ * vocabulary everything shares), reducers derived from it, and the
6
+ * error type. Implementing a contract — storage, handlers, sessions —
7
+ * lives in experimental-a2/server, which never enters a browser bundle.
8
+ */
9
+
10
+ export { contract } from './contract.ts'
11
+ export type {
12
+ AppendInput,
13
+ Contract,
14
+ ContractEvent,
15
+ EventDefs,
16
+ EventBatchFactory,
17
+ PresenceDefs,
18
+ PresenceMap,
19
+ PresencePatch,
20
+ PresenceSnapshot,
21
+ ReducerOptions,
22
+ } from './contract.ts'
23
+
24
+ export type { Reducer, ReducerBuilder } from './reducer.ts'
25
+
26
+ export { A2Error } from './errors.ts'
27
+ export type { A2ErrorCode } from './errors.ts'
28
+
29
+ export type {
30
+ A2AttributeValue,
31
+ A2SpanHandle,
32
+ A2SpanName,
33
+ A2Telemetry,
34
+ } from './telemetry.ts'
35
+
36
+ export type { Event } from './store.ts'
37
+ export type { StandardSchemaV1 } from './standard-schema.ts'
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Private bridge between `createServer` and `experimental-a2/devtools/server`.
3
+ * Servers keep their store closure private; the WeakMap grants A2's own
4
+ * read-only devtools entry point access without adding public methods to
5
+ * application server handles.
6
+ */
7
+
8
+ import type { StoredEvent, StoredSessionPage, StoredSnapshot } from './store.ts'
9
+
10
+ export class InspectionUnsupportedError extends Error {}
11
+
12
+ export type ServerInspection = {
13
+ listSessions(options: {
14
+ cursor?: string
15
+ limit: number
16
+ }): Promise<StoredSessionPage>
17
+ readSessionPage(
18
+ sessionId: string,
19
+ options: { afterIndex: number; throughIndex?: number; limit: number },
20
+ ): Promise<{
21
+ events: StoredEvent[]
22
+ snapshots: StoredSnapshot[]
23
+ throughIndex: number
24
+ nextIndex: number | null
25
+ }>
26
+ }
27
+
28
+ export const serverInspection: WeakMap<object, ServerInspection> = new WeakMap()
29
+
30
+ /** Owned live-inspection cadence; mutable only for white-box tests. */
31
+ export const DEVTOOLS_TIMINGS: {
32
+ activeMs: number
33
+ idleMs: number
34
+ heartbeatMs: number
35
+ } = {
36
+ activeMs: 250,
37
+ idleMs: 1_000,
38
+ heartbeatMs: 15_000,
39
+ }