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
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Private seams shared by a2 entry points and a2's own tests. Not exported
3
+ * from the package. Nothing here is public API.
4
+ */
5
+
6
+ import type { ScheduledEvent } from './scheduler-task.ts'
7
+ import type { A2Scheduler, DrainableServer } from './server.ts'
8
+
9
+ export type DrainOutcome = 'busy' | 'stalled' | 'settled'
10
+
11
+ /**
12
+ * A null-prototype record for maps keyed by caller-controlled strings
13
+ * (participant ids, presence field names). On a normal object,
14
+ * assigning to '__proto__' rewrites the prototype instead of creating
15
+ * an own property — the prototype-pollution class. Null-prototype
16
+ * objects still satisfy Record types and JSON.stringify cleanly.
17
+ */
18
+ export function nullProtoRecord<T extends object>(): T {
19
+ return Object.create(null) as T
20
+ }
21
+
22
+ /**
23
+ * ECMA-262's maximum Date value (±8.64e15 ms). A presence stamp past
24
+ * it becomes an Invalid Date whose toISOString throws — one hostile
25
+ * stamp would poison every subscriber's snapshot frame — so both
26
+ * seams bound `at` to the representable range.
27
+ */
28
+ export const MAX_DATE_MS = 8_640_000_000_000_000
29
+
30
+ /**
31
+ * Participant ids that collide with Object.prototype plumbing.
32
+ * Null-prototype construction already defuses them, but they are
33
+ * rejected at both seams (`parsePushBody`, `setPresence`) so a hostile
34
+ * id never propagates into userland maps either.
35
+ */
36
+ export const RESERVED_PARTICIPANT_IDS: ReadonlySet<string> = new Set([
37
+ '__proto__',
38
+ 'constructor',
39
+ 'prototype',
40
+ ])
41
+
42
+ export type DrainResult = {
43
+ settled: boolean
44
+ outcome: DrainOutcome
45
+ /** Existing successor arm a scheduler callback may join before acking. */
46
+ schedulerArm?: Promise<void>
47
+ }
48
+
49
+ export type ServerInternals = {
50
+ /**
51
+ * Await every in-flight background drain this machine has scheduled,
52
+ * including ones scheduled while waiting. Lets tests flush the
53
+ * fire-and-forget inline path deterministically — no sleeps.
54
+ */
55
+ settle(): Promise<void>
56
+ /** The public `server.drain()` result deliberately remains `{ settled }`. */
57
+ schedulerDrain(
58
+ sessionId: string,
59
+ opts?: { watchdogDueAt?: number },
60
+ ): Promise<DrainResult>
61
+ /** Deliver a scheduler-owned delayed append through the normal root path. */
62
+ schedulerAppend(
63
+ sessionId: string,
64
+ events: readonly ScheduledEvent[],
65
+ ): Promise<void>
66
+ }
67
+
68
+ export const serverInternals: WeakMap<object, ServerInternals> = new WeakMap()
69
+
70
+ type ServerSchedulerBinding = {
71
+ scheduler: A2Scheduler | undefined
72
+ }
73
+
74
+ /** Scheduler configuration captured only for servers made by `createServer`. */
75
+ export const serverSchedulerBindings: WeakMap<object, ServerSchedulerBinding> =
76
+ new WeakMap()
77
+
78
+ const objectLike = (value: unknown): value is object =>
79
+ (typeof value === 'object' && value !== null) || typeof value === 'function'
80
+
81
+ /** The ambient value delivered to `handlerContext` during a tool execution. */
82
+ export type AmbientToolScope = {
83
+ /** Executing server identity; a test scope from `runWithHandlerContext` has none. */
84
+ contract?: { readonly name: string }
85
+ context: unknown
86
+ }
87
+
88
+ /**
89
+ * Structural AsyncLocalStorage surface for the ambient tool scope. The
90
+ * instance is created by the server-only AI entry point (the only place
91
+ * `node:async_hooks` may be imported) and shared through a global
92
+ * symbol slot, so `handlerContext` in the isomorphic AI entry point and
93
+ * independently bundled A2 copies all read the same scope.
94
+ */
95
+ export type AmbientToolScopeStorage = {
96
+ getStore(): AmbientToolScope | undefined
97
+ run<R>(store: AmbientToolScope, callback: () => R): R
98
+ }
99
+
100
+ const TOOL_SCOPE_STORAGE = Symbol.for('experimental-a2.ai.tool-scope')
101
+
102
+ export function ambientToolScopeStorage(): AmbientToolScopeStorage | undefined {
103
+ const value = Reflect.get(globalThis, TOOL_SCOPE_STORAGE)
104
+ if (
105
+ !objectLike(value) ||
106
+ typeof Reflect.get(value, 'getStore') !== 'function' ||
107
+ typeof Reflect.get(value, 'run') !== 'function'
108
+ ) {
109
+ return undefined
110
+ }
111
+ return value as AmbientToolScopeStorage
112
+ }
113
+
114
+ export function installAmbientToolScopeStorage(
115
+ create: () => AmbientToolScopeStorage,
116
+ ): AmbientToolScopeStorage {
117
+ const existing = ambientToolScopeStorage()
118
+ if (existing !== undefined) return existing
119
+ const storage = create()
120
+ Reflect.set(globalThis, TOOL_SCOPE_STORAGE, storage)
121
+ return storage
122
+ }
123
+
124
+ export type SchedulerSendFailureDisposition = 'retryable' | 'terminal'
125
+
126
+ type SchedulerSendFailureMarker = {
127
+ readonly version: 1
128
+ readonly pending: SchedulerSendFailureDisposition[]
129
+ readonly active: SchedulerSendFailureDisposition[]
130
+ }
131
+
132
+ const SCHEDULER_SEND_FAILURE = Symbol.for(
133
+ 'experimental-a2.scheduler.send-failure',
134
+ )
135
+
136
+ const schedulerSendFailureMarker = (
137
+ value: unknown,
138
+ ): SchedulerSendFailureMarker | undefined => {
139
+ if (!objectLike(value)) return undefined
140
+ const marker = Reflect.get(value, SCHEDULER_SEND_FAILURE)
141
+ if (
142
+ !objectLike(marker) ||
143
+ Reflect.get(marker, 'version') !== 1 ||
144
+ !Array.isArray(Reflect.get(marker, 'pending')) ||
145
+ !Array.isArray(Reflect.get(marker, 'active'))
146
+ ) {
147
+ return undefined
148
+ }
149
+ return marker as SchedulerSendFailureMarker
150
+ }
151
+
152
+ const schedulerFailureObject = (error: unknown): object => {
153
+ if (
154
+ objectLike(error) &&
155
+ (schedulerSendFailureMarker(error) !== undefined ||
156
+ Object.isExtensible(error))
157
+ ) {
158
+ return error
159
+ }
160
+ return new Error(String(error), { cause: error })
161
+ }
162
+
163
+ const schedulerSendFailureState = (
164
+ error: object,
165
+ ): SchedulerSendFailureMarker => {
166
+ const existing = schedulerSendFailureMarker(error)
167
+ if (existing !== undefined) return existing
168
+ const marker: SchedulerSendFailureMarker = {
169
+ version: 1,
170
+ pending: [],
171
+ active: [],
172
+ }
173
+ Object.defineProperty(error, SCHEDULER_SEND_FAILURE, {
174
+ value: marker,
175
+ enumerable: false,
176
+ configurable: true,
177
+ writable: false,
178
+ })
179
+ return marker
180
+ }
181
+
182
+ const clearSchedulerSendFailureState = (
183
+ error: object,
184
+ marker: SchedulerSendFailureMarker,
185
+ ): void => {
186
+ if (marker.pending.length === 0 && marker.active.length === 0) {
187
+ Reflect.deleteProperty(error, SCHEDULER_SEND_FAILURE)
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Classify an adapter error before throwing it. Unclassified adapter failures
193
+ * are retryable because the caller cannot know whether a remote send landed.
194
+ */
195
+ export function classifySchedulerSendFailure(
196
+ error: unknown,
197
+ disposition: SchedulerSendFailureDisposition,
198
+ ): unknown {
199
+ const classified = schedulerFailureObject(error)
200
+ schedulerSendFailureState(classified).pending.push(disposition)
201
+ return classified
202
+ }
203
+
204
+ /** Consume a pending adapter classification and count this rejected send. */
205
+ export function markSchedulerSendFailure(error: unknown): unknown {
206
+ const marked = schedulerFailureObject(error)
207
+ const marker = schedulerSendFailureState(marked)
208
+ marker.active.push(marker.pending.pop() ?? 'retryable')
209
+ return marked
210
+ }
211
+
212
+ export function consumeSchedulerSendFailure(
213
+ error: unknown,
214
+ ): SchedulerSendFailureDisposition | undefined {
215
+ if (!objectLike(error)) return undefined
216
+ const marker = schedulerSendFailureMarker(error)
217
+ const disposition = marker?.active.pop()
218
+ if (marker !== undefined) clearSchedulerSendFailureState(error, marker)
219
+ return disposition
220
+ }
221
+
222
+ const isDrainableServer = (value: unknown): value is DrainableServer => {
223
+ if (value === null || typeof value !== 'object') return false
224
+ const contract = Reflect.get(value, 'contract')
225
+ return (
226
+ contract !== null &&
227
+ typeof contract === 'object' &&
228
+ typeof Reflect.get(contract, 'name') === 'string' &&
229
+ typeof Reflect.get(value, 'drain') === 'function'
230
+ )
231
+ }
232
+
233
+ /**
234
+ * Validate the server list captured by a scheduler route at construction time.
235
+ * Built-in adapters keep their low-level target seam structural for custom
236
+ * integrations. The application-facing `schedulerHandler` uses strict mode
237
+ * so only servers made by this A2 copy reach a configured adapter.
238
+ */
239
+ export function assertSchedulerTargets(
240
+ servers: readonly unknown[],
241
+ options: { allowStructural: boolean },
242
+ ): asserts servers is readonly DrainableServer[] {
243
+ const contracts = new Set<string>()
244
+ for (const target of servers) {
245
+ if (!isDrainableServer(target)) {
246
+ throw new TypeError('a2 scheduler: expected a server from createServer()')
247
+ }
248
+ const name = target.contract.name
249
+ if (contracts.has(name)) {
250
+ throw new TypeError(
251
+ `a2 scheduler: duplicate server contract '${name}' in one handler`,
252
+ )
253
+ }
254
+ contracts.add(name)
255
+
256
+ const binding = serverSchedulerBindings.get(target)
257
+ if (!binding) {
258
+ if (!options.allowStructural) {
259
+ throw new TypeError(
260
+ `a2 scheduler: server for contract '${name}' was not created by createServer()`,
261
+ )
262
+ }
263
+ continue
264
+ }
265
+ }
266
+ }
267
+
268
+ /** Require every real server to share one exact configured scheduler. */
269
+ export function assertServerSchedulerBindings(
270
+ scheduler: A2Scheduler,
271
+ servers: readonly DrainableServer[],
272
+ ): void {
273
+ for (const server of servers) {
274
+ const binding = serverSchedulerBindings.get(server)
275
+ if (!binding?.scheduler) {
276
+ throw new TypeError(
277
+ `a2 scheduler: server for contract '${server.contract.name}' has no scheduler configured`,
278
+ )
279
+ }
280
+ if (binding.scheduler !== scheduler) {
281
+ throw new TypeError(
282
+ `a2 scheduler: server for contract '${server.contract.name}' is configured with a different scheduler instance`,
283
+ )
284
+ }
285
+ }
286
+ }
287
+
288
+ /** Resolve the scheduler privately bound to a real A2 server. */
289
+ export function schedulerForServer(server: unknown): A2Scheduler {
290
+ if (!isDrainableServer(server)) {
291
+ throw new TypeError('a2 scheduler: expected a server from createServer()')
292
+ }
293
+ const binding = serverSchedulerBindings.get(server)
294
+ if (!binding) {
295
+ throw new TypeError(
296
+ `a2 scheduler: server for contract '${server.contract.name}' was not created by createServer()`,
297
+ )
298
+ }
299
+ if (!binding.scheduler) {
300
+ throw new TypeError(
301
+ `a2 scheduler: server for contract '${server.contract.name}' has no scheduler configured`,
302
+ )
303
+ }
304
+ return binding.scheduler
305
+ }
306
+
307
+ /**
308
+ * Drain timing knobs. Mutable only as a white-box test seam. Claim
309
+ * heartbeats run on real timers, so tests shrink these to keep suites
310
+ * fast. Production always uses the defaults.
311
+ */
312
+ export const DRAIN_TIMINGS: {
313
+ /** How long a claim lives without renewal. */
314
+ claimTtlMs: number
315
+ /** Heartbeat cadence while a handler is running. */
316
+ claimHeartbeatMs: number
317
+ /** Base delay before retrying a failed renewal while the lease lives. */
318
+ renewRetryMs: number
319
+ /** Delay between a claim's expiry and its scheduler watchdog. */
320
+ watchdogGraceMs: number
321
+ /** Maximum time a top-level append waits for its initial scheduler arm. */
322
+ schedulerArmTimeoutMs: number
323
+ } = {
324
+ claimTtlMs: 5_000,
325
+ claimHeartbeatMs: 2_000,
326
+ renewRetryMs: 400,
327
+ watchdogGraceMs: 1_000,
328
+ schedulerArmTimeoutMs: 2_000,
329
+ }
330
+
331
+ /**
332
+ * Stream liveness knobs — the server-side SSE heartbeat and the
333
+ * client-side stall watchdog. Paired by design: the client declares a
334
+ * connection dead after roughly two missed heartbeats. Mutable only as
335
+ * a white-box test seam; production always uses the defaults.
336
+ */
337
+ /**
338
+ * Poll cadence for the poll-based store streams (sqlite, postgres) —
339
+ * owned, not configurable: adaptive polling removed the operator's
340
+ * reason to tune it (the floor keeps active streams smooth; the
341
+ * ceiling bounds idle cost). Mutable only as a white-box test seam so
342
+ * suites don't wait out real idle gaps.
343
+ */
344
+ export const POLL_TIMINGS: {
345
+ /** Gap between polls while a session is producing events. */
346
+ activeFloorMs: number
347
+ /** The idle ceiling — the longest gap between polls. */
348
+ idleCeilingMs: number
349
+ } = {
350
+ activeFloorMs: 25,
351
+ idleCeilingMs: 250,
352
+ }
353
+
354
+ /**
355
+ * Safety-read cadence for the notify-driven redis live stream. A
356
+ * parked feed re-reads the store at this interval even without a
357
+ * pub/sub wake-up, so a lost notification (subscriber reconnect gap,
358
+ * dropped PUBLISH) costs latency, never an event. Mutable only as a
359
+ * white-box test seam.
360
+ */
361
+ export const NOTIFY_TIMINGS: {
362
+ safetyReadMs: number
363
+ } = {
364
+ safetyReadMs: 10_000,
365
+ }
366
+
367
+ export const STREAM_TIMINGS: {
368
+ /** Cadence of `: ping` comment frames from `sseResponse`. */
369
+ sseHeartbeatMs: number
370
+ /** How far ahead of an invocation deadline an SSE response closes. */
371
+ sseDeadlineGraceMs: number
372
+ /** Client: silence longer than this means the connection is dead. */
373
+ stallTimeoutMs: number
374
+ /** Client reconnect backoff: first retry delay, doubling per attempt. */
375
+ reconnectBaseMs: number
376
+ /** Client reconnect backoff ceiling. */
377
+ reconnectMaxMs: number
378
+ } = {
379
+ sseHeartbeatMs: 15_000,
380
+ sseDeadlineGraceMs: 1_000,
381
+ stallTimeoutMs: 35_000,
382
+ reconnectBaseMs: 500,
383
+ reconnectMaxMs: 5_000,
384
+ }
385
+
386
+ /**
387
+ * WebSocket down-pump backpressure — `sessionsSocket` parks its pull
388
+ * loop while the socket reports more buffered bytes than the
389
+ * high-water mark and re-checks on the poll cadence, so a slow reader
390
+ * applies backpressure to the store subscription instead of buffering
391
+ * without bound in the socket. Acks and heartbeats stay exempt: they
392
+ * are small and correctness-relevant. Mutable only as a white-box test
393
+ * seam.
394
+ */
395
+ export const SOCKET_TIMINGS: {
396
+ highWaterMarkBytes: number
397
+ resumePollMs: number
398
+ /** Up-lane bound: concurrent push/presence handlers per socket. POST
399
+ * gets per-request platform limits for free; the socket must bring
400
+ * its own. Over the cap, pushes are answered with a retryable error
401
+ * ack and presence frames are shed (that plane repaints). */
402
+ maxInFlightMessages: number
403
+ /** Hard ceiling on `bufferedAmount`: past it the peer is a
404
+ * non-reader (acks and pings are park-exempt, so only a peer that
405
+ * never reads can reach it) and the socket closes 1008. */
406
+ disconnectWaterMarkBytes: number
407
+ } = {
408
+ highWaterMarkBytes: 1_048_576,
409
+ resumePollMs: 25,
410
+ maxInFlightMessages: 32,
411
+ disconnectWaterMarkBytes: 8_388_608,
412
+ }
413
+
414
+ /**
415
+ * Client presence send cadence — a leading send immediately, then at
416
+ * most one merged send per interval (~15Hz). Owned, not configurable
417
+ * (decision #36's rule): coupling the cadence to the POST ack tied
418
+ * remote cursor smoothness to RTT, and the ack serves no correctness
419
+ * purpose on a fire-and-forget LWW plane. Mutable only as a white-box
420
+ * test seam.
421
+ */
422
+ export const PRESENCE_TIMINGS: {
423
+ sendIntervalMs: number
424
+ } = {
425
+ sendIntervalMs: 66,
426
+ }
package/src/otel.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * experimental-a2/otel — OpenTelemetry adapter for the A2Telemetry interface.
3
+ *
4
+ * Peer-depends on `@opentelemetry/api` (optional). Spans nest through
5
+ * the active context, so with a context manager registered (any real
6
+ * OTel setup, e.g. `@vercel/otel`) the happy path lands in one trace:
7
+ * request → a2.append → a2.drain → a2.event → the handler's own spans.
8
+ */
9
+
10
+ import {
11
+ SpanStatusCode,
12
+ trace,
13
+ type Span,
14
+ type Tracer,
15
+ } from '@opentelemetry/api'
16
+ import type { A2Telemetry } from './telemetry.ts'
17
+
18
+ export type OtelOptions = {
19
+ /** Bring your own tracer; defaults to `trace.getTracer(tracerName)`. */
20
+ tracer?: Tracer
21
+ /** Tracer name for the global provider. Default `'a2'`. */
22
+ tracerName?: string
23
+ }
24
+
25
+ const fail = (span: Span, error: unknown): void => {
26
+ span.recordException(
27
+ error instanceof Error ? error : new Error(String(error)),
28
+ )
29
+ span.setStatus({
30
+ code: SpanStatusCode.ERROR,
31
+ message: error instanceof Error ? error.message : String(error),
32
+ })
33
+ }
34
+
35
+ export function otel(options: OtelOptions = {}): A2Telemetry {
36
+ const tracer = options.tracer ?? trace.getTracer(options.tracerName ?? 'a2')
37
+
38
+ return {
39
+ span(name, attributes, fn) {
40
+ return tracer.startActiveSpan(name, { attributes }, async (span) => {
41
+ try {
42
+ return await fn({
43
+ setAttribute: (key, value) => {
44
+ span.setAttribute(key, value)
45
+ },
46
+ recordError: (error) => {
47
+ fail(span, error)
48
+ },
49
+ })
50
+ } catch (error) {
51
+ fail(span, error)
52
+ throw error
53
+ } finally {
54
+ span.end()
55
+ }
56
+ })
57
+ },
58
+ }
59
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Platform seams, read from the ambient request context — the same
3
+ * channel `@vercel/functions` uses (`Symbol.for('@vercel/request-context')`)
4
+ * — with no dependency on that package. Where no context exists (local
5
+ * dev, long-lived servers, other clouds) everything degrades cleanly:
6
+ * `waitUntil` becomes a no-op (background work is tracked in-process
7
+ * anyway) and the deadline is unknown (claim watchdogs use their ordinary
8
+ * TTL window).
9
+ *
10
+ * Tests exercise these paths by installing a real context object on the
11
+ * symbol — simulating the platform contract, not mocking a2.
12
+ */
13
+
14
+ type RequestContext = {
15
+ headers?: Readonly<Record<string, string>>
16
+ waitUntil?: (promise: Promise<unknown>) => void
17
+ /**
18
+ * When the platform will terminate this invocation (epoch ms, ISO
19
+ * string, or Date) — on Vercel this includes `waitUntil` work.
20
+ */
21
+ deadline?: number | string | Date
22
+ }
23
+
24
+ const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context')
25
+
26
+ function requestContext(): RequestContext {
27
+ try {
28
+ const holder = Reflect.get(globalThis, SYMBOL_FOR_REQ_CONTEXT) as
29
+ { get?: () => RequestContext | undefined } | undefined
30
+ return holder?.get?.() ?? {}
31
+ } catch {
32
+ return {}
33
+ }
34
+ }
35
+
36
+ /** The current invocation's Vercel deployment identity, when available. */
37
+ export function platformVercelOidcToken(): string | null {
38
+ try {
39
+ const token = requestContext().headers?.['x-vercel-oidc-token']
40
+ return typeof token === 'string' && token.length > 0 ? token : null
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ export function platformWaitUntil(promise: Promise<unknown>): void {
47
+ try {
48
+ requestContext().waitUntil?.(promise)
49
+ } catch {
50
+ // Best-effort — the promise is tracked in-process either way.
51
+ }
52
+ }
53
+
54
+ /** The invocation's termination time in epoch ms, or null if unknowable. */
55
+ export function invocationDeadlineMs(): number | null {
56
+ const deadline = requestContext().deadline
57
+ if (deadline === undefined || deadline === null) return null
58
+ const ms = new Date(deadline as number | string | Date).getTime()
59
+ return Number.isNaN(ms) ? null : ms
60
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The push envelope's trust boundary — parsing and provenance-branding
3
+ * for `{ sessionId, events, presence? }`, shared verbatim by the HTTP
4
+ * POST lane and the socket's push/presence frames so INVALID_PAYLOAD
5
+ * shapes are one implementation. Internal module — `handle` (and the
6
+ * socket handler) compose it; it is not part of the public surface.
7
+ */
8
+
9
+ import { A2Error } from './errors.ts'
10
+ import { MAX_DATE_MS, RESERVED_PARTICIPANT_IDS } from './internal.ts'
11
+ import type { PushedEvent, PushedPresence } from './server.ts'
12
+
13
+ export const invalidPushBody = (message: string): A2Error =>
14
+ new A2Error('INVALID_PAYLOAD', `malformed push body: ${message}`)
15
+
16
+ const invalid = invalidPushBody
17
+
18
+ export type PushBody = {
19
+ sessionId: string
20
+ /** Branded: `session.append` accepts these directly (the push route
21
+ * path); schema validation still happens inside `append`. Empty only
22
+ * for a presence-only push. */
23
+ events: PushedEvent[]
24
+ /** Branded like `events`: `session.setPresence` accepts it whole;
25
+ * field validation still happens inside `setPresence`. */
26
+ presence?: PushedPresence
27
+ }
28
+
29
+ export function parsePresenceSibling(
30
+ value: unknown,
31
+ ): PushedPresence | undefined {
32
+ if (value === undefined) return undefined
33
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
34
+ throw invalid('presence must be an object when present')
35
+ }
36
+ const { participant, values, seen, at } = value as Record<string, unknown>
37
+ if (typeof participant !== 'string' || participant.length === 0) {
38
+ throw invalid('presence.participant must be a non-empty string')
39
+ }
40
+ if (RESERVED_PARTICIPANT_IDS.has(participant)) {
41
+ throw invalid(`presence.participant must not be '${participant}'`)
42
+ }
43
+ if (values === null || typeof values !== 'object' || Array.isArray(values)) {
44
+ throw invalid('presence.values must be an object')
45
+ }
46
+ if (
47
+ seen !== undefined &&
48
+ (typeof seen !== 'number' || !Number.isFinite(seen) || seen < 0)
49
+ ) {
50
+ throw invalid('presence.seen must be a non-negative number when present')
51
+ }
52
+ if (
53
+ at !== undefined &&
54
+ (typeof at !== 'number' ||
55
+ !Number.isFinite(at) ||
56
+ at < 0 ||
57
+ at > MAX_DATE_MS)
58
+ ) {
59
+ throw invalid(
60
+ 'presence.at must be a non-negative epoch-milliseconds timestamp within the Date range when present',
61
+ )
62
+ }
63
+ const out: {
64
+ participant: string
65
+ values: Record<string, unknown>
66
+ seen?: number
67
+ at?: number
68
+ } = { participant, values: values as Record<string, unknown> }
69
+ if (seen !== undefined) out.seen = seen
70
+ if (at !== undefined) out.at = at
71
+ Object.defineProperty(out, '~a2.pushed', { value: true })
72
+ return out as PushedPresence
73
+ }
74
+
75
+ /** The events half of the envelope — shared verbatim by the POST body
76
+ * and the socket's `push` frames, so the provenance brand and the
77
+ * INVALID_PAYLOAD shapes are one implementation. */
78
+ export function parsePushEvents(events: unknown): PushedEvent[] {
79
+ if (!Array.isArray(events) || events.length === 0) {
80
+ throw invalid('events must be a non-empty array')
81
+ }
82
+ return events.map((event, i) => {
83
+ if (event === null || typeof event !== 'object') {
84
+ throw invalid(`events[${i}] must be an object`)
85
+ }
86
+ const { type, payload, id } = event as Record<string, unknown>
87
+ if (typeof type !== 'string' || type.length === 0) {
88
+ throw invalid(`events[${i}].type must be a non-empty string`)
89
+ }
90
+ if (id !== undefined && typeof id !== 'string') {
91
+ throw invalid(`events[${i}].id must be a string when present`)
92
+ }
93
+ const out: { type: string; payload: unknown; id?: string } = {
94
+ type,
95
+ payload,
96
+ }
97
+ if (id !== undefined) out.id = id
98
+ Object.defineProperty(out, '~a2.pushed', { value: true })
99
+ return out as PushedEvent
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Validate the push envelope — `{ sessionId, events, presence? }` —
105
+ * throwing `INVALID_PAYLOAD` on a malformed body. Payload validation
106
+ * against the machine's schemas happens in `append` and `setPresence`,
107
+ * not here. Either plane may appear alone: a presence-only push has no
108
+ * (or empty) `events`.
109
+ */
110
+ export async function parsePushBody(req: Request): Promise<PushBody> {
111
+ let body: unknown
112
+ try {
113
+ body = await req.json()
114
+ } catch (cause) {
115
+ throw new A2Error('INVALID_PAYLOAD', 'push body is not valid JSON', {
116
+ cause,
117
+ })
118
+ }
119
+ if (body === null || typeof body !== 'object') {
120
+ throw invalid('expected an object')
121
+ }
122
+ const { sessionId, events, presence } = body as Record<string, unknown>
123
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
124
+ throw invalid('sessionId must be a non-empty string')
125
+ }
126
+ const parsedPresence = parsePresenceSibling(presence)
127
+ if (
128
+ parsedPresence !== undefined &&
129
+ (events === undefined || (Array.isArray(events) && events.length === 0))
130
+ ) {
131
+ return { sessionId, events: [], presence: parsedPresence }
132
+ }
133
+ const parsed = parsePushEvents(events)
134
+ return parsedPresence !== undefined
135
+ ? { sessionId, events: parsed, presence: parsedPresence }
136
+ : { sessionId, events: parsed }
137
+ }