Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/13450-retire-widget-module-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/core': minor
'@object-ui/types': minor
---

**BREAKING:** the `module` widget source is retired. `WidgetRegistry` no longer loads a widget's code from a URL at runtime (objectui#13450).

`RuntimeWidgetSource` had a `module` member, `{ type: 'module', url, exportName? }`, and `WidgetRegistry.load()` resolved it with a dynamic `import()` built through the Function constructor so that bundlers could not see it. That is the runtime dynamic import AGENTS.md commandment #6 forbids ("No `eval()` / runtime dynamic imports to load components"), and it also needs `unsafe-eval` under a strict Content Security Policy. The member, its `WidgetSourceModule` interface and the loader arm are removed. `RuntimeWidgetSource` is now `WidgetSourceInline | WidgetSourceRegistry`.

An old manifest is refused loudly, not skipped. In TypeScript, `type: 'module'` no longer type-checks, and importing `WidgetSourceModule` is `TS2305`. A manifest that reaches `WidgetRegistry.load()` untyped (plain JavaScript, a cast, or an older `@object-ui/types`) is rejected before anything is fetched. The rejection names the retired `module` source and both replacements, `load()` emits `widget:error`, and `loadAll()` reports the error for that widget. Any other unknown source type is rejected the same way, and the message lists the supported types.

Migration, FROM → TO:

- FROM `source: { type: 'module', url: '/widgets/chart.js', exportName: 'Chart' }`
- TO `import { Chart } from './widgets/chart';` and then `source: { type: 'inline', component: Chart }`. The component is imported at build time, so the bundler sees it.
- OR register the component once with the component registry (`ComponentRegistry.register('chart', Chart)`) and declare `source: { type: 'registry', registryKey: 'chart' }`.

One-line fix: replace each `{ type: 'module', url }` with `{ type: 'inline', component }`, where `component` is imported at build time.

Remote widgets as a product capability would be a new contract (a signed manifest and an allow-listed origin, decided in `@objectstack/spec`). This release does not add one.

