@pie-players/pie-players-shared 0.3.69 → 0.3.70

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 (35) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +1 -0
  3. package/dist/loaders/iife-adapter.js +7 -3
  4. package/dist/pie/element-observer.d.ts +59 -0
  5. package/dist/pie/element-observer.js +131 -0
  6. package/dist/pie/index.d.ts +4 -2
  7. package/dist/pie/index.js +7 -2
  8. package/dist/pie/initialization.d.ts +13 -13
  9. package/dist/pie/initialization.js +97 -147
  10. package/dist/pie/initialize-element.d.ts +23 -0
  11. package/dist/pie/initialize-element.js +78 -0
  12. package/dist/pie/instrumentation-event-map.d.ts +1 -0
  13. package/dist/pie/instrumentation-event-map.js +18 -0
  14. package/dist/pie/math-rendering.js +6 -2
  15. package/dist/pie/types.d.ts +10 -0
  16. package/dist/pie/utils.d.ts +27 -0
  17. package/dist/pie/utils.js +56 -1
  18. package/dist/security/index.d.ts +3 -2
  19. package/dist/security/index.js +3 -2
  20. package/dist/security/sanitize-forbidden-lists.js +9 -0
  21. package/dist/security/sanitize-item-markup.js +4 -0
  22. package/dist/security/sanitize-style-attribute.d.ts +48 -0
  23. package/dist/security/sanitize-style-attribute.js +129 -0
  24. package/dist/security/sanitize-svg-icon.js +2 -0
  25. package/dist/security/validate-style-url.d.ts +13 -0
  26. package/dist/security/validate-style-url.js +36 -2
  27. package/dist/security/wrap-overwide-images.d.ts +7 -0
  28. package/dist/security/wrap-overwide-images.js +10 -1
  29. package/dist/security/wrap-overwide-tables.d.ts +7 -0
  30. package/dist/security/wrap-overwide-tables.js +10 -1
  31. package/dist/security/wrap-overwide.d.ts +16 -0
  32. package/dist/security/wrap-overwide.js +52 -0
  33. package/dist/ui/overlay-containment.d.ts +48 -0
  34. package/dist/ui/overlay-containment.js +65 -0
  35. package/package.json +6 -6
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export { editorPostFix } from "./types/index.js";
11
11
  export * from "./ui/attribute-coercion.js";
12
12
  export * from "./ui/content-styles.js";
13
13
  export * from "./ui/pointer-drag.js";
14
+ export * from "./ui/overlay-containment.js";
14
15
  export * from "./ui/focus-trap.js";
15
16
  export * from "./ui/first-focusable.js";
16
17
  export * from "./ui/debug-panel-persistence.js";
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export { editorPostFix } from "./types/index.js";
11
11
  export * from "./ui/attribute-coercion.js";
12
12
  export * from "./ui/content-styles.js";
13
13
  export * from "./ui/pointer-drag.js";
14
+ export * from "./ui/overlay-containment.js";
14
15
  export * from "./ui/focus-trap.js";
15
16
  export * from "./ui/first-focusable.js";
16
17
  export * from "./ui/debug-panel-persistence.js";
@@ -24,7 +24,7 @@ import { defineCustomElementSafely } from "../pie/custom-element-define.js";
24
24
  import { pieRegistry } from "../pie/registry.js";
25
25
  import { validateCustomElementTag } from "../pie/tag-names.js";
26
26
  import { BundleType, isCustomElementConstructor, Status, } from "../pie/types.js";
27
- import { getPackageWithoutVersion, parsePackageName } from "../pie/utils.js";
27
+ import { encodeElementPackageSpecs, getPackageWithoutVersion, parsePackageName, } from "../pie/utils.js";
28
28
  import { AdapterFailure, } from "./element-loader-types.js";
