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.
- package/CHANGELOG.md +22 -0
- package/dist/ai-server.d.ts +4 -5
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +8 -7
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +334 -2
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +1 -1
- package/dist/client.d.ts +202 -2
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1025 -1
- package/dist/client.js.map +1 -0
- package/dist/errors-BQuJpe82.js.map +1 -1
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -0
- package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
- package/dist/internal-DRXJ56EI.js.map +1 -0
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/scheduler-qstash.d.ts +3 -3
- package/dist/scheduler-qstash.js +4 -5
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +4 -4
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Duw6MVlB.js → server-286j79Mt.js} +708 -79
- package/dist/server-286j79Mt.js.map +1 -0
- package/dist/{server-DpvjhdoE.d.ts → server-DgXmORIq.d.ts} +67 -49
- package/dist/server-DgXmORIq.d.ts.map +1 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/{store-DysUkTH3.d.ts → store-flRz1OWh.d.ts} +2 -57
- package/dist/store-flRz1OWh.d.ts.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +1 -59
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
- package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +1 -81
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DEYO8Ryv.js} +48 -134
- package/dist/store-redis-core-DEYO8Ryv.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -3
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +3 -4
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +1 -72
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
- package/dist/wire-B6te_wns.js.map +1 -0
- package/docs/guides/03-react.mdx +20 -28
- package/docs/guides/05-production.mdx +9 -11
- package/docs/guides/06-ai-agents.mdx +8 -14
- package/docs/guides/09-presence.mdx +14 -19
- package/docs/guides/10-transports.mdx +104 -86
- package/docs/reference/01-api.mdx +143 -277
- package/docs/reference/02-errors.mdx +5 -7
- package/package.json +1 -14
- package/src/ai-server.ts +9 -5
- package/src/client.ts +2 -2
- package/src/errors.ts +1 -0
- package/src/internal.ts +3 -62
- package/src/push-envelope.ts +24 -21
- package/src/scheduler-qstash.ts +3 -3
- package/src/scheduler-vercel.ts +2 -2
- package/src/server-fetch.ts +344 -0
- package/src/server.ts +73 -225
- package/src/session-socket.ts +36 -20
- package/src/sse.ts +2 -2
- package/src/store-memory.ts +0 -81
- package/src/store-postgres.ts +0 -100
- package/src/store-redis-core.ts +47 -211
- package/src/store-redis-http.ts +0 -1
- package/src/store-redis.ts +0 -1
- package/src/store-sqlite.ts +0 -119
- package/src/store.ts +0 -60
- package/src/wire.ts +2 -1
- package/dist/ai-D_PGS-JR.d.ts +0 -334
- package/dist/ai-D_PGS-JR.d.ts.map +0 -1
- package/dist/cli-B3VuxoDe.js +0 -599
- package/dist/cli-B3VuxoDe.js.map +0 -1
- package/dist/cli-bin.d.ts +0 -1
- package/dist/cli-bin.js +0 -7
- package/dist/cli-bin.js.map +0 -1
- package/dist/cli.d.ts +0 -20
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -2
- package/dist/client-CdMqi7mC.d.ts +0 -202
- package/dist/client-CdMqi7mC.d.ts.map +0 -1
- package/dist/client-Dj5d3SP_.js +0 -1026
- package/dist/client-Dj5d3SP_.js.map +0 -1
- package/dist/devtools-J_jZ2vQf.d.ts +0 -152
- package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
- package/dist/devtools-kJJaORn-.js +0 -340
- package/dist/devtools-kJJaORn-.js.map +0 -1
- package/dist/devtools-server.browser.d.ts +0 -1
- package/dist/devtools-server.browser.js +0 -6
- package/dist/devtools-server.browser.js.map +0 -1
- package/dist/devtools-server.d.ts +0 -23
- package/dist/devtools-server.d.ts.map +0 -1
- package/dist/devtools-server.js +0 -1270
- package/dist/devtools-server.js.map +0 -1
- package/dist/devtools.d.ts +0 -2
- package/dist/devtools.js +0 -2
- package/dist/errors-W6nwJ-fm.d.ts +0 -21
- package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
- package/dist/http.d.ts +0 -151
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js +0 -706
- package/dist/http.js.map +0 -1
- package/dist/inspection-DaxB5jM2.js +0 -13
- package/dist/inspection-DaxB5jM2.js.map +0 -1
- package/dist/internal-DstsI6Re.js.map +0 -1
- package/dist/platform-B4TnJtWu.js +0 -34
- package/dist/platform-B4TnJtWu.js.map +0 -1
- package/dist/server-DpvjhdoE.d.ts.map +0 -1
- package/dist/server-Duw6MVlB.js.map +0 -1
- package/dist/store-DysUkTH3.d.ts.map +0 -1
- package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
- package/dist/testing.browser.d.ts +0 -1
- package/dist/testing.browser.js +0 -6
- package/dist/testing.browser.js.map +0 -1
- package/dist/testing.d.ts +0 -32
- package/dist/testing.d.ts.map +0 -1
- package/dist/testing.js +0 -103
- package/dist/testing.js.map +0 -1
- package/dist/wire-BFQmSJ-9.js.map +0 -1
- package/docs/guides/07-devtools.mdx +0 -229
- package/src/cli-bin.ts +0 -5
- package/src/cli.ts +0 -1046
- package/src/devtools-app.ts +0 -989
- package/src/devtools-server.browser.ts +0 -5
- package/src/devtools-server.ts +0 -604
- package/src/devtools.ts +0 -716
- package/src/http.ts +0 -394
- package/src/inspection.ts +0 -39
- package/src/testing.browser.ts +0 -5
- 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 {
|
|
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
|
-
*
|
|
236
|
-
*
|
|
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
|
/**
|
package/src/push-envelope.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The push envelope's trust boundary
|
|
3
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
72
|
-
return out as PushedPresence
|
|
77
|
+
return out
|
|
73
78
|
}
|
|
74
79
|
|
|
75
|
-
/** The events half of the envelope
|
|
76
|
-
*
|
|
77
|
-
|
|
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
|
-
|
|
99
|
-
return out as PushedEvent
|
|
102
|
+
return out
|
|
100
103
|
})
|
|
101
104
|
}
|
|
102
105
|
|
package/src/scheduler-qstash.ts
CHANGED
|
@@ -94,7 +94,7 @@ export type QStashTransport = {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
export type QStashSchedulerOptions = {
|
|
97
|
-
/** Exact URL mounted with `
|
|
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
|
|
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
|
|
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)
|
package/src/scheduler-vercel.ts
CHANGED
|
@@ -368,7 +368,7 @@ export function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {
|
|
|
368
368
|
},
|
|
369
369
|
|
|
370
370
|
handler(...servers: DrainableServer[]) {
|
|
371
|
-
assertSchedulerTargets(servers
|
|
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
|
|
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
|
+
}
|