@braidlabs/core 0.1.1 → 0.2.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/package.json +2 -2
- package/src/adapters/adapter.d.ts +13 -0
- package/src/adapters/adapter.js.map +1 -1
- package/src/adapters/compat-adapter.d.ts +7 -0
- package/src/adapters/compat-adapter.js +24 -16
- package/src/adapters/compat-adapter.js.map +1 -1
- package/src/adapters/contract-adapter.d.ts +21 -0
- package/src/adapters/contract-adapter.js +71 -0
- package/src/adapters/contract-adapter.js.map +1 -0
- package/src/config.d.ts +29 -0
- package/src/config.js +16 -0
- package/src/config.js.map +1 -1
- package/src/context/context-bus.d.ts +63 -3
- package/src/context/context-bus.js +147 -61
- package/src/context/context-bus.js.map +1 -1
- package/src/elements/fragment-slot.d.ts +114 -1
- package/src/elements/fragment-slot.js +743 -60
- package/src/elements/fragment-slot.js.map +1 -1
- package/src/env/create-env.d.ts +20 -8
- package/src/env/create-env.js +48 -16
- package/src/env/create-env.js.map +1 -1
- package/src/env/define-fragment.d.ts +43 -0
- package/src/env/define-fragment.js +23 -0
- package/src/env/define-fragment.js.map +1 -0
- package/src/env/fragment-env.d.ts +20 -1
- package/src/env/fragment-env.js +0 -12
- package/src/env/fragment-env.js.map +1 -1
- package/src/errors.d.ts +1 -1
- package/src/errors.js.map +1 -1
- package/src/index.d.ts +14 -1
- package/src/index.js +7 -1
- package/src/index.js.map +1 -1
- package/src/protocol.d.ts +20 -4
- package/src/protocol.js +23 -7
- package/src/protocol.js.map +1 -1
- package/src/realm/realm-manager.d.ts +19 -2
- package/src/realm/realm-manager.js +32 -7
- package/src/realm/realm-manager.js.map +1 -1
- package/src/realm/sandbox-realm.d.ts +95 -0
- package/src/realm/sandbox-realm.js +194 -0
- package/src/realm/sandbox-realm.js.map +1 -0
- package/src/weave/capabilities.d.ts +71 -0
- package/src/weave/capabilities.js +61 -0
- package/src/weave/capabilities.js.map +1 -0
- package/src/weave/channel.d.ts +63 -0
- package/src/weave/channel.js +189 -0
- package/src/weave/channel.js.map +1 -0
- package/src/weave/closing.d.ts +99 -0
- package/src/weave/closing.js +130 -0
- package/src/weave/closing.js.map +1 -0
- package/src/weave/context-bridge.d.ts +81 -0
- package/src/weave/context-bridge.js +145 -0
- package/src/weave/context-bridge.js.map +1 -0
- package/src/weave/contract.d.ts +89 -0
- package/src/weave/contract.js +143 -0
- package/src/weave/contract.js.map +1 -0
- package/src/weave/envelope.d.ts +80 -0
- package/src/weave/envelope.js +49 -0
- package/src/weave/envelope.js.map +1 -0
- package/src/weave/guest.d.ts +59 -0
- package/src/weave/guest.js +120 -0
- package/src/weave/guest.js.map +1 -0
- package/src/weave/handshake.d.ts +124 -0
- package/src/weave/handshake.js +109 -0
- package/src/weave/handshake.js.map +1 -0
- package/src/weave/liveness.d.ts +90 -0
- package/src/weave/liveness.js +189 -0
- package/src/weave/liveness.js.map +1 -0
- package/src/weave/messages.d.ts +27 -0
- package/src/weave/messages.js +13 -0
- package/src/weave/messages.js.map +1 -0
- package/src/weave/port-backing.d.ts +19 -0
- package/src/weave/port-backing.js +54 -0
- package/src/weave/port-backing.js.map +1 -0
- package/src/weave/same-realm-backing.d.ts +21 -0
- package/src/weave/same-realm-backing.js +44 -0
- package/src/weave/same-realm-backing.js.map +1 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { BraidError } from '../errors.js';
|
|
2
|
+
import { WEAVE_VERSION, weaveId } from './envelope.js';
|
|
3
|
+
/**
|
|
4
|
+
* Five seconds: long enough to survive a slow first paint on a cold realm, short enough that a
|
|
5
|
+
* developer watching a wedged fragment gets a named error inside their attention span rather than
|
|
6
|
+
* a spinner that never resolves. Individual calls override it — the handshake uses its own.
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
9
|
+
export function createBoundaryChannel(options) {
|
|
10
|
+
const { backing, fragmentId, instance } = options;
|
|
11
|
+
const defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
12
|
+
/** Keyed by `type` and `scope` together — see `on`. `\u0000` cannot occur in either. */
|
|
13
|
+
const handlers = new Map();
|
|
14
|
+
const handlerKey = (type, scope) => `${type}\u0000${scope ?? ''}`;
|
|
15
|
+
const pending = new Map();
|
|
16
|
+
const closeController = new AbortController();
|
|
17
|
+
const fail = (message, fixHint) => new BraidError(message, { fragmentId, stage: 'boundary', ...(fixHint === undefined ? {} : { fixHint }) });
|
|
18
|
+
function settlePending(id, settle) {
|
|
19
|
+
const entry = pending.get(id);
|
|
20
|
+
if (!entry)
|
|
21
|
+
return;
|
|
22
|
+
pending.delete(id);
|
|
23
|
+
if (entry.timer !== undefined)
|
|
24
|
+
clearTimeout(entry.timer);
|
|
25
|
+
settle(entry);
|
|
26
|
+
}
|
|
27
|
+
function post(envelope) {
|
|
28
|
+
if (closeController.signal.aborted)
|
|
29
|
+
return;
|
|
30
|
+
backing.post(envelope);
|
|
31
|
+
}
|
|
32
|
+
function onInbound(envelope) {
|
|
33
|
+
if (closeController.signal.aborted)
|
|
34
|
+
return;
|
|
35
|
+
// A reply: match it to its request and settle. An unmatched reply is dropped rather than
|
|
36
|
+
// reported — it is the normal shape of a request that already timed out, and turning every
|
|
37
|
+
// slow-but-eventual answer into a console error would train people to ignore the console.
|
|
38
|
+
if (envelope.re !== undefined) {
|
|
39
|
+
settlePending(envelope.re, (entry) => {
|
|
40
|
+
if (envelope.error)
|
|
41
|
+
entry.reject(remoteError(envelope.error, fragmentId));
|
|
42
|
+
else
|
|
43
|
+
entry.resolve(envelope.payload);
|
|
44
|
+
});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const handler = handlers.get(handlerKey(envelope.type, envelope.scope));
|
|
48
|
+
if (!handler) {
|
|
49
|
+
// An unhandled request must be answered, or the caller waits out its whole deadline for a
|
|
50
|
+
// message that was never going to arrive. Say which type was unhandled: the most common
|
|
51
|
+
// cause is a version mismatch between the two ends.
|
|
52
|
+
if (envelope.reply) {
|
|
53
|
+
post(replyTo(envelope, undefined, {
|
|
54
|
+
message: `no handler for "${envelope.type}"${envelope.scope === undefined ? '' : ` on scope "${envelope.scope}"`}`,
|
|
55
|
+
stage: 'boundary',
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const meta = {
|
|
61
|
+
fragmentId: envelope.fragmentId,
|
|
62
|
+
instance: envelope.instance,
|
|
63
|
+
scope: envelope.scope,
|
|
64
|
+
type: envelope.type,
|
|
65
|
+
};
|
|
66
|
+
let result;
|
|
67
|
+
try {
|
|
68
|
+
result = handler(envelope.payload, meta);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (envelope.reply)
|
|
72
|
+
post(replyTo(envelope, undefined, errorPayload(error)));
|
|
73
|
+
// A throw from a fire-and-forget handler has nobody to tell, and swallowing it is how one
|
|
74
|
+
// fragment's bug becomes another fragment's mystery. Report it where it happened.
|
|
75
|
+
else
|
|
76
|
+
console.error(`[braid:${fragmentId}] a "${envelope.type}" handler threw`, error);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!envelope.reply)
|
|
80
|
+
return;
|
|
81
|
+
Promise.resolve(result).then((value) => post(replyTo(envelope, value)), (error) => post(replyTo(envelope, undefined, errorPayload(error))));
|
|
82
|
+
}
|
|
83
|
+
function replyTo(request, payload, error) {
|
|
84
|
+
return {
|
|
85
|
+
v: WEAVE_VERSION,
|
|
86
|
+
id: weaveId(),
|
|
87
|
+
re: request.id,
|
|
88
|
+
type: request.type,
|
|
89
|
+
fragmentId,
|
|
90
|
+
instance,
|
|
91
|
+
...(request.scope === undefined ? {} : { scope: request.scope }),
|
|
92
|
+
...(payload === undefined ? {} : { payload }),
|
|
93
|
+
...(error === undefined ? {} : { error }),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// Attached before the channel object exists, so that a backing which delivers eagerly cannot
|
|
97
|
+
// drop a message that arrives between construction and subscription.
|
|
98
|
+
const detach = backing.receive(onInbound);
|
|
99
|
+
const channel = {
|
|
100
|
+
fragmentId,
|
|
101
|
+
instance,
|
|
102
|
+
closed: closeController.signal,
|
|
103
|
+
send(type, payload, sendOptions) {
|
|
104
|
+
post({
|
|
105
|
+
v: WEAVE_VERSION,
|
|
106
|
+
id: weaveId(),
|
|
107
|
+
type,
|
|
108
|
+
fragmentId,
|
|
109
|
+
instance,
|
|
110
|
+
...(sendOptions?.scope === undefined ? {} : { scope: sendOptions.scope }),
|
|
111
|
+
...(payload === undefined ? {} : { payload }),
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
request(type, payload, requestOptions) {
|
|
115
|
+
if (closeController.signal.aborted) {
|
|
116
|
+
return Promise.reject(fail(`cannot send "${type}": the boundary channel is closed`));
|
|
117
|
+
}
|
|
118
|
+
const id = weaveId();
|
|
119
|
+
const timeoutMs = requestOptions?.timeoutMs ?? defaultTimeoutMs;
|
|
120
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
121
|
+
const timer = timeoutMs > 0
|
|
122
|
+
? setTimeout(() => {
|
|
123
|
+
settlePending(id, (entry) => entry.reject(fail(`"${type}" got no reply within ${timeoutMs}ms`, 'the fragment may not have booted, may have wedged, or may not handle this message type')));
|
|
124
|
+
}, timeoutMs)
|
|
125
|
+
: undefined;
|
|
126
|
+
pending.set(id, { resolve, reject, timer });
|
|
127
|
+
post({
|
|
128
|
+
v: WEAVE_VERSION,
|
|
129
|
+
id,
|
|
130
|
+
reply: true,
|
|
131
|
+
type,
|
|
132
|
+
fragmentId,
|
|
133
|
+
instance,
|
|
134
|
+
...(requestOptions?.scope === undefined ? {} : { scope: requestOptions.scope }),
|
|
135
|
+
...(payload === undefined ? {} : { payload }),
|
|
136
|
+
});
|
|
137
|
+
return promise;
|
|
138
|
+
},
|
|
139
|
+
on(type, handler, onOptions) {
|
|
140
|
+
const key = handlerKey(type, onOptions?.scope);
|
|
141
|
+
handlers.set(key, handler);
|
|
142
|
+
return () => {
|
|
143
|
+
if (handlers.get(key) === handler)
|
|
144
|
+
handlers.delete(key);
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
close() {
|
|
148
|
+
if (closeController.signal.aborted)
|
|
149
|
+
return;
|
|
150
|
+
closeController.abort();
|
|
151
|
+
// Every in-flight request is rejected before the transport goes away. The alternative — let
|
|
152
|
+
// them time out — leaves a fragment's teardown path waiting seconds on answers that can no
|
|
153
|
+
// longer arrive, which is exactly the hang Phase 3 exists to prevent.
|
|
154
|
+
for (const id of [...pending.keys()]) {
|
|
155
|
+
settlePending(id, (entry) => entry.reject(fail('the boundary channel closed before a reply arrived')));
|
|
156
|
+
}
|
|
157
|
+
handlers.clear();
|
|
158
|
+
detach();
|
|
159
|
+
backing.close();
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
options.signal?.addEventListener('abort', () => channel.close(), { once: true });
|
|
163
|
+
return channel;
|
|
164
|
+
}
|
|
165
|
+
function errorPayload(error) {
|
|
166
|
+
if (error instanceof BraidError) {
|
|
167
|
+
return {
|
|
168
|
+
message: error.message,
|
|
169
|
+
stage: error.stage,
|
|
170
|
+
...(error.fixHint === undefined ? {} : { fixHint: error.fixHint }),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return { message: error instanceof Error ? error.message : String(error) };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Rebuilds a remote failure as a local `BraidError`.
|
|
177
|
+
*
|
|
178
|
+
* The remote's stage is preserved rather than flattened to `boundary`: a contract mismatch that
|
|
179
|
+
* happened inside the fragment is a contract error that merely *travelled* over the boundary, and
|
|
180
|
+
* relabelling it would point every reader at the transport instead of at the cause.
|
|
181
|
+
*/
|
|
182
|
+
function remoteError(payload, fragmentId) {
|
|
183
|
+
return new BraidError(payload.message, {
|
|
184
|
+
fragmentId,
|
|
185
|
+
stage: payload.stage ?? 'boundary',
|
|
186
|
+
...(payload.fixHint === undefined ? {} : { fixHint: payload.fixHint }),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=channel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channel.js","sourceRoot":"","sources":["../../../../../libs/core/src/weave/channel.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { BraidError } from '../errors.js';
|
|
2
|
+
import { BoundaryChannel } from './channel.js';
|
|
3
|
+
/**
|
|
4
|
+
* Teardown as a negotiation.
|
|
5
|
+
*
|
|
6
|
+
* Before this, `#teardown()` aborted the instance signal and dropped the subtree in one synchronous
|
|
7
|
+
* step. That is fine for a fragment whose state is all on screen and wrong for every fragment that
|
|
8
|
+
* has any. With `libs/data`'s optimistic writes and persistent outbox in the picture, unmounting a
|
|
9
|
+
* fragment mid-flush discards work the user has already been told was saved — and it does so
|
|
10
|
+
* silently, which is the part that makes it a bug rather than a limitation.
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* host → CLOSE { reason, deadlineMs }
|
|
14
|
+
* fragment enters `closing` — flush the outbox, persist drafts, release locks
|
|
15
|
+
* frag → ACK { flushed, dropped }
|
|
16
|
+
* host completes teardown
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* The deadline is not negotiable and not extendable. **A teardown that can block forever is a worse
|
|
20
|
+
* bug than a teardown that loses a draft**: the first freezes a page the user is trying to leave,
|
|
21
|
+
* and the second at least leaves them somewhere. So the host proceeds when the deadline expires and
|
|
22
|
+
* reports what it could not confirm.
|
|
23
|
+
*/
|
|
24
|
+
export declare const CLOSE = "weave/close";
|
|
25
|
+
export declare const DIRTY = "frag/dirty";
|
|
26
|
+
export type CloseReason =
|
|
27
|
+
/** The slot left the DOM. */
|
|
28
|
+
'unmount'
|
|
29
|
+
/** The same fragment is about to boot again into the same slot. */
|
|
30
|
+
| 'reload'
|
|
31
|
+
/** The host is navigating away from the route this fragment was mounted for. */
|
|
32
|
+
| 'navigate';
|
|
33
|
+
export interface ClosePayload {
|
|
34
|
+
reason: CloseReason;
|
|
35
|
+
deadlineMs: number;
|
|
36
|
+
}
|
|
37
|
+
export interface CloseSummary {
|
|
38
|
+
/** Work sent successfully during the close. */
|
|
39
|
+
flushed: number;
|
|
40
|
+
/** Work that will not arrive — abandoned, or belonging to a handler that failed. */
|
|
41
|
+
dropped: number;
|
|
42
|
+
/**
|
|
43
|
+
* Work not sent, but safely persisted, so it will be retried on a later boot.
|
|
44
|
+
*
|
|
45
|
+
* A third category rather than a rounding of the other two, because it is genuinely neither: an
|
|
46
|
+
* outbox entry still in IndexedDB has not been flushed and has not been lost. Folding it into
|
|
47
|
+
* `dropped` would raise an alarm about data that is fine; folding it into `flushed` would claim a
|
|
48
|
+
* write reached the server when it has not left the device.
|
|
49
|
+
*/
|
|
50
|
+
deferred?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface CloseResult extends CloseSummary {
|
|
53
|
+
/** False when the deadline expired first — in which case the counts are what the host could see. */
|
|
54
|
+
acknowledged: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface DirtyPayload {
|
|
57
|
+
/** Human-readable reason the fragment has unsaved work, or null when it no longer does. */
|
|
58
|
+
reason: string | null;
|
|
59
|
+
}
|
|
60
|
+
export interface CloseFragmentOptions {
|
|
61
|
+
channel: BoundaryChannel;
|
|
62
|
+
fragmentId: string;
|
|
63
|
+
reason: CloseReason;
|
|
64
|
+
deadlineMs?: number;
|
|
65
|
+
/** Reports a deadline overrun. Defaults to `console.error`. */
|
|
66
|
+
onOverrun?(error: BraidError): void;
|
|
67
|
+
}
|
|
68
|
+
/** Host side: asks the fragment to close, and waits — but only for so long. */
|
|
69
|
+
export declare function closeFragment(options: CloseFragmentOptions): Promise<CloseResult>;
|
|
70
|
+
export interface CloseAndDisposeOptions extends CloseFragmentOptions {
|
|
71
|
+
/** Aborts the instance signal and closes the channel. Called once, after the negotiation ends. */
|
|
72
|
+
dispose(): void;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The ordering the whole phase exists to establish: **negotiate, then dispose.**
|
|
76
|
+
*
|
|
77
|
+
* Named and extracted rather than inlined into `<fragment-slot>` because it is a one-line sequence
|
|
78
|
+
* that is worthless in the wrong order — a `dispose()` before the await aborts `env.signal` while
|
|
79
|
+
* the fragment is still flushing, which is precisely the silent data loss this phase set out to
|
|
80
|
+
* fix, and it would still pass every test that only checked the counts.
|
|
81
|
+
*/
|
|
82
|
+
export declare function closeAndDispose(options: CloseAndDisposeOptions): Promise<CloseResult>;
|
|
83
|
+
export type ClosingHandler = (reason: CloseReason) => void | Partial<CloseSummary> | Promise<void | Partial<CloseSummary>>;
|
|
84
|
+
export interface ClosingCoordinator {
|
|
85
|
+
/** Registers work to run before the instance signal aborts. Returns an unregister function. */
|
|
86
|
+
onClosing(handler: ClosingHandler): () => void;
|
|
87
|
+
/** Declares (or clears) unsaved work. Pushed to the host as it changes, not asked for at close. */
|
|
88
|
+
setDirty(reason: string | null): void;
|
|
89
|
+
readonly dirty: string | null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Fragment side: answers CLOSE by running every registered handler, and tracks dirty state.
|
|
93
|
+
*
|
|
94
|
+
* Dirty state is a **continuous** channel rather than a question asked during teardown, and that is
|
|
95
|
+
* deliberate. Asking "do you have unsaved work?" at close time is too late whenever the answer
|
|
96
|
+
* needs a round trip of its own — and a host that wants to warn the user before a page unload needs
|
|
97
|
+
* the answer synchronously, at a moment when it cannot await anything at all.
|
|
98
|
+
*/
|
|
99
|
+
export declare function createClosingCoordinator(channel: BoundaryChannel): ClosingCoordinator;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { BraidError } from '../errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Teardown as a negotiation.
|
|
4
|
+
*
|
|
5
|
+
* Before this, `#teardown()` aborted the instance signal and dropped the subtree in one synchronous
|
|
6
|
+
* step. That is fine for a fragment whose state is all on screen and wrong for every fragment that
|
|
7
|
+
* has any. With `libs/data`'s optimistic writes and persistent outbox in the picture, unmounting a
|
|
8
|
+
* fragment mid-flush discards work the user has already been told was saved — and it does so
|
|
9
|
+
* silently, which is the part that makes it a bug rather than a limitation.
|
|
10
|
+
*
|
|
11
|
+
* ```
|
|
12
|
+
* host → CLOSE { reason, deadlineMs }
|
|
13
|
+
* fragment enters `closing` — flush the outbox, persist drafts, release locks
|
|
14
|
+
* frag → ACK { flushed, dropped }
|
|
15
|
+
* host completes teardown
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The deadline is not negotiable and not extendable. **A teardown that can block forever is a worse
|
|
19
|
+
* bug than a teardown that loses a draft**: the first freezes a page the user is trying to leave,
|
|
20
|
+
* and the second at least leaves them somewhere. So the host proceeds when the deadline expires and
|
|
21
|
+
* reports what it could not confirm.
|
|
22
|
+
*/
|
|
23
|
+
export const CLOSE = 'weave/close';
|
|
24
|
+
export const DIRTY = 'frag/dirty';
|
|
25
|
+
/** Two seconds: long enough for a queued flush over a slow connection, short enough to leave on. */
|
|
26
|
+
const CLOSE_DEADLINE_MS = 2_000;
|
|
27
|
+
/** Host side: asks the fragment to close, and waits — but only for so long. */
|
|
28
|
+
export async function closeFragment(options) {
|
|
29
|
+
const { channel, fragmentId, reason } = options;
|
|
30
|
+
const deadlineMs = options.deadlineMs ?? CLOSE_DEADLINE_MS;
|
|
31
|
+
const payload = { reason, deadlineMs };
|
|
32
|
+
try {
|
|
33
|
+
const summary = await channel.request(CLOSE, payload, { timeoutMs: deadlineMs });
|
|
34
|
+
return {
|
|
35
|
+
acknowledged: true,
|
|
36
|
+
flushed: Number(summary?.flushed ?? 0),
|
|
37
|
+
dropped: Number(summary?.dropped ?? 0),
|
|
38
|
+
deferred: Number(summary?.deferred ?? 0),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (cause) {
|
|
42
|
+
/**
|
|
43
|
+
* No ACK. The counts are unknown rather than zero, and saying so is the point of this branch —
|
|
44
|
+
* a fragment that wedged mid-flush may have persisted everything, nothing, or half of it, and
|
|
45
|
+
* a report of `dropped: 0` here would be a lie told in a reassuring tone.
|
|
46
|
+
*/
|
|
47
|
+
const error = new BraidError(`the fragment did not acknowledge close within ${deadlineMs}ms`, {
|
|
48
|
+
fragmentId,
|
|
49
|
+
stage: 'teardown',
|
|
50
|
+
cause,
|
|
51
|
+
fixHint: 'the fragment may have unflushed work — check its env.onClosing() handlers, or raise the deadline ' +
|
|
52
|
+
'if a slow flush is expected here',
|
|
53
|
+
});
|
|
54
|
+
(options.onOverrun ?? ((reported) => console.error(reported)))(error);
|
|
55
|
+
return { acknowledged: false, flushed: 0, dropped: 0 };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The ordering the whole phase exists to establish: **negotiate, then dispose.**
|
|
60
|
+
*
|
|
61
|
+
* Named and extracted rather than inlined into `<fragment-slot>` because it is a one-line sequence
|
|
62
|
+
* that is worthless in the wrong order — a `dispose()` before the await aborts `env.signal` while
|
|
63
|
+
* the fragment is still flushing, which is precisely the silent data loss this phase set out to
|
|
64
|
+
* fix, and it would still pass every test that only checked the counts.
|
|
65
|
+
*/
|
|
66
|
+
export async function closeAndDispose(options) {
|
|
67
|
+
const { dispose, ...closeOptions } = options;
|
|
68
|
+
const result = await closeFragment(closeOptions);
|
|
69
|
+
dispose();
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Fragment side: answers CLOSE by running every registered handler, and tracks dirty state.
|
|
74
|
+
*
|
|
75
|
+
* Dirty state is a **continuous** channel rather than a question asked during teardown, and that is
|
|
76
|
+
* deliberate. Asking "do you have unsaved work?" at close time is too late whenever the answer
|
|
77
|
+
* needs a round trip of its own — and a host that wants to warn the user before a page unload needs
|
|
78
|
+
* the answer synchronously, at a moment when it cannot await anything at all.
|
|
79
|
+
*/
|
|
80
|
+
export function createClosingCoordinator(channel) {
|
|
81
|
+
const handlers = new Set();
|
|
82
|
+
let dirty = null;
|
|
83
|
+
channel.on(CLOSE, async (payload) => {
|
|
84
|
+
const { reason } = (payload ?? { reason: 'unmount' });
|
|
85
|
+
/**
|
|
86
|
+
* `async` on the wrapper is load-bearing, not stylistic.
|
|
87
|
+
*
|
|
88
|
+
* A handler that throws *synchronously* escapes a bare `.map(handler)` before `allSettled` ever
|
|
89
|
+
* sees it, rejecting the whole CLOSE reply — so one broken handler would take every other
|
|
90
|
+
* handler's flush down with it and report nothing. The wrapper turns a synchronous throw into a
|
|
91
|
+
* rejected promise, which is the isolation the bus applies to subscribers for the same reason.
|
|
92
|
+
*/
|
|
93
|
+
const results = await Promise.allSettled([...handlers].map(async (handler) => handler(reason)));
|
|
94
|
+
let flushed = 0;
|
|
95
|
+
let dropped = 0;
|
|
96
|
+
let deferred = 0;
|
|
97
|
+
for (const result of results) {
|
|
98
|
+
if (result.status === 'rejected') {
|
|
99
|
+
// A handler that throws has failed to flush whatever it was responsible for. Counting it as
|
|
100
|
+
// a drop is the conservative reading, and the only one that does not overstate success.
|
|
101
|
+
dropped += 1;
|
|
102
|
+
console.error('braid: a closing handler threw; its work is reported as dropped', result.reason);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const summary = result.value;
|
|
106
|
+
if (summary && typeof summary === 'object') {
|
|
107
|
+
flushed += Number(summary.flushed ?? 0);
|
|
108
|
+
dropped += Number(summary.dropped ?? 0);
|
|
109
|
+
deferred += Number(summary.deferred ?? 0);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { flushed, dropped, deferred };
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
onClosing(handler) {
|
|
116
|
+
handlers.add(handler);
|
|
117
|
+
return () => void handlers.delete(handler);
|
|
118
|
+
},
|
|
119
|
+
setDirty(reason) {
|
|
120
|
+
if (dirty === reason)
|
|
121
|
+
return;
|
|
122
|
+
dirty = reason;
|
|
123
|
+
channel.send(DIRTY, { reason });
|
|
124
|
+
},
|
|
125
|
+
get dirty() {
|
|
126
|
+
return dirty;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=closing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"closing.js","sourceRoot":"","sources":["../../../../../libs/core/src/weave/closing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG1C;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,CAAC,MAAM,KAAK,GAAG,aAAa,CAAC;AACnC,MAAM,CAAC,MAAM,KAAK,GAAG,YAAY,CAAC;AAyClC,oGAAoG;AACpG,MAAM,iBAAiB,GAAG,KAAK,CAAC;AAWhC,+EAA+E;AAC/E,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAA6B;IAC/D,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAChD,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,iBAAiB,CAAC;IAE3D,MAAM,OAAO,GAAiB,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAErD,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,OAAO,CAAe,KAAK,EAAE,OAAO,EAAE,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC;QAC/F,OAAO;YACL,YAAY,EAAE,IAAI;YAClB,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,OAAO,IAAI,CAAC,CAAC;YACtC,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,OAAO,IAAI,CAAC,CAAC;YACtC,QAAQ,EAAE,MAAM,CAAC,OAAO,EAAE,QAAQ,IAAI,CAAC,CAAC;SACzC,CAAC;IACJ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf;;;;WAIG;QACH,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,iDAAiD,UAAU,IAAI,EAAE;YAC5F,UAAU;YACV,KAAK,EAAE,UAAU;YACjB,KAAK;YACL,OAAO,EACL,mGAAmG;gBACnG,kCAAkC;SACrC,CAAC,CAAC;QACH,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,CAAC,QAAoB,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;QAClF,OAAO,EAAE,YAAY,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;IACzD,CAAC;AACH,CAAC;AAOD;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,OAA+B;IACnE,MAAM,EAAE,OAAO,EAAE,GAAG,YAAY,EAAE,GAAG,OAAO,CAAC;IAC7C,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,YAAY,CAAC,CAAC;IACjD,OAAO,EAAE,CAAC;IACV,OAAO,MAAM,CAAC;AAChB,CAAC;AAYD;;;;;;;GAOG;AACH,MAAM,UAAU,wBAAwB,CAAC,OAAwB;IAC/D,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC3C,IAAI,KAAK,GAAkB,IAAI,CAAC;IAEhC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAyB,EAAE;QACzD,MAAM,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,CAAiB,CAAC;QAEtE;;;;;;;WAOG;QACH,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAEhG,IAAI,OAAO,GAAG,CAAC,CAAC;QAChB,IAAI,OAAO,GAAG,CAAC,CAAC;QAChB,IAAI,QAAQ,GAAG,CAAC,CAAC;QACjB,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;YAC7B,IAAI,MAAM,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;gBACjC,4FAA4F;gBAC5F,wFAAwF;gBACxF,OAAO,IAAI,CAAC,CAAC;gBACb,OAAO,CAAC,KAAK,CAAC,iEAAiE,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;gBAChG,SAAS;YACX,CAAC;YACD,MAAM,OAAO,GAAG,MAAM,CAAC,KAAK,CAAC;YAC7B,IAAI,OAAO,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;gBAC3C,OAAO,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC;gBACxC,OAAO,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC;gBACxC,QAAQ,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAC;YAC5C,CAAC;QACH,CAAC;QAED,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IACxC,CAAC,CAAC,CAAC;IAEH,OAAO;QACL,SAAS,CAAC,OAAO;YACf,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YACtB,OAAO,GAAG,EAAE,CAAC,KAAK,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QAC7C,CAAC;QAED,QAAQ,CAAC,MAAM;YACb,IAAI,KAAK,KAAK,MAAM;gBAAE,OAAO;YAC7B,KAAK,GAAG,MAAM,CAAC;YACf,OAAO,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,MAAM,EAAyB,CAAC,CAAC;QACzD,CAAC;QAED,IAAI,KAAK;YACP,OAAO,KAAK,CAAC;QACf,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { EnvContext } from '../env/fragment-env.js';
|
|
2
|
+
import type { ContextBusLike } from '../context/context-bus.js';
|
|
3
|
+
import { BoundaryChannel } from './channel.js';
|
|
4
|
+
import { OpenPayload } from './handshake.js';
|
|
5
|
+
import { FragmentCapabilities } from './capabilities.js';
|
|
6
|
+
/**
|
|
7
|
+
* Context across the boundary — a **mirror**, not a remote call.
|
|
8
|
+
*
|
|
9
|
+
* The obvious implementation is a request per read: `env.context.get('cart')` sends `ctx/get` and
|
|
10
|
+
* awaits a reply. It is also wrong, and wrong in a way that would be very expensive to walk back.
|
|
11
|
+
* `EnvContext.get` is synchronous today, and a transport that cannot preserve that forces the
|
|
12
|
+
* signature to become `Promise<unknown>` — which is a public API break, and worse, a break that
|
|
13
|
+
* would land differently on each tier: synchronous on trusted, asynchronous on untrusted.
|
|
14
|
+
*
|
|
15
|
+
* So the fragment holds a local mirror instead. The host seeds it in full at OPEN and pushes every
|
|
16
|
+
* subsequent change. Reads never leave the realm, so `get` stays synchronous forever and works
|
|
17
|
+
* unchanged over a `MessagePort` in Phase 5.
|
|
18
|
+
*
|
|
19
|
+
* Three consequences worth naming, because each is an improvement rather than a compromise:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Projection happens once per change, not once per read.** The host projects a value to the
|
|
22
|
+
* fragment's declared version when it pushes; today's bus re-projects on every single `get`.
|
|
23
|
+
* 2. **Reachability is decided at the handshake**, before any state has crossed, rather than at
|
|
24
|
+
* first subscribe. The bus already argues for the earlier of two moments; this is earlier still.
|
|
25
|
+
* 3. **Phase 6's grants have somewhere to live.** What a fragment may read becomes a question about
|
|
26
|
+
* what enters its mirror, asked in one place, rather than a check to remember at every read.
|
|
27
|
+
*/
|
|
28
|
+
export declare const CTX_CHANGED = "ctx/changed";
|
|
29
|
+
export interface ContextChangedPayload {
|
|
30
|
+
key: string;
|
|
31
|
+
/** Absent when the key was removed or became unprojectable for this fragment. */
|
|
32
|
+
value?: unknown;
|
|
33
|
+
}
|
|
34
|
+
export interface AttachContextRouterOptions {
|
|
35
|
+
/**
|
|
36
|
+
* The bus this fragment's context is routed to.
|
|
37
|
+
*
|
|
38
|
+
* Injected, never imported — see `ContextBusLike`. Most fragments resolve to the page bus; one
|
|
39
|
+
* given a `scope` resolves to a bus of its own, and this argument is the only thing that differs.
|
|
40
|
+
*/
|
|
41
|
+
bus: ContextBusLike;
|
|
42
|
+
/**
|
|
43
|
+
* Which bus this router speaks for. `undefined` is the page bus.
|
|
44
|
+
*
|
|
45
|
+
* Stamped onto every `ctx/changed` it sends and matched against what the mirror expects, so a
|
|
46
|
+
* fragment attached to two buses can tell their traffic apart. Reserved on the envelope since
|
|
47
|
+
* Phase 1 for exactly this.
|
|
48
|
+
*/
|
|
49
|
+
scope?: string;
|
|
50
|
+
channel: BoundaryChannel;
|
|
51
|
+
fragmentId: string;
|
|
52
|
+
/** The versions agreed at handshake, already merged with the manifest's. */
|
|
53
|
+
contextVersions: Readonly<Record<string, number>>;
|
|
54
|
+
/**
|
|
55
|
+
* What this fragment may read. Absent grants everything, which is the pre-capability behaviour.
|
|
56
|
+
*
|
|
57
|
+
* Applied here rather than at the read site because the mirror is the choke point: a key that
|
|
58
|
+
* never enters it cannot be read, cannot be subscribed to, and cannot be inferred from a
|
|
59
|
+
* subscription that never fires.
|
|
60
|
+
*/
|
|
61
|
+
capabilities?: FragmentCapabilities;
|
|
62
|
+
signal: AbortSignal;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Host side: projects the bus into one fragment's mirror and keeps it current.
|
|
66
|
+
*
|
|
67
|
+
* Returns the opening snapshot, which the caller puts on the OPEN payload — the router does not
|
|
68
|
+
* send it itself, because OPEN carries props too and two messages where one would do is two chances
|
|
69
|
+
* for a fragment to observe half a world.
|
|
70
|
+
*/
|
|
71
|
+
export declare function attachContextRouter(options: AttachContextRouterOptions): Record<string, unknown>;
|
|
72
|
+
/**
|
|
73
|
+
* Fragment side: the mirror, presented as the `EnvContext` a fragment already expects.
|
|
74
|
+
*
|
|
75
|
+
* Note what is *not* here — no version negotiation, no projection, no Skew. All of that stayed in
|
|
76
|
+
* the host bus, which is the point: the fragment receives values already in the shape it declared,
|
|
77
|
+
* so the migration engine never has to run in a realm whose code may be older than the schemas.
|
|
78
|
+
*/
|
|
79
|
+
export declare function createContextMirror(channel: BoundaryChannel, scope?: string): EnvContext & {
|
|
80
|
+
seed(open: OpenPayload): void;
|
|
81
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createContextGate } from './capabilities.js';
|
|
2
|
+
/**
|
|
3
|
+
* Context across the boundary — a **mirror**, not a remote call.
|
|
4
|
+
*
|
|
5
|
+
* The obvious implementation is a request per read: `env.context.get('cart')` sends `ctx/get` and
|
|
6
|
+
* awaits a reply. It is also wrong, and wrong in a way that would be very expensive to walk back.
|
|
7
|
+
* `EnvContext.get` is synchronous today, and a transport that cannot preserve that forces the
|
|
8
|
+
* signature to become `Promise<unknown>` — which is a public API break, and worse, a break that
|
|
9
|
+
* would land differently on each tier: synchronous on trusted, asynchronous on untrusted.
|
|
10
|
+
*
|
|
11
|
+
* So the fragment holds a local mirror instead. The host seeds it in full at OPEN and pushes every
|
|
12
|
+
* subsequent change. Reads never leave the realm, so `get` stays synchronous forever and works
|
|
13
|
+
* unchanged over a `MessagePort` in Phase 5.
|
|
14
|
+
*
|
|
15
|
+
* Three consequences worth naming, because each is an improvement rather than a compromise:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Projection happens once per change, not once per read.** The host projects a value to the
|
|
18
|
+
* fragment's declared version when it pushes; today's bus re-projects on every single `get`.
|
|
19
|
+
* 2. **Reachability is decided at the handshake**, before any state has crossed, rather than at
|
|
20
|
+
* first subscribe. The bus already argues for the earlier of two moments; this is earlier still.
|
|
21
|
+
* 3. **Phase 6's grants have somewhere to live.** What a fragment may read becomes a question about
|
|
22
|
+
* what enters its mirror, asked in one place, rather than a check to remember at every read.
|
|
23
|
+
*/
|
|
24
|
+
export const CTX_CHANGED = 'ctx/changed';
|
|
25
|
+
/**
|
|
26
|
+
* Host side: projects the bus into one fragment's mirror and keeps it current.
|
|
27
|
+
*
|
|
28
|
+
* Returns the opening snapshot, which the caller puts on the OPEN payload — the router does not
|
|
29
|
+
* send it itself, because OPEN carries props too and two messages where one would do is two chances
|
|
30
|
+
* for a fragment to observe half a world.
|
|
31
|
+
*/
|
|
32
|
+
export function attachContextRouter(options) {
|
|
33
|
+
const { bus, channel, fragmentId, contextVersions, signal } = options;
|
|
34
|
+
const mayRead = createContextGate(options.capabilities, fragmentId);
|
|
35
|
+
const readFor = (key) => {
|
|
36
|
+
const as = contextVersions[key];
|
|
37
|
+
try {
|
|
38
|
+
return { ok: true, value: bus.get(key, { ...(as === undefined ? {} : { as }), fragmentId }) };
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
/**
|
|
42
|
+
* One unprojectable key must not cost the fragment every other key.
|
|
43
|
+
*
|
|
44
|
+
* The handshake already refused the versions it could see, so reaching here means a schema
|
|
45
|
+
* was registered — or a key first published — after this fragment mounted, which is a
|
|
46
|
+
* deployment race rather than a misconfiguration the developer can fix in advance. Report it
|
|
47
|
+
* and leave that one key absent.
|
|
48
|
+
*/
|
|
49
|
+
console.error(`[braid:${fragmentId}] context "${key}" could not be projected for this fragment`, error);
|
|
50
|
+
return { ok: false };
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const snapshot = {};
|
|
54
|
+
for (const key of bus.keys()) {
|
|
55
|
+
if (!mayRead(key))
|
|
56
|
+
continue;
|
|
57
|
+
const read = readFor(key);
|
|
58
|
+
if (read.ok)
|
|
59
|
+
snapshot[key] = read.value;
|
|
60
|
+
}
|
|
61
|
+
const unobserve = bus.observe((key) => {
|
|
62
|
+
if (signal.aborted)
|
|
63
|
+
return;
|
|
64
|
+
/**
|
|
65
|
+
* A denied key sends **nothing at all** — not a change, not a deletion.
|
|
66
|
+
*
|
|
67
|
+
* The obvious implementation sends a valueless `ctx/changed` and lets the mirror treat it as an
|
|
68
|
+
* absence, which produces the right value and the wrong behaviour: the fragment learns that a
|
|
69
|
+
* key it was refused exists, and learns the exact moment it changes. That is a timing channel
|
|
70
|
+
* on precisely the data the grant was written to withhold, and it is invisible in any test that
|
|
71
|
+
* only checks what `get()` returns.
|
|
72
|
+
*/
|
|
73
|
+
if (!mayRead(key))
|
|
74
|
+
return;
|
|
75
|
+
const read = readFor(key);
|
|
76
|
+
/**
|
|
77
|
+
* An unprojectable key *does* send the valueless form, and the difference matters: that key was
|
|
78
|
+
* granted, so clearing a now-stale mirror entry is the honest thing to do, and the fragment was
|
|
79
|
+
* always allowed to know it exists.
|
|
80
|
+
*/
|
|
81
|
+
const payload = read.ok ? { key, value: read.value } : { key };
|
|
82
|
+
channel.send(CTX_CHANGED, payload, ...(options.scope === undefined ? [] : [{ scope: options.scope }]));
|
|
83
|
+
});
|
|
84
|
+
signal.addEventListener('abort', unobserve, { once: true });
|
|
85
|
+
return snapshot;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Fragment side: the mirror, presented as the `EnvContext` a fragment already expects.
|
|
89
|
+
*
|
|
90
|
+
* Note what is *not* here — no version negotiation, no projection, no Skew. All of that stayed in
|
|
91
|
+
* the host bus, which is the point: the fragment receives values already in the shape it declared,
|
|
92
|
+
* so the migration engine never has to run in a realm whose code may be older than the schemas.
|
|
93
|
+
*/
|
|
94
|
+
export function createContextMirror(channel, scope) {
|
|
95
|
+
const values = new Map();
|
|
96
|
+
const listeners = new Map();
|
|
97
|
+
/**
|
|
98
|
+
* Registered *on this mirror's scope*, so two mirrors on one channel do not displace each other.
|
|
99
|
+
*
|
|
100
|
+
* Without scope in the address, a fragment reading both its own bus and the page bus would have
|
|
101
|
+
* the second registration silently replace the first, and one of the two buses would simply go
|
|
102
|
+
* quiet — a failure with no error and no symptom except missing updates.
|
|
103
|
+
*/
|
|
104
|
+
channel.on(CTX_CHANGED, (payload) => {
|
|
105
|
+
const { key, value } = (payload ?? {});
|
|
106
|
+
if (typeof key !== 'string')
|
|
107
|
+
return;
|
|
108
|
+
if (value === undefined)
|
|
109
|
+
values.delete(key);
|
|
110
|
+
else
|
|
111
|
+
values.set(key, value);
|
|
112
|
+
for (const listener of [...(listeners.get(key) ?? [])]) {
|
|
113
|
+
try {
|
|
114
|
+
listener(value);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// Same isolation rule the bus applies, for the same reason: one component's broken
|
|
118
|
+
// effect must not present as a different component silently failing to update.
|
|
119
|
+
console.error(`braid: a "${key}" context subscriber threw; other subscribers were unaffected`, error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}, ...(scope === undefined ? [] : [{ scope }]));
|
|
123
|
+
return {
|
|
124
|
+
seed(open) {
|
|
125
|
+
for (const [key, value] of Object.entries(open.context ?? {}))
|
|
126
|
+
values.set(key, value);
|
|
127
|
+
},
|
|
128
|
+
get(key) {
|
|
129
|
+
return values.get(key);
|
|
130
|
+
},
|
|
131
|
+
subscribe(key, listener, subscribeOptions) {
|
|
132
|
+
let keyListeners = listeners.get(key);
|
|
133
|
+
if (!keyListeners) {
|
|
134
|
+
keyListeners = new Set();
|
|
135
|
+
listeners.set(key, keyListeners);
|
|
136
|
+
}
|
|
137
|
+
keyListeners.add(listener);
|
|
138
|
+
const unsubscribe = () => void keyListeners.delete(listener);
|
|
139
|
+
subscribeOptions?.signal?.addEventListener('abort', unsubscribe, { once: true });
|
|
140
|
+
channel.closed.addEventListener('abort', unsubscribe, { once: true });
|
|
141
|
+
return unsubscribe;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=context-bridge.js.map
|