@telorun/kernel 0.28.0 → 0.30.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.
Files changed (45) hide show
  1. package/dist/controller-loader.d.ts +27 -0
  2. package/dist/controller-loader.d.ts.map +1 -1
  3. package/dist/controller-loader.js +45 -3
  4. package/dist/controller-loader.js.map +1 -1
  5. package/dist/controller-loaders/bundle-builder.d.ts +15 -0
  6. package/dist/controller-loaders/bundle-builder.d.ts.map +1 -0
  7. package/dist/controller-loaders/bundle-builder.js +306 -0
  8. package/dist/controller-loaders/bundle-builder.js.map +1 -0
  9. package/dist/controller-loaders/bundle-loader.d.ts +10 -0
  10. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  11. package/dist/controller-loaders/bundle-loader.js +29 -14
  12. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  13. package/dist/controller-loaders/napi-loader.d.ts +11 -0
  14. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  15. package/dist/controller-loaders/napi-loader.js +49 -30
  16. package/dist/controller-loaders/napi-loader.js.map +1 -1
  17. package/dist/controller-loaders/npm-loader.d.ts +10 -0
  18. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  19. package/dist/controller-loaders/npm-loader.js +21 -3
  20. package/dist/controller-loaders/npm-loader.js.map +1 -1
  21. package/dist/controller-registry.d.ts +17 -10
  22. package/dist/controller-registry.d.ts.map +1 -1
  23. package/dist/controller-registry.js +44 -10
  24. package/dist/controller-registry.js.map +1 -1
  25. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  26. package/dist/controllers/resource-definition/resource-definition-controller.js +27 -8
  27. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  28. package/dist/kernel.d.ts +7 -0
  29. package/dist/kernel.d.ts.map +1 -1
  30. package/dist/kernel.js +19 -1
  31. package/dist/kernel.js.map +1 -1
  32. package/dist/resource-context.d.ts +7 -0
  33. package/dist/resource-context.d.ts.map +1 -1
  34. package/dist/resource-context.js +10 -0
  35. package/dist/resource-context.js.map +1 -1
  36. package/package.json +6 -3
  37. package/src/controller-loader.ts +72 -3
  38. package/src/controller-loaders/bundle-builder.ts +313 -0
  39. package/src/controller-loaders/bundle-loader.ts +39 -20
  40. package/src/controller-loaders/napi-loader.ts +51 -30
  41. package/src/controller-loaders/npm-loader.ts +25 -3
  42. package/src/controller-registry.ts +63 -0
  43. package/src/controllers/resource-definition/resource-definition-controller.ts +44 -11
  44. package/src/kernel.ts +25 -1
  45. package/src/resource-context.ts +15 -0
@@ -141,6 +141,20 @@ export class BundleControllerLoader {
141
141
  purl: string,
142
142
  baseUri: string,
143
143
  ): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
144
+ const { source, importInstance } = await this.resolve(purl, baseUri);
145
+ return { instance: await importInstance(), source };
146
+ }
147
+
148
+ /**
149
+ * Resolve without importing: parse + validate the PURL, confirm the bundle
150
+ * file exists and its format is hostable, and ensure the realm symlinks — all
151
+ * cheap, fail-fast checks — but defer the bundle `import()` (the eval cost)
152
+ * into the returned `importInstance` thunk. Used by lazy controller loading.
153
+ */
154
+ async resolve(
155
+ purl: string,
156
+ baseUri: string,
157
+ ): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
144
158
  let parsed: PackageURL;
