@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
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import { BraidError } from '../errors.js';
|
|
2
|
-
import { braidDocumentUrl, BRAID_FRAGMENT_ID_HEADER } from '../protocol.js';
|
|
2
|
+
import { braidDocumentUrl, BRAID_FRAGMENT_ID_HEADER, BRAID_REDIRECT_LOCATION_HEADER, BRAID_RETURN_URL_HEADER, } from '../protocol.js';
|
|
3
3
|
import { createRealm } from '../realm/realm-manager.js';
|
|
4
|
+
import { contractAdapter } from '../adapters/contract-adapter.js';
|
|
4
5
|
import { resolveAdapter } from '../adapters/adapter.js';
|
|
6
|
+
import { parseFragmentContent } from '../adapters/compat-adapter.js';
|
|
5
7
|
import { createFragmentEnv } from '../env/create-env.js';
|
|
6
|
-
import { isDevMode } from '../config.js';
|
|
8
|
+
import { getBraidConfig, isDevMode } from '../config.js';
|
|
9
|
+
import { braidContext } from '../context/context-bus.js';
|
|
10
|
+
import { createBoundaryChannel } from '../weave/channel.js';
|
|
11
|
+
import { createSameRealmBackingPair } from '../weave/same-realm-backing.js';
|
|
12
|
+
import { weaveId } from '../weave/envelope.js';
|
|
13
|
+
import { performHostHandshake } from '../weave/handshake.js';
|
|
14
|
+
import { attachContextRouter } from '../weave/context-bridge.js';
|
|
15
|
+
import { FRAGMENT_EVENT, PROPS_CHANGED } from '../weave/messages.js';
|
|
16
|
+
import { startFragmentBeats, startHostLiveness } from '../weave/liveness.js';
|
|
17
|
+
import { DIRTY, closeAndDispose } from '../weave/closing.js';
|
|
18
|
+
import { negotiateContract } from '../weave/contract.js';
|
|
19
|
+
import { createSandboxRealm } from '../realm/sandbox-realm.js';
|
|
20
|
+
import { createPortBacking } from '../weave/port-backing.js';
|
|
21
|
+
import { readCapabilities, resolveCapabilities } from '../weave/capabilities.js';
|
|
22
|
+
/** `connecting` is reported as `ready`; every other liveness state carries its own name. */
|
|
23
|
+
export function slotStateFor(liveness) {
|
|
24
|
+
return liveness === 'connecting' ? 'ready' : liveness;
|
|
25
|
+
}
|
|
7
26
|
/**
|
|
8
27
|
* Finds server-rendered fragment content the gateway pierced into a slot.
|
|
9
28
|
*
|
|
@@ -21,6 +40,42 @@ export function findPiercedContentRoot(shadowRoot) {
|
|
|
21
40
|
}
|
|
22
41
|
return null;
|
|
23
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The slot's own styles, including the fallback rules.
|
|
45
|
+
*
|
|
46
|
+
* `gone` and `error` hide the fragment's content and reveal whatever the host put in the fallback
|
|
47
|
+
* slot. Note what is *not* hidden: `suspect`. A fragment that is merely slow keeps its rendered
|
|
48
|
+
* content on screen, because replacing a working-if-sluggish UI with an apology is a worse outcome
|
|
49
|
+
* than a moment of unresponsiveness — and suspicion is designed to be recoverable.
|
|
50
|
+
*/
|
|
51
|
+
export const SLOT_STYLES = ':host, braid-document, braid-html, braid-body { display: block; } braid-head { display: none; }' +
|
|
52
|
+
'slot[name="fallback"] { display: none; }' +
|
|
53
|
+
':host([state="gone"]) braid-document, :host([state="error"]) braid-document { display: none; }' +
|
|
54
|
+
':host([state="gone"]) slot[name="fallback"], :host([state="error"]) slot[name="fallback"] { display: block; }';
|
|
55
|
+
/**
|
|
56
|
+
* Ensures the shadow root can present host-provided fallback content:
|
|
57
|
+
*
|
|
58
|
+
* ```html
|
|
59
|
+
* <fragment-slot name="checkout"><p slot="fallback">Checkout is unavailable.</p></fragment-slot>
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* Added separately from the styles because pierced fragments arrive with a shadow root the gateway
|
|
63
|
+
* wrote, which has content but no fallback slot — and a fallback that only worked for
|
|
64
|
+
* client-rendered fragments would be missing from exactly the pages that server-render because
|
|
65
|
+
* their first paint matters.
|
|
66
|
+
*/
|
|
67
|
+
export function ensureFallbackSlot(shadowRoot) {
|
|
68
|
+
if (shadowRoot.querySelector('slot[name="fallback"]'))
|
|
69
|
+
return;
|
|
70
|
+
if (!shadowRoot.querySelector('style')) {
|
|
71
|
+
const styleSheet = document.createElement('style');
|
|
72
|
+
styleSheet.textContent = SLOT_STYLES;
|
|
73
|
+
shadowRoot.prepend(styleSheet);
|
|
74
|
+
}
|
|
75
|
+
const fallback = document.createElement('slot');
|
|
76
|
+
fallback.name = 'fallback';
|
|
77
|
+
shadowRoot.append(fallback);
|
|
78
|
+
}
|
|
24
79
|
/**
|
|
25
80
|
* `<fragment-slot>` — the custom element a host renders to mount a fragment.
|
|
26
81
|
*
|
|
@@ -44,8 +99,22 @@ export class FragmentSlot extends HTMLElement {
|
|
|
44
99
|
}
|
|
45
100
|
#state = 'idle';
|
|
46
101
|
#props = {};
|
|
47
|
-
|
|
102
|
+
/** Host end of this instance's boundary channel; undefined between teardown and the next boot. */
|
|
103
|
+
#channel;
|
|
104
|
+
#liveness;
|
|
48
105
|
#abortController;
|
|
106
|
+
/**
|
|
107
|
+
* The in-flight close, if any.
|
|
108
|
+
*
|
|
109
|
+
* Teardown became asynchronous when it became a negotiation, and `disconnectedCallback` is not.
|
|
110
|
+
* So a close runs on after the element has left the DOM, and the next boot waits for it — which
|
|
111
|
+
* is also what stops a completing close from clearing a shadow root the *next* instance has
|
|
112
|
+
* already rendered into.
|
|
113
|
+
*/
|
|
114
|
+
#closing;
|
|
115
|
+
/** Identifies which close is current, so a superseded one does not clear the DOM on its way out. */
|
|
116
|
+
#closingToken;
|
|
117
|
+
#dirty = null;
|
|
49
118
|
#booted = false;
|
|
50
119
|
#bootScheduled = false;
|
|
51
120
|
/** Set by reload(): a reload must re-fetch, never re-adopt the pierced content. */
|
|
@@ -53,15 +122,48 @@ export class FragmentSlot extends HTMLElement {
|
|
|
53
122
|
get state() {
|
|
54
123
|
return this.#state;
|
|
55
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Sets the state, reflects it to the `state` attribute, and announces the transition.
|
|
127
|
+
*
|
|
128
|
+
* The attribute is what lets a host show fallback content in CSS alone — `:host([state="gone"])`
|
|
129
|
+
* — which matters because the page most in need of a fallback is the one whose JavaScript is
|
|
130
|
+
* already in trouble.
|
|
131
|
+
*/
|
|
132
|
+
#setState(next) {
|
|
133
|
+
if (this.#state === next)
|
|
134
|
+
return;
|
|
135
|
+
const previous = this.#state;
|
|
136
|
+
this.#state = next;
|
|
137
|
+
this.setAttribute('state', next);
|
|
138
|
+
this.dispatchEvent(new CustomEvent('braid:state', { detail: { state: next, previous, fragmentId: this.name } }));
|
|
139
|
+
}
|
|
56
140
|
get name() {
|
|
57
141
|
return this.getAttribute('name') ?? '';
|
|
58
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* The fragment's unsaved-work declaration, or null.
|
|
145
|
+
*
|
|
146
|
+
* Readable synchronously because the only place a host can act on it — a `beforeunload`
|
|
147
|
+
* handler — cannot await anything. Braid never installs that handler itself:
|
|
148
|
+
*
|
|
149
|
+
* ```ts
|
|
150
|
+
* addEventListener('beforeunload', (event) => {
|
|
151
|
+
* if (slot.dirty) event.preventDefault();
|
|
152
|
+
* });
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
get dirty() {
|
|
156
|
+
return this.#dirty;
|
|
157
|
+
}
|
|
59
158
|
get props() {
|
|
60
159
|
return this.#props;
|
|
61
160
|
}
|
|
62
161
|
set props(value) {
|
|
63
162
|
this.#props = structuredClone(value ?? {});
|
|
64
|
-
|
|
163
|
+
// Cloned again on the way out. The slot's copy is the host's to mutate; a fragment holding a
|
|
164
|
+
// live reference to it would see changes the host never announced — the asymmetry that made
|
|
165
|
+
// props behave unlike context before Weave.
|
|
166
|
+
this.#channel?.send(PROPS_CHANGED, { props: structuredClone(this.#props) });
|
|
65
167
|
}
|
|
66
168
|
attributeChangedCallback(attribute, oldValue, newValue) {
|
|
67
169
|
if (attribute === 'props') {
|
|
@@ -104,38 +206,109 @@ export class FragmentSlot extends HTMLElement {
|
|
|
104
206
|
}
|
|
105
207
|
disconnectedCallback() {
|
|
106
208
|
this.#bootScheduled = false;
|
|
107
|
-
|
|
209
|
+
// Not awaited, because `disconnectedCallback` cannot be. The close runs on after the element
|
|
210
|
+
// has left the DOM — which is exactly the window a flush needs, and the reason the fragment's
|
|
211
|
+
// realm is not disposed until the negotiation finishes.
|
|
212
|
+
void this.#teardown('unmount');
|
|
108
213
|
this.#booted = false;
|
|
109
214
|
}
|
|
110
215
|
async reload() {
|
|
111
|
-
this.#teardown();
|
|
216
|
+
await this.#teardown('reload');
|
|
112
217
|
this.#forceFetch = true;
|
|
113
218
|
await this.#boot();
|
|
114
219
|
}
|
|
115
|
-
|
|
116
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Closes the current instance: ask, wait for the deadline at most, then dispose.
|
|
222
|
+
*
|
|
223
|
+
* Resolves once the instance is fully gone, so `reload()` and the next `#boot()` can wait on it.
|
|
224
|
+
*/
|
|
225
|
+
#teardown(reason) {
|
|
226
|
+
const channel = this.#channel;
|
|
227
|
+
const abortController = this.#abortController;
|
|
228
|
+
this.#liveness?.stop();
|
|
229
|
+
this.#liveness = undefined;
|
|
230
|
+
this.#channel = undefined;
|
|
117
231
|
this.#abortController = undefined;
|
|
118
|
-
|
|
119
|
-
|
|
232
|
+
// Nothing mounted: the pre-Weave synchronous path, still the right one for an idle slot.
|
|
233
|
+
if (!channel || !abortController) {
|
|
234
|
+
abortController?.abort();
|
|
235
|
+
this.shadowRoot?.replaceChildren();
|
|
236
|
+
this.#setState('idle');
|
|
237
|
+
return Promise.resolve();
|
|
238
|
+
}
|
|
239
|
+
this.#setState('closing');
|
|
240
|
+
const token = Symbol('braid:closing');
|
|
241
|
+
this.#closingToken = token;
|
|
242
|
+
const closed = (async () => {
|
|
243
|
+
const result = await closeAndDispose({
|
|
244
|
+
channel,
|
|
245
|
+
fragmentId: this.name,
|
|
246
|
+
reason,
|
|
247
|
+
// Runs only after the fragment has acknowledged, or the deadline has passed. Aborting any
|
|
248
|
+
// earlier would cut off the flush this negotiation exists to allow.
|
|
249
|
+
dispose: () => {
|
|
250
|
+
abortController.abort();
|
|
251
|
+
channel.close();
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
this.#setDirty(null);
|
|
255
|
+
this.dispatchEvent(new CustomEvent('braid:closed', {
|
|
256
|
+
detail: { fragmentId: this.name, reason, ...result },
|
|
257
|
+
}));
|
|
258
|
+
// Only the close that is still the current one clears the DOM. A boot that overtook this
|
|
259
|
+
// close has already rendered into the shadow root, and wiping it here would blank a fragment
|
|
260
|
+
// that had just successfully mounted.
|
|
261
|
+
if (this.#closingToken === token) {
|
|
262
|
+
this.shadowRoot?.replaceChildren();
|
|
263
|
+
this.#setState('idle');
|
|
264
|
+
this.#closing = undefined;
|
|
265
|
+
this.#closingToken = undefined;
|
|
266
|
+
}
|
|
267
|
+
})();
|
|
268
|
+
this.#closing = closed;
|
|
269
|
+
return closed;
|
|
270
|
+
}
|
|
271
|
+
#setDirty(reason) {
|
|
272
|
+
if (this.#dirty === reason)
|
|
273
|
+
return;
|
|
274
|
+
this.#dirty = reason;
|
|
275
|
+
this.dispatchEvent(new CustomEvent('braid:dirty', { detail: { fragmentId: this.name, reason } }));
|
|
120
276
|
}
|
|
121
277
|
async #boot() {
|
|
122
278
|
const fragmentId = this.name;
|
|
279
|
+
// A close still negotiating owns the shadow root until it finishes. Booting into it first
|
|
280
|
+
// would render content that the completing close would then clear.
|
|
281
|
+
await this.#closing;
|
|
123
282
|
try {
|
|
124
283
|
if (!fragmentId) {
|
|
284
|
+
/**
|
|
285
|
+
* Every tier needs a name, but only one of them needs a *registry entry*.
|
|
286
|
+
*
|
|
287
|
+
* The name is this fragment's identity to the runtime: it appears in every `BraidError`, it
|
|
288
|
+
* is what capability grants are looked up by, and it is what distinguishes two instances in
|
|
289
|
+
* a trace. Contract and untrusted fragments have no gateway registration at all, so a hint
|
|
290
|
+
* that told their authors to register one sent them looking for a file that should not
|
|
291
|
+
* exist.
|
|
292
|
+
*/
|
|
293
|
+
const registered = this.getAttribute('adapter') !== 'contract' && this.getAttribute('trust') !== 'untrusted';
|
|
125
294
|
throw new BraidError('the <fragment-slot> element is missing its name attribute', {
|
|
126
295
|
fragmentId: '<unnamed>',
|
|
127
296
|
stage: 'slot-config',
|
|
128
|
-
fixHint:
|
|
297
|
+
fixHint: registered
|
|
298
|
+
? '<fragment-slot name="..."> must name a fragment registered in the gateway'
|
|
299
|
+
: '<fragment-slot name="..."> needs a name to identify this fragment in errors and ' +
|
|
300
|
+
'capability grants — it is not looked up in a registry on this tier',
|
|
129
301
|
});
|
|
130
302
|
}
|
|
303
|
+
this.#setState('loading');
|
|
131
304
|
if (this.getAttribute('trust') === 'untrusted') {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
305
|
+
await this.#bootUntrusted(fragmentId);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (this.getAttribute('adapter') === 'contract') {
|
|
309
|
+
await this.#bootContract(fragmentId);
|
|
310
|
+
return;
|
|
137
311
|
}
|
|
138
|
-
this.#state = 'loading';
|
|
139
312
|
const abortController = new AbortController();
|
|
140
313
|
this.#abortController = abortController;
|
|
141
314
|
const signal = abortController.signal;
|
|
@@ -161,11 +334,11 @@ export class FragmentSlot extends HTMLElement {
|
|
|
161
334
|
else {
|
|
162
335
|
shadowRoot = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
|
|
163
336
|
const styleSheet = document.createElement('style');
|
|
164
|
-
styleSheet.textContent =
|
|
165
|
-
':host, braid-document, braid-html, braid-body { display: block; } braid-head { display: none; }';
|
|
337
|
+
styleSheet.textContent = SLOT_STYLES;
|
|
166
338
|
contentRoot = document.createElement('braid-document');
|
|
167
339
|
shadowRoot.replaceChildren(styleSheet, contentRoot);
|
|
168
340
|
}
|
|
341
|
+
ensureFallbackSlot(shadowRoot);
|
|
169
342
|
/**
|
|
170
343
|
* Boot the realm and fetch the fragment's document at the same time, unless it was
|
|
171
344
|
* already pierced in. Both go through the gateway's namespaces, addressed by id.
|
|
@@ -175,41 +348,139 @@ export class FragmentSlot extends HTMLElement {
|
|
|
175
348
|
* an adapter that builds its own UI from an entry module (a lone custom element, say) has
|
|
176
349
|
* no document to fetch, and must not be reported as broken for not serving one.
|
|
177
350
|
*/
|
|
351
|
+
/**
|
|
352
|
+
* Painted as soon as it arrives, without waiting for the realm.
|
|
353
|
+
*
|
|
354
|
+
* The gateway prepared it exactly as it prepares pierced content — scripts inert, urls
|
|
355
|
+
* re-rooted — so it is safe on screen before anything runs, and from here on it *is* pierced
|
|
356
|
+
* content: already in the DOM, activated in place by the adapter. Realm boot is a round trip
|
|
357
|
+
* plus a handshake, and the user has nothing to look at in the meantime otherwise.
|
|
358
|
+
*/
|
|
359
|
+
let painted = false;
|
|
360
|
+
const paint = (html, isHtml) => {
|
|
361
|
+
if (signal.aborted || !isHtml || !html.trim())
|
|
362
|
+
return;
|
|
363
|
+
contentRoot.appendChild(parseFragmentContent(html, document));
|
|
364
|
+
painted = true;
|
|
365
|
+
};
|
|
178
366
|
const [htmlResult, realm] = await Promise.all([
|
|
179
367
|
piercedContentRoot
|
|
180
368
|
? Promise.resolve({ ok: true, html: null })
|
|
181
|
-
:
|
|
369
|
+
: fetchFragmentHtml(fragmentId, routeSrcUrl, signal).then(({ html, isHtml }) => {
|
|
370
|
+
paint(html, isHtml);
|
|
371
|
+
return { ok: true, html };
|
|
372
|
+
}, (error) => ({ ok: false, error })),
|
|
182
373
|
createRealm('compat-http', { fragmentId, routeUrl, bound, signal }),
|
|
183
374
|
]);
|
|
184
375
|
// The gateway stamps the manifest-declared adapter onto the realm stub; unknown adapters
|
|
185
376
|
// fail as a named error, and an undeclared adapter resolves to the default (compat).
|
|
186
377
|
const adapter = resolveAdapter(realm.manifestAdapter, fragmentId);
|
|
187
|
-
if (!htmlResult.ok && adapter.needsDocument !== false)
|
|
378
|
+
if (!htmlResult.ok && adapter.needsDocument !== false) {
|
|
379
|
+
// The fragment sent the user somewhere else — a login, almost always — and the gateway has
|
|
380
|
+
// said the host allows following it. The slot stays `loading`: the page is about to be
|
|
381
|
+
// replaced, and flashing an error or a fallback in the meantime would only be noise.
|
|
382
|
+
if (htmlResult.error instanceof FragmentRedirectError) {
|
|
383
|
+
// a slot that was removed or rebooted while the realm was loading must not move the page
|
|
384
|
+
if (signal.aborted)
|
|
385
|
+
return;
|
|
386
|
+
const refusal = followFragmentRedirect(htmlResult.error, browserRedirectDeps());
|
|
387
|
+
if (!refusal)
|
|
388
|
+
return;
|
|
389
|
+
throw refusal;
|
|
390
|
+
}
|
|
188
391
|
throw htmlResult.error;
|
|
392
|
+
}
|
|
189
393
|
const html = htmlResult.ok ? htmlResult.html : null;
|
|
190
|
-
|
|
394
|
+
// An adapter that builds its own UI never reads a document; one painted for it is taken down
|
|
395
|
+
// rather than left under whatever the adapter mounts.
|
|
396
|
+
if (painted && adapter.needsDocument === false) {
|
|
397
|
+
contentRoot.replaceChildren();
|
|
398
|
+
painted = false;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The boundary channel for this instance.
|
|
402
|
+
*
|
|
403
|
+
* Both ends live in the host realm on the trusted tier — the "fragment end" is the object
|
|
404
|
+
* graph the realm holds — so the same-realm backing dispatches directly with no clone. The
|
|
405
|
+
* shape is nonetheless identical to what Phase 5 will run over a `MessagePort`, which is the
|
|
406
|
+
* only reason building the transport before the tier that needs it is worth anything.
|
|
407
|
+
*/
|
|
408
|
+
const instance = weaveId();
|
|
409
|
+
const backings = createSameRealmBackingPair();
|
|
410
|
+
const hostChannel = createBoundaryChannel({ backing: backings.host, fragmentId, instance, signal });
|
|
411
|
+
const fragmentChannel = createBoundaryChannel({ backing: backings.fragment, fragmentId, instance, signal });
|
|
412
|
+
this.#channel = hostChannel;
|
|
413
|
+
hostChannel.on(DIRTY, (payload) => {
|
|
414
|
+
this.#setDirty((payload ?? {}).reason ?? null);
|
|
415
|
+
});
|
|
416
|
+
hostChannel.on(FRAGMENT_EVENT, (payload) => {
|
|
417
|
+
const { type, detail } = (payload ?? {});
|
|
418
|
+
this.dispatchEvent(new CustomEvent('braid:event', { detail: { type, detail }, bubbles: true }));
|
|
419
|
+
});
|
|
420
|
+
const { env, opened } = createFragmentEnv({
|
|
191
421
|
contentRoot,
|
|
192
422
|
shadowRoot,
|
|
193
423
|
routeUrl,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
424
|
+
channel: fragmentChannel,
|
|
425
|
+
fragmentId,
|
|
426
|
+
signal,
|
|
427
|
+
});
|
|
428
|
+
/**
|
|
429
|
+
* Terms are agreed before the adapter mounts, not after.
|
|
430
|
+
*
|
|
431
|
+
* A fragment that cannot be given the context it declared should never have been booted, and
|
|
432
|
+
* discovering that after its framework has rendered means tearing down a live UI instead of
|
|
433
|
+
* showing a fallback in an empty slot.
|
|
434
|
+
*/
|
|
435
|
+
const hostContract = getBraidConfig().contract;
|
|
436
|
+
const capabilities = this.#capabilitiesFor(fragmentId, readCapabilities(realm.adapterOptions));
|
|
437
|
+
await performHostHandshake({
|
|
438
|
+
channel: hostChannel,
|
|
439
|
+
fragmentId,
|
|
440
|
+
instance,
|
|
441
|
+
...(hostContract === undefined ? {} : { hostContract }),
|
|
442
|
+
negotiate: (host, fragment) => negotiateContract({ host, fragment, schemaFor: (key) => braidContext.schemaFor(key) }),
|
|
443
|
+
onBridged: (bridges) => {
|
|
444
|
+
/**
|
|
445
|
+
* A bridged connection is a working one, so this is an event and not an error — but it is
|
|
446
|
+
* announced rather than logged quietly, because `discards` names fields this fragment
|
|
447
|
+
* will never see, and a screen rendered confidently without them looks correct.
|
|
448
|
+
*/
|
|
449
|
+
this.dispatchEvent(new CustomEvent('braid:bridged', { detail: { fragmentId, bridges } }));
|
|
450
|
+
if (isDevMode()) {
|
|
451
|
+
for (const bridge of bridges) {
|
|
452
|
+
console.debug(`[braid:${fragmentId}] context "${bridge.key}" bridged v${bridge.from} → v${bridge.to}` +
|
|
453
|
+
(bridge.discards.length > 0 ? `, discarding ${bridge.discards.join(', ')}` : ''));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
200
456
|
},
|
|
201
|
-
|
|
202
|
-
|
|
457
|
+
assertReachable: (versions) => {
|
|
458
|
+
for (const [key, as] of Object.entries(versions))
|
|
459
|
+
braidContext.assertReachable(key, as, fragmentId);
|
|
203
460
|
},
|
|
204
|
-
|
|
461
|
+
openWith: (contextVersions) => ({
|
|
462
|
+
context: attachContextRouter({
|
|
463
|
+
bus: braidContext,
|
|
464
|
+
channel: hostChannel,
|
|
465
|
+
fragmentId,
|
|
466
|
+
contextVersions,
|
|
467
|
+
...(capabilities === undefined ? {} : { capabilities }),
|
|
468
|
+
signal,
|
|
469
|
+
}),
|
|
470
|
+
props: structuredClone(this.#props),
|
|
471
|
+
}),
|
|
205
472
|
});
|
|
473
|
+
await opened;
|
|
474
|
+
if (signal.aborted)
|
|
475
|
+
return;
|
|
206
476
|
await adapter.boot({
|
|
207
477
|
fragmentId,
|
|
208
478
|
shadowRoot,
|
|
209
479
|
contentRoot,
|
|
210
480
|
realm,
|
|
211
|
-
|
|
212
|
-
|
|
481
|
+
// painted content is in the DOM already, which is all `pierced` tells the adapter
|
|
482
|
+
html: painted ? null : html,
|
|
483
|
+
pierced: Boolean(piercedContentRoot) || painted,
|
|
213
484
|
routeUrl,
|
|
214
485
|
bound,
|
|
215
486
|
env,
|
|
@@ -217,8 +488,28 @@ export class FragmentSlot extends HTMLElement {
|
|
|
217
488
|
});
|
|
218
489
|
if (signal.aborted)
|
|
219
490
|
return;
|
|
220
|
-
this.#
|
|
491
|
+
this.#setState('ready');
|
|
221
492
|
this.dispatchEvent(new CustomEvent('braid:ready', { detail: { fragmentId } }));
|
|
493
|
+
/**
|
|
494
|
+
* Liveness starts only once the adapter has mounted.
|
|
495
|
+
*
|
|
496
|
+
* Starting it at handshake would put a deadline on a fragment that is still evaluating its
|
|
497
|
+
* framework bundle, and the honest name for that state is `loading` — the host knows exactly
|
|
498
|
+
* what it is waiting for, so it has nothing to be suspicious about yet.
|
|
499
|
+
*/
|
|
500
|
+
this.#liveness = startHostLiveness({
|
|
501
|
+
channel: hostChannel,
|
|
502
|
+
instance,
|
|
503
|
+
signal,
|
|
504
|
+
onState: (liveness) => this.#setState(slotStateFor(liveness)),
|
|
505
|
+
});
|
|
506
|
+
// Scheduled on the realm's own event loop, never the host's — a beat is only evidence if it
|
|
507
|
+
// had to be produced by the thing being asked about. See `BeatScheduler`.
|
|
508
|
+
startFragmentBeats({
|
|
509
|
+
channel: fragmentChannel,
|
|
510
|
+
scheduler: realm.window,
|
|
511
|
+
signal,
|
|
512
|
+
});
|
|
222
513
|
if (isDevMode()) {
|
|
223
514
|
console.debug(`[braid:${fragmentId}] ready`, { slot: this });
|
|
224
515
|
}
|
|
@@ -226,7 +517,7 @@ export class FragmentSlot extends HTMLElement {
|
|
|
226
517
|
catch (error) {
|
|
227
518
|
if (this.#abortController?.signal.aborted)
|
|
228
519
|
return;
|
|
229
|
-
this.#
|
|
520
|
+
this.#setState('error');
|
|
230
521
|
const braidError = error instanceof BraidError
|
|
231
522
|
? error
|
|
232
523
|
: new BraidError(error instanceof Error ? error.message : String(error), {
|
|
@@ -245,37 +536,429 @@ export class FragmentSlot extends HTMLElement {
|
|
|
245
536
|
}));
|
|
246
537
|
}
|
|
247
538
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
539
|
+
/**
|
|
540
|
+
* Mounts a contract fragment, with **no gateway involved at all**.
|
|
541
|
+
*
|
|
542
|
+
* ```html
|
|
543
|
+
* <fragment-slot name="checkout" adapter="contract" entry="https://checkout.example.com/main.js">
|
|
544
|
+
* ```
|
|
545
|
+
*
|
|
546
|
+
* The host declares the entry in markup because a gateway-free host has nowhere else to declare
|
|
547
|
+
* it — there is no registry to read and no realm stub to stamp. That is the whole point: no
|
|
548
|
+
* middleware on the host origin, no namespace routing, no HTML rewriting, no document fetch. A
|
|
549
|
+
* statically hosted SPA can compose contract fragments.
|
|
550
|
+
*
|
|
551
|
+
* Everything else is shared with the trusted compat path — the same handshake, contract
|
|
552
|
+
* negotiation, capabilities, liveness and teardown — because all of it was built against the
|
|
553
|
+
* boundary rather than against compat's machinery.
|
|
554
|
+
*/
|
|
555
|
+
async #bootContract(fragmentId) {
|
|
556
|
+
const entry = this.getAttribute('entry');
|
|
557
|
+
if (!entry) {
|
|
558
|
+
throw new BraidError('a contract fragment needs an entry module', {
|
|
263
559
|
fragmentId,
|
|
264
|
-
stage: '
|
|
265
|
-
|
|
266
|
-
fixHint: 'ensure the braid gateway is mounted in front of this app and reachable from the browser',
|
|
560
|
+
stage: 'slot-config',
|
|
561
|
+
fixHint: '<fragment-slot adapter="contract" entry="https://checkout.example.com/main.js">',
|
|
267
562
|
});
|
|
268
563
|
}
|
|
269
|
-
|
|
270
|
-
|
|
564
|
+
const abortController = new AbortController();
|
|
565
|
+
this.#abortController = abortController;
|
|
566
|
+
const signal = abortController.signal;
|
|
567
|
+
const src = this.getAttribute('src');
|
|
568
|
+
const routeUrl = src ?? location.pathname + location.search;
|
|
569
|
+
const shadowRoot = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
|
|
570
|
+
const styleSheet = document.createElement('style');
|
|
571
|
+
styleSheet.textContent = SLOT_STYLES;
|
|
572
|
+
const contentRoot = document.createElement('braid-document');
|
|
573
|
+
shadowRoot.replaceChildren(styleSheet, contentRoot);
|
|
574
|
+
ensureFallbackSlot(shadowRoot);
|
|
575
|
+
// The realm's base is the entry's own directory, not a gateway namespace: a contract fragment
|
|
576
|
+
// fetches its assets from wherever it is hosted, directly.
|
|
577
|
+
const entryUrl = new URL(entry, document.baseURI);
|
|
578
|
+
const realm = await createRealm('contract-blob', {
|
|
579
|
+
fragmentId,
|
|
580
|
+
routeUrl,
|
|
581
|
+
bound: !src,
|
|
582
|
+
signal,
|
|
583
|
+
baseHref: new URL('./', entryUrl).href,
|
|
584
|
+
});
|
|
585
|
+
const instance = weaveId();
|
|
586
|
+
const backings = createSameRealmBackingPair();
|
|
587
|
+
const hostChannel = createBoundaryChannel({ backing: backings.host, fragmentId, instance, signal });
|
|
588
|
+
const fragmentChannel = createBoundaryChannel({ backing: backings.fragment, fragmentId, instance, signal });
|
|
589
|
+
this.#channel = hostChannel;
|
|
590
|
+
hostChannel.on(DIRTY, (payload) => this.#setDirty((payload ?? {}).reason ?? null));
|
|
591
|
+
hostChannel.on(FRAGMENT_EVENT, (payload) => {
|
|
592
|
+
const { type, detail } = (payload ?? {});
|
|
593
|
+
this.dispatchEvent(new CustomEvent('braid:event', { detail: { type, detail }, bubbles: true }));
|
|
594
|
+
});
|
|
595
|
+
const { env, opened } = createFragmentEnv({
|
|
596
|
+
contentRoot,
|
|
597
|
+
shadowRoot,
|
|
598
|
+
routeUrl,
|
|
599
|
+
channel: fragmentChannel,
|
|
600
|
+
fragmentId,
|
|
601
|
+
signal,
|
|
602
|
+
});
|
|
603
|
+
const hostContract = getBraidConfig().contract;
|
|
604
|
+
const capabilities = this.#capabilitiesFor(fragmentId, undefined);
|
|
605
|
+
await performHostHandshake({
|
|
606
|
+
channel: hostChannel,
|
|
607
|
+
fragmentId,
|
|
608
|
+
instance,
|
|
609
|
+
...(hostContract === undefined ? {} : { hostContract }),
|
|
610
|
+
negotiate: (host, fragment) => negotiateContract({ host, fragment, schemaFor: (key) => braidContext.schemaFor(key) }),
|
|
611
|
+
onBridged: (bridges) => void this.dispatchEvent(new CustomEvent('braid:bridged', { detail: { fragmentId, bridges } })),
|
|
612
|
+
assertReachable: (versions) => {
|
|
613
|
+
for (const [key, as] of Object.entries(versions))
|
|
614
|
+
braidContext.assertReachable(key, as, fragmentId);
|
|
615
|
+
},
|
|
616
|
+
openWith: (contextVersions) => ({
|
|
617
|
+
context: attachContextRouter({
|
|
618
|
+
bus: braidContext,
|
|
619
|
+
channel: hostChannel,
|
|
620
|
+
fragmentId,
|
|
621
|
+
contextVersions,
|
|
622
|
+
...(capabilities === undefined ? {} : { capabilities }),
|
|
623
|
+
signal,
|
|
624
|
+
}),
|
|
625
|
+
props: structuredClone(this.#props),
|
|
626
|
+
}),
|
|
627
|
+
});
|
|
628
|
+
await opened;
|
|
629
|
+
await contractAdapter.boot({
|
|
630
|
+
fragmentId,
|
|
631
|
+
shadowRoot,
|
|
632
|
+
contentRoot,
|
|
633
|
+
realm,
|
|
634
|
+
html: null,
|
|
635
|
+
pierced: false,
|
|
636
|
+
routeUrl,
|
|
637
|
+
bound: !src,
|
|
638
|
+
// Resolved against the document, so a relative `entry` works for a fragment served from the
|
|
639
|
+
// host's own origin — the static-host case, where there is no gateway *and* no second origin.
|
|
640
|
+
entry: entryUrl.href,
|
|
641
|
+
env,
|
|
642
|
+
signal,
|
|
643
|
+
});
|
|
644
|
+
if (signal.aborted)
|
|
645
|
+
return;
|
|
646
|
+
this.#setState('ready');
|
|
647
|
+
this.dispatchEvent(new CustomEvent('braid:ready', { detail: { fragmentId } }));
|
|
648
|
+
this.#liveness = startHostLiveness({
|
|
649
|
+
channel: hostChannel,
|
|
650
|
+
instance,
|
|
651
|
+
signal,
|
|
652
|
+
onState: (liveness) => this.#setState(slotStateFor(liveness)),
|
|
653
|
+
});
|
|
654
|
+
startFragmentBeats({ channel: fragmentChannel, scheduler: realm.window, signal });
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Mounts an untrusted fragment: a visible cross-origin frame, and a port.
|
|
658
|
+
*
|
|
659
|
+
* Almost nothing from the trusted path applies here, and the shape of this method is the honest
|
|
660
|
+
* expression of that. There is no gateway namespace, no document fetch, no HTML rewriting, no
|
|
661
|
+
* pierced content to adopt, no adapter, and no realm to evaluate a module in — the fragment is a
|
|
662
|
+
* whole application at its own origin, and the only things that cross are the ones Weave carries.
|
|
663
|
+
*
|
|
664
|
+
* What it shares with the trusted path is everything built in Phases 1–4: the same handshake, the
|
|
665
|
+
* same contract negotiation, the same liveness, the same negotiated teardown. That is the return
|
|
666
|
+
* on having built the transport before the tier that needed it.
|
|
667
|
+
*/
|
|
668
|
+
async #bootUntrusted(fragmentId) {
|
|
669
|
+
const src = this.getAttribute('src');
|
|
670
|
+
if (!src) {
|
|
671
|
+
throw new BraidError('an untrusted fragment needs an explicit cross-origin src', {
|
|
271
672
|
fragmentId,
|
|
272
|
-
stage: '
|
|
273
|
-
fixHint:
|
|
274
|
-
? `register a manifest for fragment id "${fragmentId}" in the gateway registry`
|
|
275
|
-
: `check the gateway logs for fragment id "${fragmentId}"`,
|
|
673
|
+
stage: 'slot-config',
|
|
674
|
+
fixHint: '<fragment-slot trust="untrusted" src="https://vendor.example.com/widget">',
|
|
276
675
|
});
|
|
277
676
|
}
|
|
278
|
-
|
|
677
|
+
const abortController = new AbortController();
|
|
678
|
+
this.#abortController = abortController;
|
|
679
|
+
const signal = abortController.signal;
|
|
680
|
+
const shadowRoot = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
|
|
681
|
+
const styleSheet = document.createElement('style');
|
|
682
|
+
styleSheet.textContent = SLOT_STYLES;
|
|
683
|
+
shadowRoot.replaceChildren(styleSheet);
|
|
684
|
+
ensureFallbackSlot(shadowRoot);
|
|
685
|
+
/**
|
|
686
|
+
* On this tier the capability block is not advisory: `sandbox` and `permissions` become real
|
|
687
|
+
* iframe attributes that the browser enforces, and `context.read` decides what enters the
|
|
688
|
+
* fragment's mirror at all.
|
|
689
|
+
*/
|
|
690
|
+
// No realm stub on this tier — there is no gateway — so `initBraid({ capabilities })` is the
|
|
691
|
+
// registry, and the slot's own attributes are the least authoritative source.
|
|
692
|
+
const capabilities = this.#capabilitiesFor(fragmentId, undefined, {
|
|
693
|
+
...(this.#sandboxTokens() === undefined ? {} : { sandbox: this.#sandboxTokens() }),
|
|
694
|
+
...(this.#permissions() === undefined ? {} : { permissions: this.#permissions() }),
|
|
695
|
+
});
|
|
696
|
+
const { promise: mounted, resolve: markMounted } = Promise.withResolvers();
|
|
697
|
+
const realm = await createSandboxRealm({
|
|
698
|
+
fragmentId,
|
|
699
|
+
src,
|
|
700
|
+
container: shadowRoot,
|
|
701
|
+
signal,
|
|
702
|
+
...(capabilities?.sandbox === undefined ? {} : { sandboxTokens: capabilities.sandbox }),
|
|
703
|
+
...(capabilities?.permissions === undefined ? {} : { permissions: capabilities.permissions }),
|
|
704
|
+
});
|
|
705
|
+
const instance = weaveId();
|
|
706
|
+
const hostChannel = createBoundaryChannel({
|
|
707
|
+
backing: createPortBacking(realm.port),
|
|
708
|
+
fragmentId,
|
|
709
|
+
instance,
|
|
710
|
+
signal,
|
|
711
|
+
});
|
|
712
|
+
this.#channel = hostChannel;
|
|
713
|
+
hostChannel.on(DIRTY, (payload) => {
|
|
714
|
+
this.#setDirty((payload ?? {}).reason ?? null);
|
|
715
|
+
});
|
|
716
|
+
hostChannel.on(FRAGMENT_EVENT, (payload) => {
|
|
717
|
+
const { type, detail } = (payload ?? {});
|
|
718
|
+
this.dispatchEvent(new CustomEvent('braid:event', { detail: { type, detail }, bubbles: true }));
|
|
719
|
+
});
|
|
720
|
+
/**
|
|
721
|
+
* Listening starts *before* the handshake; judging starts at `mounted`.
|
|
722
|
+
*
|
|
723
|
+
* A cross-origin guest begins beating the moment it is open, and nothing orders that against the
|
|
724
|
+
* host finishing its own work — so registering the handler afterwards drops the opening beat and
|
|
725
|
+
* leaves a healthy fragment in `connecting` for a full interval. Arming the deadline this early
|
|
726
|
+
* would be the opposite mistake, which is exactly what `armAfter` separates.
|
|
727
|
+
*/
|
|
728
|
+
this.#liveness = startHostLiveness({
|
|
729
|
+
channel: hostChannel,
|
|
730
|
+
instance,
|
|
731
|
+
signal,
|
|
732
|
+
armAfter: mounted,
|
|
733
|
+
onState: (liveness) => this.#setState(slotStateFor(liveness)),
|
|
734
|
+
});
|
|
735
|
+
const hostContract = getBraidConfig().contract;
|
|
736
|
+
await performHostHandshake({
|
|
737
|
+
channel: hostChannel,
|
|
738
|
+
fragmentId,
|
|
739
|
+
instance,
|
|
740
|
+
...(hostContract === undefined ? {} : { hostContract }),
|
|
741
|
+
negotiate: (host, fragment) => negotiateContract({ host, fragment, schemaFor: (key) => braidContext.schemaFor(key) }),
|
|
742
|
+
onBridged: (bridges) => {
|
|
743
|
+
this.dispatchEvent(new CustomEvent('braid:bridged', { detail: { fragmentId, bridges } }));
|
|
744
|
+
},
|
|
745
|
+
assertReachable: (versions) => {
|
|
746
|
+
for (const [key, as] of Object.entries(versions))
|
|
747
|
+
braidContext.assertReachable(key, as, fragmentId);
|
|
748
|
+
},
|
|
749
|
+
openWith: (contextVersions) => ({
|
|
750
|
+
context: attachContextRouter({
|
|
751
|
+
bus: braidContext,
|
|
752
|
+
channel: hostChannel,
|
|
753
|
+
fragmentId,
|
|
754
|
+
contextVersions,
|
|
755
|
+
...(capabilities === undefined ? {} : { capabilities }),
|
|
756
|
+
signal,
|
|
757
|
+
}),
|
|
758
|
+
props: structuredClone(this.#props),
|
|
759
|
+
}),
|
|
760
|
+
});
|
|
761
|
+
if (signal.aborted)
|
|
762
|
+
return;
|
|
763
|
+
this.#setState('ready');
|
|
764
|
+
this.dispatchEvent(new CustomEvent('braid:ready', { detail: { fragmentId } }));
|
|
765
|
+
/**
|
|
766
|
+
* No `startFragmentBeats` here. The guest schedules its own, in its own document, on its own
|
|
767
|
+
* event loop — which is stronger evidence than the trusted tier can offer, where the beat comes
|
|
768
|
+
* from a hidden frame rather than from the application itself.
|
|
769
|
+
*/
|
|
770
|
+
markMounted();
|
|
771
|
+
}
|
|
772
|
+
/** Resolves this fragment's capabilities. See `resolveCapabilities` for the precedence. */
|
|
773
|
+
#capabilitiesFor(fragmentId, fromStub, fromSlot) {
|
|
774
|
+
return resolveCapabilities([fromStub, getBraidConfig().capabilities?.[fragmentId], fromSlot]);
|
|
775
|
+
}
|
|
776
|
+
/** `sandbox="allow-popups allow-downloads"` on the slot: additive tokens, host-declared. */
|
|
777
|
+
#sandboxTokens() {
|
|
778
|
+
const value = this.getAttribute('sandbox');
|
|
779
|
+
return value ? value.split(/\s+/).filter(Boolean) : undefined;
|
|
780
|
+
}
|
|
781
|
+
/** `allow="clipboard-write"` on the slot: Permissions-Policy features delegated into the frame. */
|
|
782
|
+
#permissions() {
|
|
783
|
+
const value = this.getAttribute('allow');
|
|
784
|
+
return value ? value.split(/;/).map((entry) => entry.trim()).filter(Boolean) : undefined;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* A redirect target for an error message: origin and path only. The query string is where login
|
|
789
|
+
* redirects carry tokens and return-urls, and an error message ends up in the console and in the
|
|
790
|
+
* `braid:error` event.
|
|
791
|
+
*/
|
|
792
|
+
function redactedLocation(location, base) {
|
|
793
|
+
if (!location)
|
|
794
|
+
return null;
|
|
795
|
+
try {
|
|
796
|
+
const url = new URL(location, new URL(base, 'http://localhost'));
|
|
797
|
+
return `${url.origin === 'http://localhost' ? '' : url.origin}${url.pathname}`;
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
return null;
|
|
279
801
|
}
|
|
280
802
|
}
|
|
803
|
+
/**
|
|
804
|
+
* Fetches the fragment's document from the gateway's document namespace.
|
|
805
|
+
*
|
|
806
|
+
* Redirects are refused rather than followed. Followed, a cross-origin one (an auth redirect to an
|
|
807
|
+
* identity provider, say) dies as an opaque CORS error that hides the status that caused it, and a
|
|
808
|
+
* relative one resolves against the gateway's origin — outside the namespace — so the shell's or a
|
|
809
|
+
* login page's html would be injected as the fragment's document without any error at all.
|
|
810
|
+
*
|
|
811
|
+
* A browser hands back an `opaqueredirect` for `redirect: 'manual'` (status 0, no readable
|
|
812
|
+
* `Location`); other runtimes expose the real 3xx, so the target is named when it can be read.
|
|
813
|
+
*
|
|
814
|
+
* A fragment that opted in to `redirect: 'navigate'` is the exception: the gateway answers with a
|
|
815
|
+
* `409` carrying the target, which becomes a {@link FragmentRedirectError} for the slot to follow.
|
|
816
|
+
*
|
|
817
|
+
* Resolves with the document and whether it arrived as HTML — which, through the gateway, is the one
|
|
818
|
+
* kind of document it prepares (scripts inert, handlers stripped). Anything else is not safe to put
|
|
819
|
+
* on screen ahead of the adapter.
|
|
820
|
+
*/
|
|
821
|
+
export async function fetchFragmentHtml(fragmentId, routeSrcUrl, signal) {
|
|
822
|
+
// the document namespace: the gateway prepares this exactly as it prepares pierced content
|
|
823
|
+
const documentUrl = braidDocumentUrl(fragmentId, routeSrcUrl.pathname, routeSrcUrl.search, getBraidConfig().basePath);
|
|
824
|
+
let response;
|
|
825
|
+
try {
|
|
826
|
+
response = await fetch(documentUrl, {
|
|
827
|
+
headers: {
|
|
828
|
+
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
829
|
+
[BRAID_FRAGMENT_ID_HEADER]: fragmentId,
|
|
830
|
+
// Where a login should send the user back to. Sent on every document fetch, because
|
|
831
|
+
// whether the fragment will redirect is not known until it answers.
|
|
832
|
+
...(typeof location === 'undefined' ? {} : { [BRAID_RETURN_URL_HEADER]: location.href }),
|
|
833
|
+
},
|
|
834
|
+
redirect: 'manual',
|
|
835
|
+
signal,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
throw new BraidError(`fetching the fragment's html from "${documentUrl}" failed`, {
|
|
840
|
+
fragmentId,
|
|
841
|
+
stage: 'fragment-fetch',
|
|
842
|
+
cause: error,
|
|
843
|
+
fixHint: 'ensure the braid gateway is mounted in front of this app and reachable from the browser',
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400 && response.status !== 304)) {
|
|
847
|
+
const location = redactedLocation(response.headers.get('location'), documentUrl);
|
|
848
|
+
throw new BraidError(`the fragment redirected "${documentUrl}"` +
|
|
849
|
+
(response.status > 0 ? ` with HTTP ${response.status}` : '') +
|
|
850
|
+
(location ? ` to "${location}"` : ' (the target is not visible to the browser)'), {
|
|
851
|
+
fragmentId,
|
|
852
|
+
stage: 'fragment-fetch',
|
|
853
|
+
fixHint: `a fragment's document must not redirect — it is fetched, not navigated to. Usually the request is ` +
|
|
854
|
+
`missing authentication and the fragment is sending it to a login page: handle auth inside ` +
|
|
855
|
+
`fragment "${fragmentId}" instead of redirecting, and check the Network tab for the redirect's target`,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
// The gateway's answer for a fragment that opted in to `redirect: 'navigate'`: a 409 that carries
|
|
859
|
+
// where the fragment wanted to go. A 409 without the header is an ordinary failure, and the header
|
|
860
|
+
// on any other status is ignored — a fragment can set headers too, and only this pair is the
|
|
861
|
+
// gateway speaking.
|
|
862
|
+
if (response.status === 409) {
|
|
863
|
+
const target = response.headers.get(BRAID_REDIRECT_LOCATION_HEADER);
|
|
864
|
+
if (target)
|
|
865
|
+
throw new FragmentRedirectError(fragmentId, target, documentUrl);
|
|
866
|
+
}
|
|
867
|
+
if (!response.ok) {
|
|
868
|
+
throw new BraidError(`the gateway responded with HTTP ${response.status} for "${documentUrl}"`, {
|
|
869
|
+
fragmentId,
|
|
870
|
+
stage: 'fragment-fetch',
|
|
871
|
+
fixHint: response.status === 404
|
|
872
|
+
? `register a manifest for fragment id "${fragmentId}" in the gateway registry`
|
|
873
|
+
: `check the gateway logs for fragment id "${fragmentId}"`,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
const isHtml = response.headers.get('content-type')?.toLowerCase().includes('text/html') ?? false;
|
|
877
|
+
return { html: await response.text(), isHtml };
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* The gateway relayed a redirect from the fragment and vouched for its target. Carries the full
|
|
881
|
+
* target; the message does not, because a login's query string is where its tokens live and an
|
|
882
|
+
* error message ends up in the console and in the `braid:error` event.
|
|
883
|
+
*/
|
|
884
|
+
export class FragmentRedirectError extends BraidError {
|
|
885
|
+
target;
|
|
886
|
+
constructor(fragmentId, target, documentUrl) {
|
|
887
|
+
super(`the fragment redirected "${documentUrl}" to "${redactedLocation(target, documentUrl)}"`, {
|
|
888
|
+
fragmentId,
|
|
889
|
+
stage: 'fragment-fetch',
|
|
890
|
+
fixHint: `the redirect was not followed. ` +
|
|
891
|
+
`Check the Network tab for the redirect's target`,
|
|
892
|
+
});
|
|
893
|
+
this.name = 'FragmentRedirectError';
|
|
894
|
+
this.target = target;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/** How long a second redirect from the same fragment is treated as a loop rather than a new one. */
|
|
898
|
+
export const REDIRECT_LOOP_WINDOW_MS = 30_000;
|
|
899
|
+
const redirectsInFlight = new Set();
|
|
900
|
+
/** The real thing, for the browser. Kept out of `followFragmentRedirect` so that is testable. */
|
|
901
|
+
function browserRedirectDeps() {
|
|
902
|
+
return {
|
|
903
|
+
assign: (url) => location.assign(url),
|
|
904
|
+
storage: () => sessionStorage,
|
|
905
|
+
now: () => Date.now(),
|
|
906
|
+
baseHref: location.href,
|
|
907
|
+
inFlight: redirectsInFlight,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Sends the page where the fragment's redirect asked, once.
|
|
912
|
+
*
|
|
913
|
+
* @returns `null` when the page is navigating, or the error to report when it is not.
|
|
914
|
+
*
|
|
915
|
+
* Two things stop it. The scheme must be http(s) — the gateway already checks, and this is the one
|
|
916
|
+
* line between a `javascript:` URL and `location.assign`. And a fragment that redirects again
|
|
917
|
+
* within {@link REDIRECT_LOOP_WINDOW_MS} is a loop: login succeeded, the user came back, and the
|
|
918
|
+
* fragment still cannot tell who they are. A page reload loses all in-memory state, so the only
|
|
919
|
+
* thing that can remember the last redirect is `sessionStorage`; if that is unavailable, the answer
|
|
920
|
+
* is no — an unbounded loop is worse than an error.
|
|
921
|
+
*/
|
|
922
|
+
export function followFragmentRedirect(error, deps) {
|
|
923
|
+
const { fragmentId } = error;
|
|
924
|
+
if (deps.inFlight.has(fragmentId))
|
|
925
|
+
return null;
|
|
926
|
+
const refuse = (message, fixHint) => new BraidError(message, { fragmentId, stage: 'fragment-fetch', fixHint, cause: error });
|
|
927
|
+
let url;
|
|
928
|
+
try {
|
|
929
|
+
url = new URL(error.target, deps.baseHref);
|
|
930
|
+
}
|
|
931
|
+
catch {
|
|
932
|
+
return refuse('the fragment redirected to a URL that cannot be parsed', `check the gateway logs for fragment "${fragmentId}"`);
|
|
933
|
+
}
|
|
934
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
935
|
+
return refuse(`the fragment redirected to a "${url.protocol}" URL, which is not followed`, `check the gateway logs for fragment "${fragmentId}"`);
|
|
936
|
+
}
|
|
937
|
+
// A path the gateway vouched for is a path on this origin. If it resolves anywhere else — a
|
|
938
|
+
// protocol-relative `//host` is the way — something upstream is wrong, and following it would be
|
|
939
|
+
// an open redirect.
|
|
940
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(error.target) && url.origin !== new URL(deps.baseHref).origin) {
|
|
941
|
+
return refuse('the fragment redirected to a path that resolves to another origin, which is not followed', `check the gateway logs for fragment "${fragmentId}"`);
|
|
942
|
+
}
|
|
943
|
+
const key = `braid:redirect:${fragmentId}`;
|
|
944
|
+
try {
|
|
945
|
+
const storage = deps.storage();
|
|
946
|
+
const last = Number(storage.getItem(key));
|
|
947
|
+
const now = deps.now();
|
|
948
|
+
const age = now - last;
|
|
949
|
+
// a stamp from the future (a clock that moved) is stale, not a reason to refuse for the tab's lifetime
|
|
950
|
+
if (last > 0 && age >= 0 && age < REDIRECT_LOOP_WINDOW_MS) {
|
|
951
|
+
return refuse(`fragment "${fragmentId}" redirected again straight after the last redirect, so it was not followed`, `the fragment cannot see who the user is after login, so it keeps redirecting: forward their ` +
|
|
952
|
+
`identity to it with forwardCredentials or additionalHeaders on the gateway`);
|
|
953
|
+
}
|
|
954
|
+
storage.setItem(key, String(now));
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
return refuse(`the fragment redirected to "${redactedLocation(error.target, deps.baseHref)}", but there is no way to ` +
|
|
958
|
+
`stop a redirect loop here (sessionStorage is unavailable), so it was not followed`, 'allow sessionStorage for this page, or handle authentication inside the fragment');
|
|
959
|
+
}
|
|
960
|
+
deps.inFlight.add(fragmentId);
|
|
961
|
+
deps.assign(url.href);
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
281
964
|
//# sourceMappingURL=fragment-slot.js.map
|