experimental-a2 0.6.0 → 0.7.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 (145) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/ai-server.d.ts +4 -5
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +8 -7
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +334 -2
  7. package/dist/ai.d.ts.map +1 -0
  8. package/dist/ai.js +1 -1
  9. package/dist/client.d.ts +202 -2
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +1025 -1
  12. package/dist/client.js.map +1 -0
  13. package/dist/errors-BQuJpe82.js.map +1 -1
  14. package/dist/index.d.ts +22 -3
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
  17. package/dist/internal-DRXJ56EI.js.map +1 -0
  18. package/dist/react.d.ts +1 -1
  19. package/dist/react.js +1 -1
  20. package/dist/scheduler-qstash.d.ts +3 -3
  21. package/dist/scheduler-qstash.js +4 -5
  22. package/dist/scheduler-qstash.js.map +1 -1
  23. package/dist/scheduler-vercel.d.ts +2 -2
  24. package/dist/scheduler-vercel.js +4 -4
  25. package/dist/scheduler-vercel.js.map +1 -1
  26. package/dist/{server-Duw6MVlB.js → server-286j79Mt.js} +708 -79
  27. package/dist/server-286j79Mt.js.map +1 -0
  28. package/dist/{server-DpvjhdoE.d.ts → server-DgXmORIq.d.ts} +67 -49
  29. package/dist/server-DgXmORIq.d.ts.map +1 -0
  30. package/dist/server.d.ts +3 -3
  31. package/dist/server.js +1 -1
  32. package/dist/store-N8PXxDAS.js.map +1 -1
  33. package/dist/{store-DysUkTH3.d.ts → store-flRz1OWh.d.ts} +2 -57
  34. package/dist/store-flRz1OWh.d.ts.map +1 -0
  35. package/dist/store-memory.d.ts +1 -1
  36. package/dist/store-memory.d.ts.map +1 -1
  37. package/dist/store-memory.js +1 -59
  38. package/dist/store-memory.js.map +1 -1
  39. package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
  40. package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
  41. package/dist/store-postgres.d.ts +1 -1
  42. package/dist/store-postgres.js +1 -81
  43. package/dist/store-postgres.js.map +1 -1
  44. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DEYO8Ryv.js} +48 -134
  45. package/dist/store-redis-core-DEYO8Ryv.js.map +1 -0
  46. package/dist/store-redis-http.d.ts +1 -1
  47. package/dist/store-redis-http.js +2 -3
  48. package/dist/store-redis-http.js.map +1 -1
  49. package/dist/store-redis.d.ts +1 -1
  50. package/dist/store-redis.js +3 -4
  51. package/dist/store-redis.js.map +1 -1
  52. package/dist/store-sqlite.d.ts +1 -1
  53. package/dist/store-sqlite.d.ts.map +1 -1
  54. package/dist/store-sqlite.js +1 -72
  55. package/dist/store-sqlite.js.map +1 -1
  56. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  57. package/dist/wire-B6te_wns.js.map +1 -0
  58. package/docs/guides/03-react.mdx +20 -28
  59. package/docs/guides/05-production.mdx +9 -11
  60. package/docs/guides/06-ai-agents.mdx +8 -14
  61. package/docs/guides/09-presence.mdx +14 -19
  62. package/docs/guides/10-transports.mdx +104 -86
  63. package/docs/reference/01-api.mdx +143 -277
  64. package/docs/reference/02-errors.mdx +5 -7
  65. package/package.json +1 -14
  66. package/src/ai-server.ts +9 -5
  67. package/src/client.ts +2 -2
  68. package/src/errors.ts +1 -0
  69. package/src/internal.ts +3 -62
  70. package/src/push-envelope.ts +24 -21
  71. package/src/scheduler-qstash.ts +3 -3
  72. package/src/scheduler-vercel.ts +2 -2
  73. package/src/server-fetch.ts +344 -0
  74. package/src/server.ts +73 -225
  75. package/src/session-socket.ts +36 -20
  76. package/src/sse.ts +2 -2
  77. package/src/store-memory.ts +0 -81
  78. package/src/store-postgres.ts +0 -100
  79. package/src/store-redis-core.ts +47 -211
  80. package/src/store-redis-http.ts +0 -1
  81. package/src/store-redis.ts +0 -1
  82. package/src/store-sqlite.ts +0 -119
  83. package/src/store.ts +0 -60
  84. package/src/wire.ts +2 -1
  85. package/dist/ai-D_PGS-JR.d.ts +0 -334
  86. package/dist/ai-D_PGS-JR.d.ts.map +0 -1
  87. package/dist/cli-B3VuxoDe.js +0 -599
  88. package/dist/cli-B3VuxoDe.js.map +0 -1
  89. package/dist/cli-bin.d.ts +0 -1
  90. package/dist/cli-bin.js +0 -7
  91. package/dist/cli-bin.js.map +0 -1
  92. package/dist/cli.d.ts +0 -20
  93. package/dist/cli.d.ts.map +0 -1
  94. package/dist/cli.js +0 -2
  95. package/dist/client-CdMqi7mC.d.ts +0 -202
  96. package/dist/client-CdMqi7mC.d.ts.map +0 -1
  97. package/dist/client-Dj5d3SP_.js +0 -1026
  98. package/dist/client-Dj5d3SP_.js.map +0 -1
  99. package/dist/devtools-J_jZ2vQf.d.ts +0 -152
  100. package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
  101. package/dist/devtools-kJJaORn-.js +0 -340
  102. package/dist/devtools-kJJaORn-.js.map +0 -1
  103. package/dist/devtools-server.browser.d.ts +0 -1
  104. package/dist/devtools-server.browser.js +0 -6
  105. package/dist/devtools-server.browser.js.map +0 -1
  106. package/dist/devtools-server.d.ts +0 -23
  107. package/dist/devtools-server.d.ts.map +0 -1
  108. package/dist/devtools-server.js +0 -1270
  109. package/dist/devtools-server.js.map +0 -1
  110. package/dist/devtools.d.ts +0 -2
  111. package/dist/devtools.js +0 -2
  112. package/dist/errors-W6nwJ-fm.d.ts +0 -21
  113. package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
  114. package/dist/http.d.ts +0 -151
  115. package/dist/http.d.ts.map +0 -1
  116. package/dist/http.js +0 -706
  117. package/dist/http.js.map +0 -1
  118. package/dist/inspection-DaxB5jM2.js +0 -13
  119. package/dist/inspection-DaxB5jM2.js.map +0 -1
  120. package/dist/internal-DstsI6Re.js.map +0 -1
  121. package/dist/platform-B4TnJtWu.js +0 -34
  122. package/dist/platform-B4TnJtWu.js.map +0 -1
  123. package/dist/server-DpvjhdoE.d.ts.map +0 -1
  124. package/dist/server-Duw6MVlB.js.map +0 -1
  125. package/dist/store-DysUkTH3.d.ts.map +0 -1
  126. package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
  127. package/dist/testing.browser.d.ts +0 -1
  128. package/dist/testing.browser.js +0 -6
  129. package/dist/testing.browser.js.map +0 -1
  130. package/dist/testing.d.ts +0 -32
  131. package/dist/testing.d.ts.map +0 -1
  132. package/dist/testing.js +0 -103
  133. package/dist/testing.js.map +0 -1
  134. package/dist/wire-BFQmSJ-9.js.map +0 -1
  135. package/docs/guides/07-devtools.mdx +0 -229
  136. package/src/cli-bin.ts +0 -5
  137. package/src/cli.ts +0 -1046
  138. package/src/devtools-app.ts +0 -989
  139. package/src/devtools-server.browser.ts +0 -5
  140. package/src/devtools-server.ts +0 -604
  141. package/src/devtools.ts +0 -716
  142. package/src/http.ts +0 -394
  143. package/src/inspection.ts +0 -39
  144. package/src/testing.browser.ts +0 -5
  145. package/src/testing.ts +0 -185