145
159
  try {
146
160
  parsed = PackageURL.fromString(purl);
@@ -196,26 +210,31 @@ export class BundleControllerLoader {
196
210
  // importing the bundle, so authors write normal imports.
197
211
  await ensureRealmSymlinks(path.dirname(absFile));
198
212
 
199
- // A broken bundle (syntax / failed import) is a real user-code failure —
200
- // let it propagate rather than masking it as env-missing.
201
- const mod = (await import(pathToFileURL(absFile).href)) as Record<string, ControllerInstance>;
202
213
  const fragment = parsed.subpath;
203
- // Distinguish "no such export" from "export isn't a controller" so the error
204
- // points at the actual problem (mirrors the napi loader's project()).
205
- if (fragment && !(fragment in mod)) {
206
- throw new RuntimeError(
207
- "ERR_CONTROLLER_INVALID",
208
- `Bundled controller "${purl}": module "${absFile}" has no export named "${fragment}"`,
209
- );
210
- }
211
- const instance = fragment ? mod[fragment] : (mod as unknown as ControllerInstance);
212
- if (!instance || (!instance.create && !instance.register)) {
213
- throw new RuntimeError(
214
- "ERR_CONTROLLER_INVALID",
215
- `Bundled controller "${purl}" exports neither create() nor register()` +
216
- (fragment ? ` at fragment "#${fragment}"` : ""),
217
- );
218
- }
219
- return { instance, source: "bundle" };
214
+ return {
215
+ source: "bundle",
216
+ importInstance: async () => {
217
+ // A broken bundle (syntax / failed import) is a real user-code failure —
218
+ // let it propagate rather than masking it as env-missing.
219
+ const mod = (await import(pathToFileURL(absFile).href)) as Record<string, ControllerInstance>;
220
+ // Distinguish "no such export" from "export isn't a controller" so the
221
+ // error points at the actual problem (mirrors the napi loader's project()).
222
+ if (fragment && !(fragment in mod)) {
223
+ throw new RuntimeError(
224
+ "ERR_CONTROLLER_INVALID",
225
+ `Bundled controller "${purl}": module "${absFile}" has no export named "${fragment}"`,
226
+ );
227
+ }
228
+ const instance = fragment ? mod[fragment] : (mod as unknown as ControllerInstance);
229
+ if (!instance || (!instance.create && !instance.register)) {
230
+ throw new RuntimeError(
231
+ "ERR_CONTROLLER_INVALID",
232
+ `Bundled controller "${purl}" exports neither create() nor register()` +
233
+ (fragment ? ` at fragment "#${fragment}"` : ""),
234
+ );
235
+ }
236
+ return instance;
237
+ },
238
+ };
220
239
  }
221
240
  }
