@nwire/scan 0.13.2 → 0.13.3

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.
@@ -12,7 +12,7 @@
12
12
  * workflows (including the workflow closure edge-walk and the projection /
13
13
  * workflow event-graph edges).
14
14
  */
15
- import type { ActionEntry, ActorEntry, CommandEntry, CronEntry, ErrorEntry, EventEntry, EventGraphEdge, ExternalCallEntry, InboundWebhookEntry, InboxEntry, OutboxEntry, ProjectionEntry, QueryEntry, ResourceEntry, SourceLocationEntry, WorkflowEntry } from "./scan.js";
15
+ import type { ActionEntry, ActorEntry, CommandEntry, CronEntry, ErrorEntry, EventEntry, EventGraphEdge, ExternalCallEntry, HandlerEntry, InboundWebhookEntry, InboxEntry, OutboxEntry, ProjectionEntry, QueryEntry, ResourceEntry, SourceLocationEntry, WorkflowEntry } from "./scan.js";
16
16
  /** A `config/*.ts` module — its file and the top-level config field names it exposes. */
17
17
  export interface ConfigModuleEntry {
18
18
  readonly file: string;
@@ -27,13 +27,14 @@ export interface SchemaEntry {
27
27
  readonly source?: SourceLocationEntry;
28
28
  }
29
29
  export interface UnanalyzableEntry {
30
- readonly kind: "event" | "action" | "actor" | "projection" | "query" | "workflow" | "command" | "cron" | "externalCall" | "inboundWebhook" | "outbox" | "inbox" | "resource" | "error" | "schema";
30
+ readonly kind: "event" | "action" | "handler" | "actor" | "projection" | "query" | "workflow" | "command" | "cron" | "externalCall" | "inboundWebhook" | "outbox" | "inbox" | "resource" | "error" | "schema";
31
31
  readonly reason: string;
32
32
  readonly source: SourceLocationEntry;
33
33
  }
34
34
  export interface AstExtract {
35
35
  readonly events: EventEntry[];
36
36
  readonly actions: ActionEntry[];
37
+ readonly handlers: HandlerEntry[];
37
38
  readonly actors: ActorEntry[];
38
39
  readonly projections: ProjectionEntry[];
39
40
  readonly queries: QueryEntry[];
@@ -427,6 +427,7 @@ export function extractFromFiles(files, app = "") {
427
427
  const parsed = parse(files);
428
428
  const events = [];
429
429
  const actions = [];
430
+ const handlers = [];
430
431
  const actors = [];
431
432
  const projections = [];
432
433
  const queries = [];
@@ -659,6 +660,36 @@ export function extractFromFiles(files, app = "") {
659
660
  });
660
661
  });
661
662
  }
663
+ // ── Pass 2b: handlers ──
664
+ // The transport-core primitive. Same name shape as actions (positional or
665
+ // `{ name }`); no emits/projection contract — just the request→response unit.
666
+ for (const { file, sf } of parsed) {
667
+ walk(sf, (node) => {
668
+ if (!isDefineCall(node, "defineHandler"))
669
+ return;
670
+ const src = sourceOf(node, sf, file);
671
+ const cfg = configObject(node);
672
+ const name = actionName(node, cfg);
673
+ if (!name) {
674
+ unanalyzable.push({
675
+ kind: "handler",
676
+ reason: "non-literal or missing handler name",
677
+ source: src,
678
+ });
679
+ return;
680
+ }
681
+ const handlerExpr = cfg ? prop(cfg, "handler") : undefined;
682
+ const calls = handlerExpr ? extractCalls(handlerExpr, externalCallNameByBinding) : [];
683
+ handlers.push({
684
+ name,
685
+ app,
686
+ public: isPublic(node),
687
+ description: cfg ? stringLiteral(prop(cfg, "description")) : undefined,
688
+ calls: calls.length ? calls : undefined,
689
+ source: src,
690
+ });
691
+ });
692
+ }
662
693
  // ── Pass 3: projections ──
663
694
  // After events so `listens`/`on` refs resolve. Records the projection
664
695
  // binding so queries (pass 5) can map a projection ref → its name.
