@ontrails/trails 1.0.0-beta.2 → 1.0.0-beta.22
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/CHANGELOG.md +647 -0
- package/README.md +26 -0
- package/package.json +28 -7
- package/src/app.ts +86 -2
- package/src/clack.ts +22 -0
- package/src/cli.ts +330 -11
- package/src/completions.ts +240 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +153 -0
- package/src/mcp-app.ts +30 -0
- package/src/mcp-options.ts +77 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/release/bindings.ts +39 -0
- package/src/release/check.ts +818 -0
- package/src/release/config.ts +63 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/index.ts +85 -0
- package/src/release/native-bun-publish.ts +651 -0
- package/src/release/native-bun-registry.ts +350 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/smoke.ts +46 -0
- package/src/release/wayfinder-dogfood-smoke.ts +226 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-collision.ts +126 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-release-check.ts +74 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch.ts +432 -0
- package/src/scaffold-version-sync.ts +183 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +94 -40
- package/src/trails/add-trail.ts +79 -41
- package/src/trails/add-verify.ts +95 -25
- package/src/trails/compile.ts +67 -0
- package/src/trails/completions-complete.ts +165 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/create-adapter.ts +1084 -0
- package/src/trails/create-scaffold.ts +399 -104
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +185 -71
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +82 -0
- package/src/trails/dev-reset.ts +50 -0
- package/src/trails/dev-stats.ts +72 -0
- package/src/trails/dev-support.ts +340 -0
- package/src/trails/doctor.ts +56 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +74 -68
- package/src/trails/load-app.ts +1143 -15
- package/src/trails/project.ts +17 -3
- package/src/trails/release-check.ts +104 -0
- package/src/trails/release-smoke.ts +48 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +491 -0
- package/src/trails/run-examples.ts +145 -0
- package/src/trails/run.ts +410 -0
- package/src/trails/scaffold-json.ts +58 -0
- package/src/trails/survey.ts +881 -226
- package/src/trails/topo-activation.ts +385 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +248 -0
- package/src/trails/topo-pin.ts +52 -0
- package/src/trails/topo-read-support.ts +313 -0
- package/src/trails/topo-reports.ts +807 -0
- package/src/trails/topo-store-support.ts +174 -0
- package/src/trails/topo-support.ts +220 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +106 -0
- package/src/trails/validate.ts +38 -0
- package/src/trails/version-lifecycle-support.ts +945 -0
- package/src/trails/warden-guide.ts +129 -0
- package/src/trails/warden.ts +165 -58
- package/src/versions.ts +31 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/__tests__/examples.test.ts +0 -6
- package/dist/bin/trails.d.ts +0 -3
- package/dist/bin/trails.d.ts.map +0 -1
- package/dist/bin/trails.js +0 -4
- package/dist/bin/trails.js.map +0 -1
- package/dist/src/app.d.ts +0 -2
- package/dist/src/app.d.ts.map +0 -1
- package/dist/src/app.js +0 -11
- package/dist/src/app.js.map +0 -1
- package/dist/src/clack.d.ts +0 -9
- package/dist/src/clack.d.ts.map +0 -1
- package/dist/src/clack.js +0 -62
- package/dist/src/clack.js.map +0 -1
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js +0 -13
- package/dist/src/cli.js.map +0 -1
- package/dist/src/trails/add-surface.d.ts +0 -13
- package/dist/src/trails/add-surface.d.ts.map +0 -1
- package/dist/src/trails/add-surface.js +0 -88
- package/dist/src/trails/add-surface.js.map +0 -1
- package/dist/src/trails/add-trail.d.ts +0 -11
- package/dist/src/trails/add-trail.d.ts.map +0 -1
- package/dist/src/trails/add-trail.js +0 -85
- package/dist/src/trails/add-trail.js.map +0 -1
- package/dist/src/trails/add-verify.d.ts +0 -10
- package/dist/src/trails/add-verify.d.ts.map +0 -1
- package/dist/src/trails/add-verify.js +0 -67
- package/dist/src/trails/add-verify.js.map +0 -1
- package/dist/src/trails/create-scaffold.d.ts +0 -15
- package/dist/src/trails/create-scaffold.d.ts.map +0 -1
- package/dist/src/trails/create-scaffold.js +0 -288
- package/dist/src/trails/create-scaffold.js.map +0 -1
- package/dist/src/trails/create.d.ts +0 -22
- package/dist/src/trails/create.d.ts.map +0 -1
- package/dist/src/trails/create.js +0 -121
- package/dist/src/trails/create.js.map +0 -1
- package/dist/src/trails/guide.d.ts +0 -11
- package/dist/src/trails/guide.d.ts.map +0 -1
- package/dist/src/trails/guide.js +0 -80
- package/dist/src/trails/guide.js.map +0 -1
- package/dist/src/trails/load-app.d.ts +0 -4
- package/dist/src/trails/load-app.d.ts.map +0 -1
- package/dist/src/trails/load-app.js +0 -24
- package/dist/src/trails/load-app.js.map +0 -1
- package/dist/src/trails/project.d.ts +0 -8
- package/dist/src/trails/project.d.ts.map +0 -1
- package/dist/src/trails/project.js +0 -43
- package/dist/src/trails/project.js.map +0 -1
- package/dist/src/trails/survey.d.ts +0 -33
- package/dist/src/trails/survey.d.ts.map +0 -1
- package/dist/src/trails/survey.js +0 -225
- package/dist/src/trails/survey.js.map +0 -1
- package/dist/src/trails/warden.d.ts +0 -19
- package/dist/src/trails/warden.d.ts.map +0 -1
- package/dist/src/trails/warden.js +0 -88
- package/dist/src/trails/warden.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/__tests__/create.test.ts +0 -349
- package/src/__tests__/guide.test.ts +0 -91
- package/src/__tests__/load-app.test.ts +0 -15
- package/src/__tests__/survey.test.ts +0 -161
- package/src/__tests__/warden.test.ts +0 -74
- package/tsconfig.json +0 -9
package/src/trails/load-app.ts
CHANGED
|
@@ -1,37 +1,1165 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
statSync,
|
|
7
|
+
symlinkSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import {
|
|
10
|
+
dirname,
|
|
11
|
+
extname,
|
|
12
|
+
isAbsolute,
|
|
13
|
+
join,
|
|
14
|
+
relative,
|
|
15
|
+
resolve,
|
|
16
|
+
} from 'node:path';
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
3
18
|
|
|
19
|
+
import {
|
|
20
|
+
deriveSafePath,
|
|
21
|
+
isTrailsError,
|
|
22
|
+
PermissionError,
|
|
23
|
+
Result,
|
|
24
|
+
ValidationError,
|
|
25
|
+
} from '@ontrails/core';
|
|
4
26
|
import type { Topo } from '@ontrails/core';
|
|
27
|
+
import { findAppModule } from '@ontrails/cli';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
createLoadAppMirrorRootPath,
|
|
31
|
+
ensureLoadAppMirrorDirectory,
|
|
32
|
+
LOAD_APP_MIRROR_ENTRY_PREFIX,
|
|
33
|
+
LOAD_APP_MIRROR_PARENT_DIRNAME,
|
|
34
|
+
removeLoadAppMirrorRootQuietly,
|
|
35
|
+
resolveLoadAppMirrorFilePath,
|
|
36
|
+
writeLoadAppMirrorFile,
|
|
37
|
+
} from '../load-app-mirror.js';
|
|
5
38
|
|
|
6
39
|
const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
|
|
7
40
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
41
|
+
type TranspilerLoader = 'ts' | 'tsx' | 'js' | 'jsx';
|
|
42
|
+
|
|
43
|
+
/** Extension → Bun.Transpiler loader, so JSX-bearing files parse correctly. */
|
|
44
|
+
const LOADER_BY_EXTENSION: Record<string, TranspilerLoader> = {
|
|
45
|
+
'.cjs': 'js',
|
|
46
|
+
'.cts': 'ts',
|
|
47
|
+
'.js': 'js',
|
|
48
|
+
'.jsx': 'jsx',
|
|
49
|
+
'.mjs': 'js',
|
|
50
|
+
'.mts': 'ts',
|
|
51
|
+
'.ts': 'ts',
|
|
52
|
+
'.tsx': 'tsx',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const SCANNABLE_EXTENSIONS = new Set(Object.keys(LOADER_BY_EXTENSION));
|
|
56
|
+
|
|
57
|
+
const TRANSPILER_CACHE = new Map<TranspilerLoader, Bun.Transpiler>();
|
|
58
|
+
|
|
59
|
+
const getImportScanner = (loader: TranspilerLoader): Bun.Transpiler => {
|
|
60
|
+
const cached = TRANSPILER_CACHE.get(loader);
|
|
61
|
+
if (cached !== undefined) {
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
const scanner = new Bun.Transpiler({ loader });
|
|
65
|
+
TRANSPILER_CACHE.set(loader, scanner);
|
|
66
|
+
return scanner;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mirror roots kept alive for the lifetime of the process.
|
|
71
|
+
*
|
|
72
|
+
* @remarks
|
|
73
|
+
* A fresh-loaded module may expose functions whose deferred relative imports
|
|
74
|
+
* are resolved only when those functions run (for example inside a trail's
|
|
75
|
+
* `blaze`). If we deleted the mirror tree immediately after the initial
|
|
76
|
+
* `import()` resolved, those later resolutions would hit an ENOENT. We keep
|
|
77
|
+
* the mirrors on disk and clean them up once, on process exit.
|
|
78
|
+
*/
|
|
79
|
+
const ACTIVE_MIRROR_ROOTS = new Set<string>();
|
|
80
|
+
const RETAINED_MIRROR_ROOTS = new Set<string>();
|
|
81
|
+
|
|
82
|
+
const cleanupAllMirrorRoots = (): void => {
|
|
83
|
+
for (const root of [...ACTIVE_MIRROR_ROOTS, ...RETAINED_MIRROR_ROOTS]) {
|
|
84
|
+
removeLoadAppMirrorRootQuietly(root);
|
|
85
|
+
}
|
|
86
|
+
ACTIVE_MIRROR_ROOTS.clear();
|
|
87
|
+
RETAINED_MIRROR_ROOTS.clear();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const mirrorCleanup = (() => {
|
|
91
|
+
let registered = false;
|
|
92
|
+
return {
|
|
93
|
+
ensureRegistered(): void {
|
|
94
|
+
if (registered) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
registered = true;
|
|
98
|
+
process.once('exit', cleanupAllMirrorRoots);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
const ensureMirrorCleanupHook = (): void => {
|
|
104
|
+
mirrorCleanup.ensureRegistered();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Retain a fresh-import mirror for the lifetime of the process. A previously
|
|
109
|
+
* returned `loadApp` result may hold deferred relative `import()` calls whose
|
|
110
|
+
* resolution requires the mirror directory to still exist, so we cannot prune
|
|
111
|
+
* these by age without risking ENOENT in long-lived sessions (dev server,
|
|
112
|
+
* survey polling, concurrent fresh loads). Cleanup happens once on process
|
|
113
|
+
* exit via `cleanupAllMirrorRoots`.
|
|
114
|
+
*/
|
|
115
|
+
const retainMirrorRoot = (mirrorRoot: string): void => {
|
|
116
|
+
if (RETAINED_MIRROR_ROOTS.has(mirrorRoot)) {
|
|
117
|
+
return;
|
|
12
118
|
}
|
|
119
|
+
RETAINED_MIRROR_ROOTS.add(mirrorRoot);
|
|
120
|
+
ensureMirrorCleanupHook();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const acquireMirrorLease = (mirrorRoot: string): (() => void) => {
|
|
124
|
+
ACTIVE_MIRROR_ROOTS.add(mirrorRoot);
|
|
125
|
+
ensureMirrorCleanupHook();
|
|
126
|
+
|
|
127
|
+
let released = false;
|
|
128
|
+
|
|
129
|
+
return () => {
|
|
130
|
+
if (released) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
released = true;
|
|
135
|
+
ACTIVE_MIRROR_ROOTS.delete(mirrorRoot);
|
|
136
|
+
removeLoadAppMirrorRootQuietly(mirrorRoot);
|
|
137
|
+
};
|
|
138
|
+
};
|
|
13
139
|
|
|
140
|
+
const resolveUrlModulePath = (modulePath: string): string => {
|
|
141
|
+
const url = new URL(modulePath);
|
|
142
|
+
return url.protocol === 'file:' ? fileURLToPath(url) : modulePath;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const resolveFilesystemModulePath = (
|
|
146
|
+
modulePath: string,
|
|
147
|
+
cwd: string
|
|
148
|
+
): string => {
|
|
14
149
|
const absolutePath = isAbsolute(modulePath)
|
|
15
150
|
? modulePath
|
|
16
151
|
: resolve(cwd, modulePath);
|
|
17
|
-
|
|
152
|
+
if (!absolutePath.endsWith('.js') || existsSync(absolutePath)) {
|
|
153
|
+
return absolutePath;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const tsPath = absolutePath.replace(/\.js$/, '.ts');
|
|
157
|
+
return existsSync(tsPath) ? tsPath : absolutePath;
|
|
18
158
|
};
|
|
19
159
|
|
|
20
|
-
|
|
21
|
-
|
|
160
|
+
const trustBoundaryError = (reason: string): PermissionError =>
|
|
161
|
+
new PermissionError(
|
|
162
|
+
`Refusing to load an app module outside the workspace trust boundary (${reason}). Use a workspace-relative module path, or pass trustedModulePath: true from trusted code.`
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const asError = (error: unknown): Error =>
|
|
166
|
+
error instanceof Error ? error : new Error(String(error));
|
|
167
|
+
|
|
168
|
+
const toLoadAppError = (error: unknown): Error => {
|
|
169
|
+
if (isTrailsError(error)) {
|
|
170
|
+
return error;
|
|
171
|
+
}
|
|
172
|
+
const cause = asError(error);
|
|
173
|
+
return new ValidationError(`Failed to load app module: ${cause.message}`, {
|
|
174
|
+
cause,
|
|
175
|
+
context: { detail: cause.message },
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const isPathInside = (root: string, target: string): boolean => {
|
|
180
|
+
const candidate = relative(root, target);
|
|
181
|
+
return (
|
|
182
|
+
candidate === '' || (!candidate.startsWith('..') && !isAbsolute(candidate))
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const realpathIfPresent = (path: string): string | undefined => {
|
|
187
|
+
try {
|
|
188
|
+
return realpathSync(path);
|
|
189
|
+
} catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const ensureRealPathInsideCwd = (
|
|
195
|
+
resolvedModulePath: string,
|
|
196
|
+
cwd: string
|
|
197
|
+
): string => {
|
|
198
|
+
const realRoot = realpathIfPresent(cwd);
|
|
199
|
+
const realModule = realpathIfPresent(resolvedModulePath);
|
|
200
|
+
if (
|
|
201
|
+
realRoot !== undefined &&
|
|
202
|
+
realModule !== undefined &&
|
|
203
|
+
!isPathInside(realRoot, realModule)
|
|
204
|
+
) {
|
|
205
|
+
throw trustBoundaryError('symlink_escape');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return resolvedModulePath;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** Resolve a caller-trusted module path using the legacy escape hatch policy. */
|
|
212
|
+
const resolveTrustedModulePath = (modulePath: string, cwd: string): string =>
|
|
213
|
+
URL_SCHEME.test(modulePath)
|
|
214
|
+
? resolveUrlModulePath(modulePath)
|
|
215
|
+
: resolveFilesystemModulePath(modulePath, cwd);
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolve the default app module path inside cwd.
|
|
219
|
+
*
|
|
220
|
+
* @remarks
|
|
221
|
+
* CLI and trail callers accept user-supplied module specifiers, so the default
|
|
222
|
+
* policy is deliberately narrower than `import()` itself: no URL schemes, no
|
|
223
|
+
* absolute paths, and no parent traversal. Internal callers that intentionally
|
|
224
|
+
* load a path outside cwd must opt into `trustedModulePath`.
|
|
225
|
+
*/
|
|
226
|
+
const resolveContainedModulePath = (
|
|
22
227
|
modulePath: string,
|
|
23
228
|
cwd: string
|
|
24
|
-
):
|
|
25
|
-
|
|
229
|
+
): string => {
|
|
230
|
+
if (URL_SCHEME.test(modulePath)) {
|
|
231
|
+
throw trustBoundaryError('url_scheme');
|
|
232
|
+
}
|
|
233
|
+
if (isAbsolute(modulePath)) {
|
|
234
|
+
throw trustBoundaryError('absolute_path');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const safePath = deriveSafePath(cwd, modulePath);
|
|
238
|
+
if (safePath.isErr()) {
|
|
239
|
+
throw trustBoundaryError('parent_escape');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return ensureRealPathInsideCwd(
|
|
243
|
+
resolveFilesystemModulePath(safePath.value, cwd),
|
|
244
|
+
cwd
|
|
245
|
+
);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
interface LoadAppTrustOptions {
|
|
249
|
+
readonly trustedModulePath?: boolean | undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const resolveLoadAppModulePath = (
|
|
253
|
+
modulePath: string,
|
|
254
|
+
cwd: string,
|
|
255
|
+
options: LoadAppTrustOptions = {}
|
|
256
|
+
): string =>
|
|
257
|
+
options.trustedModulePath === true
|
|
258
|
+
? resolveTrustedModulePath(modulePath, cwd)
|
|
259
|
+
: resolveContainedModulePath(modulePath, cwd);
|
|
260
|
+
|
|
261
|
+
const findWorkspaceRelativeAppModule = (cwd: string): string => {
|
|
262
|
+
const discovered = findAppModule(cwd);
|
|
263
|
+
return isAbsolute(discovered) ? relative(cwd, discovered) : discovered;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const isLocalFilesystemImport = (importPath: string): boolean =>
|
|
267
|
+
importPath.startsWith('.') ||
|
|
268
|
+
importPath.startsWith('/') ||
|
|
269
|
+
importPath.startsWith('file:');
|
|
270
|
+
|
|
271
|
+
interface PackageJson {
|
|
272
|
+
readonly exports?: unknown;
|
|
273
|
+
readonly imports?: unknown;
|
|
274
|
+
readonly main?: unknown;
|
|
275
|
+
readonly module?: unknown;
|
|
276
|
+
readonly name?: unknown;
|
|
277
|
+
readonly type?: unknown;
|
|
278
|
+
readonly workspaces?: unknown;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const readPackageJson = (packagePath: string): PackageJson | undefined => {
|
|
282
|
+
try {
|
|
283
|
+
return JSON.parse(readFileSync(packagePath, 'utf8')) as PackageJson;
|
|
284
|
+
} catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const readPackageName = (packagePath: string): string | undefined => {
|
|
290
|
+
const parsed = readPackageJson(packagePath);
|
|
291
|
+
return typeof parsed?.name === 'string' && parsed.name.length > 0
|
|
292
|
+
? parsed.name
|
|
293
|
+
: undefined;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const findNearestPackageName = (directoryPath: string): string | undefined => {
|
|
297
|
+
let current = directoryPath;
|
|
298
|
+
while (true) {
|
|
299
|
+
const name = readPackageName(join(current, 'package.json'));
|
|
300
|
+
if (name !== undefined) {
|
|
301
|
+
return name;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const parent = dirname(current);
|
|
305
|
+
if (parent === current) {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
current = parent;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const isPackageLocalImport = (
|
|
313
|
+
importerPath: string,
|
|
314
|
+
importPath: string
|
|
315
|
+
): boolean => {
|
|
316
|
+
if (importPath.startsWith('#')) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const packageName = findNearestPackageName(dirname(importerPath));
|
|
321
|
+
return (
|
|
322
|
+
packageName !== undefined &&
|
|
323
|
+
(importPath === packageName || importPath.startsWith(`${packageName}/`))
|
|
324
|
+
);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const isScannableModule = (modulePath: string): boolean =>
|
|
328
|
+
SCANNABLE_EXTENSIONS.has(extname(modulePath));
|
|
329
|
+
|
|
330
|
+
const resolveImportedModulePath = (
|
|
331
|
+
importerPath: string,
|
|
332
|
+
importPath: string
|
|
333
|
+
): string => {
|
|
334
|
+
const resolved = import.meta.resolve(
|
|
335
|
+
importPath,
|
|
336
|
+
pathToFileURL(importerPath).href
|
|
337
|
+
);
|
|
338
|
+
return resolveFilesystemModulePath(
|
|
339
|
+
fileURLToPath(resolved),
|
|
340
|
+
dirname(importerPath)
|
|
341
|
+
);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const readDirectoryEntries = (directoryPath: string): readonly string[] => {
|
|
345
|
+
try {
|
|
346
|
+
return readdirSync(directoryPath);
|
|
347
|
+
} catch {
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const safeStat = (
|
|
353
|
+
entryPath: string
|
|
354
|
+
): ReturnType<typeof statSync> | undefined => {
|
|
355
|
+
try {
|
|
356
|
+
return statSync(entryPath);
|
|
357
|
+
} catch {
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const readWorkspacePatterns = (cwd: string): readonly string[] => {
|
|
363
|
+
const rootPackage = readPackageJson(join(cwd, 'package.json'));
|
|
364
|
+
const workspaces = rootPackage?.workspaces;
|
|
365
|
+
if (Array.isArray(workspaces)) {
|
|
366
|
+
return workspaces.filter(
|
|
367
|
+
(pattern): pattern is string => typeof pattern === 'string'
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (
|
|
371
|
+
typeof workspaces === 'object' &&
|
|
372
|
+
workspaces !== null &&
|
|
373
|
+
'packages' in workspaces &&
|
|
374
|
+
Array.isArray(workspaces.packages)
|
|
375
|
+
) {
|
|
376
|
+
return workspaces.packages.filter(
|
|
377
|
+
(pattern): pattern is string => typeof pattern === 'string'
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return [];
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
interface WorkspacePackage {
|
|
384
|
+
readonly name: string;
|
|
385
|
+
readonly packageJson: PackageJson;
|
|
386
|
+
readonly packageRoot: string;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const listWorkspacePackageRoots = (cwd: string): readonly string[] => {
|
|
390
|
+
const roots: string[] = [];
|
|
391
|
+
for (const pattern of readWorkspacePatterns(cwd)) {
|
|
392
|
+
if (pattern.endsWith('/*')) {
|
|
393
|
+
const baseDir = resolve(cwd, pattern.slice(0, -2));
|
|
394
|
+
for (const entry of readDirectoryEntries(baseDir)) {
|
|
395
|
+
const entryPath = join(baseDir, entry);
|
|
396
|
+
if (safeStat(entryPath)?.isDirectory()) {
|
|
397
|
+
roots.push(entryPath);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!pattern.includes('*')) {
|
|
404
|
+
roots.push(resolve(cwd, pattern));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return roots;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const readWorkspacePackages = (cwd: string): readonly WorkspacePackage[] => {
|
|
411
|
+
const packages: WorkspacePackage[] = [];
|
|
412
|
+
for (const packageRoot of listWorkspacePackageRoots(cwd)) {
|
|
413
|
+
const packageJson = readPackageJson(join(packageRoot, 'package.json'));
|
|
414
|
+
if (
|
|
415
|
+
packageJson !== undefined &&
|
|
416
|
+
typeof packageJson.name === 'string' &&
|
|
417
|
+
packageJson.name.length > 0
|
|
418
|
+
) {
|
|
419
|
+
packages.push({
|
|
420
|
+
name: packageJson.name,
|
|
421
|
+
packageJson,
|
|
422
|
+
packageRoot,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return packages;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const parsePackageSpecifier = (
|
|
430
|
+
importPath: string
|
|
431
|
+
): { packageName: string; subpath: string } | null => {
|
|
432
|
+
if (URL_SCHEME.test(importPath)) {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
if (importPath.startsWith('.') || importPath.startsWith('#')) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
const segments = importPath.split('/');
|
|
439
|
+
const [firstSegment, secondSegment] = segments;
|
|
440
|
+
if (firstSegment?.startsWith('@')) {
|
|
441
|
+
const scope = firstSegment;
|
|
442
|
+
const name = secondSegment;
|
|
443
|
+
if (name === undefined) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
const packageName = `${scope}/${name}`;
|
|
447
|
+
const rest = segments.slice(2).join('/');
|
|
448
|
+
return { packageName, subpath: rest.length > 0 ? `./${rest}` : '.' };
|
|
449
|
+
}
|
|
450
|
+
const [name] = segments;
|
|
451
|
+
if (name === undefined || name.length === 0) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
const rest = segments.slice(1).join('/');
|
|
455
|
+
return { packageName: name, subpath: rest.length > 0 ? `./${rest}` : '.' };
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const resolveConditionalExportTarget = (
|
|
459
|
+
target: unknown
|
|
460
|
+
): string | undefined => {
|
|
461
|
+
if (typeof target === 'string') {
|
|
462
|
+
return target;
|
|
463
|
+
}
|
|
464
|
+
if (typeof target !== 'object' || target === null || Array.isArray(target)) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
const record = target as Record<string, unknown>;
|
|
468
|
+
return (
|
|
469
|
+
resolveConditionalExportTarget(record['import']) ??
|
|
470
|
+
resolveConditionalExportTarget(record['default']) ??
|
|
471
|
+
resolveConditionalExportTarget(record['bun']) ??
|
|
472
|
+
resolveConditionalExportTarget(record['node'])
|
|
473
|
+
);
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const resolveExportTarget = (
|
|
477
|
+
packageJson: PackageJson,
|
|
478
|
+
subpath: string
|
|
479
|
+
): string | undefined => {
|
|
480
|
+
const { exports } = packageJson;
|
|
481
|
+
if (exports === undefined) {
|
|
482
|
+
if (subpath === '.') {
|
|
483
|
+
if (typeof packageJson.module === 'string') {
|
|
484
|
+
return packageJson.module;
|
|
485
|
+
}
|
|
486
|
+
if (typeof packageJson.main === 'string') {
|
|
487
|
+
return packageJson.main;
|
|
488
|
+
}
|
|
489
|
+
return './src/index.ts';
|
|
490
|
+
}
|
|
491
|
+
return subpath;
|
|
492
|
+
}
|
|
493
|
+
if (typeof exports === 'string' || Array.isArray(exports)) {
|
|
494
|
+
return subpath === '.'
|
|
495
|
+
? resolveConditionalExportTarget(exports)
|
|
496
|
+
: undefined;
|
|
497
|
+
}
|
|
498
|
+
if (typeof exports !== 'object' || exports === null) {
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
return resolveConditionalExportTarget(
|
|
502
|
+
(exports as Record<string, unknown>)[subpath]
|
|
503
|
+
);
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
interface WorkspacePackageResolution {
|
|
507
|
+
readonly modulePath: string;
|
|
508
|
+
readonly packageName: string;
|
|
509
|
+
readonly packageRoot: string;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const resolveWorkspacePackageImport = (
|
|
513
|
+
importPath: string,
|
|
514
|
+
cwd: string
|
|
515
|
+
): WorkspacePackageResolution | null => {
|
|
516
|
+
const parsed = parsePackageSpecifier(importPath);
|
|
517
|
+
if (parsed === null) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
const workspacePackage = readWorkspacePackages(cwd).find(
|
|
521
|
+
(candidate) => candidate.name === parsed.packageName
|
|
522
|
+
);
|
|
523
|
+
if (workspacePackage === undefined) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
const target = resolveExportTarget(
|
|
527
|
+
workspacePackage.packageJson,
|
|
528
|
+
parsed.subpath
|
|
529
|
+
);
|
|
530
|
+
if (target === undefined || !target.startsWith('.')) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
const targetPath = deriveSafePath(workspacePackage.packageRoot, target);
|
|
534
|
+
if (targetPath.isErr()) {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
modulePath: resolveFilesystemModulePath(
|
|
539
|
+
ensureRealPathInsideCwd(targetPath.value, cwd),
|
|
540
|
+
workspacePackage.packageRoot
|
|
541
|
+
),
|
|
542
|
+
packageName: workspacePackage.name,
|
|
543
|
+
packageRoot: workspacePackage.packageRoot,
|
|
544
|
+
};
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const findPackageRootForName = (
|
|
548
|
+
directoryPath: string,
|
|
549
|
+
packageName: string
|
|
550
|
+
): string | null => {
|
|
551
|
+
let current = directoryPath;
|
|
552
|
+
while (true) {
|
|
553
|
+
const packagePath = join(current, 'package.json');
|
|
554
|
+
if (readPackageName(packagePath) === packageName) {
|
|
555
|
+
return current;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const parent = dirname(current);
|
|
559
|
+
if (parent === current) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
current = parent;
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
interface ExternalPackageResolution {
|
|
567
|
+
readonly packageName: string;
|
|
568
|
+
readonly packageRoot: string;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const resolveExternalPackageImport = (
|
|
572
|
+
importerPath: string,
|
|
573
|
+
importPath: string
|
|
574
|
+
): ExternalPackageResolution | null => {
|
|
575
|
+
const parsed = parsePackageSpecifier(importPath);
|
|
576
|
+
if (parsed === null) {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
let resolved: string;
|
|
580
|
+
try {
|
|
581
|
+
resolved = import.meta.resolve(
|
|
582
|
+
importPath,
|
|
583
|
+
pathToFileURL(importerPath).href
|
|
584
|
+
);
|
|
585
|
+
} catch {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
if (!resolved.startsWith('file:')) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
const packageRoot = findPackageRootForName(
|
|
592
|
+
dirname(fileURLToPath(resolved)),
|
|
593
|
+
parsed.packageName
|
|
594
|
+
);
|
|
595
|
+
return packageRoot === null
|
|
596
|
+
? null
|
|
597
|
+
: { packageName: parsed.packageName, packageRoot };
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
type MirrorImportResolution =
|
|
601
|
+
| {
|
|
602
|
+
readonly kind: 'module';
|
|
603
|
+
readonly modulePath: string;
|
|
604
|
+
}
|
|
605
|
+
| {
|
|
606
|
+
readonly kind: 'external-package';
|
|
607
|
+
readonly packageName: string;
|
|
608
|
+
readonly packageRoot: string;
|
|
609
|
+
}
|
|
610
|
+
| {
|
|
611
|
+
readonly kind: 'workspace-package';
|
|
612
|
+
readonly modulePath: string;
|
|
613
|
+
readonly packageName: string;
|
|
614
|
+
readonly packageRoot: string;
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const resolveMirrorImport = (
|
|
618
|
+
importerPath: string,
|
|
619
|
+
importPath: string,
|
|
620
|
+
cwd: string
|
|
621
|
+
): MirrorImportResolution | null => {
|
|
622
|
+
if (
|
|
623
|
+
isLocalFilesystemImport(importPath) ||
|
|
624
|
+
isPackageLocalImport(importerPath, importPath)
|
|
625
|
+
) {
|
|
626
|
+
return {
|
|
627
|
+
kind: 'module',
|
|
628
|
+
modulePath: resolveImportedModulePath(importerPath, importPath),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const workspacePackage = resolveWorkspacePackageImport(importPath, cwd);
|
|
633
|
+
if (workspacePackage !== null) {
|
|
634
|
+
return { kind: 'workspace-package', ...workspacePackage };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const externalPackage = resolveExternalPackageImport(
|
|
638
|
+
importerPath,
|
|
639
|
+
importPath
|
|
640
|
+
);
|
|
641
|
+
return externalPackage === null
|
|
642
|
+
? null
|
|
643
|
+
: { kind: 'external-package', ...externalPackage };
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const collectImportedModuleResolutions = (
|
|
647
|
+
modulePath: string,
|
|
648
|
+
source: string,
|
|
649
|
+
cwd: string
|
|
650
|
+
): readonly MirrorImportResolution[] => {
|
|
651
|
+
const extension = extname(modulePath);
|
|
652
|
+
const loader = LOADER_BY_EXTENSION[extension];
|
|
653
|
+
if (loader === undefined) {
|
|
654
|
+
return [];
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return getImportScanner(loader)
|
|
658
|
+
.scanImports(source)
|
|
659
|
+
.map((entry) => entry.path)
|
|
660
|
+
.map((importPath) => resolveMirrorImport(modulePath, importPath, cwd))
|
|
661
|
+
.filter(
|
|
662
|
+
(resolution): resolution is MirrorImportResolution => resolution !== null
|
|
663
|
+
);
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const copyFileToMirror = async (
|
|
667
|
+
sourcePath: string,
|
|
668
|
+
mirrorRoot: string,
|
|
669
|
+
copied: Set<string>
|
|
670
|
+
): Promise<void> => {
|
|
671
|
+
if (copied.has(sourcePath)) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
copied.add(sourcePath);
|
|
675
|
+
|
|
676
|
+
const written = await writeLoadAppMirrorFile(sourcePath, mirrorRoot);
|
|
677
|
+
if (written.isErr()) {
|
|
678
|
+
throw written.error;
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Directory basenames that are never worth mirroring.
|
|
684
|
+
*
|
|
685
|
+
* @remarks
|
|
686
|
+
* These directories are excluded because they can be large and are never
|
|
687
|
+
* sources of resolvable imports — they hold VCS metadata, package installs,
|
|
688
|
+
* prior mirror artifacts, or build/tooling output that module resolution
|
|
689
|
+
* should not touch.
|
|
690
|
+
*/
|
|
691
|
+
const MIRROR_SKIP_DIRECTORIES = new Set([
|
|
692
|
+
'.cache',
|
|
693
|
+
'.git',
|
|
694
|
+
'.next',
|
|
695
|
+
'.nuxt',
|
|
696
|
+
'.output',
|
|
697
|
+
'.svelte-kit',
|
|
698
|
+
'.trails-tmp',
|
|
699
|
+
'.turbo',
|
|
700
|
+
'build',
|
|
701
|
+
'coverage',
|
|
702
|
+
'dist',
|
|
703
|
+
'node_modules',
|
|
704
|
+
]);
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Recursively copy every regular file inside `directoryPath` into the
|
|
708
|
+
* mirror, skipping well-known heavy directories.
|
|
709
|
+
*
|
|
710
|
+
* @remarks
|
|
711
|
+
* `Bun.Transpiler#scanImports` only surfaces statically analyzable import
|
|
712
|
+
* specifiers. Computed dynamic imports such as `import(\`./${name}.ts\`)`
|
|
713
|
+
* never appear, so their targets would otherwise be missing from the
|
|
714
|
+
* mirror. Shadowing each directory touched by the static walk with its
|
|
715
|
+
* full subtree keeps those sibling modules resolvable under the mirror
|
|
716
|
+
* root at runtime without pulling in package installs or nested mirror
|
|
717
|
+
* artifacts.
|
|
718
|
+
*/
|
|
719
|
+
/**
|
|
720
|
+
* Age threshold (ms) above which a mirror entry in `.trails-tmp/` is
|
|
721
|
+
* considered stale and safe to remove opportunistically.
|
|
722
|
+
*
|
|
723
|
+
* @remarks
|
|
724
|
+
* Fresh loads complete in seconds. Anything older than 10 minutes is almost
|
|
725
|
+
* certainly left over from a crashed or signal-killed process. We intentionally
|
|
726
|
+
* avoid registering SIGTERM/SIGINT handlers here because that would risk
|
|
727
|
+
* clobbering host-app signal handlers (and still wouldn't rescue SIGKILL).
|
|
728
|
+
* Opportunistic cleanup is self-healing across crashes from any cause.
|
|
729
|
+
*/
|
|
730
|
+
const STALE_MIRROR_THRESHOLD_MS = 10 * 60 * 1000;
|
|
731
|
+
|
|
732
|
+
const isStaleMirrorEntry = (entryPath: string, now: number): boolean => {
|
|
733
|
+
if (
|
|
734
|
+
ACTIVE_MIRROR_ROOTS.has(entryPath) ||
|
|
735
|
+
RETAINED_MIRROR_ROOTS.has(entryPath)
|
|
736
|
+
) {
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
const entryStat = safeStat(entryPath);
|
|
740
|
+
if (entryStat === undefined) {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
const mtimeMs = Number(entryStat.mtimeMs);
|
|
744
|
+
return now - mtimeMs >= STALE_MIRROR_THRESHOLD_MS;
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const removeStaleMirrorEntry = (entryPath: string): void => {
|
|
748
|
+
/*
|
|
749
|
+
* Another concurrent load may own it. Safe to ignore — the next sweep
|
|
750
|
+
* will retry.
|
|
751
|
+
*/
|
|
752
|
+
removeLoadAppMirrorRootQuietly(entryPath);
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Best-effort removal of stale mirror directories left by previous (crashed or
|
|
757
|
+
* signal-killed) processes. Called before creating a new mirror root.
|
|
758
|
+
*/
|
|
759
|
+
const cleanupStaleMirrorRoots = (mirrorParent: string): void => {
|
|
760
|
+
const entries = readDirectoryEntries(mirrorParent);
|
|
761
|
+
if (entries.length === 0) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const now = Date.now();
|
|
765
|
+
for (const entry of entries) {
|
|
766
|
+
if (!entry.startsWith(LOAD_APP_MIRROR_ENTRY_PREFIX)) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const entryPath = join(mirrorParent, entry);
|
|
770
|
+
if (isStaleMirrorEntry(entryPath, now)) {
|
|
771
|
+
removeStaleMirrorEntry(entryPath);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const freshMirrorRootPath = (cwd: string): string => {
|
|
777
|
+
const mirrorParent = join(cwd, LOAD_APP_MIRROR_PARENT_DIRNAME);
|
|
778
|
+
cleanupStaleMirrorRoots(mirrorParent);
|
|
779
|
+
return createLoadAppMirrorRootPath(cwd);
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
interface MirrorWalkContext {
|
|
783
|
+
readonly cwd: string;
|
|
784
|
+
readonly mirrorRoot: string;
|
|
785
|
+
readonly copied: Set<string>;
|
|
786
|
+
readonly visitedDirectories: Set<string>;
|
|
787
|
+
readonly linkedPackageNames: Set<string>;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
type DirectoryEntryKind = 'directory' | 'file' | 'skip';
|
|
791
|
+
|
|
792
|
+
const classifyDirectoryEntry = (
|
|
793
|
+
entry: string,
|
|
794
|
+
entryPath: string
|
|
795
|
+
): DirectoryEntryKind => {
|
|
796
|
+
const entryStat = safeStat(entryPath);
|
|
797
|
+
if (entryStat === undefined) {
|
|
798
|
+
return 'skip';
|
|
799
|
+
}
|
|
800
|
+
if (entryStat.isDirectory()) {
|
|
801
|
+
return MIRROR_SKIP_DIRECTORIES.has(entry) ? 'skip' : 'directory';
|
|
802
|
+
}
|
|
803
|
+
return entryStat.isFile() ? 'file' : 'skip';
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
const copyDirectoryTreeToMirror = async (
|
|
807
|
+
directoryPath: string,
|
|
808
|
+
context: MirrorWalkContext
|
|
809
|
+
): Promise<void> => {
|
|
810
|
+
if (context.visitedDirectories.has(directoryPath)) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
context.visitedDirectories.add(directoryPath);
|
|
814
|
+
|
|
815
|
+
for (const entry of readDirectoryEntries(directoryPath)) {
|
|
816
|
+
const entryPath = join(directoryPath, entry);
|
|
817
|
+
const kind = classifyDirectoryEntry(entry, entryPath);
|
|
818
|
+
if (kind === 'directory') {
|
|
819
|
+
await copyDirectoryTreeToMirror(entryPath, context);
|
|
820
|
+
} else if (kind === 'file') {
|
|
821
|
+
await copyFileToMirror(entryPath, context.mirrorRoot, context.copied);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
const copyNearestPackageJsonToMirror = async (
|
|
827
|
+
directoryPath: string,
|
|
828
|
+
context: MirrorWalkContext
|
|
829
|
+
): Promise<void> => {
|
|
830
|
+
let current = directoryPath;
|
|
831
|
+
while (true) {
|
|
832
|
+
const packagePath = join(current, 'package.json');
|
|
833
|
+
const packageStat = safeStat(packagePath);
|
|
834
|
+
if (packageStat?.isFile()) {
|
|
835
|
+
await copyFileToMirror(packagePath, context.mirrorRoot, context.copied);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const parent = dirname(current);
|
|
840
|
+
if (parent === current) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
current = parent;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
const packageLinkSegments = (packageName: string): readonly string[] =>
|
|
848
|
+
packageName.split('/').filter((segment) => segment.length > 0);
|
|
849
|
+
|
|
850
|
+
const createPackageMirrorLink = (
|
|
851
|
+
packageName: string,
|
|
852
|
+
targetRoot: string,
|
|
853
|
+
context: MirrorWalkContext
|
|
854
|
+
): void => {
|
|
855
|
+
if (context.linkedPackageNames.has(packageName)) {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const mirrorWorkspaceRoot = resolveLoadAppMirrorFilePath(
|
|
859
|
+
context.cwd,
|
|
860
|
+
context.mirrorRoot
|
|
861
|
+
);
|
|
862
|
+
if (mirrorWorkspaceRoot.isErr()) {
|
|
863
|
+
throw mirrorWorkspaceRoot.error;
|
|
864
|
+
}
|
|
865
|
+
const linkPath = join(
|
|
866
|
+
mirrorWorkspaceRoot.value,
|
|
867
|
+
'node_modules',
|
|
868
|
+
...packageLinkSegments(packageName)
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
const ensured = ensureLoadAppMirrorDirectory(
|
|
872
|
+
dirname(linkPath),
|
|
873
|
+
context.mirrorRoot
|
|
874
|
+
);
|
|
875
|
+
if (ensured.isErr()) {
|
|
876
|
+
throw ensured.error;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
symlinkSync(targetRoot, linkPath, 'dir');
|
|
881
|
+
} catch (error) {
|
|
882
|
+
if (
|
|
883
|
+
!(error instanceof Error) ||
|
|
884
|
+
!('code' in error) ||
|
|
885
|
+
error.code !== 'EEXIST'
|
|
886
|
+
) {
|
|
887
|
+
throw error;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
context.linkedPackageNames.add(packageName);
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
const createWorkspacePackageMirrorLink = (
|
|
894
|
+
packageName: string,
|
|
895
|
+
packageRoot: string,
|
|
896
|
+
context: MirrorWalkContext
|
|
897
|
+
): void => {
|
|
898
|
+
const mirrorPackageRoot = resolveLoadAppMirrorFilePath(
|
|
899
|
+
packageRoot,
|
|
900
|
+
context.mirrorRoot
|
|
901
|
+
);
|
|
902
|
+
if (mirrorPackageRoot.isErr()) {
|
|
903
|
+
throw mirrorPackageRoot.error;
|
|
904
|
+
}
|
|
905
|
+
createPackageMirrorLink(packageName, mirrorPackageRoot.value, context);
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const mirrorImportedModule = async (
|
|
909
|
+
modulePath: string,
|
|
910
|
+
context: MirrorWalkContext
|
|
911
|
+
): Promise<void> => {
|
|
912
|
+
const moduleDirectory = dirname(modulePath);
|
|
913
|
+
await copyNearestPackageJsonToMirror(moduleDirectory, context);
|
|
914
|
+
if (context.visitedDirectories.has(moduleDirectory)) {
|
|
915
|
+
await copyFileToMirror(modulePath, context.mirrorRoot, context.copied);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
await copyDirectoryTreeToMirror(moduleDirectory, context);
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
const scanAndVisitLocalImports = async (
|
|
922
|
+
modulePath: string,
|
|
923
|
+
context: MirrorWalkContext,
|
|
924
|
+
visit: (path: string) => Promise<void>
|
|
925
|
+
): Promise<void> => {
|
|
926
|
+
if (!isScannableModule(modulePath)) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const source = await Bun.file(modulePath).text();
|
|
930
|
+
for (const imported of collectImportedModuleResolutions(
|
|
931
|
+
modulePath,
|
|
932
|
+
source,
|
|
933
|
+
context.cwd
|
|
934
|
+
)) {
|
|
935
|
+
if (imported.kind === 'external-package') {
|
|
936
|
+
createPackageMirrorLink(
|
|
937
|
+
imported.packageName,
|
|
938
|
+
imported.packageRoot,
|
|
939
|
+
context
|
|
940
|
+
);
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
if (imported.kind === 'workspace-package') {
|
|
944
|
+
createWorkspacePackageMirrorLink(
|
|
945
|
+
imported.packageName,
|
|
946
|
+
imported.packageRoot,
|
|
947
|
+
context
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
await visit(imported.modulePath);
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const mirrorFreshImportGraph = async (
|
|
955
|
+
entryPath: string,
|
|
956
|
+
cwd: string,
|
|
957
|
+
mirrorRoot: string
|
|
958
|
+
): Promise<string> => {
|
|
959
|
+
const scanned = new Set<string>();
|
|
960
|
+
const context: MirrorWalkContext = {
|
|
961
|
+
copied: new Set<string>(),
|
|
962
|
+
cwd,
|
|
963
|
+
linkedPackageNames: new Set<string>(),
|
|
964
|
+
mirrorRoot,
|
|
965
|
+
visitedDirectories: new Set<string>(),
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const visit = async (modulePath: string): Promise<void> => {
|
|
969
|
+
if (scanned.has(modulePath)) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
scanned.add(modulePath);
|
|
973
|
+
await scanAndVisitLocalImports(modulePath, context, visit);
|
|
974
|
+
await mirrorImportedModule(modulePath, context);
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
await visit(entryPath);
|
|
978
|
+
const freshPath = resolveLoadAppMirrorFilePath(entryPath, mirrorRoot);
|
|
979
|
+
if (freshPath.isErr()) {
|
|
980
|
+
throw freshPath.error;
|
|
981
|
+
}
|
|
982
|
+
return freshPath.value;
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Import a module bypassing the ESM cache for the local filesystem import graph.
|
|
987
|
+
*
|
|
988
|
+
* @remarks
|
|
989
|
+
* External packages and built-in modules still resolve normally. Only local
|
|
990
|
+
* filesystem imports are mirrored into the fresh temp root. The mirror tree
|
|
991
|
+
* is retained for the lifetime of the process so that deferred relative
|
|
992
|
+
* `import()`/`require()` calls originating from the loaded module (e.g.
|
|
993
|
+
* inside a trail's `blaze`) can still resolve. If the graph walk itself
|
|
994
|
+
* fails, the partially-written mirror is removed immediately so failed
|
|
995
|
+
* loads do not leak disk space.
|
|
996
|
+
*/
|
|
997
|
+
const importWithCacheBust = async (
|
|
998
|
+
absolutePath: string
|
|
999
|
+
): Promise<Record<string, unknown>> => {
|
|
1000
|
+
const url = new URL(absolutePath);
|
|
1001
|
+
url.searchParams.set('t', Date.now().toString());
|
|
1002
|
+
return (await import(url.href)) as Record<string, unknown>;
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const prepareMirror = async (
|
|
1006
|
+
absolutePath: string,
|
|
1007
|
+
cwd: string
|
|
1008
|
+
): Promise<{ mirrorRoot: string; freshPath: string }> => {
|
|
1009
|
+
const resolvedCwd = resolve(cwd);
|
|
1010
|
+
const mirrorRoot = freshMirrorRootPath(resolvedCwd);
|
|
1011
|
+
try {
|
|
1012
|
+
const freshPath = await mirrorFreshImportGraph(
|
|
1013
|
+
absolutePath,
|
|
1014
|
+
resolvedCwd,
|
|
1015
|
+
mirrorRoot
|
|
1016
|
+
);
|
|
1017
|
+
return { freshPath, mirrorRoot };
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
removeLoadAppMirrorRootQuietly(mirrorRoot);
|
|
1020
|
+
throw error;
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const importFreshModule = async (
|
|
1025
|
+
resolvedModulePath: string,
|
|
1026
|
+
cwd: string
|
|
1027
|
+
): Promise<Record<string, unknown>> => {
|
|
1028
|
+
const absolutePath = resolvedModulePath;
|
|
1029
|
+
if (URL_SCHEME.test(absolutePath) && !absolutePath.startsWith('/')) {
|
|
1030
|
+
return await importWithCacheBust(absolutePath);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const { mirrorRoot, freshPath } = await prepareMirror(absolutePath, cwd);
|
|
1034
|
+
retainMirrorRoot(mirrorRoot);
|
|
1035
|
+
return (await import(pathToFileURL(freshPath).href)) as Record<
|
|
26
1036
|
string,
|
|
27
1037
|
unknown
|
|
28
1038
|
>;
|
|
29
|
-
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
const resolveLoadedTopo = (
|
|
1042
|
+
effectivePath: string,
|
|
1043
|
+
mod: Record<string, unknown>
|
|
1044
|
+
): Topo => {
|
|
1045
|
+
const app = (mod['default'] ?? mod['graph'] ?? mod['app']) as
|
|
1046
|
+
| Topo
|
|
1047
|
+
| undefined;
|
|
30
1048
|
if (!app?.trails) {
|
|
31
|
-
throw new
|
|
32
|
-
`Could not find a Topo export in "${
|
|
33
|
-
"Expected a default or
|
|
1049
|
+
throw new ValidationError(
|
|
1050
|
+
`Could not find a Topo export in "${effectivePath}". ` +
|
|
1051
|
+
"Expected a default, 'graph', or 'app' named export created with topo()."
|
|
34
1052
|
);
|
|
35
1053
|
}
|
|
36
1054
|
return app;
|
|
37
1055
|
};
|
|
1056
|
+
|
|
1057
|
+
export interface FreshAppLease {
|
|
1058
|
+
readonly app: Topo;
|
|
1059
|
+
readonly mirrorRoot: string;
|
|
1060
|
+
readonly release: () => void;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
export type LoadAppLeaseOptions = LoadAppTrustOptions;
|
|
1064
|
+
|
|
1065
|
+
export interface LoadAppOptions extends LoadAppTrustOptions {
|
|
1066
|
+
readonly fresh?: boolean | undefined;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const noopRelease = (): void => undefined;
|
|
1070
|
+
|
|
1071
|
+
const createUrlSchemeLease = async (
|
|
1072
|
+
absolutePath: string,
|
|
1073
|
+
effectivePath: string
|
|
1074
|
+
): Promise<FreshAppLease> => ({
|
|
1075
|
+
app: resolveLoadedTopo(
|
|
1076
|
+
effectivePath,
|
|
1077
|
+
await importWithCacheBust(absolutePath)
|
|
1078
|
+
),
|
|
1079
|
+
mirrorRoot: absolutePath,
|
|
1080
|
+
release: noopRelease,
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
const createFilesystemLease = async (
|
|
1084
|
+
absolutePath: string,
|
|
1085
|
+
cwd: string,
|
|
1086
|
+
effectivePath: string
|
|
1087
|
+
): Promise<FreshAppLease> => {
|
|
1088
|
+
const { mirrorRoot, freshPath } = await prepareMirror(absolutePath, cwd);
|
|
1089
|
+
const release = acquireMirrorLease(mirrorRoot);
|
|
1090
|
+
|
|
1091
|
+
try {
|
|
1092
|
+
const mod = (await import(pathToFileURL(freshPath).href)) as Record<
|
|
1093
|
+
string,
|
|
1094
|
+
unknown
|
|
1095
|
+
>;
|
|
1096
|
+
|
|
1097
|
+
return {
|
|
1098
|
+
app: resolveLoadedTopo(effectivePath, mod),
|
|
1099
|
+
mirrorRoot,
|
|
1100
|
+
release,
|
|
1101
|
+
};
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
release();
|
|
1104
|
+
throw error;
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
export const loadFreshAppLease = async (
|
|
1109
|
+
modulePath: string | undefined,
|
|
1110
|
+
cwd: string,
|
|
1111
|
+
options: LoadAppLeaseOptions = {}
|
|
1112
|
+
): Promise<FreshAppLease> => {
|
|
1113
|
+
const effectivePath =
|
|
1114
|
+
modulePath === undefined ? findWorkspaceRelativeAppModule(cwd) : modulePath;
|
|
1115
|
+
const absolutePath = resolveLoadAppModulePath(effectivePath, cwd, options);
|
|
1116
|
+
|
|
1117
|
+
return URL_SCHEME.test(absolutePath) && !absolutePath.startsWith('/')
|
|
1118
|
+
? await createUrlSchemeLease(absolutePath, effectivePath)
|
|
1119
|
+
: await createFilesystemLease(absolutePath, cwd, effectivePath);
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
export const tryLoadFreshAppLease = async (
|
|
1123
|
+
modulePath: string | undefined,
|
|
1124
|
+
cwd: string,
|
|
1125
|
+
options: LoadAppLeaseOptions = {}
|
|
1126
|
+
): Promise<Result<FreshAppLease, Error>> => {
|
|
1127
|
+
try {
|
|
1128
|
+
return Result.ok(await loadFreshAppLease(modulePath, cwd, options));
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
return Result.err(toLoadAppError(error));
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Load a Topo export from a module path relative to cwd.
|
|
1136
|
+
*
|
|
1137
|
+
* @remarks
|
|
1138
|
+
* By default, `modulePath` must be workspace-relative and stay under `cwd`.
|
|
1139
|
+
* URL-shaped, absolute, and parent-escape paths are rejected. Trusted internal
|
|
1140
|
+
* callers can pass `trustedModulePath: true` to deliberately use the broader
|
|
1141
|
+
* dynamic-import escape hatch.
|
|
1142
|
+
*/
|
|
1143
|
+
export const loadApp = async (
|
|
1144
|
+
modulePath: string | undefined,
|
|
1145
|
+
cwd: string,
|
|
1146
|
+
options: LoadAppOptions = {}
|
|
1147
|
+
): Promise<Topo> => {
|
|
1148
|
+
const effectivePath =
|
|
1149
|
+
modulePath === undefined ? findWorkspaceRelativeAppModule(cwd) : modulePath;
|
|
1150
|
+
const resolvedModulePath = resolveLoadAppModulePath(
|
|
1151
|
+
effectivePath,
|
|
1152
|
+
cwd,
|
|
1153
|
+
options
|
|
1154
|
+
);
|
|
1155
|
+
const mod =
|
|
1156
|
+
options.fresh === true
|
|
1157
|
+
? await importFreshModule(resolvedModulePath, cwd)
|
|
1158
|
+
: ((await import(
|
|
1159
|
+
URL_SCHEME.test(resolvedModulePath) &&
|
|
1160
|
+
!resolvedModulePath.startsWith('/')
|
|
1161
|
+
? new URL(resolvedModulePath).href
|
|
1162
|
+
: pathToFileURL(resolvedModulePath).href
|
|
1163
|
+
)) as Record<string, unknown>);
|
|
1164
|
+
return resolveLoadedTopo(effectivePath, mod);
|
|
1165
|
+
};
|