29
29
  /**
30
30
  * Default PIE bundle service base URL. Exported so widgets can use it as
@@ -352,9 +352,13 @@ function buildBundleUrl(elements, bundleType, config) {
352
352
  ? `${config.bundleInfo.url}${separator}elements=${encodeURIComponent(elementTags)}`
353
353
  : config.bundleInfo.url;
354
354
  }
355
- const packageVersions = Object.values(elements).join("+");
355
+ // The two encoders here are deliberately different: the path takes
356
+ // per-spec encoding because a scoped spec's `/` and `@` must stay literal
357
+ // for the route to match, while the `elements=` value is an ordinary query
358
+ // parameter. See `encodeElementPackageSpec`.
359
+ const packageVersions = encodeElementPackageSpecs(Object.values(elements));
356
360
  const host = normalizeBundleHost(config.bundleHost);
357
- const base = `${host}${encodeURI(packageVersions)}/${bundleType}`;
361
+ const base = `${host}${packageVersions}/${bundleType}`;
358
362
  return elementTags
359
363
  ? `${base}?elements=${encodeURIComponent(elementTags)}`
360
364
  : base;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Binding PIE elements that arrive after their bundle was registered.
3
+ *
4
+ * A bundle registration binds models and sessions to the PIE elements present
5
+ * in its container at registration time. Elements that arrive afterwards — ones
6
+ * a host appends to authored markup after the player mounted, or ones a tag's
7
+ * own render pass paints into its subtree — are bound by a `MutationObserver`.
8
+ *
9
+ * The observer is scoped to the registration's container, so a mutation
10
+ * elsewhere on the host page never reaches the callback and no `contains()`
11
+ * walk is needed to reject one. One observer serves every registration made
12
+ * against the same container — an item player registers its item config and its
13
+ * passage config separately — and it disconnects when the last of those
14
+ * registrations is released.
15
+ *
16
+ * This replaces a pair of `window` globals: one observer on `document.body` for
17
+ * the lifetime of the page, and one context slot that every registration
18
+ * overwrote. With a single slot the second registration displaced the first, so
19
+ * a late passage element, or any element in a player that was not the most
20
+ * recently registered, silently never bound.
21
+ */
22
+ import type { ConfigEntity, Env } from "../types/index.js";
23
+ import type { EventListenersMap } from "./types.js";
24
+ /**
25
+ * What one registration binds a PIE element with.
26
+ */
27
+ export interface PieElementContext {
28
+ config: ConfigEntity;
29
+ session: any[];
30
+ env?: Env;
31
+ eventListeners?: EventListenersMap;
32
+ }
33
+ /**
34
+ * Reads the context to bind with.
35
+ *
36
+ * Called when an element arrives, not when the registration is made: a player
37
+ * recomputes its session and env on render, so a value captured at
38
+ * registration time is stale by the time a late element needs it.
39
+ */
40
+ export type PieElementContextSource = () => PieElementContext;
41
+ /**
42
+ * Watch `container` for late-arriving PIE elements and bind them with whatever
43
+ * `getContext` returns at that moment. `container` defaults to `document.body`,
44
+ * matching an unscoped `LoadPieElementsOptions.container`.
45
+ *
46
+ * Returns the release for this registration. Calling it more than once is a
47
+ * no-op; the observer disconnects once every registration against the container
48
+ * has been released, so the caller that acquired it owns it.
49
+ */
50
+ export declare const observePieElements: (container: Element | Document | undefined, getContext: PieElementContextSource) => (() => void);
51
+ /**
52
+ * The contexts registered for `root` or for any container inside it, resolved
53
+ * now, in registration order.
54
+ *
55
+ * A host holds the custom element it mounted while the player registers its own
56
+ * inner root as the container, so the lookup accepts an ancestor of the
57
+ * container.
58
+ */
59
+ export declare const pieElementContextsWithin: (root: Element | Document) => PieElementContext[];
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Binding PIE elements that arrive after their bundle was registered.
3
+ *
4
+ * A bundle registration binds models and sessions to the PIE elements present
5
+ * in its container at registration time. Elements that arrive afterwards — ones
6
+ * a host appends to authored markup after the player mounted, or ones a tag's
7
+ * own render pass paints into its subtree — are bound by a `MutationObserver`.
8
+ *
9
+ * The observer is scoped to the registration's container, so a mutation
10
+ * elsewhere on the host page never reaches the callback and no `contains()`
11
+ * walk is needed to reject one. One observer serves every registration made
12
+ * against the same container — an item player registers its item config and its
13
+ * passage config separately — and it disconnects when the last of those
14
+ * registrations is released.
15
+ *
16
+ * This replaces a pair of `window` globals: one observer on `document.body` for
17
+ * the lifetime of the page, and one context slot that every registration
18
+ * overwrote. With a single slot the second registration displaced the first, so
19
+ * a late passage element, or any element in a player that was not the most
20
+ * recently registered, silently never bound.
21
+ */
22
+ import { initializePieElement } from "./initialize-element.js";
23
+ import { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
24
+ import { pieRegistry } from "./registry.js";
25
+ const logger = createPieLogger("pie-element-observer", () => isGlobalDebugEnabled());
26
+ const observed = new Map();
27
+ const resolveContexts = (sources) => {
28
+ const contexts = [];
29
+ for (const source of sources) {
30
+ try {
31
+ contexts.push(source());
32
+ }
33
+ catch (error) {
34
+ logger.error("[pieElementObserver] A context source threw; skipping it.", error);
35
+ }
36
+ }
37
+ return contexts;
38
+ };
39
+ const bindElement = (element, contexts) => {
40
+ const tagName = element.tagName.toLowerCase();
41
+ if (!pieRegistry()[tagName])
42
+ return;
43
+ for (const context of contexts) {
44
+ const bound = initializePieElement(element, {
45
+ config: context.config,
46
+ session: context.session,
47
+ env: context.env,
48
+ eventListeners: context.eventListeners?.[tagName],
49
+ });
50
+ if (bound)
51
+ return;
52
+ }
53
+ };
54
+ const handleMutations = (sources, mutations) => {
55
+ // Resolved once per delivery, not per element.
56
+ const contexts = resolveContexts(sources);
57
+ if (contexts.length === 0)
58
+ return;
59
+ for (const mutation of mutations) {
60
+ if (mutation.type !== "childList")
61
+ continue;
62
+ for (const node of mutation.addedNodes) {
63
+ if (node.nodeType !== Node.ELEMENT_NODE)
64
+ continue;
65
+ const element = node;
66
+ bindElement(element, contexts);
67
+ for (const descendant of Array.from(element.querySelectorAll("*"))) {
68
+ bindElement(descendant, contexts);
69
+ }
70
+ }
71
+ }
72
+ };
73
+ /**
74
+ * Watch `container` for late-arriving PIE elements and bind them with whatever
75
+ * `getContext` returns at that moment. `container` defaults to `document.body`,
76
+ * matching an unscoped `LoadPieElementsOptions.container`.
77
+ *
78
+ * Returns the release for this registration. Calling it more than once is a
79
+ * no-op; the observer disconnects once every registration against the container
80
+ * has been released, so the caller that acquired it owns it.
81
+ */
82
+ export const observePieElements = (container, getContext) => {
83
+ if (typeof document === "undefined" ||
84
+ typeof MutationObserver === "undefined") {
85
+ return () => { };
86
+ }
87
+ const target = container ?? document.body;
88
+ if (!target)
89
+ return () => { };
90
+ let entry = observed.get(target);
91
+ if (!entry) {
92
+ const sources = new Set();
93
+ const observer = new MutationObserver((mutations) => handleMutations(sources, mutations));
94
+ observer.observe(target, { childList: true, subtree: true });
95
+ entry = { sources, observer };
96
+ observed.set(target, entry);
97
+ logger.debug("[observePieElements] Observing a new container");
98
+ }
99
+ const { sources, observer } = entry;
100
+ sources.add(getContext);
101
+ logger.debug(`[observePieElements] Container now has ${sources.size} registration(s)`);
102
+ let released = false;
103
+ return () => {
104
+ if (released)
105
+ return;
106
+ released = true;
107
+ sources.delete(getContext);
108
+ if (sources.size > 0)
109
+ return;
110
+ observer.disconnect();
111
+ observed.delete(target);
112
+ logger.debug("[observePieElements] Released the last registration, disconnected");
113
+ };
114
+ };
115
+ /**
116
+ * The contexts registered for `root` or for any container inside it, resolved
117
+ * now, in registration order.
118
+ *
119
+ * A host holds the custom element it mounted while the player registers its own
120
+ * inner root as the container, so the lookup accepts an ancestor of the
121
+ * container.
122
+ */
123
+ export const pieElementContextsWithin = (root) => {
124
+ const sources = [];
125
+ for (const [container, entry] of observed) {
126
+ if (container !== root && !root.contains(container))
127
+ continue;
128
+ sources.push(...entry.sources);
129
+ }
130
+ return resolveContexts(sources);
131
+ };
@@ -10,6 +10,8 @@
10
10
  */
11
11
  export { assertPieConfigContract, addMarkupForPackage, addRubricIfNeeded, elementForPackage, makeUniqueTags, modelsForPackage, validatePieConfigContract, } from "./config.js";
12
12
  export { initializePiesFromLoadedBundle, loadBundleFromString, loadPieModule, loadPieModuleFromString, } from "./initialization.js";
13
+ export { observePieElements, pieElementContextsWithin, } from "./element-observer.js";
14
+ export type { PieElementContext, PieElementContextSource, } from "./element-observer.js";
13
15
  export { createAuthoringAssetEventManager, initializeAuthoringConfigures, resolveConfigureConfiguration, validateAuthoringModels, } from "./authoring.js";
14
16
  export type { AuthoringMediaHandlers, AuthoringValidationResult, InitializedConfigureModel, } from "./authoring.js";
15
17
  export { initializeMathRendering, renderMath, setMathRenderer, } from "./math-rendering.js";
@@ -20,7 +22,7 @@ export { STAGES, applicableStages, stageOrdinal, } from "./stages.js";
20
22
  export type { LoadingCompleteDetail, Stage, StageChangeDetail, StageSourceCe, StageStatus, } from "./stages.js";
21
23
  export { createStageTracker } from "./stage-tracker.js";
22
24
  export type { CreateStageTrackerOptions, StageTracker, } from "./stage-tracker.js";
23
- export { ASSESSMENT_INSTRUMENTATION_EVENT_MAP, SECTION_INSTRUMENTATION_EVENT_MAP, TOOLKIT_INSTRUMENTATION_EVENT_MAP, } from "./instrumentation-event-map.js";
25
+ export { ASSESSMENT_INSTRUMENTATION_EVENT_MAP, ITEM_INSTRUMENTATION_EVENT_MAP, SECTION_INSTRUMENTATION_EVENT_MAP, TOOLKIT_INSTRUMENTATION_EVENT_MAP, } from "./instrumentation-event-map.js";
24
26
  export type { InstrumentationEventMapping } from "./instrumentation-event-map.js";
25
27
  export type { ItemControllerOptions } from "./item-controller.js";
26
28
  export { ItemController, normalizeItemSessionContainer, } from "./item-controller.js";
@@ -42,4 +44,4 @@ export type { PieViewMode } from "./tag-names.js";
42
44
  export { updatePieElement, updatePieElements, updatePieElementWithRef, } from "./updates.js";
43
45
  export type { ElementOverrides } from "./overrides.js";
44
46
  export { addOrUpdateOverrideInUrl, applyElementOverrides, applyElementVersionOverridesPreserveTags, extractPackageInfo, formatElementOverrideParam, parseElementOverridesFromCurrentUrl, parseElementOverridesFromUrl, } from "./overrides.js";
45
- export { findOrAddSession, getPackageWithoutVersion, getPieElementBundlesUrl, parsePackageName, } from "./utils.js";
47
+ export { encodeElementPackageSpecs, findOrAddSession, getPackageWithoutVersion, getPieElementBundlesUrl, parsePackageName, } from "./utils.js";
package/dist/pie/index.js CHANGED
@@ -14,6 +14,11 @@ export { assertPieConfigContract, addMarkupForPackage, addRubricIfNeeded, elemen
14
14
  // sync `assertRegistered`) lives under `pie-players-shared/loaders`.
15
15
  // Initialization
16
16
  export { initializePiesFromLoadedBundle, loadBundleFromString, loadPieModule, loadPieModuleFromString, } from "./initialization.js";
17
+ // Late-arrival element binding. The owner of a container observes it and
18
+ // releases the observer on teardown; a host reads `pieElementContextsWithin` to
19
+ // recover the config and session a mounted player registered for a container it
20
+ // owns.
21
+ export { observePieElements, pieElementContextsWithin, } from "./element-observer.js";
17
22
  export { createAuthoringAssetEventManager, initializeAuthoringConfigures, resolveConfigureConfiguration, validateAuthoringModels, } from "./authoring.js";
18
23
  export { initializeMathRendering, renderMath, setMathRenderer, } from "./math-rendering.js";
19
24
  export { attachInstrumentationEventBridge } from "./instrumentation-event-bridge.js";
@@ -23,7 +28,7 @@ export { resolveInstrumentationProvider } from "./instrumentation-provider-resol
23
28
  // `pie-stage-change` event family stays coherent across CE shapes.
24
29
  export { STAGES, applicableStages, stageOrdinal, } from "./stages.js";
25
30
  export { createStageTracker } from "./stage-tracker.js";
26
- export { ASSESSMENT_INSTRUMENTATION_EVENT_MAP, SECTION_INSTRUMENTATION_EVENT_MAP, TOOLKIT_INSTRUMENTATION_EVENT_MAP, } from "./instrumentation-event-map.js";
31
+ export { ASSESSMENT_INSTRUMENTATION_EVENT_MAP, ITEM_INSTRUMENTATION_EVENT_MAP, SECTION_INSTRUMENTATION_EVENT_MAP, TOOLKIT_INSTRUMENTATION_EVENT_MAP, } from "./instrumentation-event-map.js";
27
32
  export { ItemController, normalizeItemSessionContainer, } from "./item-controller.js";
28
33
  export { hasResponseValue, normalizeItemSessionChange, } from "./item-session-contract.js";
29
34
  export { MemoryItemSessionStorage, SessionStorageItemSessionStorage, } from "./item-controller-storage.js";
@@ -43,4 +48,4 @@ export { toPrintHashedTag, toViewTag, validateCustomElementTag, VIEW_TAG_SUFFIX,
43
48
  export { updatePieElement, updatePieElements, updatePieElementWithRef, } from "./updates.js";
44
49
  export { addOrUpdateOverrideInUrl, applyElementOverrides, applyElementVersionOverridesPreserveTags, extractPackageInfo, formatElementOverrideParam, parseElementOverridesFromCurrentUrl, parseElementOverridesFromUrl, } from "./overrides.js";
45
50
  // Utils
46
- export { findOrAddSession, getPackageWithoutVersion, getPieElementBundlesUrl, parsePackageName, } from "./utils.js";
51
+ export { encodeElementPackageSpecs, findOrAddSession, getPackageWithoutVersion, getPieElementBundlesUrl, parsePackageName, } from "./utils.js";
@@ -4,19 +4,8 @@
4
4
  * Bundle loading and element initialization logic.
5
5
  * This is the core of the PIE player system.
6
6
  */
7
- import type { ConfigEntity, Env } from "../types/index.js";
7
+ import type { ConfigEntity } from "../types/index.js";
8
8
  import type { LoadPieElementsOptions } from "./types.js";
9
- declare global {
10
- interface Window {
11
- _pieElementObserver?: MutationObserver;
12
- _pieCurrentContext?: {
13
- config: ConfigEntity;
14
- session: any[];
15
- env?: Env;
16
- container?: Element | Document;
17
- };
18
- }
19
- }
20
9
  /**
21
10
  * Initialize PIE elements from a bundle that may already be loaded.
22
11
  *
@@ -44,10 +33,21 @@ declare global {
44
33
  * (bundle not loaded by *anyone*) still surface — every unregistered tag
45
34
  * gets its own warning, and `updatePieElements` later reports any tag
46
35
  * that never resolves.
36
+ *
37
+ * Binds the elements present in `opts.container` now. Elements that arrive
38
+ * later are the container owner's concern: a player with a lifecycle calls
39
+ * `observePieElements` and releases it on teardown.
47
40
  */
48
41
  export declare const initializePiesFromLoadedBundle: (config: ConfigEntity, session: any[], opts?: LoadPieElementsOptions) => void;
49
42
  /**
50
- * Load a PIE bundle from a URL and initialize elements
43
+ * Load a PIE bundle from a URL and initialize elements.
44
+ *
45
+ * Rejects — rather than hanging or throwing on the window — for every way
46
+ * the load can fail: the `error` event (404, blocked request, CSP refusal),
47
+ * the `loadTimeoutMs` deadline (a stalled request that never fires either
48
+ * event), a bundle whose script ran without populating `window.pie`, and a
49
+ * throw out of registration. Every rejection names the bundle URL and drops
50
+ * the injected `<script>`.
51
51
  */
52
52
  export declare const loadPieModule: (config: ConfigEntity, session: any[], opts?: LoadPieElementsOptions) => Promise<{
53
53
  session: any[];
@@ -5,84 +5,33 @@
5
5
  * This is the core of the PIE player system.
6
6
  */
7
7
  import { BUILDER_BUNDLE_URL } from "../config/profile.js";
8
+ import { DEFAULT_IIFE_BUNDLE_RETRY_CONFIG } from "../loader-config.js";
8
9
  import { mergeObjectsIgnoringNullUndefined } from "../object/index.js";
9
- import { wrapModelRichContent } from "../security/wrap-model-rich-content.js";
10
10
  import { editorPostFix } from "../types/index.js";
11
+ import { initializePieElement } from "./initialize-element.js";
11
12
  import { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
12
13
  import { initializeMathRendering } from "./math-rendering.js";
13
14
  import { pieRegistry } from "./registry.js";
14
- import { findPieController } from "./scoring.js";
15
15
  import { defineCustomElementSafely } from "./custom-element-define.js";
16
16
  import { validateCustomElementTag } from "./tag-names.js";
17
17
  import { BundleType, isCustomElementConstructor, isPieAvailable, Status, } from "./types.js";
18
18
  import { updatePieElement } from "./updates.js";
19
- import { findOrAddSession, getPackageWithoutVersion, getPieElementBundlesUrl, } from "./utils.js";
19
+ import { getPackageWithoutVersion, getPieElementBundlesUrl, } from "./utils.js";
20
20
  // Create module-level logger (respects global debug flag - pass function for dynamic checking)
21
21
  const logger = createPieLogger("pie-initialization", () => isGlobalDebugEnabled());
22
+ /**
23
+ * Deadline for `loadPieModule`'s bundle `<script>` load when the caller
24
+ * sets no `loadTimeoutMs`. Shared with the `ElementLoader` primitive's
25
+ * `DEFAULT_LOAD_TIMEOUT_MS` so a bundle gets the same budget whichever
26
+ * path a host loads it through.
27
+ */
28
+ const DEFAULT_LOAD_TIMEOUT_MS = DEFAULT_IIFE_BUNDLE_RETRY_CONFIG.timeoutMs;
22
29
  // Default options for loading PIE elements
23
30
  const defaultOptions = {
24
31
  buildServiceBase: BUILDER_BUNDLE_URL,
25
32
  bundleType: BundleType.player, // Default to player.js (no controllers, server-processed models)
26
33
  env: { mode: "gather", role: "student" },
27
34
  };
28
- /**
29
- * Helper function to initialize a PIE element
30
- */
31
- const initializePieElement = (element, options) => {
32
- const { config, session, env, eventListeners } = options;
33
- if (element.__pieInitialized) {
34
- return;
35
- }
36
- const tagName = element.tagName.toLowerCase();
37
- logger.debug(`[initializePieElement] Initializing ${tagName}#${element.id}`);
38
- // Find model for this element
39
- let model = config?.models?.find((m) => m.id === element.id);
40
- if (!model) {
41
- // Only warn if this element is from a client-player.js bundle (where models are expected)
42
- // player.js bundles use server-processed models, so missing models are expected there
43
- const registry = pieRegistry();
44
- const registryEntry = registry[tagName];
45
- if (registryEntry && registryEntry.bundleType === BundleType.clientPlayer) {
46
- logger.warn(`[initializePieElement] Model not found for PIE element ${tagName}#${element.id} (client-player.js bundle)`);
47
- }
48
- return;
49
- }
50
- // Set session (with element property for updateSession callback)
51
- const elementSession = findOrAddSession(session, model.id, model.element);
52
- element.session = elementSession;
53
- element.__pieInitialized = true;
54
- logger.debug(`[initializePieElement] Session set for ${tagName}#${element.id}:`, elementSession);
55
- // Set model - use controller if available (client-player.js), or use server-processed model (player.js)
56
- const controller = findPieController(tagName);
57
- if (!env) {
58
- logger.error(`[initializePieElement] ❌ FATAL: No env provided for ${tagName}`);
59
- throw new Error(`No env provided for ${tagName}. PIE elements require an env object with mode and role.`);
60
- }
61
- if (!controller) {
62
- // No controller available - using server-processed model (player.js bundle)
63
- logger.debug(`[initializePieElement] ℹ️ No controller for ${tagName}, using server-processed model`);
64
- logger.debug(`[initializePieElement] Model already processed by server:`, {
65
- id: model.id,
66
- element: model.element,
67
- hasCorrectResponse: "correctResponse" in model,
68
- mode: env.mode,
69
- role: env.role,
70
- });
71
- // Set model directly - server already processed it
72
- element.model = wrapModelRichContent(model);
73
- }
74
- else {
75
- // Controller available - run client-side processing (client-player.js bundle)
76
- // Note: updatePieElementWithRef handles controller invocation
77
- logger.debug(`[initializePieElement] Controller found for ${tagName}, will invoke model() function`);
78
- }
79
- // Add event listeners
80
- if (eventListeners) {
81
- Object.entries(eventListeners).forEach(([evt, fn]) => {
82
- element.addEventListener(evt, fn);
83
- });
84
- }
85
- };
86
35
  const getEditorElementTagName = (elementTagName, pkg) => validateCustomElementTag(elementTagName + editorPostFix, `editor element tag for ${pkg}`);
87
36
  const updateRegisteredElement = (elementTagName, config, session, options, omitEnv = false) => {
88
37
  updatePieElement(elementTagName, {
@@ -98,7 +47,10 @@ const updateRegisteredElement = (elementTagName, config, session, options, omitE
98
47
  /**
99
48
  * Shared element registration logic
100
49
  * Extracted from initializePiesFromLoadedBundle and loadPieModule to eliminate ~200 lines of duplication
101
- * Also fixes MutationObserver memory leak by storing latest config/session in window context
50
+ *
51
+ * Binds the elements already present in `options.container`. Elements that
52
+ * arrive later are bound by the container owner's observer — see
53
+ * `element-observer.ts`.
102
54
  *
103
55
  * `elementModule` may be `null`. In that case we cannot register *new*
104
56
  * tags (no element constructor source), but we can still update tags
@@ -111,11 +63,6 @@ const updateRegisteredElement = (elementTagName, config, session, options, omitE
111
63
  */
112
64
  const registerPieElementsFromBundle = (elementModule, config, session, registry, options) => {
113
65
  const promises = [];
114
- const isNodeWithinContainer = (node, container) => {
115
- if (!container || container === document)
116
- return true;
117
- return node instanceof Node && container.contains(node);
118
- };
119
66
  if (elementModule) {
120
67
  logger.debug("[registerPieElementsFromBundle] Available packages in bundle:", Object.keys(elementModule));
121
68
  }
@@ -123,15 +70,6 @@ const registerPieElementsFromBundle = (elementModule, config, session, registry,
123
70
  logger.debug("[registerPieElementsFromBundle] No bundle module supplied; will only update tags already registered with customElements.");
124
71
  }
125
72
  logger.debug("[registerPieElementsFromBundle] config.elements:", config.elements);
126
- // Store latest config/session in window so MutationObserver can access current values
127
- if (typeof window !== "undefined") {
128
- window._pieCurrentContext = {
129
- config,
130
- session,
131
- env: options.env,
132
- container: options.container,
133
- };
134
- }
135
73
  Object.entries(config.elements).forEach(([elName, pkg]) => {
136
74
  const elementTagName = validateCustomElementTag(elName, `element tag in config.elements for ${String(pkg)}`);
137
75
  logger.debug(`[registerPieElementsFromBundle] Processing element: ${elementTagName} -> ${pkg}`);
@@ -227,58 +165,6 @@ const registerPieElementsFromBundle = (elementModule, config, session, registry,
227
165
  promises.push(customElements.whenDefined(elementTagName).then(() => {
228
166
  logger.debug("[registerPieElementsFromBundle] defined custom PIE element: %s", elementTagName);
229
167
  }));
230
- // Setup MutationObserver that uses current context (only once)
231
- if (!window._pieElementObserver) {
232
- window._pieElementObserver = new MutationObserver((mutations) => {
233
- // Use current context from window instead of stale closure
234
- const context = window._pieCurrentContext;
235
- if (!context) {
236
- logger.warn("[MutationObserver] No current context available");
237
- return;
238
- }
239
- mutations.forEach((mutation) => {
240
- if (mutation.type === "childList") {
241
- mutation.addedNodes.forEach((node) => {
242
- if (node.nodeType === Node.ELEMENT_NODE) {
243
- if (!isNodeWithinContainer(node, context.container)) {
244
- return;
245
- }
246
- const tagName = node.tagName.toLowerCase();
247
- if (registry[tagName]) {
248
- initializePieElement(node, {
249
- config: context.config,
250
- session: context.session,
251
- env: context.env,
252
- eventListeners: options.eventListeners?.[tagName],
253
- });
254
- }
255
- // Check children of added nodes
256
- node
257
- .querySelectorAll("*")
258
- .forEach((childNode) => {
259
- if (!isNodeWithinContainer(childNode, context.container)) {
260
- return;
261
- }
262
- const childTagName = childNode.tagName.toLowerCase();
263
- if (registry[childTagName]) {
264
- initializePieElement(childNode, {
265
- config: context.config,
266
- session: context.session,
267
- env: context.env,
268
- eventListeners: options.eventListeners?.[childTagName],
269
- });
270
- }
271
- });
272
- }
273
- });
274
- }
275
- });
276
- });
277
- window._pieElementObserver.observe(document.body, {
278
- childList: true,
279
- subtree: true,
280
- });
281
- }
282
168
  // Handle editor elements if needed
283
169
  if (options.bundleType === BundleType.editor) {
284
170
  if (isCustomElementConstructor(elementData.Configure)) {
@@ -327,6 +213,10 @@ const registerPieElementsFromBundle = (elementModule, config, session, registry,
327
213
  * (bundle not loaded by *anyone*) still surface — every unregistered tag
328
214
  * gets its own warning, and `updatePieElements` later reports any tag
329
215
  * that never resolves.
216
+ *
217
+ * Binds the elements present in `opts.container` now. Elements that arrive
218
+ * later are the container owner's concern: a player with a lifecycle calls
219
+ * `observePieElements` and releases it on teardown.
330
220
  */
331
221
  export const initializePiesFromLoadedBundle = (config, session, opts = {}) => {
332
222
  const registry = pieRegistry();
@@ -341,7 +231,14 @@ export const initializePiesFromLoadedBundle = (config, session, opts = {}) => {
341
231
  registerPieElementsFromBundle(null, config, session, registry, options);
342
232
  };
343
233
  /**
344
- * Load a PIE bundle from a URL and initialize elements
234
+ * Load a PIE bundle from a URL and initialize elements.
235
+ *
236
+ * Rejects — rather than hanging or throwing on the window — for every way
237
+ * the load can fail: the `error` event (404, blocked request, CSP refusal),
238
+ * the `loadTimeoutMs` deadline (a stalled request that never fires either
239
+ * event), a bundle whose script ran without populating `window.pie`, and a
240
+ * throw out of registration. Every rejection names the bundle URL and drops
241
+ * the injected `<script>`.
345
242
  */
346
243
  export const loadPieModule = async (config, session, opts = {}) => {
347
244
  if (!session) {
@@ -352,31 +249,84 @@ export const loadPieModule = async (config, session, opts = {}) => {
352
249
  const registry = pieRegistry();
353
250
  const options = mergeObjectsIgnoringNullUndefined(defaultOptions, opts);
354
251
  const url = opts.bundleUrl || getPieElementBundlesUrl(config, options);
252
+ const loadTimeoutMs = options.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS;
355
253
  const script = document.createElement("script");
356
254
  script.src = url;
357
255
  script.defer = true;
358
- script.onerror = () => {
359
- throw new Error(`failed to load script: ${url}`);
360
- };
361
- const loadPromise = new Promise((loadResolve) => {
362
- script.addEventListener("load", () => {
363
- logger.debug("[loadPieModule] Script loaded from:", url);
364
- if (isPieAvailable(window)) {
256
+ let timer;
257
+ // Removing a `<script>` does not abort a request already in flight, so a
258
+ // late `load` can still fire after the deadline rejected. Each handler
259
+ // checks this so registration never runs for a load the caller has
260
+ // already been told failed.
261
+ let settled = false;
262
+ try {
263
+ await new Promise((resolve, reject) => {
264
+ const succeed = () => {
265
+ settled = true;
266
+ resolve();
267
+ };
268
+ const fail = (error) => {
269
+ settled = true;
270
+ reject(error);
271
+ };
272
+ script.addEventListener("load", () => {
273
+ if (settled)
274
+ return;
275
+ logger.debug("[loadPieModule] Script loaded from:", url);
276
+ if (!isPieAvailable(window)) {
277
+ // Deliberately a rejection, not a resolve. The script executed
278
+ // and registered nothing, so the URL did not serve a PIE IIFE
279
+ // bundle; resolving would report a successful load to a caller
280
+ // that then waits on elements which never arrive.
281
+ // `initializePiesFromLoadedBundle` tolerates the same missing
282
+ // global because there the host's own loader owns registration.
283
+ // Here this function owns it, so there is no other party to
284
+ // wait for. Matches the IIFE `ElementLoader` adapter, which
285
+ // fails with `cause: "window.pie.default missing after bundle
286
+ // load"`.
287
+ fail(new Error(`PIE bundle loaded but window.pie is absent; is ${url} a proper PIE IIFE module?`));
288
+ return;
289
+ }
365
290
  logger.debug("[loadPieModule] window.pie available");
366
291
  const elementModule = window.pie.default;
367
- // Use shared registration logic (returns array of promises)
368
- const registrationPromises = registerPieElementsFromBundle(elementModule, config, session, registry, options);
369
- // Wait for all element definitions to complete
370
- Promise.all(registrationPromises).then(() => loadResolve());
371
- }
372
- else {
373
- logger.error("[loadPieModule] pie var not found; is %s a proper PIE IIFE module?", url);
374
- loadResolve();
292
+ try {
293
+ // Use shared registration logic (returns array of promises)
294
+ const registrationPromises = registerPieElementsFromBundle(elementModule, config, session, registry, options);
295
+ // Wait for all element definitions to complete
296
+ Promise.all(registrationPromises).then(succeed, fail);
297
+ }
298
+ catch (error) {
299
+ // `registerPieElementsFromBundle` throws synchronously for a
300
+ // package missing from the bundle and for a client-player
301
+ // bundle with no controller. Inside a DOM event handler that
302
+ // throw reaches the window instead of the caller.
303
+ fail(error instanceof Error ? error : new Error(String(error)));
304
+ }
305
+ });
306
+ script.addEventListener("error", () => {
307
+ if (settled)
308
+ return;
309
+ fail(new Error(`failed to load PIE bundle script: ${url}`));
310
+ });
311
+ if (loadTimeoutMs > 0) {
312
+ timer = setTimeout(() => {
313
+ if (settled)
314
+ return;
315
+ fail(new Error(`PIE bundle script load timed out after ${loadTimeoutMs}ms: ${url}`));
316
+ }, loadTimeoutMs);
375
317
  }
318
+ document.head.appendChild(script);
376
319
  });
377
- });
378
- document.head.appendChild(script);
379
- await loadPromise;
320
+ }
321
+ catch (error) {
322
+ // Drop the injected node so a retry starts from a clean head.
323
+ script.remove();
324
+ throw error;
325
+ }
326
+ finally {
327
+ if (timer)
328
+ clearTimeout(timer);
329
+ }
380
330
  return { session };
381
331
  };
382
332
  /**