@omriashke/dynamico-core 0.1.8 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/bookPreview.d.ts +3 -0
  2. package/dist/bookPreview.d.ts.map +1 -1
  3. package/dist/bookPreview.js +5 -0
  4. package/dist/bookPreview.js.map +1 -1
  5. package/dist/constants.d.ts +6 -0
  6. package/dist/constants.d.ts.map +1 -0
  7. package/dist/constants.js +8 -0
  8. package/dist/constants.js.map +1 -0
  9. package/dist/esbuildFlatten.d.ts +15 -0
  10. package/dist/esbuildFlatten.d.ts.map +1 -0
  11. package/dist/esbuildFlatten.js +39 -0
  12. package/dist/esbuildFlatten.js.map +1 -0
  13. package/dist/index.d.ts +7 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +7 -3
  16. package/dist/index.js.map +1 -1
  17. package/dist/loader.d.ts +2 -0
  18. package/dist/loader.d.ts.map +1 -1
  19. package/dist/loader.js +62 -19
  20. package/dist/loader.js.map +1 -1
  21. package/dist/node/bookConfig.d.ts +13 -0
  22. package/dist/node/bookConfig.d.ts.map +1 -0
  23. package/dist/node/bookConfig.js +54 -0
  24. package/dist/node/bookConfig.js.map +1 -0
  25. package/dist/node/index.d.ts +2 -0
  26. package/dist/node/index.d.ts.map +1 -0
  27. package/dist/node/index.js +2 -0
  28. package/dist/node/index.js.map +1 -0
  29. package/dist/packageScope.d.ts.map +1 -1
  30. package/dist/packageScope.js +15 -7
  31. package/dist/packageScope.js.map +1 -1
  32. package/dist/propsSchema.d.ts +2 -0
  33. package/dist/propsSchema.d.ts.map +1 -1
  34. package/dist/propsSchema.js +36 -0
  35. package/dist/propsSchema.js.map +1 -1
  36. package/dist/react/createRuntime.d.ts.map +1 -1
  37. package/dist/react/createRuntime.js +3 -13
  38. package/dist/react/createRuntime.js.map +1 -1
  39. package/dist/react/useRegistryModule.d.ts +4 -0
  40. package/dist/react/useRegistryModule.d.ts.map +1 -0
  41. package/dist/react/useRegistryModule.js +8 -0
  42. package/dist/react/useRegistryModule.js.map +1 -0
  43. package/dist/registry.d.ts +5 -0
  44. package/dist/registry.d.ts.map +1 -1
  45. package/dist/registry.js +35 -4
  46. package/dist/registry.js.map +1 -1
  47. package/dist/registryModule.d.ts.map +1 -1
  48. package/dist/registryModule.js +13 -2
  49. package/dist/registryModule.js.map +1 -1
  50. package/dist/relativeRequires.d.ts +12 -0
  51. package/dist/relativeRequires.d.ts.map +1 -1
  52. package/dist/relativeRequires.js +33 -0
  53. package/dist/relativeRequires.js.map +1 -1
  54. package/dist/sources/remote.d.ts +7 -8
  55. package/dist/sources/remote.d.ts.map +1 -1
  56. package/dist/sources/remote.js +73 -19
  57. package/dist/sources/remote.js.map +1 -1
  58. package/dist/types.d.ts +6 -0
  59. package/dist/types.d.ts.map +1 -1
  60. package/package.json +11 -2
  61. package/src/bookPreview.ts +7 -0
  62. package/src/constants.ts +9 -0
  63. package/src/esbuildFlatten.ts +47 -0
  64. package/src/index.ts +22 -2
  65. package/src/loader.ts +52 -19
  66. package/src/node/bookConfig.ts +63 -0
  67. package/src/node/index.ts +9 -0
  68. package/src/packageScope.ts +15 -7
  69. package/src/propsSchema.ts +35 -0
  70. package/src/react/createRuntime.tsx +3 -10
  71. package/src/react/useRegistryModule.ts +15 -0
  72. package/src/registry.ts +39 -4
  73. package/src/registryModule.ts +12 -3
  74. package/src/relativeRequires.ts +48 -0
  75. package/src/sources/remote.ts +72 -26
  76. package/src/types.ts +6 -0
@@ -3,6 +3,13 @@ import type { PropsSchema } from "./types.js";
3
3
 
