@braidlabs/core 0.6.0 → 0.7.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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/adapter.d.ts +11 -0
  3. package/src/adapters/adapter.js.map +1 -1
  4. package/src/adapters/compat-adapter.js +2 -2
  5. package/src/adapters/compat-adapter.js.map +1 -1
  6. package/src/compat/born-inert-scripts.d.ts +2 -0
  7. package/src/compat/born-inert-scripts.js +39 -0
  8. package/src/compat/born-inert-scripts.js.map +1 -1
  9. package/src/compat/cookie-partition.d.ts +25 -0
  10. package/src/compat/cookie-partition.js +79 -0
  11. package/src/compat/cookie-partition.js.map +1 -0
  12. package/src/compat/document-member-classification.js +3 -2
  13. package/src/compat/document-member-classification.js.map +1 -1
  14. package/src/compat/document-overrides.d.ts +6 -6
  15. package/src/compat/document-overrides.js +23 -0
  16. package/src/compat/document-overrides.js.map +1 -1
  17. package/src/compat/fragment-boundary.d.ts +5 -0
  18. package/src/compat/fragment-boundary.js +159 -12
  19. package/src/compat/fragment-boundary.js.map +1 -1
  20. package/src/compat/html-policy.d.ts +50 -0
  21. package/src/compat/html-policy.js +155 -0
  22. package/src/compat/html-policy.js.map +1 -0
  23. package/src/compat/realm-patches.d.ts +2 -1
  24. package/src/compat/realm-patches.js +117 -3
  25. package/src/compat/realm-patches.js.map +1 -1
  26. package/src/compat/script-execution.d.ts +24 -0
  27. package/src/compat/script-execution.js +177 -10
  28. package/src/compat/script-execution.js.map +1 -1
  29. package/src/compat/storage-partition.d.ts +47 -0
  30. package/src/compat/storage-partition.js +291 -0
  31. package/src/compat/storage-partition.js.map +1 -0
  32. package/src/compat/subresource-urls.d.ts +41 -0
  33. package/src/compat/subresource-urls.js +133 -0
  34. package/src/compat/subresource-urls.js.map +1 -0
  35. package/src/config.d.ts +28 -0
  36. package/src/config.js +24 -0
  37. package/src/config.js.map +1 -1
  38. package/src/elements/boot-gate.d.ts +47 -0
  39. package/src/elements/boot-gate.js +207 -0
  40. package/src/elements/boot-gate.js.map +1 -0
  41. package/src/elements/fragment-slot.d.ts +34 -0
  42. package/src/elements/fragment-slot.js +282 -24
  43. package/src/elements/fragment-slot.js.map +1 -1
  44. package/src/elements/late-content.d.ts +44 -0
  45. package/src/elements/late-content.js +107 -0
  46. package/src/elements/late-content.js.map +1 -0
  47. package/src/elements/prefetch.d.ts +27 -0
  48. package/src/elements/prefetch.js +100 -0
  49. package/src/elements/prefetch.js.map +1 -0
  50. package/src/elements/prerequisites.d.ts +42 -0
  51. package/src/elements/prerequisites.js +133 -0
  52. package/src/elements/prerequisites.js.map +1 -0
  53. package/src/elements/recovery.d.ts +46 -0
  54. package/src/elements/recovery.js +59 -0
  55. package/src/elements/recovery.js.map +1 -0
  56. package/src/errors.d.ts +4 -1
  57. package/src/errors.js +2 -0
  58. package/src/errors.js.map +1 -1
  59. package/src/index.d.ts +4 -1
  60. package/src/index.js +10 -1
  61. package/src/index.js.map +1 -1
  62. package/src/realm/realm-manager.js +23 -0
  63. package/src/realm/realm-manager.js.map +1 -1
  64. package/src/weave/capabilities.d.ts +35 -7
  65. package/src/weave/capabilities.js +17 -0
  66. package/src/weave/capabilities.js.map +1 -1
