Installation
npm install @tuvix.js/loader
# or pnpm add @tuvix.js/loaderWhen to use it
@tuvix.js/loader turns an entry (a script URL, a list of scripts and styles, or () => import('./app')) into a micro app module with mount and unmount. It adds the <script> and <link> tags, retries failed attempts, gives up after a timeout and caches the result by app name.
@tuvix.js/core creates a ModuleLoader for you and passes the loader option of createOrchestrator to it, so most shells never import this package. Import it when you:
- write a micro app or adapter that publishes itself with
registerModule, or reportstuvix:mountedwithemitLifecycle - need to know when another app mounted (
whenMounted,onLifecycle) - load micro apps without the orchestrator, for example from your own router
- load a single script or stylesheet with deduplication (
loadScript,loadStyle)
If your bundler can import the app at build time and you never load code from another origin, a plain import() is enough.
Quick start
The micro app publishes its module under a name. This file is built to https://cdn.example.com/dashboard/main.js:
// dashboard/src/main.ts
import { registerModule } from '@tuvix.js/loader';
registerModule('dashboard', {
mount({ container, props }) {
container.innerHTML = `<h2>Dashboard for user ${String(props?.userId)}</h2>`;
},
unmount({ container }) {
container.replaceChildren();
},
});The shell loads it by name and mounts it:
// shell/src/main.ts
import { createLoader } from '@tuvix.js/loader';
const loader = createLoader({ timeout: 15000, retries: 1 });
const container = document.getElementById('dashboard')!;
const result = await loader.load('dashboard', {
scripts: ['https://cdn.example.com/dashboard/main.js'],
styles: ['https://cdn.example.com/dashboard/main.css'],
type: 'module',
});
if (result.module) {
await result.module.mount({ container, props: { userId: 42 } });
} else {
container.textContent = 'The dashboard is unavailable.';
console.error(result.error);
}load() never throws. A failed load comes back with status: 'error', module: null and the last error.
Entry forms
An Entry tells the loader where an app's code lives. It takes three forms.
A URL string. The loader treats 'https://cdn.example.com/app.js' as { scripts: ['https://cdn.example.com/app.js'] }.
An EntryConfig object:
| Option | Type | Default | Meaning |
|---|---|---|---|
scripts | string[] | required | Script URLs. The loader runs them one after another in this order. An empty array loads nothing and resolves the module from the registry. |
styles | string[] | [] | Stylesheet URLs, loaded in parallel with the scripts. The load waits for both. |
type | 'module' | 'classic' | inferred per URL | Script type for every URL in scripts. Without it, URLs ending in .mjs, .mts, .ts, .tsx or .jsx load as type="module" and every other URL loads as a classic script. Set 'module' for Vite or Rollup ES output, which ends in .js. |
integrity | Record<string, string> | none | Subresource integrity hashes keyed by URL. The loader writes them to the <script>, <link> and prefetch tags. |
A loader function (EntryLoader). The loader calls it with { signal } and accepts three results, checked in this order:
- a module with a
mountfunction - an object whose
defaultexport is such a module (an ES module namespace) - nothing usable, in which case the loader looks up the module the import registered with
registerModule(name, ...)
import { createOrchestrator } from '@tuvix.js/core';
const orchestrator = createOrchestrator();
orchestrator.register({
name: 'dashboard',
entry: () => import('./dashboard'),
container: '#main',
activeWhen: '/dashboard/*',
});
await orchestrator.start();Your bundler code-splits ./dashboard into its own chunk and fetches it on first mount. Loader functions are also how @tuvix.js/module-federation plugs a federated remote into the orchestrator.
signal aborts when the attempt times out. Pass it to anything you fetch on the way:
import type { EntryLoader } from '@tuvix.js/loader';
const entry: EntryLoader = async ({ signal }) => {
const response = await fetch('/api/manifest/dashboard.json', { signal });
const { url } = (await response.json()) as { url: string };
return import(/* @vite-ignore */ url);
};API
ModuleLoader
class ModuleLoader {
constructor(options?: LoaderOptions);
load(name: string, entry: Entry): Promise<LoadResult>;
prefetch(entry: Entry): void;
removeStyles(name: string): void;
unload(name: string): void;
clearCache(): void;
isLoaded(name: string): boolean;
getModule(name: string): MicroAppModule | null;
getStatus(name: string): LoadStatus;
getCachedNames(): string[];
}Each ModuleLoader keeps its own cache of resolved modules by app name. The <script> and <link> tags are page-wide: two loaders that load the same URL share one tag.
LoaderOptions:
| Option | Type | Default | Meaning |
|---|---|---|---|
timeout | number | 10000 | Time limit in ms for one attempt, covering every script and stylesheet of the entry. 0 disables it. |
retries | number | 2 | Extra attempts after the first one fails. 2 means up to 3 attempts. |
retryDelay | number | 1000 | Wait in ms between attempts. |
globals | Record<string, unknown> | {} | Properties the loader assigns to window before each attempt of a URL entry, for UMD bundles that expect window.React or a config object. Loader-function entries ignore it. |
load(name, entry)
Resolves to a LoadResult:
| Field | Type | Meaning |
|---|---|---|
status | 'loaded' | 'error' | Outcome of the load. |
module | MicroAppModule | null | The resolved module, null on error. |
error | Error | null | The error of the last attempt. |
duration | number | Milliseconds from the call to the result, measured with performance.now(). |
For a URL entry, one attempt:
- assigns
options.globalstowindow - appends the scripts in order, each waiting for the previous one, and the stylesheets in parallel
- reads the module from the registry (
getRegisteredModule(name)) - falls back to a UMD global: a
windowproperty with amountfunction that appeared during this load
The loader then checks that the module has mount and unmount functions and caches it under name. Later calls with the same name return the cached module and ignore entry. A second call while the first is still loading returns the same promise. Loads of different names run in parallel.
prefetch(entry)
Asks the browser to download an entry's files without running them. Module scripts get <link rel="modulepreload">; classic scripts and stylesheets get <link rel="prefetch">. Loader-function entries are skipped, because calling the function would run the module. The orchestrator calls this for its prefetch setting.
import { createLoader } from '@tuvix.js/loader';
const loader = createLoader();
loader.prefetch({ scripts: ['/apps/settings/main.js'], type: 'module' });removeStyles(name)
Removes the stylesheets of a loaded app so its CSS stops applying while it is unmounted. A stylesheet stays when another cached app, whose styles you have not removed, lists the same URL. The next load(name) adds the stylesheets back and waits for them before it resolves. The orchestrator calls removeStyles after every unmount.
import { createLoader } from '@tuvix.js/loader';
const loader = createLoader();
const container = document.getElementById('cart')!;
const { module } = await loader.load('cart', { scripts: ['/cart.js'], styles: ['/cart.css'] });
await module?.mount({ container });
await module?.unmount({ container });
loader.removeStyles('cart');unload(name)
Forgets an app: removes its stylesheets and script tags, drops it from the cache, and unregisters it from the shared registry when the registered module is the one this loader resolved. The next load(name) fetches and runs the entry again. The orchestrator calls it from unregister(name).
clearCache()
Calls unload for every cached app. The orchestrator calls it from destroy().
isLoaded(name), getModule(name), getStatus(name), getCachedNames()
Read the cache. getStatus returns 'loading' while a load runs, 'loaded' once the module is cached, and 'idle' otherwise. A failed load leaves the status at 'idle'.
createLoader
function createLoader(options?: LoaderOptions): ModuleLoader;Same as new ModuleLoader(options). Takes the LoaderOptions from the table above.
import { createLoader } from '@tuvix.js/loader';
const loader = createLoader({ timeout: 8000, retries: 0, globals: { APP_ENV: 'production' } });To set these options for the orchestrator, pass them as loader:
import { createOrchestrator } from '@tuvix.js/core';
const orchestrator = createOrchestrator({ loader: { timeout: 15000, retries: 1, retryDelay: 500 } });normalizeEntry
function normalizeEntry(entry: string | EntryConfig): EntryConfig;Turns a URL into { scripts: [url] } and returns an EntryConfig unchanged. It does not accept loader functions; check typeof entry === 'function' first.
import { normalizeEntry } from '@tuvix.js/loader';
normalizeEntry('https://cdn.example.com/app.js'); // { scripts: ['https://cdn.example.com/app.js'] }loadScript
function loadScript(url: string, options?: LoadScriptOptions | string): Promise<void>;| Option | Type | Default | Meaning |
|---|---|---|---|
type | 'module' | 'classic' | inferred from the extension | Script type, with the same rules as EntryConfig.type. |
integrity | string | none | SRI hash for the integrity attribute. |
signal | AbortSignal | none | Stops waiting when aborted. The promise rejects with the signal's reason. |
Appends <script crossorigin="anonymous"> to document.head and resolves on its load event. Concurrent calls for one URL share one tag, and a call for a URL that already loaded resolves at once. A failed load removes its tag and rejects with [Tuvix Loader] Failed to load script: <url>, so the next call starts with a fresh tag. An aborted call removes the tag only when no other caller still waits for it. loadScript has no timeout of its own; pass a signal.
A string as the second argument is read as the integrity hash, as in 0.4.
import { loadScript } from '@tuvix.js/loader';
await loadScript('https://cdn.example.com/charts.js', { type: 'classic', signal: AbortSignal.timeout(5000) });loadStyle
function loadStyle(url: string, options?: LoadStyleOptions | number): Promise<void>;| Option | Type | Default | Meaning |
|---|---|---|---|
timeout | number | 10000 | Fails the load after this many ms with [Tuvix Loader] Style load timed out: <url>. 0 disables it. |
integrity | string | none | SRI hash for the integrity attribute. |
signal | AbortSignal | none | Stops waiting when aborted. |
Appends <link rel="stylesheet" crossorigin="anonymous"> to document.head with the same sharing, failure and abort rules as loadScript. A number as the second argument is read as the timeout, as in 0.4.
import { loadStyle } from '@tuvix.js/loader';
await loadStyle('https://cdn.example.com/app.css', { timeout: 5000 });removeScript
function removeScript(url: string): void;Removes the tag loadScript added for url, plus any <script> whose src attribute equals url. A load still in flight for that URL rejects with [Tuvix Loader] Removed before load: <url>. The next loadScript(url) appends a new tag. Code the script already ran stays in memory.
removeStyle
function removeStyle(url: string): void;Same as removeScript for <link rel="stylesheet"> tags.
prefetchResource
function prefetchResource(url: string, options?: PrefetchOptions): void;| Option | Type | Default | Meaning |
|---|---|---|---|
as | 'script' | 'style' | 'style' for .css URLs, else 'script' | Resource kind. |
type | 'module' | 'classic' | inferred from the extension | Module scripts get rel="modulepreload". |
integrity | string | none | SRI hash for the link. |
Appends one <link> per URL and page. A URL that loadScript or loadStyle already handles, or that was prefetched before, is skipped.
import { prefetchResource } from '@tuvix.js/loader';
prefetchResource('https://cdn.example.com/app.js', { type: 'module' });registerModule
function registerModule(name: string, module: MicroAppModule): void;Publishes a module under a name so a loader can find it after the app's script runs. A second call with the same name replaces the first module. The React, Vue, Svelte and Angular adapters and core's defineMicroApp call it for you; call it yourself when you write the module by hand.
The registry and the mounted-app state live on window.__TUVIX_RUNTIME__, and the module map is also reachable as window.__TUVIX_MODULES__. Micro apps that each bundle their own copy of @tuvix.js/loader read and write the same registry. On the server, without window, the state lives in the module.
A hand-written micro app that follows the adapter contract:
import { emitLifecycle, hasServerMarkup, registerModule } from '@tuvix.js/loader';
registerModule('clock', {
mount({ container }) {
const hydrated = hasServerMarkup(container);
if (!hydrated) container.textContent = new Date().toLocaleTimeString();
emitLifecycle('mounted', { name: 'clock', container, hydrated });
},
unmount({ container }) {
container.replaceChildren();
emitLifecycle('unmounted', { name: 'clock', container, hydrated: false });
},
});unregisterModule
function unregisterModule(name: string): void;Removes a module from the registry. ModuleLoader.unload calls it for modules it resolved.
getRegisteredModule
function getRegisteredModule(name: string): MicroAppModule | undefined;Returns the registered module, or undefined.
emitLifecycle
function emitLifecycle(type: 'mounted' | 'unmounted' | 'error', detail: LifecycleDetail): void;Records a lifecycle change and dispatches a CustomEvent named tuvix:mounted, tuvix:unmounted or tuvix:error. The event bubbles and is composed, so a listener on document hears events from containers inside shadow roots. When detail.container is in the DOM, the event fires on the container and bubbles to document once. When the container is detached (isConnected is false), for example because React removed an island before the adapter reported unmounted, the event fires on document itself, since an event on a detached element never reaches it. detail.container still points at the element in both cases. event.detail is the LifecycleDetail:
| Field | Type | Meaning |
|---|---|---|
name | string | Micro app name. |
container | HTMLElement | Element the app rendered into. |
hydrated | boolean | true when the adapter adopted server-rendered markup instead of rendering fresh. |
error | unknown | The thrown value, set on error events. |
mounted records the container as mounted for detail.name and resolves pending whenMounted(name) promises. unmounted removes that container from the record. error only dispatches the event. The React, Vue, Svelte and Angular adapters and core's defineMicroApp call this after mount and unmount finish and when either throws.
isMounted
function isMounted(name: string): boolean;true while at least one container of name is mounted. The runtime tracks mounted containers per app, so if one module mounts into two containers and you unmount one, isMounted(name) stays true until the second unmounts.
whenMounted
function whenMounted(name: string, options?: { signal?: AbortSignal }): Promise<LifecycleDetail>;Resolves with the LifecycleDetail of the first mount. If the app is mounted already, it resolves at once with the detail of its earliest still-mounted container. Abort signal to stop waiting; the promise then rejects with the signal's reason.
import { whenMounted } from '@tuvix.js/loader';
const { container, hydrated } = await whenMounted('header', { signal: AbortSignal.timeout(5000) });
console.log(`header mounted into #${container.id}, hydrated: ${hydrated}`);onLifecycle
function onLifecycle(type: LifecycleType, handler: (detail: LifecycleDetail) => void, target?: EventTarget): () => void;Listens for tuvix:<type> on target (default document) and returns a function that removes the listener. With the default target the handler runs once per event, for attached and detached containers alike. A listener on a container element does not hear events dispatched while that container is detached.
import { onLifecycle } from '@tuvix.js/loader';
const stop = onLifecycle('error', ({ name, error }) => {
console.error(`Micro app "${name}" failed`, error);
});
// later
stop();Without this package, listen to the DOM event:
import type { LifecycleEvent } from '@tuvix.js/loader';
document.addEventListener('tuvix:mounted', (event) => {
const { name, hydrated } = (event as LifecycleEvent).detail;
console.log(name, hydrated);
});hasServerMarkup
function hasServerMarkup(container: Element): boolean;true when the element has a child element or a text node with non-whitespace text. Comments and whitespace do not count. Adapters use it to choose between hydrating and rendering fresh.
Types
The interfaces a micro app module implements:
interface MicroAppModule {
bootstrap?: () => void | Promise<void>;
mount: (context: MountContext) => void | Promise<void>;
unmount: (context: UnmountContext) => void | Promise<void>;
update?: (context: UpdateContext) => void | Promise<void>;
[key: string]: unknown;
}
interface MountContext {
container: HTMLElement;
props?: Record<string, unknown>;
/** App name the orchestrator registered this module under */
name?: string;
/** The orchestrator's event bus */
bus?: MicroAppBus;
}
interface UnmountContext {
container: HTMLElement;
}
interface UpdateContext {
props: Record<string, unknown>;
/** Container to update; without it, adapters update every mounted instance */
container?: HTMLElement;
}
interface MicroAppBus {
on<T = unknown>(event: string, handler: (data: T) => void): () => void;
once<T = unknown>(event: string, handler: (data: T) => void): () => void;
emit<T = unknown>(event: string, data?: T): void;
off<T = unknown>(event: string, handler: (data: T) => void): void;
}The orchestrator passes name and bus on every mount. bus is the orchestrator's @tuvix.js/event-bus instance; MicroAppBus describes it structurally, so the loader has no dependency on the event bus package.
| Type | Meaning |
|---|---|
Entry | string | EntryConfig | EntryLoader. |
EntryConfig | { scripts, styles?, type?, integrity? }, see Entry forms. |
EntryLoader | (context: EntryLoaderContext) => Promise<MicroAppModule | { default: MicroAppModule }>. |
EntryLoaderContext | { signal: AbortSignal }. |
ScriptType | 'module' | 'classic'. |
MicroAppModule | Lifecycle object a micro app exports. |
MountContext, UnmountContext, UpdateContext | Arguments of mount, unmount and update. |
MicroAppBus | { on, once, emit, off }; IEventBus satisfies it. |
LoaderOptions | { timeout?, retries?, retryDelay?, globals? }. |
LoadResult | { status, module, error, duration }. |
LoadStatus | 'idle' | 'loading' | 'loaded' | 'error'. |
CacheEntry | { module, loadedAt, entry }, one cached module. |
PrefetchStrategy | 'immediate' | 'idle' | 'hover' | 'none'. The loader does not read it. |
ResourceOptions | { integrity?, signal? }, shared by loadScript and loadStyle. |
LoadScriptOptions | ResourceOptions plus type?. |
LoadStyleOptions | ResourceOptions plus timeout?. |
PrefetchOptions | { as?, type?, integrity? }. |
LifecycleType | 'mounted' | 'unmounted' | 'error'. |
LifecycleDetail | { name, container, hydrated, error? }. |
LifecycleEvent | CustomEvent<LifecycleDetail>. |
Edge cases
- Timeouts.
timeoutcovers one whole attempt. When it fires, the loader aborts the attempt'ssignal, removes the attempt's tags that no other load waits for, waitsretryDelayand tries again. The error reads[Tuvix Loader] Loading "<name>" timed out after <timeout>ms. A loader function that ignoressignalkeeps running in the background; the loader stops waiting for it. - Retries. A failed
<script>is removed before the next attempt, so a retry appends one fresh tag. After the last attemptload()resolves with the last error. - Same URL, several apps. Two entries that list the same vendor script share one tag and one download.
- Cached entries.
load(name, otherEntry)returns the module cached undernameand ignoresotherEntry. Callunload(name)first to switch entries. - Module scripts run once per URL. After
unload(name), an app whosetype: 'module'script calledregisterModuledoes not load again from the same URL, because the browser does not run a module script twice. The load fails withno exports found. Use a loader-function entry or a new URL (a content hash or?v=2). unloadremoves shared script tags. It removes every script URL of the entry, including a vendor script another app also lists. Code that already ran stays; a later load that lists the URL adds the tag again and a classic script runs again.- CORS. The loader sets
crossorigin="anonymous"on every tag. A script or stylesheet from another origin needs anAccess-Control-Allow-Originheader, or the browser blocks it and the load fails. - UMD globals. When two UMD bundles load at the same time, the loader skips globals another load already claimed and matches the rest to the app name, ignoring case and punctuation (
my-appmatchesMyApp). If several candidates remain, the load fails withseveral globals with mount() appeared while it loaded (<names>).registerModuleavoids the guesswork. - Missing lifecycle. A module without
mountorunmountfails with[Tuvix Loader] Module "<name>" is missing required "unmount" function. - Nothing found. A URL entry whose scripts neither register a module nor define a UMD global fails with
Module "<name>" loaded but no exports found. - Several containers. The runtime tracks mounted state per container. Unmounting one of two containers keeps
isMounted(name)true;whenMounted(name)returns the earliest container still mounted. - Detached containers. When a host framework removes the container before the adapter reports
unmountedorerror,emitLifecycledispatches the event ondocument. Listen ondocument(theonLifecycledefault) to receive every event. whenMountedignores errors. Atuvix:errorevent does not reject it. Pass asignalor listen withonLifecycle('error', ...).- Server. The runtime functions work without
window;emitLifecycleskips the DOM event whenCustomEventis undefined.ModuleLoader,loadScript,loadStyleandprefetchResourceneeddocument.
What it does not do
- It does not fetch an HTML page and pull scripts out of it. List the script and style URLs, or use a loader function.
- It does not isolate JavaScript. Scripts run in the page's global scope. For CSS isolation use the orchestrator's
sandboxoption or@tuvix.js/sandbox. - It does not mount apps. You or the orchestrator call
module.mount. - It does not keep a cache across page loads. The browser's HTTP cache does that.
- It does not read
PrefetchStrategy; the orchestrator'sprefetchsetting decides when to callprefetch.
Migrating from 0.4
LoaderOptions.fetchandEntryConfig.htmlare gone. The loader never read them. Delete them from your config.loadScript(url, integrity)andloadStyle(url, timeoutMs)still work. The options form addstypeandsignal:tsimport { loadScript } from '@tuvix.js/loader'; // 0.4 await loadScript('/vendor.js', 'sha384-abc'); // 0.5 await loadScript('/vendor.js', { integrity: 'sha384-abc', type: 'classic' });Vite ES output ending in
.jsloaded as a classic script in 0.4 and failed on its firstimportstatement. Settype: 'module'on the entry.A
type: 'module'entry no longer loads again from the same URL afterunload. Use a loader function or a new URL.load()for a cached module reports the realdurationinstead of0.The 0.4 version of this page showed
loader.load(url),result.app,onLoadStart,cacheandloader.clear(). None of them existed. Useload(name, entry),result.module,unload(name)andclearCache().