@braidlabs/core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/package.json +2 -2
  2. package/src/adapters/adapter.d.ts +13 -0
  3. package/src/adapters/adapter.js.map +1 -1
  4. package/src/adapters/compat-adapter.d.ts +7 -0
  5. package/src/adapters/compat-adapter.js +24 -16
  6. package/src/adapters/compat-adapter.js.map +1 -1
  7. package/src/adapters/contract-adapter.d.ts +21 -0
  8. package/src/adapters/contract-adapter.js +71 -0
  9. package/src/adapters/contract-adapter.js.map +1 -0
  10. package/src/config.d.ts +29 -0
  11. package/src/config.js +16 -0
  12. package/src/config.js.map +1 -1
  13. package/src/context/context-bus.d.ts +63 -3
  14. package/src/context/context-bus.js +147 -61
  15. package/src/context/context-bus.js.map +1 -1
  16. package/src/elements/fragment-slot.d.ts +114 -1
  17. package/src/elements/fragment-slot.js +743 -60
  18. package/src/elements/fragment-slot.js.map +1 -1
  19. package/src/env/create-env.d.ts +20 -8
  20. package/src/env/create-env.js +48 -16
  21. package/src/env/create-env.js.map +1 -1
  22. package/src/env/define-fragment.d.ts +43 -0
  23. package/src/env/define-fragment.js +23 -0
  24. package/src/env/define-fragment.js.map +1 -0
  25. package/src/env/fragment-env.d.ts +20 -1
  26. package/src/env/fragment-env.js +0 -12
  27. package/src/env/fragment-env.js.map +1 -1
  28. package/src/errors.d.ts +1 -1
  29. package/src/errors.js.map +1 -1
  30. package/src/index.d.ts +14 -1
  31. package/src/index.js +7 -1
  32. package/src/index.js.map +1 -1
  33. package/src/protocol.d.ts +20 -4
  34. package/src/protocol.js +23 -7
  35. package/src/protocol.js.map +1 -1
  36. package/src/realm/realm-manager.d.ts +19 -2
  37. package/src/realm/realm-manager.js +32 -7
  38. package/src/realm/realm-manager.js.map +1 -1
  39. package/src/realm/sandbox-realm.d.ts +95 -0
  40. package/src/realm/sandbox-realm.js +194 -0
  41. package/src/realm/sandbox-realm.js.map +1 -0
  42. package/src/weave/capabilities.d.ts +71 -0
  43. package/src/weave/capabilities.js +61 -0
  44. package/src/weave/capabilities.js.map +1 -0
  45. package/src/weave/channel.d.ts +63 -0
  46. package/src/weave/channel.js +189 -0
  47. package/src/weave/channel.js.map +1 -0
  48. package/src/weave/closing.d.ts +99 -0
  49. package/src/weave/closing.js +130 -0
  50. package/src/weave/closing.js.map +1 -0
  51. package/src/weave/context-bridge.d.ts +81 -0
  52. package/src/weave/context-bridge.js +145 -0
  53. package/src/weave/context-bridge.js.map +1 -0
  54. package/src/weave/contract.d.ts +89 -0
  55. package/src/weave/contract.js +143 -0
  56. package/src/weave/contract.js.map +1 -0
  57. package/src/weave/envelope.d.ts +80 -0
  58. package/src/weave/envelope.js +49 -0
  59. package/src/weave/envelope.js.map +1 -0
  60. package/src/weave/guest.d.ts +59 -0
  61. package/src/weave/guest.js +120 -0
  62. package/src/weave/guest.js.map +1 -0
  63. package/src/weave/handshake.d.ts +124 -0
  64. package/src/weave/handshake.js +109 -0
  65. package/src/weave/handshake.js.map +1 -0
  66. package/src/weave/liveness.d.ts +90 -0
  67. package/src/weave/liveness.js +189 -0
  68. package/src/weave/liveness.js.map +1 -0
  69. package/src/weave/messages.d.ts +27 -0
  70. package/src/weave/messages.js +13 -0
  71. package/src/weave/messages.js.map +1 -0
  72. package/src/weave/port-backing.d.ts +19 -0
  73. package/src/weave/port-backing.js +54 -0
  74. package/src/weave/port-backing.js.map +1 -0
  75. package/src/weave/same-realm-backing.d.ts +21 -0
  76. package/src/weave/same-realm-backing.js +44 -0
  77. 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
- #propsListeners = new Set();
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
- this.#propsListeners.forEach((listener) => listener(this.#props));
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
- this.#teardown();
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
- #teardown() {
116
- this.#abortController?.abort();
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
- this.shadowRoot?.replaceChildren();
119
- this.#state = 'idle';
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: '<fragment-slot name="..."> must name a fragment registered in the gateway',
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
- throw new BraidError('the untrusted tier is not available in this build', {
133
- fragmentId,
134
- stage: 'slot-config',
135
- fixHint: 'this build of @braidlabs/core ships the trusted compat tier only',
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
- : this.#fetchFragmentHtml(fragmentId, routeSrcUrl, signal).then((html) => ({ ok: true, html }), (error) => ({ ok: false, error })),
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
- const env = createFragmentEnv({
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
- getProps: () => this.#props,
195
- onPropsChanged: (listener) => {
196
- this.#propsListeners.add(listener);
197
- const unsubscribe = () => this.#propsListeners.delete(listener);
198
- signal.addEventListener('abort', unsubscribe, { once: true });
199
- return unsubscribe;
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
- emit: (type, detail) => {
202
- this.dispatchEvent(new CustomEvent('braid:event', { detail: { type, detail }, bubbles: true }));
457
+ assertReachable: (versions) => {
458
+ for (const [key, as] of Object.entries(versions))
459
+ braidContext.assertReachable(key, as, fragmentId);
203
460
  },
204
- signal,
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
- html,
212
- pierced: Boolean(piercedContentRoot),
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.#state = 'ready';
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.#state = 'error';
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
- async #fetchFragmentHtml(fragmentId, routeSrcUrl, signal) {
249
- // the document namespace: the gateway prepares this exactly as it prepares pierced content
250
- const documentUrl = braidDocumentUrl(fragmentId, routeSrcUrl.pathname, routeSrcUrl.search);
251
- let response;
252
- try {
253
- response = await fetch(documentUrl, {
254
- headers: {
255
- accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
256
- [BRAID_FRAGMENT_ID_HEADER]: fragmentId,
257
- },
258
- signal,
259
- });
260
- }
261
- catch (error) {
262
- throw new BraidError(`fetching the fragment's html from "${documentUrl}" failed`, {
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: 'fragment-fetch',
265
- cause: error,
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
- if (!response.ok) {
270
- throw new BraidError(`the gateway responded with HTTP ${response.status} for "${documentUrl}"`, {
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: 'fragment-fetch',
273
- fixHint: response.status === 404
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
- return response.text();
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