@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.
- 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 +159 -12
- 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 +34 -0
- package/src/elements/fragment-slot.js +282 -24
- 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/errors.d.ts +4 -1
- package/src/errors.js +2 -0
- package/src/errors.js.map +1 -1
- package/src/index.d.ts +4 -1
- package/src/index.js +10 -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
|
@@ -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.#
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}"`,
|