package/src/internal.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { ScheduledEvent } from './scheduler-task.ts'
7
- import type { A2Scheduler, DrainableServer } from './server.ts'
7
+ import type { DrainableServer } from './server.ts'
8
8
 
9
9
  export type DrainOutcome = 'busy' | 'stalled' | 'settled'
10
10
 
@@ -67,14 +67,6 @@ export type ServerInternals = {
67
67
 
68
68
  export const serverInternals: WeakMap<object, ServerInternals> = new WeakMap()
69
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
70
  const objectLike = (value: unknown): value is object =>
79
71
  (typeof value === 'object' && value !== null) || typeof value === 'function'
80
72
 
@@ -232,13 +224,11 @@ const isDrainableServer = (value: unknown): value is DrainableServer => {
232
224
 
233
225
  /**
234
226
  * 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.
227
+ * The seam stays structural so custom scheduler integrations can supply their
228
+ * own drainable targets.
238
229
  */
239
230
  export function assertSchedulerTargets(
240
231
  servers: readonly unknown[],
241
- options: { allowStructural: boolean },
242
232
  ): asserts servers is readonly DrainableServer[] {
243
233
  const contracts = new Set<string>()
244
234
  for (const target of servers) {
@@ -252,56 +242,7 @@ export function assertSchedulerTargets(
252
242
  )
253
243
  }
254
244
  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
245
  }
304
- return binding.scheduler
305
246
  }
306
247
 
307
248
  /**
@@ -1,14 +1,25 @@
1
1
  /**
2
- * The push envelope's trust boundary — parsing and provenance-branding
3
- * for `{ sessionId, events, presence? }`, shared verbatim by the HTTP
2
+ * The push envelope's trust boundary for `{ sessionId, events, presence? }`,
3
+ * shared verbatim by the HTTP
4
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.
5
+ * shapes are one implementation. Internal module, not public API.
7
6
  */
8
7
 
9
8
  import { A2Error } from './errors.ts'
10
9
  import { MAX_DATE_MS, RESERVED_PARTICIPANT_IDS } from './internal.ts'
11
- import type { PushedEvent, PushedPresence } from './server.ts'
10
+
11
+ export type ParsedPushEvent = {
12
+ type: string
13
+ payload: unknown
14
+ id?: string
15
+ }
16
+
17
+ export type ParsedPushPresence = {
18
+ participant: string
19
+ values: Record<string, unknown>
20
+ seen?: number
21
+ at?: number
22
+ }
12
23
 
13
24
  export const invalidPushBody = (message: string): A2Error =>
14
25
  new A2Error('INVALID_PAYLOAD', `malformed push body: ${message}`)
@@ -17,18 +28,13 @@ const invalid = invalidPushBody
17
28
 
18
29
  export type PushBody = {
19
30
  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
31
+ events: ParsedPushEvent[]
32
+ presence?: ParsedPushPresence
27
33
  }
28
34
 
29
35
  export function parsePresenceSibling(
30
36
  value: unknown,
31
- ): PushedPresence | undefined {
37
+ ): ParsedPushPresence | undefined {
32
38
  if (value === undefined) return undefined
33
39
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
34
40
  throw invalid('presence must be an object when present')
@@ -68,14 +74,12 @@ export function parsePresenceSibling(
68
74
  } = { participant, values: values as Record<string, unknown> }
69
75
  if (seen !== undefined) out.seen = seen
70
76
  if (at !== undefined) out.at = at
71
- Object.defineProperty(out, '~a2.pushed', { value: true })
72
- return out as PushedPresence
77
+ return out
73
78
  }
74
79
 
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[] {
80
+ /** The events half of the envelope, shared by the POST body and socket
81
+ * `push` frames so INVALID_PAYLOAD shapes have one implementation. */
82
+ export function parsePushEvents(events: unknown): ParsedPushEvent[] {
79
83
  if (!Array.isArray(events) || events.length === 0) {
80
84
  throw invalid('events must be a non-empty array')
81
85
  }
@@ -95,8 +99,7 @@ export function parsePushEvents(events: unknown): PushedEvent[] {
95
99
  payload,
96
100
  }
97
101
  if (id !== undefined) out.id = id
98
- Object.defineProperty(out, '~a2.pushed', { value: true })
99
- return out as PushedEvent
102
+ return out
100
103
  })
101
104
  }