@@ -19,11 +19,32 @@ 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';
23
29
  /** `connecting` is reported as `ready`; every other liveness state carries its own name. */
24
30
  export function slotStateFor(liveness) {
25
31
  return liveness === 'connecting' ? 'ready' : liveness;
26
32
  }
33
+ /**
34
+ * Whether a slot sits in another fragment's DOM: some shadow root above it belongs to a slot. A
35
+ * host's own components may keep slots in their shadow roots; those are the host's.
36
+ */
37
+ export function insideAnotherFragment(slot) {
38
+ // the native method: a slot a compat fragment wrote is stamped, and its own getRootNode() answers
39
+ // with the fragment's virtual document instead of the shadow root it is really in
40
+ const rootOf = (node) => nativeGetRootNode.call(node);
41
+ for (let root = rootOf(slot); root instanceof ShadowRoot; root = rootOf(root.host)) {
42
+ if (root.host.localName === 'fragment-slot')
43
+ return true;
44
+ }
45
+ return false;
46
+ }
47
+ const nativeGetRootNode = Node.prototype.getRootNode;
27
48
  /**
28
49
  * Finds server-rendered fragment content the gateway pierced into a slot.
29
50
  *
@@ -52,7 +73,26 @@ export function findPiercedContentRoot(shadowRoot) {
52
73
  export const SLOT_STYLES = ':host, braid-document, braid-html, braid-body { display: block; } braid-head { display: none; }' +
53
74
  'slot[name="fallback"] { display: none; }' +
54
75
  ':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; }';
76
+ ':host([state="gone"]) slot[name="fallback"], :host([state="error"]) slot[name="fallback"] { display: block; }' +
77
+ // A host's `slot="loading"` child shows while the fragment boots and nothing of it is on screen
78
+ // yet — never for a pierced slot, whose root has no loading slot. After --braid-loading-delay,
79
+ // so a fast boot never flashes it: a zero-length animation that holds it hidden until then.
80
+ 'slot[name="loading"] { display: none; }' +
81
+ ':host([state="loading"]) braid-document:empty ~ slot[name="loading"] {' +
82
+ ' display: block; animation: braid-loading-in 0s linear var(--braid-loading-delay, 200ms) backwards; }' +
83
+ '@keyframes braid-loading-in { from, to { visibility: hidden; } }';
84
+ /**
85
+ * Adds the slot a host's `slot="loading"` child projects into. Only on roots the client builds: a
86
+ * pierced fragment already has its content on screen. (After `reload()` a pierced slot's root is
87
+ * rebuilt by the client, and can show its loading state like any other.)
88
+ */
89
+ export function ensureLoadingSlot(shadowRoot) {
90
+ if (shadowRoot.querySelector('slot[name="loading"]'))
91
+ return;
92
+ const loading = document.createElement('slot');
93
+ loading.name = 'loading';
94
+ shadowRoot.append(loading);
95
+ }
56
96
  /**
57
97
  * Ensures the shadow root can present host-provided fallback content:
58
98
  *
@@ -88,6 +128,8 @@ export function ensureFallbackSlot(shadowRoot) {
88
128
  * - `src` — optional route url for the fragment. When absent, the fragment is *bound*: it
89
129
  * follows the host page's location and participates in host navigation.
90
130
  * - `props` — JSON attribute (or the `props` property) passed to the fragment instance.
131
+ * - `boot` — `eager` (default), `idle`, `visible`, or `interaction`: when to boot. Read when the
132
+ * slot connects, or when a deferred slot's late markup arrives. See `boot-gate.ts`.
91
133
  *
92
134
  * Events: `braid:ready`, `braid:error` (detail includes stage + fix hint), `braid:event`
93
135
  * (fragment → host).
@@ -118,11 +160,55 @@ export class FragmentSlot extends HTMLElement {
118
160
  #dirty = null;
119
161
  #booted = false;
120
162
  #bootScheduled = false;
163
+ /**
164
+ * A deferred boot's gate and the listeners that replay or release it; undefined for an eager slot
165
+ * and once the slot leaves the DOM. Kept after the trigger fires, for the replay.
166
+ */
167
+ #bootGate;
168
+ /** Which scheduled boot is current: a disconnect or reload makes an older one's trigger moot. */
169
+ #scheduleGeneration = 0;
170
+ /** Reboots a fragment that failed for a fixable reason, when the host opted in. */
171
+ #recovery = createRecovery({
172
+ policy: () => getBraidConfig().recover,
173
+ announce: (event) => this.dispatchEvent(new CustomEvent('braid:recovering', { detail: { fragmentId: this.name, ...event } })),
174
+ // only if it still needs it: a host may have reloaded it, or it may have come back on its own
175
+ reboot: () => {
176
+ if (this.isConnected && (this.#state === 'error' || this.#state === 'gone')) {
177
+ this.#reload().catch((error) => console.error(`[braid:${this.name}] recovery failed`, error));
178
+ }
179
+ },
180
+ });
121
181
  /** Set by reload(): a reload must re-fetch, never re-adopt the pierced content. */
