4441 lines
168 KiB
TypeScript
4441 lines
168 KiB
TypeScript
|
|
// src/browser/MatrixDSLHost.ts
|
||
|
|
//
|
||
|
|
// FP-36: Declarative DSL Host — Zero-Ceremony Browser Bootstrap
|
||
|
|
//
|
||
|
|
// <matrix-dsl-host> creates its own NATS transport,
|
||
|
|
// MatrixRuntime, and context hierarchy from HTML attributes — so each
|
||
|
|
// browser app can compose Matrix actors and CQRS views with zero init.ts.
|
||
|
|
//
|
||
|
|
// Two modes:
|
||
|
|
// LOCAL: <matrix-dsl-host root="myapp">
|
||
|
|
// FEDERATED: <matrix-dsl-host root="myapp" backbone="ws://localhost:4223">
|
||
|
|
//
|
||
|
|
// Children receive contexts via explicit mounting (not AUTO-PULL,
|
||
|
|
// because this element is not a MatrixActorHtmlElement).
|
||
|
|
|
||
|
|
import { MatrixRuntime } from '@open-matrix/core';
|
||
|
|
import { BrowserDomStrategy } from '@open-matrix/core';
|
||
|
|
import { TopicRouter } from '@open-matrix/core';
|
||
|
|
import { RequestReply } from '@open-matrix/core';
|
||
|
|
import { createBrowserNatsTransport } from '@open-matrix/core/transport/createBrowserNatsTransport';
|
||
|
|
import {
|
||
|
|
ACTOR_CALL_CLIENT,
|
||
|
|
ACTOR_SERVICES,
|
||
|
|
ActorCallClient,
|
||
|
|
ActorServices,
|
||
|
|
RUNTIME_ADDRESS_CONTEXT,
|
||
|
|
SCOPED_INTENT_RESOLVER,
|
||
|
|
ScopedIntentResolver,
|
||
|
|
WELL_KNOWN_SERVICE_BINDINGS,
|
||
|
|
} from '@open-matrix/core';
|
||
|
|
import type { IMatrixContext } from '@open-matrix/core';
|
||
|
|
import type { IMatrixRuntime } from '@open-matrix/core';
|
||
|
|
import type { MatrixAuthorityContext } from '@open-matrix/core';
|
||
|
|
import type { RuntimeAddressContext } from '@open-matrix/core';
|
||
|
|
import type { ITransportAdapter } from '@open-matrix/core';
|
||
|
|
import type { MxEnvelope } from '@open-matrix/core';
|
||
|
|
import { SESSION_INFO_KEY, FEDERATION_PEER_KEY, type ISessionInfo } from '@open-matrix/core/services/SessionInfoService';
|
||
|
|
import {
|
||
|
|
HOSTED_RUNTIME_TARGET_CHANGED_EVENT,
|
||
|
|
hostedRuntimeTargetInfoEquals,
|
||
|
|
resolveHostedRuntimeTargetInfo,
|
||
|
|
type IHostedRuntimeTargetInfo,
|
||
|
|
} from './HostedRuntimeTarget.js';
|
||
|
|
import { resolveHostedRuntimeRecoveryPlan } from './HostedRuntimeRecovery.js';
|
||
|
|
import {
|
||
|
|
createBrowserProfileSpan,
|
||
|
|
logBrowserProfile,
|
||
|
|
profileBrowserSpan,
|
||
|
|
} from './BrowserProfileTrace.js';
|
||
|
|
import {
|
||
|
|
mountBrowserRuntimeControl,
|
||
|
|
type BrowserRuntimeLifecycleState,
|
||
|
|
} from './BrowserRuntimeControlActor.js';
|
||
|
|
import {
|
||
|
|
getOrCreateTabRealm,
|
||
|
|
getOrCreateIdentityTabRoot,
|
||
|
|
isValidRealm,
|
||
|
|
getBrowserRouter,
|
||
|
|
type FederationPeer,
|
||
|
|
type BrowserRouterResult,
|
||
|
|
} from '@open-matrix/federation';
|
||
|
|
import type { RootContext, FederationMode } from '@open-matrix/core/core/context/RealmContext';
|
||
|
|
import { createRootContext } from '@open-matrix/core/core/context/RealmContext';
|
||
|
|
import {
|
||
|
|
COMPONENT_RUNTIME_METADATA,
|
||
|
|
type ComponentRegistrationSource,
|
||
|
|
type ComponentSourceKind,
|
||
|
|
type IComponentRuntimeMetadata,
|
||
|
|
} from '@open-matrix/core';
|
||
|
|
import type { ComponentArtifactFreshness } from '@open-matrix/core/core/composite/IComponentRuntimeMetadata';
|
||
|
|
import type {
|
||
|
|
ServiceRegistryEntryKindV1,
|
||
|
|
ServiceRegistryEntryV1,
|
||
|
|
} from '@open-matrix/contracts/service-registry';
|
||
|
|
import type {
|
||
|
|
RuntimeInventoryEntryV1,
|
||
|
|
RuntimePresenceEventV1,
|
||
|
|
RuntimePresenceRecordV1,
|
||
|
|
} from '@open-matrix/contracts/runtime-presence';
|
||
|
|
|
||
|
|
interface IBootstrapTransportPlan {
|
||
|
|
readonly protocolVersion?: 1;
|
||
|
|
readonly kind?: 'nats';
|
||
|
|
readonly wsMode?: 'same-origin-proxy';
|
||
|
|
readonly wsPath?: string | null;
|
||
|
|
readonly authMode?: 'jwt' | 'static' | 'anonymous' | 'login-required';
|
||
|
|
readonly authEndpoint?: string | null;
|
||
|
|
readonly busAuthMode?: 'bus-token' | 'none';
|
||
|
|
readonly sessionMode?: 'session-cookie' | 'local-client' | 'anonymous' | 'device-login-required' | 'device-session';
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IBootstrapValueSources {
|
||
|
|
readonly root?: string;
|
||
|
|
readonly authorityRoot?: string;
|
||
|
|
readonly assetHostRoot?: string;
|
||
|
|
readonly platformServiceRoot?: string;
|
||
|
|
readonly sessionAuthorityRoot?: string;
|
||
|
|
readonly selectedAuthorityRoot?: string;
|
||
|
|
readonly runtimeInstanceRoot?: string;
|
||
|
|
readonly platformRoot?: string;
|
||
|
|
readonly addressRoot?: string;
|
||
|
|
readonly routeKey?: string;
|
||
|
|
readonly publicNamespace?: string;
|
||
|
|
readonly workspaceRealm?: string;
|
||
|
|
readonly browserWsPath?: string;
|
||
|
|
readonly transportAuthMode?: string;
|
||
|
|
readonly busToken?: string;
|
||
|
|
readonly sessionIdentity?: string;
|
||
|
|
readonly federation?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IBootstrapFailureDiagnostics {
|
||
|
|
readonly classification: string;
|
||
|
|
readonly hint: string;
|
||
|
|
readonly lines: string[];
|
||
|
|
}
|
||
|
|
|
||
|
|
interface INatsWsBootstrapPayload {
|
||
|
|
readonly natsWsUrl?: string;
|
||
|
|
readonly authenticated?: boolean;
|
||
|
|
readonly busToken?: string;
|
||
|
|
readonly authorityContext?: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readBootstrapRecord(value: unknown, label: string): Record<string, unknown> {
|
||
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
|
|
throw new Error(`MatrixDSLHost: /api/bootstrap missing ${label}`);
|
||
|
|
}
|
||
|
|
return value as Record<string, unknown>;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readBootstrapString(value: unknown, field: string): string {
|
||
|
|
if (typeof value !== 'string') {
|
||
|
|
throw new Error(`MatrixDSLHost: /api/bootstrap authorityContext.${field} must be a string`);
|
||
|
|
}
|
||
|
|
const trimmed = value.trim();
|
||
|
|
if (!trimmed) {
|
||
|
|
throw new Error(`MatrixDSLHost: /api/bootstrap authorityContext.${field} must not be empty`);
|
||
|
|
}
|
||
|
|
return trimmed;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readBootstrapNullableString(value: unknown, field: string): string | null {
|
||
|
|
if (value === null || value === undefined) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
if (typeof value !== 'string') {
|
||
|
|
throw new Error(`MatrixDSLHost: /api/bootstrap authorityContext.${field} must be a string or null`);
|
||
|
|
}
|
||
|
|
const trimmed = value.trim();
|
||
|
|
return trimmed.length > 0 ? trimmed : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readBootstrapBoolean(value: unknown, field: string): boolean {
|
||
|
|
if (typeof value !== 'boolean') {
|
||
|
|
throw new Error(`MatrixDSLHost: /api/bootstrap authorityContext.${field} must be a boolean`);
|
||
|
|
}
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readBootstrapStringArray(value: unknown, field: string): readonly string[] {
|
||
|
|
if (!Array.isArray(value)) {
|
||
|
|
throw new Error(`MatrixDSLHost: /api/bootstrap authorityContext.${field} must be a string array`);
|
||
|
|
}
|
||
|
|
return value.map((item) => {
|
||
|
|
if (typeof item !== 'string') {
|
||
|
|
throw new Error(`MatrixDSLHost: /api/bootstrap authorityContext.${field} must contain only strings`);
|
||
|
|
}
|
||
|
|
return item.trim();
|
||
|
|
}).filter((item) => item.length > 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseBootstrapAuthorityContext(value: unknown): MatrixAuthorityContext {
|
||
|
|
const record = readBootstrapRecord(value, 'authorityContext');
|
||
|
|
return {
|
||
|
|
assetHostRoot: readBootstrapString(record.assetHostRoot, 'assetHostRoot'),
|
||
|
|
platformServiceRoot: readBootstrapString(record.platformServiceRoot, 'platformServiceRoot'),
|
||
|
|
sessionAuthorityRoot: readBootstrapNullableString(record.sessionAuthorityRoot, 'sessionAuthorityRoot'),
|
||
|
|
selectedAuthorityRoot: readBootstrapNullableString(record.selectedAuthorityRoot, 'selectedAuthorityRoot'),
|
||
|
|
runtimeInstanceRoot: readBootstrapNullableString(record.runtimeInstanceRoot, 'runtimeInstanceRoot'),
|
||
|
|
isAuthenticated: readBootstrapBoolean(record.isAuthenticated, 'isAuthenticated'),
|
||
|
|
isPlatformAdmin: readBootstrapBoolean(record.isPlatformAdmin, 'isPlatformAdmin'),
|
||
|
|
selectableAuthorityRoots: readBootstrapStringArray(record.selectableAuthorityRoots, 'selectableAuthorityRoots'),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IRegistryQueryResponse {
|
||
|
|
readonly entries?: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IRouteKeyWebappRoute {
|
||
|
|
readonly appName: string;
|
||
|
|
readonly routeKey: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IBrowserRuntimeInventoryEntry {
|
||
|
|
mount: string;
|
||
|
|
type: string;
|
||
|
|
surface: string;
|
||
|
|
runtimeId: string;
|
||
|
|
claimKind: string;
|
||
|
|
description?: string;
|
||
|
|
hasChildren?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IBrowserRuntimeRegistryState {
|
||
|
|
readonly registryTarget: string;
|
||
|
|
readonly namespaceRoot: string;
|
||
|
|
readonly runtimeId: string;
|
||
|
|
readonly runtimeRoot: string;
|
||
|
|
readonly app: string;
|
||
|
|
readonly contextMount: string;
|
||
|
|
readonly hostBootstrapMode: boolean;
|
||
|
|
readonly sessionMount: string;
|
||
|
|
readonly relativeMount: string;
|
||
|
|
readonly runtimeMount: string;
|
||
|
|
readonly controlMount: string;
|
||
|
|
readonly authorityRoot: string;
|
||
|
|
readonly sessionAuthorityRoot: string;
|
||
|
|
readonly selectedAuthorityRoot: string;
|
||
|
|
readonly runtimeInstanceRoot: string;
|
||
|
|
readonly runtimeHref: string;
|
||
|
|
readonly registeredAt: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IRuntimeSummaryPayload {
|
||
|
|
readonly authority?: {
|
||
|
|
readonly addressRoot?: string | null;
|
||
|
|
} | null;
|
||
|
|
readonly attachedLeafRoots?: {
|
||
|
|
readonly items?: Array<{
|
||
|
|
readonly root?: string;
|
||
|
|
readonly connectionKind?: string | null;
|
||
|
|
}>;
|
||
|
|
} | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IDevicesListResponse {
|
||
|
|
readonly ok?: unknown;
|
||
|
|
readonly devices?: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IDeviceInventoryRecord {
|
||
|
|
readonly authorityRoot?: unknown;
|
||
|
|
readonly status?: unknown;
|
||
|
|
readonly updatedAt?: unknown;
|
||
|
|
readonly lastHeartbeatAt?: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
const HOSTED_SELECTED_LEAF_STORAGE_KEY = 'matrix-web.selected-leaf-root';
|
||
|
|
const HOSTED_RUNTIME_RECOVERY_NOTICE_ATTR = 'data-hosted-runtime-recovery';
|
||
|
|
const BROWSER_RUNTIME_ID_PREFIX = 'RUNTIME-BROWSER-';
|
||
|
|
const HOST_PROFILE_SCOPE = 'host';
|
||
|
|
|
||
|
|
type HostedRuntimeTargetSource = 'explicit' | 'auto' | 'none';
|
||
|
|
type HostedRuntimeTargetKind = 'leaf' | 'authority' | 'unknown';
|
||
|
|
|
||
|
|
interface IHostedRuntimeTarget {
|
||
|
|
readonly root: string | null;
|
||
|
|
readonly source: HostedRuntimeTargetSource;
|
||
|
|
readonly kind: HostedRuntimeTargetKind;
|
||
|
|
readonly requestedRoot: string | null;
|
||
|
|
readonly unavailableReason: 'missing' | 'not-live' | 'unauthorized' | 'none';
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IHostedRuntimeSummaryState {
|
||
|
|
readonly authorityRoot: string | null;
|
||
|
|
readonly liveLeafRoots: string[];
|
||
|
|
readonly knownLeafRoots: string[];
|
||
|
|
}
|
||
|
|
|
||
|
|
const HOSTED_RUNTIME_TARGET_REFRESH_INTERVAL_MS = 3_000;
|
||
|
|
|
||
|
|
interface IHostRoutingPeer {
|
||
|
|
readonly hasBackbone: boolean;
|
||
|
|
readonly localRoot: string;
|
||
|
|
readonly localRealm?: string;
|
||
|
|
invoke<T>(
|
||
|
|
target: string,
|
||
|
|
opOrRequest: string | { op: string; payload?: unknown },
|
||
|
|
payloadOrTimeout?: unknown,
|
||
|
|
timeoutMs?: number,
|
||
|
|
): Promise<T>;
|
||
|
|
route(topic: string, payload: unknown): Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IMatrixChildElementHooks {
|
||
|
|
_receiveContext?: (ctx: IMatrixContext) => Promise<void>;
|
||
|
|
mount?: (ctx: IMatrixContext) => Promise<void>;
|
||
|
|
setContext?: (ctx: IMatrixContext) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IMatrixRootContextReceiver {
|
||
|
|
_receiveContext?: (ctx: { rootContext: RootContext; realmContext: RootContext }) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IMatrixActorElementVersionSource {
|
||
|
|
_actor?: {
|
||
|
|
children?: {
|
||
|
|
version?: number;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Options for programmatic bootstrap via `host.bootstrap()`.
|
||
|
|
*/
|
||
|
|
export interface BootstrapOptions {
|
||
|
|
/** Wrap the transport after creation but before runtime/children. */
|
||
|
|
wrapTransport?: (transport: ITransportAdapter) => ITransportAdapter;
|
||
|
|
/** Override bus root (alternative to setting attribute). */
|
||
|
|
busRoot?: string;
|
||
|
|
/** Override backbone URL. */
|
||
|
|
backbone?: string;
|
||
|
|
/** Override mount path. */
|
||
|
|
mount?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* <matrix-dsl-host> - Self-contained declarative browser bootstrap.
|
||
|
|
*
|
||
|
|
* Creates MatrixRuntime, FederationPeer, and context hierarchy from
|
||
|
|
* HTML attributes. Children (MatrixActorHtmlElement instances) receive
|
||
|
|
* derived contexts and can communicate immediately.
|
||
|
|
*
|
||
|
|
* @example Local-only (in-memory transport, no NATS)
|
||
|
|
* ```html
|
||
|
|
* <matrix-dsl-host root="myapp" mount="dashboard">
|
||
|
|
* <my-counter id="c1"></my-counter>
|
||
|
|
* <my-chart id="chart" tracks="c1"></my-chart>
|
||
|
|
* </matrix-dsl-host>
|
||
|
|
* ```
|
||
|
|
*
|
||
|
|
* @example Federated (NATS backbone for cross-root)
|
||
|
|
* ```html
|
||
|
|
* <matrix-dsl-host root="flowpad" backbone="ws://localhost:4223">
|
||
|
|
* <ralph-supervisor-view tracks="matrix-3/ralph" autoview>
|
||
|
|
* </ralph-supervisor-view>
|
||
|
|
* </matrix-dsl-host>
|
||
|
|
* ```
|
||
|
|
*
|
||
|
|
* @example Existing init.ts auto-init control
|
||
|
|
* ```html
|
||
|
|
* <matrix-dsl-host auto-init="false">
|
||
|
|
* <my-app></my-app>
|
||
|
|
* </matrix-dsl-host>
|
||
|
|
* <script>
|
||
|
|
* const host = document.querySelector('matrix-dsl-host');
|
||
|
|
* host.initialize(existingRuntime, existingContext);
|
||
|
|
* </script>
|
||
|
|
* ```
|
||
|
|
*/
|
||
|
|
export class MatrixDSLHost extends HTMLElement {
|
||
|
|
/** Matrix context for this host's children */
|
||
|
|
private _context: IMatrixContext | null = null;
|
||
|
|
|
||
|
|
/** Runtime instance (created internally or injected) */
|
||
|
|
private _runtime: IMatrixRuntime | null = null;
|
||
|
|
|
||
|
|
/** FederationPeer (only in federated mode) */
|
||
|
|
private _peer: FederationPeer | null = null;
|
||
|
|
|
||
|
|
/** Wall-clock time the splash was first shown; used to enforce a minimum
|
||
|
|
* visible duration before fade-out so the brand frame is perceivable
|
||
|
|
* even when bootstrap is sub-100ms (warm cache). */
|
||
|
|
private _splashShownAt: number | null = null;
|
||
|
|
private _splashHideScheduled = false;
|
||
|
|
|
||
|
|
/** Router-shaped service exposed to app code. */
|
||
|
|
private _routingPeer: IHostRoutingPeer | null = null;
|
||
|
|
|
||
|
|
/** Transport adapter used by the runtime */
|
||
|
|
private _transport: ITransportAdapter | null = null;
|
||
|
|
|
||
|
|
/** Initialization flag */
|
||
|
|
private _initialized = false;
|
||
|
|
|
||
|
|
/** Mutation observer for light DOM changes */
|
||
|
|
private _observer: MutationObserver | null = null;
|
||
|
|
|
||
|
|
/** Root context for federation metadata */
|
||
|
|
private _rootContext: RootContext | null = null;
|
||
|
|
|
||
|
|
/** $join subscription cleanup */
|
||
|
|
private _joinUnsub: (() => void) | null = null;
|
||
|
|
|
||
|
|
/** Transport wrapper callback (set via bootstrap() or public property) */
|
||
|
|
private _wrapTransport: ((t: ITransportAdapter) => ITransportAdapter) | null = null;
|
||
|
|
|
||
|
|
/** Bus token refresh timer (INV-ID-7: 4-min interval, token has 5-min TTL) */
|
||
|
|
private _busTokenRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
|
|
||
|
|
/** Background runtime registry discovery retry timer after initial discovery timeouts. */
|
||
|
|
private _daemonDiscoveryRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
|
|
||
|
|
/** Exponential backoff for background runtime registry rediscovery. */
|
||
|
|
private _daemonDiscoveryRetryDelayMs = 1_000;
|
||
|
|
|
||
|
|
/** Prevent overlapping runtime registry discovery loops. */
|
||
|
|
private _daemonDiscoveryInFlight = false;
|
||
|
|
|
||
|
|
/** Background refresh loop for hosted runtime liveness metadata. */
|
||
|
|
private _hostedRuntimeTargetRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
|
|
||
|
|
/** Prevent overlapping hosted runtime summary polls. */
|
||
|
|
private _hostedRuntimeTargetRefreshInFlight = false;
|
||
|
|
|
||
|
|
/** Service Registry lease renewal timer for this browser runtime. */
|
||
|
|
private _browserRuntimeRenewTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
|
|
||
|
|
/** Bound beforeunload handler for releasing this browser runtime claim. */
|
||
|
|
private _browserRuntimeReleaseHandler: (() => void) | null = null;
|
||
|
|
|
||
|
|
/** Current Service Registry claim state for this browser runtime. */
|
||
|
|
private _browserRuntimeRegistryState: IBrowserRuntimeRegistryState | null = null;
|
||
|
|
|
||
|
|
/** Component IDs currently claimed for this browser runtime in system.registry. */
|
||
|
|
private _browserRuntimeClaimedComponentIds = new Set<string>();
|
||
|
|
|
||
|
|
/** Current browser runtime id, used to refresh inventory after dynamic child changes. */
|
||
|
|
private _runtimeRegistryId = '';
|
||
|
|
|
||
|
|
/** Browser runtime control actor start time for stable runtime status. */
|
||
|
|
private _runtimeControlStartedAt = '';
|
||
|
|
|
||
|
|
/** Debounce timer for child-driven registry claim refreshes. */
|
||
|
|
private _runtimeInventoryClaimRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
|
|
||
|
|
/** Increments whenever cleanup invalidates async registration continuations. */
|
||
|
|
private _lifecycleGeneration = 0;
|
||
|
|
|
||
|
|
/** Last reported host child version for runtime inventory refreshes. */
|
||
|
|
private _runtimeInventoryVersion = -1;
|
||
|
|
|
||
|
|
/** URL param keys to read (when url-params attribute is present) */
|
||
|
|
private static readonly URL_PARAM_MAP: Record<string, string> = {
|
||
|
|
root: 'root',
|
||
|
|
realm: 'root', // ?realm= maps to root attr
|
||
|
|
backbone: 'backbone',
|
||
|
|
backboneUrl: 'backbone',
|
||
|
|
mount: 'mount',
|
||
|
|
daemonRoot: 'daemon-root',
|
||
|
|
daemonRealm: 'daemon-root',
|
||
|
|
addressRoot: 'data-address-root',
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Raw adapter before wrapping (for getWireTopicInfo etc.) */
|
||
|
|
private _rawAdapter: ITransportAdapter | null = null;
|
||
|
|
|
||
|
|
/** Identity state from bootstrap */
|
||
|
|
private _principal: { principalId: string; displayName?: string; email?: string; tier?: string } | null = null;
|
||
|
|
private _activeOrg: { orgId: string; displayName: string; role: string } | null = null;
|
||
|
|
private _isAuthenticated = false;
|
||
|
|
|
||
|
|
/** Promise that resolves when initialization is complete */
|
||
|
|
readonly ready: Promise<void>;
|
||
|
|
private _resolveReady!: () => void;
|
||
|
|
|
||
|
|
private _bootstrapTransportPlan: IBootstrapTransportPlan | null = null;
|
||
|
|
|
||
|
|
static get observedAttributes(): string[] {
|
||
|
|
return ['mount', 'auto-init', 'root', 'realm', 'federation', 'backbone', 'identity-provider', 'client-id', 'auto-attach'];
|
||
|
|
}
|
||
|
|
|
||
|
|
constructor() {
|
||
|
|
super();
|
||
|
|
this.ready = new Promise(resolve => {
|
||
|
|
this._resolveReady = resolve;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private _deriveNatsWsTransportPlan(bootstrap: INatsWsBootstrapPayload): IBootstrapTransportPlan | null {
|
||
|
|
const rawWsUrl = typeof bootstrap.natsWsUrl === 'string' ? bootstrap.natsWsUrl.trim() : '';
|
||
|
|
if (!rawWsUrl) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
let wsPath = '/nats-ws';
|
||
|
|
try {
|
||
|
|
const parsed = new URL(rawWsUrl, window.location.href);
|
||
|
|
if (parsed.pathname && parsed.pathname !== '/') {
|
||
|
|
wsPath = parsed.pathname;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
const match = rawWsUrl.match(/^wss?:\/\/[^/]+(\/.*)$/i);
|
||
|
|
if (match?.[1]) {
|
||
|
|
wsPath = match[1];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
kind: 'nats',
|
||
|
|
wsMode: 'same-origin-proxy',
|
||
|
|
wsPath,
|
||
|
|
busAuthMode: bootstrap.busToken ? 'bus-token' : 'none',
|
||
|
|
sessionMode: bootstrap.authenticated ? 'session-cookie' : 'anonymous',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
// DOM Lifecycle
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
connectedCallback(): void {
|
||
|
|
const autoInit = this.getAttribute('auto-init');
|
||
|
|
if (autoInit !== 'false') {
|
||
|
|
this._bootstrapAsync();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
disconnectedCallback(): void {
|
||
|
|
this._cleanup();
|
||
|
|
}
|
||
|
|
|
||
|
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
|
||
|
|
if (oldValue === newValue) return;
|
||
|
|
if (name === 'mount' && this._initialized) {
|
||
|
|
this._cleanup();
|
||
|
|
this._bootstrapAsync();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
// Public API
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
/** The FederationPeer (null in local-only mode). */
|
||
|
|
get peer(): FederationPeer | null {
|
||
|
|
return this._peer;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The MatrixRuntime instance. */
|
||
|
|
get runtime(): IMatrixRuntime | null {
|
||
|
|
return this._runtime;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The host's Matrix context (children derive from this). */
|
||
|
|
get context(): IMatrixContext | null {
|
||
|
|
return this._context;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The root context for federation metadata. */
|
||
|
|
get rootContext(): RootContext | null {
|
||
|
|
return this._rootContext;
|
||
|
|
}
|
||
|
|
|
||
|
|
get realmContext(): RootContext | null {
|
||
|
|
return this._rootContext;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The transport adapter (possibly wrapped). */
|
||
|
|
get transport(): ITransportAdapter | null {
|
||
|
|
return this._transport;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The raw (unwrapped) transport adapter (for getWireTopicInfo etc.). */
|
||
|
|
get rawAdapter(): ITransportAdapter | null {
|
||
|
|
return this._rawAdapter;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether backbone is connected (false in local-only or before bootstrap). */
|
||
|
|
get hasBackbone(): boolean {
|
||
|
|
return this._routingPeer?.hasBackbone ?? false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Authenticated principal from bootstrap (null if anonymous). */
|
||
|
|
get principal(): { principalId: string; displayName?: string; email?: string; tier?: string } | null {
|
||
|
|
return this._principal;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Active org context (null if not in an org). */
|
||
|
|
get activeOrg(): { orgId: string; displayName: string; role: string } | null {
|
||
|
|
return this._activeOrg;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Subscription tier (from principal or 'anonymous'). */
|
||
|
|
get tier(): string {
|
||
|
|
return this._principal?.tier ?? 'anonymous';
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether the session is authenticated. */
|
||
|
|
get isAuthenticated(): boolean {
|
||
|
|
return this._isAuthenticated;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Set a transport wrapper BEFORE bootstrap.
|
||
|
|
* The wrapper is applied after transport creation but before runtime/children.
|
||
|
|
* This is how apps intercept all pub/sub (e.g., for logging panels).
|
||
|
|
*/
|
||
|
|
set wrapTransport(fn: ((t: ITransportAdapter) => ITransportAdapter) | null) {
|
||
|
|
this._wrapTransport = fn;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Programmatic bootstrap with options.
|
||
|
|
* Use when auto-init="false" and you need to configure before bootstrap.
|
||
|
|
*/
|
||
|
|
async bootstrap(options?: BootstrapOptions): Promise<void> {
|
||
|
|
if (this._initialized) {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.warn('[MatrixDSLHost] Already initialized');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply option overrides to attributes.
|
||
|
|
if (options?.busRoot) this.setAttribute('bus-root', options.busRoot);
|
||
|
|
if (options?.backbone) this.setAttribute('backbone', options.backbone);
|
||
|
|
if (options?.mount !== undefined) this.setAttribute('mount', options.mount);
|
||
|
|
|
||
|
|
// Store transport wrapper (option overrides property setter)
|
||
|
|
if (options?.wrapTransport) {
|
||
|
|
this._wrapTransport = options.wrapTransport;
|
||
|
|
}
|
||
|
|
|
||
|
|
await this._bootstrapAsync();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Initialize with an externally-created runtime.
|
||
|
|
* Use this when auto-init="false" and you have an existing runtime.
|
||
|
|
*/
|
||
|
|
async initialize(runtime: IMatrixRuntime, context?: IMatrixContext): Promise<void> {
|
||
|
|
if (this._initialized) {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.warn('[MatrixDSLHost] Already initialized');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this._runtime = runtime;
|
||
|
|
|
||
|
|
const mountPath = this.getAttribute('mount') ?? this.id ?? '';
|
||
|
|
const rootContext = runtime.getRootContext?.();
|
||
|
|
if (!rootContext && !context) {
|
||
|
|
throw new Error('[MatrixDSLHost] Runtime has no getRootContext() and no context provided');
|
||
|
|
}
|
||
|
|
|
||
|
|
this._context = context ?? (mountPath
|
||
|
|
? rootContext!.derive(mountPath)
|
||
|
|
: rootContext!);
|
||
|
|
|
||
|
|
// Install $join responder and mount children
|
||
|
|
this._installJoinResponder();
|
||
|
|
await this._mountChildren();
|
||
|
|
this._setupMutationObserver();
|
||
|
|
this._parseRootAttributes();
|
||
|
|
this._propagateRootToChildren();
|
||
|
|
|
||
|
|
this._initialized = true;
|
||
|
|
this._resolveReady();
|
||
|
|
this._fireReadyEvent();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
// Self-Bootstrap (FP-36 Phase 1)
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Async self-bootstrap: creates runtime + transport from attributes.
|
||
|
|
*/
|
||
|
|
private async _bootstrapAsync(): Promise<void> {
|
||
|
|
const explicitBusRootAttr = this.getAttribute('bus-root');
|
||
|
|
let hostBootstrapMode = this._isHostBootstrapMode();
|
||
|
|
const routeKeyRoute = this._parseRouteKeyWebappRouteFromLocation();
|
||
|
|
const requireDaemonBootstrap = this._shouldRequireDaemonBootstrap()
|
||
|
|
|| routeKeyRoute !== null;
|
||
|
|
delete this.dataset.explicitAuthorityRoot;
|
||
|
|
const hadExplicitDaemonTarget = this.hasAttribute('daemon-root')
|
||
|
|
|| this.hasAttribute('daemon-realm')
|
||
|
|
|| routeKeyRoute !== null;
|
||
|
|
const initialUrlParams = this.hasAttribute('url-params')
|
||
|
|
? new URLSearchParams(window.location.search)
|
||
|
|
: null;
|
||
|
|
const hasExplicitDaemonTargetParam = initialUrlParams?.has('daemonRoot') || initialUrlParams?.has('daemonRealm') || false;
|
||
|
|
const hostedAppName = this._deriveHostedAppName();
|
||
|
|
const finishBootstrapProfile = createBrowserProfileSpan(HOST_PROFILE_SCOPE, 'bootstrapAsync', {
|
||
|
|
hostedApp: hostedAppName ?? '',
|
||
|
|
requireDaemonBootstrap,
|
||
|
|
hasExplicitDaemonTarget: hadExplicitDaemonTarget || hasExplicitDaemonTargetParam,
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
this.dataset.status = 'initializing';
|
||
|
|
this._manageLoadingElement('initializing');
|
||
|
|
let discoveredNats = false;
|
||
|
|
let bootstrapAuthorityRoot = '';
|
||
|
|
let bootstrapPublicNamespace = '';
|
||
|
|
let bootstrapRouteKey = '';
|
||
|
|
let renderNamespaceSetupLocally = false;
|
||
|
|
let loginRequiredBootstrap = false;
|
||
|
|
|
||
|
|
// Phase A: Transport coordinate discovery.
|
||
|
|
// Daemon-served apps must declare their path through /api/bootstrap.
|
||
|
|
let usedBootstrap = false;
|
||
|
|
try {
|
||
|
|
const bootstrapEndpoint = this._bootstrapEndpointForRoute(routeKeyRoute, hostedAppName);
|
||
|
|
const bootstrapResp = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'api.bootstrap',
|
||
|
|
async () => fetch(bootstrapEndpoint, { credentials: 'same-origin' }),
|
||
|
|
{
|
||
|
|
hostedApp: hostedAppName ?? '',
|
||
|
|
required: requireDaemonBootstrap,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
if (!bootstrapResp.ok) {
|
||
|
|
if (requireDaemonBootstrap) {
|
||
|
|
throw new Error(`Hosted app requires /api/bootstrap (HTTP ${bootstrapResp.status})`);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
const bootstrap = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'api.bootstrap.parse',
|
||
|
|
async () => bootstrapResp.json() as Promise<{
|
||
|
|
natsWsUrl?: string; root?: string; natsToken?: string; busToken?: string;
|
||
|
|
authenticated?: boolean; principal?: { principalId: string; displayName?: string; email?: string; tier?: string };
|
||
|
|
activeOrg?: { orgId: string; displayName: string; role: string };
|
||
|
|
addressRoot?: string;
|
||
|
|
authorityRoot?: string;
|
||
|
|
assetHostRoot?: string;
|
||
|
|
platformServiceRoot?: string;
|
||
|
|
sessionAuthorityRoot?: string;
|
||
|
|
selectedAuthorityRoot?: string;
|
||
|
|
runtimeInstanceRoot?: string;
|
||
|
|
authorityContext?: unknown;
|
||
|
|
selectableAuthorityRoots?: string[];
|
||
|
|
isPlatformAdmin?: boolean;
|
||
|
|
routeKey?: string;
|
||
|
|
publicNamespace?: string;
|
||
|
|
spaceId?: string;
|
||
|
|
platformRoot?: string;
|
||
|
|
busRoot?: string;
|
||
|
|
appName?: string;
|
||
|
|
role?: string;
|
||
|
|
workspaceRealm?: string;
|
||
|
|
routeKind?: string;
|
||
|
|
login?: { provider?: string; startUrl?: string };
|
||
|
|
sources?: IBootstrapValueSources;
|
||
|
|
transportPlan?: IBootstrapTransportPlan;
|
||
|
|
topologyKind?: string;
|
||
|
|
nodeRoles?: unknown;
|
||
|
|
federation?: {
|
||
|
|
attached?: unknown;
|
||
|
|
hubUrl?: unknown;
|
||
|
|
hubKind?: unknown;
|
||
|
|
};
|
||
|
|
shellAccessMode?: string;
|
||
|
|
appLinkMode?: string;
|
||
|
|
}>,
|
||
|
|
{
|
||
|
|
hostedApp: hostedAppName ?? '',
|
||
|
|
},
|
||
|
|
);
|
||
|
|
const authorityContext = parseBootstrapAuthorityContext(bootstrap.authorityContext);
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info(`[MatrixDSLHost] /api/bootstrap → selectedAuthorityRoot="${authorityContext.selectedAuthorityRoot ?? ''}", runtimeInstanceRoot="${authorityContext.runtimeInstanceRoot ?? ''}", wsUrl="${bootstrap.natsWsUrl}", authenticated=${authorityContext.isAuthenticated}`);
|
||
|
|
|
||
|
|
// Store identity state from bootstrap
|
||
|
|
this._isAuthenticated = authorityContext.isAuthenticated;
|
||
|
|
this.dataset.authenticated = String(this._isAuthenticated);
|
||
|
|
if (bootstrap.principal) {
|
||
|
|
this._principal = bootstrap.principal;
|
||
|
|
this.dataset.principalId = bootstrap.principal.principalId;
|
||
|
|
if (bootstrap.principal.displayName) {
|
||
|
|
this.dataset.displayName = bootstrap.principal.displayName;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (bootstrap.activeOrg) {
|
||
|
|
this._activeOrg = bootstrap.activeOrg;
|
||
|
|
}
|
||
|
|
const platformServiceRoot = authorityContext.platformServiceRoot;
|
||
|
|
const assetHostRoot = authorityContext.assetHostRoot;
|
||
|
|
const sessionAuthorityRoot = authorityContext.sessionAuthorityRoot ?? '';
|
||
|
|
const selectedAuthorityRoot = authorityContext.selectedAuthorityRoot ?? '';
|
||
|
|
const runtimeInstanceRoot = authorityContext.runtimeInstanceRoot ?? '';
|
||
|
|
this._setOptionalDatasetValue('assetHostRoot', assetHostRoot);
|
||
|
|
this._setOptionalDatasetValue('platformServiceRoot', platformServiceRoot);
|
||
|
|
this._setOptionalDatasetValue('sessionAuthorityRoot', sessionAuthorityRoot);
|
||
|
|
this._setOptionalDatasetValue('selectedAuthorityRoot', selectedAuthorityRoot);
|
||
|
|
this._setOptionalDatasetValue('runtimeInstanceRoot', runtimeInstanceRoot);
|
||
|
|
delete this.dataset.addressRoot;
|
||
|
|
const selectableAuthorityRoots = authorityContext.selectableAuthorityRoots;
|
||
|
|
this._setOptionalDatasetValue('selectableAuthorityRoots', selectableAuthorityRoots.join(','));
|
||
|
|
this.dataset.isPlatformAdmin = authorityContext.isPlatformAdmin ? 'true' : 'false';
|
||
|
|
if (authorityContext.isAuthenticated && sessionAuthorityRoot) {
|
||
|
|
this.dataset.principalAuthorityRoot = sessionAuthorityRoot;
|
||
|
|
} else {
|
||
|
|
delete this.dataset.principalAuthorityRoot;
|
||
|
|
}
|
||
|
|
bootstrapAuthorityRoot = selectedAuthorityRoot;
|
||
|
|
bootstrapPublicNamespace = (bootstrap.publicNamespace || '').trim();
|
||
|
|
bootstrapRouteKey = (bootstrap.routeKey || routeKeyFromPublicNamespace(bootstrapPublicNamespace) || '').trim();
|
||
|
|
const bootstrapSpaceId = (bootstrap.spaceId || '').trim();
|
||
|
|
this._setOptionalDatasetValue('routeKey', bootstrapRouteKey);
|
||
|
|
this._setOptionalDatasetValue('publicNamespace', bootstrapPublicNamespace);
|
||
|
|
this._setOptionalDatasetValue('spaceId', bootstrapSpaceId);
|
||
|
|
const bootstrapAppName = typeof bootstrap.appName === 'string'
|
||
|
|
? bootstrap.appName.trim()
|
||
|
|
: '';
|
||
|
|
if (bootstrapAppName) {
|
||
|
|
this.setAttribute('app-name', bootstrapAppName);
|
||
|
|
}
|
||
|
|
const bootstrapAttributeAuthorityRoot = sessionAuthorityRoot || selectedAuthorityRoot;
|
||
|
|
if (bootstrapAttributeAuthorityRoot) {
|
||
|
|
this.setAttribute('authority-root', bootstrapAttributeAuthorityRoot);
|
||
|
|
}
|
||
|
|
const bootstrapPlatformRoot = platformServiceRoot;
|
||
|
|
renderNamespaceSetupLocally = hostedAppName === 'web'
|
||
|
|
&& authorityContext.isAuthenticated
|
||
|
|
&& !bootstrapPublicNamespace
|
||
|
|
&& !bootstrapRouteKey
|
||
|
|
&& bootstrapAuthorityRoot === bootstrapPlatformRoot;
|
||
|
|
if (renderNamespaceSetupLocally) {
|
||
|
|
this.dataset.namespaceSetupMode = 'local';
|
||
|
|
} else {
|
||
|
|
delete this.dataset.namespaceSetupMode;
|
||
|
|
}
|
||
|
|
if (bootstrap.workspaceRealm) {
|
||
|
|
this.dataset.workspaceRealm = bootstrap.workspaceRealm;
|
||
|
|
}
|
||
|
|
this._setOptionalDatasetValue('routeKind', typeof bootstrap.routeKind === 'string' ? bootstrap.routeKind : '');
|
||
|
|
this._setOptionalDatasetValue('topologyKind', typeof bootstrap.topologyKind === 'string' ? bootstrap.topologyKind : '');
|
||
|
|
const authorityCoordinatorActive = bootstrapNodeRoleActive(bootstrap.nodeRoles, 'authorityCoordinator');
|
||
|
|
if (typeof authorityCoordinatorActive === 'boolean') {
|
||
|
|
this.dataset.authorityCoordinatorActive = String(authorityCoordinatorActive);
|
||
|
|
} else {
|
||
|
|
delete this.dataset.authorityCoordinatorActive;
|
||
|
|
}
|
||
|
|
this._setOptionalDatasetValue('shellAccessMode', typeof bootstrap.shellAccessMode === 'string' ? bootstrap.shellAccessMode : '');
|
||
|
|
this._setOptionalDatasetValue('appLinkMode', typeof bootstrap.appLinkMode === 'string' ? bootstrap.appLinkMode : '');
|
||
|
|
if (bootstrap.federation && typeof bootstrap.federation === 'object') {
|
||
|
|
this.dataset.federationAttached = bootstrap.federation.attached === true ? 'true' : 'false';
|
||
|
|
this._setOptionalDatasetValue(
|
||
|
|
'federationHubUrl',
|
||
|
|
typeof bootstrap.federation.hubUrl === 'string' ? bootstrap.federation.hubUrl : '',
|
||
|
|
);
|
||
|
|
this._setOptionalDatasetValue(
|
||
|
|
'federationHubKind',
|
||
|
|
typeof bootstrap.federation.hubKind === 'string' ? bootstrap.federation.hubKind : '',
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
delete this.dataset.federationAttached;
|
||
|
|
delete this.dataset.federationHubUrl;
|
||
|
|
delete this.dataset.federationHubKind;
|
||
|
|
}
|
||
|
|
this._setOptionalDatasetValue('loginProvider', bootstrap.login?.provider ?? '');
|
||
|
|
this._setOptionalDatasetValue('loginStartUrl', bootstrap.login?.startUrl ?? '');
|
||
|
|
const transportPlan = bootstrap.transportPlan ?? this._deriveNatsWsTransportPlan(bootstrap);
|
||
|
|
this._bootstrapTransportPlan = transportPlan ?? null;
|
||
|
|
loginRequiredBootstrap = transportPlan?.authMode === 'login-required';
|
||
|
|
if (loginRequiredBootstrap) {
|
||
|
|
this.dataset.status = 'login-required';
|
||
|
|
this.dataset.daemonStatus = 'skipped';
|
||
|
|
}
|
||
|
|
usedBootstrap = true;
|
||
|
|
const bootstrapRole = typeof bootstrap.role === 'string' ? bootstrap.role.trim().toLowerCase() : '';
|
||
|
|
const isLocalClientSession = transportPlan?.sessionMode === 'local-client'
|
||
|
|
|| bootstrap.sources?.sessionIdentity === 'local-client';
|
||
|
|
if (isLocalClientSession && !hostBootstrapMode) {
|
||
|
|
hostBootstrapMode = true;
|
||
|
|
this.setAttribute('bootstrap-mode', 'host');
|
||
|
|
}
|
||
|
|
if (hostBootstrapMode && bootstrapRole === 'platform' && hostedAppName && hostedAppName !== 'web') {
|
||
|
|
hostBootstrapMode = false;
|
||
|
|
this.setAttribute('bootstrap-mode', 'hosted');
|
||
|
|
}
|
||
|
|
// NATS subject scope = busRoot (Host's transport.root).
|
||
|
|
// Ambiguous bootstrap fields are intentionally not read here.
|
||
|
|
const busRoot = (bootstrap.busRoot || platformServiceRoot).trim();
|
||
|
|
if (busRoot) {
|
||
|
|
this.setAttribute('bus-root', busRoot);
|
||
|
|
if (renderNamespaceSetupLocally) {
|
||
|
|
this.removeAttribute('daemon-root');
|
||
|
|
this.removeAttribute('daemon-realm');
|
||
|
|
} else {
|
||
|
|
this.setAttribute('daemon-realm', busRoot);
|
||
|
|
}
|
||
|
|
if (hostBootstrapMode) {
|
||
|
|
this._ensureHostRuntimeMount(busRoot, hostedAppName);
|
||
|
|
}
|
||
|
|
if (!renderNamespaceSetupLocally && !hadExplicitDaemonTarget && !hasExplicitDaemonTargetParam) {
|
||
|
|
this.setAttribute('daemon-root', busRoot);
|
||
|
|
}
|
||
|
|
this.dataset.platformRoot = platformServiceRoot || busRoot;
|
||
|
|
}
|
||
|
|
discoveredNats = true;
|
||
|
|
this.setAttribute('transport', 'nats');
|
||
|
|
if (transportPlan) {
|
||
|
|
const wsPath = transportPlan.wsPath ?? '/nats-ws';
|
||
|
|
const localOnlyAnonymousSession = transportPlan.authMode === 'anonymous'
|
||
|
|
&& transportPlan.sessionMode === 'anonymous';
|
||
|
|
if (transportPlan.authMode === 'login-required' || localOnlyAnonymousSession) {
|
||
|
|
this.removeAttribute('nats-ws-url');
|
||
|
|
this.removeAttribute('backbone');
|
||
|
|
delete this.dataset.natsWsUrl;
|
||
|
|
delete this.dataset.natsAuthEndpoint;
|
||
|
|
delete this.dataset.busToken;
|
||
|
|
} else {
|
||
|
|
const sameOriginWsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${wsPath}`;
|
||
|
|
this.setAttribute('nats-ws-url', sameOriginWsUrl);
|
||
|
|
this.setAttribute('backbone', sameOriginWsUrl);
|
||
|
|
}
|
||
|
|
if (transportPlan.authMode) {
|
||
|
|
this.dataset.natsAuthMode = transportPlan.authMode;
|
||
|
|
}
|
||
|
|
if (transportPlan.authEndpoint && transportPlan.authMode !== 'login-required') {
|
||
|
|
this.dataset.natsAuthEndpoint = transportPlan.authEndpoint;
|
||
|
|
} else {
|
||
|
|
delete this.dataset.natsAuthEndpoint;
|
||
|
|
}
|
||
|
|
if (transportPlan.busAuthMode) {
|
||
|
|
this.dataset.busAuthMode = transportPlan.busAuthMode;
|
||
|
|
}
|
||
|
|
if (transportPlan.sessionMode) {
|
||
|
|
this.dataset.sessionMode = transportPlan.sessionMode;
|
||
|
|
}
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info(
|
||
|
|
`[MatrixDSLHost] transport plan → wsMode="${transportPlan.wsMode ?? 'same-origin-proxy'}", wsPath="${wsPath ?? 'none'}", authMode="${transportPlan.authMode ?? 'unknown'}", busAuthMode="${transportPlan.busAuthMode ?? 'unknown'}", sessionMode="${transportPlan.sessionMode ?? 'unknown'}"`,
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
throw new Error('Hosted bootstrap did not declare transportPlan');
|
||
|
|
}
|
||
|
|
if (bootstrap.sources) {
|
||
|
|
this.dataset.rootSource = bootstrap.sources.root ?? '';
|
||
|
|
this.dataset.addressRootSource = bootstrap.sources.addressRoot ?? '';
|
||
|
|
this.dataset.selectedAuthorityRootSource = bootstrap.sources.selectedAuthorityRoot ?? '';
|
||
|
|
this.dataset.runtimeInstanceRootSource = bootstrap.sources.runtimeInstanceRoot ?? '';
|
||
|
|
this.dataset.transportAuthSource = bootstrap.sources.transportAuthMode ?? '';
|
||
|
|
this.dataset.busTokenSource = bootstrap.sources.busToken ?? '';
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info(
|
||
|
|
`[MatrixDSLHost] bootstrap sources → root="${bootstrap.sources.root ?? 'unknown'}", addressRoot="${bootstrap.sources.addressRoot ?? 'unknown'}", wsPath="${bootstrap.sources.browserWsPath ?? 'unknown'}", auth="${bootstrap.sources.transportAuthMode ?? 'unknown'}", busToken="${bootstrap.sources.busToken ?? 'unknown'}", identity="${bootstrap.sources.sessionIdentity ?? 'unknown'}"`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (bootstrap.natsToken) {
|
||
|
|
this.dataset.natsToken = bootstrap.natsToken;
|
||
|
|
}
|
||
|
|
// Store bus token in memory only (INV-ID-7: never localStorage/cookies)
|
||
|
|
if (bootstrap.busToken && !renderNamespaceSetupLocally) {
|
||
|
|
this.dataset.busToken = bootstrap.busToken;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
if (requireDaemonBootstrap) {
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 0b. URL param overrides (when url-params attribute is present)
|
||
|
|
// URL params take highest priority, overriding even Host bootstrap config.
|
||
|
|
if (this.hasAttribute('url-params')) {
|
||
|
|
const urlParams = new URLSearchParams(window.location.search);
|
||
|
|
for (const [paramKey, attrName] of Object.entries(MatrixDSLHost.URL_PARAM_MAP)) {
|
||
|
|
const value = urlParams.get(paramKey);
|
||
|
|
if (value) this.setAttribute(attrName, value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
usedBootstrap
|
||
|
|
&& hostedAppName
|
||
|
|
&& hostedAppName !== 'web'
|
||
|
|
&& routeKeyRoute !== null
|
||
|
|
&& !this._isAuthenticated
|
||
|
|
&& !loginRequiredBootstrap
|
||
|
|
) {
|
||
|
|
this._redirectHostedAppToLogin();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
usedBootstrap
|
||
|
|
&& hostedAppName
|
||
|
|
&& hostedAppName !== 'web'
|
||
|
|
&& !hostBootstrapMode
|
||
|
|
&& hasExplicitDaemonTargetParam
|
||
|
|
) {
|
||
|
|
await this._applyExplicitHostedRuntimeTarget('bootstrap-explicit');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (routeKeyRoute && !hasExplicitDaemonTargetParam) {
|
||
|
|
const routeKeyMatchesBootstrap = !bootstrapRouteKey
|
||
|
|
|| bootstrapRouteKey === routeKeyRoute.routeKey;
|
||
|
|
const authorityRoot = routeKeyMatchesBootstrap
|
||
|
|
? bootstrapAuthorityRoot || (this.dataset.selectedAuthorityRoot ?? '').trim()
|
||
|
|
: '';
|
||
|
|
if (authorityRoot) {
|
||
|
|
this.setAttribute('daemon-root', authorityRoot);
|
||
|
|
this.setAttribute('daemon-realm', authorityRoot);
|
||
|
|
this.dataset.selectedAuthorityRoot = authorityRoot;
|
||
|
|
this.dataset.explicitAuthorityRoot = authorityRoot;
|
||
|
|
this._applyHostedRuntimeTargetMetadata(
|
||
|
|
authorityRoot,
|
||
|
|
'explicit',
|
||
|
|
'authority',
|
||
|
|
authorityRoot,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
usedBootstrap
|
||
|
|
&& hostedAppName
|
||
|
|
&& hostedAppName !== 'web'
|
||
|
|
&& !hostBootstrapMode
|
||
|
|
&& routeKeyRoute === null
|
||
|
|
) {
|
||
|
|
if (!this._isAuthenticated && !loginRequiredBootstrap) {
|
||
|
|
this._redirectHostedAppToLogin();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (hadExplicitDaemonTarget && !hasExplicitDaemonTargetParam) {
|
||
|
|
await this._applyExplicitHostedRuntimeTarget('bootstrap-explicit');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
usedBootstrap
|
||
|
|
&& hostedAppName
|
||
|
|
&& hostedAppName !== 'web'
|
||
|
|
&& !hostBootstrapMode
|
||
|
|
&& !hadExplicitDaemonTarget
|
||
|
|
&& !hasExplicitDaemonTargetParam
|
||
|
|
) {
|
||
|
|
const runtimeTarget = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'runtimeTarget.auto',
|
||
|
|
async () => this._resolveHostedUserRuntimeTarget(),
|
||
|
|
{
|
||
|
|
hostedApp: hostedAppName ?? '',
|
||
|
|
},
|
||
|
|
);
|
||
|
|
this._applyHostedRuntimeTargetMetadata(
|
||
|
|
runtimeTarget.root,
|
||
|
|
runtimeTarget.source,
|
||
|
|
runtimeTarget.kind,
|
||
|
|
runtimeTarget.requestedRoot,
|
||
|
|
runtimeTarget.unavailableReason,
|
||
|
|
);
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info(
|
||
|
|
`[MatrixDSLHost] hosted app runtime target → ${runtimeTarget.root || '(none)'} ` +
|
||
|
|
`(source=${runtimeTarget.source}, kind=${runtimeTarget.kind}, unavailable=${runtimeTarget.unavailableReason})`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 1. Determine mode from explicit four-roots attributes.
|
||
|
|
const busRootAttr = this.getAttribute('bus-root');
|
||
|
|
const backboneUrl = this.getAttribute('backbone');
|
||
|
|
const natsWsAttr = this.getAttribute('nats-ws-url');
|
||
|
|
const natsWsUrl = natsWsAttr ?? (!discoveredNats ? backboneUrl : null);
|
||
|
|
const mountPath = this.getAttribute('mount') ?? this.id ?? '';
|
||
|
|
const hasNatsBackbone = Boolean(busRootAttr && natsWsUrl);
|
||
|
|
const anonymousLocalBootstrap = this._bootstrapTransportPlan?.authMode === 'anonymous'
|
||
|
|
&& this._bootstrapTransportPlan.sessionMode === 'anonymous';
|
||
|
|
if (requireDaemonBootstrap
|
||
|
|
&& !renderNamespaceSetupLocally
|
||
|
|
&& !loginRequiredBootstrap
|
||
|
|
&& !anonymousLocalBootstrap
|
||
|
|
&& (!usedBootstrap || !hasNatsBackbone)) {
|
||
|
|
throw new Error('Hosted app bootstrap did not provide complete transport coordinates');
|
||
|
|
}
|
||
|
|
let selectedBackboneUrl: string | undefined;
|
||
|
|
|
||
|
|
// 2. Create transport + optional peer
|
||
|
|
if (busRootAttr && natsWsUrl) {
|
||
|
|
// FEDERATED MODE: Connect to the NATS backbone when transport coordinates exist.
|
||
|
|
// _bootstrapFederated() already handles both authenticated JWT flows and
|
||
|
|
// unauthenticated direct websocket connections.
|
||
|
|
await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'bootstrapFederated',
|
||
|
|
async () => this._bootstrapFederated(busRootAttr, natsWsUrl, mountPath),
|
||
|
|
{
|
||
|
|
root: busRootAttr,
|
||
|
|
mountPath,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
selectedBackboneUrl = natsWsUrl;
|
||
|
|
} else if (busRootAttr) {
|
||
|
|
// ROOT-LOCAL MODE: Anonymous users or no backbone URL.
|
||
|
|
// Uses in-memory transport — actor lifecycle works normally,
|
||
|
|
// onReady fires, pages render. No NATS permission errors.
|
||
|
|
this._bootstrapRootLocal(busRootAttr, mountPath);
|
||
|
|
} else {
|
||
|
|
// LOCAL MODE: plain InMemoryBroker, no federation
|
||
|
|
this._bootstrapLocal(mountPath);
|
||
|
|
}
|
||
|
|
|
||
|
|
const rootCtx = this._runtime!.getRootContext!();
|
||
|
|
|
||
|
|
// 2b. Register context services for child components
|
||
|
|
const routingPeer = this._routingPeer;
|
||
|
|
const sessionRoot = (this._transport?.root ?? this._transport?.realm) ?? busRootAttr ?? '';
|
||
|
|
const hasBackbone = routingPeer?.hasBackbone ?? false;
|
||
|
|
this.dataset.sessionRoot = sessionRoot;
|
||
|
|
this.dataset.runtimeRoot = sessionRoot;
|
||
|
|
this.dataset.runtimeInstanceRoot = sessionRoot;
|
||
|
|
rootCtx.setService('runtime-instance-root', sessionRoot);
|
||
|
|
|
||
|
|
if (routingPeer) {
|
||
|
|
rootCtx.setService(SESSION_INFO_KEY, {
|
||
|
|
mode: 'router',
|
||
|
|
root: sessionRoot,
|
||
|
|
realm: sessionRoot,
|
||
|
|
hasBackbone,
|
||
|
|
backboneUrl: selectedBackboneUrl,
|
||
|
|
backboneError: hasBackbone ? null : 'Connection failed or not attempted',
|
||
|
|
} as ISessionInfo);
|
||
|
|
rootCtx.setService(FEDERATION_PEER_KEY, routingPeer);
|
||
|
|
} else {
|
||
|
|
rootCtx.setService(SESSION_INFO_KEY, {
|
||
|
|
mode: 'local',
|
||
|
|
root: sessionRoot,
|
||
|
|
realm: sessionRoot,
|
||
|
|
hasBackbone: false,
|
||
|
|
} as ISessionInfo);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2c. Register identity services on root context for child actors
|
||
|
|
const daemonRoot = this.getAttribute('daemon-root') ?? this.getAttribute('daemon-realm');
|
||
|
|
if (daemonRoot) {
|
||
|
|
rootCtx.setService('daemon-root', daemonRoot);
|
||
|
|
}
|
||
|
|
if (this.dataset.platformRoot) {
|
||
|
|
rootCtx.setService('platform-root', this.dataset.platformRoot);
|
||
|
|
}
|
||
|
|
if (this.dataset.assetHostRoot) {
|
||
|
|
rootCtx.setService('asset-host-root', this.dataset.assetHostRoot);
|
||
|
|
}
|
||
|
|
if (this.dataset.platformServiceRoot) {
|
||
|
|
rootCtx.setService('platform-service-root', this.dataset.platformServiceRoot);
|
||
|
|
}
|
||
|
|
if (this.dataset.sessionAuthorityRoot) {
|
||
|
|
rootCtx.setService('session-authority-root', this.dataset.sessionAuthorityRoot);
|
||
|
|
}
|
||
|
|
if (this.dataset.selectedAuthorityRoot) {
|
||
|
|
rootCtx.setService('selected-authority-root', this.dataset.selectedAuthorityRoot);
|
||
|
|
}
|
||
|
|
rootCtx.setService('is-platform-admin', this.dataset.isPlatformAdmin === 'true');
|
||
|
|
const authorityRootService = this.dataset.selectedAuthorityRoot
|
||
|
|
|| this.dataset.sessionAuthorityRoot;
|
||
|
|
if (authorityRootService) {
|
||
|
|
rootCtx.setService('authority-root', authorityRootService);
|
||
|
|
}
|
||
|
|
if (this.dataset.principalId) {
|
||
|
|
rootCtx.setService('principal-id', this.dataset.principalId);
|
||
|
|
}
|
||
|
|
if (this.dataset.busToken) {
|
||
|
|
rootCtx.setService('bus-token', this.dataset.busToken);
|
||
|
|
}
|
||
|
|
this._installScopedActorServices(rootCtx, sessionRoot);
|
||
|
|
if (this.dataset.orgId) {
|
||
|
|
rootCtx.setService('org-id', this.dataset.orgId);
|
||
|
|
}
|
||
|
|
if (this.dataset.workspaceId) {
|
||
|
|
rootCtx.setService('workspace-id', this.dataset.workspaceId);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Install $join responder so children can join
|
||
|
|
this._installJoinResponder();
|
||
|
|
|
||
|
|
// 4. Mount all light DOM children with derived contexts
|
||
|
|
await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'mountChildren',
|
||
|
|
async () => this._mountChildren(),
|
||
|
|
{
|
||
|
|
childCount: this.children.length,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
// 5. Set up mutation observer for dynamic children
|
||
|
|
this._setupMutationObserver();
|
||
|
|
|
||
|
|
// 6. Parse root attributes for RootContext propagation
|
||
|
|
this._parseRootAttributes();
|
||
|
|
this._propagateRootToChildren();
|
||
|
|
|
||
|
|
// 7. Done
|
||
|
|
this._initialized = true;
|
||
|
|
const status = loginRequiredBootstrap
|
||
|
|
? 'login-required'
|
||
|
|
: (routingPeer?.hasBackbone || hasNatsBackbone) ? 'connected' : 'local';
|
||
|
|
this.dataset.status = status;
|
||
|
|
this.dataset.daemonStatus = loginRequiredBootstrap
|
||
|
|
? 'skipped'
|
||
|
|
: this.getAttribute('daemon-root') ? 'discovering' : 'skipped';
|
||
|
|
this._manageLoadingElement(status);
|
||
|
|
this._resolveReady();
|
||
|
|
this._fireReadyEvent();
|
||
|
|
const skipHostedShellDaemonDiscovery = this._shouldSkipHostedShellDaemonDiscovery(
|
||
|
|
hadExplicitDaemonTarget || hasExplicitDaemonTargetParam,
|
||
|
|
);
|
||
|
|
|
||
|
|
// Dispatch auth state event for app-level code
|
||
|
|
this.dispatchEvent(new CustomEvent(
|
||
|
|
this._isAuthenticated ? 'matrix-dsl-authenticated' : 'matrix-dsl-anonymous',
|
||
|
|
{ bubbles: true, detail: { principal: this._principal, activeOrg: this._activeOrg } },
|
||
|
|
));
|
||
|
|
|
||
|
|
if (loginRequiredBootstrap) {
|
||
|
|
this.dispatchEvent(new CustomEvent('matrix-host-login-required', {
|
||
|
|
bubbles: true,
|
||
|
|
composed: true,
|
||
|
|
detail: {
|
||
|
|
routeKind: this.dataset.routeKind ?? '',
|
||
|
|
sessionMode: this.dataset.sessionMode ?? '',
|
||
|
|
provider: this.dataset.loginProvider ?? '',
|
||
|
|
startUrl: this.dataset.loginStartUrl ?? '',
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
if (this._shouldRedirectToDeviceLogin(hostedAppName)) {
|
||
|
|
this._redirectToDeviceLogin();
|
||
|
|
} else if (hostedAppName !== 'web') {
|
||
|
|
this._redirectToPlatformLogin();
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Non-blocking — children are already mounted and can observe late identity resolution.
|
||
|
|
logBrowserProfile(HOST_PROFILE_SCOPE, 'phaseB.scheduled', {
|
||
|
|
usedBootstrap,
|
||
|
|
skipped: renderNamespaceSetupLocally,
|
||
|
|
});
|
||
|
|
if (!renderNamespaceSetupLocally) {
|
||
|
|
void this._runPhaseBBusDiscovery(usedBootstrap);
|
||
|
|
} else {
|
||
|
|
console.info('[MatrixDSLHost] Phase B service probe skipped for namespace setup — awaiting route-key claim');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Non-blocking — must run after _fireReadyEvent() so child app subscriptions are live
|
||
|
|
logBrowserProfile(HOST_PROFILE_SCOPE, 'daemonDiscovery.scheduled', {
|
||
|
|
daemonRoot: this.getAttribute('daemon-root') ?? '',
|
||
|
|
skipped: renderNamespaceSetupLocally || skipHostedShellDaemonDiscovery,
|
||
|
|
});
|
||
|
|
if (!renderNamespaceSetupLocally && !skipHostedShellDaemonDiscovery) {
|
||
|
|
void this._discoverDaemon();
|
||
|
|
} else {
|
||
|
|
this.dataset.daemonStatus = 'skipped';
|
||
|
|
const skippedDaemonRoot = this.getAttribute('daemon-root') ?? this.getAttribute('daemon-realm') ?? '';
|
||
|
|
rootCtx.transport.publish(TopicRouter.events(''), {
|
||
|
|
op: 'daemonReady',
|
||
|
|
payload: {
|
||
|
|
root: skippedDaemonRoot,
|
||
|
|
realm: skippedDaemonRoot,
|
||
|
|
skipped: true,
|
||
|
|
namespaceSetup: renderNamespaceSetupLocally,
|
||
|
|
platformShell: skipHostedShellDaemonDiscovery,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this._shouldRefreshHostedRuntimeTarget()) {
|
||
|
|
this._startHostedRuntimeTargetRefresh();
|
||
|
|
void this._refreshHostedRuntimeTargetMetadata();
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
const diagnostics = await this._diagnoseBootstrapFailure(err);
|
||
|
|
const error = err instanceof Error ? err : new Error(String(err));
|
||
|
|
error.message = [error.message, '', ...diagnostics.lines].join('\n');
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.error(
|
||
|
|
requireDaemonBootstrap
|
||
|
|
? '[MatrixDSLHost] Hosted bootstrap failed — refusing local substitution:'
|
||
|
|
: '[MatrixDSLHost] Bootstrap failed — offline local substitution disabled:',
|
||
|
|
error,
|
||
|
|
);
|
||
|
|
this.dataset.errorClass = diagnostics.classification;
|
||
|
|
this.dataset.status = 'disconnected';
|
||
|
|
this.dataset.daemonStatus = 'error';
|
||
|
|
|
||
|
|
this.dataset.status = 'error';
|
||
|
|
this._manageLoadingElement('error', error);
|
||
|
|
|
||
|
|
this.dispatchEvent(new CustomEvent('matrix-dsl-error', {
|
||
|
|
bubbles: true,
|
||
|
|
detail: { error, classification: diagnostics.classification, hint: diagnostics.hint },
|
||
|
|
}));
|
||
|
|
} finally {
|
||
|
|
finishBootstrapProfile({
|
||
|
|
status: this.dataset.status ?? '',
|
||
|
|
daemonStatus: this.dataset.daemonStatus ?? '',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private _readHostedSelectedLeafRoot(): string | null {
|
||
|
|
if (typeof window === 'undefined') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const stores: Array<Storage | undefined> = [window.sessionStorage, window.localStorage];
|
||
|
|
for (const store of stores) {
|
||
|
|
const raw = store?.getItem(HOSTED_SELECTED_LEAF_STORAGE_KEY);
|
||
|
|
if (typeof raw !== 'string') {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const normalized = raw.trim();
|
||
|
|
if (normalized.length > 0) {
|
||
|
|
return normalized;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _sortRuntimeRootsForDefault(authorityRoot: string | null, roots: string[]): string[] {
|
||
|
|
const normalizedAuthority = typeof authorityRoot === 'string' && authorityRoot.trim().length > 0
|
||
|
|
? authorityRoot.trim()
|
||
|
|
: null;
|
||
|
|
const filtered = normalizedAuthority
|
||
|
|
? roots.filter((root) => root === normalizedAuthority || root.startsWith(`${normalizedAuthority}.`))
|
||
|
|
: roots.slice();
|
||
|
|
const candidates = normalizedAuthority ? filtered : roots.slice();
|
||
|
|
return candidates.sort((left, right) => {
|
||
|
|
const leftSegments = left.split('.').length;
|
||
|
|
const rightSegments = right.split('.').length;
|
||
|
|
if (leftSegments !== rightSegments) {
|
||
|
|
return leftSegments - rightSegments;
|
||
|
|
}
|
||
|
|
return left.localeCompare(right);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private _classifyHostedRuntimeTarget(
|
||
|
|
root: string | null,
|
||
|
|
authorityRoot: string | null,
|
||
|
|
attachedLeafRoots: string[],
|
||
|
|
): HostedRuntimeTargetKind {
|
||
|
|
if (!root) {
|
||
|
|
return 'unknown';
|
||
|
|
}
|
||
|
|
if (authorityRoot && root === authorityRoot) {
|
||
|
|
return 'authority';
|
||
|
|
}
|
||
|
|
if (attachedLeafRoots.includes(root)) {
|
||
|
|
return 'leaf';
|
||
|
|
}
|
||
|
|
if (authorityRoot && root.startsWith(`${authorityRoot}.`)) {
|
||
|
|
return 'leaf';
|
||
|
|
}
|
||
|
|
return 'unknown';
|
||
|
|
}
|
||
|
|
|
||
|
|
private _authenticatedPrincipalAuthorityRoot(): string | null {
|
||
|
|
if (!this._isAuthenticated) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const principalRoot = (this.dataset.principalAuthorityRoot ?? '').trim();
|
||
|
|
if (principalRoot) {
|
||
|
|
return principalRoot;
|
||
|
|
}
|
||
|
|
const sessionRoot = (this.dataset.sessionAuthorityRoot ?? '').trim();
|
||
|
|
return sessionRoot || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _hostedAuthorityRootFromBootstrap(): string {
|
||
|
|
const selectedRoot = (this.dataset.selectedAuthorityRoot ?? '').trim();
|
||
|
|
const principalRoot = this._authenticatedPrincipalAuthorityRoot();
|
||
|
|
return selectedRoot || principalRoot || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
private _parseHostedRuntimeSummary(summary: IRuntimeSummaryPayload): IHostedRuntimeSummaryState {
|
||
|
|
const authorityRoot = this._hostedAuthorityRootFromBootstrap();
|
||
|
|
|
||
|
|
const items = Array.isArray(summary.attachedLeafRoots?.items)
|
||
|
|
? summary.attachedLeafRoots.items
|
||
|
|
: [];
|
||
|
|
const knownLeafRoots = [...new Set(
|
||
|
|
items
|
||
|
|
.map((item) => typeof item?.root === 'string' ? item.root.trim() : '')
|
||
|
|
.filter((root) => root.length > 0),
|
||
|
|
)];
|
||
|
|
const liveLeafRoots = [...new Set(
|
||
|
|
items
|
||
|
|
.filter((item) => {
|
||
|
|
const connectionKind = typeof item?.connectionKind === 'string' ? item.connectionKind.trim() : '';
|
||
|
|
return connectionKind.length === 0 || connectionKind !== 'registered';
|
||
|
|
})
|
||
|
|
.map((item) => typeof item?.root === 'string' ? item.root.trim() : '')
|
||
|
|
.filter((root) => root.length > 0),
|
||
|
|
)];
|
||
|
|
|
||
|
|
return {
|
||
|
|
authorityRoot: authorityRoot || null,
|
||
|
|
liveLeafRoots,
|
||
|
|
knownLeafRoots,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private _readHostedRuntimeTargetInfo(): IHostedRuntimeTargetInfo {
|
||
|
|
return resolveHostedRuntimeTargetInfo(this);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _setOptionalDatasetValue(name: string, value: string): void {
|
||
|
|
if (value) {
|
||
|
|
this.dataset[name] = value;
|
||
|
|
} else {
|
||
|
|
delete this.dataset[name];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private _initialRuntimeSummaryState(): IHostedRuntimeSummaryState {
|
||
|
|
return {
|
||
|
|
authorityRoot: typeof this.dataset.selectedAuthorityRoot === 'string'
|
||
|
|
? this.dataset.selectedAuthorityRoot.trim() || null
|
||
|
|
: null,
|
||
|
|
liveLeafRoots: [],
|
||
|
|
knownLeafRoots: [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _applyExplicitHostedRuntimeTarget(reason: string): Promise<void> {
|
||
|
|
const explicitRuntimeTarget = (
|
||
|
|
this.getAttribute('daemon-root')
|
||
|
|
?? this.getAttribute('daemon-realm')
|
||
|
|
?? ''
|
||
|
|
).trim();
|
||
|
|
if (!explicitRuntimeTarget) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!this._context) {
|
||
|
|
const summaryState = this._initialRuntimeSummaryState();
|
||
|
|
const runtimeTarget = this._resolveHostedRequestedRuntimeTarget(
|
||
|
|
explicitRuntimeTarget,
|
||
|
|
summaryState,
|
||
|
|
'explicit',
|
||
|
|
);
|
||
|
|
const authorizedInitialRoot = runtimeTarget.root
|
||
|
|
?? this._authorizedExplicitRootBeforeRuntimeSummary(
|
||
|
|
explicitRuntimeTarget,
|
||
|
|
summaryState.authorityRoot,
|
||
|
|
runtimeTarget.unavailableReason,
|
||
|
|
);
|
||
|
|
this._applyHostedRuntimeTargetMetadata(
|
||
|
|
authorizedInitialRoot,
|
||
|
|
runtimeTarget.source,
|
||
|
|
authorizedInitialRoot
|
||
|
|
? this._classifyHostedRuntimeTarget(
|
||
|
|
authorizedInitialRoot,
|
||
|
|
summaryState.authorityRoot,
|
||
|
|
summaryState.liveLeafRoots,
|
||
|
|
)
|
||
|
|
: runtimeTarget.kind,
|
||
|
|
runtimeTarget.requestedRoot,
|
||
|
|
authorizedInitialRoot ? 'none' : runtimeTarget.unavailableReason,
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let summaryState = this._initialRuntimeSummaryState();
|
||
|
|
try {
|
||
|
|
const summary = await this._loadHostedRuntimeSummaryFromBus(reason);
|
||
|
|
if (summary) {
|
||
|
|
summaryState = this._parseHostedRuntimeSummary(summary);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Preserve the explicit requested target in metadata, but do not promote
|
||
|
|
// it to the actual runtime root when live runtime inventory is missing.
|
||
|
|
}
|
||
|
|
|
||
|
|
const runtimeTarget = this._resolveHostedRequestedRuntimeTarget(
|
||
|
|
explicitRuntimeTarget,
|
||
|
|
summaryState,
|
||
|
|
'explicit',
|
||
|
|
);
|
||
|
|
this._applyHostedRuntimeTargetMetadata(
|
||
|
|
runtimeTarget.root,
|
||
|
|
runtimeTarget.source,
|
||
|
|
runtimeTarget.kind,
|
||
|
|
runtimeTarget.requestedRoot,
|
||
|
|
runtimeTarget.unavailableReason,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _applyHostedRuntimeTargetMetadata(
|
||
|
|
root: string | null,
|
||
|
|
source: HostedRuntimeTargetSource,
|
||
|
|
kind: HostedRuntimeTargetKind,
|
||
|
|
requestedRoot: string | null = null,
|
||
|
|
unavailableReason: 'missing' | 'not-live' | 'unauthorized' | 'none' = 'none',
|
||
|
|
): void {
|
||
|
|
if (root) {
|
||
|
|
this.setAttribute('daemon-root', root);
|
||
|
|
this.setAttribute('daemon-realm', root);
|
||
|
|
} else {
|
||
|
|
this.removeAttribute('daemon-root');
|
||
|
|
this.removeAttribute('daemon-realm');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (root) {
|
||
|
|
this.dataset.runtimeTargetRoot = root;
|
||
|
|
this.dataset.runtimeTargetSource = source;
|
||
|
|
this.dataset.runtimeTargetKind = kind;
|
||
|
|
} else {
|
||
|
|
delete this.dataset.runtimeTargetRoot;
|
||
|
|
this.dataset.runtimeTargetSource = source;
|
||
|
|
this.dataset.runtimeTargetKind = kind;
|
||
|
|
}
|
||
|
|
if (requestedRoot) {
|
||
|
|
this.dataset.runtimeTargetRequestedRoot = requestedRoot;
|
||
|
|
} else {
|
||
|
|
delete this.dataset.runtimeTargetRequestedRoot;
|
||
|
|
}
|
||
|
|
if (unavailableReason !== 'none') {
|
||
|
|
this.dataset.runtimeTargetUnavailableReason = unavailableReason;
|
||
|
|
} else {
|
||
|
|
delete this.dataset.runtimeTargetUnavailableReason;
|
||
|
|
}
|
||
|
|
this._renderHostedRuntimeRecoveryNotice(root, requestedRoot, unavailableReason);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _renderHostedRuntimeRecoveryNotice(
|
||
|
|
root: string | null,
|
||
|
|
requestedRoot: string | null,
|
||
|
|
unavailableReason: 'missing' | 'not-live' | 'unauthorized' | 'none',
|
||
|
|
): void {
|
||
|
|
const existing = this._hostedRuntimeRecoveryNotice();
|
||
|
|
const plan = resolveHostedRuntimeRecoveryPlan({
|
||
|
|
runtimeRoot: root,
|
||
|
|
authorityRoot: this._initialRuntimeSummaryState().authorityRoot,
|
||
|
|
requestedRoot,
|
||
|
|
unavailableReason,
|
||
|
|
currentHref: typeof window !== 'undefined' ? window.location.href : null,
|
||
|
|
});
|
||
|
|
if (!plan) {
|
||
|
|
existing?.remove();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const notice = existing ?? document.createElement('div');
|
||
|
|
notice.setAttribute(HOSTED_RUNTIME_RECOVERY_NOTICE_ATTR, 'true');
|
||
|
|
notice.setAttribute('role', 'status');
|
||
|
|
notice.style.display = 'flex';
|
||
|
|
notice.style.gap = '8px';
|
||
|
|
notice.style.alignItems = 'center';
|
||
|
|
notice.style.padding = '8px 12px';
|
||
|
|
notice.style.background = '#fff4e5';
|
||
|
|
notice.style.color = '#4a2a00';
|
||
|
|
notice.style.border = '1px solid #ffcc80';
|
||
|
|
notice.style.font = '13px system-ui, sans-serif';
|
||
|
|
notice.textContent = '';
|
||
|
|
|
||
|
|
const summary = document.createElement('span');
|
||
|
|
summary.textContent = plan.summary;
|
||
|
|
notice.appendChild(summary);
|
||
|
|
|
||
|
|
for (const action of plan.actions) {
|
||
|
|
const link = document.createElement('a');
|
||
|
|
link.href = action.href;
|
||
|
|
link.textContent = action.label;
|
||
|
|
link.title = action.title;
|
||
|
|
link.style.color = '#6b3900';
|
||
|
|
link.style.fontWeight = '600';
|
||
|
|
notice.appendChild(link);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!existing) {
|
||
|
|
this.insertBefore(notice, this.firstChild);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private _hostedRuntimeRecoveryNotice(): HTMLElement | null {
|
||
|
|
for (const child of Array.from(this.children)) {
|
||
|
|
if (
|
||
|
|
child instanceof HTMLElement
|
||
|
|
&& child.getAttribute(HOSTED_RUNTIME_RECOVERY_NOTICE_ATTR) === 'true'
|
||
|
|
) {
|
||
|
|
return child;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _resolveHostedRequestedRuntimeTarget(
|
||
|
|
requestedRoot: string | null,
|
||
|
|
summary: IHostedRuntimeSummaryState,
|
||
|
|
preferredSource: HostedRuntimeTargetSource,
|
||
|
|
): IHostedRuntimeTarget {
|
||
|
|
const target = typeof requestedRoot === 'string' && requestedRoot.trim().length > 0
|
||
|
|
? requestedRoot.trim()
|
||
|
|
: null;
|
||
|
|
const authorityRoot = summary.authorityRoot;
|
||
|
|
const liveLeafRoots = summary.liveLeafRoots;
|
||
|
|
const knownLeafRoots = summary.knownLeafRoots;
|
||
|
|
const chooseDefault = (): string | null => {
|
||
|
|
const candidates = this._sortRuntimeRootsForDefault(authorityRoot, liveLeafRoots);
|
||
|
|
return candidates[0] ?? null;
|
||
|
|
};
|
||
|
|
|
||
|
|
if (target && liveLeafRoots.includes(target)) {
|
||
|
|
return {
|
||
|
|
root: target,
|
||
|
|
source: preferredSource,
|
||
|
|
kind: this._classifyHostedRuntimeTarget(target, authorityRoot, liveLeafRoots),
|
||
|
|
requestedRoot: target,
|
||
|
|
unavailableReason: 'none',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (target && authorityRoot && target === authorityRoot) {
|
||
|
|
return {
|
||
|
|
root: authorityRoot,
|
||
|
|
source: preferredSource,
|
||
|
|
kind: this._classifyHostedRuntimeTarget(authorityRoot, authorityRoot, liveLeafRoots),
|
||
|
|
requestedRoot: target,
|
||
|
|
unavailableReason: 'none',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (target) {
|
||
|
|
if (this._isUnauthorizedHostedRuntimeTarget(target, authorityRoot, liveLeafRoots, knownLeafRoots)) {
|
||
|
|
return {
|
||
|
|
root: null,
|
||
|
|
source: preferredSource,
|
||
|
|
kind: 'unknown',
|
||
|
|
requestedRoot: target,
|
||
|
|
unavailableReason: 'unauthorized',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const unavailableReason = knownLeafRoots.includes(target) ? 'not-live' : 'missing';
|
||
|
|
return {
|
||
|
|
root: null,
|
||
|
|
source: preferredSource,
|
||
|
|
kind: this._classifyHostedRuntimeTarget(target, authorityRoot, liveLeafRoots),
|
||
|
|
requestedRoot: target,
|
||
|
|
unavailableReason,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const automaticRoot = chooseDefault();
|
||
|
|
if (automaticRoot) {
|
||
|
|
return {
|
||
|
|
root: automaticRoot,
|
||
|
|
source: 'auto',
|
||
|
|
kind: this._classifyHostedRuntimeTarget(automaticRoot, authorityRoot, liveLeafRoots),
|
||
|
|
requestedRoot: null,
|
||
|
|
unavailableReason: 'none',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (authorityRoot) {
|
||
|
|
return {
|
||
|
|
root: authorityRoot,
|
||
|
|
source: 'auto',
|
||
|
|
kind: 'authority',
|
||
|
|
requestedRoot: null,
|
||
|
|
unavailableReason: 'none',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
root: null,
|
||
|
|
source: 'none',
|
||
|
|
kind: 'unknown',
|
||
|
|
requestedRoot: target,
|
||
|
|
unavailableReason: 'none',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private _isUnauthorizedHostedRuntimeTarget(
|
||
|
|
target: string,
|
||
|
|
authorityRoot: string | null,
|
||
|
|
liveLeafRoots: readonly string[],
|
||
|
|
knownLeafRoots: readonly string[],
|
||
|
|
): boolean {
|
||
|
|
if (!authorityRoot || target === authorityRoot) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (liveLeafRoots.includes(target) || knownLeafRoots.includes(target)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (target.startsWith(`${authorityRoot}.`)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return this._looksLikeHostedAuthorityRoot(target);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _authorizedExplicitRootBeforeRuntimeSummary(
|
||
|
|
target: string,
|
||
|
|
authorityRoot: string | null,
|
||
|
|
unavailableReason: 'missing' | 'not-live' | 'unauthorized' | 'none',
|
||
|
|
): string | null {
|
||
|
|
if (unavailableReason === 'unauthorized' || !authorityRoot) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
if (target === authorityRoot || target.startsWith(`${authorityRoot}.`)) {
|
||
|
|
return target;
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _looksLikeHostedAuthorityRoot(root: string): boolean {
|
||
|
|
return /^SPACE-[A-Z0-9][A-Z0-9_-]*$/.test(root)
|
||
|
|
|| /^[A-Z][A-Z0-9_-]*(?:\.[A-Z0-9_-]+)+$/.test(root);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _shouldRefreshHostedRuntimeTarget(): boolean {
|
||
|
|
if (!this._isAuthenticated) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const pathname = typeof window !== 'undefined' && typeof window.location?.pathname === 'string'
|
||
|
|
? window.location.pathname
|
||
|
|
: '';
|
||
|
|
if (!pathname.startsWith('/apps/') && this._parseRouteKeyWebappRouteFromLocation() === null) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const hostedAppName = this._deriveHostedAppName()?.trim().toLowerCase();
|
||
|
|
return Boolean(hostedAppName && hostedAppName !== 'web');
|
||
|
|
}
|
||
|
|
|
||
|
|
private _resolveHostedRuntimeTargetForActiveSession(
|
||
|
|
summary: IHostedRuntimeSummaryState,
|
||
|
|
): IHostedRuntimeTarget {
|
||
|
|
const current = this._readHostedRuntimeTargetInfo();
|
||
|
|
const preferredSource = current.targetSource === 'explicit' ? 'explicit' : 'auto';
|
||
|
|
const requestedRoot = preferredSource === 'explicit'
|
||
|
|
? current.requestedRoot || current.runtimeRoot || null
|
||
|
|
: null;
|
||
|
|
return this._resolveHostedRequestedRuntimeTarget(requestedRoot, {
|
||
|
|
authorityRoot: summary.authorityRoot ?? (current.authorityRoot || null),
|
||
|
|
liveLeafRoots: summary.liveLeafRoots,
|
||
|
|
knownLeafRoots: summary.knownLeafRoots,
|
||
|
|
}, preferredSource);
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _refreshHostedRuntimeTargetMetadata(): Promise<void> {
|
||
|
|
if (!this._shouldRefreshHostedRuntimeTarget() || this._hostedRuntimeTargetRefreshInFlight) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this._hostedRuntimeTargetRefreshInFlight = true;
|
||
|
|
const previous = this._readHostedRuntimeTargetInfo();
|
||
|
|
try {
|
||
|
|
const summary = await this._loadHostedRuntimeSummaryFromBus('refresh');
|
||
|
|
if (!summary) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const parsed = this._parseHostedRuntimeSummary(summary);
|
||
|
|
const current = this._resolveHostedRuntimeTargetForActiveSession(parsed);
|
||
|
|
this._applyHostedRuntimeTargetMetadata(
|
||
|
|
current.root,
|
||
|
|
current.source,
|
||
|
|
current.kind,
|
||
|
|
current.requestedRoot,
|
||
|
|
current.unavailableReason,
|
||
|
|
);
|
||
|
|
const next = this._readHostedRuntimeTargetInfo();
|
||
|
|
if (!hostedRuntimeTargetInfoEquals(previous, next)) {
|
||
|
|
this.dispatchEvent(new CustomEvent<IHostedRuntimeTargetInfo>(
|
||
|
|
HOSTED_RUNTIME_TARGET_CHANGED_EVENT,
|
||
|
|
{
|
||
|
|
bubbles: true,
|
||
|
|
detail: next,
|
||
|
|
},
|
||
|
|
));
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Keep current metadata when runtime summary is temporarily unavailable.
|
||
|
|
} finally {
|
||
|
|
this._hostedRuntimeTargetRefreshInFlight = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _resolveHostedUserRuntimeTarget(): Promise<IHostedRuntimeTarget> {
|
||
|
|
const selectedLeafRoot = this._readHostedSelectedLeafRoot();
|
||
|
|
|
||
|
|
if (!this._isAuthenticated) {
|
||
|
|
return {
|
||
|
|
root: null,
|
||
|
|
source: 'none',
|
||
|
|
kind: 'unknown',
|
||
|
|
requestedRoot: selectedLeafRoot,
|
||
|
|
unavailableReason: 'none',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!this._context) {
|
||
|
|
const summaryState = this._initialRuntimeSummaryState();
|
||
|
|
if (selectedLeafRoot) {
|
||
|
|
return {
|
||
|
|
root: selectedLeafRoot,
|
||
|
|
source: 'explicit',
|
||
|
|
kind: this._classifyHostedRuntimeTarget(
|
||
|
|
selectedLeafRoot,
|
||
|
|
summaryState.authorityRoot,
|
||
|
|
summaryState.liveLeafRoots,
|
||
|
|
),
|
||
|
|
requestedRoot: selectedLeafRoot,
|
||
|
|
unavailableReason: 'none',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const authorityRoot = summaryState.authorityRoot;
|
||
|
|
return {
|
||
|
|
root: authorityRoot,
|
||
|
|
source: authorityRoot ? 'auto' : 'none',
|
||
|
|
kind: authorityRoot ? 'authority' : 'unknown',
|
||
|
|
requestedRoot: selectedLeafRoot,
|
||
|
|
unavailableReason: 'none',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const summary = await this._loadHostedRuntimeSummaryFromBus('bootstrap-auto');
|
||
|
|
if (!summary) {
|
||
|
|
return {
|
||
|
|
root: null,
|
||
|
|
source: 'none',
|
||
|
|
kind: 'unknown',
|
||
|
|
requestedRoot: selectedLeafRoot,
|
||
|
|
unavailableReason: 'none',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const parsed = this._parseHostedRuntimeSummary(summary);
|
||
|
|
return this._resolveHostedRequestedRuntimeTarget(selectedLeafRoot, parsed, 'explicit');
|
||
|
|
} catch {
|
||
|
|
return {
|
||
|
|
root: null,
|
||
|
|
source: 'none',
|
||
|
|
kind: 'unknown',
|
||
|
|
requestedRoot: selectedLeafRoot,
|
||
|
|
unavailableReason: 'none',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _loadHostedRuntimeSummaryFromBus(reason: string): Promise<IRuntimeSummaryPayload | null> {
|
||
|
|
if (!this._context) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const authorityRoot = this._authenticatedPrincipalAuthorityRoot()
|
||
|
|
?? this._initialRuntimeSummaryState().authorityRoot;
|
||
|
|
if (!authorityRoot) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const principalId = typeof this._principal?.principalId === 'string'
|
||
|
|
? this._principal.principalId.trim()
|
||
|
|
: '';
|
||
|
|
const busToken = typeof this.dataset.busToken === 'string' ? this.dataset.busToken.trim() : '';
|
||
|
|
if (!busToken) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const payload: Record<string, unknown> = {
|
||
|
|
busToken,
|
||
|
|
...(principalId ? { principalId } : {}),
|
||
|
|
authorityRoot,
|
||
|
|
includeOffline: true,
|
||
|
|
};
|
||
|
|
const controlPlaneRoot = this._controlPlaneRootForSingletons(authorityRoot);
|
||
|
|
const response = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'runtimeSummary.devices.list',
|
||
|
|
async () => RequestReply.execute<Record<string, unknown>, IDevicesListResponse>(
|
||
|
|
this._context!,
|
||
|
|
`${controlPlaneRoot}/system.devices`,
|
||
|
|
'devices.list',
|
||
|
|
payload,
|
||
|
|
{ timeoutMs: 5000 },
|
||
|
|
),
|
||
|
|
{ reason },
|
||
|
|
);
|
||
|
|
if (response.ok !== true) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return this._runtimeSummaryFromDeviceInventory(authorityRoot, response.devices);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _runtimeSummaryFromDeviceInventory(authorityRoot: string, value: unknown): IRuntimeSummaryPayload {
|
||
|
|
const devices = Array.isArray(value)
|
||
|
|
? value
|
||
|
|
.map((entry) => this._deviceInventoryRecord(entry))
|
||
|
|
.filter((entry): entry is IDeviceInventoryRecord => entry !== null)
|
||
|
|
: [];
|
||
|
|
return {
|
||
|
|
authority: {
|
||
|
|
addressRoot: authorityRoot,
|
||
|
|
},
|
||
|
|
attachedLeafRoots: {
|
||
|
|
items: devices
|
||
|
|
.map((device) => {
|
||
|
|
const root = typeof device.authorityRoot === 'string'
|
||
|
|
? device.authorityRoot.trim()
|
||
|
|
: '';
|
||
|
|
if (!root) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const status = typeof device.status === 'string'
|
||
|
|
? device.status.trim().toLowerCase()
|
||
|
|
: '';
|
||
|
|
return {
|
||
|
|
root,
|
||
|
|
connectionKind: status === 'online' ? 'Leafnode' : 'registered',
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.filter((entry): entry is { readonly root: string; readonly connectionKind: string } => entry !== null),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private _deviceInventoryRecord(value: unknown): IDeviceInventoryRecord | null {
|
||
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||
|
|
? value as IDeviceInventoryRecord
|
||
|
|
: null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _shouldRequireDaemonBootstrap(): boolean {
|
||
|
|
const bootstrapMode = this.getAttribute('bootstrap-mode')?.trim().toLowerCase();
|
||
|
|
const pathname = typeof window !== 'undefined' && typeof window.location?.pathname === 'string'
|
||
|
|
? window.location.pathname
|
||
|
|
: '';
|
||
|
|
if (bootstrapMode === 'local' || bootstrapMode === 'standalone') {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (pathname.startsWith('/apps/')) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
if (this._isHostBootstrapMode() || this._isHostServiceBootstrap()) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
if (bootstrapMode === 'daemon' || bootstrapMode === 'hosted') {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _redirectHostedAppToLogin(): void {
|
||
|
|
const redirectTarget = this._currentRelativeLocation();
|
||
|
|
const location = window.location;
|
||
|
|
const nextUrl = `/login?redirect=${encodeURIComponent(redirectTarget)}`;
|
||
|
|
this.dataset.status = 'redirecting-auth';
|
||
|
|
this._manageLoadingElement('redirecting-auth');
|
||
|
|
if (typeof location.assign === 'function') {
|
||
|
|
location.assign(nextUrl);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (typeof location.replace === 'function') {
|
||
|
|
location.replace(nextUrl);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
location.href = nextUrl;
|
||
|
|
} catch {
|
||
|
|
// no-op: test doubles may expose a read-only href
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private _currentRelativeLocation(): string {
|
||
|
|
const pathname = typeof window !== 'undefined' && typeof window.location?.pathname === 'string'
|
||
|
|
? window.location.pathname
|
||
|
|
: '/';
|
||
|
|
const search = typeof window !== 'undefined' && typeof window.location?.search === 'string'
|
||
|
|
? window.location.search
|
||
|
|
: '';
|
||
|
|
const hash = typeof window !== 'undefined' && typeof window.location?.hash === 'string'
|
||
|
|
? window.location.hash
|
||
|
|
: '';
|
||
|
|
return `${pathname || '/'}${search}${hash}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _bootstrapEndpointForRoute(routeKeyRoute: IRouteKeyWebappRoute | null, appName: string | null): string {
|
||
|
|
const params = new URLSearchParams();
|
||
|
|
if (routeKeyRoute) {
|
||
|
|
params.set('routeKey', routeKeyRoute.routeKey);
|
||
|
|
}
|
||
|
|
if (appName) {
|
||
|
|
params.set('appName', appName);
|
||
|
|
}
|
||
|
|
const query = params.toString();
|
||
|
|
return query ? `/api/bootstrap?${query}` : '/api/bootstrap';
|
||
|
|
}
|
||
|
|
|
||
|
|
private _isHostBootstrapMode(): boolean {
|
||
|
|
const mode = this.getAttribute('bootstrap-mode')?.trim().toLowerCase();
|
||
|
|
return mode === 'host' || mode === 'host-service' || mode === 'matrix-host';
|
||
|
|
}
|
||
|
|
|
||
|
|
private _isHostServiceBootstrap(): boolean {
|
||
|
|
return this.dataset.rootSource === 'host-service:transport.root';
|
||
|
|
}
|
||
|
|
|
||
|
|
private _isAuthoritativeBootstrapIdentitySource(source: string): boolean {
|
||
|
|
return source === 'system-auth:principal.addressRoots'
|
||
|
|
|| source === 'system-auth:publicNamespaces';
|
||
|
|
}
|
||
|
|
|
||
|
|
private _shouldUseRuntimeRegistryDiscovery(authorityRoot: string): boolean {
|
||
|
|
const root = authorityRoot.trim();
|
||
|
|
if (!root) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const hostedAppName = this._deriveHostedAppName();
|
||
|
|
if (!hostedAppName) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const runtimeTargetKind = this.dataset.runtimeTargetKind ?? '';
|
||
|
|
const runtimeTargetRoot = this.dataset.runtimeTargetRoot ?? '';
|
||
|
|
if (runtimeTargetKind === 'authority' && runtimeTargetRoot === root) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const selectedAuthorityRoot = (this.dataset.selectedAuthorityRoot ?? '').trim();
|
||
|
|
const rootSource = this.dataset.rootSource ?? '';
|
||
|
|
const selectedAuthorityRootSource = this.dataset.selectedAuthorityRootSource ?? '';
|
||
|
|
const rootCameFromAuthoritativeBootstrap = this._isAuthoritativeBootstrapIdentitySource(rootSource)
|
||
|
|
|| this._isAuthoritativeBootstrapIdentitySource(selectedAuthorityRootSource);
|
||
|
|
const authenticated = this.dataset.authenticated === 'true' || this._isAuthenticated === true;
|
||
|
|
|
||
|
|
// Host Service deployments discover authority runtime state through
|
||
|
|
// system.runtimes. Retired actor paths are not a product path.
|
||
|
|
return authenticated && rootCameFromAuthoritativeBootstrap && selectedAuthorityRoot === root;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _shouldSkipHostedShellDaemonDiscovery(hasExplicitDaemonTarget: boolean): boolean {
|
||
|
|
if (hasExplicitDaemonTarget || this._isHostBootstrapMode() || this._isHostServiceBootstrap()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return this._deriveHostedAppName() === 'web';
|
||
|
|
}
|
||
|
|
|
||
|
|
private _shouldRetainBootstrapIdentity(authorityRoot: string): boolean {
|
||
|
|
const root = authorityRoot.trim();
|
||
|
|
const selectedAuthorityRoot = (this.dataset.selectedAuthorityRoot ?? '').trim();
|
||
|
|
const rootSource = this.dataset.rootSource ?? '';
|
||
|
|
const selectedAuthorityRootSource = this.dataset.selectedAuthorityRootSource ?? '';
|
||
|
|
const rootCameFromAuthoritativeBootstrap = this._isAuthoritativeBootstrapIdentitySource(rootSource)
|
||
|
|
|| this._isAuthoritativeBootstrapIdentitySource(selectedAuthorityRootSource);
|
||
|
|
const authenticated = this.dataset.authenticated === 'true' || this._isAuthenticated === true;
|
||
|
|
|
||
|
|
return authenticated
|
||
|
|
&& root.length > 0
|
||
|
|
&& rootCameFromAuthoritativeBootstrap
|
||
|
|
&& (!selectedAuthorityRoot || selectedAuthorityRoot === root);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _ensureHostRuntimeMount(authorityRoot: string, appName: string | null): void {
|
||
|
|
const existingMount = this.getAttribute('mount')?.trim() ?? '';
|
||
|
|
if (existingMount.length > 0) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const runtimeId = this._getOrCreateBrowserRuntimeId(authorityRoot, appName);
|
||
|
|
this.dataset.hostRuntimeId = runtimeId;
|
||
|
|
this.setAttribute('mount', `system.runtimes.${runtimeId}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _getOrCreateBrowserRuntimeId(authorityRoot: string, appName: string | null): string {
|
||
|
|
const normalizedAuthority = authorityRoot.trim() || 'LOCAL';
|
||
|
|
const normalizedApp = (appName ?? 'browser').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '-');
|
||
|
|
const storageKey = `matrix-browser-runtime-id:${normalizedAuthority}:${normalizedApp}`;
|
||
|
|
const existing = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(storageKey) : null;
|
||
|
|
if (existing && /^RUNTIME-BROWSER-[A-Z0-9_-]+$/.test(existing)) {
|
||
|
|
return existing;
|
||
|
|
}
|
||
|
|
|
||
|
|
const randomId = this._randomRuntimeSuffix();
|
||
|
|
const runtimeId = `${BROWSER_RUNTIME_ID_PREFIX}${randomId}`;
|
||
|
|
if (typeof sessionStorage !== 'undefined') {
|
||
|
|
sessionStorage.setItem(storageKey, runtimeId);
|
||
|
|
}
|
||
|
|
return runtimeId;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _randomRuntimeSuffix(): string {
|
||
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||
|
|
return crypto.randomUUID().replace(/-/g, '').slice(0, 8).toUpperCase();
|
||
|
|
}
|
||
|
|
return Math.random().toString(36).slice(2, 10).toUpperCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _diagnoseBootstrapFailure(err: unknown): Promise<IBootstrapFailureDiagnostics> {
|
||
|
|
const rawMessage = err instanceof Error ? err.message : String(err);
|
||
|
|
const lines = [`Cause: ${rawMessage}`];
|
||
|
|
|
||
|
|
const classify = (classification: string, hint: string): IBootstrapFailureDiagnostics => ({
|
||
|
|
classification,
|
||
|
|
hint,
|
||
|
|
lines: [...lines, `Classification: ${classification}`, `Hint: ${hint}`],
|
||
|
|
});
|
||
|
|
|
||
|
|
if (/NATS JWT bootstrap failed/i.test(rawMessage)) {
|
||
|
|
let healthLine = 'HTTP /healthz: not checked';
|
||
|
|
let bootstrapLine = 'HTTP /api/bootstrap: not checked';
|
||
|
|
|
||
|
|
try {
|
||
|
|
const healthResp = await fetch('/healthz', { credentials: 'same-origin' });
|
||
|
|
healthLine = `HTTP /healthz: ${healthResp.status} ${healthResp.ok ? 'ok' : 'not ok'}`;
|
||
|
|
} catch (error) {
|
||
|
|
healthLine = `HTTP /healthz: unreachable (${error instanceof Error ? error.message : String(error)})`;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const bootstrapResp = await fetch('/api/bootstrap', { credentials: 'same-origin' });
|
||
|
|
bootstrapLine = `HTTP /api/bootstrap: ${bootstrapResp.status} ${bootstrapResp.ok ? 'ok' : 'not ok'}`;
|
||
|
|
} catch (error) {
|
||
|
|
bootstrapLine = `HTTP /api/bootstrap: unreachable (${error instanceof Error ? error.message : String(error)})`;
|
||
|
|
}
|
||
|
|
|
||
|
|
lines.push(healthLine, bootstrapLine);
|
||
|
|
return classify(
|
||
|
|
'nats-jwt-bootstrap',
|
||
|
|
'The HiveCast HTTP surface may still be up. This usually means session-to-bus auth or first-boot provisioning failed, not that the whole Host is down.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (/Failed to fetch|NetworkError|fetch/i.test(rawMessage)) {
|
||
|
|
try {
|
||
|
|
const healthResp = await fetch('/healthz', { credentials: 'same-origin' });
|
||
|
|
lines.push(`HTTP /healthz: ${healthResp.status} ${healthResp.ok ? 'ok' : 'not ok'}`);
|
||
|
|
return classify(
|
||
|
|
'http-bootstrap',
|
||
|
|
'The browser reached the HiveCast origin but a bootstrap HTTP request failed. Check /api/bootstrap and auth/session routes before assuming the Host is down.',
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
lines.push(`HTTP /healthz: unreachable (${error instanceof Error ? error.message : String(error)})`);
|
||
|
|
return classify(
|
||
|
|
'host-gateway-unreachable',
|
||
|
|
'The browser cannot reach the HiveCast HTTP gateway at all. This is the closest thing to “Host is down” in the bootstrap path.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (/websocket|nats|backbone|ECONNREFUSED|timeout/i.test(rawMessage)) {
|
||
|
|
return classify(
|
||
|
|
'backbone-connect',
|
||
|
|
'HTTP bootstrap likely succeeded, but the browser failed while connecting to the NATS/websocket backbone or the routed Host path.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return classify(
|
||
|
|
'bootstrap-runtime',
|
||
|
|
'The Host may still be running. This failure happened inside browser bootstrap logic after initial HTTP discovery.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* FEDERATED MODE: Browser NATS WebSocket transport.
|
||
|
|
*/
|
||
|
|
private async _bootstrapFederated(
|
||
|
|
busRootInput: string,
|
||
|
|
wsUrl: string,
|
||
|
|
mountPath: string,
|
||
|
|
): Promise<void> {
|
||
|
|
const hostBootstrapMode = this._isHostBootstrapMode();
|
||
|
|
const busRoot = hostBootstrapMode ? busRootInput.trim() : busRootInput.toLowerCase().trim();
|
||
|
|
const runtimeInstanceRoot = (this.dataset.runtimeInstanceRoot ?? '').trim();
|
||
|
|
const sessionAuthorityRoot = (this.dataset.sessionAuthorityRoot ?? '').trim();
|
||
|
|
const selectedAuthorityRoot = (this.dataset.selectedAuthorityRoot ?? '').trim();
|
||
|
|
const identityRoot = sessionAuthorityRoot || selectedAuthorityRoot;
|
||
|
|
const appName = this.getAttribute('app-name')?.trim() ?? '';
|
||
|
|
if (!hostBootstrapMode && !runtimeInstanceRoot && identityRoot && !appName) {
|
||
|
|
throw new Error(
|
||
|
|
'MatrixDSLHost: bootstrap did not provide app-name. '
|
||
|
|
+ 'Identity tab root requires appName; see four-roots.md.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
const tabRoot = hostBootstrapMode
|
||
|
|
? busRoot
|
||
|
|
: runtimeInstanceRoot
|
||
|
|
? runtimeInstanceRoot
|
||
|
|
: identityRoot
|
||
|
|
? getOrCreateIdentityTabRoot(appName, identityRoot)
|
||
|
|
: getOrCreateTabRealm(busRoot);
|
||
|
|
this._setOptionalDatasetValue('runtimeInstanceRoot', tabRoot);
|
||
|
|
const declaredAuthMode = this._bootstrapTransportPlan?.authMode;
|
||
|
|
|
||
|
|
// Prefer the explicit transport plan from /api/bootstrap.
|
||
|
|
// If absent, probe the NATS auth endpoint to identify the configured mode.
|
||
|
|
let jwtProvider: (() => Promise<{ jwt: string; seed: string }>) | undefined;
|
||
|
|
let cachedJwt: { jwt: string; seed: string } | null = null;
|
||
|
|
|
||
|
|
const buildNatsAuthEndpoint = (): string => {
|
||
|
|
const base = this.dataset.natsAuthEndpoint || '/api/nats-jwt';
|
||
|
|
const url = new URL(base, window.location.origin);
|
||
|
|
const platformRoot = (this.dataset.platformRoot ?? '').trim();
|
||
|
|
const requestedDaemonRoot = (this.getAttribute('daemon-root') ?? this.getAttribute('daemon-realm') ?? '').trim();
|
||
|
|
const selectedAuthorityRoot = (this.dataset.selectedAuthorityRoot ?? '').trim();
|
||
|
|
const anonymousSession = !this._isAuthenticated
|
||
|
|
&& (this._bootstrapTransportPlan?.sessionMode === 'anonymous' || this.dataset.sessionMode === 'anonymous');
|
||
|
|
const daemonRoot = anonymousSession
|
||
|
|
? (platformRoot && requestedDaemonRoot === platformRoot ? platformRoot : '')
|
||
|
|
: (selectedAuthorityRoot || requestedDaemonRoot);
|
||
|
|
if (daemonRoot) {
|
||
|
|
url.searchParams.set('daemonRoot', daemonRoot);
|
||
|
|
} else if (anonymousSession) {
|
||
|
|
url.searchParams.delete('daemonRoot');
|
||
|
|
url.searchParams.delete('root');
|
||
|
|
}
|
||
|
|
if (tabRoot) {
|
||
|
|
url.searchParams.set('clientRoot', tabRoot);
|
||
|
|
}
|
||
|
|
return url.pathname + url.search;
|
||
|
|
};
|
||
|
|
|
||
|
|
const fetchNatsAuthPayload = async (): Promise<{ jwt?: string; seed?: string; user?: string; pass?: string; mode?: string }> => {
|
||
|
|
const endpoint = buildNatsAuthEndpoint();
|
||
|
|
const response = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'natsAuth.fetch',
|
||
|
|
async () => fetch(endpoint, { method: 'POST', credentials: 'same-origin' }),
|
||
|
|
{
|
||
|
|
authMode: declaredAuthMode ?? 'auto',
|
||
|
|
endpoint,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
const payload = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'natsAuth.parse',
|
||
|
|
async () => response.json().catch(() => ({})) as Promise<{ jwt?: string; seed?: string; user?: string; pass?: string; mode?: string; error?: string }>,
|
||
|
|
{
|
||
|
|
authMode: declaredAuthMode ?? 'auto',
|
||
|
|
endpoint,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
if (!response.ok) {
|
||
|
|
const detail = typeof payload.error === 'string' && payload.error.length > 0
|
||
|
|
? payload.error
|
||
|
|
: `HTTP ${response.status}`;
|
||
|
|
throw new Error(`NATS auth request failed (${response.status}): ${detail}`);
|
||
|
|
}
|
||
|
|
return payload;
|
||
|
|
};
|
||
|
|
|
||
|
|
const fetchJwtCredentials = async (): Promise<{ jwt: string; seed: string } | null> => {
|
||
|
|
if (!this._isAuthenticated) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
let lastError: Error | null = null;
|
||
|
|
|
||
|
|
for (let attempt = 0; attempt < 8; attempt++) {
|
||
|
|
const endpoint = buildNatsAuthEndpoint();
|
||
|
|
const response = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'natsJwt.fetch',
|
||
|
|
async () => fetch(endpoint, { method: 'POST', credentials: 'same-origin' }),
|
||
|
|
{
|
||
|
|
attempt: attempt + 1,
|
||
|
|
authMode: declaredAuthMode ?? 'auto',
|
||
|
|
},
|
||
|
|
);
|
||
|
|
if (response.ok) {
|
||
|
|
const payload = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'natsJwt.parse',
|
||
|
|
async () => response.json() as Promise<{ jwt?: string; seed?: string; user?: string; pass?: string; mode?: string }>,
|
||
|
|
{
|
||
|
|
attempt: attempt + 1,
|
||
|
|
authMode: declaredAuthMode ?? 'auto',
|
||
|
|
},
|
||
|
|
);
|
||
|
|
if (payload.jwt && payload.seed) {
|
||
|
|
return { jwt: payload.jwt, seed: payload.seed };
|
||
|
|
}
|
||
|
|
// Static NATS mode — store user/pass for transport auth
|
||
|
|
if (payload.mode === 'static' && payload.user && payload.pass) {
|
||
|
|
this.dataset.natsUser = payload.user;
|
||
|
|
this.dataset.natsPass = payload.pass;
|
||
|
|
return null; // No JWT, use user/pass auth instead
|
||
|
|
}
|
||
|
|
// Anonymous mode — no credentials needed
|
||
|
|
if (payload.mode === 'anonymous') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
lastError = new Error('NATS JWT response missing jwt or seed');
|
||
|
|
} else {
|
||
|
|
const bodyText = await response.text().catch(() => '');
|
||
|
|
const detail = bodyText.trim();
|
||
|
|
|
||
|
|
// This deployment does not expose operator/JWT auth, so plain WS token mode is expected.
|
||
|
|
if (
|
||
|
|
response.status === 401
|
||
|
|
|| response.status === 403
|
||
|
|
|| response.status === 404
|
||
|
|
|| (response.status === 503 && /not configured|no operatorseed|no account selected|failed to create nats jwt/i.test(detail))
|
||
|
|
) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Provisioning and rate-limit windows are transient immediately after login/startup.
|
||
|
|
if (response.status === 429 || response.status >= 500 || /provisioning operation is in progress/i.test(detail)) {
|
||
|
|
lastError = new Error(`NATS JWT temporary failure (${response.status}): ${detail || 'no body'}`);
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, Math.min(250 * (attempt + 1), 1000)));
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new Error(`NATS JWT request failed (${response.status}): ${detail || 'no body'}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (lastError) {
|
||
|
|
throw lastError;
|
||
|
|
}
|
||
|
|
throw new Error('NATS JWT request failed without a response');
|
||
|
|
};
|
||
|
|
|
||
|
|
if (declaredAuthMode === 'jwt') {
|
||
|
|
const payload = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'natsAuth.declared',
|
||
|
|
async () => fetchNatsAuthPayload(),
|
||
|
|
{ authMode: declaredAuthMode },
|
||
|
|
);
|
||
|
|
if (!payload.jwt || !payload.seed) {
|
||
|
|
throw new Error('Declared NATS authMode "jwt" but /api/nats-jwt did not return jwt+seed');
|
||
|
|
}
|
||
|
|
cachedJwt = { jwt: payload.jwt, seed: payload.seed };
|
||
|
|
jwtProvider = async () => {
|
||
|
|
if (cachedJwt) {
|
||
|
|
const next = cachedJwt;
|
||
|
|
cachedJwt = null;
|
||
|
|
return next;
|
||
|
|
}
|
||
|
|
const refreshed = await fetchNatsAuthPayload();
|
||
|
|
if (!refreshed.jwt || !refreshed.seed) {
|
||
|
|
throw new Error('Declared NATS authMode "jwt" but refresh did not return jwt+seed');
|
||
|
|
}
|
||
|
|
return { jwt: refreshed.jwt, seed: refreshed.seed };
|
||
|
|
};
|
||
|
|
} else if (declaredAuthMode === 'static') {
|
||
|
|
const payload = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'natsAuth.declared',
|
||
|
|
async () => fetchNatsAuthPayload(),
|
||
|
|
{ authMode: declaredAuthMode },
|
||
|
|
);
|
||
|
|
if (payload.mode !== 'static' || !payload.user || !payload.pass) {
|
||
|
|
throw new Error('Declared NATS authMode "static" but /api/nats-jwt did not return user/pass');
|
||
|
|
}
|
||
|
|
this.dataset.natsUser = payload.user;
|
||
|
|
this.dataset.natsPass = payload.pass;
|
||
|
|
} else if (declaredAuthMode === 'anonymous') {
|
||
|
|
// No extra auth bootstrap required.
|
||
|
|
} else {
|
||
|
|
try {
|
||
|
|
const initial = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'natsJwt.bootstrap',
|
||
|
|
async () => fetchJwtCredentials(),
|
||
|
|
{ authMode: declaredAuthMode ?? 'auto' },
|
||
|
|
);
|
||
|
|
if (initial) {
|
||
|
|
cachedJwt = initial;
|
||
|
|
jwtProvider = async () => {
|
||
|
|
if (cachedJwt) {
|
||
|
|
const next = cachedJwt;
|
||
|
|
cachedJwt = null;
|
||
|
|
return next;
|
||
|
|
}
|
||
|
|
const refreshed = await fetchJwtCredentials();
|
||
|
|
if (!refreshed) {
|
||
|
|
throw new Error('NATS JWT refresh unavailable: operator mode no longer configured');
|
||
|
|
}
|
||
|
|
return refreshed;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
throw new Error(`NATS JWT bootstrap failed: ${error instanceof Error ? error.message : String(error)}`);
|
||
|
|
}
|
||
|
|
if (!jwtProvider) {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info('[MatrixDSLHost] NATS JWT unavailable — connecting with configured non-JWT transport');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (declaredAuthMode === 'anonymous') {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info('[MatrixDSLHost] NATS auth plan → anonymous');
|
||
|
|
} else if (declaredAuthMode === 'static') {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info('[MatrixDSLHost] NATS auth plan → static credentials via /api/nats-jwt');
|
||
|
|
} else if (declaredAuthMode === 'jwt') {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info('[MatrixDSLHost] NATS auth plan → JWT via /api/nats-jwt');
|
||
|
|
} else if (!jwtProvider) {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info('[MatrixDSLHost] NATS operator mode not available — connecting without JWT auth');
|
||
|
|
}
|
||
|
|
|
||
|
|
const connectBackbone = async () => createBrowserNatsTransport({
|
||
|
|
root: tabRoot,
|
||
|
|
wsUrl,
|
||
|
|
token: this.dataset.natsToken,
|
||
|
|
user: this.dataset.natsUser,
|
||
|
|
pass: this.dataset.natsPass,
|
||
|
|
jwtProvider,
|
||
|
|
});
|
||
|
|
|
||
|
|
const shouldRetryJwtReadiness = (error: unknown): boolean => {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
return declaredAuthMode === 'jwt'
|
||
|
|
&& Boolean(jwtProvider)
|
||
|
|
&& /Authorization Violation|authentication violation/i.test(message);
|
||
|
|
};
|
||
|
|
|
||
|
|
let rawAdapter: Awaited<ReturnType<typeof connectBackbone>> | null = null;
|
||
|
|
let lastConnectError: unknown = null;
|
||
|
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||
|
|
try {
|
||
|
|
rawAdapter = await profileBrowserSpan(
|
||
|
|
HOST_PROFILE_SCOPE,
|
||
|
|
'nats.connect',
|
||
|
|
async () => connectBackbone(),
|
||
|
|
{
|
||
|
|
root: tabRoot,
|
||
|
|
authMode: declaredAuthMode ?? 'auto',
|
||
|
|
attempt: attempt + 1,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
lastConnectError = null;
|
||
|
|
break;
|
||
|
|
} catch (error) {
|
||
|
|
lastConnectError = error;
|
||
|
|
if (!shouldRetryJwtReadiness(error) || attempt === 3) {
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.warn(`[MatrixDSLHost] NATS JWT account not ready yet; retrying backbone connect (${attempt + 2}/4)...`);
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!this._rawAdapter && lastConnectError) {
|
||
|
|
throw lastConnectError;
|
||
|
|
}
|
||
|
|
if (!rawAdapter) {
|
||
|
|
throw new Error('NATS backbone connection failed before an adapter was created');
|
||
|
|
}
|
||
|
|
this._rawAdapter = rawAdapter;
|
||
|
|
this._transport = this._rawAdapter;
|
||
|
|
this.dataset.status = 'connected';
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info(`[MatrixDSLHost] NATS backbone connected: ${wsUrl} (${tabRoot})`);
|
||
|
|
|
||
|
|
if (this._wrapTransport && this._transport) {
|
||
|
|
this._transport = this._wrapTransport(this._transport);
|
||
|
|
}
|
||
|
|
|
||
|
|
this._runtime = new MatrixRuntime({
|
||
|
|
transport: this._transport ?? undefined,
|
||
|
|
domStrategy: new BrowserDomStrategy(),
|
||
|
|
});
|
||
|
|
const rootCtx = this._runtime.getRootContext!();
|
||
|
|
this._context = mountPath ? rootCtx.derive(mountPath) : rootCtx;
|
||
|
|
await this._applyHostedWebappMetadata();
|
||
|
|
this._routingPeer = this._createTransportRoutingPeer(tabRoot);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _deriveHostedAppName(): string | null {
|
||
|
|
const routeKeyRoute = this._parseRouteKeyWebappRouteFromLocation();
|
||
|
|
if (routeKeyRoute) {
|
||
|
|
return routeKeyRoute.appName;
|
||
|
|
}
|
||
|
|
const pathname = typeof window !== 'undefined' && typeof window.location?.pathname === 'string'
|
||
|
|
? window.location.pathname
|
||
|
|
: '';
|
||
|
|
const match = /^\/apps\/([^/]+)/.exec(pathname);
|
||
|
|
const rawAppName = match?.[1];
|
||
|
|
if (!rawAppName) return null;
|
||
|
|
try {
|
||
|
|
const decoded = decodeURIComponent(rawAppName).trim();
|
||
|
|
return decoded.length > 0 ? decoded : null;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private _parseRouteKeyWebappRouteFromLocation(): IRouteKeyWebappRoute | null {
|
||
|
|
const pathname = typeof window !== 'undefined' && typeof window.location?.pathname === 'string'
|
||
|
|
? window.location.pathname
|
||
|
|
: '';
|
||
|
|
if (!pathname.startsWith('/')) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const withoutLeadingSlash = pathname.slice(1);
|
||
|
|
const firstSlash = withoutLeadingSlash.indexOf('/');
|
||
|
|
if (firstSlash <= 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
let routeKey: string;
|
||
|
|
try {
|
||
|
|
routeKey = decodeURIComponent(withoutLeadingSlash.slice(0, firstSlash)).trim().toLowerCase();
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
if (!isValidRouteKey(routeKey)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const afterRouteKey = withoutLeadingSlash.slice(firstSlash + 1);
|
||
|
|
const appPath = afterRouteKey.startsWith('apps/')
|
||
|
|
? afterRouteKey.slice('apps/'.length)
|
||
|
|
: afterRouteKey;
|
||
|
|
const appSlash = appPath.indexOf('/');
|
||
|
|
const rawAppName = appSlash === -1 ? appPath : appPath.slice(0, appSlash);
|
||
|
|
let appName: string;
|
||
|
|
try {
|
||
|
|
appName = decodeURIComponent(rawAppName).trim();
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
if (!/^[A-Za-z0-9._-]+$/.test(appName) || appName === 'web') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
appName,
|
||
|
|
routeKey,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _applyHostedWebappMetadata(): Promise<void> {
|
||
|
|
if (!this._context) return;
|
||
|
|
|
||
|
|
const appName = this._deriveHostedAppName();
|
||
|
|
if (!appName) return;
|
||
|
|
if (appName === 'web') return;
|
||
|
|
if (this._parseRouteKeyWebappRouteFromLocation()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const assetHostRoot = (this.dataset.assetHostRoot ?? '').trim();
|
||
|
|
const selectedAuthorityRoot = (this.dataset.selectedAuthorityRoot ?? '').trim();
|
||
|
|
const platformServiceRoot = (this.dataset.platformServiceRoot ?? '').trim();
|
||
|
|
const isPlatformAdmin = this.dataset.isPlatformAdmin === 'true';
|
||
|
|
if (platformServiceRoot && selectedAuthorityRoot === platformServiceRoot && !isPlatformAdmin) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (assetHostRoot && selectedAuthorityRoot && assetHostRoot !== selectedAuthorityRoot && !isPlatformAdmin) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const entry = await this._loadHostedWebappRegistryEntry(appName);
|
||
|
|
const metadataRecord = registryRecord(entry.metadata);
|
||
|
|
const artifact = registryArtifact(metadataRecord.artifact);
|
||
|
|
const packageName = registryString(entry.placement.packageRef) ?? registryString(entry.placement.packageId);
|
||
|
|
const packageVersion = registryString(metadataRecord.packageVersion);
|
||
|
|
const sourceKind = registrySourceKind(metadataRecord.sourceKind)
|
||
|
|
?? (registryString(metadataRecord.origin) ? 'external-webapp' : 'installed-webapp');
|
||
|
|
const registrationSource = registryRegistrationSource(metadataRecord.registrationSource);
|
||
|
|
const deploymentInstanceId = registryString(entry.placement.deploymentInstanceId) ?? registryString(entry.placement.runtimeId);
|
||
|
|
const distPath = registryString(metadataRecord.distPath);
|
||
|
|
const entryFile = registryString(metadataRecord.entryFile);
|
||
|
|
const buildGitSha = registryString(artifact.gitSha);
|
||
|
|
const artifactBuiltAt = registryString(artifact.builtAt);
|
||
|
|
const artifactSignature = registryString(artifact.signature);
|
||
|
|
const artifactFreshness = registryArtifactFreshness(artifact.freshness);
|
||
|
|
const artifactFreshnessReason = registryString(artifact.reason);
|
||
|
|
const metadata: IComponentRuntimeMetadata = {
|
||
|
|
versionSource: packageVersion ? 'webapp-registry' : 'undeclared',
|
||
|
|
sourceKind,
|
||
|
|
appName,
|
||
|
|
...(packageName ? { packageName } : {}),
|
||
|
|
...(packageVersion ? { version: packageVersion } : {}),
|
||
|
|
...(registrationSource ? { registrationSource } : {}),
|
||
|
|
...(deploymentInstanceId ? { deploymentInstanceId } : {}),
|
||
|
|
...(distPath ? { distPath } : {}),
|
||
|
|
...(entryFile ? { entryFile } : {}),
|
||
|
|
...(buildGitSha ? { buildGitSha } : {}),
|
||
|
|
...(artifactBuiltAt ? { artifactBuiltAt } : {}),
|
||
|
|
...(artifactSignature ? { artifactSignature } : {}),
|
||
|
|
...(artifactFreshness ? { artifactFreshness } : {}),
|
||
|
|
...(artifactFreshnessReason ? { artifactFreshnessReason } : {}),
|
||
|
|
};
|
||
|
|
|
||
|
|
if (metadata.artifactFreshness && metadata.artifactFreshness !== 'current') {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap visibility
|
||
|
|
console.warn(
|
||
|
|
`[MatrixDSLHost] Hosted webapp artifact for "${appName}" is ${metadata.artifactFreshness}: ${metadata.artifactFreshnessReason ?? 'no reason provided'}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const contextual = this._context as IMatrixContext & {
|
||
|
|
setService?: (key: string | symbol, service: unknown) => void;
|
||
|
|
};
|
||
|
|
contextual.setService?.(COMPONENT_RUNTIME_METADATA, metadata);
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _loadHostedWebappRegistryEntry(appName: string): Promise<ServiceRegistryEntryV1> {
|
||
|
|
if (!this._context) {
|
||
|
|
throw new Error(`system.registry lookup for hosted app '${appName}' requires a Matrix context`);
|
||
|
|
}
|
||
|
|
const namespaceRoot = this._hostedWebappRegistryNamespaceRoot();
|
||
|
|
const response = await RequestReply.execute<Record<string, unknown>, IRegistryQueryResponse>(
|
||
|
|
this._context,
|
||
|
|
`${namespaceRoot}/system.registry`,
|
||
|
|
'registry.query',
|
||
|
|
{
|
||
|
|
namespaceRoot,
|
||
|
|
mount: appName,
|
||
|
|
kind: 'app',
|
||
|
|
openable: true,
|
||
|
|
},
|
||
|
|
{ timeoutMs: 5000 },
|
||
|
|
);
|
||
|
|
const entries = Array.isArray(response.entries) ? response.entries : [];
|
||
|
|
const entry = entries
|
||
|
|
.map((candidate) => serviceRegistryEntry(candidate))
|
||
|
|
.find((candidate) => candidate !== null
|
||
|
|
&& candidate.kind === 'app'
|
||
|
|
&& candidate.app?.openable === true
|
||
|
|
&& candidate.mount === appName);
|
||
|
|
if (!entry) {
|
||
|
|
throw new Error(`app '${appName}' not registered by ${namespaceRoot}/system.registry`);
|
||
|
|
}
|
||
|
|
return entry;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _hostedWebappRegistryNamespaceRoot(): string {
|
||
|
|
const assetHostRoot = (this.dataset.assetHostRoot ?? '').trim();
|
||
|
|
if (assetHostRoot) {
|
||
|
|
return assetHostRoot;
|
||
|
|
}
|
||
|
|
const busRoot = (this.getAttribute('bus-root') ?? '').trim();
|
||
|
|
if (!busRoot) {
|
||
|
|
throw new Error('Hosted webapp registry lookup requires assetHostRoot from /api/bootstrap');
|
||
|
|
}
|
||
|
|
return busRoot;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ROOT LOCAL MODE: FederationPeer without backbone (local pub/sub only).
|
||
|
|
*/
|
||
|
|
private _bootstrapRootLocal(busRootInput: string, mountPath: string): void {
|
||
|
|
const baseRoot = busRootInput.toLowerCase().trim();
|
||
|
|
|
||
|
|
// Create browser router (FederationPeer in local-only mode)
|
||
|
|
const result: BrowserRouterResult = getBrowserRouter({ baseRealm: baseRoot });
|
||
|
|
this._peer = result.router;
|
||
|
|
this._routingPeer = result.router;
|
||
|
|
this._rawAdapter = browserRouterTransportAdapter(result.adapter, result.router);
|
||
|
|
this._transport = this._rawAdapter;
|
||
|
|
|
||
|
|
// Apply transport wrapper if provided
|
||
|
|
if (this._wrapTransport) {
|
||
|
|
this._transport = this._wrapTransport(this._transport);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create runtime with federation adapter
|
||
|
|
this._runtime = new MatrixRuntime({
|
||
|
|
transport: this._transport,
|
||
|
|
domStrategy: new BrowserDomStrategy(),
|
||
|
|
});
|
||
|
|
const rootCtx = this._runtime.getRootContext!();
|
||
|
|
this._context = mountPath ? rootCtx.derive(mountPath) : rootCtx;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _createTransportRoutingPeer(localRoot: string): IHostRoutingPeer {
|
||
|
|
return {
|
||
|
|
hasBackbone: true,
|
||
|
|
localRoot,
|
||
|
|
localRealm: localRoot,
|
||
|
|
invoke: async <T>(
|
||
|
|
target: string,
|
||
|
|
opOrRequest: string | { op: string; payload?: unknown },
|
||
|
|
payloadOrTimeout?: unknown,
|
||
|
|
timeoutMs?: number,
|
||
|
|
): Promise<T> => {
|
||
|
|
if (!this._context) {
|
||
|
|
throw new Error('MatrixDSLHost transport router is not initialized');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof opOrRequest === 'string') {
|
||
|
|
const op = opOrRequest;
|
||
|
|
const payload = payloadOrTimeout;
|
||
|
|
return await RequestReply.execute<unknown, T>(this._context, target, op, payload, {
|
||
|
|
timeoutMs,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const op = opOrRequest.op;
|
||
|
|
const payload = opOrRequest.payload;
|
||
|
|
const resolvedTimeoutMs = typeof payloadOrTimeout === 'number' ? payloadOrTimeout : timeoutMs;
|
||
|
|
return await RequestReply.execute<unknown, T>(this._context, target, op, payload, {
|
||
|
|
timeoutMs: resolvedTimeoutMs,
|
||
|
|
});
|
||
|
|
},
|
||
|
|
route: async (topic: string, payload: unknown): Promise<void> => {
|
||
|
|
if (!this._transport) {
|
||
|
|
throw new Error('MatrixDSLHost transport router is not initialized');
|
||
|
|
}
|
||
|
|
this._transport.publish(topic, payload);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* LOCAL MODE: Plain in-memory transport, no federation overhead.
|
||
|
|
*/
|
||
|
|
private _bootstrapLocal(mountPath: string): void {
|
||
|
|
// No federation — just in-memory broker + transport
|
||
|
|
this._runtime = new MatrixRuntime({
|
||
|
|
domStrategy: new BrowserDomStrategy(),
|
||
|
|
});
|
||
|
|
const rootCtx = this._runtime.getRootContext!();
|
||
|
|
this._context = mountPath ? rootCtx.derive(mountPath) : rootCtx;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
// $join Responder
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Subscribe to this host's $inbox to respond to $join from children.
|
||
|
|
*
|
||
|
|
* When children call _receiveContext(derivedCtx), they send $join to
|
||
|
|
* parent.mount/$inbox. This responder acks them so they don't timeout.
|
||
|
|
*/
|
||
|
|
private _installJoinResponder(): void {
|
||
|
|
if (!this._context) return;
|
||
|
|
|
||
|
|
const hostMount = this._context.mount;
|
||
|
|
const hostInbox = TopicRouter.inbox(hostMount);
|
||
|
|
|
||
|
|
this._joinUnsub = this._context.transport.subscribe(
|
||
|
|
hostInbox,
|
||
|
|
(rawPayload: unknown) => {
|
||
|
|
const envelope = rawPayload as MxEnvelope;
|
||
|
|
if (!envelope || envelope.op !== '$join') return;
|
||
|
|
|
||
|
|
const joinPayload = envelope.payload as { id?: string; mount?: string } | undefined;
|
||
|
|
const childMount = joinPayload?.mount;
|
||
|
|
if (!childMount) return;
|
||
|
|
|
||
|
|
const childInbox = TopicRouter.inbox(childMount);
|
||
|
|
this._context!.transport.publish(childInbox, {
|
||
|
|
op: '$join_ack',
|
||
|
|
payload: { status: 'ok', supervisor: hostMount },
|
||
|
|
});
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
// Child Mounting
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Mount all light DOM children that are custom elements.
|
||
|
|
* Uses explicit _receiveContext() — NOT AUTO-PULL (because this host
|
||
|
|
* is not a MatrixActorHtmlElement and won't be found by _findParentShell).
|
||
|
|
*/
|
||
|
|
private async _mountChildren(): Promise<void> {
|
||
|
|
if (!this._context) return;
|
||
|
|
|
||
|
|
const mountPromises: Promise<void>[] = [];
|
||
|
|
for (const child of Array.from(this.children)) {
|
||
|
|
if (!(child instanceof HTMLElement)) continue;
|
||
|
|
mountPromises.push(this._mountChild(child));
|
||
|
|
}
|
||
|
|
await Promise.all(mountPromises);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Mount a single child element with a derived context.
|
||
|
|
*/
|
||
|
|
private async _mountChild(element: HTMLElement): Promise<void> {
|
||
|
|
if (!this._context) return;
|
||
|
|
|
||
|
|
const tagName = element.tagName.toLowerCase();
|
||
|
|
|
||
|
|
// Skip non-custom elements (no hyphen = not a CE)
|
||
|
|
if (!tagName.includes('-')) return;
|
||
|
|
// Skip script/style
|
||
|
|
if (tagName === 'script' || tagName === 'style') return;
|
||
|
|
|
||
|
|
// Wait for custom element definition (handles script loading order)
|
||
|
|
await customElements.whenDefined(tagName);
|
||
|
|
|
||
|
|
// Derive child ID from name, id, or tag
|
||
|
|
const childId = (
|
||
|
|
element.getAttribute('name') ||
|
||
|
|
element.id ||
|
||
|
|
tagName
|
||
|
|
).toLowerCase();
|
||
|
|
|
||
|
|
const childContext = this._context.derive(childId);
|
||
|
|
|
||
|
|
const shell = matrixChildElementHooks(element);
|
||
|
|
|
||
|
|
if (typeof shell._receiveContext === 'function') {
|
||
|
|
await shell._receiveContext(childContext);
|
||
|
|
} else if (typeof shell.mount === 'function') {
|
||
|
|
await shell.mount(childContext);
|
||
|
|
} else if (typeof shell.setContext === 'function') {
|
||
|
|
shell.setContext(childContext);
|
||
|
|
}
|
||
|
|
// Plain HTML elements without Matrix interface are silently skipped
|
||
|
|
}
|
||
|
|
|
||
|
|
private _browserRuntimeInventory(runtimeId: string, contextMount: string, hostBootstrapMode: boolean): IBrowserRuntimeInventoryEntry[] {
|
||
|
|
const inventory: IBrowserRuntimeInventoryEntry[] = [];
|
||
|
|
const runtimeMount = `system.runtimes.${runtimeId}`;
|
||
|
|
inventory.push({
|
||
|
|
mount: runtimeMount,
|
||
|
|
type: 'BrowserRuntimeControlActor',
|
||
|
|
surface: 'runtime',
|
||
|
|
runtimeId,
|
||
|
|
claimKind: 'local-only',
|
||
|
|
description: 'Browser runtime actor for this tab.',
|
||
|
|
hasChildren: true,
|
||
|
|
});
|
||
|
|
for (const child of Array.from(this.children)) {
|
||
|
|
if (!(child instanceof HTMLElement)) continue;
|
||
|
|
const tag = child.tagName.toLowerCase();
|
||
|
|
if (!tag.includes('-')) continue;
|
||
|
|
const name = child.getAttribute('name') || child.getAttribute('id') || tag;
|
||
|
|
const localMount = hostBootstrapMode && contextMount
|
||
|
|
? `${contextMount}.${name}`
|
||
|
|
: name;
|
||
|
|
inventory.push({
|
||
|
|
mount: localMount,
|
||
|
|
type: child.constructor.name !== 'HTMLElement' ? child.constructor.name : tag,
|
||
|
|
surface: 'dom',
|
||
|
|
runtimeId,
|
||
|
|
claimKind: 'local-only',
|
||
|
|
hasChildren: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return inventory;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _ensureBrowserRuntimeControl(
|
||
|
|
runtimeId: string,
|
||
|
|
runtimeMount: string,
|
||
|
|
controlMount: string,
|
||
|
|
): Promise<void> {
|
||
|
|
if (!(this._runtime instanceof MatrixRuntime)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!this._runtimeControlStartedAt) {
|
||
|
|
this._runtimeControlStartedAt = new Date().toISOString();
|
||
|
|
}
|
||
|
|
await mountBrowserRuntimeControl({
|
||
|
|
runtime: this._runtime,
|
||
|
|
runtimeId,
|
||
|
|
runtimeMount,
|
||
|
|
controlMount,
|
||
|
|
startedAt: this._runtimeControlStartedAt,
|
||
|
|
getState: (): BrowserRuntimeLifecycleState => this._initialized ? 'live' : 'closing',
|
||
|
|
getInventory: () => this._browserRuntimeInventory(
|
||
|
|
runtimeId,
|
||
|
|
this._context?.mount ?? '',
|
||
|
|
this._isHostBootstrapMode(),
|
||
|
|
),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private _runtimeInventoryVersionForHost(): number {
|
||
|
|
let version = 0;
|
||
|
|
for (const child of Array.from(this.children)) {
|
||
|
|
if (!(child instanceof HTMLElement)) continue;
|
||
|
|
const shell = matrixActorElementVersionSource(child);
|
||
|
|
const childVersion = shell._actor?.children?.version;
|
||
|
|
if (typeof childVersion === 'number' && Number.isFinite(childVersion)) {
|
||
|
|
version += childVersion;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return version;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _scheduleRuntimeInventoryRefresh(): void {
|
||
|
|
if (!this._browserRuntimeRegistryState) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (this._runtimeInventoryClaimRefreshTimer) {
|
||
|
|
clearTimeout(this._runtimeInventoryClaimRefreshTimer);
|
||
|
|
}
|
||
|
|
this._runtimeInventoryClaimRefreshTimer = setTimeout(() => {
|
||
|
|
this._runtimeInventoryClaimRefreshTimer = null;
|
||
|
|
void this._refreshRuntimeInventory();
|
||
|
|
}, 250);
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _refreshRuntimeInventory(): Promise<void> {
|
||
|
|
const state = this._browserRuntimeRegistryState;
|
||
|
|
if (!this._context || !state) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const inventory = this._browserRuntimeInventory(
|
||
|
|
state.runtimeId,
|
||
|
|
state.contextMount,
|
||
|
|
state.hostBootstrapMode,
|
||
|
|
);
|
||
|
|
const inventoryVersion = this._runtimeInventoryVersionForHost();
|
||
|
|
try {
|
||
|
|
this._runtimeInventoryVersion = inventoryVersion;
|
||
|
|
await this._replaceBrowserRuntimeClaims(state, inventory, inventoryVersion);
|
||
|
|
} catch {
|
||
|
|
// Registry lease renewal keeps liveness; the next DOM mutation retries inventory claims.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
// Root Context
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse root and federation attributes into RootContext.
|
||
|
|
*/
|
||
|
|
private _parseRootAttributes(): void {
|
||
|
|
const rawRoot = this.getAttribute('bus-root');
|
||
|
|
if (!rawRoot) return;
|
||
|
|
|
||
|
|
const baseRoot = rawRoot.toLowerCase().trim();
|
||
|
|
if (!isValidRealm(baseRoot)) {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.error(`[MatrixDSLHost] Invalid root: "${rawRoot}"`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const rawFederation = this.getAttribute('federation') || 'auto';
|
||
|
|
const federation = rawFederation.toLowerCase().trim() as FederationMode;
|
||
|
|
const validModes: FederationMode[] = ['auto', 'local', 'public', 'disabled'];
|
||
|
|
if (!validModes.includes(federation)) {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.error(`[MatrixDSLHost] Invalid federation mode: "${rawFederation}"`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let tabRoot: string | undefined;
|
||
|
|
if (federation !== 'disabled') {
|
||
|
|
tabRoot = getOrCreateTabRealm(baseRoot);
|
||
|
|
}
|
||
|
|
|
||
|
|
this._rootContext = createRootContext(baseRoot, federation, tabRoot);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Propagate RootContext to child elements that support it.
|
||
|
|
*/
|
||
|
|
private _propagateRootToChildren(): void {
|
||
|
|
const rootContext = this._rootContext;
|
||
|
|
if (!rootContext) return;
|
||
|
|
|
||
|
|
const children = this.querySelectorAll('*');
|
||
|
|
children.forEach((child: Element) => {
|
||
|
|
const contextReceiver = matrixRootContextReceiver(child);
|
||
|
|
if (typeof contextReceiver._receiveContext === 'function') {
|
||
|
|
// Only propagate root context (not a full IMatrixContext)
|
||
|
|
// MatrixActorHtmlElement._receiveContext handles this special case
|
||
|
|
contextReceiver._receiveContext({ rootContext, realmContext: rootContext });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
// Mutation Observation
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
private _setupMutationObserver(): void {
|
||
|
|
this._observer = new MutationObserver(mutations => {
|
||
|
|
for (const mutation of mutations) {
|
||
|
|
if (mutation.type === 'childList') {
|
||
|
|
for (const node of Array.from(mutation.addedNodes)) {
|
||
|
|
if (node instanceof HTMLElement) {
|
||
|
|
void this._mountChild(node).then(() => this._scheduleRuntimeInventoryRefresh());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (mutation.removedNodes.length > 0) {
|
||
|
|
this._scheduleRuntimeInventoryRefresh();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
this._observer.observe(this, { childList: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
// Loading Element Management
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Manage a sibling loading element and this host's visibility.
|
||
|
|
* Looks for element with id from `loading-el` attribute, or `#loading`.
|
||
|
|
*/
|
||
|
|
private _manageLoadingElement(status: string, error?: unknown): void {
|
||
|
|
const loadingId = this.getAttribute('loading-el') || 'loading';
|
||
|
|
const loadingEl = document.getElementById(loadingId);
|
||
|
|
|
||
|
|
if (status === 'initializing') {
|
||
|
|
if (loadingEl) loadingEl.style.display = '';
|
||
|
|
if (this._splashShownAt === null) {
|
||
|
|
this._splashShownAt = performance.now();
|
||
|
|
}
|
||
|
|
this.style.display = 'none';
|
||
|
|
} else if (status === 'connected' || status === 'local' || status === 'login-required') {
|
||
|
|
this._scheduleSplashHide(loadingEl);
|
||
|
|
this.style.display = '';
|
||
|
|
} else if (status === 'disconnected') {
|
||
|
|
// Show app in offline mode — UI loads, just no backbone
|
||
|
|
this._scheduleSplashHide(loadingEl);
|
||
|
|
this.style.display = '';
|
||
|
|
} else if (status === 'error') {
|
||
|
|
this.style.display = 'none';
|
||
|
|
if (loadingEl) {
|
||
|
|
const root = this.getAttribute('bus-root') ?? '(not set)';
|
||
|
|
const backbone = this.getAttribute('backbone') || '(none)';
|
||
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||
|
|
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||
|
|
loadingEl.outerHTML = `
|
||
|
|
<div style="max-width: 700px; margin: 40px auto; padding: 20px; background: #ffebee; border: 1px solid #f44336; border-radius: 8px; font-family: monospace; color: #c62828;">
|
||
|
|
<strong style="font-size: 16px;">Bootstrap Failed</strong>
|
||
|
|
<pre style="background: #fff0f0; padding: 12px; border-radius: 4px; overflow-x: auto; margin: 12px 0;">${esc(errMsg)}</pre>
|
||
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
||
|
|
<tr><td style="padding: 4px 8px; color: #666;">Root</td><td style="padding: 4px 8px;">${esc(root)}</td></tr>
|
||
|
|
<tr><td style="padding: 4px 8px; color: #666;">Backbone</td><td style="padding: 4px 8px;">${esc(backbone)}</td></tr>
|
||
|
|
<tr><td style="padding: 4px 8px; color: #666;">Status</td><td style="padding: 4px 8px;">${this._peer?.hasBackbone ? 'connected' : 'not connected'}</td></tr>
|
||
|
|
</table>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Hide the splash with a minimum-visible-time guard and a fade-out.
|
||
|
|
*
|
||
|
|
* Bootstrap can complete in <100ms on a warm cache, which makes the splash
|
||
|
|
* appear and disappear in a single frame — users perceive a flash rather
|
||
|
|
* than a brand frame. We enforce a 700ms minimum visible time and fade the
|
||
|
|
* last 200ms via opacity so the dismissal reads as intentional. The
|
||
|
|
* `splash-min-ms` attribute on `<matrix-dsl-host>` overrides the default;
|
||
|
|
* `splash-min-ms="0"` restores the original instant-hide behavior.
|
||
|
|
*/
|
||
|
|
private _scheduleSplashHide(loadingEl: HTMLElement | null): void {
|
||
|
|
if (!loadingEl || this._splashHideScheduled) return;
|
||
|
|
this._splashHideScheduled = true;
|
||
|
|
|
||
|
|
const minMs = (() => {
|
||
|
|
const raw = this.getAttribute('splash-min-ms');
|
||
|
|
if (raw === null) return 700;
|
||
|
|
const n = Number.parseInt(raw, 10);
|
||
|
|
return Number.isFinite(n) && n >= 0 ? n : 700;
|
||
|
|
})();
|
||
|
|
const fadeMs = Math.min(200, Math.floor(minMs / 3));
|
||
|
|
const shownAt = this._splashShownAt ?? performance.now();
|
||
|
|
const elapsed = performance.now() - shownAt;
|
||
|
|
const remaining = Math.max(0, minMs - elapsed - fadeMs);
|
||
|
|
|
||
|
|
const startFade = () => {
|
||
|
|
loadingEl.style.transition = `opacity ${fadeMs}ms ease-out`;
|
||
|
|
loadingEl.style.opacity = '0';
|
||
|
|
window.setTimeout(() => {
|
||
|
|
loadingEl.style.display = 'none';
|
||
|
|
loadingEl.style.opacity = '';
|
||
|
|
loadingEl.style.transition = '';
|
||
|
|
}, fadeMs);
|
||
|
|
};
|
||
|
|
|
||
|
|
if (remaining > 0) {
|
||
|
|
window.setTimeout(startFade, remaining);
|
||
|
|
} else {
|
||
|
|
startFade();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private _redirectToDeviceLogin(): void {
|
||
|
|
if (this.dataset.loginRedirectStarted === 'true') {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.dataset.loginRedirectStarted = 'true';
|
||
|
|
const startUrl = this.dataset.loginStartUrl || '/_auth/device-session/start';
|
||
|
|
const loginUrl = new URL(startUrl, window.location.origin);
|
||
|
|
loginUrl.searchParams.set('returnTo', `${window.location.pathname}${window.location.search}${window.location.hash}`);
|
||
|
|
window.location.assign(loginUrl.toString());
|
||
|
|
}
|
||
|
|
|
||
|
|
private _shouldRedirectToDeviceLogin(hostedAppName: string | null): boolean {
|
||
|
|
const normalizedAppName = hostedAppName?.trim().toLowerCase() ?? '';
|
||
|
|
return normalizedAppName === 'edge';
|
||
|
|
}
|
||
|
|
|
||
|
|
private _redirectToPlatformLogin(): void {
|
||
|
|
if (this.dataset.loginRedirectStarted === 'true') {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.dataset.loginRedirectStarted = 'true';
|
||
|
|
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||
|
|
const loginUrl = new URL('/apps/web/', window.location.origin);
|
||
|
|
loginUrl.hash = `login?redirect=${encodeURIComponent(returnTo)}`;
|
||
|
|
window.location.assign(loginUrl.toString());
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
// Events
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
private _fireReadyEvent(): void {
|
||
|
|
this.dispatchEvent(new CustomEvent('matrix-dsl-ready', {
|
||
|
|
bubbles: true,
|
||
|
|
detail: {
|
||
|
|
peer: this._peer,
|
||
|
|
runtime: this._runtime,
|
||
|
|
context: this._context,
|
||
|
|
root: this.getAttribute('bus-root'),
|
||
|
|
realm: this.getAttribute('bus-root'),
|
||
|
|
backbone: this.getAttribute('backbone'),
|
||
|
|
hasBackbone: this._peer?.hasBackbone ?? false,
|
||
|
|
status: this.dataset.status,
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Async runtime registry discovery — fires after full bootstrap (children already mounted).
|
||
|
|
*
|
||
|
|
* Confirms the runtime registry actor is reachable, then publishes readiness on the root
|
||
|
|
* $events topic so child apps receive it through their transport subscriptions.
|
||
|
|
* No DOM events — pure transport pub/sub so timing is deterministic.
|
||
|
|
*
|
||
|
|
* Publishes:
|
||
|
|
* { op: 'daemonReady', payload: { root } } — on success
|
||
|
|
* { op: 'daemonError', payload: { root, error } } — on failure
|
||
|
|
* { op: 'daemonReady', payload: { root: '', skipped: true } } — no explicit runtime target set
|
||
|
|
*/
|
||
|
|
private async _discoverDaemon(): Promise<void> {
|
||
|
|
if (!this._context || !this._runtime) return;
|
||
|
|
const rootCtx = this._runtime.getRootContext!();
|
||
|
|
const rootEventsTopic = TopicRouter.events('');
|
||
|
|
const daemonRoot = this.getAttribute('daemon-root') ?? this.getAttribute('daemon-realm');
|
||
|
|
|
||
|
|
if (this._isHostBootstrapMode() || this._isHostServiceBootstrap()) {
|
||
|
|
const hostRoot = daemonRoot || this.getAttribute('bus-root') || '';
|
||
|
|
if (!hostRoot) {
|
||
|
|
this.dataset.daemonStatus = 'skipped';
|
||
|
|
rootCtx.transport.publish(rootEventsTopic, {
|
||
|
|
op: 'daemonReady',
|
||
|
|
payload: { root: '', realm: '', skipped: true, hostMode: true },
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.dataset.daemonStatus = 'connected';
|
||
|
|
rootCtx.transport.publish(rootEventsTopic, {
|
||
|
|
op: 'daemonReady',
|
||
|
|
payload: { root: hostRoot, realm: hostRoot, hostMode: true },
|
||
|
|
});
|
||
|
|
rootCtx.transport.publish(rootEventsTopic, {
|
||
|
|
op: 'hostReady',
|
||
|
|
payload: { root: hostRoot },
|
||
|
|
});
|
||
|
|
void this._registerWithRuntimeRegistry(hostRoot);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!daemonRoot) {
|
||
|
|
// No explicit runtime target configured — signal immediately so children enable without waiting
|
||
|
|
this.dataset.daemonStatus = 'skipped';
|
||
|
|
rootCtx.transport.publish(rootEventsTopic, {
|
||
|
|
op: 'daemonReady',
|
||
|
|
payload: { root: '', realm: '', skipped: true },
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this._daemonDiscoveryInFlight) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const controlPlaneRoot = this._controlPlaneRootForSingletons(daemonRoot);
|
||
|
|
const runtimeRoot = transportRootForContext(this._context) ?? '(unknown)';
|
||
|
|
const natsWsUrl = this.getAttribute('nats-ws-url') ?? this.getAttribute('backbone') ?? '(none)';
|
||
|
|
// First attempt uses a shorter timeout (500ms) — if the registry is ready,
|
||
|
|
// it responds in <10ms. If it's still loading, we fail fast and retry
|
||
|
|
// rather than wasting 2.5s on the first attempt.
|
||
|
|
const firstAttemptTimeoutMs = 500;
|
||
|
|
const attemptTimeoutMs = 2_500;
|
||
|
|
const retryDelaysMs = [100, 250, 500, 1_000, 2_000] as const;
|
||
|
|
const finishDiscoveryProfile = createBrowserProfileSpan(HOST_PROFILE_SCOPE, 'daemonDiscovery', {
|
||
|
|
daemonRoot: daemonRoot ?? '',
|
||
|
|
controlPlaneRoot,
|
||
|
|
runtimeRoot,
|
||
|
|
});
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info(`[MatrixDSLHost] runtime registry discovery: targetRoot="${controlPlaneRoot}", authorityRoot="${daemonRoot}", runtimeRoot="${runtimeRoot}", natsWsUrl="${natsWsUrl}"`);
|
||
|
|
|
||
|
|
const runtimeRegistryTarget = this._runtimeRegistryTargetForRoot(controlPlaneRoot);
|
||
|
|
const discoveryTargets: Array<{
|
||
|
|
readonly target: string;
|
||
|
|
readonly kind: 'runtime-registry';
|
||
|
|
}> = [{ target: runtimeRegistryTarget, kind: 'runtime-registry' }];
|
||
|
|
const discoveryOp = '$ping';
|
||
|
|
let lastError = 'Unknown error';
|
||
|
|
this._daemonDiscoveryInFlight = true;
|
||
|
|
this._stopDaemonDiscoveryRetry();
|
||
|
|
|
||
|
|
try {
|
||
|
|
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
|
||
|
|
let attemptSucceeded = false;
|
||
|
|
for (const discoveryTarget of discoveryTargets) {
|
||
|
|
try {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info(
|
||
|
|
`[MatrixDSLHost] Sending ${discoveryOp} to "${discoveryTarget.target}" ` +
|
||
|
|
`(attempt ${attempt + 1}/${retryDelaysMs.length + 1}, timeout ${attemptTimeoutMs}ms)...`,
|
||
|
|
);
|
||
|
|
await RequestReply.execute<Record<string, unknown>, unknown>(
|
||
|
|
this._context,
|
||
|
|
discoveryTarget.target,
|
||
|
|
discoveryOp,
|
||
|
|
{},
|
||
|
|
{ timeoutMs: attempt === 0 ? firstAttemptTimeoutMs : attemptTimeoutMs },
|
||
|
|
);
|
||
|
|
|
||
|
|
this.dataset.daemonStatus = 'connected';
|
||
|
|
this._daemonDiscoveryRetryDelayMs = 1_000;
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.info(
|
||
|
|
`[MatrixDSLHost] ${discoveryOp} success — system.runtimes at "${controlPlaneRoot}" is reachable`,
|
||
|
|
);
|
||
|
|
|
||
|
|
rootCtx.transport.publish(rootEventsTopic, {
|
||
|
|
op: 'daemonReady',
|
||
|
|
payload: {
|
||
|
|
root: daemonRoot,
|
||
|
|
realm: daemonRoot,
|
||
|
|
authorityRuntime: true,
|
||
|
|
registryTarget: runtimeRegistryTarget,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// Register this browser tab as a runtime in the namespace registry.
|
||
|
|
void this._registerWithRuntimeRegistry(controlPlaneRoot);
|
||
|
|
finishDiscoveryProfile({
|
||
|
|
result: 'connected',
|
||
|
|
attempts: attempt + 1,
|
||
|
|
targetKind: discoveryTarget.kind,
|
||
|
|
});
|
||
|
|
attemptSucceeded = true;
|
||
|
|
return;
|
||
|
|
} catch (err) {
|
||
|
|
lastError = err instanceof Error ? err.message : String(err);
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.warn(
|
||
|
|
`[MatrixDSLHost] Host Service runtime discovery failed at "${discoveryTarget.target}": ${lastError}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!attemptSucceeded) {
|
||
|
|
const retryDelayMs = retryDelaysMs[attempt];
|
||
|
|
if (retryDelayMs === undefined) break;
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.warn(
|
||
|
|
`[MatrixDSLHost] Runtime registry discovery attempt ${attempt + 1}/${retryDelaysMs.length + 1} failed: ` +
|
||
|
|
`${lastError}. Retrying in ${retryDelayMs}ms...`,
|
||
|
|
);
|
||
|
|
await new Promise<void>((resolve) => setTimeout(resolve, retryDelayMs));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const backgroundRetryDelayMs = this._daemonDiscoveryRetryDelayMs;
|
||
|
|
this._daemonDiscoveryRetryDelayMs = Math.min(this._daemonDiscoveryRetryDelayMs * 2, 10_000);
|
||
|
|
this.dataset.daemonStatus = 'retrying';
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.warn(
|
||
|
|
`[MatrixDSLHost] Runtime registry discovery failed: root="${controlPlaneRoot}", authorityRoot="${daemonRoot}", runtimeRoot="${runtimeRoot}", ` +
|
||
|
|
`natsWsUrl="${natsWsUrl}", error="${lastError}". Background retry in ${backgroundRetryDelayMs}ms.`,
|
||
|
|
);
|
||
|
|
rootCtx.transport.publish(rootEventsTopic, {
|
||
|
|
op: 'daemonError',
|
||
|
|
payload: {
|
||
|
|
root: daemonRoot,
|
||
|
|
realm: daemonRoot,
|
||
|
|
error: lastError,
|
||
|
|
retryInMs: backgroundRetryDelayMs,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
this._scheduleDaemonDiscoveryRetry(backgroundRetryDelayMs);
|
||
|
|
finishDiscoveryProfile({
|
||
|
|
result: 'retrying',
|
||
|
|
retryInMs: backgroundRetryDelayMs,
|
||
|
|
});
|
||
|
|
} finally {
|
||
|
|
this._daemonDiscoveryInFlight = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Claim this browser tab's runtime and mounted child actors in system.registry.
|
||
|
|
*/
|
||
|
|
private async _registerWithRuntimeRegistry(controlPlaneRoot: string): Promise<void> {
|
||
|
|
if (!this._context || !this._runtime) return;
|
||
|
|
const registrationGeneration = this._lifecycleGeneration;
|
||
|
|
const finishRegistrationProfile = createBrowserProfileSpan(HOST_PROFILE_SCOPE, 'runtimeRegistry.claim', {
|
||
|
|
controlPlaneRoot,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!this.hasBackbone) {
|
||
|
|
finishRegistrationProfile({
|
||
|
|
result: 'skipped-no-backbone',
|
||
|
|
controlPlaneRoot,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this._shouldSkipBrowserRuntimeRegistryClaim(controlPlaneRoot)) {
|
||
|
|
finishRegistrationProfile({
|
||
|
|
result: 'skipped-no-registry-authority',
|
||
|
|
controlPlaneRoot,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const rootCtx = this._runtime.getRootContext?.();
|
||
|
|
if (!rootCtx) {
|
||
|
|
finishRegistrationProfile({ result: 'skipped-no-root-context' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const hostBootstrapMode = this._isHostBootstrapMode();
|
||
|
|
const contextMount = this._context.mount ?? '';
|
||
|
|
const runtimeRoot = transportRootForContext(rootCtx) ?? '';
|
||
|
|
const app = this.getAttribute('app-name') || document.title.split(/\s*[—–-]\s*/)[0]?.trim() || 'browser';
|
||
|
|
const registryTarget = this._serviceRegistryTargetForRoot(controlPlaneRoot);
|
||
|
|
const selectedAuthorityRoot = (this.dataset.selectedAuthorityRoot ?? '').trim();
|
||
|
|
const sessionAuthorityRoot = (this.dataset.sessionAuthorityRoot ?? '').trim();
|
||
|
|
const authorityRoot = selectedAuthorityRoot || sessionAuthorityRoot;
|
||
|
|
const runtimeInstanceRoot = (this.dataset.runtimeInstanceRoot ?? '').trim() || runtimeRoot;
|
||
|
|
const runtimeId = this.dataset.hostRuntimeId
|
||
|
|
|| this._runtimeIdFromMount(contextMount)
|
||
|
|
|| this._getOrCreateBrowserRuntimeId(authorityRoot || controlPlaneRoot || runtimeRoot, app);
|
||
|
|
this.dataset.hostRuntimeId = runtimeId;
|
||
|
|
const runtimeWireRoot = runtimeRoot || controlPlaneRoot;
|
||
|
|
const runtimeMount = `system.runtimes.${runtimeId}`;
|
||
|
|
const controlMount = runtimeMount;
|
||
|
|
const contextIsRuntimeMount = contextMount === runtimeMount || contextMount.startsWith(`${runtimeMount}.`);
|
||
|
|
const sessionMount = hostBootstrapMode
|
||
|
|
? contextIsRuntimeMount
|
||
|
|
? runtimeWireRoot || rootCtx.mount || 'unknown'
|
||
|
|
: [runtimeWireRoot || controlPlaneRoot, contextMount].filter(Boolean).join('.')
|
||
|
|
: runtimeRoot || rootCtx.mount || 'unknown';
|
||
|
|
|
||
|
|
await this._ensureBrowserRuntimeControl(runtimeId, runtimeMount, controlMount);
|
||
|
|
const inventory = this._browserRuntimeInventory(runtimeId, contextMount, hostBootstrapMode);
|
||
|
|
|
||
|
|
// Strip namespace root from sessionMount to get relative prefix for claims
|
||
|
|
const relativeMount = hostBootstrapMode && contextMount
|
||
|
|
? contextMount
|
||
|
|
: sessionMount.startsWith(controlPlaneRoot + '.')
|
||
|
|
? sessionMount.slice(controlPlaneRoot.length + 1)
|
||
|
|
: sessionMount;
|
||
|
|
const runtimeHref = typeof globalThis.location?.href === 'string'
|
||
|
|
? globalThis.location.href
|
||
|
|
: (typeof document !== 'undefined' && typeof document.baseURI === 'string' ? document.baseURI : '');
|
||
|
|
const state: IBrowserRuntimeRegistryState = {
|
||
|
|
registryTarget,
|
||
|
|
namespaceRoot: controlPlaneRoot,
|
||
|
|
runtimeId,
|
||
|
|
runtimeRoot: runtimeWireRoot,
|
||
|
|
app,
|
||
|
|
contextMount,
|
||
|
|
hostBootstrapMode,
|
||
|
|
sessionMount,
|
||
|
|
relativeMount,
|
||
|
|
runtimeMount,
|
||
|
|
controlMount,
|
||
|
|
authorityRoot,
|
||
|
|
sessionAuthorityRoot,
|
||
|
|
selectedAuthorityRoot,
|
||
|
|
runtimeInstanceRoot,
|
||
|
|
runtimeHref,
|
||
|
|
registeredAt: Date.now(),
|
||
|
|
};
|
||
|
|
|
||
|
|
try {
|
||
|
|
this._runtimeInventoryVersion = this._runtimeInventoryVersionForHost();
|
||
|
|
await this._replaceBrowserRuntimeClaims(state, inventory, this._runtimeInventoryVersion);
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap
|
||
|
|
console.info(`[MatrixDSLHost] Claimed browser runtime "${runtimeId}" in system.registry`);
|
||
|
|
if (registrationGeneration !== this._lifecycleGeneration || !this._context || !this._runtime) {
|
||
|
|
finishRegistrationProfile({
|
||
|
|
result: 'skipped-after-cleanup',
|
||
|
|
runtimeId,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this._browserRuntimeRegistryState = state;
|
||
|
|
this._runtimeRegistryId = runtimeId;
|
||
|
|
this._publishBrowserRuntimePresence(state, inventory, 'announce');
|
||
|
|
finishRegistrationProfile({
|
||
|
|
result: 'ok',
|
||
|
|
runtimeId,
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap
|
||
|
|
console.warn(`[MatrixDSLHost] Browser runtime registry claim failed (non-fatal):`, err);
|
||
|
|
finishRegistrationProfile({
|
||
|
|
result: 'error',
|
||
|
|
error: err instanceof Error ? err.message : String(err),
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this._browserRuntimeRenewTimer = setInterval(() => {
|
||
|
|
const currentState = this._browserRuntimeRegistryState;
|
||
|
|
if (!currentState || !this._context) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
RequestReply.execute(
|
||
|
|
this._context,
|
||
|
|
currentState.registryTarget,
|
||
|
|
'registry.claim.renew',
|
||
|
|
{
|
||
|
|
providerRuntimeId: currentState.runtimeId,
|
||
|
|
ttlMs: 45_000,
|
||
|
|
health: { status: 'ok' },
|
||
|
|
},
|
||
|
|
{ timeoutMs: 5000 },
|
||
|
|
).catch(() => { /* lease renewal failure is non-fatal */ });
|
||
|
|
this._publishBrowserRuntimePresence(
|
||
|
|
currentState,
|
||
|
|
this._browserRuntimeInventory(
|
||
|
|
currentState.runtimeId,
|
||
|
|
currentState.contextMount,
|
||
|
|
currentState.hostBootstrapMode,
|
||
|
|
),
|
||
|
|
'heartbeat',
|
||
|
|
);
|
||
|
|
}, 15_000);
|
||
|
|
|
||
|
|
this._browserRuntimeReleaseHandler = () => {
|
||
|
|
const context = this._context;
|
||
|
|
const currentState = this._browserRuntimeRegistryState;
|
||
|
|
if (!context || !currentState) return;
|
||
|
|
try {
|
||
|
|
RequestReply.sendToInbox(context, currentState.registryTarget, 'registry.release', {
|
||
|
|
providerRuntimeId: currentState.runtimeId,
|
||
|
|
});
|
||
|
|
} catch {
|
||
|
|
// Best-effort unload cleanup; the registry lease expires missed releases.
|
||
|
|
}
|
||
|
|
this._publishBrowserRuntimePresence(
|
||
|
|
currentState,
|
||
|
|
this._browserRuntimeInventory(
|
||
|
|
currentState.runtimeId,
|
||
|
|
currentState.contextMount,
|
||
|
|
currentState.hostBootstrapMode,
|
||
|
|
),
|
||
|
|
'departed',
|
||
|
|
);
|
||
|
|
};
|
||
|
|
window.addEventListener('beforeunload', this._browserRuntimeReleaseHandler);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Publish browser runtime presence to the runtime-directory authority
|
||
|
|
* (`system.runtimes`). Browser tabs claim child actors in `system.registry`
|
||
|
|
* via `registry.claim`, but `system.runtimes.runtimes.list` is fed by
|
||
|
|
* runtime presence — without this call, browser tabs are invisible to
|
||
|
|
* Director Topology, Host Ops Workloads, and any other consumer of the
|
||
|
|
* runtime directory.
|
||
|
|
*
|
||
|
|
* We route through the explicit `runtimes.presence.update` op via
|
||
|
|
* `RequestReply.execute("${controlPlaneRoot}/system.runtimes", ...)`, NOT through
|
||
|
|
* `publishRuntimePresence(ctx, record, event)`. The pub/sub path uses bare
|
||
|
|
* `$runtime.*` topics on the local transport — in federated HiveCast mode
|
||
|
|
* the tab realm is a synthetic per-tab root (`s-b-root-*`) so the publish
|
||
|
|
* lands on a NATS subject prefix the `RuntimeManagerActor` at the
|
||
|
|
* authority root never subscribes to. Routed RequestReply.execute uses
|
||
|
|
* the same federation-peer cross-routing that `registry.claim` already
|
||
|
|
* relies on.
|
||
|
|
*
|
||
|
|
* Best-effort: presence publication failures are non-fatal because the
|
||
|
|
* registry claim already carries the authoritative liveness of the tab.
|
||
|
|
*/
|
||
|
|
private _publishBrowserRuntimePresence(
|
||
|
|
state: IBrowserRuntimeRegistryState,
|
||
|
|
inventory: readonly IBrowserRuntimeInventoryEntry[],
|
||
|
|
event: RuntimePresenceEventV1,
|
||
|
|
): void {
|
||
|
|
const context = this._context;
|
||
|
|
if (!context) return;
|
||
|
|
try {
|
||
|
|
const presenceInventory = mapBrowserInventoryToPresence(inventory);
|
||
|
|
const now = Date.now();
|
||
|
|
const record: RuntimePresenceRecordV1 = {
|
||
|
|
version: '1',
|
||
|
|
namespaceRoot: state.namespaceRoot,
|
||
|
|
runtimeId: state.runtimeId,
|
||
|
|
runtimeType: 'browser',
|
||
|
|
runtimeRoot: state.runtimeRoot,
|
||
|
|
...(state.authorityRoot.length > 0 ? { authorityRoot: state.authorityRoot } : {}),
|
||
|
|
app: state.app,
|
||
|
|
claims: [{ prefix: state.runtimeMount, kind: 'local-only' }],
|
||
|
|
inventoryMode: 'embedded',
|
||
|
|
...(presenceInventory.length > 0 ? { inventory: presenceInventory } : {}),
|
||
|
|
controlMount: state.controlMount,
|
||
|
|
heartbeatTtlMs: 45_000,
|
||
|
|
registeredAt: state.registeredAt,
|
||
|
|
lastHeartbeat: now,
|
||
|
|
health: { status: 'ok', updatedAt: now },
|
||
|
|
metadata: {
|
||
|
|
source: 'browser-runtime',
|
||
|
|
surface: 'webapp',
|
||
|
|
ownerKind: 'client-session',
|
||
|
|
packageRef: state.app,
|
||
|
|
runtimeInstanceRoot: state.runtimeInstanceRoot,
|
||
|
|
// Render as "<App Name> (browser)" so it's distinguishable from
|
||
|
|
// the server-side runtime sharing the same packageRef.
|
||
|
|
displayName: `${state.app} (browser)`,
|
||
|
|
...(state.sessionAuthorityRoot.length > 0 ? { sessionAuthorityRoot: state.sessionAuthorityRoot } : {}),
|
||
|
|
...(state.selectedAuthorityRoot.length > 0 ? { selectedAuthorityRoot: state.selectedAuthorityRoot } : {}),
|
||
|
|
...(state.runtimeHref.length > 0 ? { href: state.runtimeHref.slice(0, 200) } : {}),
|
||
|
|
...(typeof navigator !== 'undefined' ? { userAgent: navigator.userAgent.slice(0, 120) } : {}),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const target = this._runtimeRegistryTargetForRoot(state.namespaceRoot);
|
||
|
|
RequestReply.execute(
|
||
|
|
context,
|
||
|
|
target,
|
||
|
|
'runtimes.presence.update',
|
||
|
|
{ record, event },
|
||
|
|
{ timeoutMs: 5000 },
|
||
|
|
).catch((err) => {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap
|
||
|
|
console.warn(`[MatrixDSLHost] Browser runtime presence (${event}) failed (non-fatal):`, err);
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap
|
||
|
|
console.warn(`[MatrixDSLHost] Browser runtime presence publish (${event}) failed (non-fatal):`, err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _replaceBrowserRuntimeClaims(
|
||
|
|
state: IBrowserRuntimeRegistryState,
|
||
|
|
inventory: readonly IBrowserRuntimeInventoryEntry[],
|
||
|
|
inventoryVersion: number,
|
||
|
|
): Promise<void> {
|
||
|
|
if (!this._context) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const nextComponentIds = new Set(inventory.map((entry) => browserInventoryComponentId(state, entry)));
|
||
|
|
for (const entry of inventory) {
|
||
|
|
await this._claimBrowserRuntimeEntry(state, entry, inventory.length, inventoryVersion);
|
||
|
|
}
|
||
|
|
for (const componentId of this._browserRuntimeClaimedComponentIds) {
|
||
|
|
if (nextComponentIds.has(componentId)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
await RequestReply.execute(
|
||
|
|
this._context,
|
||
|
|
state.registryTarget,
|
||
|
|
'registry.release',
|
||
|
|
{
|
||
|
|
componentId,
|
||
|
|
providerRuntimeId: state.runtimeId,
|
||
|
|
},
|
||
|
|
{ timeoutMs: 5000 },
|
||
|
|
);
|
||
|
|
}
|
||
|
|
this._browserRuntimeClaimedComponentIds = nextComponentIds;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _claimBrowserRuntimeEntry(
|
||
|
|
state: IBrowserRuntimeRegistryState,
|
||
|
|
entry: IBrowserRuntimeInventoryEntry,
|
||
|
|
actorCount: number,
|
||
|
|
inventoryVersion: number,
|
||
|
|
): Promise<void> {
|
||
|
|
if (!this._context) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const kind = browserInventoryRegistryKind(entry);
|
||
|
|
await RequestReply.execute(
|
||
|
|
this._context,
|
||
|
|
state.registryTarget,
|
||
|
|
'registry.claim',
|
||
|
|
{
|
||
|
|
namespaceRoot: state.namespaceRoot,
|
||
|
|
mount: entry.mount,
|
||
|
|
kind,
|
||
|
|
componentId: browserInventoryComponentId(state, entry),
|
||
|
|
componentType: entry.type,
|
||
|
|
providerRuntimeId: state.runtimeId,
|
||
|
|
runtimeWireRoot: state.runtimeRoot,
|
||
|
|
localMount: entry.mount,
|
||
|
|
packageName: state.app,
|
||
|
|
runtimeId: state.runtimeId,
|
||
|
|
packageId: state.app,
|
||
|
|
mode: 'local-only',
|
||
|
|
ttlMs: 45_000,
|
||
|
|
health: { status: 'ok' },
|
||
|
|
metadata: {
|
||
|
|
source: 'browser-runtime-registry-claim',
|
||
|
|
runtimeType: 'browser',
|
||
|
|
ownerKind: 'client-session',
|
||
|
|
runtimeRoot: state.runtimeRoot,
|
||
|
|
runtimeInstanceRoot: state.runtimeInstanceRoot,
|
||
|
|
sessionMount: state.sessionMount,
|
||
|
|
relativeMount: state.relativeMount,
|
||
|
|
controlMount: state.controlMount,
|
||
|
|
actorCount,
|
||
|
|
inventoryVersion,
|
||
|
|
surface: entry.surface,
|
||
|
|
...(entry.description ? { description: entry.description } : {}),
|
||
|
|
...(state.authorityRoot.length > 0 ? {
|
||
|
|
addressRoot: state.authorityRoot,
|
||
|
|
authorityRoot: state.authorityRoot,
|
||
|
|
} : {}),
|
||
|
|
...(state.sessionAuthorityRoot.length > 0 ? { sessionAuthorityRoot: state.sessionAuthorityRoot } : {}),
|
||
|
|
...(state.selectedAuthorityRoot.length > 0 ? { selectedAuthorityRoot: state.selectedAuthorityRoot } : {}),
|
||
|
|
...(state.runtimeHref.length > 0 ? { href: state.runtimeHref.slice(0, 200) } : {}),
|
||
|
|
...(typeof navigator !== 'undefined' ? { userAgent: navigator.userAgent.slice(0, 120) } : {}),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{ timeoutMs: 5000 },
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _runtimeRegistryTargetForRoot(root: string): string {
|
||
|
|
const trimmed = root.trim();
|
||
|
|
return trimmed ? `${trimmed}/system.runtimes` : 'system.runtimes';
|
||
|
|
}
|
||
|
|
|
||
|
|
private _serviceRegistryTargetForRoot(root: string): string {
|
||
|
|
const trimmed = root.trim();
|
||
|
|
return trimmed ? `${trimmed}/system.registry` : 'system.registry';
|
||
|
|
}
|
||
|
|
|
||
|
|
private _shouldSkipBrowserRuntimeRegistryClaim(controlPlaneRoot: string): boolean {
|
||
|
|
if (this.dataset.authorityCoordinatorActive === 'false') {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const targetRoot = controlPlaneRoot.trim();
|
||
|
|
const platformRoot = (
|
||
|
|
this.dataset.platformServiceRoot
|
||
|
|
?? this.dataset.platformRoot
|
||
|
|
?? ''
|
||
|
|
).trim();
|
||
|
|
if (!targetRoot || !platformRoot || targetRoot !== platformRoot) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (this.dataset.isPlatformAdmin === 'true') {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (this.dataset.authenticated !== 'true' && this._isAuthenticated !== true) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const candidateAuthorityRoots = [
|
||
|
|
this.dataset.selectedAuthorityRoot,
|
||
|
|
this.dataset.sessionAuthorityRoot,
|
||
|
|
this.dataset.principalAuthorityRoot,
|
||
|
|
].map((root) => (root ?? '').trim());
|
||
|
|
return !candidateAuthorityRoots.some((root) => root.length > 0 && root !== platformRoot);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _controlPlaneRootForSingletons(authorityRoot: string | null | undefined): string {
|
||
|
|
const selectedRoot = typeof this.dataset.selectedAuthorityRoot === 'string'
|
||
|
|
? this.dataset.selectedAuthorityRoot.trim()
|
||
|
|
: '';
|
||
|
|
if (selectedRoot) {
|
||
|
|
return selectedRoot;
|
||
|
|
}
|
||
|
|
const principalRoot = typeof this.dataset.principalAuthorityRoot === 'string'
|
||
|
|
? this.dataset.principalAuthorityRoot.trim()
|
||
|
|
: '';
|
||
|
|
if (principalRoot) {
|
||
|
|
return principalRoot;
|
||
|
|
}
|
||
|
|
return typeof authorityRoot === 'string' ? authorityRoot.trim() : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
private _runtimeIdFromMount(mount: string): string {
|
||
|
|
const parts = mount.split('.').filter(Boolean);
|
||
|
|
return parts.find((part) => part.startsWith(BROWSER_RUNTIME_ID_PREFIX)) ?? '';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Phase B bus discovery resolves identity and service metadata after first render.
|
||
|
|
*
|
||
|
|
* This must not block child mounting. Apps already tolerate late identity
|
||
|
|
* attributes on the host and should render while the bus discovery completes.
|
||
|
|
*/
|
||
|
|
private async _runPhaseBBusDiscovery(usedBootstrap: boolean): Promise<void> {
|
||
|
|
if (!usedBootstrap || !this._context || !this._transport || !this._runtime) return;
|
||
|
|
const finishPhaseBProfile = createBrowserProfileSpan(HOST_PROFILE_SCOPE, 'phaseB.discovery', {
|
||
|
|
usedBootstrap,
|
||
|
|
});
|
||
|
|
|
||
|
|
const daemonRoot = this.getAttribute('daemon-root') ?? '';
|
||
|
|
const serviceRoot = this.dataset.platformServiceRoot ?? this.dataset.platformRoot ?? daemonRoot;
|
||
|
|
const explicitAuthorityRoot = this.dataset.explicitAuthorityRoot;
|
||
|
|
if (explicitAuthorityRoot) {
|
||
|
|
// Explicit authority URLs already declare the target identity in the
|
||
|
|
// path. Do not probe platform-local actors such as system.auth or
|
||
|
|
// system.directory here; that turns a shareable user URL into a hidden
|
||
|
|
// HiveCast-local lookup and can wait on unrelated platform timeouts.
|
||
|
|
console.info('[MatrixDSLHost] Phase B platform probe skipped for explicit authority URL - retaining explicit authority root');
|
||
|
|
finishPhaseBProfile({
|
||
|
|
result: 'skipped-explicit-authority',
|
||
|
|
daemonRoot,
|
||
|
|
serviceRoot,
|
||
|
|
authorityRoot: explicitAuthorityRoot,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const authTarget = serviceRoot ? `${serviceRoot}/system.auth` : 'system.auth';
|
||
|
|
const dirTarget = serviceRoot ? `${serviceRoot}/system.directory` : 'system.directory';
|
||
|
|
const busTokenPayload = this.dataset.busToken ? { busToken: this.dataset.busToken } : {};
|
||
|
|
const rootCtx = this._runtime.getRootContext!();
|
||
|
|
const bootstrapAuthenticated = (this.dataset.authenticated === 'true') || this._isAuthenticated === true;
|
||
|
|
if (!bootstrapAuthenticated && !this.dataset.busToken) {
|
||
|
|
console.info('[MatrixDSLHost] Phase B platform probe skipped for anonymous bootstrap');
|
||
|
|
finishPhaseBProfile({
|
||
|
|
result: 'skipped-anonymous',
|
||
|
|
daemonRoot,
|
||
|
|
serviceRoot,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const isHostedLeafTarget = this.dataset.runtimeTargetKind === 'leaf'
|
||
|
|
&& daemonRoot.length > 0
|
||
|
|
&& serviceRoot.length > 0
|
||
|
|
&& daemonRoot !== serviceRoot;
|
||
|
|
const selectedAuthorityRoot = (this.dataset.selectedAuthorityRoot ?? '').trim();
|
||
|
|
const userAuthorityRoot = selectedAuthorityRoot
|
||
|
|
|| (this.dataset.principalAuthorityRoot ?? '').trim();
|
||
|
|
const isPlatformAdmin = this.dataset.isPlatformAdmin === 'true';
|
||
|
|
const isNormalUserAuthoritySession = bootstrapAuthenticated
|
||
|
|
&& userAuthorityRoot.length > 0
|
||
|
|
&& serviceRoot.length > 0
|
||
|
|
&& userAuthorityRoot !== serviceRoot
|
||
|
|
&& !isPlatformAdmin;
|
||
|
|
|
||
|
|
if (isNormalUserAuthoritySession) {
|
||
|
|
console.info('[MatrixDSLHost] Phase B platform probe skipped for user authority session — retaining bootstrap identity');
|
||
|
|
finishPhaseBProfile({
|
||
|
|
result: 'skipped-user-authority',
|
||
|
|
daemonRoot,
|
||
|
|
serviceRoot,
|
||
|
|
authorityRoot: userAuthorityRoot,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
bootstrapAuthenticated
|
||
|
|
&& (
|
||
|
|
this._isHostBootstrapMode()
|
||
|
|
|| this._isHostServiceBootstrap()
|
||
|
|
|| this._shouldRetainBootstrapIdentity(daemonRoot)
|
||
|
|
|| this._shouldUseRuntimeRegistryDiscovery(daemonRoot)
|
||
|
|
)
|
||
|
|
) {
|
||
|
|
console.info('[MatrixDSLHost] Phase B service probe skipped for Host bootstrap — retaining bootstrap identity');
|
||
|
|
finishPhaseBProfile({
|
||
|
|
result: 'skipped-host-bootstrap',
|
||
|
|
daemonRoot,
|
||
|
|
serviceRoot,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (bootstrapAuthenticated && isHostedLeafTarget) {
|
||
|
|
// Leaf-targeted hosted apps already have HTTP-session identity from
|
||
|
|
// bootstrap. The browser transport is scoped to the leaf account, so a
|
||
|
|
// platform Phase B RR would require a cross-account reply path and only
|
||
|
|
// produces a non-fatal timeout.
|
||
|
|
console.info('[MatrixDSLHost] Phase B platform probe skipped for hosted leaf runtime — retaining bootstrap identity');
|
||
|
|
finishPhaseBProfile({
|
||
|
|
result: 'skipped-leaf-runtime',
|
||
|
|
daemonRoot,
|
||
|
|
serviceRoot,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Phase B: auth + directory in PARALLEL (saves ~400ms vs sequential)
|
||
|
|
const authPromise = this._executePhaseBRequest<Record<string, unknown>, {
|
||
|
|
ok: boolean; principalId?: string; displayName?: string; root?: string; tier?: string; authenticated?: boolean;
|
||
|
|
}>(authTarget, 'auth.whoami', busTokenPayload).catch((err) => {
|
||
|
|
if (bootstrapAuthenticated) {
|
||
|
|
console.info('[MatrixDSLHost] Phase B auth probe deferred — retaining bootstrap identity:', err);
|
||
|
|
} else {
|
||
|
|
console.warn('[MatrixDSLHost] Phase B auth probe failed:', err);
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
});
|
||
|
|
|
||
|
|
const dirPromise = this._executePhaseBRequest<Record<string, unknown>, {
|
||
|
|
ok: boolean; root?: string; services?: Array<{ name: string; mount: string }>;
|
||
|
|
parentRoots?: unknown[];
|
||
|
|
}>(dirTarget, 'directory.whoami', busTokenPayload).catch((err) => {
|
||
|
|
console.info('[MatrixDSLHost] Phase B directory probe deferred (non-fatal):', err);
|
||
|
|
return null;
|
||
|
|
});
|
||
|
|
|
||
|
|
const [authResult, dirResult] = await Promise.all([authPromise, dirPromise]);
|
||
|
|
|
||
|
|
if (authResult?.ok) {
|
||
|
|
console.info(`[MatrixDSLHost] Phase B: ${authTarget} → principalId="${authResult.principalId}", authenticated=${authResult.authenticated}, root="${authResult.root}"`);
|
||
|
|
if (authResult.principalId) {
|
||
|
|
this.dataset.principalId = authResult.principalId;
|
||
|
|
rootCtx.setService('principal-id', authResult.principalId);
|
||
|
|
}
|
||
|
|
const orgRoot = this.dataset.platformRoot || serviceRoot || authResult.root || '';
|
||
|
|
if (orgRoot) {
|
||
|
|
this.dataset.orgId = orgRoot;
|
||
|
|
rootCtx.setService('org-id', orgRoot);
|
||
|
|
}
|
||
|
|
if (authResult.root) {
|
||
|
|
this.dataset.workspaceId = authResult.root;
|
||
|
|
rootCtx.setService('workspace-id', authResult.root);
|
||
|
|
}
|
||
|
|
if (authResult.displayName) this.dataset.displayName = authResult.displayName;
|
||
|
|
this.dataset.authenticated = String(authResult.authenticated ?? false);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (dirResult?.ok) {
|
||
|
|
console.info(`[MatrixDSLHost] Phase B: ${dirTarget} → root="${dirResult.root}", services=${dirResult.services?.length ?? 0}`);
|
||
|
|
if (dirResult.parentRoots) {
|
||
|
|
this.setAttribute('parent-roots', JSON.stringify(dirResult.parentRoots));
|
||
|
|
this.setAttribute('parent-realms', JSON.stringify(dirResult.parentRoots));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.dataset.busToken) {
|
||
|
|
this._startBusTokenRefresh(authTarget);
|
||
|
|
}
|
||
|
|
finishPhaseBProfile({
|
||
|
|
authOk: authResult?.ok === true,
|
||
|
|
dirOk: dirResult?.ok === true,
|
||
|
|
daemonRoot,
|
||
|
|
serviceRoot,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private async _executePhaseBRequest<TPayload extends Record<string, unknown>, TResult>(
|
||
|
|
target: string,
|
||
|
|
op: string,
|
||
|
|
payload: TPayload,
|
||
|
|
): Promise<TResult> {
|
||
|
|
const retryDelaysMs = [0, 200, 500] as const;
|
||
|
|
let lastError: unknown;
|
||
|
|
|
||
|
|
for (const delayMs of retryDelaysMs) {
|
||
|
|
if (delayMs > 0) {
|
||
|
|
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
return await RequestReply.execute<TPayload, TResult>(
|
||
|
|
this._context!,
|
||
|
|
target,
|
||
|
|
op,
|
||
|
|
payload,
|
||
|
|
{ timeoutMs: 5000 },
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
lastError = error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
throw lastError instanceof Error ? lastError : new Error(`Phase B request failed: ${String(lastError)}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
// Cleanup
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Start a 4-minute interval to refresh the bus token before its 5-min expiry.
|
||
|
|
* Calls system.auth auth.refresh on the bus with the current token.
|
||
|
|
*/
|
||
|
|
private _startBusTokenRefresh(authTarget: string): void {
|
||
|
|
this._stopBusTokenRefresh();
|
||
|
|
if (this._shouldSkipBusTokenRefreshForTarget(authTarget)) {
|
||
|
|
console.info('[MatrixDSLHost] Bus token refresh skipped for platform auth target in user authority session');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const REFRESH_INTERVAL_MS = 4 * 60 * 1000; // 4 minutes
|
||
|
|
this._busTokenRefreshTimer = setInterval(async () => {
|
||
|
|
const currentToken = this.dataset.busToken;
|
||
|
|
if (!currentToken || !this._context) {
|
||
|
|
this._stopBusTokenRefresh();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const result = await RequestReply.execute<Record<string, unknown>, {
|
||
|
|
ok: boolean; busToken?: string; expiresAt?: number; principalId?: string;
|
||
|
|
}>(this._context, authTarget, 'auth.refresh', { busToken: currentToken }, { timeoutMs: 5000 });
|
||
|
|
if (result?.ok && result.busToken) {
|
||
|
|
this.dataset.busToken = result.busToken;
|
||
|
|
this._context.setService('bus-token', result.busToken);
|
||
|
|
this._installScopedActorServices(this._context, this.dataset.runtimeRoot ?? this.dataset.sessionRoot ?? null);
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.debug(`[MatrixDSLHost] Bus token refreshed for ${result.principalId}`);
|
||
|
|
} else {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.warn('[MatrixDSLHost] Bus token refresh failed — clearing token');
|
||
|
|
delete this.dataset.busToken;
|
||
|
|
this.dataset.authenticated = 'false';
|
||
|
|
this._stopBusTokenRefresh();
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
// eslint-disable-next-line no-console -- browser bootstrap, not an actor
|
||
|
|
console.warn('[MatrixDSLHost] Bus token refresh error:', err);
|
||
|
|
}
|
||
|
|
}, REFRESH_INTERVAL_MS);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _shouldSkipBusTokenRefreshForTarget(authTarget: string): boolean {
|
||
|
|
const targetRoot = authTarget.includes('/')
|
||
|
|
? authTarget.slice(0, authTarget.indexOf('/')).trim()
|
||
|
|
: '';
|
||
|
|
if (!targetRoot) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const userAuthorityRoot = (this.dataset.selectedAuthorityRoot ?? '').trim()
|
||
|
|
|| (this.dataset.principalAuthorityRoot ?? '').trim();
|
||
|
|
return (this.dataset.authenticated === 'true' || this._isAuthenticated === true)
|
||
|
|
&& this.dataset.isPlatformAdmin !== 'true'
|
||
|
|
&& userAuthorityRoot.length > 0
|
||
|
|
&& targetRoot !== userAuthorityRoot;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _installScopedActorServices(context: IMatrixContext, runtimeInstanceRoot: string | null): void {
|
||
|
|
const platformRoot = (
|
||
|
|
this.dataset.platformServiceRoot
|
||
|
|
?? this.dataset.platformRoot
|
||
|
|
?? ''
|
||
|
|
).trim();
|
||
|
|
const userSpaceRoot = (
|
||
|
|
this.dataset.selectedAuthorityRoot
|
||
|
|
?? this.dataset.sessionAuthorityRoot
|
||
|
|
?? this.dataset.principalAuthorityRoot
|
||
|
|
?? ''
|
||
|
|
).trim();
|
||
|
|
const accountRoot = (
|
||
|
|
this.dataset.sessionAuthorityRoot
|
||
|
|
?? this.dataset.principalAuthorityRoot
|
||
|
|
?? this.dataset.selectedAuthorityRoot
|
||
|
|
?? ''
|
||
|
|
).trim();
|
||
|
|
const addressContext: RuntimeAddressContext = {
|
||
|
|
runtimeInstanceRoot: runtimeInstanceRoot && runtimeInstanceRoot.trim().length > 0
|
||
|
|
? runtimeInstanceRoot.trim()
|
||
|
|
: null,
|
||
|
|
accountRoot: accountRoot.length > 0 ? accountRoot : null,
|
||
|
|
userSpaceRoot: userSpaceRoot.length > 0 ? userSpaceRoot : null,
|
||
|
|
platformRoot,
|
||
|
|
isPlatformAdmin: this.dataset.isPlatformAdmin === 'true',
|
||
|
|
principalId: this.dataset.principalId ?? null,
|
||
|
|
busToken: this.dataset.busToken ?? null,
|
||
|
|
};
|
||
|
|
const callClient = new ActorCallClient(context, addressContext);
|
||
|
|
const resolver = new ScopedIntentResolver(callClient, WELL_KNOWN_SERVICE_BINDINGS);
|
||
|
|
const services = new ActorServices(resolver, addressContext);
|
||
|
|
context.setService(RUNTIME_ADDRESS_CONTEXT, addressContext);
|
||
|
|
context.setService(ACTOR_CALL_CLIENT, callClient);
|
||
|
|
context.setService(SCOPED_INTENT_RESOLVER, resolver);
|
||
|
|
context.setService(ACTOR_SERVICES, services);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _stopBusTokenRefresh(): void {
|
||
|
|
if (this._busTokenRefreshTimer) {
|
||
|
|
clearInterval(this._busTokenRefreshTimer);
|
||
|
|
this._busTokenRefreshTimer = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private _startHostedRuntimeTargetRefresh(): void {
|
||
|
|
this._stopHostedRuntimeTargetRefresh();
|
||
|
|
this._hostedRuntimeTargetRefreshTimer = setInterval(() => {
|
||
|
|
void this._refreshHostedRuntimeTargetMetadata();
|
||
|
|
}, HOSTED_RUNTIME_TARGET_REFRESH_INTERVAL_MS);
|
||
|
|
}
|
||
|
|
|
||
|
|
private _stopHostedRuntimeTargetRefresh(): void {
|
||
|
|
if (this._hostedRuntimeTargetRefreshTimer) {
|
||
|
|
clearInterval(this._hostedRuntimeTargetRefreshTimer);
|
||
|
|
this._hostedRuntimeTargetRefreshTimer = null;
|
||
|
|
}
|
||
|
|
this._hostedRuntimeTargetRefreshInFlight = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _scheduleDaemonDiscoveryRetry(delayMs: number): void {
|
||
|
|
if (this._daemonDiscoveryRetryTimer || !this._context || !this._runtime) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const retryTimer = setTimeout(() => {
|
||
|
|
this._daemonDiscoveryRetryTimer = null;
|
||
|
|
void this._discoverDaemon();
|
||
|
|
}, delayMs);
|
||
|
|
if (typeof retryTimer === 'object' && retryTimer !== null && 'unref' in retryTimer) {
|
||
|
|
const unref = retryTimer.unref;
|
||
|
|
if (typeof unref === 'function') {
|
||
|
|
unref.call(retryTimer);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this._daemonDiscoveryRetryTimer = retryTimer;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _stopDaemonDiscoveryRetry(): void {
|
||
|
|
if (this._daemonDiscoveryRetryTimer) {
|
||
|
|
clearTimeout(this._daemonDiscoveryRetryTimer);
|
||
|
|
this._daemonDiscoveryRetryTimer = null;
|
||
|
|
}
|
||
|
|
this._daemonDiscoveryRetryDelayMs = 1_000;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _cleanup(): void {
|
||
|
|
this._lifecycleGeneration += 1;
|
||
|
|
const transportToDisconnect = this._transport;
|
||
|
|
this._stopBusTokenRefresh();
|
||
|
|
this._stopHostedRuntimeTargetRefresh();
|
||
|
|
this._stopDaemonDiscoveryRetry();
|
||
|
|
delete this.dataset.sessionRoot;
|
||
|
|
delete this.dataset.runtimeRoot;
|
||
|
|
delete this.dataset.daemonStatus;
|
||
|
|
if (this._observer) {
|
||
|
|
this._observer.disconnect();
|
||
|
|
this._observer = null;
|
||
|
|
}
|
||
|
|
if (transportToDisconnect) {
|
||
|
|
void transportToDisconnect.disconnect().catch((error) => {
|
||
|
|
// eslint-disable-next-line no-console -- browser host teardown has no actor context
|
||
|
|
console.warn('[MatrixDSLHost] Transport disconnect during cleanup failed:', error);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (this._joinUnsub) {
|
||
|
|
this._joinUnsub();
|
||
|
|
this._joinUnsub = null;
|
||
|
|
}
|
||
|
|
if (this._browserRuntimeRenewTimer) {
|
||
|
|
clearInterval(this._browserRuntimeRenewTimer);
|
||
|
|
this._browserRuntimeRenewTimer = null;
|
||
|
|
}
|
||
|
|
if (this._runtimeInventoryClaimRefreshTimer) {
|
||
|
|
clearTimeout(this._runtimeInventoryClaimRefreshTimer);
|
||
|
|
this._runtimeInventoryClaimRefreshTimer = null;
|
||
|
|
}
|
||
|
|
if (this._browserRuntimeRegistryState && this._runtime) {
|
||
|
|
this._publishBrowserRuntimePresence(
|
||
|
|
this._browserRuntimeRegistryState,
|
||
|
|
this._browserRuntimeInventory(
|
||
|
|
this._browserRuntimeRegistryState.runtimeId,
|
||
|
|
this._browserRuntimeRegistryState.contextMount,
|
||
|
|
this._browserRuntimeRegistryState.hostBootstrapMode,
|
||
|
|
),
|
||
|
|
'departed',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
this._browserRuntimeRegistryState = null;
|
||
|
|
this._browserRuntimeClaimedComponentIds.clear();
|
||
|
|
this._runtimeRegistryId = '';
|
||
|
|
this._runtimeControlStartedAt = '';
|
||
|
|
if (this._browserRuntimeReleaseHandler) {
|
||
|
|
if (typeof window !== 'undefined') {
|
||
|
|
window.removeEventListener('beforeunload', this._browserRuntimeReleaseHandler);
|
||
|
|
}
|
||
|
|
this._browserRuntimeReleaseHandler = null;
|
||
|
|
}
|
||
|
|
if (this._peer) {
|
||
|
|
this._peer.stop();
|
||
|
|
this._peer = null;
|
||
|
|
}
|
||
|
|
this._context = null;
|
||
|
|
this._runtime = null;
|
||
|
|
this._transport = null;
|
||
|
|
this._rawAdapter = null;
|
||
|
|
this._routingPeer = null;
|
||
|
|
this._wrapTransport = null;
|
||
|
|
this._initialized = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const ROUTE_KEY_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
||
|
|
const RESERVED_ROUTE_KEYS = new Set([
|
||
|
|
'api',
|
||
|
|
'auth',
|
||
|
|
'login',
|
||
|
|
'logout',
|
||
|
|
'signup',
|
||
|
|
'setup',
|
||
|
|
'settings',
|
||
|
|
'apps',
|
||
|
|
'app',
|
||
|
|
'assets',
|
||
|
|
'static',
|
||
|
|
'branding',
|
||
|
|
'nats-ws',
|
||
|
|
'matrix',
|
||
|
|
'system',
|
||
|
|
'admin',
|
||
|
|
'root',
|
||
|
|
'support',
|
||
|
|
'docs',
|
||
|
|
'help',
|
||
|
|
'billing',
|
||
|
|
'status',
|
||
|
|
'healthz',
|
||
|
|
'well-known',
|
||
|
|
'favicon.ico',
|
||
|
|
'manifest.json',
|
||
|
|
'hivecast',
|
||
|
|
'open-matrix',
|
||
|
|
'me',
|
||
|
|
]);
|
||
|
|
|
||
|
|
function isValidRouteKey(routeKey: string): boolean {
|
||
|
|
return ROUTE_KEY_PATTERN.test(routeKey)
|
||
|
|
&& !RESERVED_ROUTE_KEYS.has(routeKey);
|
||
|
|
}
|
||
|
|
|
||
|
|
function routeKeyFromPublicNamespace(publicNamespace: string): string | null {
|
||
|
|
const normalized = publicNamespace.trim().toLowerCase();
|
||
|
|
if (!normalized.startsWith('space.')) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const routeKey = normalized.slice('space.'.length);
|
||
|
|
return isValidRouteKey(routeKey) ? routeKey : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function matrixChildElementHooks(element: HTMLElement): IMatrixChildElementHooks {
|
||
|
|
return element as IMatrixChildElementHooks;
|
||
|
|
}
|
||
|
|
|
||
|
|
function matrixActorElementVersionSource(element: HTMLElement): IMatrixActorElementVersionSource {
|
||
|
|
return element as IMatrixActorElementVersionSource;
|
||
|
|
}
|
||
|
|
|
||
|
|
function matrixRootContextReceiver(element: Element): IMatrixRootContextReceiver {
|
||
|
|
return element as IMatrixRootContextReceiver;
|
||
|
|
}
|
||
|
|
|
||
|
|
function transportRootForContext(context: IMatrixContext): string | undefined {
|
||
|
|
return context.transport.root || context.transport.realm;
|
||
|
|
}
|
||
|
|
|
||
|
|
function browserInventoryRegistryKind(entry: IBrowserRuntimeInventoryEntry): ServiceRegistryEntryKindV1 {
|
||
|
|
return entry.surface === 'runtime' ? 'runtime' : 'actor';
|
||
|
|
}
|
||
|
|
|
||
|
|
function browserInventoryComponentId(
|
||
|
|
state: IBrowserRuntimeRegistryState,
|
||
|
|
entry: IBrowserRuntimeInventoryEntry,
|
||
|
|
): string {
|
||
|
|
return browserInventoryRegistryKind(entry) === 'runtime'
|
||
|
|
? `runtime:${state.runtimeId}`
|
||
|
|
: `runtime:${state.runtimeId}:actor:${entry.mount}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function mapBrowserInventoryToPresence(
|
||
|
|
inventory: readonly IBrowserRuntimeInventoryEntry[],
|
||
|
|
): RuntimeInventoryEntryV1[] {
|
||
|
|
return inventory.map((entry) => ({
|
||
|
|
mount: entry.mount,
|
||
|
|
type: entry.type,
|
||
|
|
surface: entry.surface === 'dom' ? 'dom' : 'webapp',
|
||
|
|
runtimeId: entry.runtimeId,
|
||
|
|
claimKind: 'local-only',
|
||
|
|
...(entry.description ? { description: entry.description } : {}),
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
function browserRouterTransportAdapter(
|
||
|
|
adapter: BrowserRouterResult['adapter'],
|
||
|
|
peer: FederationPeer,
|
||
|
|
): ITransportAdapter {
|
||
|
|
return {
|
||
|
|
get root() {
|
||
|
|
return adapter.root ?? peer.localRoot;
|
||
|
|
},
|
||
|
|
get realm() {
|
||
|
|
return adapter.realm ?? peer.localRoot;
|
||
|
|
},
|
||
|
|
publish: adapter.publish,
|
||
|
|
subscribe: adapter.subscribe,
|
||
|
|
getWireTopicInfo: adapter.getWireTopicInfo,
|
||
|
|
getUnderlyingClient: () => adapter.getUnderlyingClient?.() ?? peer,
|
||
|
|
disconnect: async () => {
|
||
|
|
peer.stop();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function serviceRegistryEntry(value: unknown): ServiceRegistryEntryV1 | null {
|
||
|
|
const record = registryRecord(value);
|
||
|
|
const kind = serviceRegistryEntryKind(record.kind);
|
||
|
|
const placement = registryPlacement(record.placement);
|
||
|
|
const claim = registryClaim(record.claim);
|
||
|
|
const health = registryHealth(record.health);
|
||
|
|
if (
|
||
|
|
record.version !== '1'
|
||
|
|
|| typeof record.namespaceRoot !== 'string'
|
||
|
|
|| typeof record.mount !== 'string'
|
||
|
|
|| kind === null
|
||
|
|
|| placement === null
|
||
|
|
|| claim === null
|
||
|
|
|| health === null
|
||
|
|
|| typeof record.ttlMs !== 'number'
|
||
|
|
) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
version: '1',
|
||
|
|
namespaceRoot: record.namespaceRoot,
|
||
|
|
mount: record.mount,
|
||
|
|
kind,
|
||
|
|
...(registryService(record.service) ? { service: registryService(record.service) } : {}),
|
||
|
|
...(registryApp(record.app) ? { app: registryApp(record.app) } : {}),
|
||
|
|
placement,
|
||
|
|
claim,
|
||
|
|
health,
|
||
|
|
ttlMs: record.ttlMs,
|
||
|
|
...(isRegistryRecord(record.metadata) ? { metadata: record.metadata } : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function serviceRegistryEntryKind(value: unknown): ServiceRegistryEntryKindV1 | null {
|
||
|
|
return value === 'actor'
|
||
|
|
|| value === 'service'
|
||
|
|
|| value === 'app'
|
||
|
|
|| value === 'feed'
|
||
|
|
|| value === 'runtime'
|
||
|
|
|| value === 'gateway'
|
||
|
|
|| value === 'connector'
|
||
|
|
|| value === 'resource'
|
||
|
|
? value
|
||
|
|
: null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryService(value: unknown): ServiceRegistryEntryV1['service'] | undefined {
|
||
|
|
const record = registryRecord(value);
|
||
|
|
if (Object.keys(record).length === 0) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
accepts: registryStringArray(record.accepts),
|
||
|
|
emits: registryStringArray(record.emits),
|
||
|
|
streams: registryStringArray(record.streams),
|
||
|
|
capabilities: [],
|
||
|
|
intents: [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryApp(value: unknown): ServiceRegistryEntryV1['app'] | undefined {
|
||
|
|
const record = registryRecord(value);
|
||
|
|
const route = registryString(record.route);
|
||
|
|
const title = registryString(record.title);
|
||
|
|
if (!route || !title) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
route,
|
||
|
|
title,
|
||
|
|
...(registryString(record.icon) ? { icon: registryString(record.icon) } : {}),
|
||
|
|
surface: registryAppSurface(record.surface),
|
||
|
|
openable: record.openable !== false,
|
||
|
|
...(registryString(record.appName) ? { appName: registryString(record.appName) } : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryPlacement(value: unknown): ServiceRegistryEntryV1['placement'] | null {
|
||
|
|
if (!isRegistryRecord(value)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
...(registryString(value.deviceId) ? { deviceId: registryString(value.deviceId) } : {}),
|
||
|
|
...(registryString(value.hostId) ? { hostId: registryString(value.hostId) } : {}),
|
||
|
|
...(registryString(value.runtimeId) ? { runtimeId: registryString(value.runtimeId) } : {}),
|
||
|
|
...(registryString(value.packageId) ? { packageId: registryString(value.packageId) } : {}),
|
||
|
|
...(registryString(value.actorId) ? { actorId: registryString(value.actorId) } : {}),
|
||
|
|
...(registryString(value.deploymentInstanceId) ? { deploymentInstanceId: registryString(value.deploymentInstanceId) } : {}),
|
||
|
|
...(registryString(value.instanceName) ? { instanceName: registryString(value.instanceName) } : {}),
|
||
|
|
...(registryString(value.packageRef) ? { packageRef: registryString(value.packageRef) } : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryClaim(value: unknown): ServiceRegistryEntryV1['claim'] | null {
|
||
|
|
if (!isRegistryRecord(value)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const mode = registryClaimMode(value.mode);
|
||
|
|
const result = registryClaimResult(value.result);
|
||
|
|
const ownerComponentId = registryString(value.ownerComponentId);
|
||
|
|
const loadBalancing = registryLoadBalancing(value.loadBalancing);
|
||
|
|
if (
|
||
|
|
mode === null
|
||
|
|
|| result === null
|
||
|
|
|| !ownerComponentId
|
||
|
|
|| loadBalancing === null
|
||
|
|
|| typeof value.claimedAt !== 'number'
|
||
|
|
|| typeof value.renewedAt !== 'number'
|
||
|
|
|| typeof value.expiresAt !== 'number'
|
||
|
|
) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
mode,
|
||
|
|
result,
|
||
|
|
ownerComponentId,
|
||
|
|
...(registryString(value.electionGroup) ? { electionGroup: registryString(value.electionGroup) } : {}),
|
||
|
|
...(registryString(value.leaderId) ? { leaderId: registryString(value.leaderId) } : {}),
|
||
|
|
loadBalancing,
|
||
|
|
claimedAt: value.claimedAt,
|
||
|
|
renewedAt: value.renewedAt,
|
||
|
|
expiresAt: value.expiresAt,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryHealth(value: unknown): ServiceRegistryEntryV1['health'] | null {
|
||
|
|
if (!isRegistryRecord(value)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const status = registryHealthStatus(value.status);
|
||
|
|
if (status === null || typeof value.updatedAt !== 'number') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
status,
|
||
|
|
...(registryString(value.reason) ? { reason: registryString(value.reason) } : {}),
|
||
|
|
updatedAt: value.updatedAt,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryRecord(value: unknown): Record<string, unknown> {
|
||
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||
|
|
? value as Record<string, unknown>
|
||
|
|
: {};
|
||
|
|
}
|
||
|
|
|
||
|
|
function bootstrapNodeRoleActive(value: unknown, roleName: string): boolean | undefined {
|
||
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
const role = (value as Record<string, unknown>)[roleName];
|
||
|
|
if (!role || typeof role !== 'object' || Array.isArray(role)) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
const active = (role as Record<string, unknown>).active;
|
||
|
|
return typeof active === 'boolean' ? active : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isRegistryRecord(value: unknown): value is Record<string, unknown> {
|
||
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryArtifact(value: unknown): Record<string, unknown> {
|
||
|
|
return registryRecord(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryString(value: unknown): string | undefined {
|
||
|
|
return typeof value === 'string' && value.trim().length > 0
|
||
|
|
? value.trim()
|
||
|
|
: undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryStringArray(value: unknown): string[] {
|
||
|
|
return Array.isArray(value)
|
||
|
|
? value.filter((item): item is string => typeof item === 'string')
|
||
|
|
.map((item) => item.trim())
|
||
|
|
.filter(Boolean)
|
||
|
|
: [];
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryAppSurface(value: unknown): NonNullable<ServiceRegistryEntryV1['app']>['surface'] {
|
||
|
|
return value === 'panel' || value === 'modal' || value === 'widget'
|
||
|
|
? value
|
||
|
|
: 'page';
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryClaimMode(value: unknown): ServiceRegistryEntryV1['claim']['mode'] | null {
|
||
|
|
return value === 'authoritative'
|
||
|
|
|| value === 'proxy'
|
||
|
|
|| value === 'mirror'
|
||
|
|
|| value === 'local-only'
|
||
|
|
|| value === 'pool-member'
|
||
|
|
? value
|
||
|
|
: null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryClaimResult(value: unknown): ServiceRegistryEntryV1['claim']['result'] | null {
|
||
|
|
return value === 'accepted'
|
||
|
|
|| value === 'rejected'
|
||
|
|
|| value === 'collision'
|
||
|
|
|| value === 'joined-pool'
|
||
|
|
|| value === 'standby'
|
||
|
|
|| value === 'leader'
|
||
|
|
|| value === 'mirror'
|
||
|
|
|| value === 'local-only'
|
||
|
|
? value
|
||
|
|
: null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryLoadBalancing(value: unknown): ServiceRegistryEntryV1['claim']['loadBalancing'] | null {
|
||
|
|
return value === 'none'
|
||
|
|
|| value === 'round-robin'
|
||
|
|
|| value === 'least-loaded'
|
||
|
|
|| value === 'broadcast'
|
||
|
|
? value
|
||
|
|
: null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryHealthStatus(value: unknown): ServiceRegistryEntryV1['health']['status'] | null {
|
||
|
|
return value === 'ok'
|
||
|
|
|| value === 'degraded'
|
||
|
|
|| value === 'error'
|
||
|
|
|| value === 'unknown'
|
||
|
|
|| value === 'starting'
|
||
|
|
|| value === 'stopped'
|
||
|
|
? value
|
||
|
|
: null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function registrySourceKind(value: unknown): ComponentSourceKind | undefined {
|
||
|
|
const sourceKind = registryString(value);
|
||
|
|
return sourceKind === 'installed-webapp' || sourceKind === 'external-webapp'
|
||
|
|
? sourceKind
|
||
|
|
: undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryRegistrationSource(value: unknown): ComponentRegistrationSource | undefined {
|
||
|
|
const registrationSource = registryString(value);
|
||
|
|
return registrationSource === 'package-manifest'
|
||
|
|
|| registrationSource === 'daemon-config'
|
||
|
|
|| registrationSource === 'deployment-spec'
|
||
|
|
? registrationSource
|
||
|
|
: undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function registryArtifactFreshness(value: unknown): ComponentArtifactFreshness | undefined {
|
||
|
|
const freshness = registryString(value);
|
||
|
|
return freshness === 'current'
|
||
|
|
|| freshness === 'stale'
|
||
|
|
|| freshness === 'incomplete'
|
||
|
|
|| freshness === 'missing-manifest'
|
||
|
|
|| freshness === 'invalid-manifest'
|
||
|
|
|| freshness === 'unverifiable'
|
||
|
|
|| freshness === 'missing-output'
|
||
|
|
? freshness
|
||
|
|
: undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
// Custom Element Registration
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
if (typeof customElements !== 'undefined' && !customElements.get('matrix-dsl-host')) {
|
||
|
|
customElements.define('matrix-dsl-host', MatrixDSLHost);
|
||
|
|
}
|