102
105
 
@@ -94,7 +94,7 @@ export type QStashTransport = {
94
94
  }
95
95
 
96
96
  export type QStashSchedulerOptions = {
97
- /** Exact URL mounted with `schedulerHandler(...)`. Inferred in local dev and on Vercel. */
97
+ /** Exact URL mounted with `scheduler.handler(...)`. Inferred in local dev and on Vercel. */
98
98
  url?: string
99
99
  /** QStash API token. Default: `QSTASH_TOKEN`. */
100
100
  token?: string
@@ -819,7 +819,7 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
819
819
  },
820
820
 
821
821
  handler(...servers: DrainableServer[]) {
822
- assertSchedulerTargets(servers, { allowStructural: true })
822
+ assertSchedulerTargets(servers)
823
823
  const byContract = new Map<string, DrainableServer>()
824
824
  for (const server of servers) {
825
825
  byContract.set(server.contract.name, server)
@@ -851,7 +851,7 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
851
851
  const server = byContract.get(task.contract)
852
852
  if (!server) {
853
853
  throw new Error(
854
- `a2 scheduler: no server for contract '${task.contract}' — pass it to schedulerHandler(...)`,
854
+ `a2 scheduler: no server for contract '${task.contract}' — pass it to scheduler.handler(...)`,
855
855
  )
856
856
  }
857
857
  const internals = serverInternals.get(server)
@@ -368,7 +368,7 @@ export function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {
368
368
  },
369
369
 
370
370
  handler(...servers: DrainableServer[]) {
371
- assertSchedulerTargets(servers, { allowStructural: true })
371
+ assertSchedulerTargets(servers)
372
372
  const byContract = new Map<string, DrainableServer>()
373
373
  for (const server of servers) {
374
374
  byContract.set(server.contract.name, server)
@@ -386,7 +386,7 @@ export function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {
386
386
  // wiring bug — throw so it redelivers and stays visible in
387
387
  // queue observability instead of vanishing on an ack.
388
388
  throw new Error(
389
- `a2 scheduler: no server for contract '${task.contract}' — pass it to schedulerHandler(...)`,
389
+ `a2 scheduler: no server for contract '${task.contract}' — pass it to scheduler.handler(...)`,
390
390
  )
391
391
  }
392
392
  const internals = serverInternals.get(server)
@@ -0,0 +1,344 @@
1
+ import type {
2
+ AppendInput,
3
+ ContractEvent,
4
+ EventDefs,
5
+ PresenceDefs,
6
+ } from './contract.ts'
7
+ import { A2Error } from './errors.ts'
8
+ import { invocationDeadlineMs } from './platform.ts'
9
+ import {
10
+ invalidPushBody,
11
+ parsePushBody,
12
+ type ParsedPushEvent,
13
+ type ParsedPushPresence,
14
+ } from './push-envelope.ts'
15
+ import type { A2Server } from './server.ts'
16
+ import {
17
+ sessionsSocket,
18
+ type A2Socket,
19
+ type SocketSession,
20
+ } from './session-socket.ts'
21
+ import { sseResponse } from './sse.ts'
22
+ import { asA2Error, errorStatus, errorToWire, eventToWire } from './wire.ts'
23
+
24
+ type SuggestedEventType<D extends EventDefs> =
25
+ (keyof D & string) | (string & Record<never, never>)
26
+
27
+ type SuggestedPresenceValues<P extends PresenceDefs> = Readonly<
28
+ { [F in keyof P & string]?: unknown } & Record<string, unknown>
29
+ >
30
+
31
+ export type A2PushEvent<D extends EventDefs = EventDefs> = {
32
+ readonly type: SuggestedEventType<D>
33
+ readonly payload: unknown
34
+ readonly id?: string
35
+ }
36
+
37
+ export type A2PushPresence<P extends PresenceDefs = PresenceDefs> = {
38
+ readonly participant: string
39
+ readonly values: SuggestedPresenceValues<P>
40
+ readonly seen?: number
41
+ readonly at?: number
42
+ }
43
+
44
+ export type A2Operation<
45
+ D extends EventDefs = EventDefs,
46
+ P extends PresenceDefs = Record<never, never>,
47
+ > =
48
+ | {
49
+ readonly type: 'stream'
50
+ readonly sessionId: string
51
+ readonly startAfter: number
52
+ readonly transport: 'http' | 'websocket'
53
+ }
54
+ | {
55
+ readonly type: 'history'
56
+ readonly sessionId: string
57
+ readonly gte: number
58
+ readonly lte: number
59
+ readonly transport: 'http'
60
+ }
61
+ | {
62
+ readonly type: 'push'
63
+ readonly sessionId: string
64
+ readonly events: readonly A2PushEvent<D>[]
65
+ readonly presence?: A2PushPresence<P>
66
+ readonly transport: 'http' | 'websocket'
67
+ }
68
+
69
+ export type UpgradeWebSocket = (
70
+ attach: (socket: A2Socket) => void,
71
+ ) => Response | Promise<Response>
72
+
73
+ export type ServerFetchOptions<
74
+ D extends EventDefs = EventDefs,
75
+ P extends PresenceDefs = Record<never, never>,
76
+ > = {
77
+ authorize?: (operation: A2Operation<D, P>) => boolean | Promise<boolean>
78
+ upgradeWebSocket?: UpgradeWebSocket
79
+ }
80
+
81
+ export type ServerIngressContext = {
82
+ sessionId: string
83
+ events: readonly ParsedPushEvent[]
84
+ presence?: ParsedPushPresence
85
+ }
86
+
87
+ type ServerFetchHooks = {
88
+ validateIngress?(context: ServerIngressContext): void | PromiseLike<void>
89
+ }
90
+
91
+ const serverFetchHooks = new WeakMap<object, ServerFetchHooks>()
92
+
93
+ export function setServerFetchHooks<
94
+ D extends EventDefs,
95
+ P extends PresenceDefs,
96
+ >(server: Pick<A2Server<D, P>, 'fetch'>, hooks: ServerFetchHooks): void {
97
+ serverFetchHooks.set(server.fetch, hooks)
98
+ }
99
+
100
+ type FetchServer<D extends EventDefs, P extends PresenceDefs> = Pick<
101
+ A2Server<D, P>,
102
+ 'contract' | 'session'
103
+ >
104
+
105
+ type ServerFetch<D extends EventDefs, P extends PresenceDefs> = {
106
+ (request: Request): Promise<Response>
107
+ (request: Request, options: ServerFetchOptions<D, P>): Promise<Response>
108
+ }
109
+
110
+ const parseHistoryBounds = (
111
+ gte: string | null,
112
+ lte: string | null,
113
+ ): { gte: number; lte: number } => {
114
+ if (gte === null || lte === null) {
115
+ throw new A2Error(
116
+ 'INVALID_PAYLOAD',
117
+ 'a history read takes both gte and lte',
118
+ )
119
+ }
120
+ const bounds = { gte: Number(gte), lte: Number(lte) }
121
+ for (const [name, value] of Object.entries(bounds)) {
122
+ if (!Number.isSafeInteger(value) || value < 0) {
123
+ throw new A2Error(
124
+ 'INVALID_PAYLOAD',
125
+ `history ${name} must be a non-negative safe integer`,
126
+ )
127
+ }
128
+ }
129
+ if (bounds.gte > bounds.lte) {
130
+ throw new A2Error(
131
+ 'INVALID_PAYLOAD',
132
+ 'history gte must be less than or equal to lte',
133
+ )
134
+ }
135
+ return bounds
136
+ }
137
+
138
+ const parseResumeIndex = (raw: string | null): number => {
139
+ if (raw === null) return 0
140
+ const index = Number(raw)
141
+ if (!Number.isSafeInteger(index) || index < 0) {
142
+ throw new A2Error(
143
+ 'INVALID_PAYLOAD',
144
+ 'stream index must be a non-negative safe integer',
145
+ )
146
+ }
147
+ return index
148
+ }
149
+
150
+ const errorResponse = (error: unknown): Response => {
151
+ const a2error = asA2Error(error)
152
+ return Response.json(errorToWire(a2error), {
153
+ status: errorStatus(a2error.code),
154
+ })
155
+ }
156
+
157
+ const forbidden = (): A2Error =>
158
+ new A2Error('FORBIDDEN', 'the operation is not authorized')
159
+
160
+ const freezePush = <D extends EventDefs, P extends PresenceDefs>(
161
+ sessionId: string,
162
+ events: ParsedPushEvent[],
163
+ presence: ParsedPushPresence | undefined,
164
+ transport: 'http' | 'websocket',
165
+ ): A2Operation<D, P> & { type: 'push' } => {
166
+ for (const event of events) Object.freeze(event)
167
+ Object.freeze(events)
168
+ if (presence !== undefined) {
169
+ Object.freeze(presence.values)
170
+ Object.freeze(presence)
171
+ }
172
+ return {
173
+ type: 'push',
174
+ sessionId,
175
+ events,
176
+ ...(presence === undefined
177
+ ? {}
178
+ : { presence: presence as A2PushPresence<P> }),
179
+ transport,
180
+ }
181
+ }
182
+
183
+ export function createServerFetch<
184
+ D extends EventDefs,
185
+ P extends PresenceDefs = Record<never, never>,
186
+ >(server: FetchServer<D, P>): ServerFetch<D, P> {
187
+ const presence = Object.keys(server.contract.presence).length > 0
188
+
189
+ const fetch = async (
190
+ request: Request,
191
+ options?: ServerFetchOptions<D, P>,
192
+ ): Promise<Response> => {
193
+ const authorized = async (
194
+ operation: A2Operation<D, P>,
195
+ ): Promise<boolean> => {
196
+ try {
197
+ return (await options?.authorize?.(Object.freeze(operation))) !== false
198
+ } catch (cause) {
199
+ throw new A2Error('STORE_UNAVAILABLE', 'authorization failed', {
200
+ cause,
201
+ })
202
+ }
203
+ }
204
+
205
+ const authorize = async (operation: A2Operation<D, P>): Promise<void> => {
206
+ if (!(await authorized(operation))) throw forbidden()
207
+ }
208
+
209
+ const validateIngress = async (
210
+ context: ServerIngressContext,
211
+ ): Promise<void> => {
212
+ await serverFetchHooks.get(fetch)?.validateIngress?.(context)
213
+ }
214
+
215
+ const attach = (socket: A2Socket): void => {
216
+ const deadline = invocationDeadlineMs()
217
+ sessionsSocket<D>(
218
+ async (sessionId, startAfter) => {
219
+ const operation: A2Operation<D, P> = {
220
+ type: 'stream',
221
+ sessionId,
222
+ startAfter,
223
+ transport: 'websocket',
224
+ }
225
+ return (await authorized(operation))
226
+ ? server.session(sessionId)
227
+ : null
228
+ },
229
+ socket,
230
+ {
231
+ presence,
232
+ ...(deadline === null ? {} : { deadline }),
233
+ gatePush: async (sessionId, events, pushedPresence) => {
234
+ try {
235
+ await authorize(
236
+ freezePush<D, P>(
237
+ sessionId,
238
+ events,
239
+ pushedPresence,
240
+ 'websocket',
241
+ ),
242
+ )
243
+ await validateIngress({
244
+ sessionId,
245
+ events,
246
+ ...(pushedPresence === undefined
247
+ ? {}
248
+ : { presence: pushedPresence }),
249
+ })
250
+ return null
251
+ } catch (error) {
252
+ return asA2Error(error)
253
+ }
254
+ },
255
+ },
256
+ )
257
+ }
258
+
259
+ try {
260
+ if (request.method === 'GET') {
261
+ if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
262
+ if (options?.upgradeWebSocket === undefined) {
263
+ return new Response(
264
+ 'WebSocket upgrade requested, but server.fetch() has no upgrade implementation',
265
+ { status: 426 },
266
+ )
267
+ }
268
+ return await options.upgradeWebSocket(attach)
269
+ }
270
+
271
+ const { searchParams } = new URL(request.url)
272
+ const sessionId = searchParams.get('sessionId')
273
+ if (sessionId === null || sessionId.length === 0) {
274
+ throw new A2Error('INVALID_PAYLOAD', 'missing sessionId')
275
+ }
276
+ const gteRaw = searchParams.get('gte')
277
+ const lteRaw = searchParams.get('lte')
278
+ if (gteRaw !== null || lteRaw !== null) {
279
+ const bounds = parseHistoryBounds(gteRaw, lteRaw)
280
+ await authorize({
281
+ type: 'history',
282
+ sessionId,
283
+ ...bounds,
284
+ transport: 'http',
285
+ })
286
+ const events = await server.session(sessionId).history(bounds)
287
+ const covered = events.length === bounds.lte - bounds.gte + 1
288
+ return Response.json(events.map(eventToWire), {
289
+ headers: { 'a2-history-covered': String(covered) },
290
+ })
291
+ }
292
+
293
+ const startAfter = parseResumeIndex(searchParams.get('index'))
294
+ await authorize({
295
+ type: 'stream',
296
+ sessionId,
297
+ startAfter,
298
+ transport: 'http',
299
+ })
300
+ const target = server.session(sessionId) as unknown as SocketSession
301
+ return sseResponse(
302
+ presence
303
+ ? target.stream({ startAfter, presence: true })
304
+ : target.stream({ startAfter }),
305
+ )
306
+ }
307
+
308
+ if (request.method === 'POST') {
309
+ const body = await parsePushBody(request)
310
+ await authorize(
311
+ freezePush<D, P>(body.sessionId, body.events, body.presence, 'http'),
312
+ )
313
+ await validateIngress({
314
+ sessionId: body.sessionId,
315
+ events: body.events,
316
+ ...(body.presence === undefined ? {} : { presence: body.presence }),
317
+ })
318
+ const session = server.session(body.sessionId)
319
+ if (body.presence !== undefined) {
320
+ const target = session as unknown as SocketSession
321
+ if (typeof target.setPresence !== 'function') {
322
+ throw invalidPushBody('presence is not declared by this contract')
323
+ }
324
+ await target.setPresence(body.presence)
325
+ }
326
+ const appended: ContractEvent<D>[] =
327
+ body.events.length === 0
328
+ ? []
329
+ : await session.append(
330
+ ...(body.events as unknown as AppendInput<D>[]),
331
+ )
332
+ return Response.json(appended.map(eventToWire))
333
+ }
334
+
335
+ return new Response('Method Not Allowed', {
336
+ status: 405,
337
+ headers: { allow: 'GET, POST' },
338
+ })
339
+ } catch (error) {
340
+ return errorResponse(error)
341
+ }
342
+ }
343
+ return fetch
344
+ }