@@ -982,6 +1013,7 @@ export function extractFromFiles(files, app = "") {
982
1013
  return {
983
1014
  events,
984
1015
  actions,
1016
+ handlers,
985
1017
  actors,
986
1018
  projections,
987
1019
  queries,
package/dist/graph.js CHANGED
@@ -82,6 +82,15 @@ export function buildGraph(ast, topology) {
82
82
  intent: { public: q.public },
83
83
  data: { projection: q.projection },
84
84
  });
85
+ for (const h of ast.handlers ?? [])
86
+ add({
87
+ id: nid("handler", h.name),
88
+ kind: "handler",
89
+ name: h.name,
90
+ source: h.source,
91
+ intent: { description: h.description, public: h.public },
92
+ data: { calls: h.calls },
93
+ });
85
94
  // Shared state-machine emit: state nodes + `transitions` edges (on event).
86
95
  // `from: "*"` (always-active) is rooted at the machine node itself.
87
96
  const emitStates = (machineKind, machineName, declaredStates, transitions) => {
@@ -180,6 +189,9 @@ export function buildGraph(ast, topology) {
180
189
  for (const c of a.calls ?? [])
181
190
  edge(nid("action", a.name), nid("externalCall", c), "calls");
182
191
  }
192
+ for (const h of ast.handlers ?? [])
193
+ for (const c of h.calls ?? [])
194
+ edge(nid("handler", h.name), nid("externalCall", c), "calls");
183
195
  for (const ed of ast.graph.events) {
184
196
  switch (ed.via) {
185
197
  case "emits":
@@ -16,7 +16,7 @@ import { type AstExtract } from "./ast-extract.js";
16
16
  import { type Topology } from "./topology.js";
17
17
  import { type ManifestModel } from "./graph.js";
18
18
  /** Bumped when the manifest shape changes, so readers can detect a stale file. */
19
- export declare const MANIFEST_VERSION: 3;
19
+ export declare const MANIFEST_VERSION: 4;
20
20
  /**
21
21
  * The project manifest — the static AST graph (events/actions/queries/actors/
22
22
  * workflows/projections + positions + event-causation edges) plus, when built
package/dist/manifest.js CHANGED
@@ -19,7 +19,7 @@ import { extractFromFiles } from "./ast-extract.js";
19
19
  import { captureTopology } from "./topology.js";
20
20
  import { buildGraph } from "./graph.js";
21
21
  /** Bumped when the manifest shape changes, so readers can detect a stale file. */
22
- export const MANIFEST_VERSION = 3;
22
+ export const MANIFEST_VERSION = 4;
23
23
  const SKIP_DIRS = new Set(["node_modules", "dist", ".nwire", ".git", ".vitepress", "__tests__"]);
24
24
  /**
25
25
  * Recursively collect non-test `.ts` source files under `root` — the input to
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Build-time registry codegen — the production half of auto-discovery.
3
+ *
4
+ * At runtime, `@nwire/app`'s `discoverPrimitives` walks `app/` with `node:fs`
5
+ * and dynamic `import()`. That's perfect for dev/test and Node production, but
6
+ * it costs a filesystem scan at every boot and doesn't work in a bundle (no
7
+ * `node:fs`, no dynamic dir import) — edge targets and single-file builds.
8
+ *
9
+ * This module generates a *static* registry: a tiny ESM source that imports
10
+ * every app primitive with plain `import * as` statements and classifies the
11
+ * real exported values via `@nwire/app`'s `classifyModules`. A bundler folds
12
+ * those static imports into the output, so production boots with zero fs scan
13
+ * and zero dynamic import — and it works anywhere ESM does.
14
+ *
15
+ * Equivalence is structural, not asserted: the codegen reuses the same
16
+ * `collectFiles` (which files count) and the generated module calls the same
17
+ * `classifyModules` (how exports map to kinds) that the runtime scan uses.
18
+ * There is no second copy of the rules to drift.
19
+ *
20
+ * buildStart / load("virtual:nwire-registry")
21
+ * → generateRegistryModule(appDir)
22
+ * → import { classifyModules } from "@nwire/app"
23
+ * import * as _m0 from "/abs/app/create-item.action.ts"
24
+ * ...
25
+ * export const registry = classifyModules([_m0, ...])
26
+ */
27
+ /** The virtual module specifier the runtime imports and the plugin resolves. */
28
+ export declare const REGISTRY_MODULE_ID = "virtual:nwire-registry";
29
+ export interface RegistryCodegenOptions {
30
+ /**
31
+ * Absolute path to the app source root to scan. Defaults to `<cwd>/app`.
32
+ * The same directory `createApp({ discover })` walks at runtime.
33
+ */
34
+ readonly root?: string;
35
+ }
36
+ /**
37
+ * Generate the ESM source for the static registry module. Walks `root` for
38
+ * primitive files (using the runtime's own `collectFiles` rules) and emits a
39
+ * module that statically imports each and classifies them with the runtime's
40
+ * `classifyModules`.
41
+ *
42
+ * Returns a valid module even when no files are found — the registry is just
43
+ * empty, so `createApp` can fall through to whatever explicit arrays it has.
44
+ */
45
+ export declare function generateRegistryModule(options?: RegistryCodegenOptions): string;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Build-time registry codegen — the production half of auto-discovery.
3
+ *
4
+ * At runtime, `@nwire/app`'s `discoverPrimitives` walks `app/` with `node:fs`
5
+ * and dynamic `import()`. That's perfect for dev/test and Node production, but
6
+ * it costs a filesystem scan at every boot and doesn't work in a bundle (no
7
+ * `node:fs`, no dynamic dir import) — edge targets and single-file builds.
8
+ *
9
+ * This module generates a *static* registry: a tiny ESM source that imports
10
+ * every app primitive with plain `import * as` statements and classifies the
11
+ * real exported values via `@nwire/app`'s `classifyModules`. A bundler folds
12
+ * those static imports into the output, so production boots with zero fs scan
13
+ * and zero dynamic import — and it works anywhere ESM does.
14
+ *
15
+ * Equivalence is structural, not asserted: the codegen reuses the same
16
+ * `collectFiles` (which files count) and the generated module calls the same
17
+ * `classifyModules` (how exports map to kinds) that the runtime scan uses.
18
+ * There is no second copy of the rules to drift.
19
+ *
20
+ * buildStart / load("virtual:nwire-registry")
21
+ * → generateRegistryModule(appDir)
22
+ * → import { classifyModules } from "@nwire/app"
23
+ * import * as _m0 from "/abs/app/create-item.action.ts"
24
+ * ...
25
+ * export const registry = classifyModules([_m0, ...])
26
+ */
27
+ import { collectFiles } from "@nwire/app";
28
+ /** The virtual module specifier the runtime imports and the plugin resolves. */
29
+ export const REGISTRY_MODULE_ID = "virtual:nwire-registry";
30
+ /** Normalize a filesystem path to a forward-slash import specifier. */
31
+ function toImportSpecifier(file) {
32
+ return file.replace(/\\/g, "/");
33
+ }
34
+ /**
35
+ * Generate the ESM source for the static registry module. Walks `root` for
36
+ * primitive files (using the runtime's own `collectFiles` rules) and emits a
37
+ * module that statically imports each and classifies them with the runtime's
38
+ * `classifyModules`.
39
+ *
40
+ * Returns a valid module even when no files are found — the registry is just
41
+ * empty, so `createApp` can fall through to whatever explicit arrays it has.
42
+ */
43
+ export function generateRegistryModule(options = {}) {
44
+ const root = options.root ?? `${process.cwd()}/app`;
45
+ const files = collectFiles(root).map(toImportSpecifier).sort();
46
+ const imports = files.map((f, i) => `import * as _m${i} from ${JSON.stringify(f)};`);
47
+ const list = files.map((_, i) => `_m${i}`).join(", ");
48
+ return [
49
+ "// AUTO-GENERATED by @nwire/scan registry codegen — do not edit.",
50
+ "// Statically imports every app primitive so production registers them",
51
+ "// with no filesystem scan and no dynamic import (bundle/edge-safe).",
52
+ 'import { classifyModules } from "@nwire/app";',
53
+ ...imports,
54
+ "",
55
+ `export const registry = classifyModules([${list}]);`,
56
+ "export const handlers = registry.handlers;",
57
+ "export const actors = registry.actors;",
58
+ "export const projections = registry.projections;",
59
+ "export const workflows = registry.workflows;",
60
+ "",
61
+ ].join("\n");
62
+ }
package/dist/scan.d.ts CHANGED
@@ -87,6 +87,21 @@ export interface CommandEntry {
87
87
  readonly app: string;
88
88
  readonly source?: SourceLocationEntry;
89
89
  }
90
+ /**
91
+ * A `defineHandler(...)` — the transport-core primitive ("one handler, every
92
+ * transport"). Distinct from actions/queries: a handler has no projection
93
+ * backing and no event emission contract; it's the plain request→response unit
94
+ * any transport can dispatch.
95
+ */
96
+ export interface HandlerEntry {
97
+ readonly name: string;
98
+ readonly app: string;
99
+ readonly public: boolean;
100
+ readonly description?: string;
101
+ /** External-call names invoked in the handler body. */
102
+ readonly calls?: readonly string[];
103
+ readonly source?: SourceLocationEntry;
104
+ }
90
105
  export interface WorkflowEntry {
91
106
  readonly name: string;
92
107
  readonly app: string;
@@ -230,6 +245,7 @@ export interface EventGraphEdge {
230
245
  }
231
246
  export { buildManifest, writeManifest, readManifest, collectSourceFiles, MANIFEST_VERSION, type Manifest, } from "./manifest.js";
232
247
  export { extractFromFiles, collectConfigModules, type AstExtract, type SchemaEntry, type ConfigModuleEntry as AstConfigModuleEntry, } from "./ast-extract.js";
248
+ export { generateRegistryModule, REGISTRY_MODULE_ID, type RegistryCodegenOptions, } from "./registry-codegen.js";
233
249
  export { captureTopology, type Topology, type CapabilityInfo, type StageInfo, type PluginInfo, type BindingInfo, type HandlerInfo, type HookInfo, type ContributionInfo, type TriggerInfo, } from "./topology.js";
234
250
  export { buildGraph, type ManifestModel, type GraphNode, type GraphEdge, type GraphIntent, type EdgeType, } from "./graph.js";
235
251
  export { listTelemetryRuns, readTelemetryRun, type TelemetryRunMeta } from "./telemetry-runs.js";
package/dist/scan.js CHANGED
@@ -10,6 +10,7 @@
10
10
  // equivalence harness proves they match.
11
11
  export { buildManifest, writeManifest, readManifest, collectSourceFiles, MANIFEST_VERSION, } from "./manifest.js";
12
12
  export { extractFromFiles, collectConfigModules, } from "./ast-extract.js";
13
+ export { generateRegistryModule, REGISTRY_MODULE_ID, } from "./registry-codegen.js";
13
14
  export { captureTopology, } from "./topology.js";
14
15
  export { buildGraph, } from "./graph.js";
15
16
  export { listTelemetryRuns, readTelemetryRun } from "./telemetry-runs.js";
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Vite/unplugin form — emit the build-time manifest.
2
+ * Vite/unplugin form — the build-side of nwire discovery. Two jobs:
3
3
  *
4
- * On build start and on dev-server start (re-emitted as `.ts` source changes),
5
- * walks the project's source tree with the AST extractor and writes
6
- * `.nwire/manifest.json`. No app boot — "scan the graph, not the runtime."
7
- * Serves the live manifest at `/__nwire/manifest` in dev.
4
+ * 1. **Descriptive manifest** on build/dev start (and on `.ts` change),
5
+ * walk the source tree with the AST extractor and write
6
+ * `.nwire/manifest.json` for Studio. No app boot — "scan the graph, not
7
+ * the runtime." Also served live at `/__nwire/manifest` in dev.
8
+ *
9
+ * 2. **Registry codegen** — resolve the `virtual:nwire-registry` module to a
10
+ * static-import registry of every `app/` primitive. `createApp({ discover })`
11
+ * imports this first, so a production bundle registers handlers/actors/etc.
12
+ * with zero filesystem scan and zero dynamic import (edge-safe). In dev/test
13
+ * without this plugin the import simply fails and `createApp` falls back to
14
+ * the runtime fs-scan — same result, slower path.
8
15
  */
9
16
  import type { Plugin } from "vite";
10
17
  export interface NwireManifestPluginOptions {
@@ -14,6 +21,18 @@ export interface NwireManifestPluginOptions {
14
21
  readonly app?: string;
15
22
  /** Output directory for `manifest.json`. Default `.nwire`. */
16
23
  readonly outDir?: string;
24
+ /**
25
+ * App primitive root for registry codegen. Defaults to `<root>/app`. Set to
26
+ * `false` to disable codegen and only emit the descriptive manifest.
27
+ */
28
+ readonly appDir?: string | false;
29
+ /**
30
+ * Emit + serve the descriptive `.nwire/manifest.json`. Default `true`. Set to
31
+ * `false` when a host already owns that pipeline (the `nwire dev` host runs
32
+ * its own richer scan with runtime fold) — the plugin then only resolves the
33
+ * `virtual:nwire-registry` module.
34
+ */
35
+ readonly manifest?: boolean;
17
36
  }
18
37
  export declare function nwireManifestPlugin(options?: NwireManifestPluginOptions): Plugin;
19
38
  /** @deprecated Use {@link nwireManifestPlugin}. Kept so existing imports resolve. */
@@ -1,25 +1,61 @@
1
1
  /**
2
- * Vite/unplugin form — emit the build-time manifest.
2
+ * Vite/unplugin form — the build-side of nwire discovery. Two jobs:
3
3
  *
4
- * On build start and on dev-server start (re-emitted as `.ts` source changes),
5
- * walks the project's source tree with the AST extractor and writes
6
- * `.nwire/manifest.json`. No app boot — "scan the graph, not the runtime."
7
- * Serves the live manifest at `/__nwire/manifest` in dev.
4
+ * 1. **Descriptive manifest** on build/dev start (and on `.ts` change),
5
+ * walk the source tree with the AST extractor and write
6
+ * `.nwire/manifest.json` for Studio. No app boot — "scan the graph, not
7
+ * the runtime." Also served live at `/__nwire/manifest` in dev.
8
+ *
9
+ * 2. **Registry codegen** — resolve the `virtual:nwire-registry` module to a
10
+ * static-import registry of every `app/` primitive. `createApp({ discover })`
11
+ * imports this first, so a production bundle registers handlers/actors/etc.
12
+ * with zero filesystem scan and zero dynamic import (edge-safe). In dev/test
13
+ * without this plugin the import simply fails and `createApp` falls back to
14
+ * the runtime fs-scan — same result, slower path.
8
15
  */
9
16
  import { buildManifest, writeManifest } from "./manifest.js";
17
+ import { generateRegistryModule, REGISTRY_MODULE_ID } from "./registry-codegen.js";
10
18
  export function nwireManifestPlugin(options = {}) {
11
19
  const outDir = options.outDir ?? ".nwire";
12
20
  let root = options.root ?? process.cwd();
21
+ let isBuild = false;
22
+ // Rollup convention: prefix a resolved virtual id with `\0` so other plugins
23
+ // leave it alone and it never hits the filesystem resolver.
24
+ const resolvedRegistryId = `\0${REGISTRY_MODULE_ID}`;
25
+ const appDirFor = () => options.appDir || `${root}/app`;
13
26
  return {
14
27
  name: "nwire:manifest",
15
28
  configResolved(config) {
16
29
  if (!options.root && config.root)
17
30
  root = config.root;
31
+ isBuild = config.command === "build";
32
+ },
33
+ // Always resolve the virtual id so the literal `import("virtual:nwire-registry")`
34
+ // in `@nwire/app` never hard-fails a Vite build or dev server.
35
+ resolveId(id) {
36
+ if (id === REGISTRY_MODULE_ID)
37
+ return resolvedRegistryId;
38
+ return null;
39
+ },
40
+ // Bake the static registry into production builds. In dev (serve) or when
41
+ // codegen is off, emit a null registry so `createApp` falls back to the
42
+ // runtime fs-scan — fresh on every change, HMR-friendly.
43
+ load(id) {
44
+ if (id !== resolvedRegistryId)
45
+ return null;
46
+ if (isBuild && options.appDir !== false) {
47
+ return generateRegistryModule({ root: appDirFor() });
48
+ }
49
+ return "export const registry = null;\n";
18
50
  },
19
51
  async buildStart() {
52
+ if (options.manifest === false)
53
+ return;
20
54
  await writeManifest(buildManifest(root, options.app), outDir);
21
55
  },
22
56
  async configureServer(server) {
57
+ if (options.manifest === false)
58
+ return;
23
59
  const emit = () => writeManifest(buildManifest(root, options.app), outDir);
24
60
  await emit();
25
61
  const onChange = (file) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/scan",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "description": "Nwire — system registry scanner. Walks AppDefinition[] manifests and writes the .nwire/ cache (actions, events, actors, projections, queries, routes, event graph). Vite plugin + standalone function.",
5
5
  "keywords": [
6
6
  "cache",
@@ -34,17 +34,18 @@
34
34
  "dependencies": {
35
35
  "typescript": "^5.9.3",
36
36
  "zod": "^4.4.3",
37
- "@nwire/forge": "0.13.2",
38
- "@nwire/messages": "0.13.2",
39
- "@nwire/telemetry": "0.13.2",
40
- "@nwire/hooks": "0.13.2"
37
+ "@nwire/app": "0.13.3",
38
+ "@nwire/forge": "0.13.3",
39
+ "@nwire/messages": "0.13.3",
40
+ "@nwire/telemetry": "0.13.3",
41
+ "@nwire/hooks": "0.13.3"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@types/node": "^22.19.9",
44
45
  "typescript": "^5.9.3",
45
46
  "vite": "npm:rolldown-vite@latest",
46
47
  "vitest": "^4.0.18",
47
- "@nwire/container": "0.13.2"
48
+ "@nwire/container": "0.13.3"
48
49
  },
49
50
  "scripts": {
50
51
  "build": "tsc && node ../../scripts/fix-dist-extensions.mjs dist",