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.
- package/CHANGELOG.md +207 -0
- package/dist/ai-CFNeCrRl.d.ts +334 -0
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +49 -9
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +123 -49
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +233 -86
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +599 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +7 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2 -0
- package/dist/client-BKlyLiOU.js +1008 -0
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/client-D7mvIXrF.d.ts +191 -0
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +152 -0
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +340 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +3 -1
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +4 -3
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +226 -43
- package/dist/devtools-server.js.map +1 -0
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +135 -28
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +647 -61
- package/dist/http.js.map +1 -0
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/internal-DstsI6Re.js +211 -0
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +3 -2
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +47 -15
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +30 -15
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +79 -0
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +501 -0
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +56 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +228 -0
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/server-C72KOw51.js +1457 -0
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/server-DUF9pjsx.d.ts +261 -0
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +10 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +12 -0
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/{log-memory.js → store-memory.js} +145 -34
- package/dist/store-memory.js.map +1 -0
- package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/{log-postgres.js → store-postgres.js} +214 -24
- package/dist/store-postgres.js.map +1 -0
- package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
- package/dist/store-redis-http.js.map +1 -0
- package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/{log-redis.js → store-redis.js} +101 -39
- package/dist/store-redis.js.map +1 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
- package/dist/store-sqlite.js.map +1 -0
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +6 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +32 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +103 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/wire-BFQmSJ-9.js +284 -0
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/01-quickstart.mdx +3 -3
- package/docs/concepts/01-contracts.mdx +18 -13
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +21 -21
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -49
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +76 -57
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +310 -61
- package/docs/guides/06-ai-agents.mdx +252 -74
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +263 -0
- package/docs/guides/10-transports.mdx +140 -0
- package/docs/index.mdx +18 -14
- package/docs/reference/01-api.mdx +839 -110
- package/docs/reference/02-errors.mdx +13 -6
- package/package.json +24 -7
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
- package/dist/internal-gCd5qMry.js +0 -44
- package/dist/log-memory.d.ts +0 -11
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-BcLa4RFL.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
package/src/internal.ts
ADDED
|
@@ -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
|
+
}
|
package/src/platform.ts
ADDED
|
@@ -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
|
+
}
|