122
182
  #forceFetch = false;
183
+ /** Which boot is current: a teardown or a newer boot makes an older one's outcome moot. */
184
+ #bootGeneration = 0;
123
185
  get state() {
124
186
  return this.#state;
125
187
  }
188
+ /**
189
+ * The slot's own `aria-busy` before {@link #markBusy} set it — `null` for none — or `undefined`
190
+ * when the attribute is not the slot's doing, so whatever the host wrote is left as it is.
191
+ */
192
+ #busyRestore = undefined;
193
+ /**
194
+ * Busy while a host-provided loading state may be showing: a client-built root with a
195
+ * `slot="loading"` child, until first paint or the slot leaves `loading`.
196
+ */
197
+ #markBusy() {
198
+ if (this.#busyRestore !== undefined || !this.querySelector(':scope > [slot="loading"]'))
199
+ return;
200
+ this.#busyRestore = this.getAttribute('aria-busy');
201
+ this.setAttribute('aria-busy', 'true');
202
+ }
203
+ #clearBusy() {
204
+ if (this.#busyRestore === undefined)
205
+ return;
206
+ if (this.#busyRestore === null)
207
+ this.removeAttribute('aria-busy');
208
+ else
209
+ this.setAttribute('aria-busy', this.#busyRestore);
210
+ this.#busyRestore = undefined;
211
+ }
126
212
  /**
127
213
  * Sets the state, reflects it to the `state` attribute, and announces the transition.
128
214
  *
@@ -136,7 +222,12 @@ export class FragmentSlot extends HTMLElement {
136
222
  const previous = this.#state;
137
223
  this.#state = next;
138
224
  this.setAttribute('state', next);
225
+ if (next !== 'loading')
226
+ this.#clearBusy();
139
227
  this.dispatchEvent(new CustomEvent('braid:state', { detail: { state: next, previous, fragmentId: this.name } }));
228
+ // not over unsaved work: a fragment that declared some may yet come back with it
229
+ if (next === 'gone' && this.#dirty === null)
230
+ this.#recovery.onGone();
140
231
  }
141
232
  get name() {
142
233
  return this.getAttribute('name') ?? '';
@@ -180,14 +271,7 @@ export class FragmentSlot extends HTMLElement {
180
271
  void this.reload();
181
272
  }
182
273
  else if (this.isConnected && !this.#booted && !this.#bootScheduled) {
183
- this.#bootScheduled = true;
184
- queueMicrotask(() => {
185
- this.#bootScheduled = false;
186
- if (this.isConnected && !this.#booted) {
187
- this.#booted = true;
188
- void this.#boot();
189
- }
190
- });
274
+ this.#scheduleBoot();
191
275
  }
192
276
  }
193
277
  }
@@ -201,25 +285,109 @@ export class FragmentSlot extends HTMLElement {
201
285
  if (!this.name && (this.hasAttribute('outlet') || this.hasAttribute('data-braid-in-outlet'))) {
202
286
  return;
203
287
  }
288
+ this.#scheduleBoot();
289
+ }
290
+ /**
291
+ * Boots once the slot's `boot` trigger fires — on the next microtask for an eager slot.
292
+ *
293
+ * The microtask is what lets a host set `props` or attributes in the same task that inserted the
294
+ * slot; a deferred slot waits longer still, and boots with whatever name it has by then.
295
+ */
296
+ #scheduleBoot() {
204
297
  this.#bootScheduled = true;
205
- queueMicrotask(() => {
298
+ const schedule = ++this.#scheduleGeneration;
299
+ const current = () => schedule === this.#scheduleGeneration && this.isConnected && !this.#booted;
300
+ // A slot the gateway streamed past gets its markup at the end of the document; until then it
301
+ // cannot know whether it is pierced, which is what every boot mode decides on.
302
+ if (this.hasAttribute(DEFERRED_ATTRIBUTE)) {
303
+ void lateContentArrival(this).then(() => {
304
+ if (current())
305
+ this.#gateBoot(current);
306
+ });
307
+ return;
308
+ }
309
+ this.#gateBoot(current);
310
+ }
311
+ #gateBoot(current) {
312
+ const start = () => {
206
313
  this.#bootScheduled = false;
207
- if (!this.isConnected || this.#booted) {
314
+ if (!current())
208
315
  return;
209
- }
210
316
  this.#booted = true;
211
317
  void this.#boot();
318
+ };
319
+ const mode = parseBootMode(this.getAttribute('boot'), this.name);
320
+ if (mode === 'eager') {
321
+ queueMicrotask(start);
322
+ return;
323
+ }
324
+ this.#releaseBootGate();
325
+ const gate = createBootGate(this, mode, {
326
+ pierced: findPiercedContentRoot(this.shadowRoot) !== null,
327
+ fragmentId: this.name,
212
328
  });
329
+ const listening = new AbortController();
330
+ this.#bootGate = { gate, listening };
331
+ // Replayed once the fragment can handle what was held. A fragment that fails, or is gone, never
332
+ // will: the gate lets go, so its content and the host's fallback are clickable again.
333
+ this.addEventListener('braid:ready', () => setTimeout(() => gate.replay(), 0), { once: true, signal: listening.signal });
334
+ this.addEventListener('braid:state', (event) => {
335
+ const { state } = event.detail;
336
+ if (state === 'error' || state === 'gone')
337
+ this.#releaseBootGate();
338
+ }, { signal: listening.signal });
339
+ void gate.triggered.then(start);
340
+ }
341
+ /** Drops the slot's boot gate, its held actions, and the listeners waiting to replay them. */
342
+ #releaseBootGate() {
343
+ this.#bootGate?.gate.dispose();
344
+ this.#bootGate?.listening.abort();
345
+ this.#bootGate = undefined;
213
346
  }
214
347
  disconnectedCallback() {
215
348
  this.#bootScheduled = false;
349
+ this.#scheduleGeneration++;
350
+ this.#recovery.reset();
351
+ this.#releaseBootGate();
216
352
  // Not awaited, because `disconnectedCallback` cannot be. The close runs on after the element
217
353
  // has left the DOM — which is exactly the window a flush needs, and the reason the fragment's
218
354
  // realm is not disposed until the negotiation finishes.
219
355
  void this.#teardown('unmount');
220
356
  this.#booted = false;
221
357
  }
358
+ /**
359
+ * Tears down and boots afresh. A host's reload also restores the recovery budget and cancels a
360
+ * scheduled recovery reboot: the host has taken over.
361
+ */
362
+ /**
363
+ * A slot's `entry`, or its untrusted `src`, names the code a fragment runs — so only the host may
364
+ * give one. A slot inside another fragment's DOM was written by that fragment, perhaps from data it
365
+ * rendered, and may compose registered fragments, whose code the registry names, but not bring
366
+ * its own. An untrusted slot would also be handed whatever capabilities the host granted its name.
367
+ */
368
+ #assertHostDeclaresCode(fragmentId) {
369
+ const bringsCode = this.hasAttribute('entry') || this.getAttribute('trust') === 'untrusted';
370
+ if (!bringsCode || !insideAnotherFragment(this))
371
+ return;
372
+ throw new BraidError(`a <fragment-slot> inside another fragment may not declare an entry or an untrusted src`, {
373
+ fragmentId,
374
+ stage: 'slot-config',
375
+ fixHint: `register fragment "${fragmentId}" in the gateway and compose it by name, or move the slot into ` +
376
+ `the host page's own DOM`,
377
+ });
378
+ }
222
379
  async reload() {
380
+ this.#recovery.reset();
381
+ await this.#reload();
382
+ }
383
+ async #reload() {
384
+ // A reload boots now, whatever the slot was waiting for: its trigger, or late markup, which
385
+ // would otherwise arrive later and replace the content this boot fetched.
386
+ this.#scheduleGeneration++;
387
+ this.#bootScheduled = false;
388
+ this.#releaseBootGate();
389
+ this.removeAttribute(DEFERRED_ATTRIBUTE);
390
+ this.#booted = true;
223
391
  await this.#teardown('reload');
