@telorun/kernel 0.27.0 → 0.29.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.
- package/dist/controller-loader.d.ts +27 -0
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +45 -3
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/bundle-loader.d.ts +10 -0
- package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
- package/dist/controller-loaders/bundle-loader.js +29 -14
- package/dist/controller-loaders/bundle-loader.js.map +1 -1
- package/dist/controller-loaders/napi-loader.d.ts +11 -0
- package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
- package/dist/controller-loaders/napi-loader.js +49 -30
- package/dist/controller-loaders/napi-loader.js.map +1 -1
- package/dist/controller-loaders/npm-loader.d.ts +10 -0
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +15 -2
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controller-registry.d.ts +17 -10
- package/dist/controller-registry.d.ts.map +1 -1
- package/dist/controller-registry.js +44 -10
- package/dist/controller-registry.js.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +27 -8
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/kernel.d.ts +23 -0
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +40 -1
- package/dist/kernel.js.map +1 -1
- package/dist/resource-context.d.ts +7 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +10 -0
- package/dist/resource-context.js.map +1 -1
- package/package.json +2 -2
- package/src/controller-loader.ts +72 -3
- package/src/controller-loaders/bundle-loader.ts +39 -20
- package/src/controller-loaders/napi-loader.ts +51 -30
- package/src/controller-loaders/npm-loader.ts +19 -2
- package/src/controller-registry.ts +63 -0
- package/src/controllers/resource-definition/resource-definition-controller.ts +44 -11
- package/src/kernel.ts +47 -1
- package/src/resource-context.ts +15 -0
package/src/controller-loader.ts
CHANGED
|
@@ -20,6 +20,19 @@ export type ControllerResolveSource =
|
|
|
20
20
|
| "cargo-build"
|
|
21
21
|
| "bundle";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* A controller candidate that has been *resolved* (verified hostable: package
|
|
25
|
+
* installed / bundle present / crate located) but not yet imported/evaluated.
|
|
26
|
+
* `importInstance` performs the deferred — and expensive — module load; lazy
|
|
27
|
+
* controller loading calls it on the kind's first instantiation. `purl`/`source`
|
|
28
|
+
* are known at resolve time and carried for the load-time events.
|
|
29
|
+
*/
|
|
30
|
+
export interface ResolvedController {
|
|
31
|
+
purl: string;
|
|
32
|
+
source: ControllerResolveSource;
|
|
33
|
+
importInstance: () => Promise<ControllerInstance>;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
export type ControllerLoaderEvent =
|
|
24
37
|
| { name: "ControllerLoading"; payload: { purl: string } }
|
|
25
38
|
| {
|
|
@@ -151,18 +164,74 @@ export class ControllerLoader {
|
|
|
151
164
|
throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", aggregated);
|
|
152
165
|
}
|
|
153
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Resolve a controller without importing it: pick the first candidate this
|
|
169
|
+
* environment can host (same ordering + env-missing fallback as {@link load}),
|
|
170
|
+
* verify it's present, and return a {@link ResolvedController} whose
|
|
171
|
+
* `importInstance` defers the actual import/eval. Used by lazy controller
|
|
172
|
+
* loading so a `Telo.Definition` fails fast at boot when its controller can't
|
|
173
|
+
* load at all, while the expensive import is paid only on first instantiation.
|
|
174
|
+
*
|
|
175
|
+
* Silent by design — no lifecycle events here; the caller emits
|
|
176
|
+
* ControllerLoading/Loaded around `importInstance` so the events fire when the
|
|
177
|
+
* load actually happens. A total resolution failure throws (the boot-time
|
|
178
|
+
* fail-fast), mirroring {@link load}'s aggregated error.
|
|
179
|
+
*/
|
|
180
|
+
async resolve(
|
|
181
|
+
purlCandidates: string[],
|
|
182
|
+
baseUri: string,
|
|
183
|
+
policy?: ControllerPolicy,
|
|
184
|
+
): Promise<ResolvedController> {
|
|
185
|
+
if (!purlCandidates || purlCandidates.length === 0) {
|
|
186
|
+
throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", "Missing controller PURL candidates");
|
|
187
|
+
}
|
|
188
|
+
const effectivePolicy = policy ?? DEFAULT_POLICY;
|
|
189
|
+
const ordered = orderCandidates(purlCandidates, effectivePolicy);
|
|
190
|
+
if (ordered.length === 0) {
|
|
191
|
+
throw new RuntimeError(
|
|
192
|
+
"ERR_CONTROLLER_NOT_FOUND",
|
|
193
|
+
`No controllers match runtime selection [${effectivePolicy.load.join(", ")}]; declared: ${purlCandidates.join(", ")}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const errors: string[] = [];
|
|
197
|
+
for (const purl of ordered) {
|
|
198
|
+
try {
|
|
199
|
+
const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri);
|
|
200
|
+
return { purl, source, importInstance };
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err instanceof ControllerEnvMissingError) {
|
|
203
|
+
errors.push(`${purl}: ${err.message}`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
throw new RuntimeError(
|
|
210
|
+
"ERR_CONTROLLER_NOT_FOUND",
|
|
211
|
+
`No controller resolved. Tried ${ordered.length} candidate(s):\n${errors.join("\n")}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
154
215
|
private async dispatchOne(
|
|
155
216
|
purl: string,
|
|
156
217
|
baseUri: string,
|
|
157
218
|
): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
|
|
219
|
+
const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri);
|
|
220
|
+
return { instance: await importInstance(), source };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private async dispatchResolveOne(
|
|
224
|
+
purl: string,
|
|
225
|
+
baseUri: string,
|
|
226
|
+
): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
|
|
158
227
|
if (purl.startsWith("pkg:npm")) {
|
|
159
|
-
return this.npmLoader.
|
|
228
|
+
return this.npmLoader.resolve(purl, baseUri);
|
|
160
229
|
}
|
|
161
230
|
if (purl.startsWith("pkg:cargo")) {
|
|
162
|
-
return this.napiLoader.
|
|
231
|
+
return this.napiLoader.resolve(purl, baseUri);
|
|
163
232
|
}
|
|
164
233
|
if (purl.startsWith("pkg:telo")) {
|
|
165
|
-
return this.bundleLoader.
|
|
234
|
+
return this.bundleLoader.resolve(purl, baseUri);
|
|
166
235
|
}
|
|
167
236
|
throw new ControllerEnvMissingError(`Unsupported PURL scheme: ${purl}`);
|
|
168
237
|
}
|
|
@@ -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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// the
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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(
|
|
@@ -178,6 +178,20 @@ export class NpmControllerLoader {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
async load(purl: string, baseUri: string): Promise<NpmLoadResult> {
|
|
181
|
+
const { source, importInstance } = await this.resolve(purl, baseUri);
|
|
182
|
+
return { instance: await importInstance(), source };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolve without importing: materialize the install root and install/verify
|
|
187
|
+
* the package (the cheap, cache-hit-fast part that fails fast if the package
|
|
188
|
+
* is absent), but defer `loadFromInstall` — the actual `import()`/eval, which
|
|
189
|
+
* is the expensive cold-start cost — into the returned `importInstance` thunk.
|
|
190
|
+
*/
|
|
191
|
+
async resolve(
|
|
192
|
+
purl: string,
|
|
193
|
+
baseUri: string,
|
|
194
|
+
): Promise<{ source: NpmResolveSource; importInstance: () => Promise<ControllerInstance> }> {
|
|
181
195
|
const parsed = PackageURL.fromString(purl);
|
|
182
196
|
if (!parsed.name) {
|
|
183
197
|
throw new Error(`Invalid PURL '${purl}': missing package name`);
|
|
@@ -197,8 +211,11 @@ export class NpmControllerLoader {
|
|
|
197
211
|
resolved.kind,
|
|
198
212
|
version,
|
|
199
213
|
);
|
|
200
|
-
const
|
|
201
|
-
return {
|
|
214
|
+
const subpath = parsed.subpath ?? null;
|
|
215
|
+
return {
|
|
216
|
+
source,
|
|
217
|
+
importInstance: () => loadFromInstall(installRoot, alias, subpath, purl),
|
|
218
|
+
};
|
|
202
219
|
}
|
|
203
220
|
|
|
204
221
|
/**
|
|
@@ -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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
@@ -112,6 +112,10 @@ export class Kernel implements IKernel {
|
|
|
112
112
|
private rootContext!: ModuleContext;
|
|
113
113
|
private staticManifests: ResourceManifest[] = [];
|
|
114
114
|
private _entryUrl?: string;
|
|
115
|
+
/** Root Application `ports:` resolved in `load()` — integer + declared protocol
|
|
116
|
+
* per name. Surfaced via {@link getResolvedPorts} so a host can advertise where
|
|
117
|
+
* the running app is reachable. */
|
|
118
|
+
private _resolvedPorts: Array<{ name: string; port: number; protocol: "tcp" | "udp" }> = [];
|
|
115
119
|
/** The `.telo` cache root for this load, resolved once in `load()` and
|
|
116
120
|
* threaded to the validator, analysis stamp, and npm install root. */
|
|
117
121
|
private _cacheRoot?: string | null;
|
|
@@ -161,6 +165,21 @@ export class Kernel implements IKernel {
|
|
|
161
165
|
await controllerInstance.register?.(this.createControllerContext(`${moduleName}.${kindName}`));
|
|
162
166
|
}
|
|
163
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
|
+
|
|
164
183
|
/**
|
|
165
184
|
* Register a resource definition with the controller registry
|
|
166
185
|
*/
|
|
@@ -509,9 +528,27 @@ export class Kernel implements IKernel {
|
|
|
509
528
|
if (Object.keys(ports).length > 0) {
|
|
510
529
|
this.rootContext.setPorts(ports);
|
|
511
530
|
}
|
|
531
|
+
const portDecls = (rootApplicationManifest as { ports?: Record<string, { protocol?: string }> })
|
|
532
|
+
.ports ?? {};
|
|
533
|
+
this._resolvedPorts = Object.entries(ports).map(([name, port]) => ({
|
|
534
|
+
name,
|
|
535
|
+
port,
|
|
536
|
+
protocol: portDecls[name]?.protocol === "udp" ? "udp" : "tcp",
|
|
537
|
+
}));
|
|
512
538
|
}
|
|
513
539
|
}
|
|
514
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Resolved inbound ports from the root Application's `ports:` block, available
|
|
543
|
+
* after {@link load}. Each carries the resolved integer and its declared
|
|
544
|
+
* protocol; empty when the root declares no ports (or isn't an Application).
|
|
545
|
+
* Surfaced so a host (e.g. the CLI inspection endpoint) can tell the debug UI
|
|
546
|
+
* where the running app is reachable.
|
|
547
|
+
*/
|
|
548
|
+
getResolvedPorts(): ReadonlyArray<{ name: string; port: number; protocol: "tcp" | "udp" }> {
|
|
549
|
+
return this._resolvedPorts;
|
|
550
|
+
}
|
|
551
|
+
|
|
515
552
|
/**
|
|
516
553
|
* Initialize every resource declared in the manifest. Does not run targets
|
|
517
554
|
* and does not wait — returns as soon as the kernel is ready to accept
|
|
@@ -880,7 +917,16 @@ export class Kernel implements IKernel {
|
|
|
880
917
|
const resolvedKind = (findEnclosingModule(evalContext) ?? this.rootContext).resolveKind(kind);
|
|
881
918
|
|
|
882
919
|
const fingerprint = policyFingerprint(findEnclosingPolicy(evalContext));
|
|
883
|
-
|
|
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
|
+
}
|
|
884
930
|
if (!controller) {
|
|
885
931
|
const kindInfo =
|
|
886
932
|
resolvedKind !== kind ? `'${kind}' (resolved to '${resolvedKind}')` : `'${kind}'`;
|
package/src/resource-context.ts
CHANGED
|
@@ -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
|
}
|