@@ -123,6 +123,21 @@ export class NapiControllerLoader {
123
123
  * though all exports come from one linked dylib.
124
124
  */
125
125
  async load(purl: string, baseUri: string): Promise<NapiLoadResult> {
126
+ const { source, importInstance } = await this.resolve(purl, baseUri);
127
+ return { instance: await importInstance(), source };
128
+ }
129
+
130
+ /**
131
+ * Resolve without building: validate the PURL and confirm the crate's
132
+ * `local_path` exists (the fail-fast checks), then defer the cargo build +
133
+ * dylib load — the expensive part — into the returned `importInstance` thunk.
134
+ * A cache hit resolves instantly. Used by lazy controller loading so the build
135
+ * is paid on the kind's first instantiation, not at boot.
136
+ */
137
+ async resolve(
138
+ purl: string,
139
+ baseUri: string,
140
+ ): Promise<{ source: NapiResolveSource; importInstance: () => Promise<ControllerInstance> }> {
126
141
  const [, , name, , qualifiers, entry] = PackageURL.parseString(purl);
127
142
  const localPath = (qualifiers as any)?.get("local_path");
128
143
 
@@ -149,38 +164,44 @@ export class NapiControllerLoader {
149
164
  // can leave napi finalize callbacks racing and crash Node.
150
165
  const canonicalCratePath = await fs.realpath(cratePath);
151
166
  const cacheKey = canonicalCratePath;
152
- const cached = _napiModuleCache.get(cacheKey);
153
- if (cached) {
154
- return { instance: project(cached, entry, cratePath), source: "cache" };
167
+ if (_napiModuleCache.has(cacheKey)) {
168
+ return {
169
+ source: "cache",
170
+ importInstance: async () => project(_napiModuleCache.get(cacheKey), entry, cratePath),
171
+ };
155
172
  }
156
173
 
157
- // Concurrent callers for the same crate await one shared build. They
158
- // report `local` (not `cache`): they paid the same wall-clock cost as
159
- // the originator, just by sharing one cargo invocation rather than
160
- // running their own. Reporting `cache` here would mislead metrics/event
161
- // consumers into thinking it was a sub-ms hit.
162
- const existingInFlight = _napiInFlight.get(cacheKey);
163
- if (existingInFlight) {
164
- const { rawModule } = await existingInFlight;
165
- return { instance: project(rawModule, entry, cratePath), source: "local" };
166
- }
167
-
168
- const buildPromise = this.buildAndLoad(cratePath, name ?? "", cacheKey);
169
- _napiInFlight.set(cacheKey, buildPromise);
170
- let rawModule: any;
171
- let nodePath: string;
172
- try {
173
- ({ rawModule, nodePath } = await buildPromise);
174
- } finally {
175
- _napiInFlight.delete(cacheKey);
176
- }
177
- // `local` rather than `cargo-build` because the only mode currently
178
- // wired up is `local_path` dev-mode — cargo's incremental cache means
179
- // every run after the first is ~50ms of cargo-startup with no real
180
- // compilation, conceptually the same as the npm `local_path` branch
181
- // that just imports source already on disk. Distribution mode (when
182
- // implemented) will return `cargo-build` from its own branch.
183
- return { instance: project(rawModule, entry, nodePath), source: "local" };
174
+ // `local` rather than `cargo-build` because the only mode currently wired up
175
+ // is `local_path` dev-mode see the original note below. The thunk re-checks
176
+ // the module cache and the in-flight gate because resolve() and the deferred
177
+ // import can be separated in time (another instantiation may have built the
178
+ // crate in between).
179
+ const crateName = name ?? "";
180
+ const build = this.buildAndLoad.bind(this);
181
+ return {
182
+ source: "local",
183
+ importInstance: async () => {
184
+ const cached = _napiModuleCache.get(cacheKey);
185
+ if (cached) return project(cached, entry, cratePath);
186
+ // Concurrent callers for the same crate await one shared build, sharing
187
+ // one cargo invocation rather than each running their own.
188
+ const existingInFlight = _napiInFlight.get(cacheKey);
189
+ if (existingInFlight) {
190
+ const { rawModule } = await existingInFlight;
191
+ return project(rawModule, entry, cratePath);
192
+ }
193
+ const buildPromise = build(cratePath, crateName, cacheKey);
194
+ _napiInFlight.set(cacheKey, buildPromise);
195
+ let rawModule: any;
196
+ let nodePath: string;
197
+ try {
198
+ ({ rawModule, nodePath } = await buildPromise);
199
+ } finally {
200
+ _napiInFlight.delete(cacheKey);
201
+ }
202
+ return project(rawModule, entry, nodePath);
203
+ },
204
+ };
184
205
  }
185
206
 
186
207
  private async buildAndLoad(
@@ -8,6 +8,7 @@ import { PackageURL } from "packageurl-js";
8
8
  import * as path from "path";
9
9
  import { fileURLToPath, pathToFileURL } from "url";
10
10
  import { promisify } from "util";
11
+ import { tryBuildControllerBundle } from "./bundle-builder.js";
11
12
  import { ControllerEnvMissingError } from "./napi-loader.js";
12
13
  import { REALM_COLLAPSE_NAMES } from "./realm.js";
13
14
 
@@ -178,6 +179,20 @@ export class NpmControllerLoader {
178
179
  }
179
180
 
180
181
  async load(purl: string, baseUri: string): Promise<NpmLoadResult> {
182
+ const { source, importInstance } = await this.resolve(purl, baseUri);
183
+ return { instance: await importInstance(), source };
184
+ }
185
+
186
+ /**
187
+ * Resolve without importing: materialize the install root and install/verify
188
+ * the package (the cheap, cache-hit-fast part that fails fast if the package
189
+ * is absent), but defer `loadFromInstall` — the actual `import()`/eval, which
190
+ * is the expensive cold-start cost — into the returned `importInstance` thunk.
191
+ */
192
+ async resolve(
193
+ purl: string,
194
+ baseUri: string,
195
+ ): Promise<{ source: NpmResolveSource; importInstance: () => Promise<ControllerInstance> }> {
181
196
  const parsed = PackageURL.fromString(purl);
182
197
  if (!parsed.name) {
183
198
  throw new Error(`Invalid PURL '${purl}': missing package name`);
@@ -197,8 +212,11 @@ export class NpmControllerLoader {
197
212
  resolved.kind,
198
213
  version,
199
214
  );
200
- const instance = await loadFromInstall(installRoot, alias, parsed.subpath ?? null, purl);
201
- return { instance, source };
215
+ const subpath = parsed.subpath ?? null;
216
+ return {
217
+ source,
218
+ importInstance: () => loadFromInstall(installRoot, alias, subpath, purl),
219
+ };
202
220
  }
203
221
 
204
222
  /**
@@ -865,6 +883,10 @@ async function loadFromInstall(
865
883
  const packageRoot = path.join(installRoot, "node_modules", alias);
866
884
  const entry = subpath ? `./${subpath}` : ".";
867
885
  const entryFile = await resolvePackageEntry(packageRoot, entry);
886
+ // Transparent bundling: import a single esbuild bundle of the entry (one file
887
+ // vs a cold loose `node_modules` tree) when available; otherwise the loose
888
+ // entry. Pure accelerator — `null` on any miss, so behavior is unchanged.
889
+ const target = (await tryBuildControllerBundle(installRoot, entryFile)) ?? entryFile;
868
890
  // ESM dynamic `import()` accepts either a relative specifier or a `file://`
869
891
  // URL — but NOT a bare absolute filesystem path. On POSIX the `/abs/path`
870
892
  // form works by happy accident; on Windows `C:\path\to\file.js` is rejected
@@ -874,7 +896,7 @@ async function loadFromInstall(
874
896
  // Dynamic `import()` always resolves to a module namespace object on
875
897
  // success; it never returns null/undefined. The only meaningful contract
876
898
  // check is whether the module exports at least one of the controller hooks.
877
- const instance = await import(pathToFileURL(entryFile).href);
899
+ const instance = await import(pathToFileURL(target).href);
878
900
  if (!instance.create && !instance.register) {
879
901
  throw new Error(
880
902
  `Invalid controller loaded from "${purl}": exports neither create() nor register()`,
@@ -12,9 +12,21 @@ const DEFAULT_FINGERPRINT = "default";
12
12
  * lock out the second. Definitions remain kind-only; only the loaded
13
13
  * controller instance is policy-scoped.
14
14
  */
15
+ /**
16
+ * A controller whose definition has been resolved but whose module is not yet
17
+ * imported. `load()` performs the import + `registerController` (firing the
18
+ * controller's `register()` hook); `loading` single-flights concurrent
19
+ * instantiations of the same kind through one import.
20
+ */
21
+ interface LazyControllerEntry {
22
+ load: () => Promise<void>;
23
+ loading?: Promise<void>;
24
+ }
25
+
15
26
  export class ControllerRegistry {
16
27
  private controllersByKind: Map<string, Map<string, ControllerInstance>> = new Map();
17
28
  private definitionsByKind: Map<string, ResourceDefinition> = new Map();
29
+ private lazyByKind: Map<string, Map<string, LazyControllerEntry>> = new Map();
18
30
 
19
31
  /**
20
32
  * Register a controller definition
@@ -143,4 +155,55 @@ export class ControllerRegistry {
143
155
  }
144
156
  byFp.set(fingerprint, wrappedController);
145
157
  }
158
+
159
+ /**
160
+ * Register a deferred controller for a (kind, fingerprint). The definition's
161
+ * metadata is already registered (so analysis/refs work); the controller
162
+ * module itself is imported lazily by {@link takeLazyController} on the kind's
163
+ * first instantiation. Mirrors `registerController`'s fingerprint keying.
164
+ */
165
+ registerLazyController(
166
+ kind: string,
167
+ fingerprint: string,
168
+ load: () => Promise<void>,
169
+ ): void {
170
+ if (!this.definitionsByKind.has(kind)) {
171
+ throw new Error(`Cannot register lazy controller for kind ${kind} without definition`);
172
+ }
173
+ let byFp = this.lazyByKind.get(kind);
174
+ if (!byFp) {
175
+ byFp = new Map();
176
+ this.lazyByKind.set(kind, byFp);
177
+ }
178
+ byFp.set(fingerprint, { load });
179
+ }
180
+
181
+ /**
182
+ * Drive the deferred import for a (kind, fingerprint) if one is registered,
183
+ * single-flighting concurrent callers through the same import. Returns true
184
+ * when a lazy entry existed (after its `load()` has completed and the
185
+ * controller is registered), false when there is none — letting the caller
186
+ * fall through to its existing "no controller" handling. Same fingerprint
187
+ * fallback order as `getControllerOrUndefined`.
188
+ */
189
+ async takeLazyController(
190
+ kind: string,
191
+ fingerprint: string = DEFAULT_FINGERPRINT,
192
+ ): Promise<boolean> {
193
+ const byFp = this.lazyByKind.get(kind);
194
+ if (!byFp) return false;
195
+ const entry =
196
+ byFp.get(fingerprint) ?? byFp.get(DEFAULT_FINGERPRINT) ?? byFp.values().next().value;
197
+ if (!entry) return false;
198
+ if (!entry.loading) {
199
+ // Reset on failure so a later instantiation re-attempts and re-surfaces
200
+ // the error rather than awaiting a cached rejection forever.
201
+ entry.loading = entry.load().catch((err) => {
202
+ entry.loading = undefined;
203
+ throw err;
204
+ });
205
+ }
206
+ await entry.loading;
207
+ return true;
208
+ }
146
209
  }
@@ -46,30 +46,63 @@ class ResourceDefinition implements ResourceInstance {
46
46
  );
47
47
  return;
48
48
  }
49
- // The loader owns ControllerLoading / ControllerLoaded / ControllerLoadFailed
50
- // emission so it can fire one event per attempted candidate (env-missing
51
- // fallback chains), and so the payload can include the actually-picked PURL,
52
- // which branch resolved it (`source`), and timing — none of which are known
53
- // here at the call site.
54
49
  const loader = new ControllerLoader({
55
- emit: (e) => ctx.emit(e.name, e.payload),
56
50
  entryUrl: ctx.getEntryUrl(),
57
51
  installRoot: ctx.getInstallRoot(),
58
52
  });
59
- const controllerInstance = await loader.load(
53
+ // Eager resolve verify the controller is hostable now (so a broken
54
+ // `controllers:` candidate fails fast at boot), but defer the expensive
55
+ // import/eval and the controller's `register()` to the kind's first
56
+ // instantiation. Definitions whose kind is never instantiated never import.
57
+ const resolved = await loader.resolve(
60
58
  this.resource.controllers,
61
59
  this.resource.metadata.source,
62
60
  ctx.getControllerPolicy(),
63
61
  );
64
62
  ctx.registerDefinition(this.resource);
65
- await ctx.registerController(
66
- this.resource.metadata.module,
67
- this.resource.metadata.name,
68
- controllerInstance,
63
+
64
+ const moduleName = this.resource.metadata.module;
65
+ const kindName = this.resource.metadata.name;
66
+ // Emitted here (not in the loader) so ControllerLoading / ControllerLoaded /
67
+ // ControllerLoadFailed — and the import duration — surface when the load
68
+ // actually happens (first instantiation), with the resolved PURL + source.
69
+ (ctx as unknown as LazyControllerHost).registerLazyController(
70
+ moduleName,
71
+ kindName,
72
+ async () => {
73
+ await ctx.emit("ControllerLoading", { purl: resolved.purl });
74
+ const startedAt = Date.now();
75
+ const instance = await resolved.importInstance().catch(async (err) => {
76
+ await ctx.emit("ControllerLoadFailed", {
77
+ purl: resolved.purl,
78
+ error: err instanceof Error ? err.message : String(err),
79
+ });
80
+ throw err;
81
+ });
82
+ await ctx.registerController(moduleName, kindName, instance);
83
+ await ctx.emit("ControllerLoaded", {
84
+ purl: resolved.purl,
85
+ source: resolved.source,
86
+ durationMs: Date.now() - startedAt,
87
+ });
88
+ },
69
89
  );
70
90
  }
71
91
  }
72
92
 
93
+ /**
94
+ * Kernel-internal hook the concrete `ResourceContextImpl` exposes for lazy
95
+ * controller loading — deliberately off the public SDK `ResourceContext`
96
+ * surface, since only this controller uses it.
97
+ */
98
+ interface LazyControllerHost {
99
+ registerLazyController(
100
+ moduleName: string,
101
+ kindName: string,
102
+ load: () => Promise<void>,
103
+ ): void;
104
+ }
105
+
73
106
  export function register(ctx: ControllerContext): void {
74
107
  // ResourceDefinition is a passive resource - no registration needed
75
108
  }
package/src/kernel.ts CHANGED
@@ -165,6 +165,21 @@ export class Kernel implements IKernel {
165
165
  await controllerInstance.register?.(this.createControllerContext(`${moduleName}.${kindName}`));
166
166
  }
167
167
 
168
+ /**
169
+ * Register a deferred controller. The definition is already registered; the
170
+ * controller module is imported (and `register()` fired, via the `load` thunk
171
+ * calling back into `registerController`) only on the kind's first
172
+ * instantiation — see `_createInstance` / `ControllerRegistry.takeLazyController`.
173
+ */
174
+ registerLazyController(
175
+ moduleName: string,
176
+ kindName: string,
177
+ fingerprint: string,
178
+ load: () => Promise<void>,
179
+ ): void {
180
+ this.controllers.registerLazyController(`${moduleName}.${kindName}`, fingerprint, load);
181
+ }
182
+
168
183
  /**
169
184
  * Register a resource definition with the controller registry
170
185
  */
@@ -902,7 +917,16 @@ export class Kernel implements IKernel {
902
917
  const resolvedKind = (findEnclosingModule(evalContext) ?? this.rootContext).resolveKind(kind);
903
918
 
904
919
  const fingerprint = policyFingerprint(findEnclosingPolicy(evalContext));
905
- const controller = this.controllers.getControllerOrUndefined(resolvedKind, fingerprint);
920
+ let controller = this.controllers.getControllerOrUndefined(resolvedKind, fingerprint);
921
+ if (!controller) {
922
+ // Lazy controller loading: the kind's Telo.Definition registered a deferred
923
+ // controller (resolved but not imported). Import + register it now, on this
924
+ // first instantiation, then re-resolve. A kind with no lazy entry (abstract,
925
+ // or genuinely unknown) falls through to the error below unchanged.
926
+ if (await this.controllers.takeLazyController(resolvedKind, fingerprint)) {
927
+ controller = this.controllers.getControllerOrUndefined(resolvedKind, fingerprint);
928
+ }
929
+ }
906
930
  if (!controller) {
907
931
  const kindInfo =
908
932
  resolvedKind !== kind ? `'${kind}' (resolved to '${resolvedKind}')` : `'${kind}'`;
@@ -332,6 +332,21 @@ export class ResourceContextImpl implements ResourceContext {
332
332
  await this.kernel.registerController(moduleName, kindName, controllerInstance, fingerprint);
333
333
  }
334
334
 
335
+ /**
336
+ * Register a deferred controller under the declaring module's policy
337
+ * fingerprint (same keying as `registerController`). Internal to the kernel's
338
+ * lazy controller loading — not part of the public SDK `ResourceContext`
339
+ * surface; the only caller is the `Telo.Definition` controller.
340
+ */
341
+ registerLazyController(
342
+ moduleName: string,
343
+ kindName: string,
344
+ load: () => Promise<void>,
345
+ ): void {
346
+ const fingerprint = policyFingerprint(this.moduleContext.getControllerPolicy());
347
+ this.kernel.registerLazyController(moduleName, kindName, fingerprint, load);
348
+ }
349
+
335
350
  registerDefinition(def: any) {
336
351
  this.kernel.registerResourceDefinition(def);
337
352
  }