224
392
  this.#forceFetch = true;
225
393
  await this.#boot();
@@ -236,6 +404,7 @@ export class FragmentSlot extends HTMLElement {
236
404
  this.#liveness = undefined;
237
405
  this.#channel = undefined;
238
406
  this.#abortController = undefined;
407
+ this.#bootGeneration++;
239
408
  // Nothing mounted: the pre-Weave synchronous path, still the right one for an idle slot.
240
409
  if (!channel || !abortController) {
241
410
  abortController?.abort();
@@ -286,6 +455,7 @@ export class FragmentSlot extends HTMLElement {
286
455
  // A close still negotiating owns the shadow root until it finishes. Booting into it first
287
456
  // would render content that the completing close would then clear.
288
457
  await this.#closing;
458
+ const generation = ++this.#bootGeneration;
289
459
  try {
290
460
  if (!fragmentId) {
291
461
  /**
@@ -307,13 +477,17 @@ export class FragmentSlot extends HTMLElement {
307
477
  'capability grants — it is not looked up in a registry on this tier',
308
478
  });
309
479
  }
480
+ this.#assertHostDeclaresCode(fragmentId);
310
481
  this.#setState('loading');
482
+ // Prerequisites the host's markup declares, on every tier: a gate that silently did not apply
483
+ // to some fragments would be worse than none.
484
+ const markupRequires = parseRequires(this.getAttribute('requires'));
311
485
  if (this.getAttribute('trust') === 'untrusted') {
312
- await this.#bootUntrusted(fragmentId);
486
+ await this.#bootUntrusted(fragmentId, markupRequires);
313
487
  return;
314
488
  }
315
489
  if (this.getAttribute('adapter') === 'contract') {
316
- await this.#bootContract(fragmentId);
490
+ await this.#bootContract(fragmentId, markupRequires);
317
491
  return;
318
492
  }
319
493
  const abortController = new AbortController();
@@ -346,6 +520,18 @@ export class FragmentSlot extends HTMLElement {
346
520
  shadowRoot.replaceChildren(styleSheet, contentRoot);
347
521
  }
348
522
  ensureFallbackSlot(shadowRoot);
523
+ if (!piercedContentRoot) {
524
+ ensureLoadingSlot(shadowRoot);
525
+ this.#markBusy();
526
+ }
527
+ // Prerequisites the host's markup declares start now, alongside the realm and the document;
528
+ // the manifest's arrive with the realm stub. Either way the fragment's code waits for them.
529
+ const earlyPrerequisites = markupRequires.length ? awaitPrerequisites(markupRequires, fragmentId, signal) : null;
530
+ // awaited below; until then a failure must not surface as an unhandled rejection
531
+ earlyPrerequisites?.catch(() => undefined);
532
+ // A failing prerequisite ends the boot at once, not after the realm and the handshake: the
533
+ // fallback should not wait a round trip on a fragment the user was never going to see.
534
+ const failFast = (step) => earlyPrerequisites ? Promise.race([step, earlyPrerequisites.then(() => step)]) : step;
349
535
  /**
350
536
  * Boot the realm and fetch the fragment's document at the same time, unless it was
351
537
  * already pierced in. Both go through the gateway's namespaces, addressed by id.
@@ -368,20 +554,29 @@ export class FragmentSlot extends HTMLElement {
368
554
  if (signal.aborted || !isHtml || !html.trim())
369
555
  return;
370
556
  contentRoot.appendChild(parseFragmentContent(html, document));
557
+ // on screen now: the loading state has given way
558
+ this.#clearBusy();
371
559
  painted = true;
372
560
  };
373
- const [htmlResult, realm] = await Promise.all([
561
+ const [htmlResult, realm] = await failFast(Promise.all([
374
562
  piercedContentRoot
375
563
  ? Promise.resolve({ ok: true, html: null })
376
- : fetchFragmentHtml(fragmentId, routeSrcUrl, signal).then(({ html, isHtml }) => {
377
- paint(html, isHtml);
564
+ : (takePrefetchedDocument(fragmentId, routeSrcUrl, signal) ?? fetchFragmentHtml(fragmentId, routeSrcUrl, signal)).then(({ html, isHtml }) => {
565
+ // nothing of a gated fragment goes on screen before its markup prerequisites hold
566
+ if (earlyPrerequisites)
567
+ earlyPrerequisites.then(() => paint(html, isHtml), () => undefined);
568
+ else
569
+ paint(html, isHtml);
378
570
  return { ok: true, html };
379
571
  }, (error) => ({ ok: false, error })),
380
572
  createRealm('compat-http', { fragmentId, routeUrl, bound, signal }),
381
- ]);
573
+ ]));
382
574
  // The gateway stamps the manifest-declared adapter onto the realm stub; unknown adapters
383
575
  // fail as a named error, and an undeclared adapter resolves to the default (compat).
384
576
  const adapter = resolveAdapter(realm.manifestAdapter, fragmentId);
577
+ const manifestRequires = parseRequires(realm.adapterOptions['requires']).filter((key) => !markupRequires.includes(key));
578
+ const latePrerequisites = manifestRequires.length ? awaitPrerequisites(manifestRequires, fragmentId, signal) : null;
579
+ latePrerequisites?.catch(() => undefined);
385
580
  if (!htmlResult.ok && adapter.needsDocument !== false) {
386
581
  // The fragment sent the user somewhere else — a login, almost always — and the gateway has
387
582
  // said the host allows following it. The slot stays `loading`: the page is about to be
@@ -477,10 +672,16 @@ export class FragmentSlot extends HTMLElement {
477
672
  props: structuredClone(this.#props),
478
673
  }),
479
674
  });
480
- await opened;
675
+ await failFast(opened);
481
676
  if (signal.aborted)
482
677
  return;
483
678
  const realmGlobals = adapter.installsRealmGlobals ? await resolveRealmGlobals(fragmentId, signal) : undefined;
679
+ if (signal.aborted)
680
+ return;
681
+ const storagePartition = resolveStoragePartition(capabilities, fragmentId);
682
+ const htmlPolicy = resolveHtmlPolicy(capabilities, fragmentId);
683
+ // nothing of the fragment's has run yet; its content may already be painted, inert
684
+ await Promise.all([earlyPrerequisites, latePrerequisites]);
484
685
  if (signal.aborted)
485
686
  return;
486
687
  await adapter.boot({
@@ -495,6 +696,8 @@ export class FragmentSlot extends HTMLElement {
495
696
  bound,
496
697
  env,
497
698
  sharedGlobals: resolveSharedGlobals(capabilities),
699
+ ...(storagePartition === null ? {} : { storagePartition }),
700
+ ...(htmlPolicy === null ? {} : { htmlPolicy }),
498
701
  ...(realmGlobals ? { realmGlobals } : {}),
499
702
  signal,
500
703
  });
@@ -527,7 +730,8 @@ export class FragmentSlot extends HTMLElement {
527
730
  }
528
731
  }
529
732
  catch (error) {
530
- if (this.#abortController?.signal.aborted)
733
+ // a boot a teardown or a newer boot overtook has nothing to report — its abort is not a failure
734
+ if (generation !== this.#bootGeneration || this.#abortController?.signal.aborted)
531
735
  return;
532
736
  this.#setState('error');
533
737
  const braidError = error instanceof BraidError
@@ -537,15 +741,28 @@ export class FragmentSlot extends HTMLElement {
537
741
  stage: 'adapter-mount',
538
742
  cause: error,
539
743
  });
540
- console.error(braidError);
744
+ // a user without access to a prerequisite is an outcome, not a failure: fallback, no alarm,
745
+ // and none of the fragment's content left in the slot
746
+ const notEntitled = braidError instanceof NotEntitledError;
747
+ if (notEntitled) {
748
+ this.shadowRoot?.querySelector('braid-document')?.replaceChildren();
749
+ if (isDevMode())
750
+ console.debug(`[braid:${fragmentId}] ${braidError.message}; showing the fallback`);
751
+ }
752
+ else {
753
+ console.error(braidError);
754
+ }
541
755
  this.dispatchEvent(new CustomEvent('braid:error', {
542
756
  detail: {
543
757
  fragmentId: braidError.fragmentId,
544
758
  stage: braidError.stage,
545
759
  fixHint: braidError.fixHint,
546
760
  error: braidError,
761
+ // an outcome, not a failure: a host routing braid:error to monitoring can skip it
762
+ ...(notEntitled ? { notEntitled: true } : {}),
547
763
  },
548
764
  }));
765
+ this.#recovery.onError(braidError, notEntitled);
549
766
  }
550
767
  }
551
768
  /**
@@ -564,7 +781,9 @@ export class FragmentSlot extends HTMLElement {
564
781
  * negotiation, capabilities, liveness and teardown — because all of it was built against the
565
782
  * boundary rather than against compat's machinery.
566
783
  */
567
- async #bootContract(fragmentId) {
784
+ async #bootContract(fragmentId, requires) {
785
+ // again where it is read: a `braid:state` listener runs between #boot's check and here
786
+ this.#assertHostDeclaresCode(fragmentId);
568
787
  const entry = this.getAttribute('entry');
569
788
  if (!entry) {
570
789
  throw new BraidError('a contract fragment needs an entry module', {
@@ -584,6 +803,13 @@ export class FragmentSlot extends HTMLElement {
584
803
  const contentRoot = document.createElement('braid-document');
585
804
  shadowRoot.replaceChildren(styleSheet, contentRoot);
586
805
  ensureFallbackSlot(shadowRoot);
806
+ ensureLoadingSlot(shadowRoot);
807
+ this.#markBusy();
808
+ // before anything of the fragment exists: no realm, nothing mounted
809
+ if (requires.length)
810
+ await awaitPrerequisites(requires, fragmentId, signal);
811
+ if (signal.aborted)
812
+ return;
587
813
  // The realm's base is the entry's own directory, not a gateway namespace: a contract fragment
588
814
  // fetches its assets from wherever it is hosted, directly.
589
815
  const entryUrl = new URL(entry, document.baseURI);
@@ -677,7 +903,9 @@ export class FragmentSlot extends HTMLElement {
677
903
  * same contract negotiation, the same liveness, the same negotiated teardown. That is the return
678
904
  * on having built the transport before the tier that needed it.
679
905
  */
680
- async #bootUntrusted(fragmentId) {
906
+ async #bootUntrusted(fragmentId, requires) {
907
+ // again where it is read: a `braid:state` listener runs between #boot's check and here
908
+ this.#assertHostDeclaresCode(fragmentId);
681
909
  const src = this.getAttribute('src');
682
910
  if (!src) {
683
911
  throw new BraidError('an untrusted fragment needs an explicit cross-origin src', {
@@ -694,6 +922,11 @@ export class FragmentSlot extends HTMLElement {
694
922
  styleSheet.textContent = SLOT_STYLES;
695
923
  shadowRoot.replaceChildren(styleSheet);
696
924
  ensureFallbackSlot(shadowRoot);
925
+ // before anything of the fragment exists: no frame, no connection
926
+ if (requires.length)
927
+ await awaitPrerequisites(requires, fragmentId, signal);
928
+ if (signal.aborted)
929
+ return;
697
930
  /**
698
931
  * On this tier the capability block is not advisory: `sandbox` and `permissions` become real
699
932
  * iframe attributes that the browser enforces, and `context.read` decides what enters the
@@ -812,6 +1045,29 @@ function redactedLocation(location, base) {
812
1045
  return null;
813
1046
  }
814
1047
  }
1048
+ /**
1049
+ * Starts loading a fragment before its slot exists — from a link's hover, or a router's preload
1050
+ * hook — so a navigation that is about to mount it finds the work already in flight.
1051
+ *
1052
+ * ```ts
1053
+ * link.addEventListener('pointerenter', () => prefetchFragment('billing', '/billing/invoices'));
1054
+ * ```
1055
+ *
1056
+ * `route` is what the slot will render: the destination page's path for a routed fragment, or the
1057
+ * slot's `src` for a widget. Two things start:
1058
+ *
1059
+ * - the fragment's document, held for the next slot of that fragment and route to mount within
1060
+ * ten seconds (`PREFETCH_TTL_MS`) — so that slot makes no document request of its own;
1061
+ * - the scripts the document names, in its own namespace, as `<link rel="prefetch">` the realm
1062
+ * then loads from the HTTP cache.
1063
+ *
1064
+ * Compat fragments only: contract and untrusted fragments load no Braid document. Calling it again
1065
+ * for the same fragment and route while a prefetch is held does nothing. Call
1066
+ * `clearPrefetchedFragments()` when the user signs out.
1067
+ */
1068
+ export function prefetchFragment(fragmentId, route) {
1069
+ startPrefetch(fragmentId, route, (url, signal) => fetchFragmentHtml(fragmentId, url, signal));
1070
+ }
815
1071
  /**
816
1072
  * Fetches the fragment's document from the gateway's document namespace.
817
1073
  *
@@ -862,6 +1118,7 @@ export async function fetchFragmentHtml(fragmentId, routeSrcUrl, signal) {
862
1118
  (location ? ` to "${location}"` : ' (the target is not visible to the browser)'), {
863
1119
  fragmentId,
864
1120
  stage: 'fragment-fetch',
1121
+ httpStatus: response.status || 302,
865
1122
  fixHint: `a fragment's document must not redirect — it is fetched, not navigated to. Usually the request is ` +
866
1123
  `missing authentication and the fragment is sending it to a login page: handle auth inside ` +
867
1124
  `fragment "${fragmentId}" instead of redirecting, and check the Network tab for the redirect's target`,
@@ -880,6 +1137,7 @@ export async function fetchFragmentHtml(fragmentId, routeSrcUrl, signal) {
880
1137
  throw new BraidError(`the gateway responded with HTTP ${response.status} for "${documentUrl}"`, {
881
1138
  fragmentId,
882
1139
  stage: 'fragment-fetch',
1140
+ httpStatus: response.status,
883
1141
  fixHint: response.status === 404
884
1142
  ? `register a manifest for fragment id "${fragmentId}" in the gateway registry`
885
1143
  : `check the gateway logs for fragment id "${fragmentId}"`,