Clause-②: no (narrowing)
Metadata-protocol: narrowed
59 changes: 34 additions & 25 deletions packages/core/src/registry/WidgetRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
* WidgetRegistry - Runtime widget management with auto-discovery.
*
* Provides registration, loading, and lookup of runtime widgets
* described by RuntimeWidgetManifest objects. Widgets can be loaded from
* ES module URLs, provided inline, or resolved from the component registry.
* described by RuntimeWidgetManifest objects. A widget's component is either
* provided inline (imported at build time) or resolved from the component
* registry. Nothing is ever loaded from a URL: the former `module` source,
* which did that through a runtime dynamic import, is retired (AGENTS.md
* commandment #6, objectui#13450) and refused by name in `load()`.
*
* @module
*/
Expand Down Expand Up @@ -40,14 +43,20 @@ export interface WidgetRegistryOptions {
*
* @example
* ```ts
* // The widget's React component. In an app it is imported at build time
* // (e.g. `import { CustomChart } from './widgets/CustomChart'`).
* function CustomChart() {
* return null;
* }
*
* const widgets = new WidgetRegistry({ componentRegistry: registry });
*
* widgets.register({
* name: 'custom-chart',
* version: '1.0.0',
* type: 'chart',
* label: 'Custom Chart',
* source: { type: 'module', url: '/widgets/chart.js' },
* source: { type: 'inline', component: CustomChart },
* });
*
* const resolved = await widgets.load('custom-chart');
Expand Down Expand Up @@ -279,32 +288,32 @@ export class WidgetRegistry {
return component;
}

case 'module': {
// Runtime-only dynamic import for loading widgets from external URLs
// This uses Function constructor to prevent bundlers (Webpack/Turbopack/Vite)
// from attempting static analysis at build time, which would fail since
// source.url is only known at runtime.
//
// Security: Widget URLs must be from trusted sources only. Never pass
// user-supplied URLs directly to RuntimeWidgetManifest. URLs should be validated
// and controlled by the application developer.
//
// CSP Consideration: If your application uses strict Content Security Policy,
// ensure dynamic imports are allowed or use 'inline' or 'registry' source types.
const dynamicImport = new Function('url', 'return import(url)');
const mod = await dynamicImport(source.url);
const exportName = source.exportName ?? 'default';
const component = mod[exportName];
if (!component) {
default: {
// Exhaustive over `RuntimeWidgetSource`: a member added to the union
// without an arm above fails to compile here. What still reaches this
// line at runtime is a manifest that did not come through the type —
// plain JavaScript, a cast, or one written against an older
// `@object-ui/types` — and it is refused loudly, never skipped.
const unhandled: never = source;
const sourceType = (unhandled as { type?: unknown }).type;
if (sourceType === 'module') {
// The retired `module` source (objectui#13450). It loaded code from
// `source.url` through a dynamic `import()` built with the Function
// constructor — the runtime dynamic import AGENTS.md commandment #6
// forbids. Refused by name so an old manifest says what to do.
throw new Error(
`Widget "${manifest.name}" module at "${source.url}" does not export "${exportName}"`,
`Widget "${manifest.name}" uses the retired "module" source: loading widget code ` +
`from a URL at runtime is no longer supported. Import the component at build ` +
`time and declare { type: 'inline', component }, or register it in the ` +
`component registry and declare { type: 'registry', registryKey }.`,
);
}
return component;
const shown = typeof sourceType === 'string' ? `"${sourceType}"` : String(sourceType);
throw new Error(
`Widget "${manifest.name}" has an unknown source type ${shown}; ` +
`the supported source types are "inline" and "registry".`,
);
}

default:
throw new Error(`Unknown widget source type for "${manifest.name}"`);
}
}

Expand Down
109 changes: 108 additions & 1 deletion packages/core/src/registry/__tests__/WidgetRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WidgetRegistry } from '../WidgetRegistry';
import { Registry } from '../Registry';
import type { RuntimeWidgetManifest, WidgetRegistryEvent } from '@object-ui/types';
import type {
RuntimeWidgetManifest,
RuntimeWidgetSource,
WidgetRegistryEvent,
} from '@object-ui/types';

function createManifest(overrides: Partial<RuntimeWidgetManifest> = {}): RuntimeWidgetManifest {
return {
Expand Down Expand Up @@ -318,4 +322,107 @@ describe('WidgetRegistry', () => {
expect(stats.categories).toHaveLength(2);
});
});

describe('the retired `module` source (objectui#13450)', () => {
/**
* Runs `body` with the global Function constructor replaced by a proxy that
* records every construction and call. The retired arm built its loader
* with `new Function('url', 'return import(url)')`, so a refusal that still
* reached that sink shows up here even if the import itself then failed.
*/
async function withFunctionSpy(body: () => Promise<void>): Promise<string[]> {
const original = globalThis.Function;
const calls: string[] = [];
globalThis.Function = new Proxy(original, {
construct(target, args) {
calls.push(`new Function(${args.map(String).join(', ')})`);
return Reflect.construct(target, args);
},
apply(target, thisArg, args) {
calls.push(`Function(${args.map(String).join(', ')})`);
return Reflect.apply(target, thisArg, args);
},
});
try {
await body();
} finally {
globalThis.Function = original;
}
return calls;
}

it('refuses a manifest that still declares a `module` source, by name, without loading code', async () => {
const componentRegistry = new Registry();
const reg = new WidgetRegistry({ componentRegistry });
reg.register(
createManifest({
type: 'retired-module-widget',
// @ts-expect-error `module` is no longer a member of RuntimeWidgetSource.
source: { type: 'module', url: '/widgets/chart.js' },
}),
);

let refusal: unknown;
const functionCalls = await withFunctionSpy(async () => {
refusal = await reg.load('test-widget').then(
() => undefined,
(err: unknown) => err,
);
});

expect(refusal).toBeInstanceOf(Error);
const message = (refusal as Error).message;
// The named subjects, not the wording: the widget, the retired source
// type, and both replacements an author can move to.
expect(message).toContain('"test-widget"');
expect(message).toContain('"module"');
expect(message).toContain("type: 'inline'");
expect(message).toContain("type: 'registry'");
// Nothing was built to fetch the URL, and nothing was registered.
expect(functionCalls).toEqual([]);
expect(reg.isLoaded('test-widget')).toBe(false);
expect(componentRegistry.get('retired-module-widget')).toBeUndefined();
});

it('emits widget:error for the refusal, and loadAll() reports it instead of skipping it', async () => {
widgetRegistry.register(createManifest({ name: 'good', type: 'good' }));
widgetRegistry.register(
createManifest({
name: 'old',
type: 'old',
source: { type: 'module', url: '/widgets/old.js' } as unknown as RuntimeWidgetSource,
}),
);
const events: WidgetRegistryEvent[] = [];
widgetRegistry.on((e) => events.push(e));

const results = await widgetRegistry.loadAll();

const old = results.find((r) => r.name === 'old');
expect(old?.result).toBeInstanceOf(Error);
expect((old?.result as Error).message).toContain('"module"');
expect(results.find((r) => r.name === 'good')?.result).not.toBeInstanceOf(Error);
expect(events.some((e) => e.type === 'widget:error' && e.name === 'old')).toBe(true);
});

it('refuses any other unknown source type loudly, naming the supported ones', async () => {
widgetRegistry.register(
createManifest({
source: { type: 'remote', url: 'https://cdn.example.com/w.mjs' } as unknown as RuntimeWidgetSource,
}),
);

const refusal = await widgetRegistry.load('test-widget').then(
() => undefined,
(err: unknown) => err,
);

expect(refusal).toBeInstanceOf(Error);
const message = (refusal as Error).message;
expect(message).toContain('"remote"');
expect(message).toContain('"inline"');
expect(message).toContain('"registry"');
expect(message).not.toContain('retired');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,7 @@ describe('renamed local dialects do not collide with a spec export (objectui#307
['PageNodeRegionSchema', 'zod twin of PageNodeRegion'],
// `MobileResponsiveConfig` retired with its type (objectui#7519).
['RuntimeWidgetManifest', 'SDUI component manifest, not the field-widget plugin'],
['RuntimeWidgetSource', 'objectui module/inline/registry loader union'],
['RuntimeWidgetSource', 'objectui inline/registry loader union'],
['InspectorFlowNode', "app-shell's flow-inspector read type, `id` REQUIRED"],
['ScopeFlowNode', "app-shell's pre-validation scope probe, members `unknown`"],
])('the spec does not own `%s` (%s)', (name) => {
Expand Down
1 change: 0 additions & 1 deletion packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1556,7 +1556,6 @@ export type {
export type {
RuntimeWidgetManifest,
RuntimeWidgetSource,
WidgetSourceModule,
WidgetSourceInline,
WidgetSourceRegistry,
WidgetInput,
Expand Down
52 changes: 28 additions & 24 deletions packages/types/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ import type { ComponentInputControlType } from './base.js';
* `@object-ui/core`'s `WidgetRegistry` registers, and what the designer palette
* lists.
*
* A manifest provides all metadata needed to discover, load, and render
* a widget without requiring an upfront import of its code.
* A manifest provides the metadata needed to list, register and render a
* widget. Its code is never fetched by the manifest: the component is either
* imported at build time and handed over (`inline`) or already registered in
* the component registry (`registry`). See {@link RuntimeWidgetSource} for why
* there is no URL-loaded source.
*
* Today this is objectui's ONLY widget-registration contract: no published
* `@objectstack/spec` schema models one.
Expand Down Expand Up @@ -61,6 +64,12 @@ import type { ComponentInputControlType } from './base.js';
*
* @example
* ```ts
* // The widget's React component. In an app it is imported at build time
* // (e.g. `import { CustomChart } from './widgets/CustomChart'`).
* function CustomChart() {
* return null;
* }
*
* const manifest: RuntimeWidgetManifest = {
* name: 'custom-chart',
* version: '1.0.0',
Expand All @@ -69,7 +78,8 @@ import type { ComponentInputControlType } from './base.js';
* description: 'A custom chart powered by D3.',
* category: 'data-visualization',
* icon: 'BarChart',
* source: { type: 'module', url: '/widgets/custom-chart.js' },
* // Imported at build time, so the bundler sees it; never a URL.
* source: { type: 'inline', component: CustomChart },
* };
* ```
*/
Expand Down Expand Up @@ -139,11 +149,25 @@ export interface RuntimeWidgetManifest {
* the spec's `inline` carried source **code** to evaluate (`{ type: 'inline',
* code: string }`), objectui's carries an **already-resolved component**
* ({@link WidgetSourceInline}). The other members never overlapped — the spec
* had `npm`/`remote`, objectui has `module`/`registry` — so a value of one
* had `npm`/`remote`, objectui had `module`/`registry` — so a value of one
* union was never a valid value of the other. ⚠️ The spec's union was retired
* with the rest of the widget-registration vocabulary (objectstack#5055): the
* union declared below is objectui's own, and never derived from it.
*
* RETIRED — the `module` member (`{ type: 'module', url, exportName? }`, the
* `WidgetSourceModule` interface) was removed by objectui#13450. It loaded a
* widget's code from a URL at runtime through a dynamic `import()` built with
* the Function constructor, which AGENTS.md commandment #6 forbids ("No
* `eval()` / runtime dynamic imports to load components"). There is no
* tombstone member: a manifest written in TypeScript is refused here by the
* discriminant, and one that reaches `WidgetRegistry.load()` untyped is refused
* there by name, before any code is fetched. Migration: import the component
* at build time and declare `{ type: 'inline', component }`, or register it
* in the component registry and declare `{ type: 'registry', registryKey }`.
* Remote widgets as a product capability would be a new contract (a signed
* manifest and an allow-listed origin, decided in `@objectstack/spec`), not a
* member of this union.
*
* The variant interfaces keep their `WidgetSource…` names: the spec never
* exported those, and renaming them would churn the public surface without
* removing a collision.
Expand All @@ -152,29 +176,9 @@ export interface RuntimeWidgetManifest {
* longer owns `WidgetSource`" row pins the vacancy.
*/
export type RuntimeWidgetSource =
| WidgetSourceModule
| WidgetSourceInline
| WidgetSourceRegistry;

/**
* Load from an ES module URL.
*
* ⚠️ SECURITY WARNING: Only use URLs from trusted sources.
* Never pass user-supplied URLs directly. URLs should be validated
* and controlled by your application. This feature uses dynamic imports
* which bypass static analysis and may be restricted by Content Security Policy.
*/
export interface WidgetSourceModule {
type: 'module';
/**
* URL to the ES module (e.g., '/widgets/chart.js' or 'https://cdn.example.com/widget.mjs')
* Must be from a trusted source - never user input.
*/
url: string;
/** Named export to use (default: 'default') */
exportName?: string;
}

/** The component is provided inline (already loaded) */
export interface WidgetSourceInline {
type: 'inline';
Expand Down
Loading