@braidlabs/core 0.6.0 → 0.7.1
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 +1 -1
- package/src/adapters/adapter.d.ts +11 -0
- package/src/adapters/adapter.js.map +1 -1
- package/src/adapters/compat-adapter.js +2 -2
- package/src/adapters/compat-adapter.js.map +1 -1
- package/src/compat/born-inert-scripts.d.ts +2 -0
- package/src/compat/born-inert-scripts.js +39 -0
- package/src/compat/born-inert-scripts.js.map +1 -1
- package/src/compat/cookie-partition.d.ts +25 -0
- package/src/compat/cookie-partition.js +79 -0
- package/src/compat/cookie-partition.js.map +1 -0
- package/src/compat/document-member-classification.js +3 -2
- package/src/compat/document-member-classification.js.map +1 -1
- package/src/compat/document-overrides.d.ts +6 -6
- package/src/compat/document-overrides.js +23 -0
- package/src/compat/document-overrides.js.map +1 -1
- package/src/compat/fragment-boundary.d.ts +5 -0
- package/src/compat/fragment-boundary.js +291 -36
- package/src/compat/fragment-boundary.js.map +1 -1
- package/src/compat/html-policy.d.ts +50 -0
- package/src/compat/html-policy.js +155 -0
- package/src/compat/html-policy.js.map +1 -0
- package/src/compat/realm-patches.d.ts +2 -1
- package/src/compat/realm-patches.js +117 -3
- package/src/compat/realm-patches.js.map +1 -1
- package/src/compat/script-execution.d.ts +24 -0
- package/src/compat/script-execution.js +177 -10
- package/src/compat/script-execution.js.map +1 -1
- package/src/compat/storage-partition.d.ts +47 -0
- package/src/compat/storage-partition.js +291 -0
- package/src/compat/storage-partition.js.map +1 -0
- package/src/compat/subresource-urls.d.ts +41 -0
- package/src/compat/subresource-urls.js +133 -0
- package/src/compat/subresource-urls.js.map +1 -0
- package/src/config.d.ts +28 -0
- package/src/config.js +24 -0
- package/src/config.js.map +1 -1
- package/src/elements/boot-gate.d.ts +47 -0
- package/src/elements/boot-gate.js +207 -0
- package/src/elements/boot-gate.js.map +1 -0
- package/src/elements/fragment-slot.d.ts +62 -15
- package/src/elements/fragment-slot.js +388 -26
- package/src/elements/fragment-slot.js.map +1 -1
- package/src/elements/late-content.d.ts +44 -0
- package/src/elements/late-content.js +107 -0
- package/src/elements/late-content.js.map +1 -0
- package/src/elements/prefetch.d.ts +27 -0
- package/src/elements/prefetch.js +100 -0
- package/src/elements/prefetch.js.map +1 -0
- package/src/elements/prerequisites.d.ts +42 -0
- package/src/elements/prerequisites.js +133 -0
- package/src/elements/prerequisites.js.map +1 -0
- package/src/elements/recovery.d.ts +46 -0
- package/src/elements/recovery.js +59 -0
- package/src/elements/recovery.js.map +1 -0
- package/src/env/create-env.js +2 -1
- package/src/env/create-env.js.map +1 -1
- package/src/env/fragment-env.d.ts +6 -0
- package/src/errors.d.ts +4 -1
- package/src/errors.js +2 -0
- package/src/errors.js.map +1 -1
- package/src/index.d.ts +5 -1
- package/src/index.js +11 -1
- package/src/index.js.map +1 -1
- package/src/realm/realm-manager.js +23 -0
- package/src/realm/realm-manager.js.map +1 -1
- package/src/weave/capabilities.d.ts +35 -7
- package/src/weave/capabilities.js +17 -0
- package/src/weave/capabilities.js.map +1 -1
- package/src/weave/guest.d.ts +2 -0
- package/src/weave/guest.js +2 -1
- package/src/weave/guest.js.map +1 -1
- package/src/weave/messages.d.ts +24 -0
- package/src/weave/messages.js +22 -0
- package/src/weave/messages.js.map +1 -1
|
@@ -13,17 +13,61 @@ import { createSameRealmBackingPair } from '../weave/same-realm-backing.js';
|
|
|
13
13
|
import { weaveId } from '../weave/envelope.js';
|
|
14
14
|
import { performHostHandshake } from '../weave/handshake.js';
|
|
15
15
|
import { attachContextRouter } from '../weave/context-bridge.js';
|
|
16
|
-
import { FRAGMENT_EVENT, PROPS_CHANGED } from '../weave/messages.js';
|
|
16
|
+
import { BRAID_FRAGMENT_REPORTER, FRAGMENT_EVENT, PROPS_CHANGED, SETTLED } from '../weave/messages.js';
|
|
17
17
|
import { startFragmentBeats, startHostLiveness } from '../weave/liveness.js';
|
|
18
18
|
import { DIRTY, closeAndDispose } from '../weave/closing.js';
|
|
19
19
|
import { negotiateContract } from '../weave/contract.js';
|
|
20
20
|
import { createSandboxRealm } from '../realm/sandbox-realm.js';
|
|
21
21
|
import { createPortBacking } from '../weave/port-backing.js';
|
|
22
|
-
import { readCapabilities, resolveCapabilities, resolveSharedGlobals, } from '../weave/capabilities.js';
|
|
22
|
+
import { readCapabilities, resolveCapabilities, resolveSharedGlobals, resolveStoragePartition, } from '../weave/capabilities.js';
|
|
23
|
+
import { awaitPrerequisites, NotEntitledError, parseRequires } from './prerequisites.js';
|
|
24
|
+
import { createBootGate, parseBootMode } from './boot-gate.js';
|
|
25
|
+
import { DEFERRED_ATTRIBUTE, lateContentArrival } from './late-content.js';
|
|
26
|
+
import { startPrefetch, takePrefetchedDocument } from './prefetch.js';
|
|
27
|
+
import { createRecovery } from './recovery.js';
|
|
28
|
+
import { resolveHtmlPolicy } from '../compat/html-policy.js';
|
|
29
|
+
/**
|
|
30
|
+
* What the slot reports about its fragment.
|
|
31
|
+
*
|
|
32
|
+
* The original four are all still here and still mean what they meant — `ready` in particular is
|
|
33
|
+
* kept as the name for "mounted, first beat not yet observed", which the liveness machine calls
|
|
34
|
+
* `connecting`. Renaming it would have broken every host checking `slot.state === 'ready'` in
|
|
35
|
+
* exchange for a word.
|
|
36
|
+
*
|
|
37
|
+
* The new three are the ones `ready` used to have to cover on its own:
|
|
38
|
+
*
|
|
39
|
+
* - `healthy` — beating.
|
|
40
|
+
* - `unobservable` — the tab is hidden, so the host has stopped being able to tell. Not a fault.
|
|
41
|
+
* - `suspect` → `gone` — beats stopped while the fragment should have been running.
|
|
42
|
+
*/
|
|
43
|
+
/** How long after `ready` a slot waits for its fragment to report settled before saying so itself. */
|
|
44
|
+
const DEFAULT_SETTLE_TIMEOUT_MS = 10_000;
|
|
45
|
+
/** The longest delay `setTimeout` honors; anything longer fires at once. */
|
|
46
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
47
|
+
/** The `settle-timeout` attribute as a delay: the default when absent, empty or not a number. */
|
|
48
|
+
export function settleTimeoutMs(attribute) {
|
|
49
|
+
const value = attribute?.trim() ? Number(attribute) : NaN;
|
|
50
|
+
return Number.isFinite(value) && value >= 0 ? Math.min(value, MAX_TIMER_DELAY_MS) : DEFAULT_SETTLE_TIMEOUT_MS;
|
|
51
|
+
}
|
|
23
52
|
/** `connecting` is reported as `ready`; every other liveness state carries its own name. */
|
|
24
53
|
export function slotStateFor(liveness) {
|
|
25
54
|
return liveness === 'connecting' ? 'ready' : liveness;
|
|
26
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Whether a slot sits in another fragment's DOM: some shadow root above it belongs to a slot. A
|
|
58
|
+
* host's own components may keep slots in their shadow roots; those are the host's.
|
|
59
|
+
*/
|
|
60
|
+
export function insideAnotherFragment(slot) {
|
|
61
|
+
// the native method: a slot a compat fragment wrote is stamped, and its own getRootNode() answers
|
|
62
|
+
// with the fragment's virtual document instead of the shadow root it is really in
|
|
63
|
+
const rootOf = (node) => nativeGetRootNode.call(node);
|
|
64
|
+
for (let root = rootOf(slot); root instanceof ShadowRoot; root = rootOf(root.host)) {
|
|
65
|
+
if (root.host.localName === 'fragment-slot')
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const nativeGetRootNode = Node.prototype.getRootNode;
|
|
27
71
|
/**
|
|
28
72
|
* Finds server-rendered fragment content the gateway pierced into a slot.
|
|
29
73
|
*
|
|
@@ -52,7 +96,26 @@ export function findPiercedContentRoot(shadowRoot) {
|
|
|
52
96
|
export const SLOT_STYLES = ':host, braid-document, braid-html, braid-body { display: block; } braid-head { display: none; }' +
|
|
53
97
|
'slot[name="fallback"] { display: none; }' +
|
|
54
98
|
':host([state="gone"]) braid-document, :host([state="error"]) braid-document { display: none; }' +
|
|
55
|
-
':host([state="gone"]) slot[name="fallback"], :host([state="error"]) slot[name="fallback"] { display: block; }'
|
|
99
|
+
':host([state="gone"]) slot[name="fallback"], :host([state="error"]) slot[name="fallback"] { display: block; }' +
|
|
100
|
+
// A host's `slot="loading"` child shows while the fragment boots and nothing of it is on screen
|
|
101
|
+
// yet — never for a pierced slot, whose root has no loading slot. After --braid-loading-delay,
|
|
102
|
+
// so a fast boot never flashes it: a zero-length animation that holds it hidden until then.
|
|
103
|
+
'slot[name="loading"] { display: none; }' +
|
|
104
|
+
':host([state="loading"]) braid-document:empty ~ slot[name="loading"] {' +
|
|
105
|
+
' display: block; animation: braid-loading-in 0s linear var(--braid-loading-delay, 200ms) backwards; }' +
|
|
106
|
+
'@keyframes braid-loading-in { from, to { visibility: hidden; } }';
|
|
107
|
+
/**
|
|
108
|
+
* Adds the slot a host's `slot="loading"` child projects into. Only on roots the client builds: a
|
|
109
|
+
* pierced fragment already has its content on screen. (After `reload()` a pierced slot's root is
|
|
110
|
+
* rebuilt by the client, and can show its loading state like any other.)
|
|
111
|
+
*/
|
|
112
|
+
export function ensureLoadingSlot(shadowRoot) {
|
|
113
|
+
if (shadowRoot.querySelector('slot[name="loading"]'))
|
|
114
|
+
return;
|
|
115
|
+
const loading = document.createElement('slot');
|
|
116
|
+
loading.name = 'loading';
|
|
117
|
+
shadowRoot.append(loading);
|
|
118
|
+
}
|
|
56
119
|
/**
|
|
57
120
|
* Ensures the shadow root can present host-provided fallback content:
|
|
58
121
|
*
|
|
@@ -88,9 +151,14 @@ export function ensureFallbackSlot(shadowRoot) {
|
|
|
88
151
|
* - `src` — optional route url for the fragment. When absent, the fragment is *bound*: it
|
|
89
152
|
* follows the host page's location and participates in host navigation.
|
|
90
153
|
* - `props` — JSON attribute (or the `props` property) passed to the fragment instance.
|
|
154
|
+
* - `boot` — `eager` (default), `idle`, `visible`, or `interaction`: when to boot. Read when the
|
|
155
|
+
* slot connects, or when a deferred slot's late markup arrives. See `boot-gate.ts`.
|
|
156
|
+
*
|
|
157
|
+
* - `settle-timeout` — milliseconds after `ready` before the slot reports `settled` on its own, for a
|
|
158
|
+
* fragment that never says it has (default 10000). See {@link FragmentSlot.settled}.
|
|
91
159
|
*
|
|
92
160
|
* Events: `braid:ready`, `braid:error` (detail includes stage + fix hint), `braid:event`
|
|
93
|
-
* (fragment → host).
|
|
161
|
+
* (fragment → host), `braid:settled` (detail: `{ fragmentId, timedOut }`).
|
|
94
162
|
*
|
|
95
163
|
* The slot uses only its own shadow root and its own elements — the host page is never patched.
|
|
96
164
|
*/
|
|
@@ -99,6 +167,8 @@ export class FragmentSlot extends HTMLElement {
|
|
|
99
167
|
return ['name', 'src', 'props'];
|
|
100
168
|
}
|
|
101
169
|
#state = 'idle';
|
|
170
|
+
#settled = false;
|
|
171
|
+
#settleTimer;
|
|
102
172
|
#props = {};
|
|
103
173
|
/** Host end of this instance's boundary channel; undefined between teardown and the next boot. */
|
|
104
174
|
#channel;
|
|
@@ -118,11 +188,55 @@ export class FragmentSlot extends HTMLElement {
|
|
|
118
188
|
#dirty = null;
|
|
119
189
|
#booted = false;
|
|
120
190
|
#bootScheduled = false;
|
|
191
|
+
/**
|
|
192
|
+
* A deferred boot's gate and the listeners that replay or release it; undefined for an eager slot
|
|
193
|
+
* and once the slot leaves the DOM. Kept after the trigger fires, for the replay.
|
|
194
|
+
*/
|
|
195
|
+
#bootGate;
|
|
196
|
+
/** Which scheduled boot is current: a disconnect or reload makes an older one's trigger moot. */
|
|
197
|
+
#scheduleGeneration = 0;
|
|
198
|
+
/** Reboots a fragment that failed for a fixable reason, when the host opted in. */
|
|
199
|
+
#recovery = createRecovery({
|
|
200
|
+
policy: () => getBraidConfig().recover,
|
|
201
|
+
announce: (event) => this.dispatchEvent(new CustomEvent('braid:recovering', { detail: { fragmentId: this.name, ...event } })),
|
|
202
|
+
// only if it still needs it: a host may have reloaded it, or it may have come back on its own
|
|
203
|
+
reboot: () => {
|
|
204
|
+
if (this.isConnected && (this.#state === 'error' || this.#state === 'gone')) {
|
|
205
|
+
this.#reload().catch((error) => console.error(`[braid:${this.name}] recovery failed`, error));
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
});
|
|
121
209
|
/** Set by reload(): a reload must re-fetch, never re-adopt the pierced content. */
|
|
122
210
|
#forceFetch = false;
|
|
211
|
+
/** Which boot is current: a teardown or a newer boot makes an older one's outcome moot. */
|
|
212
|
+
#bootGeneration = 0;
|
|
123
213
|
get state() {
|
|
124
214
|
return this.#state;
|
|
125
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* The slot's own `aria-busy` before {@link #markBusy} set it — `null` for none — or `undefined`
|
|
218
|
+
* when the attribute is not the slot's doing, so whatever the host wrote is left as it is.
|
|
219
|
+
*/
|
|
220
|
+
#busyRestore = undefined;
|
|
221
|
+
/**
|
|
222
|
+
* Busy while a host-provided loading state may be showing: a client-built root with a
|
|
223
|
+
* `slot="loading"` child, until first paint or the slot leaves `loading`.
|
|
224
|
+
*/
|
|
225
|
+
#markBusy() {
|
|
226
|
+
if (this.#busyRestore !== undefined || !this.querySelector(':scope > [slot="loading"]'))
|
|
227
|
+
return;
|
|
228
|
+
this.#busyRestore = this.getAttribute('aria-busy');
|
|
229
|
+
this.setAttribute('aria-busy', 'true');
|
|
230
|
+
}
|
|
231
|
+
#clearBusy() {
|
|
232
|
+
if (this.#busyRestore === undefined)
|
|
233
|
+
return;
|
|
234
|
+
if (this.#busyRestore === null)
|
|
235
|
+
this.removeAttribute('aria-busy');
|
|
236
|
+
else
|
|
237
|
+
this.setAttribute('aria-busy', this.#busyRestore);
|
|
238
|
+
this.#busyRestore = undefined;
|
|
239
|
+
}
|
|
126
240
|
/**
|
|
127
241
|
* Sets the state, reflects it to the `state` attribute, and announces the transition.
|
|
128
242
|
*
|
|
@@ -136,11 +250,81 @@ export class FragmentSlot extends HTMLElement {
|
|
|
136
250
|
const previous = this.#state;
|
|
137
251
|
this.#state = next;
|
|
138
252
|
this.setAttribute('state', next);
|
|
253
|
+
if (next !== 'loading')
|
|
254
|
+
this.#clearBusy();
|
|
255
|
+
// a new boot, or none, has not settled; a failed or closing one never will
|
|
256
|
+
if (next === 'loading' || next === 'idle')
|
|
257
|
+
this.#resetSettled();
|
|
258
|
+
else if (next === 'error' || next === 'closing')
|
|
259
|
+
this.#cancelSettleFallback();
|
|
139
260
|
this.dispatchEvent(new CustomEvent('braid:state', { detail: { state: next, previous, fragmentId: this.name } }));
|
|
261
|
+
// not over unsaved work: a fragment that declared some may yet come back with it
|
|
262
|
+
if (next === 'gone' && this.#dirty === null)
|
|
263
|
+
this.#recovery.onGone();
|
|
140
264
|
}
|
|
141
265
|
get name() {
|
|
142
266
|
return this.getAttribute('name') ?? '';
|
|
143
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Whether the fragment's first content has settled, reflected as the `settled` attribute.
|
|
270
|
+
*
|
|
271
|
+
* Not a slot state, because it answers a different question. `healthy` says the fragment's realm
|
|
272
|
+
* is running, which is true from its first beat — while the application inside may still be
|
|
273
|
+
* showing its own spinner. `settled` says the fragment has finished its own initial loading,
|
|
274
|
+
* which only the fragment can know: it reports it (`env.settled()`, `provideBraidFragment()` in
|
|
275
|
+
* Angular), and a host keeps its skeleton up until then. Not through `slot="loading"`, which is
|
|
276
|
+
* the slot's own boot-time state and gives way at first paint, but as a sibling the host styles:
|
|
277
|
+
*
|
|
278
|
+
* ```html
|
|
279
|
+
* <fragment-slot name="contact"></fragment-slot><div class="skeleton" hidden></div>
|
|
280
|
+
* ```
|
|
281
|
+
* ```css
|
|
282
|
+
* fragment-slot:not([settled], [state="error"]) + .skeleton { display: block; }
|
|
283
|
+
* ```
|
|
284
|
+
*
|
|
285
|
+
* A fragment that never reports is settled anyway `settle-timeout` milliseconds after `ready`, with
|
|
286
|
+
* `timedOut: true` on the event, so a skeleton cannot stick forever. One that fails never settles —
|
|
287
|
+
* it has nothing to show — so a skeleton keyed on `settled` gives way to `[state="error"]`, as above.
|
|
288
|
+
*/
|
|
289
|
+
get settled() {
|
|
290
|
+
return this.#settled;
|
|
291
|
+
}
|
|
292
|
+
#listenForSettled(channel, fragmentId, signal) {
|
|
293
|
+
// a boot that has been torn down speaks for nothing: a newer one may already be on screen
|
|
294
|
+
channel.on(SETTLED, () => {
|
|
295
|
+
if (!signal.aborted)
|
|
296
|
+
this.#markSettled(fragmentId, false);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
#armSettleFallback(fragmentId, signal) {
|
|
300
|
+
if (this.#settled)
|
|
301
|
+
return;
|
|
302
|
+
this.#cancelSettleFallback();
|
|
303
|
+
this.#settleTimer = setTimeout(() => {
|
|
304
|
+
this.#settleTimer = undefined;
|
|
305
|
+
if (!signal.aborted)
|
|
306
|
+
this.#markSettled(fragmentId, true);
|
|
307
|
+
}, settleTimeoutMs(this.getAttribute('settle-timeout')));
|
|
308
|
+
}
|
|
309
|
+
#markSettled(fragmentId, timedOut) {
|
|
310
|
+
if (this.#settled)
|
|
311
|
+
return;
|
|
312
|
+
this.#cancelSettleFallback();
|
|
313
|
+
this.#settled = true;
|
|
314
|
+
this.setAttribute('settled', '');
|
|
315
|
+
this.dispatchEvent(new CustomEvent('braid:settled', { detail: { fragmentId, timedOut } }));
|
|
316
|
+
}
|
|
317
|
+
#cancelSettleFallback() {
|
|
318
|
+
if (this.#settleTimer === undefined)
|
|
319
|
+
return;
|
|
320
|
+
clearTimeout(this.#settleTimer);
|
|
321
|
+
this.#settleTimer = undefined;
|
|
322
|
+
}
|
|
323
|
+
#resetSettled() {
|
|
324
|
+
this.#cancelSettleFallback();
|
|
325
|
+
this.#settled = false;
|
|
326
|
+
this.removeAttribute('settled');
|
|
327
|
+
}
|
|
144
328
|
/**
|
|
145
329
|
* The fragment's unsaved-work declaration, or null.
|
|
146
330
|
*
|
|
@@ -180,14 +364,7 @@ export class FragmentSlot extends HTMLElement {
|
|
|
180
364
|
void this.reload();
|
|
181
365
|
}
|
|
182
366
|
else if (this.isConnected && !this.#booted && !this.#bootScheduled) {
|
|
183
|
-
this.#
|
|
184
|
-
queueMicrotask(() => {
|
|
185
|
-
this.#bootScheduled = false;
|
|
186
|
-
if (this.isConnected && !this.#booted) {
|
|
187
|
-
this.#booted = true;
|
|
188
|
-
void this.#boot();
|
|
189
|
-
}
|
|
190
|
-
});
|
|
367
|
+
this.#scheduleBoot();
|
|
191
368
|
}
|
|
192
369
|
}
|
|
193
370
|
}
|
|
@@ -201,25 +378,109 @@ export class FragmentSlot extends HTMLElement {
|
|
|
201
378
|
if (!this.name && (this.hasAttribute('outlet') || this.hasAttribute('data-braid-in-outlet'))) {
|
|
202
379
|
return;
|
|
203
380
|
}
|
|
381
|
+
this.#scheduleBoot();
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Boots once the slot's `boot` trigger fires — on the next microtask for an eager slot.
|
|
385
|
+
*
|
|
386
|
+
* The microtask is what lets a host set `props` or attributes in the same task that inserted the
|
|
387
|
+
* slot; a deferred slot waits longer still, and boots with whatever name it has by then.
|
|
388
|
+
*/
|
|
389
|
+
#scheduleBoot() {
|
|
204
390
|
this.#bootScheduled = true;
|
|
205
|
-
|
|
391
|
+
const schedule = ++this.#scheduleGeneration;
|
|
392
|
+
const current = () => schedule === this.#scheduleGeneration && this.isConnected && !this.#booted;
|
|
393
|
+
// A slot the gateway streamed past gets its markup at the end of the document; until then it
|
|
394
|
+
// cannot know whether it is pierced, which is what every boot mode decides on.
|
|
395
|
+
if (this.hasAttribute(DEFERRED_ATTRIBUTE)) {
|
|
396
|
+
void lateContentArrival(this).then(() => {
|
|
397
|
+
if (current())
|
|
398
|
+
this.#gateBoot(current);
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
this.#gateBoot(current);
|
|
403
|
+
}
|
|
404
|
+
#gateBoot(current) {
|
|
405
|
+
const start = () => {
|
|
206
406
|
this.#bootScheduled = false;
|
|
207
|
-
if (!
|
|
407
|
+
if (!current())
|
|
208
408
|
return;
|
|
209
|
-
}
|
|
210
409
|
this.#booted = true;
|
|
211
410
|
void this.#boot();
|
|
411
|
+
};
|
|
412
|
+
const mode = parseBootMode(this.getAttribute('boot'), this.name);
|
|
413
|
+
if (mode === 'eager') {
|
|
414
|
+
queueMicrotask(start);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
this.#releaseBootGate();
|
|
418
|
+
const gate = createBootGate(this, mode, {
|
|
419
|
+
pierced: findPiercedContentRoot(this.shadowRoot) !== null,
|
|
420
|
+
fragmentId: this.name,
|
|
212
421
|
});
|
|
422
|
+
const listening = new AbortController();
|
|
423
|
+
this.#bootGate = { gate, listening };
|
|
424
|
+
// Replayed once the fragment can handle what was held. A fragment that fails, or is gone, never
|
|
425
|
+
// will: the gate lets go, so its content and the host's fallback are clickable again.
|
|
426
|
+
this.addEventListener('braid:ready', () => setTimeout(() => gate.replay(), 0), { once: true, signal: listening.signal });
|
|
427
|
+
this.addEventListener('braid:state', (event) => {
|
|
428
|
+
const { state } = event.detail;
|
|
429
|
+
if (state === 'error' || state === 'gone')
|
|
430
|
+
this.#releaseBootGate();
|
|
431
|
+
}, { signal: listening.signal });
|
|
432
|
+
void gate.triggered.then(start);
|
|
433
|
+
}
|
|
434
|
+
/** Drops the slot's boot gate, its held actions, and the listeners waiting to replay them. */
|
|
435
|
+
#releaseBootGate() {
|
|
436
|
+
this.#bootGate?.gate.dispose();
|
|
437
|
+
this.#bootGate?.listening.abort();
|
|
438
|
+
this.#bootGate = undefined;
|
|
213
439
|
}
|
|
214
440
|
disconnectedCallback() {
|
|
215
441
|
this.#bootScheduled = false;
|
|
442
|
+
this.#scheduleGeneration++;
|
|
443
|
+
this.#recovery.reset();
|
|
444
|
+
this.#releaseBootGate();
|
|
216
445
|
// Not awaited, because `disconnectedCallback` cannot be. The close runs on after the element
|
|
217
446
|
// has left the DOM — which is exactly the window a flush needs, and the reason the fragment's
|
|
218
447
|
// realm is not disposed until the negotiation finishes.
|
|
219
448
|
void this.#teardown('unmount');
|
|
220
449
|
this.#booted = false;
|
|
221
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Tears down and boots afresh. A host's reload also restores the recovery budget and cancels a
|
|
453
|
+
* scheduled recovery reboot: the host has taken over.
|
|
454
|
+
*/
|
|
455
|
+
/**
|
|
456
|
+
* A slot's `entry`, or its untrusted `src`, names the code a fragment runs — so only the host may
|
|
457
|
+
* give one. A slot inside another fragment's DOM was written by that fragment, perhaps from data it
|
|
458
|
+
* rendered, and may compose registered fragments, whose code the registry names, but not bring
|
|
459
|
+
* its own. An untrusted slot would also be handed whatever capabilities the host granted its name.
|
|
460
|
+
*/
|
|
461
|
+
#assertHostDeclaresCode(fragmentId) {
|
|
462
|
+
const bringsCode = this.hasAttribute('entry') || this.getAttribute('trust') === 'untrusted';
|
|
463
|
+
if (!bringsCode || !insideAnotherFragment(this))
|
|
464
|
+
return;
|
|
465
|
+
throw new BraidError(`a <fragment-slot> inside another fragment may not declare an entry or an untrusted src`, {
|
|
466
|
+
fragmentId,
|
|
467
|
+
stage: 'slot-config',
|
|
468
|
+
fixHint: `register fragment "${fragmentId}" in the gateway and compose it by name, or move the slot into ` +
|
|
469
|
+
`the host page's own DOM`,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
222
472
|
async reload() {
|
|
473
|
+
this.#recovery.reset();
|
|
474
|
+
await this.#reload();
|
|
475
|
+
}
|
|
476
|
+
async #reload() {
|
|
477
|
+
// A reload boots now, whatever the slot was waiting for: its trigger, or late markup, which
|
|
478
|
+
// would otherwise arrive later and replace the content this boot fetched.
|
|
479
|
+
this.#scheduleGeneration++;
|
|
480
|
+
this.#bootScheduled = false;
|
|
481
|
+
this.#releaseBootGate();
|
|
482
|
+
this.removeAttribute(DEFERRED_ATTRIBUTE);
|
|
483
|
+
this.#booted = true;
|
|
223
484
|
await this.#teardown('reload');
|
|
224
485
|
this.#forceFetch = true;
|
|
225
486
|
await this.#boot();
|
|
@@ -236,6 +497,7 @@ export class FragmentSlot extends HTMLElement {
|
|
|
236
497
|
this.#liveness = undefined;
|
|
237
498
|
this.#channel = undefined;
|
|
238
499
|
this.#abortController = undefined;
|
|
500
|
+
this.#bootGeneration++;
|
|
239
501
|
// Nothing mounted: the pre-Weave synchronous path, still the right one for an idle slot.
|
|
240
502
|
if (!channel || !abortController) {
|
|
241
503
|
abortController?.abort();
|
|
@@ -286,6 +548,7 @@ export class FragmentSlot extends HTMLElement {
|
|
|
286
548
|
// A close still negotiating owns the shadow root until it finishes. Booting into it first
|
|
287
549
|
// would render content that the completing close would then clear.
|
|
288
550
|
await this.#closing;
|
|
551
|
+
const generation = ++this.#bootGeneration;
|
|
289
552
|
try {
|
|
290
553
|
if (!fragmentId) {
|
|
291
554
|
/**
|
|
@@ -307,13 +570,17 @@ export class FragmentSlot extends HTMLElement {
|
|
|
307
570
|
'capability grants — it is not looked up in a registry on this tier',
|
|
308
571
|
});
|
|
309
572
|
}
|
|
573
|
+
this.#assertHostDeclaresCode(fragmentId);
|
|
310
574
|
this.#setState('loading');
|
|
575
|
+
// Prerequisites the host's markup declares, on every tier: a gate that silently did not apply
|
|
576
|
+
// to some fragments would be worse than none.
|
|
577
|
+
const markupRequires = parseRequires(this.getAttribute('requires'));
|
|
311
578
|
if (this.getAttribute('trust') === 'untrusted') {
|
|
312
|
-
await this.#bootUntrusted(fragmentId);
|
|
579
|
+
await this.#bootUntrusted(fragmentId, markupRequires);
|
|
313
580
|
return;
|
|
314
581
|
}
|
|
315
582
|
if (this.getAttribute('adapter') === 'contract') {
|
|
316
|
-
await this.#bootContract(fragmentId);
|
|
583
|
+
await this.#bootContract(fragmentId, markupRequires);
|
|
317
584
|
return;
|
|
318
585
|
}
|
|
319
586
|
const abortController = new AbortController();
|
|
@@ -346,6 +613,18 @@ export class FragmentSlot extends HTMLElement {
|
|
|
346
613
|
shadowRoot.replaceChildren(styleSheet, contentRoot);
|
|
347
614
|
}
|
|
348
615
|
ensureFallbackSlot(shadowRoot);
|
|
616
|
+
if (!piercedContentRoot) {
|
|
617
|
+
ensureLoadingSlot(shadowRoot);
|
|
618
|
+
this.#markBusy();
|
|
619
|
+
}
|
|
620
|
+
// Prerequisites the host's markup declares start now, alongside the realm and the document;
|
|
621
|
+
// the manifest's arrive with the realm stub. Either way the fragment's code waits for them.
|
|
622
|
+
const earlyPrerequisites = markupRequires.length ? awaitPrerequisites(markupRequires, fragmentId, signal) : null;
|
|
623
|
+
// awaited below; until then a failure must not surface as an unhandled rejection
|
|
624
|
+
earlyPrerequisites?.catch(() => undefined);
|
|
625
|
+
// A failing prerequisite ends the boot at once, not after the realm and the handshake: the
|
|
626
|
+
// fallback should not wait a round trip on a fragment the user was never going to see.
|
|
627
|
+
const failFast = (step) => earlyPrerequisites ? Promise.race([step, earlyPrerequisites.then(() => step)]) : step;
|
|
349
628
|
/**
|
|
350
629
|
* Boot the realm and fetch the fragment's document at the same time, unless it was
|
|
351
630
|
* already pierced in. Both go through the gateway's namespaces, addressed by id.
|
|
@@ -368,20 +647,29 @@ export class FragmentSlot extends HTMLElement {
|
|
|
368
647
|
if (signal.aborted || !isHtml || !html.trim())
|
|
369
648
|
return;
|
|
370
649
|
contentRoot.appendChild(parseFragmentContent(html, document));
|
|
650
|
+
// on screen now: the loading state has given way
|
|
651
|
+
this.#clearBusy();
|
|
371
652
|
painted = true;
|
|
372
653
|
};
|
|
373
|
-
const [htmlResult, realm] = await Promise.all([
|
|
654
|
+
const [htmlResult, realm] = await failFast(Promise.all([
|
|
374
655
|
piercedContentRoot
|
|
375
656
|
? Promise.resolve({ ok: true, html: null })
|
|
376
|
-
: fetchFragmentHtml(fragmentId, routeSrcUrl, signal).then(({ html, isHtml }) => {
|
|
377
|
-
|
|
657
|
+
: (takePrefetchedDocument(fragmentId, routeSrcUrl, signal) ?? fetchFragmentHtml(fragmentId, routeSrcUrl, signal)).then(({ html, isHtml }) => {
|
|
658
|
+
// nothing of a gated fragment goes on screen before its markup prerequisites hold
|
|
659
|
+
if (earlyPrerequisites)
|
|
660
|
+
earlyPrerequisites.then(() => paint(html, isHtml), () => undefined);
|
|
661
|
+
else
|
|
662
|
+
paint(html, isHtml);
|
|
378
663
|
return { ok: true, html };
|
|
379
664
|
}, (error) => ({ ok: false, error })),
|
|
380
665
|
createRealm('compat-http', { fragmentId, routeUrl, bound, signal }),
|
|
381
|
-
]);
|
|
666
|
+
]));
|
|
382
667
|
// The gateway stamps the manifest-declared adapter onto the realm stub; unknown adapters
|
|
383
668
|
// fail as a named error, and an undeclared adapter resolves to the default (compat).
|
|
384
669
|
const adapter = resolveAdapter(realm.manifestAdapter, fragmentId);
|
|
670
|
+
const manifestRequires = parseRequires(realm.adapterOptions['requires']).filter((key) => !markupRequires.includes(key));
|
|
671
|
+
const latePrerequisites = manifestRequires.length ? awaitPrerequisites(manifestRequires, fragmentId, signal) : null;
|
|
672
|
+
latePrerequisites?.catch(() => undefined);
|
|
385
673
|
if (!htmlResult.ok && adapter.needsDocument !== false) {
|
|
386
674
|
// The fragment sent the user somewhere else — a login, almost always — and the gateway has
|
|
387
675
|
// said the host allows following it. The slot stays `loading`: the page is about to be
|
|
@@ -424,6 +712,7 @@ export class FragmentSlot extends HTMLElement {
|
|
|
424
712
|
const { type, detail } = (payload ?? {});
|
|
425
713
|
this.dispatchEvent(new CustomEvent('braid:event', { detail: { type, detail }, bubbles: true }));
|
|
426
714
|
});
|
|
715
|
+
this.#listenForSettled(hostChannel, fragmentId, signal);
|
|
427
716
|
const { env, opened } = createFragmentEnv({
|
|
428
717
|
contentRoot,
|
|
429
718
|
shadowRoot,
|
|
@@ -477,12 +766,23 @@ export class FragmentSlot extends HTMLElement {
|
|
|
477
766
|
props: structuredClone(this.#props),
|
|
478
767
|
}),
|
|
479
768
|
});
|
|
480
|
-
await opened;
|
|
769
|
+
await failFast(opened);
|
|
481
770
|
if (signal.aborted)
|
|
482
771
|
return;
|
|
483
772
|
const realmGlobals = adapter.installsRealmGlobals ? await resolveRealmGlobals(fragmentId, signal) : undefined;
|
|
484
773
|
if (signal.aborted)
|
|
485
774
|
return;
|
|
775
|
+
const storagePartition = resolveStoragePartition(capabilities, fragmentId);
|
|
776
|
+
const htmlPolicy = resolveHtmlPolicy(capabilities, fragmentId);
|
|
777
|
+
// nothing of the fragment's has run yet; its content may already be painted, inert
|
|
778
|
+
await Promise.all([earlyPrerequisites, latePrerequisites]);
|
|
779
|
+
if (signal.aborted)
|
|
780
|
+
return;
|
|
781
|
+
// before any fragment code: an unmodified app has no env, so it reports through its global
|
|
782
|
+
Object.defineProperty(realm.window, BRAID_FRAGMENT_REPORTER, {
|
|
783
|
+
value: Object.freeze({ settled: () => env.settled() }),
|
|
784
|
+
configurable: true,
|
|
785
|
+
});
|
|
486
786
|
await adapter.boot({
|
|
487
787
|
fragmentId,
|
|
488
788
|
shadowRoot,
|
|
@@ -495,6 +795,8 @@ export class FragmentSlot extends HTMLElement {
|
|
|
495
795
|
bound,
|
|
496
796
|
env,
|
|
497
797
|
sharedGlobals: resolveSharedGlobals(capabilities),
|
|
798
|
+
...(storagePartition === null ? {} : { storagePartition }),
|
|
799
|
+
...(htmlPolicy === null ? {} : { htmlPolicy }),
|
|
498
800
|
...(realmGlobals ? { realmGlobals } : {}),
|
|
499
801
|
signal,
|
|
500
802
|
});
|
|
@@ -502,6 +804,7 @@ export class FragmentSlot extends HTMLElement {
|
|
|
502
804
|
return;
|
|
503
805
|
this.#setState('ready');
|
|
504
806
|
this.dispatchEvent(new CustomEvent('braid:ready', { detail: { fragmentId } }));
|
|
807
|
+
this.#armSettleFallback(fragmentId, signal);
|
|
505
808
|
/**
|
|
506
809
|
* Liveness starts only once the adapter has mounted.
|
|
507
810
|
*
|
|
@@ -527,7 +830,8 @@ export class FragmentSlot extends HTMLElement {
|
|
|
527
830
|
}
|
|
528
831
|
}
|
|
529
832
|
catch (error) {
|
|
530
|
-
|
|
833
|
+
// a boot a teardown or a newer boot overtook has nothing to report — its abort is not a failure
|
|
834
|
+
if (generation !== this.#bootGeneration || this.#abortController?.signal.aborted)
|
|
531
835
|
return;
|
|
532
836
|
this.#setState('error');
|
|
533
837
|
const braidError = error instanceof BraidError
|
|
@@ -537,15 +841,28 @@ export class FragmentSlot extends HTMLElement {
|
|
|
537
841
|
stage: 'adapter-mount',
|
|
538
842
|
cause: error,
|
|
539
843
|
});
|
|
540
|
-
|
|
844
|
+
// a user without access to a prerequisite is an outcome, not a failure: fallback, no alarm,
|
|
845
|
+
// and none of the fragment's content left in the slot
|
|
846
|
+
const notEntitled = braidError instanceof NotEntitledError;
|
|
847
|
+
if (notEntitled) {
|
|
848
|
+
this.shadowRoot?.querySelector('braid-document')?.replaceChildren();
|
|
849
|
+
if (isDevMode())
|
|
850
|
+
console.debug(`[braid:${fragmentId}] ${braidError.message}; showing the fallback`);
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
console.error(braidError);
|
|
854
|
+
}
|
|
541
855
|
this.dispatchEvent(new CustomEvent('braid:error', {
|
|
542
856
|
detail: {
|
|
543
857
|
fragmentId: braidError.fragmentId,
|
|
544
858
|
stage: braidError.stage,
|
|
545
859
|
fixHint: braidError.fixHint,
|
|
546
860
|
error: braidError,
|
|
861
|
+
// an outcome, not a failure: a host routing braid:error to monitoring can skip it
|
|
862
|
+
...(notEntitled ? { notEntitled: true } : {}),
|
|
547
863
|
},
|
|
548
864
|
}));
|
|
865
|
+
this.#recovery.onError(braidError, notEntitled);
|
|
549
866
|
}
|
|
550
867
|
}
|
|
551
868
|
/**
|
|
@@ -564,7 +881,9 @@ export class FragmentSlot extends HTMLElement {
|
|
|
564
881
|
* negotiation, capabilities, liveness and teardown — because all of it was built against the
|
|
565
882
|
* boundary rather than against compat's machinery.
|
|
566
883
|
*/
|
|
567
|
-
async #bootContract(fragmentId) {
|
|
884
|
+
async #bootContract(fragmentId, requires) {
|
|
885
|
+
// again where it is read: a `braid:state` listener runs between #boot's check and here
|
|
886
|
+
this.#assertHostDeclaresCode(fragmentId);
|
|
568
887
|
const entry = this.getAttribute('entry');
|
|
569
888
|
if (!entry) {
|
|
570
889
|
throw new BraidError('a contract fragment needs an entry module', {
|
|
@@ -584,6 +903,13 @@ export class FragmentSlot extends HTMLElement {
|
|
|
584
903
|
const contentRoot = document.createElement('braid-document');
|
|
585
904
|
shadowRoot.replaceChildren(styleSheet, contentRoot);
|
|
586
905
|
ensureFallbackSlot(shadowRoot);
|
|
906
|
+
ensureLoadingSlot(shadowRoot);
|
|
907
|
+
this.#markBusy();
|
|
908
|
+
// before anything of the fragment exists: no realm, nothing mounted
|
|
909
|
+
if (requires.length)
|
|
910
|
+
await awaitPrerequisites(requires, fragmentId, signal);
|
|
911
|
+
if (signal.aborted)
|
|
912
|
+
return;
|
|
587
913
|
// The realm's base is the entry's own directory, not a gateway namespace: a contract fragment
|
|
588
914
|
// fetches its assets from wherever it is hosted, directly.
|
|
589
915
|
const entryUrl = new URL(entry, document.baseURI);
|
|
@@ -604,6 +930,7 @@ export class FragmentSlot extends HTMLElement {
|
|
|
604
930
|
const { type, detail } = (payload ?? {});
|
|
605
931
|
this.dispatchEvent(new CustomEvent('braid:event', { detail: { type, detail }, bubbles: true }));
|
|
606
932
|
});
|
|
933
|
+
this.#listenForSettled(hostChannel, fragmentId, signal);
|
|
607
934
|
const { env, opened } = createFragmentEnv({
|
|
608
935
|
contentRoot,
|
|
609
936
|
shadowRoot,
|
|
@@ -657,6 +984,7 @@ export class FragmentSlot extends HTMLElement {
|
|
|
657
984
|
return;
|
|
658
985
|
this.#setState('ready');
|
|
659
986
|
this.dispatchEvent(new CustomEvent('braid:ready', { detail: { fragmentId } }));
|
|
987
|
+
this.#armSettleFallback(fragmentId, signal);
|
|
660
988
|
this.#liveness = startHostLiveness({
|
|
661
989
|
channel: hostChannel,
|
|
662
990
|
instance,
|
|
@@ -677,7 +1005,9 @@ export class FragmentSlot extends HTMLElement {
|
|
|
677
1005
|
* same contract negotiation, the same liveness, the same negotiated teardown. That is the return
|
|
678
1006
|
* on having built the transport before the tier that needed it.
|
|
679
1007
|
*/
|
|
680
|
-
async #bootUntrusted(fragmentId) {
|
|
1008
|
+
async #bootUntrusted(fragmentId, requires) {
|
|
1009
|
+
// again where it is read: a `braid:state` listener runs between #boot's check and here
|
|
1010
|
+
this.#assertHostDeclaresCode(fragmentId);
|
|
681
1011
|
const src = this.getAttribute('src');
|
|
682
1012
|
if (!src) {
|
|
683
1013
|
throw new BraidError('an untrusted fragment needs an explicit cross-origin src', {
|
|
@@ -694,6 +1024,11 @@ export class FragmentSlot extends HTMLElement {
|
|
|
694
1024
|
styleSheet.textContent = SLOT_STYLES;
|
|
695
1025
|
shadowRoot.replaceChildren(styleSheet);
|
|
696
1026
|
ensureFallbackSlot(shadowRoot);
|
|
1027
|
+
// before anything of the fragment exists: no frame, no connection
|
|
1028
|
+
if (requires.length)
|
|
1029
|
+
await awaitPrerequisites(requires, fragmentId, signal);
|
|
1030
|
+
if (signal.aborted)
|
|
1031
|
+
return;
|
|
697
1032
|
/**
|
|
698
1033
|
* On this tier the capability block is not advisory: `sandbox` and `permissions` become real
|
|
699
1034
|
* iframe attributes that the browser enforces, and `context.read` decides what enters the
|
|
@@ -729,6 +1064,7 @@ export class FragmentSlot extends HTMLElement {
|
|
|
729
1064
|
const { type, detail } = (payload ?? {});
|
|
730
1065
|
this.dispatchEvent(new CustomEvent('braid:event', { detail: { type, detail }, bubbles: true }));
|
|
731
1066
|
});
|
|
1067
|
+
this.#listenForSettled(hostChannel, fragmentId, signal);
|
|
732
1068
|
/**
|
|
733
1069
|
* Listening starts *before* the handshake; judging starts at `mounted`.
|
|
734
1070
|
*
|
|
@@ -774,6 +1110,7 @@ export class FragmentSlot extends HTMLElement {
|
|
|
774
1110
|
return;
|
|
775
1111
|
this.#setState('ready');
|
|
776
1112
|
this.dispatchEvent(new CustomEvent('braid:ready', { detail: { fragmentId } }));
|
|
1113
|
+
this.#armSettleFallback(fragmentId, signal);
|
|
777
1114
|
/**
|
|
778
1115
|
* No `startFragmentBeats` here. The guest schedules its own, in its own document, on its own
|
|
779
1116
|
* event loop — which is stronger evidence than the trusted tier can offer, where the beat comes
|
|
@@ -812,6 +1149,29 @@ function redactedLocation(location, base) {
|
|
|
812
1149
|
return null;
|
|
813
1150
|
}
|
|
814
1151
|
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Starts loading a fragment before its slot exists — from a link's hover, or a router's preload
|
|
1154
|
+
* hook — so a navigation that is about to mount it finds the work already in flight.
|
|
1155
|
+
*
|
|
1156
|
+
* ```ts
|
|
1157
|
+
* link.addEventListener('pointerenter', () => prefetchFragment('billing', '/billing/invoices'));
|
|
1158
|
+
* ```
|
|
1159
|
+
*
|
|
1160
|
+
* `route` is what the slot will render: the destination page's path for a routed fragment, or the
|
|
1161
|
+
* slot's `src` for a widget. Two things start:
|
|
1162
|
+
*
|
|
1163
|
+
* - the fragment's document, held for the next slot of that fragment and route to mount within
|
|
1164
|
+
* ten seconds (`PREFETCH_TTL_MS`) — so that slot makes no document request of its own;
|
|
1165
|
+
* - the scripts the document names, in its own namespace, as `<link rel="prefetch">` the realm
|
|
1166
|
+
* then loads from the HTTP cache.
|
|
1167
|
+
*
|
|
1168
|
+
* Compat fragments only: contract and untrusted fragments load no Braid document. Calling it again
|
|
1169
|
+
* for the same fragment and route while a prefetch is held does nothing. Call
|
|
1170
|
+
* `clearPrefetchedFragments()` when the user signs out.
|
|
1171
|
+
*/
|
|
1172
|
+
export function prefetchFragment(fragmentId, route) {
|
|
1173
|
+
startPrefetch(fragmentId, route, (url, signal) => fetchFragmentHtml(fragmentId, url, signal));
|
|
1174
|
+
}
|
|
815
1175
|
/**
|
|
816
1176
|
* Fetches the fragment's document from the gateway's document namespace.
|
|
817
1177
|
*
|
|
@@ -862,6 +1222,7 @@ export async function fetchFragmentHtml(fragmentId, routeSrcUrl, signal) {
|
|
|
862
1222
|
(location ? ` to "${location}"` : ' (the target is not visible to the browser)'), {
|
|
863
1223
|
fragmentId,
|
|
864
1224
|
stage: 'fragment-fetch',
|
|
1225
|
+
httpStatus: response.status || 302,
|
|
865
1226
|
fixHint: `a fragment's document must not redirect — it is fetched, not navigated to. Usually the request is ` +
|
|
866
1227
|
`missing authentication and the fragment is sending it to a login page: handle auth inside ` +
|
|
867
1228
|
`fragment "${fragmentId}" instead of redirecting, and check the Network tab for the redirect's target`,
|
|
@@ -880,6 +1241,7 @@ export async function fetchFragmentHtml(fragmentId, routeSrcUrl, signal) {
|
|
|
880
1241
|
throw new BraidError(`the gateway responded with HTTP ${response.status} for "${documentUrl}"`, {
|
|
881
1242
|
fragmentId,
|
|
882
1243
|
stage: 'fragment-fetch',
|
|
1244
|
+
httpStatus: response.status,
|
|
883
1245
|
fixHint: response.status === 404
|
|
884
1246
|
? `register a manifest for fragment id "${fragmentId}" in the gateway registry`
|
|
885
1247
|
: `check the gateway logs for fragment id "${fragmentId}"`,
|