experimental-a2 0.5.1 → 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 +67 -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 +41 -7
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +74 -35
- package/dist/react.js.map +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 +118 -29
- package/docs/guides/05-production.mdx +9 -11
- package/docs/guides/06-ai-agents.mdx +8 -14
- package/docs/guides/09-presence.mdx +19 -21
- package/docs/guides/10-transports.mdx +104 -86
- package/docs/reference/01-api.mdx +186 -279
- package/docs/reference/02-errors.mdx +5 -7
- package/package.json +1 -14
- package/src/ai-server.ts +9 -5
- package/src/client.ts +71 -35
- package/src/errors.ts +1 -0
- package/src/internal.ts +3 -62
- package/src/push-envelope.ts +24 -21
- package/src/react.ts +118 -44
- 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-BKlyLiOU.js +0 -1008
- package/dist/client-BKlyLiOU.js.map +0 -1
- package/dist/client-D7mvIXrF.d.ts +0 -191
- package/dist/client-D7mvIXrF.d.ts.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/ai-server.ts
CHANGED
|
@@ -57,11 +57,14 @@ import {
|
|
|
57
57
|
createServer,
|
|
58
58
|
type A2Server,
|
|
59
59
|
type HandlerContext,
|
|
60
|
-
type PushValidationContext,
|
|
61
60
|
type ServerOptions,
|
|
62
61
|
} from './server.ts'
|
|
62
|
+
import {
|
|
63
|
+
setServerFetchHooks,
|
|
64
|
+
type ServerIngressContext,
|
|
65
|
+
} from './server-fetch.ts'
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
function validateAgentIngress(context: ServerIngressContext): void {
|
|
65
68
|
const rejected = context.events.find((event) => {
|
|
66
69
|
if (event.type !== 'ai.message.created') {
|
|
67
70
|
return !(
|
|
@@ -203,7 +206,7 @@ export type CreateAgentServerOptions<
|
|
|
203
206
|
D extends AIEventDefs<M> & EventDefs,
|
|
204
207
|
T extends ToolSet = ToolSet,
|
|
205
208
|
> = CreateHandlersOptions<M, D, T> &
|
|
206
|
-
Omit<ServerOptions<D>, 'contract' | 'handlers'
|
|
209
|
+
Omit<ServerOptions<D>, 'contract' | 'handlers'> & {
|
|
207
210
|
handlers?: ServerOptions<D>['handlers']
|
|
208
211
|
}
|
|
209
212
|
|
|
@@ -1708,12 +1711,13 @@ export function createAgentServer<
|
|
|
1708
1711
|
...(compaction === undefined ? {} : { compaction }),
|
|
1709
1712
|
...(progress === undefined ? {} : { progress }),
|
|
1710
1713
|
})
|
|
1711
|
-
|
|
1714
|
+
const server = createServer({
|
|
1712
1715
|
...serverOptions,
|
|
1713
1716
|
contract: definition.contract,
|
|
1714
1717
|
handlers: { ...builtIns, ...handlers },
|
|
1715
|
-
validatePush: validateAgentPush,
|
|
1716
1718
|
})
|
|
1719
|
+
setServerFetchHooks(server, { validateIngress: validateAgentIngress })
|
|
1720
|
+
return server
|
|
1717
1721
|
}
|
|
1718
1722
|
|
|
1719
1723
|
export type { Instructions, LanguageModel, ToolSet }
|
package/src/client.ts
CHANGED
|
@@ -177,13 +177,19 @@ export type SessionClient<
|
|
|
177
177
|
* this throws a TypeError there.
|
|
178
178
|
*/
|
|
179
179
|
loadHistory(options?: LoadHistoryOptions): Promise<ContractEvent<D>[]>
|
|
180
|
-
/** Open the live stream (idempotent while open). Reconnects with
|
|
181
|
-
* backoff and resumes from the frontier until `close()`. */
|
|
182
|
-
connect(): void
|
|
183
180
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
181
|
+
* Take a lease on the live stream and return its release. Leases
|
|
182
|
+
* refcount per handle — the first connects, releasing the last
|
|
183
|
+
* closes — so independent consumers of one identity-mapped handle
|
|
184
|
+
* (two hooks, a hook plus vanilla code) never fight over the
|
|
185
|
+
* stream. Releasing twice is a no-op. While any lease is held the
|
|
186
|
+
* stream reconnects with backoff and resumes from the frontier.
|
|
187
|
+
*/
|
|
188
|
+
connect(): () => void
|
|
189
|
+
/**
|
|
190
|
+
* The hard stop: drops every outstanding lease and closes the
|
|
191
|
+
* stream now. Not terminal — a later `connect()` starts fresh from
|
|
192
|
+
* the current frontier.
|
|
187
193
|
*/
|
|
188
194
|
close(): void
|
|
189
195
|
}
|
|
@@ -193,11 +199,8 @@ export type SessionOptions<D extends EventDefs, S> = {
|
|
|
193
199
|
initialIndex?: number
|
|
194
200
|
/** Server-rendered history through `initialIndex`. Seeds the event feed. */
|
|
195
201
|
initialEvents?: ContractEvent<D>[]
|
|
196
|
-
/**
|
|
197
|
-
*
|
|
198
|
-
* Caller-supplied (a user id, a tab nonce, a guest name): A2 does
|
|
199
|
-
* not invent an identity story. Bound once per session handle.
|
|
200
|
-
*/
|
|
202
|
+
/** Overrides the client's `participant` for this session handle.
|
|
203
|
+
* Bound once per handle. */
|
|
201
204
|
participant?: string
|
|
202
205
|
}
|
|
203
206
|
|
|
@@ -263,6 +266,14 @@ export type CreateClientOptions<
|
|
|
263
266
|
/** How long an idle session keeps its in-memory identity, in
|
|
264
267
|
* milliseconds. Defaults to five minutes; `Infinity` disables GC. */
|
|
265
268
|
gcTime?: number
|
|
269
|
+
/**
|
|
270
|
+
* This client's presence identity — required to call `setPresence`.
|
|
271
|
+
* Caller-supplied (a user id, a tab nonce, a guest name): A2 does
|
|
272
|
+
* not invent an identity story; the app states it once here. The
|
|
273
|
+
* default for every session handle this client creates;
|
|
274
|
+
* `session(id, { participant })` overrides it per handle.
|
|
275
|
+
*/
|
|
276
|
+
participant?: string
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
const PUSH_ATTEMPTS = 3
|
|
@@ -797,7 +808,7 @@ const wsTransport = (
|
|
|
797
808
|
settleChannel(
|
|
798
809
|
channel,
|
|
799
810
|
new Error(
|
|
800
|
-
'subscribe not confirmed: is the ws route
|
|
811
|
+
'subscribe not confirmed: is the ws route calling server.fetch with upgradeWebSocket?',
|
|
801
812
|
),
|
|
802
813
|
)
|
|
803
814
|
}, STREAM_TIMINGS.stallTimeoutMs)
|
|
@@ -1022,7 +1033,7 @@ export function createClient<
|
|
|
1022
1033
|
// stream; the own entry is the local echo — server copies of self
|
|
1023
1034
|
// are ignored (the echo is at least as new, and comparing it to
|
|
1024
1035
|
// server stamps would put two clocks in one order).
|
|
1025
|
-
let participant = sessionOptions?.participant
|
|
1036
|
+
let participant = sessionOptions?.participant ?? options.participant
|
|
1026
1037
|
// Participant and field keys arrive off the wire, so the map and
|
|
1027
1038
|
// every entry are built null-prototype — see `nullProtoRecord`.
|
|
1028
1039
|
let presenceState: PresenceMap = nullProtoRecord()
|
|
@@ -1292,7 +1303,7 @@ export function createClient<
|
|
|
1292
1303
|
const setPresence = (values: PresencePatch<P>['values']): void => {
|
|
1293
1304
|
if (participant === undefined) {
|
|
1294
1305
|
throw new TypeError(
|
|
1295
|
-
'setPresence requires a participant — pass one in the session options (the SessionProvider participant prop)',
|
|
1306
|
+
'setPresence requires a participant — pass one to createClient({ participant }) or in the session options (the SessionProvider/useSession participant prop)',
|
|
1296
1307
|
)
|
|
1297
1308
|
}
|
|
1298
1309
|
const validated: Record<string, unknown> = nullProtoRecord()
|
|
@@ -1551,7 +1562,7 @@ export function createClient<
|
|
|
1551
1562
|
const readHistory = transport.history
|
|
1552
1563
|
if (readHistory === undefined) {
|
|
1553
1564
|
throw new TypeError(
|
|
1554
|
-
'loadHistory requires an http api — the ws transport has no history lane; serve the session
|
|
1565
|
+
'loadHistory requires an http api — the ws transport has no history lane; serve the session with server.fetch and pass its route url as the string or { type: "http" } api',
|
|
1555
1566
|
)
|
|
1556
1567
|
}
|
|
1557
1568
|
const before = loadOptions?.before
|
|
@@ -1600,11 +1611,34 @@ export function createClient<
|
|
|
1600
1611
|
|
|
1601
1612
|
// ── the live stream ──────────────────────────────────────────
|
|
1602
1613
|
// A run-generation model instead of a terminal `closed` flag:
|
|
1603
|
-
//
|
|
1614
|
+
// stopping bumps the generation (the running loop notices and
|
|
1604
1615
|
// exits), connect() starts a fresh one. Reentrant by design.
|
|
1616
|
+
// Consumers hold leases: connect() counts one and returns its
|
|
1617
|
+
// release, the last release stops the stream, and close() is the
|
|
1618
|
+
// hard stop — it drops every lease and stops now (bumping
|
|
1619
|
+
// `leaseEpoch` makes the outstanding releases inert).
|
|
1605
1620
|
let generation = 0
|
|
1606
1621
|
let active = false
|
|
1607
1622
|
let abort: AbortController | null = null
|
|
1623
|
+
let leases = 0
|
|
1624
|
+
let leaseEpoch = 0
|
|
1625
|
+
|
|
1626
|
+
const stopStream = (): void => {
|
|
1627
|
+
touch(sessionId)
|
|
1628
|
+
if (!active) return
|
|
1629
|
+
active = false
|
|
1630
|
+
generation += 1 // the running loop notices and exits
|
|
1631
|
+
abort?.abort()
|
|
1632
|
+
abort = null
|
|
1633
|
+
// A pending trailing send dies with the stream — losing one is
|
|
1634
|
+
// fine by definition; sets made while closed accumulate in
|
|
1635
|
+
// `presenceResend` and repaint after the next connect.
|
|
1636
|
+
clearTimeout(presenceTimer)
|
|
1637
|
+
presenceTimer = undefined
|
|
1638
|
+
presenceBuffer = null
|
|
1639
|
+
status = 'closed'
|
|
1640
|
+
notify()
|
|
1641
|
+
}
|
|
1608
1642
|
|
|
1609
1643
|
const runStream = async (run: number): Promise<void> => {
|
|
1610
1644
|
let backoff = STREAM_TIMINGS.reconnectBaseMs
|
|
@@ -1727,27 +1761,29 @@ export function createClient<
|
|
|
1727
1761
|
loadHistory,
|
|
1728
1762
|
connect() {
|
|
1729
1763
|
touch(sessionId)
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1764
|
+
leases += 1
|
|
1765
|
+
const epoch = leaseEpoch
|
|
1766
|
+
if (!active) {
|
|
1767
|
+
active = true
|
|
1768
|
+
status = 'connecting'
|
|
1769
|
+
notify()
|
|
1770
|
+
void runStream(generation)
|
|
1771
|
+
}
|
|
1772
|
+
let released = false
|
|
1773
|
+
return () => {
|
|
1774
|
+
// Idempotent, and inert after a hard close() — a stale
|
|
1775
|
+
// release must not touch leases taken since.
|
|
1776
|
+
if (released || leaseEpoch !== epoch) return
|
|
1777
|
+
released = true
|
|
1778
|
+
touch(sessionId)
|
|
1779
|
+
leases -= 1
|
|
1780
|
+
if (leases === 0) stopStream()
|
|
1781
|
+
}
|
|
1735
1782
|
},
|
|
1736
1783
|
close() {
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
generation += 1 // the running loop notices and exits
|
|
1741
|
-
abort?.abort()
|
|
1742
|
-
abort = null
|
|
1743
|
-
// A pending trailing send dies with the stream — losing one is
|
|
1744
|
-
// fine by definition; sets made while closed accumulate in
|
|
1745
|
-
// `presenceResend` and repaint after the next connect.
|
|
1746
|
-
clearTimeout(presenceTimer)
|
|
1747
|
-
presenceTimer = undefined
|
|
1748
|
-
presenceBuffer = null
|
|
1749
|
-
status = 'closed'
|
|
1750
|
-
notify()
|
|
1784
|
+
leases = 0
|
|
1785
|
+
leaseEpoch += 1
|
|
1786
|
+
stopStream()
|
|
1751
1787
|
},
|
|
1752
1788
|
hydrate(next) {
|
|
1753
1789
|
touch(sessionId)
|
package/src/errors.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
export type A2ErrorCode =
|
|
10
10
|
| 'INVALID_PAYLOAD' // schema validation failed — thrown before anything is written
|
|
11
|
+
| 'FORBIDDEN' // external ingress was denied by server.fetch authorization
|
|
11
12
|
| 'UNKNOWN_EVENT_TYPE' // event type not in the machine's `events` map
|
|
12
13
|
| 'PARTIAL_DUPLICATE_BATCH' // batch mixed already-appended and fresh events
|
|
13
14
|
| 'SUPERSEDED_ATTEMPT' // handler append from an attempt a recovery claim replaced
|
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/react.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* renders resolve the same live session. Everything is typed by the
|
|
11
11
|
* reducer value — no type arguments, and nothing here ever touches the
|
|
12
12
|
* machine module.
|
|
13
|
+
*
|
|
14
|
+
* The standalone `useSession(client, sessionId, options?)` is the
|
|
15
|
+
* provider-less primitive for client-first apps: identity-mapped
|
|
16
|
+
* handles, one leased stream per session, and an app-owned hydration
|
|
17
|
+
* input — the hook holds the stream until the app hands it a
|
|
18
|
+
* `{ state, index }` fold.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import {
|
|
@@ -44,6 +50,30 @@ import type {
|
|
|
44
50
|
} from './contract.ts'
|
|
45
51
|
import type { Reducer } from './reducer.ts'
|
|
46
52
|
|
|
53
|
+
export type UseSessionOptions<S> = {
|
|
54
|
+
/** Overrides the client's `participant` for this session — for apps
|
|
55
|
+
* whose identity is only known at mount time. Bound once per
|
|
56
|
+
* session handle. */
|
|
57
|
+
participant?: string
|
|
58
|
+
/**
|
|
59
|
+
* The server fold this session mounts from: the `{ state, index }`
|
|
60
|
+
* pair `session.state(reducer)` returns, fetched by the app through
|
|
61
|
+
* its own route. Atomic on purpose — the fold and its frontier
|
|
62
|
+
* travel together or not at all. While `undefined` (the app's fetch
|
|
63
|
+
* has not landed), the hook holds the stream; when the value
|
|
64
|
+
* arrives, the session hydrates through the ordinary re-hydration
|
|
65
|
+
* seam and the stream connects at that frontier. To fold from the log's start
|
|
66
|
+
* deliberately, hand the fold's true starting point:
|
|
67
|
+
* `{ state: reducer.initialState, index: 0 }` — state at index 0 is
|
|
68
|
+
* the reducer's seed by definition, so the explicit replay needs no
|
|
69
|
+
* special vocabulary. There is no pending/ready echo on the result:
|
|
70
|
+
* whether the fold has been handed over is this option — the
|
|
71
|
+
* caller's own input — so the data layer's loading state IS the
|
|
72
|
+
* pending state.
|
|
73
|
+
*/
|
|
74
|
+
hydrate?: { state: S; index: number } | undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
export type SessionProviderProps<D extends EventDefs, S> = {
|
|
48
78
|
/** Which session to subscribe to. */
|
|
49
79
|
sessionId: string
|
|
@@ -55,11 +85,8 @@ export type SessionProviderProps<D extends EventDefs, S> = {
|
|
|
55
85
|
initialIndex: number
|
|
56
86
|
/** Server-rendered event history through `initialIndex`. */
|
|
57
87
|
initialEvents?: ContractEvent<D>[]
|
|
58
|
-
/**
|
|
59
|
-
*
|
|
60
|
-
* Caller-supplied (a user id, a tab nonce, a guest name): A2 does
|
|
61
|
-
* not invent an identity story.
|
|
62
|
-
*/
|
|
88
|
+
/** Overrides the client's `participant` for this session — for apps
|
|
89
|
+
* whose identity is only known at mount time. */
|
|
63
90
|
participant?: string
|
|
64
91
|
children?: ReactNode
|
|
65
92
|
}
|
|
@@ -156,7 +183,6 @@ export function createReact<
|
|
|
156
183
|
options: { client: A2Client<D, S, P> } | { reducer: Reducer<D, S, P> },
|
|
157
184
|
): BoundA2React<D, S, P> | A2React<D, S, P> {
|
|
158
185
|
const Context = createContext<SessionClient<D, S, P> | null>(null)
|
|
159
|
-
const providerMounts = new WeakMap<SessionClient<D, S, P> & object, number>()
|
|
160
186
|
const resolveSession: (
|
|
161
187
|
sessionId: string,
|
|
162
188
|
api: string | undefined,
|
|
@@ -232,53 +258,101 @@ export function createReact<
|
|
|
232
258
|
),
|
|
233
259
|
[sessionId, api, initialState, initialIndex, initialEvents, participant],
|
|
234
260
|
)
|
|
235
|
-
|
|
236
|
-
const mounts = providerMounts.get(client) ?? 0
|
|
237
|
-
providerMounts.set(client, mounts + 1)
|
|
238
|
-
if (mounts === 0) client.connect()
|
|
239
|
-
return () => {
|
|
240
|
-
const remaining = (providerMounts.get(client) ?? 1) - 1
|
|
241
|
-
if (remaining === 0) {
|
|
242
|
-
providerMounts.delete(client)
|
|
243
|
-
client.close()
|
|
244
|
-
} else {
|
|
245
|
-
providerMounts.set(client, remaining)
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}, [client])
|
|
261
|
+
useSessionLifecycle(client, true)
|
|
249
262
|
return createElement(Context.Provider, { value: client }, props.children)
|
|
250
263
|
}
|
|
251
264
|
|
|
252
|
-
function
|
|
265
|
+
function useBoundSession(): UseSessionResult<D, S, P> {
|
|
253
266
|
const client = useContext(Context)
|
|
254
267
|
if (!client) {
|
|
255
268
|
throw new Error(
|
|
256
269
|
'useSession must be rendered inside its matching SessionProvider',
|
|
257
270
|
)
|
|
258
271
|
}
|
|
259
|
-
|
|
260
|
-
client.subscribe,
|
|
261
|
-
client.getSnapshot,
|
|
262
|
-
client.getSnapshot,
|
|
263
|
-
)
|
|
264
|
-
return useMemo(() => {
|
|
265
|
-
// Runtime mirror of the WithPresence surface: the client attaches
|
|
266
|
-
// setPresence (and the snapshot carries presence) exactly when the
|
|
267
|
-
// reducer declares presence fields — no runtime-inert members.
|
|
268
|
-
const { setPresence } = client as Partial<SessionClientPresence<P>>
|
|
269
|
-
const { presence } = snapshot as Partial<{ presence: PresenceMap<P> }>
|
|
270
|
-
return {
|
|
271
|
-
state: snapshot.state,
|
|
272
|
-
events: snapshot.events,
|
|
273
|
-
index: snapshot.index,
|
|
274
|
-
connection: snapshot.connection,
|
|
275
|
-
push: client.push,
|
|
276
|
-
loadHistory: client.loadHistory,
|
|
277
|
-
history: snapshot.history,
|
|
278
|
-
...(setPresence && presence ? { presence, setPresence } : {}),
|
|
279
|
-
} as UseSessionResult<D, S, P>
|
|
280
|
-
}, [snapshot, client])
|
|
272
|
+
return useSessionResult(client)
|
|
281
273
|
}
|
|
282
274
|
|
|
283
|
-
return { SessionProvider, useSession }
|
|
275
|
+
return { SessionProvider, useSession: useBoundSession }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── the shared session lifecycle ───────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Hold a stream lease while mounted and `live` — the client refcounts
|
|
282
|
+
* leases per handle, so however many providers and hooks mount one
|
|
283
|
+
* session, the first connects and the last release closes
|
|
284
|
+
* (StrictMode-safe: the lease is retaken on remount). A standalone
|
|
285
|
+
* hook without a `hydrate` value passes `live: false` and holds the
|
|
286
|
+
* stream — connecting an unhydrated session would replay the whole
|
|
287
|
+
* log, and the library never picks the expensive path silently.
|
|
288
|
+
*/
|
|
289
|
+
function useSessionLifecycle<D extends EventDefs, S, P extends PresenceDefs>(
|
|
290
|
+
session: SessionClient<D, S, P>,
|
|
291
|
+
live: boolean,
|
|
292
|
+
): void {
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
if (!live) return
|
|
295
|
+
return session.connect()
|
|
296
|
+
}, [session, live])
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function useSessionResult<D extends EventDefs, S, P extends PresenceDefs>(
|
|
300
|
+
session: SessionClient<D, S, P>,
|
|
301
|
+
): UseSessionResult<D, S, P> {
|
|
302
|
+
const snapshot: SessionSnapshot<D, S, P> = useSyncExternalStore(
|
|
303
|
+
session.subscribe,
|
|
304
|
+
session.getSnapshot,
|
|
305
|
+
session.getSnapshot,
|
|
306
|
+
)
|
|
307
|
+
return useMemo(() => {
|
|
308
|
+
// Runtime mirror of the WithPresence surface: the client attaches
|
|
309
|
+
// setPresence (and the snapshot carries presence) exactly when the
|
|
310
|
+
// reducer declares presence fields — no runtime-inert members.
|
|
311
|
+
const { setPresence } = session as Partial<SessionClientPresence<P>>
|
|
312
|
+
const { presence } = snapshot as Partial<{ presence: PresenceMap<P> }>
|
|
313
|
+
return {
|
|
314
|
+
state: snapshot.state,
|
|
315
|
+
events: snapshot.events,
|
|
316
|
+
index: snapshot.index,
|
|
317
|
+
connection: snapshot.connection,
|
|
318
|
+
push: session.push,
|
|
319
|
+
loadHistory: session.loadHistory,
|
|
320
|
+
history: snapshot.history,
|
|
321
|
+
...(setPresence && presence ? { presence, setPresence } : {}),
|
|
322
|
+
} as UseSessionResult<D, S, P>
|
|
323
|
+
}, [snapshot, session])
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The standalone, provider-less hook — for apps whose components reach
|
|
328
|
+
* sessions ad hoc (a sidebar of channel sessions, a user session read
|
|
329
|
+
* from a menu). Takes the shared client and a session id; the handle
|
|
330
|
+
* is identity-mapped, so every hook and provider mounting the same
|
|
331
|
+
* session shares one runtime and one leased stream. Hydration is
|
|
332
|
+
* app-owned: pass `hydrate` when your fetch lands; until then the
|
|
333
|
+
* stream is held, and your data layer's loading state is the pending
|
|
334
|
+
* state.
|
|
335
|
+
*/
|
|
336
|
+
export function useSession<
|
|
337
|
+
D extends EventDefs,
|
|
338
|
+
S,
|
|
339
|
+
P extends PresenceDefs = Record<never, never>,
|
|
340
|
+
>(
|
|
341
|
+
client: A2Client<D, S, P>,
|
|
342
|
+
sessionId: string,
|
|
343
|
+
options?: UseSessionOptions<S>,
|
|
344
|
+
): UseSessionResult<D, S, P> {
|
|
345
|
+
const { participant, hydrate } = options ?? {}
|
|
346
|
+
const session = useMemo(
|
|
347
|
+
() =>
|
|
348
|
+
client.session(sessionId, {
|
|
349
|
+
...(hydrate === undefined
|
|
350
|
+
? {}
|
|
351
|
+
: { initialState: hydrate.state, initialIndex: hydrate.index }),
|
|
352
|
+
...(participant === undefined ? {} : { participant }),
|
|
353
|
+
}),
|
|
354
|
+
[client, sessionId, hydrate, participant],
|
|
355
|
+
)
|
|
356
|
+
useSessionLifecycle(session, hydrate !== undefined)
|
|
357
|
+
return useSessionResult(session)
|
|
284
358
|
}
|
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)
|