@telorun/kernel 0.75.0 → 0.77.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/bundle/module-artifact.d.ts +21 -4
- package/dist/bundle/module-artifact.d.ts.map +1 -1
- package/dist/bundle/module-artifact.js +42 -17
- package/dist/bundle/module-artifact.js.map +1 -1
- package/dist/bundle/module-manifest.d.ts +5 -1
- package/dist/bundle/module-manifest.d.ts.map +1 -1
- package/dist/bundle/module-manifest.js +5 -1
- package/dist/bundle/module-manifest.js.map +1 -1
- package/dist/controller-loader.d.ts +3 -2
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +10 -9
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/bundle-loader.d.ts +51 -4
- package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
- package/dist/controller-loaders/bundle-loader.js +260 -11
- package/dist/controller-loaders/bundle-loader.js.map +1 -1
- package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
- package/dist/controller-loaders/napi-loader.js +8 -1
- package/dist/controller-loaders/napi-loader.js.map +1 -1
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +65 -3
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controller-loaders/sibling-libraries.d.ts +93 -0
- package/dist/controller-loaders/sibling-libraries.d.ts.map +1 -0
- package/dist/controller-loaders/sibling-libraries.js +111 -0
- package/dist/controller-loaders/sibling-libraries.js.map +1 -0
- package/dist/controller-loaders/source-bundle-builder.d.ts +25 -2
- package/dist/controller-loaders/source-bundle-builder.d.ts.map +1 -1
- package/dist/controller-loaders/source-bundle-builder.js +232 -26
- package/dist/controller-loaders/source-bundle-builder.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 +5 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/host-versions.d.ts +23 -0
- package/dist/host-versions.d.ts.map +1 -0
- package/dist/host-versions.js +25 -0
- package/dist/host-versions.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts +11 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +43 -5
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-schemas.d.ts +1 -1
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +5 -3
- package/dist/manifest-schemas.js.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts +13 -2
- package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.js +19 -3
- package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
- package/dist/resource-context.d.ts +5 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +6 -0
- package/dist/resource-context.js.map +1 -1
- package/dist/runtime-seam.d.ts.map +1 -1
- package/dist/runtime-seam.js +2 -0
- package/dist/runtime-seam.js.map +1 -1
- package/package.json +3 -3
- package/src/bundle/module-artifact.ts +50 -20
- package/src/bundle/module-manifest.ts +14 -1
- package/src/controller-loader.ts +25 -7
- package/src/controller-loaders/bundle-loader.ts +326 -10
- package/src/controller-loaders/napi-loader.ts +8 -1
- package/src/controller-loaders/npm-loader.ts +73 -3
- package/src/controller-loaders/sibling-libraries.ts +171 -0
- package/src/controller-loaders/source-bundle-builder.ts +281 -24
- package/src/controllers/resource-definition/resource-definition-controller.ts +7 -1
- package/src/host-versions.ts +26 -0
- package/src/index.ts +2 -0
- package/src/kernel.ts +56 -12
- package/src/manifest-schemas.ts +8 -2
- package/src/manifest-sources/local-manifest-cache-source.ts +20 -3
- package/src/resource-context.ts +8 -0
- package/src/runtime-seam.ts +2 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module-owned libraries a module's controller bundles import by bare
|
|
3
|
+
* specifier, resolved to the artifacts that carry them.
|
|
4
|
+
*
|
|
5
|
+
* A bundle imports `@telorun/kv-store`; the manifest declares the dependency as
|
|
6
|
+
* `KvStore: ../kv-store`; the target module's own `library:` block says that its
|
|
7
|
+
* `js` entry point is what `@telorun/kv-store` means. This type is the joined
|
|
8
|
+
* result — computed once per module during `kernel.load()`, where the import
|
|
9
|
+
* graph and every module's artifact are both in hand, and handed to the loader.
|
|
10
|
+
*
|
|
11
|
+
* It is deliberately **not** something the loader derives for itself. Resolution
|
|
12
|
+
* needs the import edges (which module is `KvStore`), the target's manifest (what
|
|
13
|
+
* specifier it declares) and the target's artifact (where its layer is); a loader
|
|
14
|
+
* sees one canonical base URI and none of those.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ArtifactSelector, LoadedGraph } from "@telorun/analyzer";
|
|
18
|
+
import type { Logger } from "@telorun/sdk";
|
|
19
|
+
import type { ModuleArtifact } from "../bundle/module-artifact.js";
|
|
20
|
+
import type { OwnerManifest } from "../bundle/module-manifest.js";
|
|
21
|
+
|
|
22
|
+
export interface ResolvedSiblingLibrary {
|
|
23
|
+
/** The bare specifier a consumer's bundle imports this library by. */
|
|
24
|
+
readonly specifier: string;
|
|
25
|
+
/** The selector of the library candidate — matched against the consuming
|
|
26
|
+
* candidate's own, so a `js` bundle resolves the `js` entry point. */
|
|
27
|
+
readonly selector: ArtifactSelector;
|
|
28
|
+
/** Module-root-relative built entry point. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
/** Module-root-relative TypeScript source, when the target declares one and is
|
|
31
|
+
* a working copy. */
|
|
32
|
+
readonly localPath?: string;
|
|
33
|
+
/** The owning module's directory: where its layers extract to, or where it
|
|
34
|
+
* simply sits on disk. `undefined` for a module whose payload has no local
|
|
35
|
+
* home (a `memory://` manifest), which makes the specifier unresolvable and
|
|
36
|
+
* is reported as such rather than guessed at. */
|
|
37
|
+
readonly moduleDir: string | undefined;
|
|
38
|
+
/** The owning module's artifact, when it ships one. Absent for a working copy,
|
|
39
|
+
* which is what selects the build-from-source path. */
|
|
40
|
+
readonly artifact: ModuleArtifact | undefined;
|
|
41
|
+
/** Canonical source URL of the owning module — diagnostics, and the key the
|
|
42
|
+
* loader reports a version skew against. */
|
|
43
|
+
readonly moduleSource: string;
|
|
44
|
+
/** The owning module's declared version, for the skew report. */
|
|
45
|
+
readonly moduleVersion: string | undefined;
|
|
46
|
+
/** The owning module's OWN sibling libraries. Building a library entry from
|
|
47
|
+
* source is the same build as building a controller from source, so it needs
|
|
48
|
+
* the same externals — `kv-store-sql`'s bundle externalizes `@telorun/sql`,
|
|
49
|
+
* and building `@telorun/sql`'s entry point in turn needs sql's own. Held as
|
|
50
|
+
* a live reference and filled in a second pass, so an import cycle cannot
|
|
51
|
+
* make construction recurse. */
|
|
52
|
+
readonly libraries: SiblingLibraryMap;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Every sibling library one module's bundles may import, by specifier. */
|
|
56
|
+
export type SiblingLibraryMap = ReadonlyMap<string, ResolvedSiblingLibrary>;
|
|
57
|
+
|
|
58
|
+
/** What a controller loader is handed: the libraries of the module that declared
|
|
59
|
+
* the candidate being resolved. Empty for a module that imports none. */
|
|
60
|
+
export const NO_SIBLING_LIBRARIES: SiblingLibraryMap = new Map();
|
|
61
|
+
|
|
62
|
+
/** What the join needs from the kernel: the already-parsed owner manifests, the
|
|
63
|
+
* artifacts built from them, and where each module's files live. Passed in
|
|
64
|
+
* rather than reached for, so the join is a pure function of the graph and is
|
|
65
|
+
* testable without booting a kernel. */
|
|
66
|
+
export interface SiblingLibraryInputs {
|
|
67
|
+
/** Owner manifest per module source. Parsed once in `load()` and shared with
|
|
68
|
+
* artifact construction — re-reading here would parse every target's whole
|
|
69
|
+
* `telo.yaml` again, once per import edge, on the boot path. */
|
|
70
|
+
readonly ownerManifests: ReadonlyMap<string, OwnerManifest>;
|
|
71
|
+
readonly artifactFor: (source: string) => ModuleArtifact | undefined;
|
|
72
|
+
readonly directoryFor: (source: string) => string | undefined;
|
|
73
|
+
readonly log?: Logger;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Join the import graph to each target's declared library entry point, so a
|
|
78
|
+
* controller bundle's bare `@telorun/kv-store` resolves to the module that owns
|
|
79
|
+
* it instead of being copied into the bundle.
|
|
80
|
+
*
|
|
81
|
+
* Called from `kernel.load()` for the same reason the artifacts are built there:
|
|
82
|
+
* that is the only point where all three halves are in hand — which module an
|
|
83
|
+
* alias names (`importEdges`), what specifier that module declares (`library:` on
|
|
84
|
+
* its owner doc), and where its payload lives. A controller loader sees one
|
|
85
|
+
* canonical base URI and none of them. The join itself is pure, so it lives here
|
|
86
|
+
* beside the model rather than in the orchestrator.
|
|
87
|
+
*
|
|
88
|
+
* Keyed by canonical module source, so a definition's `metadata.source` finds the
|
|
89
|
+
* libraries of the module that DECLARED it — never the consumer's. An import edge
|
|
90
|
+
* declared in an `include:` partial is attributed to the module that owns the
|
|
91
|
+
* partial, since the resulting bundle is that module's.
|
|
92
|
+
*
|
|
93
|
+
* Version skew is reported, not prevented: two dependents pinning different
|
|
94
|
+
* versions of one library legitimately resolve two copies, and two module scopes.
|
|
95
|
+
* That is different code, so it is correct — but a shared-state seam that assumed
|
|
96
|
+
* one scope would break silently, which is what the warning is for.
|
|
97
|
+
*/
|
|
98
|
+
export function buildSiblingLibraries(
|
|
99
|
+
graph: LoadedGraph,
|
|
100
|
+
inputs: SiblingLibraryInputs,
|
|
101
|
+
): Map<string, SiblingLibraryMap> {
|
|
102
|
+
// Which module owns each loaded file, so an import declared in a partial is
|
|
103
|
+
// attributed to its module.
|
|
104
|
+
const ownerOf = new Map<string, string>();
|
|
105
|
+
for (const [, module] of graph.modules) {
|
|
106
|
+
ownerOf.set(module.owner.source, module.owner.source);
|
|
107
|
+
for (const partial of module.partials) ownerOf.set(partial.source, module.owner.source);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Two passes, so an import cycle cannot make construction recurse: every module
|
|
111
|
+
// gets its (mutable) map first, and each entry then holds a live reference to
|
|
112
|
+
// its target's map rather than a copy built inline.
|
|
113
|
+
const maps = new Map<string, Map<string, ResolvedSiblingLibrary>>();
|
|
114
|
+
for (const [, module] of graph.modules) {
|
|
115
|
+
if (!maps.has(module.owner.source)) maps.set(module.owner.source, new Map());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The module first seen behind each specifier, and the specifiers already
|
|
119
|
+
* reported as skewed — so one shared library resolved at two versions is one
|
|
120
|
+
* warning, not one per consumer that imports it. */
|
|
121
|
+
const claimed = new Map<string, { source: string; version: string | undefined }>();
|
|
122
|
+
const reported = new Set<string>();
|
|
123
|
+
|
|
124
|
+
for (const [declaringFile, edges] of graph.importEdges) {
|
|
125
|
+
const map = maps.get(ownerOf.get(declaringFile) ?? declaringFile);
|
|
126
|
+
if (!map) continue;
|
|
127
|
+
for (const [, edge] of edges) {
|
|
128
|
+
const target = graph.modules.get(edge.targetSource);
|
|
129
|
+
if (!target) continue;
|
|
130
|
+
const owner = inputs.ownerManifests.get(target.owner.source);
|
|
131
|
+
if (!owner || owner.library.length === 0) continue;
|
|
132
|
+
const moduleDir = inputs.directoryFor(target.owner.source);
|
|
133
|
+
for (const candidate of owner.library) {
|
|
134
|
+
const previous = claimed.get(candidate.specifier);
|
|
135
|
+
if (!previous) {
|
|
136
|
+
claimed.set(candidate.specifier, {
|
|
137
|
+
source: target.owner.source,
|
|
138
|
+
version: owner.version,
|
|
139
|
+
});
|
|
140
|
+
} else if (previous.source !== target.owner.source && !reported.has(candidate.specifier)) {
|
|
141
|
+
reported.add(candidate.specifier);
|
|
142
|
+
inputs.log?.warn(
|
|
143
|
+
`shared module library '${candidate.specifier}' resolves to two modules in this ` +
|
|
144
|
+
`graph, so it runs as two module scopes. A seam that must share state across them ` +
|
|
145
|
+
`has to say so — the honest granularity is the import pin.`,
|
|
146
|
+
{
|
|
147
|
+
"telo.library.specifier": candidate.specifier,
|
|
148
|
+
"telo.library.source": target.owner.source,
|
|
149
|
+
"telo.library.version": owner.version ?? "",
|
|
150
|
+
"telo.library.other_source": previous.source,
|
|
151
|
+
"telo.library.other_version": previous.version ?? "",
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
map.set(candidate.specifier, {
|
|
156
|
+
specifier: candidate.specifier,
|
|
157
|
+
selector: candidate.selector,
|
|
158
|
+
path: candidate.path,
|
|
159
|
+
...(candidate.localPath ? { localPath: candidate.localPath } : {}),
|
|
160
|
+
moduleDir,
|
|
161
|
+
artifact: inputs.artifactFor(target.owner.source),
|
|
162
|
+
moduleSource: target.owner.source,
|
|
163
|
+
moduleVersion: owner.version,
|
|
164
|
+
libraries: maps.get(target.owner.source) ?? new Map(),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return maps;
|
|
171
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { DEFAULT_MANIFEST_FILENAME, type LibraryCandidate } from "@telorun/analyzer";
|
|
1
2
|
import { RuntimeError } from "@telorun/sdk";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
4
|
import { createHash } from "node:crypto";
|
|
3
5
|
import * as fs from "node:fs/promises";
|
|
4
6
|
import * as path from "node:path";
|
|
5
7
|
|
|
8
|
+
import { readOwnerManifest } from "../bundle/module-manifest.js";
|
|
6
9
|
import { ControllerEnvMissingError } from "./napi-loader.js";
|
|
7
10
|
import { REALM_COLLAPSE_NAMES } from "./realm.js";
|
|
8
11
|
|
|
@@ -58,6 +61,30 @@ import { REALM_COLLAPSE_NAMES } from "./realm.js";
|
|
|
58
61
|
* entry per entry point recording the inputs its last build read. */
|
|
59
62
|
const CACHE_DIR = "controller-src";
|
|
60
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A module-owned library this bundle must **not** inline: the bare specifier its
|
|
66
|
+
* sources import it by, and the tree that specifier's code lives in.
|
|
67
|
+
*
|
|
68
|
+
* Both halves are load-bearing. The specifier is what esbuild externalizes; the
|
|
69
|
+
* tree is what the post-build check tests the metafile against, because an import
|
|
70
|
+
* written some other way — a relative path into a sibling's sources, a subpath, a
|
|
71
|
+
* transitive dependency that reaches the same file — would sail past an externals
|
|
72
|
+
* list and silently restore the duplicated module scope this whole mechanism
|
|
73
|
+
* exists to remove.
|
|
74
|
+
*
|
|
75
|
+
* The tree is the directory of the library's **entry source**, not the module's
|
|
76
|
+
* own directory. A module directory holds its tests, and a test fixture module
|
|
77
|
+
* nested inside one is a different module whose bundle is its own; taking the
|
|
78
|
+
* whole directory would report every such fixture as an inlined sibling. What can
|
|
79
|
+
* actually be inlined is what the entry point reaches, which is what it sits in.
|
|
80
|
+
*/
|
|
81
|
+
export interface SiblingLibrary {
|
|
82
|
+
readonly specifier: string;
|
|
83
|
+
/** Absolute directory of the library's entry source. Absent for a published
|
|
84
|
+
* sibling, which ships no sources for a consumer's build to reach. */
|
|
85
|
+
readonly sourceDir?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
61
88
|
/**
|
|
62
89
|
* The esbuild options a controller bundle is built with. They must match the
|
|
63
90
|
* flags each module's `build` script passes, because a bundle a contributor runs
|
|
@@ -65,7 +92,10 @@ const CACHE_DIR = "controller-src";
|
|
|
65
92
|
*
|
|
66
93
|
* The realm names stay external because the bundle loader symlinks them to the
|
|
67
94
|
* kernel's own copy at load time. Inlining them would duplicate the runtime and
|
|
68
|
-
* break the constructor identity `Stream` / `InvokeError` depend on.
|
|
95
|
+
* break the constructor identity `Stream` / `InvokeError` depend on. A sibling
|
|
96
|
+
* module's declared specifier is external for the same reason one step out: the
|
|
97
|
+
* loader resolves it to that module's own library layer, so every consumer —
|
|
98
|
+
* and the owning module's own controllers — share one module scope.
|
|
69
99
|
*
|
|
70
100
|
* The banner defines `require` in module scope so esbuild's `__require` shim —
|
|
71
101
|
* emitted for `require(...)` calls inside a bundled CJS dependency — falls
|
|
@@ -99,18 +129,31 @@ const CONTROLLER_BUNDLE_OPTIONS = {
|
|
|
99
129
|
} as const;
|
|
100
130
|
|
|
101
131
|
/**
|
|
102
|
-
* Fingerprint of the options above
|
|
132
|
+
* Fingerprint of the options above **and this module's externals**, folded into
|
|
133
|
+
* every cache key.
|
|
103
134
|
*
|
|
104
135
|
* The output is a function of the inputs *and* how they were built, so a change
|
|
105
136
|
* to the option set has to invalidate the cache the same way an edited source
|
|
106
137
|
* does — otherwise a kernel upgrade that changes the banner or the externals
|
|
107
138
|
* keeps serving bundles built the old way, which is the exact silent-stale-copy
|
|
108
|
-
* failure the content-addressing exists to prevent.
|
|
139
|
+
* failure the content-addressing exists to prevent. The externals now vary per
|
|
140
|
+
* module, and they decide whether a library is inlined or resolved at load, so
|
|
141
|
+
* they belong in the key for exactly the same reason the banner does.
|
|
109
142
|
*/
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
143
|
+
function optionsFingerprint(externals: readonly string[]): string {
|
|
144
|
+
return createHash("sha256")
|
|
145
|
+
.update(JSON.stringify(CONTROLLER_BUNDLE_OPTIONS))
|
|
146
|
+
.update("\n")
|
|
147
|
+
.update(JSON.stringify([...externals].sort()))
|
|
148
|
+
.digest("hex")
|
|
149
|
+
.slice(0, 8);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The bare specifiers one build externalizes: the realm names plus every
|
|
153
|
+
* sibling library, sorted so the fingerprint is order-independent. */
|
|
154
|
+
function externalSpecifiers(libraries: readonly SiblingLibrary[]): string[] {
|
|
155
|
+
return [...REALM_COLLAPSE_NAMES, ...libraries.map((l) => l.specifier)];
|
|
156
|
+
}
|
|
114
157
|
|
|
115
158
|
interface BuildIndexEntry {
|
|
116
159
|
/** Absolute paths of every file the last build read, from esbuild's metafile. */
|
|
@@ -166,7 +209,7 @@ async function pathExists(p: string): Promise<boolean> {
|
|
|
166
209
|
* change: the caller rebuilds rather than trusting a signature computed over a
|
|
167
210
|
* file set that no longer exists.
|
|
168
211
|
*/
|
|
169
|
-
async function signInputs(inputs: string[]): Promise<string | null> {
|
|
212
|
+
async function signInputs(inputs: string[], externals: readonly string[]): Promise<string | null> {
|
|
170
213
|
const stats = await Promise.all(
|
|
171
214
|
inputs.map(async (file) => {
|
|
172
215
|
try {
|
|
@@ -179,7 +222,7 @@ async function signInputs(inputs: string[]): Promise<string | null> {
|
|
|
179
222
|
);
|
|
180
223
|
if (stats.some((entry) => entry === null)) return null;
|
|
181
224
|
return createHash("sha256")
|
|
182
|
-
.update(
|
|
225
|
+
.update(optionsFingerprint(externals))
|
|
183
226
|
.update("\n")
|
|
184
227
|
.update(stats.join("\n"))
|
|
185
228
|
.digest("hex")
|
|
@@ -191,8 +234,23 @@ function indexPath(cacheDir: string, entryFile: string): string {
|
|
|
191
234
|
return path.join(cacheDir, `${id}.index.json`);
|
|
192
235
|
}
|
|
193
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Each build gets its **own directory**, not just its own filename.
|
|
239
|
+
*
|
|
240
|
+
* The bundle loader writes a `node_modules/` beside a bundle to make bare
|
|
241
|
+
* specifiers resolve — the realm names, and one shim per sibling library. Those
|
|
242
|
+
* are per bundle: two modules can legitimately resolve different versions of one
|
|
243
|
+
* library, and with every dev build sharing one flat cache directory the second
|
|
244
|
+
* would overwrite the first's shim and silently hand it the wrong copy. A
|
|
245
|
+
* directory per content-addressed key makes that unrepresentable, and costs an
|
|
246
|
+
* inode.
|
|
247
|
+
*/
|
|
248
|
+
function bundleDir(cacheDir: string, key: string): string {
|
|
249
|
+
return path.join(cacheDir, key);
|
|
250
|
+
}
|
|
251
|
+
|
|
194
252
|
function bundlePath(cacheDir: string, key: string): string {
|
|
195
|
-
return path.join(cacheDir,
|
|
253
|
+
return path.join(bundleDir(cacheDir, key), "bundle.mjs");
|
|
196
254
|
}
|
|
197
255
|
|
|
198
256
|
/**
|
|
@@ -236,11 +294,13 @@ let tmpCounter = 0;
|
|
|
236
294
|
export async function buildControllerFromSource(
|
|
237
295
|
entryFile: string,
|
|
238
296
|
cacheRoot: string,
|
|
297
|
+
libraries: readonly SiblingLibrary[] = [],
|
|
239
298
|
): Promise<string> {
|
|
240
299
|
const cacheDir = path.join(cacheRoot, CACHE_DIR);
|
|
300
|
+
const externals = externalSpecifiers(libraries);
|
|
241
301
|
const index = await readIndex(indexPath(cacheDir, entryFile));
|
|
242
302
|
if (index) {
|
|
243
|
-
const key = await signInputs(index.inputs);
|
|
303
|
+
const key = await signInputs(index.inputs, externals);
|
|
244
304
|
if (key === index.key) {
|
|
245
305
|
const cached = bundlePath(cacheDir, key);
|
|
246
306
|
if (await pathExists(cached)) return cached;
|
|
@@ -249,7 +309,9 @@ export async function buildControllerFromSource(
|
|
|
249
309
|
|
|
250
310
|
const inFlight = buildsInFlight.get(entryFile);
|
|
251
311
|
if (inFlight) return inFlight;
|
|
252
|
-
const work = build(entryFile, cacheDir).finally(() =>
|
|
312
|
+
const work = build(entryFile, cacheDir, libraries).finally(() =>
|
|
313
|
+
buildsInFlight.delete(entryFile),
|
|
314
|
+
);
|
|
253
315
|
buildsInFlight.set(entryFile, work);
|
|
254
316
|
return work;
|
|
255
317
|
}
|
|
@@ -272,12 +334,201 @@ export async function buildControllerFromSource(
|
|
|
272
334
|
export async function buildControllerBundle(
|
|
273
335
|
entryFile: string,
|
|
274
336
|
cacheRoot: string,
|
|
337
|
+
libraries: readonly SiblingLibrary[] = [],
|
|
275
338
|
): Promise<{ path: string; inputs: string[] }> {
|
|
276
|
-
const path = await buildControllerFromSource(entryFile, cacheRoot);
|
|
339
|
+
const path = await buildControllerFromSource(entryFile, cacheRoot, libraries);
|
|
277
340
|
return { path, inputs: await lastBuildInputs(entryFile, cacheRoot) };
|
|
278
341
|
}
|
|
279
342
|
|
|
280
|
-
|
|
343
|
+
/**
|
|
344
|
+
* Refuse a **subpath** of an externalized specifier.
|
|
345
|
+
*
|
|
346
|
+
* A module's library surface is one specifier and one entry point — subpaths are
|
|
347
|
+
* deliberately not representable, since reproducing npm's `exports` map inside
|
|
348
|
+
* the artifact would pull a package manager's resolution semantics into Telo. Left
|
|
349
|
+
* alone, `@telorun/ai/content` matches no external, so esbuild inlines it and the
|
|
350
|
+
* duplicated scope comes back silently. Marking `<specifier>/*` external instead
|
|
351
|
+
* would trade that for a module-not-found on someone else's machine, so the
|
|
352
|
+
* honest place to fail is the build.
|
|
353
|
+
*/
|
|
354
|
+
function rejectSubpathImports(libraries: readonly SiblingLibrary[]): import("esbuild").Plugin {
|
|
355
|
+
return {
|
|
356
|
+
name: "telo-library-subpath",
|
|
357
|
+
setup(build) {
|
|
358
|
+
for (const library of libraries) {
|
|
359
|
+
const prefix = `${library.specifier}/`;
|
|
360
|
+
build.onResolve({ filter: new RegExp(`^${escapeRegExp(prefix)}`) }, (args) => ({
|
|
361
|
+
errors: [
|
|
362
|
+
{
|
|
363
|
+
text:
|
|
364
|
+
`'${args.path}' imports a subpath of the module library '${library.specifier}'. ` +
|
|
365
|
+
`A module exposes one entry point, so import '${library.specifier}' itself.`,
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function escapeRegExp(value: string): string {
|
|
375
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Refuse a bundle that reached into a sibling module's source tree by any route
|
|
380
|
+
* other than its declared specifier.
|
|
381
|
+
*
|
|
382
|
+
* The externals list only governs what the *entry's own* imports resolve to. A
|
|
383
|
+
* relative path into a sibling's `src/`, or a transitive hop through a third
|
|
384
|
+
* package, lands the sibling's source in this bundle regardless — one more copy
|
|
385
|
+
* of a module scope, and nothing else would ever report it. The metafile is the
|
|
386
|
+
* only place this is visible, so it is checked here, on both the load path and
|
|
387
|
+
* the publish path, which share this builder.
|
|
388
|
+
*/
|
|
389
|
+
function assertNoInlinedSiblings(
|
|
390
|
+
entryFile: string,
|
|
391
|
+
inputs: string[],
|
|
392
|
+
libraries: readonly SiblingLibrary[],
|
|
393
|
+
): void {
|
|
394
|
+
assertNoUndeclaredSiblings(entryFile, inputs, libraries);
|
|
395
|
+
const offenders = new Map<string, string[]>();
|
|
396
|
+
for (const input of inputs) {
|
|
397
|
+
for (const library of libraries) {
|
|
398
|
+
if (!library.sourceDir) continue;
|
|
399
|
+
const root = library.sourceDir.endsWith(path.sep)
|
|
400
|
+
? library.sourceDir
|
|
401
|
+
: library.sourceDir + path.sep;
|
|
402
|
+
if (!input.startsWith(root)) continue;
|
|
403
|
+
const seen = offenders.get(library.specifier) ?? [];
|
|
404
|
+
seen.push(input);
|
|
405
|
+
offenders.set(library.specifier, seen);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (offenders.size === 0) return;
|
|
409
|
+
const detail = [...offenders]
|
|
410
|
+
.map(([specifier, files]) => ` ${specifier}: ${files.slice(0, 5).join(", ")}`)
|
|
411
|
+
.join("\n");
|
|
412
|
+
throw new RuntimeError(
|
|
413
|
+
"ERR_CONTROLLER_BUILD_FAILED",
|
|
414
|
+
`Controller bundle "${entryFile}" inlines source from a module it imports:\n${detail}\n` +
|
|
415
|
+
`A module-owned library is resolved at load through the import graph, so its module scope ` +
|
|
416
|
+
`is shared. Import it by its declared specifier instead of reaching into its files.`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Refuse a bundle that inlined a module-owned library it never declared an import
|
|
422
|
+
* for.
|
|
423
|
+
*
|
|
424
|
+
* The check above is derived from the `imports:` edges, so it is vacuous exactly
|
|
425
|
+
* where the mistake is made: a module whose TypeScript imports `@telorun/sql`
|
|
426
|
+
* while its manifest never says `Sql: ../sql`. Nothing externalizes the specifier,
|
|
427
|
+
* the package manager resolves it, esbuild inlines it, and the duplicated module
|
|
428
|
+
* scope this whole mechanism removes comes back with nothing to report it. The
|
|
429
|
+
* analyzer cannot see TypeScript sources; the metafile is the only place this is
|
|
430
|
+
* decidable, so it is decided here — on the run path and the publish path alike,
|
|
431
|
+
* since both go through this builder.
|
|
432
|
+
*
|
|
433
|
+
* Detection needs no workspace registry: an input that is neither under this
|
|
434
|
+
* module's own root nor inside a `node_modules` tree belongs to *some* other
|
|
435
|
+
* module, and the nearest enclosing `telo.yaml` says which. A third-party
|
|
436
|
+
* dependency always resolves inside a `node_modules` directory, so the probe skips
|
|
437
|
+
* the overwhelming majority of inputs without a filesystem walk.
|
|
438
|
+
*/
|
|
439
|
+
function assertNoUndeclaredSiblings(
|
|
440
|
+
entryFile: string,
|
|
441
|
+
inputs: string[],
|
|
442
|
+
libraries: readonly SiblingLibrary[],
|
|
443
|
+
): void {
|
|
444
|
+
const declared = new Set(libraries.map((library) => library.specifier));
|
|
445
|
+
const ownRoot = nearestModuleRoot(path.dirname(entryFile));
|
|
446
|
+
const offenders = new Map<string, { root: string; files: string[] }>();
|
|
447
|
+
|
|
448
|
+
for (const input of inputs) {
|
|
449
|
+
if (input.includes(`${path.sep}node_modules${path.sep}`)) continue;
|
|
450
|
+
if (ownRoot && isUnder(input, ownRoot)) continue;
|
|
451
|
+
const root = nearestModuleRoot(path.dirname(input));
|
|
452
|
+
if (!root || root === ownRoot) continue;
|
|
453
|
+
for (const candidate of libraryCandidatesOf(root)) {
|
|
454
|
+
// A declared specifier is the other check's business — it reports the same
|
|
455
|
+
// file with the instruction that fits (import it properly, not add it).
|
|
456
|
+
if (declared.has(candidate.specifier) || !candidate.localPath) continue;
|
|
457
|
+
if (!isUnder(input, path.dirname(path.resolve(root, candidate.localPath)))) continue;
|
|
458
|
+
const seen = offenders.get(candidate.specifier) ?? { root, files: [] };
|
|
459
|
+
seen.files.push(input);
|
|
460
|
+
offenders.set(candidate.specifier, seen);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (offenders.size === 0) return;
|
|
464
|
+
|
|
465
|
+
const detail = [...offenders]
|
|
466
|
+
.map(
|
|
467
|
+
([specifier, { root, files }]) =>
|
|
468
|
+
` ${specifier} (${root}): ${files.slice(0, 5).join(", ")}`,
|
|
469
|
+
)
|
|
470
|
+
.join("\n");
|
|
471
|
+
throw new RuntimeError(
|
|
472
|
+
"ERR_CONTROLLER_BUILD_FAILED",
|
|
473
|
+
`Controller bundle "${entryFile}" inlines a module-owned library it does not import:\n` +
|
|
474
|
+
`${detail}\n` +
|
|
475
|
+
`Declare that module in this one's \`imports:\` — the kernel then resolves the specifier ` +
|
|
476
|
+
`to its own entry point at load, so the library is one module scope. Undeclared, it is ` +
|
|
477
|
+
`copied into this bundle and any state it keeps becomes a second copy.`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function isUnder(file: string, dir: string): boolean {
|
|
482
|
+
const root = dir.endsWith(path.sep) ? dir : dir + path.sep;
|
|
483
|
+
return file.startsWith(root);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Nearest ancestor directory holding a `telo.yaml` — the module a file belongs
|
|
487
|
+
* to. Memoized per directory: a bundle's inputs cluster into a handful of trees,
|
|
488
|
+
* and the walk is otherwise repeated per file. */
|
|
489
|
+
const moduleRoots = new Map<string, string | undefined>();
|
|
490
|
+
function nearestModuleRoot(from: string): string | undefined {
|
|
491
|
+
const cached = moduleRoots.get(from);
|
|
492
|
+
if (cached !== undefined || moduleRoots.has(from)) return cached;
|
|
493
|
+
let dir = from;
|
|
494
|
+
for (;;) {
|
|
495
|
+
if (existsSync(path.join(dir, DEFAULT_MANIFEST_FILENAME))) {
|
|
496
|
+
moduleRoots.set(from, dir);
|
|
497
|
+
return dir;
|
|
498
|
+
}
|
|
499
|
+
const parent = path.dirname(dir);
|
|
500
|
+
if (parent === dir) {
|
|
501
|
+
moduleRoots.set(from, undefined);
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
dir = parent;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** The `library:` candidates a module declares, read once per module root. An
|
|
509
|
+
* unreadable or malformed manifest contributes none: this check exists to report
|
|
510
|
+
* an inlined library, and the analyzer is what reports a broken manifest. */
|
|
511
|
+
const moduleLibraries = new Map<string, LibraryCandidate[]>();
|
|
512
|
+
function libraryCandidatesOf(root: string): LibraryCandidate[] {
|
|
513
|
+
const cached = moduleLibraries.get(root);
|
|
514
|
+
if (cached) return cached;
|
|
515
|
+
let candidates: LibraryCandidate[] = [];
|
|
516
|
+
try {
|
|
517
|
+
candidates = readOwnerManifest(
|
|
518
|
+
readFileSync(path.join(root, DEFAULT_MANIFEST_FILENAME), "utf8"),
|
|
519
|
+
).library;
|
|
520
|
+
} catch {
|
|
521
|
+
candidates = [];
|
|
522
|
+
}
|
|
523
|
+
moduleLibraries.set(root, candidates);
|
|
524
|
+
return candidates;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function build(
|
|
528
|
+
entryFile: string,
|
|
529
|
+
cacheDir: string,
|
|
530
|
+
libraries: readonly SiblingLibrary[],
|
|
531
|
+
): Promise<string> {
|
|
281
532
|
const esbuild = await loadEsbuild();
|
|
282
533
|
if (!esbuild) {
|
|
283
534
|
// Explicit rather than a silent fallthrough: esbuild is an *optional*
|
|
@@ -291,14 +542,16 @@ async function build(entryFile: string, cacheDir: string): Promise<string> {
|
|
|
291
542
|
);
|
|
292
543
|
}
|
|
293
544
|
|
|
545
|
+
const externals = externalSpecifiers(libraries);
|
|
294
546
|
let built: import("esbuild").BuildResult<{ write: false; metafile: true }>;
|
|
295
547
|
try {
|
|
296
548
|
built = await esbuild.build({
|
|
297
549
|
...CONTROLLER_BUNDLE_OPTIONS,
|
|
298
550
|
// esbuild's options are mutable arrays; the shared constant is `as const`
|
|
299
551
|
// so it cannot be edited in place by one caller and read by another.
|
|
300
|
-
external:
|
|
552
|
+
external: externals,
|
|
301
553
|
conditions: [...CONTROLLER_BUNDLE_OPTIONS.conditions],
|
|
554
|
+
plugins: [rejectSubpathImports(libraries)],
|
|
302
555
|
entryPoints: [entryFile],
|
|
303
556
|
write: false,
|
|
304
557
|
metafile: true,
|
|
@@ -323,13 +576,14 @@ async function build(entryFile: string, cacheDir: string): Promise<string> {
|
|
|
323
576
|
// Absolute, so the signature is independent of the working directory the next
|
|
324
577
|
// kernel happens to run from.
|
|
325
578
|
const inputs = Object.keys(built.metafile.inputs).map((rel) => path.resolve(rel));
|
|
326
|
-
|
|
579
|
+
assertNoInlinedSiblings(entryFile, inputs, libraries);
|
|
580
|
+
const key = (await signInputs(inputs, externals)) ?? createHash("sha256")
|
|
327
581
|
.update(output.text)
|
|
328
582
|
.digest("hex")
|
|
329
583
|
.slice(0, 32);
|
|
330
584
|
const target = bundlePath(cacheDir, key);
|
|
331
585
|
|
|
332
|
-
await fs.mkdir(
|
|
586
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
333
587
|
const index = indexPath(cacheDir, entryFile);
|
|
334
588
|
const superseded = (await readIndex(index))?.key;
|
|
335
589
|
const tmp = `${target}.${process.pid}.${tmpCounter++}.tmp`;
|
|
@@ -347,14 +601,17 @@ async function build(entryFile: string, cacheDir: string): Promise<string> {
|
|
|
347
601
|
/**
|
|
348
602
|
* Drop the bundle this build replaced.
|
|
349
603
|
*
|
|
350
|
-
* Every save mints a new key, so without this a day of editing leaves one
|
|
351
|
-
* per save and the cache grows for the life of the checkout. Pruned
|
|
352
|
-
* new index is in place, so a concurrent reader is already being
|
|
353
|
-
* replacement; on Linux a process that opened the old file keeps
|
|
354
|
-
* through the open handle, and on Windows a failed unlink is swallowed
|
|
355
|
-
* file costs disk, never correctness.
|
|
604
|
+
* Every save mints a new key, so without this a day of editing leaves one bundle
|
|
605
|
+
* directory per save and the cache grows for the life of the checkout. Pruned
|
|
606
|
+
* *after* the new index is in place, so a concurrent reader is already being
|
|
607
|
+
* pointed at the replacement; on Linux a process that opened the old file keeps
|
|
608
|
+
* reading it through the open handle, and on Windows a failed unlink is swallowed
|
|
609
|
+
* — a stale file costs disk, never correctness.
|
|
610
|
+
*
|
|
611
|
+
* The whole directory goes, since it holds the bundle's generated `node_modules/`
|
|
612
|
+
* as well as the bundle.
|
|
356
613
|
*/
|
|
357
614
|
async function prune(cacheDir: string, superseded: string | undefined, current: string): Promise<void> {
|
|
358
615
|
if (!superseded || superseded === current) return;
|
|
359
|
-
await fs.rm(
|
|
616
|
+
await fs.rm(bundleDir(cacheDir, superseded), { force: true, recursive: true }).catch(() => {});
|
|
360
617
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type DefResolver,
|
|
16
16
|
} from "@telorun/analyzer";
|
|
17
17
|
import type { ModuleArtifact } from "../../bundle/module-artifact.js";
|
|
18
|
+
import type { SiblingLibraryMap } from "../../controller-loaders/sibling-libraries.js";
|
|
18
19
|
import { ControllerLoader } from "../../controller-loader.js";
|
|
19
20
|
import { formatAjvErrors, validateResourceDefinition } from "../../manifest-schemas.js";
|
|
20
21
|
import { createTemplateController } from "./resource-template-controller.js";
|
|
@@ -166,6 +167,10 @@ class ResourceDefinition implements ResourceInstance {
|
|
|
166
167
|
// ref and the verified layer index, so the loader picks a candidate and asks
|
|
167
168
|
// it for that selector's directory rather than fetching anything itself.
|
|
168
169
|
const artifact = host.getModuleArtifact?.(this.resource.metadata.source);
|
|
170
|
+
// The libraries of the module that declared this kind, for the same reason:
|
|
171
|
+
// a bundle's bare `@telorun/kv-store` means whatever THAT module's imports
|
|
172
|
+
// say it means, never the consumer's.
|
|
173
|
+
const libraries = host.getSiblingLibraries?.(this.resource.metadata.source);
|
|
169
174
|
ctx.registerDefinition(this.resource);
|
|
170
175
|
|
|
171
176
|
const moduleName = this.resource.metadata.module;
|
|
@@ -187,7 +192,7 @@ class ResourceDefinition implements ResourceInstance {
|
|
|
187
192
|
kindName,
|
|
188
193
|
async () => {
|
|
189
194
|
const resolved = await loader
|
|
190
|
-
.resolve(controllers, source, policy, artifact)
|
|
195
|
+
.resolve(controllers, source, policy, artifact, libraries)
|
|
191
196
|
.catch((err) => {
|
|
192
197
|
if (err instanceof RuntimeError) {
|
|
193
198
|
throw new RuntimeError(err.code, `kind '${moduleName}.${kindName}': ${err.message}`);
|
|
@@ -229,6 +234,7 @@ class ResourceDefinition implements ResourceInstance {
|
|
|
229
234
|
*/
|
|
230
235
|
interface KernelResourceContext {
|
|
231
236
|
getModuleArtifact?(source: string | undefined): ModuleArtifact | undefined;
|
|
237
|
+
getSiblingLibraries?(source: string | undefined): SiblingLibraryMap | undefined;
|
|
232
238
|
getCacheRoot?(): string | undefined;
|
|
233
239
|
registerLazyController(
|
|
234
240
|
moduleName: string,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { HostVersions } from "@telorun/analyzer";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The versions this Node host can speak for, for a module's `requires.host.*`
|
|
5
|
+
* ranges.
|
|
6
|
+
*
|
|
7
|
+
* **One supplier, so the sites cannot drift.** Every Node-side entry into
|
|
8
|
+
* `StaticAnalyzer.analyze` — the kernel's load path, its `check` seam and the
|
|
9
|
+
* CLI's `check` command — must report the same host, or the same manifest would
|
|
10
|
+
* be accepted by one and rejected by another. Reading `process.versions` at each
|
|
11
|
+
* call site is three chances to forget one, and forgetting is silent: the axis is
|
|
12
|
+
* simply skipped and the requirement quietly stops being enforced, which is the
|
|
13
|
+
* failure class declared requirements exist to remove.
|
|
14
|
+
*
|
|
15
|
+
* It lives in the kernel rather than the analyzer because the analyzer is
|
|
16
|
+
* browser-safe and has no `process`. That split is the point: the analyzer owns
|
|
17
|
+
* reading and ordering the axes, and only the comparison target is per-runtime.
|
|
18
|
+
*
|
|
19
|
+
* `process.versions.node` is absent on no Node or Bun release this runs on, but
|
|
20
|
+
* an absent entry is skipped rather than guessed, so a host that cannot name
|
|
21
|
+
* itself declines to enforce rather than inventing a verdict.
|
|
22
|
+
*/
|
|
23
|
+
export function nodeHostVersions(): HostVersions {
|
|
24
|
+
const node = process.versions?.node;
|
|
25
|
+
return typeof node === "string" && node ? { node } : {};
|
|
26
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ export {
|
|
|
39
39
|
buildControllerBundle,
|
|
40
40
|
canBuildFromSource,
|
|
41
41
|
lastBuildInputs,
|
|
42
|
+
type SiblingLibrary,
|
|
42
43
|
} from "./controller-loaders/source-bundle-builder.js";
|
|
43
44
|
export type {
|
|
44
45
|
PayloadLayer,
|
|
@@ -110,3 +111,4 @@ export type {
|
|
|
110
111
|
} from "./logging/index.js";
|
|
111
112
|
export { KernelLogging } from "./logging/kernel-logging.js";
|
|
112
113
|
export type { LoggingHost } from "./logging/logging-host.js";
|
|
114
|
+
export { nodeHostVersions } from "./host-versions.js";
|