4
4
  export type BookPreviewJson = Record<string, unknown>;
5
5
 
6
+ /** Filenames recognized as Dynamico Book catalog configs on disk. */
7
+ export const BOOK_CONFIG_FILENAMES = ["book.config.json", "storybook.config.json"] as const;
8
+
9
+ export function isBookConfigFilename(name: string): boolean {
10
+ return (BOOK_CONFIG_FILENAMES as readonly string[]).includes(name);
11
+ }
12
+
6
13
  export interface BookPreviewConfig {
7
14
  fixtures?: Record<string, BookPreviewJson>;
8
15
  /** Registry components wrapping every preview (outermost last). */
@@ -0,0 +1,9 @@
1
+ /** Registry manifest filename at the source directory root. */
2
+ export const MANIFEST_FILENAME = "dynamico.config.json" as const;
3
+
4
+ /** Matches co-located author test files — not registry components. */
5
+ export const COMPONENT_TEST_RE = /\.test\.(tsx|jsx|ts|js)$/;
6
+
7
+ export function isComponentTestFilename(filename: string): boolean {
8
+ return COMPONENT_TEST_RE.test(filename);
9
+ }
@@ -0,0 +1,47 @@
1
+ /** Marker appended once so loadModule does not double-flatten esbuild bundles. */
2
+ export const ESBUILD_FLATTEN_MARKER = ";// dynamico-flat-exports";
3
+
4
+ export interface EsbuildExportEntry {
5
+ exportKey: string;
6
+ varName: string;
7
+ }
8
+
9
+ /** Parse esbuild `__export(mod, { name: () => var, ... })` named export entries. */
10
+ export function parseEsbuildNamedExports(code: string): EsbuildExportEntry[] {
11
+ const exportBlockMatch = code.match(/__export\(\w+,\s*\{([\s\S]*?)\}\s*\)/);
12
+ if (!exportBlockMatch) return [];
13
+ const entries: EsbuildExportEntry[] = [];
14
+ for (const m of exportBlockMatch[1].matchAll(/(\w+):\s*\(\)\s*=>\s*(\w+)/g)) {
15
+ entries.push({ exportKey: m[1], varName: m[2] });
16
+ }
17
+ return entries;
18
+ }
19
+
20
+ /**
21
+ * Append a plain `module.exports = { ... }` assignment so Hermes and relative
22
+ * imports (e.g. `require("../Colors").Colors`) see named exports, not only
23
+ * getter-based defaults.
24
+ */
25
+ export function appendPlainEsbuildExports(code: string): string {
26
+ if (code.includes(ESBUILD_FLATTEN_MARKER)) return code;
27
+
28
+ const entries = parseEsbuildNamedExports(code);
29
+ if (entries.length === 0) {
30
+ const defaultMatch = code.match(/default:\s*\(\)\s*=>\s*(\w+)/);
31
+ if (!defaultMatch) return code;
32
+ const fn = defaultMatch[1];
33
+ const propsMatch = code.match(/propsSchema:\s*\(\)\s*=>\s*(\w+)/);
34
+ const propsPart = propsMatch ? `,propsSchema:${propsMatch[1]}` : "";
35
+ return `${code}${ESBUILD_FLATTEN_MARKER}\n;(function(){try{if(typeof ${fn}==='function'){module.exports={__esModule:true,default:${fn}${propsPart}};}}catch(e){}})();\n`;
36
+ }
37
+
38
+ const parts = entries.map(({ exportKey, varName }) =>
39
+ exportKey === "default" ? `default:${varName}` : `${exportKey}:${varName}`,
40
+ );
41
+ const defaultEntry = entries.find((e) => e.exportKey === "default");
42
+ const guard = defaultEntry
43
+ ? `(typeof ${defaultEntry.varName}!=='undefined')`
44
+ : "true";
45
+
46
+ return `${code}${ESBUILD_FLATTEN_MARKER}\n;(function(){try{if(${guard}){module.exports={__esModule:true,${parts.join(",")}};}}catch(e){}})();\n`;
47
+ }
package/src/index.ts CHANGED
@@ -17,9 +17,14 @@ export type {
17
17
  } from "./types.js";
18
18
 
19
19
  export { Registry } from "./registry.js";
20
- export { loadModule } from "./loader.js";
20
+ export { loadModule, resolveModuleDefault } from "./loader.js";
21
+ export {
22
+ appendPlainEsbuildExports,
23
+ parseEsbuildNamedExports,
24
+ ESBUILD_FLATTEN_MARKER,
25
+ } from "./esbuildFlatten.js";
21
26
  export { createRemoteSource, type RemoteSourceOptions } from "./sources/remote.js";
22
- export { validateProps, type PropsValidationResult } from "./propsSchema.js";
27
+ export { validateProps, extractPropsSchema, type PropsValidationResult } from "./propsSchema.js";
23
28
  export { generateDefaultProps } from "./defaultProps.js";
24
29
  export {
25
30
  collectBookPreviewPropSets,
@@ -27,12 +32,26 @@ export {
27
32
  resolveBookFixtures,
28
33
  resolveBookPropValues,
29
34
  validateBookPreviewsForComponent,
35
+ BOOK_CONFIG_FILENAMES,
36
+ isBookConfigFilename,
30
37
  type BookPreviewBlock,
31
38
  type BookPreviewConfig,
32
39
  type BookPreviewEntry,
33
40
  type BookPreviewPropSet,
34
41
  type BookPreviewValidationResult,
35
42
  } from "./bookPreview.js";
43
+ export {
44
+ resolveRelativeComponentName,
45
+ extractRelativeRequires,
46
+ collectRelativeComponentDeps,
47
+ validateRelativeImports,
48
+ type RelativeImportValidation,
49
+ } from "./relativeRequires.js";
50
+ export {
51
+ MANIFEST_FILENAME,
52
+ COMPONENT_TEST_RE,
53
+ isComponentTestFilename,
54
+ } from "./constants.js";
36
55
  export {
37
56
  createRuntime,
38
57
  type RuntimeAPI,
@@ -40,6 +59,7 @@ export {
40
59
  type DynamicoProviderProps,
41
60
  type DynamicComponentProps,
42
61
  } from "./react/createRuntime.js";
62
+ export { createUseRegistryModule } from "./react/useRegistryModule.js";
43
63
  export {
44
64
  createPackageScope,
45
65
  createPackageScopeFromNames,
package/src/loader.ts CHANGED
@@ -1,11 +1,26 @@
1
1
  import type { Scope } from "./types.js";
2
+ import { appendPlainEsbuildExports } from "./esbuildFlatten.js";
2
3
 
3
- /** esbuild CJS bundles assign exports before the component function; materialize at end. */
4
- function patchEsbuildDefaultExport(code: string): string {
5
- const m = code.match(/default:\s*\(\)\s*=>\s*(\w+)/);
6
- if (!m) return code;
7
- const fn = m[1];
8
- return `${code}\n;try{if(typeof ${fn}==='function'){module.exports.default=${fn};}}catch(e){}\n`;
4
+ /** Copy exports to a plain object (no getters) so Hermes can read `.default` reliably. */
5
+ function toPlainExports(exports: Record<string, unknown>): Record<string, unknown> {
6
+ const plain: Record<string, unknown> = {};
7
+ for (const key of Object.getOwnPropertyNames(exports)) {
8
+ const desc = Object.getOwnPropertyDescriptor(exports, key);
9
+ if (desc?.get && !desc.set) {
10
+ try {
11
+ const value = desc.get.call(exports);
12
+ if (value !== undefined) plain[key] = value;
13
+ } catch {
14
+ /* skip broken getter */
15
+ }
16
+ } else if (desc && "value" in desc) {
17
+ plain[key] = desc.value;
18
+ } else {
19
+ plain[key] = exports[key];
20
+ }
21
+ }
22
+ if (!("__esModule" in plain)) plain.__esModule = true;
23
+ return plain;
9
24
  }
10
25
 
11
26
  /** Replace getter-only exports with plain values (Hermes-safe). */
@@ -16,12 +31,16 @@ function materializeGetterExports(exports: Record<string, unknown>): void {
16
31
  try {
17
32
  const value = desc.get.call(exports);
18
33
  if (value !== undefined) {
19
- Object.defineProperty(exports, key, {
20
- value,
21
- enumerable: true,
22
- configurable: true,
23
- writable: true,
24
- });
34
+ try {
35
+ Object.defineProperty(exports, key, {
36
+ value,
37
+ enumerable: true,
38
+ configurable: true,
39
+ writable: true,
40
+ });
41
+ } catch {
42
+ (exports as Record<string, unknown>)[key] = value;
43
+ }
25
44
  }
26
45
  } catch {
27
46
  /* leave getter */
@@ -29,6 +48,25 @@ function materializeGetterExports(exports: Record<string, unknown>): void {
29
48
  }
30
49
  }
31
50
 
51
+ /** Resolve the default export from a loaded CJS module object (Hermes-safe). */
52
+ export function resolveModuleDefault(exp: unknown): unknown {
53
+ if (typeof exp === "function") return exp;
54
+ if (!exp || typeof exp !== "object") return undefined;
55
+ const exports = exp as Record<string, unknown>;
56
+ const desc = Object.getOwnPropertyDescriptor(exports, "default");
57
+ if (desc?.get && !desc.set) {
58
+ try {
59
+ const fromGetter = desc.get.call(exports);
60
+ if (typeof fromGetter === "function") return fromGetter;
61
+ } catch {
62
+ /* fall through */
63
+ }
64
+ }
65
+ const d = exports.default;
66
+ if (typeof d === "function") return d;
67
+ return undefined;
68
+ }
69
+
32
70
  /**
33
71
  * Execute a CommonJS-style code string in a controlled scope.
34
72
  *
@@ -52,9 +90,6 @@ export function loadModule(
52
90
  if (name.startsWith("./") || name.startsWith("../") || name.startsWith("/")) {
53
91
  return requireRelative(name);
54
92
  }
55
- // Use `in` (triggers Proxy `has` traps) so that hosts can supply scope
56
- // via a Proxy and resolve module bindings lazily. Falls back to
57
- // `hasOwnProperty` semantics for plain object scopes.
58
93
  if (name in scope) {
59
94
  return scope[name];
60
95
  }
@@ -63,11 +98,9 @@ export function loadModule(
63
98
  );
64
99
  };
65
100
 
66
- // The compiled body is just the function body; arguments are well-known.
67
101
  // eslint-disable-next-line no-new-func
68
- const fn = new Function("module", "exports", "require", patchEsbuildDefaultExport(code));
102
+ const fn = new Function("module", "exports", "require", appendPlainEsbuildExports(code));
69
103
  fn(moduleObj, moduleObj.exports, requireFn);
70
104
  materializeGetterExports(moduleObj.exports);
71
-
72
- return moduleObj.exports;
105
+ return toPlainExports(moduleObj.exports);
73
106
  }
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import {
5
+ BOOK_CONFIG_FILENAMES,
6
+ isBookConfigFilename,
7
+ type BookPreviewConfig,
8
+ } from "../bookPreview.js";
9
+
10
+ export type BookConfigFilename = (typeof BOOK_CONFIG_FILENAMES)[number];
11
+
12
+ export interface BookConfigFile {
13
+ filename: BookConfigFilename;
14
+ source: string;
15
+ }
16
+
17
+ export function bookConfigPathSync(dir: string): string | null {
18
+ for (const name of BOOK_CONFIG_FILENAMES) {
19
+ const filePath = join(dir, name);
20
+ if (existsSync(filePath)) return filePath;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ export function readBookPreviewConfigSync(dir: string): BookPreviewConfig | undefined {
26
+ const filePath = bookConfigPathSync(dir);
27
+ if (!filePath) return undefined;
28
+ try {
29
+ return JSON.parse(readFileSync(filePath, "utf8")) as BookPreviewConfig;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ export async function readBookPreviewConfigAsync(dir: string): Promise<BookPreviewConfig | undefined> {
36
+ for (const name of BOOK_CONFIG_FILENAMES) {
37
+ const filePath = join(dir, name);
38
+ if (!existsSync(filePath)) continue;
39
+ try {
40
+ return JSON.parse(await readFile(filePath, "utf8")) as BookPreviewConfig;
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ /** Raw book catalog file to ship with `dynamico push --dir`. */
49
+ export async function readBookConfigFileAsync(dir: string): Promise<BookConfigFile | undefined> {
50
+ for (const filename of BOOK_CONFIG_FILENAMES) {
51
+ const filePath = join(dir, filename);
52
+ if (!existsSync(filePath)) continue;
53
+ const source = await readFile(filePath, "utf8");
54
+ JSON.parse(source);
55
+ return { filename, source };
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ export function isBookConfigPath(relPath: string): boolean {
61
+ const base = relPath.split(/[/\\]/).pop() ?? relPath;
62
+ return isBookConfigFilename(base);
63
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ bookConfigPathSync,
3
+ readBookPreviewConfigSync,
4
+ readBookPreviewConfigAsync,
5
+ readBookConfigFileAsync,
6
+ isBookConfigPath,
7
+ type BookConfigFilename,
8
+ type BookConfigFile,
9
+ } from "./bookConfig.js";
@@ -1,6 +1,7 @@
1
1
  import { createElement, useSyncExternalStore, type ComponentType } from "react";
2
2
  import type { CompiledModule, Scope, Source } from "./types.js";
3
3
  import { loadModule } from "./loader.js";
4
+ import { resolveRelativeComponentName } from "./relativeRequires.js";
4
5
 
5
6
  export interface PackageScopeOptions {
6
7
  /** Registry component names (export name = registry name). */
@@ -22,6 +23,7 @@ interface ModuleState {
22
23
  /** Bumped on every ingest so useSyncExternalStore subscribers re-render. */
23
24
  revision: number;
24
25
  listeners: Set<() => void>;
26
+ watchRelease?: () => void;
25
27
  }
26
28
 
27
29
  /**
@@ -52,9 +54,17 @@ export function createPackageScope(
52
54
 
53
55
  const subscribe = (name: string, listener: () => void): (() => void) => {
54
56
  const state = getState(name);
57
+ const wasEmpty = state.listeners.size === 0;
55
58
  state.listeners.add(listener);
59
+ if (wasEmpty && source.watch) {
60
+ state.watchRelease = source.watch(name);
61
+ }
56
62
  return () => {
57
63
  state.listeners.delete(listener);
64
+ if (state.listeners.size === 0) {
65
+ state.watchRelease?.();
66
+ state.watchRelease = undefined;
67
+ }
58
68
  };
59
69
  };
60
70
 
@@ -74,11 +84,7 @@ export function createPackageScope(
74
84
  let makeLazy: (name: string) => LazyComponent;
75
85
 
76
86
  const requireRelative = (specifier: string): unknown => {
77
- const base = specifier
78
- .replace(/^\.+\//, "")
79
- .replace(/\.[tj]sx?$/, "")
80
- .split("/")
81
- .pop();
87
+ const base = resolveRelativeComponentName(specifier);
82
88
  if (!base) throw new Error(`dynamico: cannot resolve '${specifier}'`);
83
89
  ensureLoaded(base);
84
90
  const dep = modules.get(base)?.factory;
@@ -128,14 +134,16 @@ export function createPackageScope(
128
134
  };
129
135
 
130
136
  source.subscribe(({ module }) => {
131
- if (componentSet.has(module.name)) {
137
+ if (!componentSet.has(module.name)) return;
138
+ const state = getState(module.name);
139
+ if (state.factory !== undefined || state.listeners.size > 0 || state.loading) {
132
140
  ingest(module.name, module);
133
141
  }
134
142
  });
135
143
 
136
144
  makeLazy = (name: string): LazyComponent => {
137
- ensureLoaded(name);
138
145
  const Lazy: LazyComponent = (props) => {
146
+ ensureLoaded(name);
139
147
  const revision = useSyncExternalStore(
140
148
  (cb) => subscribe(name, cb),
141
149
  () => getRevision(name),
@@ -43,3 +43,38 @@ function describe(v: unknown): string {
43
43
  if (typeof v === "function") return "function";
44
44
  return typeof v;
45
45
  }
46
+
47
+ /** Parse `export const propsSchema = { … }` from component source text. */
48
+ export function extractPropsSchema(source: string): PropsSchema | undefined {
49
+ const marker = "export const propsSchema";
50
+ const idx = source.indexOf(marker);
51
+ if (idx < 0) return undefined;
52
+
53
+ const after = source.slice(idx + marker.length);
54
+ const eq = after.indexOf("=");
55
+ if (eq < 0) return undefined;
56
+
57
+ let rest = after.slice(eq + 1).trimStart();
58
+ if (!rest.startsWith("{")) return undefined;
59
+
60
+ let depth = 0;
61
+ let end = 0;
62
+ for (let i = 0; i < rest.length; i++) {
63
+ const ch = rest[i];
64
+ if (ch === "{") depth++;
65
+ else if (ch === "}") {
66
+ depth--;
67
+ if (depth === 0) {
68
+ end = i + 1;
69
+ break;
70
+ }
71
+ }
72
+ }
73
+ if (end === 0) return undefined;
74
+
75
+ try {
76
+ return new Function(`return (${rest.slice(0, end)})`)() as PropsSchema;
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { Registry } from "../registry.js";
3
+ import { resolveModuleDefault } from "../loader.js";
3
4
  import type {
4
5
  ComponentFactory,
5
6
  DynamicError,
@@ -187,16 +188,8 @@ export function createRuntime(
187
188
  }
188
189
 
189
190
  function pickDefault(factory: ComponentFactory): unknown {
190
- if (factory && typeof factory === "object") {
191
- if ("default" in factory) {
192
- const d = factory.default;
193
- if (typeof d === "function") return d;
194
- }
195
- // CommonJS interop: the value itself may be the component
196
- if (typeof factory === "function") return factory;
197
- }
198
- if (typeof factory === "function") return factory;
199
- return undefined;
191
+ const d = resolveModuleDefault(factory);
192
+ return typeof d === "function" ? d : undefined;
200
193
  }
201
194
 
202
195
  function defaultErrorView(error: DynamicError): React.ReactElement {
@@ -0,0 +1,15 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import type { RegistryModuleSubscription } from "../registryModule.js";
3
+
4
+ /** React hook factory — re-renders when a registry data module (e.g. Colors) is pushed. */
5
+ export function createUseRegistryModule<T extends Record<string, unknown>>(
6
+ subscription: RegistryModuleSubscription<T>,
7
+ ): () => T {
8
+ return function useRegistryModule() {
9
+ return useSyncExternalStore(
10
+ subscription.subscribe,
11
+ subscription.getSnapshot,
12
+ subscription.getSnapshot,
13
+ );
14
+ };
15
+ }
package/src/registry.ts CHANGED
@@ -23,16 +23,38 @@ export class Registry {
23
23
  private listeners = new Map<string, Set<RegistryListener>>();
24
24
  private anyListeners = new Set<RegistryListener>();
25
25
  private inflight = new Map<string, Promise<RegistryEntry>>();
26
+ /** WS push cache — modules are ingested only when ensure() or a subscriber asks. */
27
+ private moduleCache = new Map<string, import("./types.js").CompiledModule>();
28
+ private watchReleases = new Map<string, () => void>();
26
29
 
27
30
  constructor(
28
31
  private readonly source: Source,
29
32
  private scope: Scope,
30
33
  ) {
31
34
  this.source.subscribe(({ module }) => {
32
- void this.ingestAsync(module.name, module);
35
+ if (module.removed) {
36
+ this.moduleCache.delete(module.name);
37
+ if (this.entries.has(module.name) || (this.listeners.get(module.name)?.size ?? 0) > 0) {
38
+ void this.ingestAsync(module.name, module);
39
+ }
40
+ return;
41
+ }
42
+ this.moduleCache.set(module.name, module);
43
+ if (this.shouldIngestOnPush(module.name)) {
44
+ void this.ingestAsync(module.name, module);
45
+ }
33
46
  });
34
47
  }
35
48
 
49
+ /** Re-ingest a WS push when the component is already loaded or has active subscribers. */
50
+ private shouldIngestOnPush(name: string): boolean {
51
+ return (
52
+ this.entries.has(name) ||
53
+ (this.listeners.get(name)?.size ?? 0) > 0 ||
54
+ this.inflight.has(name)
55
+ );
56
+ }
57
+
36
58
  /** Replace or extend the current scope (rare; typically set once). */
37
59
  setScope(scope: Scope): void {
38
60
  this.scope = scope;
@@ -61,8 +83,8 @@ export class Registry {
61
83
  if (existing) return existing;
62
84
  const pending = this.inflight.get(name);
63
85
  if (pending) return pending;
64
- const p = this.source
65
- .fetch(name)
86
+ const cached = this.moduleCache.get(name);
87
+ const p = (cached ? Promise.resolve(cached) : this.source.fetch(name))
66
88
  .then((module) => this.ingestAsync(name, module))
67
89
  .finally(() => {
68
90
  this.inflight.delete(name);
@@ -78,10 +100,23 @@ export class Registry {
78
100
  set = new Set();
79
101
  this.listeners.set(name, set);
80
102
  }
103
+ const wasEmpty = set.size === 0;
81
104
  set.add(listener);
105
+ if (wasEmpty && this.source.watch) {
106
+ this.watchReleases.set(name, this.source.watch(name));
107
+ }
108
+ const cached = this.moduleCache.get(name);
109
+ if (cached && !this.entries.has(name) && !this.inflight.has(name)) {
110
+ void this.ingestAsync(name, cached);
111
+ }
82
112
  return () => {
83
113
  set!.delete(listener);
84
- if (set!.size === 0) this.listeners.delete(name);
114
+ if (set!.size === 0) {
115
+ this.listeners.delete(name);
116
+ const release = this.watchReleases.get(name);
117
+ release?.();
118
+ this.watchReleases.delete(name);
119
+ }
85
120
  };
86
121
  }
87
122
 
@@ -43,6 +43,7 @@ export function createRegistryModuleSubscription<T extends Record<string, unknow
43
43
  ): RegistryModuleSubscription<T> {
44
44
  let snapshot = { ...defaults } as T;
45
45
  const listeners = new Set<() => void>();
46
+ let watchRelease: (() => void) | undefined;
46
47
 
47
48
  const notify = () => {
48
49
  for (const listener of listeners) listener();
@@ -83,12 +84,20 @@ export function createRegistryModuleSubscription<T extends Record<string, unknow
83
84
  }
84
85
  });
85
86
 
86
- void reload();
87
-
88
87
  return {
89
88
  subscribe(listener) {
90
89
  listeners.add(listener);
91
- return () => listeners.delete(listener);
90
+ if (listeners.size === 1) {
91
+ if (source.watch) watchRelease = source.watch(name);
92
+ void reload();
93
+ }
94
+ return () => {
95
+ listeners.delete(listener);
96
+ if (listeners.size === 0) {
97
+ watchRelease?.();
98
+ watchRelease = undefined;
99
+ }
100
+ };
92
101
  },
93
102
  getSnapshot: () => snapshot,
94
103
  proxy,
@@ -1,3 +1,5 @@
1
+ import type { Diagnostic } from "./types.js";
2
+
1
3
  /** Map a relative require specifier to the flat registry component name (basename). */
2
4
  export function resolveRelativeComponentName(specifier: string): string | null {
3
5
  if (!specifier.startsWith("./") && !specifier.startsWith("../") && !specifier.startsWith("/")) {
@@ -37,3 +39,49 @@ export function collectRelativeComponentDeps(code: string, componentName?: strin
37
39
  }
38
40
  return [...deps];
39
41
  }
42
+
43
+ export interface RelativeImportValidation {
44
+ ok: boolean;
45
+ message?: string;
46
+ diagnostics?: Diagnostic[];
47
+ }
48
+
49
+ /**
50
+ * Reject relative imports that resolve to registry names not in the manifest.
51
+ * Local utility files should be bundled at compile time; any remaining relative
52
+ * require() must target another registered component.
53
+ */
54
+ export function validateRelativeImports(
55
+ code: string,
56
+ registered: ReadonlySet<string>,
57
+ componentName?: string,
58
+ ): RelativeImportValidation {
59
+ const unresolved: string[] = [];
60
+ for (const specifier of extractRelativeRequires(code)) {
61
+ const base = resolveRelativeComponentName(specifier);
62
+ if (!base) continue;
63
+ if (base === componentName) continue;
64
+ if (!registered.has(base)) unresolved.push(specifier);
65
+ }
66
+ if (unresolved.length === 0) return { ok: true };
67
+
68
+ const lines = unresolved.map(
69
+ (spec) =>
70
+ ` ${spec} → registry component '${resolveRelativeComponentName(spec)}' is not registered`,
71
+ );
72
+ const message =
73
+ `relative import(s) must target a registered component or a local file bundled into this module:\n` +
74
+ `${lines.join("\n")}\n` +
75
+ `Push the dependency as its own component, move helpers into this file, ` +
76
+ `import from host scope (e.g. @newscast/utils-app-ui), or colocate as ./sibling.ts (auto-bundled).`;
77
+
78
+ return {
79
+ ok: false,
80
+ message,
81
+ diagnostics: unresolved.map((spec) => ({
82
+ severity: "error" as const,
83
+ message: `unregistered relative import '${spec}'`,
84
+ code: "RELATIVE_IMPORT",
85
+ })),
86
+ };
87
+ }