@braidlabs/core 0.1.1 → 0.3.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,124 @@
|
|
|
1
|
+
import { BoundaryChannel } from './channel.js';
|
|
2
|
+
import { ContextBridge, FragmentContract, HostContract } from './contract.js';
|
|
3
|
+
/**
|
|
4
|
+
* The connect-time handshake: HELLO → ACCEPT → OPEN.
|
|
5
|
+
*
|
|
6
|
+
* Braid has always verified a version at boot — the gateway stamps `BRAID_PROTOCOL_VERSION` onto
|
|
7
|
+
* the realm stub and the client checks the `<meta>`. That check answers "did these two ship
|
|
8
|
+
* together?" and nothing else. It cannot answer the questions the phases above this one ask:
|
|
9
|
+
* *which instance* am I talking to, *what* does it declare it can parse, and *when* did it become
|
|
10
|
+
* ready to hear from me.
|
|
11
|
+
*
|
|
12
|
+
* Three steps rather than two, because ACCEPT and OPEN carry different things in different
|
|
13
|
+
* directions and collapsing them would force the host to send the context snapshot before it has
|
|
14
|
+
* seen what the fragment can parse — which is precisely the information that decides how the
|
|
15
|
+
* snapshot must be projected.
|
|
16
|
+
*/
|
|
17
|
+
export declare const HELLO = "weave/hello";
|
|
18
|
+
export declare const OPEN = "weave/open";
|
|
19
|
+
/** Host → fragment. What the host is, and what it is prepared to speak. */
|
|
20
|
+
export interface HelloPayload {
|
|
21
|
+
weave: number;
|
|
22
|
+
/** The client ↔ gateway composition protocol version, echoed for diagnosis only. */
|
|
23
|
+
protocol: string;
|
|
24
|
+
fragmentId: string;
|
|
25
|
+
instance: string;
|
|
26
|
+
/** What the host offers, so a fragment can refuse from its own side too. */
|
|
27
|
+
contract?: HostContract;
|
|
28
|
+
}
|
|
29
|
+
/** Fragment → host, as the reply to HELLO. What the fragment is, and what it can parse. */
|
|
30
|
+
export interface AcceptPayload {
|
|
31
|
+
weave: number;
|
|
32
|
+
/**
|
|
33
|
+
* Per-key context contract versions this fragment speaks.
|
|
34
|
+
*
|
|
35
|
+
* A fragment built months ago reads a context published today, so it declares what it can parse
|
|
36
|
+
* and every delivery is projected down to it. The registry manifest is the authority where it has
|
|
37
|
+
* an opinion — see `resolveContextVersions` — because a compromised or merely stale fragment
|
|
38
|
+
* should not be able to talk its way into a projection the host did not sanction.
|
|
39
|
+
*/
|
|
40
|
+
contextVersions?: Record<string, number>;
|
|
41
|
+
/**
|
|
42
|
+
* What this fragment requires and provides.
|
|
43
|
+
*
|
|
44
|
+
* The registry manifest overrides it where it has an opinion, for the same reason it overrides
|
|
45
|
+
* `contextVersions` — on the untrusted tier this half of the handshake is attacker-controlled,
|
|
46
|
+
* and a fragment that could state its own requirements could state them away.
|
|
47
|
+
*/
|
|
48
|
+
contract?: FragmentContract;
|
|
49
|
+
}
|
|
50
|
+
/** Host → fragment. The opening state, sent once terms are agreed. */
|
|
51
|
+
export interface OpenPayload {
|
|
52
|
+
/** Every context key, already projected to this fragment's declared versions. */
|
|
53
|
+
context: Record<string, unknown>;
|
|
54
|
+
props: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
export interface HostHandshakeOptions {
|
|
57
|
+
channel: BoundaryChannel;
|
|
58
|
+
fragmentId: string;
|
|
59
|
+
instance: string;
|
|
60
|
+
/**
|
|
61
|
+
* Context versions from the fragment's registry manifest, where the registry has an opinion.
|
|
62
|
+
* Merged over whatever the fragment declares in ACCEPT.
|
|
63
|
+
*/
|
|
64
|
+
manifestContextVersions?: Readonly<Record<string, number>>;
|
|
65
|
+
/** What this host offers. Absent when the host declares no contract. */
|
|
66
|
+
hostContract?: HostContract;
|
|
67
|
+
/** The fragment's contract from its registry manifest, overriding what it declares in ACCEPT. */
|
|
68
|
+
manifestContract?: FragmentContract;
|
|
69
|
+
/**
|
|
70
|
+
* Compares the two contracts. Injected so the handshake carries no contract logic of its own —
|
|
71
|
+
* negotiation is a policy question and this module is a protocol.
|
|
72
|
+
*/
|
|
73
|
+
negotiate?(host: HostContract | undefined, fragment: FragmentContract | undefined): {
|
|
74
|
+
outcome: 'compatible' | 'bridged' | 'incompatible';
|
|
75
|
+
bridges: ContextBridge[];
|
|
76
|
+
reason?: string;
|
|
77
|
+
fixHint?: string;
|
|
78
|
+
};
|
|
79
|
+
/** Reports a connection opened across a version gap, with what it costs. */
|
|
80
|
+
onBridged?(bridges: ContextBridge[]): void;
|
|
81
|
+
/** Builds the opening state once terms are agreed and versions are known to be reachable. */
|
|
82
|
+
openWith(contextVersions: Readonly<Record<string, number>>): OpenPayload;
|
|
83
|
+
/**
|
|
84
|
+
* Raises if a declared version cannot be projected to from what the host publishes.
|
|
85
|
+
*
|
|
86
|
+
* Checked here, at the handshake, rather than at first delivery. That is where the context bus
|
|
87
|
+
* already argues it belongs — "refusing the subscription makes it a start-up error with a
|
|
88
|
+
* fragment's name on it" — and this moves it one step earlier still, to before the fragment has
|
|
89
|
+
* been handed any state at all.
|
|
90
|
+
*/
|
|
91
|
+
assertReachable(contextVersions: Readonly<Record<string, number>>): void;
|
|
92
|
+
timeoutMs?: number;
|
|
93
|
+
}
|
|
94
|
+
export interface HandshakeResult {
|
|
95
|
+
contextVersions: Readonly<Record<string, number>>;
|
|
96
|
+
bridges: ContextBridge[];
|
|
97
|
+
}
|
|
98
|
+
/** Drives the host end. Resolves once the fragment has been sent its opening state. */
|
|
99
|
+
export declare function performHostHandshake(options: HostHandshakeOptions): Promise<HandshakeResult>;
|
|
100
|
+
export interface FragmentHandshakeOptions {
|
|
101
|
+
channel: BoundaryChannel;
|
|
102
|
+
/** What this fragment declares it can parse, per context key. */
|
|
103
|
+
contextVersions?: Readonly<Record<string, number>>;
|
|
104
|
+
/** What this fragment requires and provides. */
|
|
105
|
+
contract?: FragmentContract;
|
|
106
|
+
/** Called once the host sends the opening state. */
|
|
107
|
+
onOpen(payload: OpenPayload): void;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Answers the host end. Registers its handlers synchronously and returns a promise that resolves
|
|
111
|
+
* at OPEN, so a caller may either await readiness or ignore it — the handlers are live either way,
|
|
112
|
+
* which matters because HELLO can arrive in the same turn this is called.
|
|
113
|
+
*/
|
|
114
|
+
export declare function answerHandshake(options: FragmentHandshakeOptions): Promise<OpenPayload>;
|
|
115
|
+
/**
|
|
116
|
+
* Merges the fragment's declaration with the registry's, with the registry winning.
|
|
117
|
+
*
|
|
118
|
+
* Not an arbitrary tie-break. The manifest is host-side configuration describing a fragment; the
|
|
119
|
+
* ACCEPT payload is the fragment describing itself. On the untrusted tier the second of those is
|
|
120
|
+
* attacker-controlled, and a fragment that could name its own context version could ask to be
|
|
121
|
+
* served a shape the host never intended to project. Letting the registry win costs nothing when
|
|
122
|
+
* the two agree, which is every honest case.
|
|
123
|
+
*/
|
|
124
|
+
export declare function resolveContextVersions(declared: Readonly<Record<string, number>> | undefined, fromManifest: Readonly<Record<string, number>> | undefined): Readonly<Record<string, number>>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { BraidError } from '../errors.js';
|
|
2
|
+
import { BRAID_PROTOCOL_VERSION } from '../protocol.js';
|
|
3
|
+
import { WEAVE_VERSION } from './envelope.js';
|
|
4
|
+
/**
|
|
5
|
+
* The connect-time handshake: HELLO → ACCEPT → OPEN.
|
|
6
|
+
*
|
|
7
|
+
* Braid has always verified a version at boot — the gateway stamps `BRAID_PROTOCOL_VERSION` onto
|
|
8
|
+
* the realm stub and the client checks the `<meta>`. That check answers "did these two ship
|
|
9
|
+
* together?" and nothing else. It cannot answer the questions the phases above this one ask:
|
|
10
|
+
* *which instance* am I talking to, *what* does it declare it can parse, and *when* did it become
|
|
11
|
+
* ready to hear from me.
|
|
12
|
+
*
|
|
13
|
+
* Three steps rather than two, because ACCEPT and OPEN carry different things in different
|
|
14
|
+
* directions and collapsing them would force the host to send the context snapshot before it has
|
|
15
|
+
* seen what the fragment can parse — which is precisely the information that decides how the
|
|
16
|
+
* snapshot must be projected.
|
|
17
|
+
*/
|
|
18
|
+
export const HELLO = 'weave/hello';
|
|
19
|
+
export const OPEN = 'weave/open';
|
|
20
|
+
/**
|
|
21
|
+
* Ten seconds, and deliberately not the channel's default five.
|
|
22
|
+
*
|
|
23
|
+
* HELLO is the one request sent while the fragment is still booting — its realm may be parsing a
|
|
24
|
+
* framework bundle on a cold cache over a slow connection. Every other request happens against a
|
|
25
|
+
* fragment that has already proven it can answer, which is why they get a tighter deadline.
|
|
26
|
+
*/
|
|
27
|
+
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
28
|
+
/** Drives the host end. Resolves once the fragment has been sent its opening state. */
|
|
29
|
+
export async function performHostHandshake(options) {
|
|
30
|
+
const { channel, fragmentId, instance } = options;
|
|
31
|
+
const hello = {
|
|
32
|
+
weave: WEAVE_VERSION,
|
|
33
|
+
protocol: BRAID_PROTOCOL_VERSION,
|
|
34
|
+
fragmentId,
|
|
35
|
+
instance,
|
|
36
|
+
...(options.hostContract === undefined ? {} : { contract: options.hostContract }),
|
|
37
|
+
};
|
|
38
|
+
const accept = await channel.request(HELLO, hello, {
|
|
39
|
+
timeoutMs: options.timeoutMs ?? HANDSHAKE_TIMEOUT_MS,
|
|
40
|
+
});
|
|
41
|
+
if (accept?.weave !== WEAVE_VERSION) {
|
|
42
|
+
throw new BraidError(`the fragment answered with weave protocol v${String(accept?.weave)}, and this host speaks v${WEAVE_VERSION}`, {
|
|
43
|
+
fragmentId,
|
|
44
|
+
stage: 'handshake',
|
|
45
|
+
fixHint: 'the host and the fragment are running different major versions of @braidlabs/core — align them',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Terms before state.
|
|
50
|
+
*
|
|
51
|
+
* The negotiation runs between ACCEPT and OPEN because that is the only point where both halves
|
|
52
|
+
* are known and nothing has crossed yet. A fragment refused here has been handed no context, no
|
|
53
|
+
* props, and no reason to have started rendering.
|
|
54
|
+
*/
|
|
55
|
+
const fragmentContract = options.manifestContract ?? accept.contract;
|
|
56
|
+
const negotiation = options.negotiate?.(options.hostContract, fragmentContract) ?? {
|
|
57
|
+
outcome: 'compatible',
|
|
58
|
+
bridges: [],
|
|
59
|
+
};
|
|
60
|
+
if (negotiation.outcome === 'incompatible') {
|
|
61
|
+
throw new BraidError(negotiation.reason ?? 'the fragment’s contract is not compatible with this host', {
|
|
62
|
+
fragmentId,
|
|
63
|
+
stage: 'contract',
|
|
64
|
+
...(negotiation.fixHint === undefined ? {} : { fixHint: negotiation.fixHint }),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (negotiation.bridges.length > 0)
|
|
68
|
+
options.onBridged?.(negotiation.bridges);
|
|
69
|
+
const contextVersions = resolveContextVersions(accept.contextVersions, options.manifestContextVersions);
|
|
70
|
+
options.assertReachable(contextVersions);
|
|
71
|
+
// Fire and forget: OPEN carries state, not a question. Making it a request would mean the host
|
|
72
|
+
// blocks its own mount on a fragment acknowledging state it has already been given, and a
|
|
73
|
+
// fragment slow to acknowledge would look identical to one that never got it.
|
|
74
|
+
channel.send(OPEN, options.openWith(contextVersions));
|
|
75
|
+
return { contextVersions, bridges: negotiation.bridges };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Answers the host end. Registers its handlers synchronously and returns a promise that resolves
|
|
79
|
+
* at OPEN, so a caller may either await readiness or ignore it — the handlers are live either way,
|
|
80
|
+
* which matters because HELLO can arrive in the same turn this is called.
|
|
81
|
+
*/
|
|
82
|
+
export function answerHandshake(options) {
|
|
83
|
+
const { channel } = options;
|
|
84
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
85
|
+
channel.on(HELLO, () => ({
|
|
86
|
+
weave: WEAVE_VERSION,
|
|
87
|
+
...(options.contextVersions === undefined ? {} : { contextVersions: { ...options.contextVersions } }),
|
|
88
|
+
...(options.contract === undefined ? {} : { contract: options.contract }),
|
|
89
|
+
}));
|
|
90
|
+
channel.on(OPEN, (payload) => {
|
|
91
|
+
const open = (payload ?? { context: {}, props: {} });
|
|
92
|
+
options.onOpen(open);
|
|
93
|
+
resolve(open);
|
|
94
|
+
});
|
|
95
|
+
return promise;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Merges the fragment's declaration with the registry's, with the registry winning.
|
|
99
|
+
*
|
|
100
|
+
* Not an arbitrary tie-break. The manifest is host-side configuration describing a fragment; the
|
|
101
|
+
* ACCEPT payload is the fragment describing itself. On the untrusted tier the second of those is
|
|
102
|
+
* attacker-controlled, and a fragment that could name its own context version could ask to be
|
|
103
|
+
* served a shape the host never intended to project. Letting the registry win costs nothing when
|
|
104
|
+
* the two agree, which is every honest case.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveContextVersions(declared, fromManifest) {
|
|
107
|
+
return { ...(declared ?? {}), ...(fromManifest ?? {}) };
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=handshake.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handshake.js","sourceRoot":"","sources":["../../../../../libs/core/src/weave/handshake.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { BoundaryChannel } from './channel.js';
|
|
2
|
+
/**
|
|
3
|
+
* Liveness — the fragment as a session rather than a mount.
|
|
4
|
+
*
|
|
5
|
+
* Before this, `<fragment-slot>` had four states and `ready` was terminal: a fragment whose realm
|
|
6
|
+
* wedged in a `while (true)` reported `ready` forever, and the only symptom was a region of the page
|
|
7
|
+
* that had quietly stopped being an application. The host had no way to tell "finished rendering"
|
|
8
|
+
* from "stopped existing".
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* idle → connecting → healthy ⇄ unobservable
|
|
12
|
+
* ↓ ↓
|
|
13
|
+
* suspect → gone
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The state that makes this correct rather than merely present is **`unobservable`**. Browsers
|
|
17
|
+
* throttle timers in hidden tabs — to once a second, then far less after a few minutes — so a naive
|
|
18
|
+
* heartbeat reports mass death the moment a user switches tabs, and a team that has seen that once
|
|
19
|
+
* will turn the whole feature off. `unobservable` is not a fault: it is the host saying it has
|
|
20
|
+
* stopped being able to tell, which is a different claim from the fragment being unwell.
|
|
21
|
+
*/
|
|
22
|
+
export declare const BEAT = "weave/beat";
|
|
23
|
+
export type LivenessState = 'connecting' | 'healthy' | 'unobservable' | 'suspect' | 'gone';
|
|
24
|
+
/**
|
|
25
|
+
* Where "can the fragment be observed right now?" is answered from.
|
|
26
|
+
*
|
|
27
|
+
* An interface rather than a direct `document.visibilityState` read so the two ends can be driven
|
|
28
|
+
* from the same source in a test. Both ends must agree — the fragment stops beating and the host
|
|
29
|
+
* suspends its deadline — and they agree by consulting the same document, which holds on the
|
|
30
|
+
* trusted tier (one document) and on the untrusted one (an iframe's visibility follows its top
|
|
31
|
+
* level).
|
|
32
|
+
*/
|
|
33
|
+
export interface VisibilitySource {
|
|
34
|
+
isHidden(): boolean;
|
|
35
|
+
onChange(listener: () => void): () => void;
|
|
36
|
+
}
|
|
37
|
+
export declare function documentVisibility(): VisibilitySource;
|
|
38
|
+
/**
|
|
39
|
+
* The timer source a fragment's beats are scheduled on.
|
|
40
|
+
*
|
|
41
|
+
* This is the whole point of the fragment half, and the easiest thing to get wrong: scheduling
|
|
42
|
+
* beats on the *host's* `setInterval` would produce a perfectly steady heartbeat from a realm that
|
|
43
|
+
* had stopped executing. The beat has to be scheduled on the realm's own event loop — pass
|
|
44
|
+
* `realm.window` — so that a fragment spinning in a synchronous loop stops beating, because its
|
|
45
|
+
* timers stop firing. A beat proves the realm's task queue is turning; nothing else here does.
|
|
46
|
+
*/
|
|
47
|
+
export interface BeatScheduler {
|
|
48
|
+
setInterval(handler: () => void, ms: number): number;
|
|
49
|
+
clearInterval(id: number): void;
|
|
50
|
+
/** Used for the opening beat, which must be evidence on the same terms as every other beat. */
|
|
51
|
+
setTimeout(handler: () => void, ms: number): number;
|
|
52
|
+
}
|
|
53
|
+
export interface HostLivenessOptions {
|
|
54
|
+
channel: BoundaryChannel;
|
|
55
|
+
/** The instance whose beats count. Beats stamped with any other instance are ignored. */
|
|
56
|
+
instance: string;
|
|
57
|
+
signal: AbortSignal;
|
|
58
|
+
onState(state: LivenessState, previous: LivenessState): void;
|
|
59
|
+
beatIntervalMs?: number;
|
|
60
|
+
graceBeats?: number;
|
|
61
|
+
goneAfterMs?: number;
|
|
62
|
+
visibility?: VisibilitySource;
|
|
63
|
+
/**
|
|
64
|
+
* Listen now, judge later: the beat handler is registered immediately, and the first deadline is
|
|
65
|
+
* armed only once this settles.
|
|
66
|
+
*
|
|
67
|
+
* The two have to be separable. Registering late loses the opening beat — the guest schedules it
|
|
68
|
+
* as soon as it is open, and on a cross-origin port nothing orders that against the host finishing
|
|
69
|
+
* its own mount — which leaves a perfectly healthy fragment sitting in `connecting` for a full
|
|
70
|
+
* interval. Arming early is the opposite mistake: it puts a deadline on a fragment that is still
|
|
71
|
+
* evaluating its framework bundle, and the honest name for that state is `loading`.
|
|
72
|
+
*/
|
|
73
|
+
armAfter?: Promise<unknown>;
|
|
74
|
+
}
|
|
75
|
+
export interface HostLiveness {
|
|
76
|
+
readonly state: LivenessState;
|
|
77
|
+
stop(): void;
|
|
78
|
+
}
|
|
79
|
+
/** Host side: watches for beats and reports what it can and cannot conclude from their absence. */
|
|
80
|
+
export declare function startHostLiveness(options: HostLivenessOptions): HostLiveness;
|
|
81
|
+
export interface FragmentBeatOptions {
|
|
82
|
+
channel: BoundaryChannel;
|
|
83
|
+
/** The realm's timer source — see `BeatScheduler`. */
|
|
84
|
+
scheduler: BeatScheduler;
|
|
85
|
+
signal: AbortSignal;
|
|
86
|
+
beatIntervalMs?: number;
|
|
87
|
+
visibility?: VisibilitySource;
|
|
88
|
+
}
|
|
89
|
+
/** Fragment side: beats while it can be observed, and stays quiet while it cannot. */
|
|
90
|
+
export declare function startFragmentBeats(options: FragmentBeatOptions): () => void;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liveness — the fragment as a session rather than a mount.
|
|
3
|
+
*
|
|
4
|
+
* Before this, `<fragment-slot>` had four states and `ready` was terminal: a fragment whose realm
|
|
5
|
+
* wedged in a `while (true)` reported `ready` forever, and the only symptom was a region of the page
|
|
6
|
+
* that had quietly stopped being an application. The host had no way to tell "finished rendering"
|
|
7
|
+
* from "stopped existing".
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* idle → connecting → healthy ⇄ unobservable
|
|
11
|
+
* ↓ ↓
|
|
12
|
+
* suspect → gone
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The state that makes this correct rather than merely present is **`unobservable`**. Browsers
|
|
16
|
+
* throttle timers in hidden tabs — to once a second, then far less after a few minutes — so a naive
|
|
17
|
+
* heartbeat reports mass death the moment a user switches tabs, and a team that has seen that once
|
|
18
|
+
* will turn the whole feature off. `unobservable` is not a fault: it is the host saying it has
|
|
19
|
+
* stopped being able to tell, which is a different claim from the fragment being unwell.
|
|
20
|
+
*/
|
|
21
|
+
export const BEAT = 'weave/beat';
|
|
22
|
+
export function documentVisibility() {
|
|
23
|
+
return {
|
|
24
|
+
isHidden: () => document.visibilityState === 'hidden',
|
|
25
|
+
onChange(listener) {
|
|
26
|
+
document.addEventListener('visibilitychange', listener);
|
|
27
|
+
return () => document.removeEventListener('visibilitychange', listener);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Two seconds: three missed beats is six, comfortably under a person's patience for a dead panel. */
|
|
32
|
+
const BEAT_INTERVAL_MS = 2_000;
|
|
33
|
+
/** Three, so one dropped beat under load is not a diagnosis. */
|
|
34
|
+
const GRACE_BEATS = 3;
|
|
35
|
+
/** Fifteen seconds of silence from a fragment that should be running is not a slow fragment. */
|
|
36
|
+
const GONE_AFTER_MS = 15_000;
|
|
37
|
+
/** Host side: watches for beats and reports what it can and cannot conclude from their absence. */
|
|
38
|
+
export function startHostLiveness(options) {
|
|
39
|
+
const { channel, instance, signal } = options;
|
|
40
|
+
const beatIntervalMs = options.beatIntervalMs ?? BEAT_INTERVAL_MS;
|
|
41
|
+
const graceBeats = options.graceBeats ?? GRACE_BEATS;
|
|
42
|
+
const goneAfterMs = options.goneAfterMs ?? GONE_AFTER_MS;
|
|
43
|
+
const visibility = options.visibility ?? documentVisibility();
|
|
44
|
+
let state = 'connecting';
|
|
45
|
+
let deadline;
|
|
46
|
+
let goneTimer;
|
|
47
|
+
let stopped = false;
|
|
48
|
+
const transition = (next) => {
|
|
49
|
+
if (stopped || next === state)
|
|
50
|
+
return;
|
|
51
|
+
const previous = state;
|
|
52
|
+
state = next;
|
|
53
|
+
options.onState(next, previous);
|
|
54
|
+
};
|
|
55
|
+
const clearDeadline = () => {
|
|
56
|
+
if (deadline !== undefined)
|
|
57
|
+
clearTimeout(deadline);
|
|
58
|
+
deadline = undefined;
|
|
59
|
+
};
|
|
60
|
+
const clearGone = () => {
|
|
61
|
+
if (goneTimer !== undefined)
|
|
62
|
+
clearTimeout(goneTimer);
|
|
63
|
+
goneTimer = undefined;
|
|
64
|
+
};
|
|
65
|
+
const armDeadline = () => {
|
|
66
|
+
clearDeadline();
|
|
67
|
+
if (stopped || visibility.isHidden())
|
|
68
|
+
return;
|
|
69
|
+
deadline = setTimeout(() => {
|
|
70
|
+
transition('suspect');
|
|
71
|
+
// Suspicion is reported but not acted on. A fragment doing a long synchronous render is
|
|
72
|
+
// indistinguishable from one that has died, right up until it finishes — so the host says
|
|
73
|
+
// what it observes and waits before it says what it concludes.
|
|
74
|
+
clearGone();
|
|
75
|
+
goneTimer = setTimeout(() => transition('gone'), goneAfterMs);
|
|
76
|
+
}, beatIntervalMs * graceBeats);
|
|
77
|
+
};
|
|
78
|
+
channel.on(BEAT, (_payload, meta) => {
|
|
79
|
+
/**
|
|
80
|
+
* A beat only counts for the instance it came from.
|
|
81
|
+
*
|
|
82
|
+
* Without this, a fragment torn down mid-flight could have its last in-flight beat credited to
|
|
83
|
+
* the instance that replaced it, and a replacement that never booted would look healthy for one
|
|
84
|
+
* interval. (Today both ends of a trusted channel are stamped with the same instance by the
|
|
85
|
+
* slot; Phase 5 binds this to the instance the fragment reports in ACCEPT.)
|
|
86
|
+
*/
|
|
87
|
+
if (meta.instance !== instance)
|
|
88
|
+
return;
|
|
89
|
+
if (stopped)
|
|
90
|
+
return;
|
|
91
|
+
clearGone();
|
|
92
|
+
transition(visibility.isHidden() ? 'unobservable' : 'healthy');
|
|
93
|
+
armDeadline();
|
|
94
|
+
});
|
|
95
|
+
const detachVisibility = visibility.onChange(() => {
|
|
96
|
+
if (stopped)
|
|
97
|
+
return;
|
|
98
|
+
if (visibility.isHidden()) {
|
|
99
|
+
clearDeadline();
|
|
100
|
+
clearGone();
|
|
101
|
+
// Only a running fragment becomes unobservable. One already suspect or gone stays that way:
|
|
102
|
+
// hiding the tab is not evidence of recovery.
|
|
103
|
+
if (state === 'healthy' || state === 'connecting')
|
|
104
|
+
transition('unobservable');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Back in view: the deadline restarts from now rather than resuming, because the fragment has
|
|
108
|
+
// only just been told it may beat again and has not had a chance to.
|
|
109
|
+
armDeadline();
|
|
110
|
+
});
|
|
111
|
+
/**
|
|
112
|
+
* Starting state depends on whether the fragment can be observed *right now*.
|
|
113
|
+
*
|
|
114
|
+
* `armDeadline()` alone is not enough, and the gap is easy to miss: it declines to arm while
|
|
115
|
+
* hidden but transitions nothing, so a fragment that mounts into an already-hidden tab sits in
|
|
116
|
+
* `connecting` indefinitely — indistinguishable, to a host, from one that never booted. Only the
|
|
117
|
+
* `visibilitychange` handler below said `unobservable`, and a tab that was hidden before the
|
|
118
|
+
* fragment existed never fires one.
|
|
119
|
+
*
|
|
120
|
+
* This is exactly the case a test that toggles visibility cannot reach, because toggling starts
|
|
121
|
+
* from visible. It took a real browser — a backgrounded tab — to surface it.
|
|
122
|
+
*/
|
|
123
|
+
const begin = () => {
|
|
124
|
+
if (visibility.isHidden())
|
|
125
|
+
transition('unobservable');
|
|
126
|
+
else
|
|
127
|
+
armDeadline();
|
|
128
|
+
};
|
|
129
|
+
if (options.armAfter) {
|
|
130
|
+
// Settled either way: a mount that failed still stops this from arming, because the slot tears
|
|
131
|
+
// the instance down and the abort below runs.
|
|
132
|
+
void options.armAfter.then(begin, begin);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
begin();
|
|
136
|
+
}
|
|
137
|
+
const stop = () => {
|
|
138
|
+
if (stopped)
|
|
139
|
+
return;
|
|
140
|
+
stopped = true;
|
|
141
|
+
clearDeadline();
|
|
142
|
+
clearGone();
|
|
143
|
+
detachVisibility();
|
|
144
|
+
};
|
|
145
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
146
|
+
return {
|
|
147
|
+
get state() {
|
|
148
|
+
return state;
|
|
149
|
+
},
|
|
150
|
+
stop,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** Fragment side: beats while it can be observed, and stays quiet while it cannot. */
|
|
154
|
+
export function startFragmentBeats(options) {
|
|
155
|
+
const { channel, scheduler, signal } = options;
|
|
156
|
+
const beatIntervalMs = options.beatIntervalMs ?? BEAT_INTERVAL_MS;
|
|
157
|
+
const visibility = options.visibility ?? documentVisibility();
|
|
158
|
+
const beat = () => {
|
|
159
|
+
// Suppressed rather than merely ignored by the host: a hidden tab's throttled beats would
|
|
160
|
+
// arrive at unpredictable intervals, and a host that had to distinguish "throttled" from
|
|
161
|
+
// "dying" from timing alone would guess wrong in both directions.
|
|
162
|
+
if (visibility.isHidden())
|
|
163
|
+
return;
|
|
164
|
+
channel.send(BEAT);
|
|
165
|
+
};
|
|
166
|
+
const timer = scheduler.setInterval(beat, beatIntervalMs);
|
|
167
|
+
/**
|
|
168
|
+
* An opening beat as well as the interval, so `connecting → healthy` costs one message rather
|
|
169
|
+
* than one interval — a fragment that appears unwell for its first two seconds reads as a slow
|
|
170
|
+
* fragment, and that impression is very hard to undo.
|
|
171
|
+
*
|
|
172
|
+
* Scheduled on the realm rather than called directly, even though calling it would be simpler and
|
|
173
|
+
* would look identical in every passing test. A beat sent from host code is not evidence about
|
|
174
|
+
* the realm, and one exception to that rule is all it takes for a realm that wedged during mount
|
|
175
|
+
* to report `healthy` on the strength of a beat it never produced.
|
|
176
|
+
*/
|
|
177
|
+
scheduler.setTimeout(beat, 0);
|
|
178
|
+
const detachVisibility = visibility.onChange(() => {
|
|
179
|
+
if (!visibility.isHidden())
|
|
180
|
+
scheduler.setTimeout(beat, 0);
|
|
181
|
+
});
|
|
182
|
+
const stop = () => {
|
|
183
|
+
scheduler.clearInterval(timer);
|
|
184
|
+
detachVisibility();
|
|
185
|
+
};
|
|
186
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
187
|
+
return stop;
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=liveness.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"liveness.js","sourceRoot":"","sources":["../../../../../libs/core/src/weave/liveness.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,CAAC,MAAM,IAAI,GAAG,YAAY,CAAC;AAkBjC,MAAM,UAAU,kBAAkB;IAChC,OAAO;QACL,QAAQ,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,eAAe,KAAK,QAAQ;QACrD,QAAQ,CAAC,QAAQ;YACf,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;YACxD,OAAO,GAAG,EAAE,CAAC,QAAQ,CAAC,mBAAmB,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;QAC1E,CAAC;KACF,CAAC;AACJ,CAAC;AAkBD,sGAAsG;AACtG,MAAM,gBAAgB,GAAG,KAAK,CAAC;AAC/B,gEAAgE;AAChE,MAAM,WAAW,GAAG,CAAC,CAAC;AACtB,gGAAgG;AAChG,MAAM,aAAa,GAAG,MAAM,CAAC;AA8B7B,mGAAmG;AACnG,MAAM,UAAU,iBAAiB,CAAC,OAA4B;IAC5D,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAC9C,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,IAAI,gBAAgB,CAAC;IAClE,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,WAAW,CAAC;IACrD,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,aAAa,CAAC;IACzD,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,kBAAkB,EAAE,CAAC;IAE9D,IAAI,KAAK,GAAkB,YAAY,CAAC;IACxC,IAAI,QAAmD,CAAC;IACxD,IAAI,SAAoD,CAAC;IACzD,IAAI,OAAO,GAAG,KAAK,CAAC;IAEpB,MAAM,UAAU,GAAG,CAAC,IAAmB,EAAE,EAAE;QACzC,IAAI,OAAO,IAAI,IAAI,KAAK,KAAK;YAAE,OAAO;QACtC,MAAM,QAAQ,GAAG,KAAK,CAAC;QACvB,KAAK,GAAG,IAAI,CAAC;QACb,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAClC,CAAC,CAAC;IAEF,MAAM,aAAa,GAAG,GAAG,EAAE;QACzB,IAAI,QAAQ,KAAK,SAAS;YAAE,YAAY,CAAC,QAAQ,CAAC,CAAC;QACnD,QAAQ,GAAG,SAAS,CAAC;IACvB,CAAC,CAAC;IAEF,MAAM,SAAS,GAAG,GAAG,EAAE;QACrB,IAAI,SAAS,KAAK,SAAS;YAAE,YAAY,CAAC,SAAS,CAAC,CAAC;QACrD,SAAS,GAAG,SAAS,CAAC;IACxB,CAAC,CAAC;IAEF,MAAM,WAAW,GAAG,GAAG,EAAE;QACvB,aAAa,EAAE,CAAC;QAChB,IAAI,OAAO,IAAI,UAAU,CAAC,QAAQ,EAAE;YAAE,OAAO;QAC7C,QAAQ,GAAG,UAAU,CAAC,GAAG,EAAE;YACzB,UAAU,CAAC,SAAS,CAAC,CAAC;YACtB,wFAAwF;YACxF,0FAA0F;YAC1F,+DAA+D;YAC/D,SAAS,EAAE,CAAC;YACZ,SAAS,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC,CAAC;QAChE,CAAC,EAAE,cAAc,GAAG,UAAU,CAAC,CAAC;IAClC,CAAC,CAAC;IAEF,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE,IAAI,EAAE,EAAE;QAClC;;;;;;;WAOG;QACH,IAAI,IAAI,CAAC,QAAQ,KAAK,QAAQ;YAAE,OAAO;QACvC,IAAI,OAAO;YAAE,OAAO;QAEpB,SAAS,EAAE,CAAC;QACZ,UAAU,CAAC,UAAU,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QAC/D,WAAW,EAAE,CAAC;IAChB,CAAC,CAAC,CAAC;IAEH,MAAM,gBAAgB,GAAG,UAAU,CAAC,QAAQ,CAAC,GAAG,EAAE;QAChD,IAAI,OAAO;YAAE,OAAO;QACpB,IAAI,UAAU,CAAC,QAAQ,EAAE,EAAE,CAAC;YAC1B,aAAa,EAAE,CAAC;YAChB,SAAS,EAAE,CAAC;YACZ,4FAA4F;YAC5F,8CAA8C;YAC9C,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,YAAY;gBAAE,UAAU,CAAC,cAAc,CAAC,CAAC;YAC9E,OAAO;QACT,CAAC;QACD,8FAA8F;QAC9F,qEAAqE;QACrE,WAAW,EAAE,CAAC;IAChB,CAAC,CAAC,CAAC;IAEH;;;;;;;;;;;OAWG;IACH,MAAM,KAAK,GAAG,GAAG,EAAE;QACjB,IAAI,UAAU,CAAC,QAAQ,EAAE;YAAE,UAAU,CAAC,cAAc,CAAC,CAAC;;YACjD,WAAW,EAAE,CAAC;IACrB,CAAC,CAAC;IAEF,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;QACrB,+FAA+F;QAC/F,8CAA8C;QAC9C,KAAK,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;IAC3C,CAAC;SAAM,CAAC;QACN,KAAK,EAAE,CAAC;IACV,CAAC;IAED,MAAM,IAAI,GAAG,GAAG,EAAE;QAChB,IAAI,OAAO;YAAE,OAAO;QACpB,OAAO,GAAG,IAAI,CAAC;QACf,aAAa,EAAE,CAAC;QAChB,SAAS,EAAE,CAAC;QACZ,gBAAgB,EAAE,CAAC;IACrB,CAAC,CAAC;IAEF,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IAEvD,OAAO;QACL,IAAI,KAAK;YACP,OAAO,KAAK,CAAC;QACf,CAAC;QACD,IAAI;KACL,CAAC;AACJ,CAAC;AAWD,sFAAsF;AACtF,MAAM,UAAU,kBAAkB,CAAC,OAA4B;IAC7D,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAC/C,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,IAAI,gBAAgB,CAAC;IAClE,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,kBAAkB,EAAE,CAAC;IAE9D,MAAM,IAAI,GAAG,GAAG,EAAE;QAChB,0FAA0F;QAC1F,yFAAyF;QACzF,kEAAkE;QAClE,IAAI,UAAU,CAAC,QAAQ,EAAE;YAAE,OAAO;QAClC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACrB,CAAC,CAAC;IAEF,MAAM,KAAK,GAAG,SAAS,CAAC,WAAW,CAAC,IAAI,EAAE,cAAc,CAAC,CAAC;IAE1D;;;;;;;;;OASG;IACH,SAAS,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAE9B,MAAM,gBAAgB,GAAG,UAAU,CAAC,QAAQ,CAAC,GAAG,EAAE;QAChD,IAAI,CAAC,UAAU,CAAC,QAAQ,EAAE;YAAE,SAAS,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAC5D,CAAC,CAAC,CAAC;IAEH,MAAM,IAAI,GAAG,GAAG,EAAE;QAChB,SAAS,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC/B,gBAAgB,EAAE,CAAC;IACrB,CAAC,CAAC;IAEF,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IACvD,OAAO,IAAI,CAAC;AACd,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message types that are not the handshake and not context.
|
|
3
|
+
*
|
|
4
|
+
* Both of these crossed the boundary before Weave existed, and both crossed it *unevenly*: the
|
|
5
|
+
* context bus cloned, while props and events handed live host-realm objects to fragment code. That
|
|
6
|
+
* asymmetry was invisible on the trusted tier and is unshippable on the untrusted one, so both are
|
|
7
|
+
* corrected here rather than carried forward.
|
|
8
|
+
*/
|
|
9
|
+
/** Host → fragment. A complete props snapshot, cloned. Never a patch — see below. */
|
|
10
|
+
export declare const PROPS_CHANGED = "props/changed";
|
|
11
|
+
/** Fragment → host. Surfaced on the slot element as `braid:event`. */
|
|
12
|
+
export declare const FRAGMENT_EVENT = "frag/event";
|
|
13
|
+
export interface PropsChangedPayload {
|
|
14
|
+
/**
|
|
15
|
+
* The whole props object, every time.
|
|
16
|
+
*
|
|
17
|
+
* A diff would be smaller and would also require both ends to agree on how to apply one — which
|
|
18
|
+
* is a second protocol, with its own skew problem, protecting a payload that is a handful of
|
|
19
|
+
* fields on every real page. Sending the whole thing keeps "what does this fragment think its
|
|
20
|
+
* props are" answerable by looking at one message.
|
|
21
|
+
*/
|
|
22
|
+
props: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
export interface FragmentEventPayload {
|
|
25
|
+
type: string;
|
|
26
|
+
detail?: unknown;
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message types that are not the handshake and not context.
|
|
3
|
+
*
|
|
4
|
+
* Both of these crossed the boundary before Weave existed, and both crossed it *unevenly*: the
|
|
5
|
+
* context bus cloned, while props and events handed live host-realm objects to fragment code. That
|
|
6
|
+
* asymmetry was invisible on the trusted tier and is unshippable on the untrusted one, so both are
|
|
7
|
+
* corrected here rather than carried forward.
|
|
8
|
+
*/
|
|
9
|
+
/** Host → fragment. A complete props snapshot, cloned. Never a patch — see below. */
|
|
10
|
+
export const PROPS_CHANGED = 'props/changed';
|
|
11
|
+
/** Fragment → host. Surfaced on the slot element as `braid:event`. */
|
|
12
|
+
export const FRAGMENT_EVENT = 'frag/event';
|
|
13
|
+
//# sourceMappingURL=messages.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messages.js","sourceRoot":"","sources":["../../../../../libs/core/src/weave/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,qFAAqF;AACrF,MAAM,CAAC,MAAM,aAAa,GAAG,eAAe,CAAC;AAE7C,sEAAsE;AACtE,MAAM,CAAC,MAAM,cAAc,GAAG,YAAY,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ChannelBacking } from './channel.js';
|
|
2
|
+
/**
|
|
3
|
+
* The cross-origin transport: one `MessagePort`, structured clone, nothing else.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here is used by the trusted tier — it exists now, and is tested now, so that Phase 5 is
|
|
6
|
+
* the act of *choosing* this backing rather than the act of inventing it. A transport written after
|
|
7
|
+
* the protocol has shipped is a transport that inherits every same-origin assumption the protocol
|
|
8
|
+
* accumulated in the meantime.
|
|
9
|
+
*
|
|
10
|
+
* Two rules this backing enforces that the same-realm one gets for free:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Everything inbound is untrusted.** A port is reachable by whatever holds the other end.
|
|
13
|
+
* Malformed messages are dropped silently — an untrusted peer must not be able to fill the
|
|
14
|
+
* host's console by posting garbage in a loop.
|
|
15
|
+
* 2. **Nothing unclonable goes out.** `postMessage` throws `DataCloneError` on a function or a DOM
|
|
16
|
+
* node, and the throw surfaces at the *sender*, far from whoever put the value in the payload.
|
|
17
|
+
* Converting it here names the offending message type while that context still exists.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createPortBacking(port: MessagePort): ChannelBacking;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isEnvelope } from './envelope.js';
|
|
2
|
+
/**
|
|
3
|
+
* The cross-origin transport: one `MessagePort`, structured clone, nothing else.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here is used by the trusted tier — it exists now, and is tested now, so that Phase 5 is
|
|
6
|
+
* the act of *choosing* this backing rather than the act of inventing it. A transport written after
|
|
7
|
+
* the protocol has shipped is a transport that inherits every same-origin assumption the protocol
|
|
8
|
+
* accumulated in the meantime.
|
|
9
|
+
*
|
|
10
|
+
* Two rules this backing enforces that the same-realm one gets for free:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Everything inbound is untrusted.** A port is reachable by whatever holds the other end.
|
|
13
|
+
* Malformed messages are dropped silently — an untrusted peer must not be able to fill the
|
|
14
|
+
* host's console by posting garbage in a loop.
|
|
15
|
+
* 2. **Nothing unclonable goes out.** `postMessage` throws `DataCloneError` on a function or a DOM
|
|
16
|
+
* node, and the throw surfaces at the *sender*, far from whoever put the value in the payload.
|
|
17
|
+
* Converting it here names the offending message type while that context still exists.
|
|
18
|
+
*/
|
|
19
|
+
export function createPortBacking(port) {
|
|
20
|
+
let closed = false;
|
|
21
|
+
return {
|
|
22
|
+
post(envelope) {
|
|
23
|
+
if (closed)
|
|
24
|
+
return;
|
|
25
|
+
try {
|
|
26
|
+
port.postMessage(envelope);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new Error(`braid: a "${envelope.type}" payload could not cross the boundary — ` +
|
|
30
|
+
`values sent to a cross-origin fragment must be structured-cloneable ` +
|
|
31
|
+
`(no functions, DOM nodes, or class instances with methods)`, { cause: error });
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
receive(handler) {
|
|
35
|
+
const listener = (event) => {
|
|
36
|
+
if (closed)
|
|
37
|
+
return;
|
|
38
|
+
if (!isEnvelope(event.data))
|
|
39
|
+
return;
|
|
40
|
+
handler(event.data);
|
|
41
|
+
};
|
|
42
|
+
port.addEventListener('message', listener);
|
|
43
|
+
port.start();
|
|
44
|
+
return () => port.removeEventListener('message', listener);
|
|
45
|
+
},
|
|
46
|
+
close() {
|
|
47
|
+
if (closed)
|
|
48
|
+
return;
|
|
49
|
+
closed = true;
|
|
50
|
+
port.close();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=port-backing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"port-backing.js","sourceRoot":"","sources":["../../../../../libs/core/src/weave/port-backing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,UAAU,EAAE,MAAM,eAAe,CAAC;AAGrD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAiB;IACjD,IAAI,MAAM,GAAG,KAAK,CAAC;IAEnB,OAAO;QACL,IAAI,CAAC,QAAkB;YACrB,IAAI,MAAM;gBAAE,OAAO;YACnB,IAAI,CAAC;gBACH,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;YAC7B,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,MAAM,IAAI,KAAK,CACb,aAAa,QAAQ,CAAC,IAAI,2CAA2C;oBACnE,sEAAsE;oBACtE,4DAA4D,EAC9D,EAAE,KAAK,EAAE,KAAK,EAAE,CACjB,CAAC;YACJ,CAAC;QACH,CAAC;QAED,OAAO,CAAC,OAAO;YACb,MAAM,QAAQ,GAAG,CAAC,KAAmB,EAAE,EAAE;gBACvC,IAAI,MAAM;oBAAE,OAAO;gBACnB,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC;oBAAE,OAAO;gBACpC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACtB,CAAC,CAAC;YACF,IAAI,CAAC,gBAAgB,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;YAC3C,IAAI,CAAC,KAAK,EAAE,CAAC;YACb,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,mBAAmB,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QAC7D,CAAC;QAED,KAAK;YACH,IAAI,MAAM;gBAAE,OAAO;YACnB,MAAM,GAAG,IAAI,CAAC;YACd,IAAI,CAAC,KAAK,EAAE,CAAC;QACf,CAAC;KACF,CAAC;AACJ,CAAC"}
|