@adea-ai/spatial 0.94.0 → 0.96.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.
@@ -0,0 +1,338 @@
1
+ import { boundedString, isPlainRecord, strictRecord } from './record'
2
+ import type { OriginAllowlist } from './origin'
3
+ import { SIM_SURFACES, type SimSurface } from './launch'
4
+ import { parseSimCharacterValue } from './character'
5
+ import { SIM_UNAVAILABLE_REASONS, type SimUnavailableReason } from './availability'
6
+
7
+ /**
8
+ * Versioned message schema between the Adea host window and the hosted Agent Sim frame.
9
+ *
10
+ * The bridge is a closed set of typed messages and nothing else: there is no generic call,
11
+ * eval, filesystem, native-bridge or credential message, and no field of any message may carry a
12
+ * token. Every message is validated against the exact frame origin, the exact frame window, the
13
+ * per-mount `bridgeId` the host issued, the protocol version and a strict payload shape. Anything
14
+ * else is dropped and never acted on.
15
+ */
16
+
17
+ export const SIM_BRIDGE_CHANNEL = 'adea.agent-sim.bridge'
18
+ export const SIM_BRIDGE_VERSION = 1
19
+ export const MAX_SIM_MESSAGE_BYTES = 2048
20
+
21
+ const BRIDGE_ID = /^[A-Za-z0-9_-]{16,64}$/
22
+ const LAUNCH_CODE = /^[A-Za-z0-9_-]{32,128}$/
23
+ const REQUEST_ID = /^[A-Za-z0-9_-]{1,32}$/
24
+
25
+ export const SIM_FRAME_STATES = ['loading', 'running', 'unavailable', 'offline'] as const
26
+ export type SimFrameState = (typeof SIM_FRAME_STATES)[number]
27
+ export const SIM_FRAME_ERRORS = [
28
+ 'launch_rejected',
29
+ 'asset_unavailable',
30
+ 'engine_failed',
31
+ 'bridge_incompatible',
32
+ ] as const
33
+ export type SimFrameError = (typeof SIM_FRAME_ERRORS)[number]
34
+
35
+ /** Where HQ may send the host: the designers only. A designer returns with `frame.close`. */
36
+ export const SIM_NAVIGATE_TARGETS = ['room-designer', 'character-designer'] as const
37
+ export type SimNavigateTarget = (typeof SIM_NAVIGATE_TARGETS)[number]
38
+ export const SIM_SAVE_FAILURES = ['refused', 'failed', 'timeout'] as const
39
+ export type SimSaveFailure = (typeof SIM_SAVE_FAILURES)[number]
40
+
41
+ /** What the frame reports about its own launch; the pure launch reducer consumes only these. */
42
+ export type SimFrameStatusMessage =
43
+ | Readonly<{ type: 'frame.ready'; payload: Readonly<Record<string, never>> }>
44
+ | Readonly<{
45
+ type: 'frame.state'
46
+ payload: Readonly<{ state: SimFrameState; reason?: SimUnavailableReason }>
47
+ }>
48
+ | Readonly<{ type: 'frame.close'; payload: Readonly<Record<string, never>> }>
49
+ | Readonly<{ type: 'frame.error'; payload: Readonly<{ code: SimFrameError }> }>
50
+
51
+ /** Closed requests the frame may make of the host. Each is answered or ends the attempt. */
52
+ export type SimFrameRequestMessage =
53
+ | Readonly<{
54
+ type: 'frame.navigate'
55
+ payload: Readonly<{ requestId: string; target: SimNavigateTarget }>
56
+ }>
57
+ | Readonly<{
58
+ type: 'frame.character.save'
59
+ payload: Readonly<{ requestId: string; character: string }>
60
+ }>
61
+
62
+ export type SimFrameMessage = SimFrameStatusMessage | SimFrameRequestMessage
63
+
64
+ export type SimHostMessage =
65
+ | Readonly<{
66
+ type: 'host.launch'
67
+ payload: Readonly<{
68
+ /** Opaque single-use code. It is exchanged at the sim origin for a sim-only session. */
69
+ code: string
70
+ expiresAt: string
71
+ surface: SimSurface
72
+ character?: string
73
+ }>
74
+ }>
75
+ | Readonly<{ type: 'host.dispose'; payload: Readonly<Record<string, never>> }>
76
+ | Readonly<{
77
+ type: 'host.character.save.result'
78
+ payload: Readonly<{ requestId: string; saved: boolean; reason?: SimSaveFailure }>
79
+ }>
80
+
81
+ export type SimBridgeFailure =
82
+ | 'origin_refused'
83
+ | 'source_mismatch'
84
+ | 'not_a_bridge_message'
85
+ | 'version_unsupported'
86
+ | 'bridge_mismatch'
87
+ | 'unknown_type'
88
+ | 'invalid_payload'
89
+ | 'too_large'
90
+
91
+ export type SimBridgeResult<T> =
92
+ | Readonly<{ ok: true; message: T }>
93
+ | Readonly<{ ok: false; reason: SimBridgeFailure }>
94
+
95
+ export type SimMessageEventLike = Readonly<{ origin: unknown; source: unknown; data: unknown }>
96
+
97
+ const ENVELOPE_KEYS = ['channel', 'version', 'bridgeId', 'type', 'payload'] as const
98
+
99
+ function envelope(
100
+ event: SimMessageEventLike,
101
+ bridgeId: string
102
+ ): SimBridgeResult<{ type: string; payload: unknown }> {
103
+ const data = strictRecord(event.data, ENVELOPE_KEYS)
104
+ if (!data || data.channel !== 'adea.agent-sim.bridge') return fail('not_a_bridge_message')
105
+ if (data.version !== SIM_BRIDGE_VERSION) return fail('version_unsupported')
106
+ if (data.bridgeId !== bridgeId) return fail('bridge_mismatch')
107
+ if (typeof data.type !== 'string' || !isPlainRecord(data.payload)) return fail('invalid_payload')
108
+ if (!withinBudget(data)) return fail('too_large')
109
+ return { ok: true, message: { type: data.type, payload: data.payload } }
110
+ }
111
+
112
+ // Traversal limits for the size check. A record costs more than its keys so that even a cyclic
113
+ // structure (reachable through structured clone) exhausts the budget in a bounded number of steps.
114
+ const MAX_NODES = 32
115
+ const MAX_DEPTH = 3
116
+ const MAX_KEYS_PER_RECORD = 16
117
+ const RECORD_COST = 16
118
+
119
+ /**
120
+ * A finite size check over plain data: strings, numbers, booleans and nested plain records. It
121
+ * stops at `MAX_NODES` nodes, `MAX_DEPTH` levels and `MAX_KEYS_PER_RECORD` keys per record, charges
122
+ * every record against the byte budget, and refuses accessors, so no input (cyclic, deep or wide)
123
+ * can make it loop or run sender code.
124
+ */
125
+ function withinBudget(value: unknown): boolean {
126
+ let budget = MAX_SIM_MESSAGE_BYTES
127
+ let nodes = 0
128
+ const stack: { item: unknown; depth: number }[] = [{ item: value, depth: 0 }]
129
+ while (stack.length > 0) {
130
+ const { item, depth } = stack.pop()!
131
+ if (++nodes > MAX_NODES) return false
132
+ if (typeof item === 'string') budget -= item.length
133
+ else if (typeof item === 'number' || typeof item === 'boolean') budget -= 8
134
+ else if (isPlainRecord(item)) {
135
+ if (depth >= MAX_DEPTH) return false
136
+ const keys = Object.getOwnPropertyNames(item)
137
+ if (keys.length > MAX_KEYS_PER_RECORD) return false
138
+ budget -= RECORD_COST
139
+ for (const key of keys) {
140
+ const descriptor = Object.getOwnPropertyDescriptor(item, key)
141
+ // Reading through a getter would run sender-controlled code; refuse accessors outright.
142
+ if (!descriptor || !('value' in descriptor)) return false
143
+ budget -= key.length + 1
144
+ stack.push({ item: descriptor.value, depth: depth + 1 })
145
+ }
146
+ } else if (item !== undefined) return false
147
+ if (budget < 0) return false
148
+ }
149
+ return true
150
+ }
151
+
152
+ function fail<T>(reason: SimBridgeFailure): SimBridgeResult<T> {
153
+ return { ok: false, reason }
154
+ }
155
+
156
+ export type SimHostContext = Readonly<{
157
+ origins: OriginAllowlist
158
+ /** The frame element's `contentWindow`, read when the event arrives. */
159
+ frameWindow: unknown
160
+ /** Issued by the host for this mount; the frame must echo it. */
161
+ bridgeId: string
162
+ }>
163
+
164
+ /** Host side: accept a message only from the exact frame window at an allowlisted origin. */
165
+ export function parseFrameMessage(
166
+ event: SimMessageEventLike,
167
+ context: SimHostContext
168
+ ): SimBridgeResult<SimFrameMessage> {
169
+ if (!BRIDGE_ID.test(context.bridgeId)) return fail('bridge_mismatch')
170
+ if (!context.origins.has(event.origin)) return fail('origin_refused')
171
+ if (event.source == null || event.source !== context.frameWindow) return fail('source_mismatch')
172
+ const opened = envelope(event, context.bridgeId)
173
+ if (!opened.ok) return opened
174
+ const { type, payload } = opened.message
175
+ switch (type) {
176
+ case 'frame.ready':
177
+ case 'frame.close':
178
+ return strictRecord(payload, [])
179
+ ? { ok: true, message: { type, payload: {} } }
180
+ : fail('invalid_payload')
181
+ case 'frame.state': {
182
+ const body = strictRecord(payload, ['state'], ['reason'])
183
+ const state = SIM_FRAME_STATES.find((item) => item === body?.state)
184
+ if (!body || !state) return fail('invalid_payload')
185
+ const reason = SIM_UNAVAILABLE_REASONS.find((item) => item === body.reason)
186
+ // A reason belongs to `unavailable` and nothing else.
187
+ if ('reason' in body ? !reason || state !== 'unavailable' : state === 'unavailable')
188
+ return fail('invalid_payload')
189
+ return { ok: true, message: { type, payload: reason ? { state, reason } : { state } } }
190
+ }
191
+ case 'frame.error': {
192
+ const body = strictRecord(payload, ['code'])
193
+ const code = SIM_FRAME_ERRORS.find((item) => item === body?.code)
194
+ return code ? { ok: true, message: { type, payload: { code } } } : fail('invalid_payload')
195
+ }
196
+ case 'frame.navigate': {
197
+ const body = strictRecord(payload, ['requestId', 'target'])
198
+ const requestId = boundedString(body?.requestId, REQUEST_ID)
199
+ const target = SIM_NAVIGATE_TARGETS.find((item) => item === body?.target)
200
+ return requestId && target
201
+ ? { ok: true, message: { type, payload: { requestId, target } } }
202
+ : fail('invalid_payload')
203
+ }
204
+ case 'frame.character.save': {
205
+ const body = strictRecord(payload, ['requestId', 'character'])
206
+ const requestId = boundedString(body?.requestId, REQUEST_ID)
207
+ const character = parseSimCharacterValue(body?.character)
208
+ return requestId && character
209
+ ? { ok: true, message: { type, payload: { requestId, character } } }
210
+ : fail('invalid_payload')
211
+ }
212
+ default:
213
+ return fail('unknown_type')
214
+ }
215
+ }
216
+
217
+ export type SimFrameContext = Readonly<{
218
+ /** The exact origin of the Adea host that embeds this frame. */
219
+ parentOrigin: string
220
+ /** `window.parent`, compared by identity. */
221
+ parentWindow: unknown
222
+ bridgeId: string
223
+ }>
224
+
225
+ /** Frame side: accept a message only from the embedding host window at its exact origin. */
226
+ export function parseHostMessage(
227
+ event: SimMessageEventLike,
228
+ context: SimFrameContext
229
+ ): SimBridgeResult<SimHostMessage> {
230
+ if (!BRIDGE_ID.test(context.bridgeId)) return fail('bridge_mismatch')
231
+ if (event.origin !== context.parentOrigin) return fail('origin_refused')
232
+ if (event.source == null || event.source !== context.parentWindow) return fail('source_mismatch')
233
+ const opened = envelope(event, context.bridgeId)
234
+ if (!opened.ok) return opened
235
+ const { type, payload } = opened.message
236
+ switch (type) {
237
+ case 'host.dispose':
238
+ return strictRecord(payload, [])
239
+ ? { ok: true, message: { type, payload: {} } }
240
+ : fail('invalid_payload')
241
+ case 'host.launch': {
242
+ const body = strictRecord(payload, ['code', 'expiresAt', 'surface'], ['character'])
243
+ const code = boundedString(body?.code, LAUNCH_CODE)
244
+ const surface = SIM_SURFACES.find((item) => item === body?.surface)
245
+ const expiresAt = body?.expiresAt
246
+ const character =
247
+ body && 'character' in body ? parseSimCharacterValue(body.character) : undefined
248
+ if (
249
+ !body ||
250
+ !code ||
251
+ !surface ||
252
+ typeof expiresAt !== 'string' ||
253
+ Number.isNaN(Date.parse(expiresAt)) ||
254
+ character === null
255
+ )
256
+ return fail('invalid_payload')
257
+ return {
258
+ ok: true,
259
+ message: {
260
+ type,
261
+ payload: { code, expiresAt, surface, ...(character ? { character } : {}) },
262
+ },
263
+ }
264
+ }
265
+ case 'host.character.save.result': {
266
+ const body = strictRecord(payload, ['requestId', 'saved'], ['reason'])
267
+ const requestId = boundedString(body?.requestId, REQUEST_ID)
268
+ if (!body || !requestId || typeof body.saved !== 'boolean') return fail('invalid_payload')
269
+ const reason = SIM_SAVE_FAILURES.find((item) => item === body.reason)
270
+ // A failure names its reason; a success has none.
271
+ if (body.saved ? 'reason' in body : !reason) return fail('invalid_payload')
272
+ return {
273
+ ok: true,
274
+ message: {
275
+ type,
276
+ payload: reason ? { requestId, saved: false, reason } : { requestId, saved: true },
277
+ },
278
+ }
279
+ }
280
+ default:
281
+ return fail('unknown_type')
282
+ }
283
+ }
284
+
285
+ /** A fresh per-mount nonce: 128 random bits as base64url. The host issues it; the frame echoes it. */
286
+ export function createBridgeId(
287
+ fill: (bytes: Uint8Array) => Uint8Array = (bytes) => crypto.getRandomValues(bytes)
288
+ ): string {
289
+ const bytes = fill(new Uint8Array(16))
290
+ let binary = ''
291
+ for (const byte of bytes) binary += String.fromCharCode(byte)
292
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
293
+ }
294
+
295
+ function wrap(bridgeId: string, type: string, payload: object) {
296
+ if (!BRIDGE_ID.test(bridgeId)) throw new Error('Invalid Agent Sim bridge id')
297
+ return {
298
+ channel: SIM_BRIDGE_CHANNEL,
299
+ version: SIM_BRIDGE_VERSION,
300
+ bridgeId,
301
+ type,
302
+ payload,
303
+ } as const
304
+ }
305
+
306
+ export function frameEnvelope(bridgeId: string, message: SimFrameMessage) {
307
+ return wrap(bridgeId, message.type, message.payload)
308
+ }
309
+
310
+ export function hostEnvelope(bridgeId: string, message: SimHostMessage) {
311
+ return wrap(bridgeId, message.type, message.payload)
312
+ }
313
+
314
+ type PostTarget = Readonly<{ postMessage(message: unknown, targetOrigin: string): void }>
315
+
316
+ /** Host to frame. The target origin must be allowlisted; `*` can never be used. */
317
+ export function postToFrame(
318
+ frameWindow: PostTarget,
319
+ frameOrigin: string,
320
+ origins: OriginAllowlist,
321
+ bridgeId: string,
322
+ message: SimHostMessage
323
+ ): void {
324
+ if (!origins.has(frameOrigin)) throw new Error('Refusing to post to an unlisted Agent Sim origin')
325
+ frameWindow.postMessage(hostEnvelope(bridgeId, message), frameOrigin)
326
+ }
327
+
328
+ /** Frame to host. The target is the exact embedding origin the frame was configured with. */
329
+ export function postToHost(
330
+ parentWindow: PostTarget,
331
+ parentOrigin: string,
332
+ bridgeId: string,
333
+ message: SimFrameMessage
334
+ ): void {
335
+ if (parentOrigin === '*' || parentOrigin === 'null' || !parentOrigin.includes('://'))
336
+ throw new Error('Refusing to post to a non-exact Agent Sim host origin')
337
+ parentWindow.postMessage(frameEnvelope(bridgeId, message), parentOrigin)
338
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Exact-origin allowlist for the hosted Agent Sim frame.
3
+ *
4
+ * An origin is allowed only by an exact string match against a canonical `scheme://host[:port]`.
5
+ * There are no wildcards, no suffix or subdomain matching, and `null`/opaque origins never match.
6
+ * Loopback `http:` origins are accepted only when the caller opts in explicitly for local
7
+ * development. Being on the allowlist (loopback included) says where a frame was served from; it
8
+ * never proves an official desktop identity or an entitlement.
9
+ */
10
+
11
+ export type OriginAllowlist = Readonly<{
12
+ /** Canonical origins in the order given. */
13
+ origins: readonly string[]
14
+ has(origin: unknown): boolean
15
+ }>
16
+
17
+ const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']
18
+
19
+ /** The canonical origin when `value` is exactly one, otherwise null. */
20
+ export function canonicalBridgeOrigin(
21
+ value: unknown,
22
+ options: Readonly<{ allowLoopbackHttp?: boolean }> = {}
23
+ ): string | null {
24
+ if (typeof value !== 'string' || value.length === 0 || value.length > 256) return null
25
+ let url: URL
26
+ try {
27
+ url = new URL(value)
28
+ } catch {
29
+ return null
30
+ }
31
+ // `origin` is "null" for opaque schemes; requiring equality also rejects paths, queries,
32
+ // credentials, trailing slashes and non-canonical spellings.
33
+ if (url.origin !== value) return null
34
+ // The URL parser tolerates `*` and similar in a host; an allowlist entry must be a real host.
35
+ if (!/^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:]+\])$/.test(url.hostname)) return null
36
+ if (url.protocol === 'https:') return value
37
+ if (
38
+ url.protocol === 'http:' &&
39
+ options.allowLoopbackHttp === true &&
40
+ LOOPBACK_HOSTS.includes(url.hostname)
41
+ )
42
+ return value
43
+ return null
44
+ }
45
+
46
+ export function createOriginAllowlist(
47
+ origins: readonly unknown[],
48
+ options: Readonly<{ allowLoopbackHttp?: boolean }> = {}
49
+ ): OriginAllowlist {
50
+ if (origins.length === 0) throw new Error('The Agent Sim bridge needs at least one origin')
51
+ const canonical = origins.map((origin) => {
52
+ const value = canonicalBridgeOrigin(origin, options)
53
+ if (value === null) throw new Error('Agent Sim bridge origins must be exact canonical origins')
54
+ return value
55
+ })
56
+ const unique = [...new Set(canonical)]
57
+ return Object.freeze({
58
+ origins: Object.freeze(unique),
59
+ has: (origin: unknown) => typeof origin === 'string' && unique.includes(origin),
60
+ })
61
+ }
@@ -0,0 +1,60 @@
1
+ /** Strict structural readers shared by the bridge validators. No coercion, no extra keys. */
2
+
3
+ export function isPlainRecord(value: unknown): value is Record<string, unknown> {
4
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
5
+ const prototype = Object.getPrototypeOf(value)
6
+ return (prototype === Object.prototype || prototype === null) && !hasSymbolKeys(value)
7
+ }
8
+
9
+ function hasSymbolKeys(value: object): boolean {
10
+ return Object.getOwnPropertySymbols(value).length > 0
11
+ }
12
+
13
+ /**
14
+ * The record when it is plain and carries exactly the allowed keys: every `required` key is
15
+ * present and no key outside `required` and `optional` exists. Accessor properties are refused so
16
+ * a getter can never run during validation.
17
+ */
18
+ export function strictRecord(
19
+ value: unknown,
20
+ required: readonly string[],
21
+ optional: readonly string[] = []
22
+ ): Record<string, unknown> | null {
23
+ if (!isPlainRecord(value)) return null
24
+ // Refuse a wide object before allocating a descriptor for each of its keys.
25
+ if (Object.getOwnPropertyNames(value).length > required.length + optional.length) return null
26
+ const descriptors = Object.getOwnPropertyDescriptors(value)
27
+ const keys = Object.keys(descriptors)
28
+ for (const key of keys) {
29
+ const descriptor = descriptors[key]!
30
+ if (!('value' in descriptor)) return null
31
+ if (!required.includes(key) && !optional.includes(key)) return null
32
+ }
33
+ for (const key of required) if (!keys.includes(key)) return null
34
+ return value
35
+ }
36
+
37
+ /**
38
+ * An own data property of a plain record, or undefined. Used to branch on a discriminant before
39
+ * the strict reader runs, without ever invoking a getter or walking the prototype chain.
40
+ */
41
+ export function ownDataValue(value: unknown, key: string): unknown {
42
+ if (!isPlainRecord(value)) return undefined
43
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
44
+ return descriptor && 'value' in descriptor ? descriptor.value : undefined
45
+ }
46
+
47
+ export function boundedString(value: unknown, pattern: RegExp): string | null {
48
+ return typeof value === 'string' && pattern.test(value) ? value : null
49
+ }
50
+
51
+ /** An ISO-8601 UTC instant as produced by `Date.prototype.toISOString`. */
52
+ export function isoInstant(value: unknown): number | null {
53
+ if (
54
+ typeof value !== 'string' ||
55
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(value)
56
+ )
57
+ return null
58
+ const time = Date.parse(value)
59
+ return Number.isFinite(time) ? time : null
60
+ }
@@ -0,0 +1,87 @@
1
+ import { parseSimCharacterValue } from './character'
2
+ import type { SimFrameRequestMessage, SimHostMessage } from './messages'
3
+
4
+ /**
5
+ * Frame side of the acknowledged character save.
6
+ *
7
+ * `save` resolves `true` only when the host answers the very request it sent with
8
+ * `saved: true`. A refusal, a failure, a timeout, a host dispose, `dispose()`, a second save while
9
+ * one is open, an invalid value, a post that throws, and a late or mismatched answer all resolve
10
+ * `false`, so the engine never shows a saved value the host did not commit.
11
+ */
12
+
13
+ export type CharacterSaveClient = Readonly<{
14
+ save(character: string): Promise<boolean>
15
+ /** Feed every parsed host message in; unrelated ones are ignored. */
16
+ receive(message: SimHostMessage): void
17
+ /** Fails any open save. Call when the frame is torn down. */
18
+ dispose(): void
19
+ }>
20
+
21
+ export type CharacterSaveClientOptions = Readonly<{
22
+ post(message: SimFrameRequestMessage): void
23
+ /** Longer than the host's own timeout so the host's answer normally arrives first. */
24
+ timeoutMs?: number
25
+ mintRequestId?: () => string
26
+ schedule?: (run: () => void, ms: number) => () => void
27
+ }>
28
+
29
+ export const DEFAULT_SAVE_CLIENT_TIMEOUT_MS = 8_000
30
+
31
+ export function createCharacterSaveClient(
32
+ options: CharacterSaveClientOptions
33
+ ): CharacterSaveClient {
34
+ const timeoutMs = options.timeoutMs ?? DEFAULT_SAVE_CLIENT_TIMEOUT_MS
35
+ const schedule =
36
+ options.schedule ??
37
+ ((run, ms) => {
38
+ const id = setTimeout(run, ms)
39
+ return () => clearTimeout(id)
40
+ })
41
+ let counter = 0
42
+ const mint = options.mintRequestId ?? (() => `save${++counter}`)
43
+ let open: { requestId: string; settle(saved: boolean): void } | null = null
44
+ let disposed = false
45
+
46
+ function failOpen() {
47
+ open?.settle(false)
48
+ }
49
+
50
+ return {
51
+ save(character) {
52
+ if (disposed || open || parseSimCharacterValue(character) === null)
53
+ return Promise.resolve(false)
54
+ const requestId = mint()
55
+ return new Promise<boolean>((resolve) => {
56
+ const cancel = schedule(() => failOpen(), timeoutMs)
57
+ const entry = {
58
+ requestId,
59
+ settle(saved: boolean) {
60
+ if (open !== entry) return
61
+ open = null
62
+ cancel()
63
+ resolve(saved)
64
+ },
65
+ }
66
+ open = entry
67
+ try {
68
+ options.post({ type: 'frame.character.save', payload: { requestId, character } })
69
+ } catch {
70
+ entry.settle(false)
71
+ }
72
+ })
73
+ },
74
+ receive(message) {
75
+ if (message.type === 'host.dispose') failOpen()
76
+ else if (
77
+ message.type === 'host.character.save.result' &&
78
+ open?.requestId === message.payload.requestId
79
+ )
80
+ open.settle(message.payload.saved)
81
+ },
82
+ dispose() {
83
+ disposed = true
84
+ failOpen()
85
+ },
86
+ }
87
+ }