@omriashke/dynamico-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ import * as React from "react";
2
+ import { Registry } from "../registry.js";
3
+ import type {
4
+ ComponentFactory,
5
+ DynamicError,
6
+ PropsSchema,
7
+ RegistryEntry,
8
+ Scope,
9
+ Source,
10
+ } from "../types.js";
11
+ import { validateProps } from "../propsSchema.js";
12
+
13
+ export interface RuntimeAPI {
14
+ DynamicoProvider: React.ComponentType<DynamicoProviderProps>;
15
+ DynamicComponent: React.ComponentType<DynamicComponentProps>;
16
+ useDynamico: (name: string) => RegistryEntry | undefined;
17
+ }
18
+
19
+ export interface DynamicoProviderProps {
20
+ source: Source;
21
+ scope?: Scope;
22
+ children?: React.ReactNode;
23
+ }
24
+
25
+ export interface DynamicComponentProps {
26
+ name: string;
27
+ props?: Record<string, unknown>;
28
+ fallback?: React.ReactNode;
29
+ errorFallback?: React.ComponentType<{ error: DynamicError }> | React.ReactNode;
30
+ }
31
+
32
+ interface RuntimeContextValue {
33
+ registry: Registry;
34
+ }
35
+
36
+ export function createRuntime(defaultScope: Scope): RuntimeAPI {
37
+ const Ctx = React.createContext<RuntimeContextValue | null>(null);
38
+
39
+ function DynamicoProvider({ source, scope, children }: DynamicoProviderProps) {
40
+ const registry = React.useMemo(() => {
41
+ const merged: Scope = { ...defaultScope, ...(scope ?? {}) };
42
+ return new Registry(source, merged);
43
+ }, [source, scope]);
44
+
45
+ const value = React.useMemo<RuntimeContextValue>(() => ({ registry }), [registry]);
46
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
47
+ }
48
+
49
+ function useRegistry(): Registry {
50
+ const ctx = React.useContext(Ctx);
51
+ if (!ctx) {
52
+ throw new Error("dynamico: useDynamico/DynamicComponent must be inside <DynamicoProvider>");
53
+ }
54
+ return ctx.registry;
55
+ }
56
+
57
+ function useDynamico(name: string): RegistryEntry | undefined {
58
+ const registry = useRegistry();
59
+ const subscribe = React.useCallback(
60
+ (cb: () => void) => registry.subscribe(name, cb),
61
+ [registry, name],
62
+ );
63
+ const getSnapshot = React.useCallback(() => registry.peek(name), [registry, name]);
64
+ const entry = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
65
+
66
+ React.useEffect(() => {
67
+ if (!registry.peek(name)) void registry.ensure(name);
68
+ }, [registry, name]);
69
+
70
+ return entry;
71
+ }
72
+
73
+ function DynamicComponent({
74
+ name,
75
+ props,
76
+ fallback = null,
77
+ errorFallback,
78
+ }: DynamicComponentProps): React.ReactElement | null {
79
+ const entry = useDynamico(name);
80
+
81
+ if (!entry) return <>{fallback}</>;
82
+
83
+ if (entry.error) {
84
+ return <>{renderError(errorFallback, entry.error)}</>;
85
+ }
86
+
87
+ if (!entry.factory) return <>{fallback}</>;
88
+
89
+ const Comp = pickDefault(entry.factory);
90
+ if (typeof Comp !== "function") {
91
+ const err: DynamicError = {
92
+ kind: "load",
93
+ name,
94
+ version: entry.version,
95
+ message: `component '${name}' has no default export of a function/class`,
96
+ };
97
+ return <>{renderError(errorFallback, err)}</>;
98
+ }
99
+
100
+ const schema = entry.factory.propsSchema as PropsSchema | undefined;
101
+ const validation = validateProps(schema, props ?? {});
102
+ if (!validation.ok) {
103
+ const err: DynamicError = {
104
+ kind: "render",
105
+ name,
106
+ version: entry.version,
107
+ message: `props validation failed: ${validation.errors.join("; ")}`,
108
+ };
109
+ return <>{renderError(errorFallback, err)}</>;
110
+ }
111
+
112
+ return (
113
+ <ErrorBoundary
114
+ key={`${name}@${entry.version}`}
115
+ onError={(message, stack) => ({
116
+ kind: "render",
117
+ name,
118
+ version: entry.version,
119
+ message,
120
+ stack,
121
+ })}
122
+ renderFallback={(err) => renderError(errorFallback, err)}
123
+ >
124
+ {React.createElement(Comp as React.ComponentType<Record<string, unknown>>, props ?? {})}
125
+ </ErrorBoundary>
126
+ );
127
+ }
128
+
129
+ return { DynamicoProvider, DynamicComponent, useDynamico };
130
+ }
131
+
132
+ function pickDefault(factory: ComponentFactory): unknown {
133
+ if (factory && typeof factory === "object") {
134
+ if ("default" in factory && factory.default) return factory.default;
135
+ // CommonJS interop: the value itself may be the component
136
+ if (typeof factory === "function") return factory;
137
+ }
138
+ if (typeof factory === "function") return factory;
139
+ return undefined;
140
+ }
141
+
142
+ function renderError(
143
+ errorFallback: DynamicComponentProps["errorFallback"],
144
+ error: DynamicError,
145
+ ): React.ReactNode {
146
+ if (!errorFallback) return defaultErrorView(error);
147
+ if (typeof errorFallback === "function") {
148
+ const Fallback = errorFallback as React.ComponentType<{ error: DynamicError }>;
149
+ return <Fallback error={error} />;
150
+ }
151
+ return errorFallback;
152
+ }
153
+
154
+ function defaultErrorView(error: DynamicError): React.ReactElement {
155
+ // Renderer-agnostic: plain text node so it works on both DOM and RN.
156
+ return React.createElement(
157
+ React.Fragment,
158
+ null,
159
+ `[dynamico ${error.kind} error] ${error.name}: ${error.message}`,
160
+ );
161
+ }
162
+
163
+ interface ErrorBoundaryProps {
164
+ onError: (message: string, stack: string | undefined) => DynamicError;
165
+ renderFallback: (err: DynamicError) => React.ReactNode;
166
+ children: React.ReactNode;
167
+ }
168
+
169
+ interface ErrorBoundaryState {
170
+ err: DynamicError | null;
171
+ }
172
+
173
+ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
174
+ state: ErrorBoundaryState = { err: null };
175
+
176
+ static getDerivedStateFromError(): ErrorBoundaryState | null {
177
+ return null; // we set state in componentDidCatch where we have the message
178
+ }
179
+
180
+ componentDidCatch(error: unknown): void {
181
+ const e = error instanceof Error ? error : new Error(String(error));
182
+ this.setState({ err: this.props.onError(e.message, e.stack) });
183
+ }
184
+
185
+ render(): React.ReactNode {
186
+ if (this.state.err) return this.props.renderFallback(this.state.err);
187
+ return this.props.children;
188
+ }
189
+ }
@@ -0,0 +1,240 @@
1
+ import type {
2
+ ComponentFactory,
3
+ DynamicError,
4
+ RegistryEntry,
5
+ RegistryListener,
6
+ Scope,
7
+ Source,
8
+ Version,
9
+ } from "./types.js";
10
+ import { loadModule } from "./loader.js";
11
+
12
+ /**
13
+ * In-memory, versioned registry of dynamic components.
14
+ *
15
+ * The registry is the single source of truth that runtime packages
16
+ * (@omriashke/dynamico-web, @omriashke/dynamico-native) subscribe to. It receives compiled
17
+ * modules from a Source, evaluates them via the loader using a host-provided
18
+ * Scope, and notifies subscribers when a component's version changes.
19
+ */
20
+ export class Registry {
21
+ private entries = new Map<string, RegistryEntry>();
22
+ private listeners = new Map<string, Set<RegistryListener>>();
23
+ private anyListeners = new Set<RegistryListener>();
24
+ private inflight = new Map<string, Promise<RegistryEntry>>();
25
+
26
+ constructor(
27
+ private readonly source: Source,
28
+ private scope: Scope,
29
+ ) {
30
+ this.source.subscribe(({ module }) => {
31
+ this.ingest(module.name, module);
32
+ });
33
+ }
34
+
35
+ /** Replace or extend the current scope (rare; typically set once). */
36
+ setScope(scope: Scope): void {
37
+ this.scope = scope;
38
+ }
39
+
40
+ /** Get the current entry for a name, if any. */
41
+ peek(name: string): RegistryEntry | undefined {
42
+ return this.entries.get(name);
43
+ }
44
+
45
+ /**
46
+ * Ensure a component is loaded. Triggers an initial fetch if we don't yet
47
+ * have an entry for this name. Returns the latest known entry.
48
+ */
49
+ async ensure(name: string): Promise<RegistryEntry> {
50
+ const existing = this.entries.get(name);
51
+ if (existing) return existing;
52
+ const pending = this.inflight.get(name);
53
+ if (pending) return pending;
54
+ const p = this.source
55
+ .fetch(name)
56
+ .then((module) => this.ingest(name, module))
57
+ .finally(() => {
58
+ this.inflight.delete(name);
59
+ });
60
+ this.inflight.set(name, p);
61
+ return p;
62
+ }
63
+
64
+ /** Subscribe to changes for a specific component. */
65
+ subscribe(name: string, listener: RegistryListener): () => void {
66
+ let set = this.listeners.get(name);
67
+ if (!set) {
68
+ set = new Set();
69
+ this.listeners.set(name, set);
70
+ }
71
+ set.add(listener);
72
+ return () => {
73
+ set!.delete(listener);
74
+ if (set!.size === 0) this.listeners.delete(name);
75
+ };
76
+ }
77
+
78
+ /** Subscribe to all changes (used internally / for debugging). */
79
+ subscribeAll(listener: RegistryListener): () => void {
80
+ this.anyListeners.add(listener);
81
+ return () => {
82
+ this.anyListeners.delete(listener);
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Resolve a relative-path require from inside a dynamic component.
88
+ * Cross-component imports look up other components by name in the registry.
89
+ * v1: we map "./Other" -> "Other" (basename, no extension).
90
+ *
91
+ * The returned value is a *lazy proxy module*: it has the same shape as
92
+ * a real CommonJS module (`{ default, ...rest }`), but every member is a
93
+ * React function component that re-resolves the target on each render.
94
+ * This means:
95
+ * - Card can `require("./Hello")` at eval time even before Hello has
96
+ * loaded — the proxy is returned immediately.
97
+ * - When Hello actually arrives (or hot-swaps to a new version), Card's
98
+ * next render automatically picks it up; no manual preload needed.
99
+ */
100
+ requireByPath(specifier: string): unknown {
101
+ const base = specifier
102
+ .replace(/^\.+\//, "")
103
+ .replace(/\.[tj]sx?$/, "")
104
+ .split("/")
105
+ .pop();
106
+ if (!base) {
107
+ throw new Error(`dynamico: cannot resolve relative require '${specifier}'`);
108
+ }
109
+ if (!this.entries.has(base) && !this.inflight.has(base)) {
110
+ void this.ensure(base);
111
+ }
112
+ return this.makeLazyProxy(base);
113
+ }
114
+
115
+ private lazyProxies = new Map<string, Record<string, unknown>>();
116
+
117
+ private makeLazyProxy(name: string): Record<string, unknown> {
118
+ const cached = this.lazyProxies.get(name);
119
+ if (cached) return cached;
120
+ const registry = this;
121
+ const proxy: Record<string, unknown> = {};
122
+ const make = (key: string) => {
123
+ const Comp = function LazyDynamic(props: Record<string, unknown>) {
124
+ const entry = registry.entries.get(name);
125
+ // Not loaded yet — render nothing. When the dependency arrives, the
126
+ // cross-dep notification in `notify()` refreshes our parent's entry,
127
+ // which causes useSyncExternalStore to re-render and we'll resolve
128
+ // for real on the next pass.
129
+ if (!entry || (!entry.factory && !entry.error)) return null;
130
+ if (entry.error) {
131
+ // Surface the dep error inline. Parent can wrap in errorFallback at
132
+ // its own level if it wants; we don't have access to it here.
133
+ return null;
134
+ }
135
+ const target = entry.factory?.[key];
136
+ if (typeof target !== "function") return null;
137
+ // The host-scope's React.createElement is what invoked us; we just
138
+ // call the real component function. props is what the parent passed.
139
+ return (target as (p: Record<string, unknown>) => unknown)(props);
140
+ };
141
+ Object.defineProperty(Comp, "name", { value: `Lazy(${name}.${key})` });
142
+ return Comp;
143
+ };
144
+ proxy.default = make("default");
145
+ // Allow named imports via Proxy: any key access returns a fresh lazy
146
+ // component bound to that key. Default is set above; everything else
147
+ // is created on demand.
148
+ const handler: ProxyHandler<Record<string, unknown>> = {
149
+ get(target, prop) {
150
+ if (typeof prop !== "string") return undefined;
151
+ if (prop in target) return target[prop];
152
+ const c = make(prop);
153
+ target[prop] = c;
154
+ return c;
155
+ },
156
+ };
157
+ const wrapped = new Proxy(proxy, handler);
158
+ this.lazyProxies.set(name, wrapped);
159
+ return wrapped;
160
+ }
161
+
162
+ /**
163
+ * Take a CompiledModule from the source, evaluate it (or record a compile
164
+ * error), update the entry, and notify listeners.
165
+ */
166
+ private ingest(name: string, module: import("./types.js").CompiledModule): RegistryEntry {
167
+ if (module.removed) {
168
+ this.entries.delete(name);
169
+ this.lazyProxies.delete(name);
170
+ const removalEntry: RegistryEntry = {
171
+ name,
172
+ version: module.version,
173
+ error: {
174
+ kind: "load",
175
+ name,
176
+ version: module.version,
177
+ message: `'${name}' was removed from the registry`,
178
+ },
179
+ };
180
+ this.notify(name, removalEntry);
181
+ return removalEntry;
182
+ }
183
+ let entry: RegistryEntry;
184
+ if (module.error) {
185
+ entry = {
186
+ name,
187
+ version: module.version,
188
+ error: {
189
+ kind: module.error.kind === "typecheck" ? "load" : "compile",
190
+ name,
191
+ version: module.version,
192
+ message: module.error.message,
193
+ stack: module.error.stack,
194
+ },
195
+ };
196
+ } else {
197
+ try {
198
+ const factory = loadModule(module.code, this.scope, (rel) =>
199
+ this.requireByPath(rel),
200
+ ) as ComponentFactory;
201
+ entry = { name, version: module.version, factory };
202
+ } catch (err) {
203
+ entry = {
204
+ name,
205
+ version: module.version,
206
+ error: toLoadError(name, module.version, err),
207
+ };
208
+ }
209
+ }
210
+ this.entries.set(name, entry);
211
+ this.notify(name, entry);
212
+ return entry;
213
+ }
214
+
215
+ private notify(name: string, entry: RegistryEntry): void {
216
+ const set = this.listeners.get(name);
217
+ if (set) for (const l of set) l(entry);
218
+ for (const l of this.anyListeners) l(entry);
219
+
220
+ // A new/updated component may be a dependency of others that are already
221
+ // mounted via lazy proxies. For v1 we don't track an explicit dep graph;
222
+ // instead we notify every other subscribed name with a *fresh entry
223
+ // object* (same content, new identity) so that useSyncExternalStore
224
+ // picks up the change and React re-renders, which causes the lazy
225
+ // proxy's render path to re-resolve the now-loaded dependency.
226
+ for (const [otherName, listeners] of this.listeners) {
227
+ if (otherName === name) continue;
228
+ const other = this.entries.get(otherName);
229
+ if (!other) continue;
230
+ const refreshed: RegistryEntry = { ...other };
231
+ this.entries.set(otherName, refreshed);
232
+ for (const l of listeners) l(refreshed);
233
+ }
234
+ }
235
+ }
236
+
237
+ function toLoadError(name: string, version: Version, err: unknown): DynamicError {
238
+ const e = err instanceof Error ? err : new Error(String(err));
239
+ return { kind: "load", name, version, message: e.message, stack: e.stack };
240
+ }
@@ -0,0 +1,119 @@
1
+ import type { CompiledModule, Source, SourceUpdate } from "../types.js";
2
+
3
+ export interface RemoteSourceOptions {
4
+ /** Base URL of the registry server, e.g. "http://localhost:4000" or "https://reg.example.com". */
5
+ url: string;
6
+ /** Optional WebSocket URL override. Defaults to `url` with http(s) -> ws(s). */
7
+ wsUrl?: string;
8
+ /** Custom fetch (for environments without a global fetch). */
9
+ fetch?: typeof fetch;
10
+ /** Custom WebSocket constructor (RN provides one globally; Node 18+ has it too). */
11
+ WebSocket?: typeof WebSocket;
12
+ /** Reconnection backoff in ms. Default: 1000. */
13
+ reconnectMs?: number;
14
+ }
15
+
16
+ /**
17
+ * Talks to @omriashke/dynamico-registry (or any compatible server).
18
+ *
19
+ * GET {url}/component/:name -> CompiledModule (initial fetch)
20
+ * WS {wsUrl}/subscribe -> stream of CompiledModule updates
21
+ */
22
+ export function createRemoteSource(options: RemoteSourceOptions): Source {
23
+ const fetchImpl: typeof fetch =
24
+ options.fetch ??
25
+ (typeof fetch !== "undefined"
26
+ ? fetch
27
+ : ((() => {
28
+ throw new Error("dynamico: no fetch implementation available");
29
+ }) as unknown as typeof fetch));
30
+ const WSCtor: typeof WebSocket =
31
+ options.WebSocket ??
32
+ (typeof WebSocket !== "undefined"
33
+ ? WebSocket
34
+ : (function MissingWS() {
35
+ throw new Error("dynamico: no WebSocket implementation available");
36
+ } as unknown as typeof WebSocket));
37
+
38
+ const httpUrl = options.url.replace(/\/$/, "");
39
+ const wsUrl =
40
+ options.wsUrl ?? httpUrl.replace(/^http/, "ws") + "/subscribe";
41
+
42
+ const listeners = new Set<(u: SourceUpdate) => void>();
43
+ let socket: WebSocket | null = null;
44
+ let disposed = false;
45
+ const reconnectMs = options.reconnectMs ?? 1000;
46
+
47
+ function connect(): void {
48
+ if (disposed) return;
49
+ try {
50
+ socket = new WSCtor(wsUrl);
51
+ } catch (err) {
52
+ scheduleReconnect();
53
+ return;
54
+ }
55
+ socket.onmessage = (ev: MessageEvent) => {
56
+ try {
57
+ const data =
58
+ typeof ev.data === "string"
59
+ ? JSON.parse(ev.data)
60
+ : JSON.parse(new TextDecoder().decode(ev.data as ArrayBuffer));
61
+ if (data && typeof data.name === "string" && typeof data.version === "string") {
62
+ const module: CompiledModule = data;
63
+ for (const l of listeners) l({ module });
64
+ }
65
+ } catch {
66
+ /* ignore malformed frames */
67
+ }
68
+ };
69
+ socket.onclose = () => {
70
+ socket = null;
71
+ scheduleReconnect();
72
+ };
73
+ socket.onerror = () => {
74
+ try {
75
+ socket?.close();
76
+ } catch {
77
+ /* noop */
78
+ }
79
+ };
80
+ }
81
+
82
+ function scheduleReconnect(): void {
83
+ if (disposed) return;
84
+ setTimeout(connect, reconnectMs);
85
+ }
86
+
87
+ connect();
88
+
89
+ return {
90
+ async fetch(name: string): Promise<CompiledModule> {
91
+ const res = await fetchImpl(`${httpUrl}/component/${encodeURIComponent(name)}`);
92
+ if (!res.ok) {
93
+ return {
94
+ name,
95
+ version: "0",
96
+ error: {
97
+ kind: "compile",
98
+ message: `registry returned ${res.status} for ${name}`,
99
+ },
100
+ };
101
+ }
102
+ return (await res.json()) as CompiledModule;
103
+ },
104
+ subscribe(listener) {
105
+ listeners.add(listener);
106
+ return () => {
107
+ listeners.delete(listener);
108
+ };
109
+ },
110
+ dispose() {
111
+ disposed = true;
112
+ try {
113
+ socket?.close();
114
+ } catch {
115
+ /* noop */
116
+ }
117
+ },
118
+ };
119
+ }
package/src/types.ts ADDED
@@ -0,0 +1,96 @@
1
+ export type Version = string;
2
+
3
+ export type Scope = Record<string, unknown>;
4
+
5
+ export interface PropsSchemaField {
6
+ type: "string" | "number" | "boolean" | "object" | "array" | "any";
7
+ required?: boolean;
8
+ }
9
+
10
+ export type PropsSchema = Record<string, PropsSchemaField>;
11
+
12
+ export interface Diagnostic {
13
+ severity: "error" | "warning";
14
+ message: string;
15
+ /** 1-based line in the source. */
16
+ line?: number;
17
+ /** 1-based column. */
18
+ column?: number;
19
+ /** TypeScript or Babel diagnostic code, e.g. "TS2304". */
20
+ code?: string;
21
+ /** A short snippet of the offending line, when available. */
22
+ snippet?: string;
23
+ }
24
+
25
+ export interface CompiledModuleOk {
26
+ name: string;
27
+ version: Version;
28
+ code: string;
29
+ /** Type-check warnings that didn't block compilation. */
30
+ warnings?: Diagnostic[];
31
+ error?: undefined;
32
+ removed?: undefined;
33
+ }
34
+
35
+ export interface CompiledModuleError {
36
+ name: string;
37
+ version: Version;
38
+ code?: undefined;
39
+ error: {
40
+ kind: "compile" | "typecheck";
41
+ message: string;
42
+ stack?: string;
43
+ diagnostics?: Diagnostic[];
44
+ };
45
+ removed?: undefined;
46
+ }
47
+
48
+ /** A removal event broadcast over WS when DELETE /component/:name is called. */
49
+ export interface CompiledModuleRemoved {
50
+ name: string;
51
+ version: Version;
52
+ removed: true;
53
+ code?: undefined;
54
+ error?: undefined;
55
+ }
56
+
57
+ export type CompiledModule =
58
+ | CompiledModuleOk
59
+ | CompiledModuleError
60
+ | CompiledModuleRemoved;
61
+
62
+ export interface DynamicError {
63
+ kind: "compile" | "load" | "render";
64
+ name: string;
65
+ version: Version;
66
+ message: string;
67
+ stack?: string;
68
+ }
69
+
70
+ export type ComponentFactory = {
71
+ default?: unknown;
72
+ propsSchema?: PropsSchema;
73
+ [key: string]: unknown;
74
+ };
75
+
76
+ export interface RegistryEntry {
77
+ name: string;
78
+ version: Version;
79
+ factory?: ComponentFactory;
80
+ error?: DynamicError;
81
+ }
82
+
83
+ export type RegistryListener = (entry: RegistryEntry) => void;
84
+
85
+ export interface SourceUpdate {
86
+ module: CompiledModule;
87
+ }
88
+
89
+ export interface Source {
90
+ /** Fetch the latest version of a single component (initial load). */
91
+ fetch(name: string): Promise<CompiledModule>;
92
+ /** Subscribe to updates for any component. Returns unsubscribe fn. */
93
+ subscribe(listener: (update: SourceUpdate) => void): () => void;
94
+ /** Optional disposal hook. */
95
+ dispose?(): void;
96
+ }