@ontrails/trails 1.0.0-beta.14 → 1.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/__tests__/examples.test.ts +29 -4
  4. package/dist/src/app.js +2 -2
  5. package/dist/src/app.js.map +1 -1
  6. package/dist/src/clack.d.ts +1 -1
  7. package/dist/src/clack.js +1 -1
  8. package/dist/src/cli.js +4 -3
  9. package/dist/src/cli.js.map +1 -1
  10. package/dist/src/trails/add-surface.d.ts +3 -3
  11. package/dist/src/trails/add-surface.d.ts.map +1 -1
  12. package/dist/src/trails/add-surface.js +46 -24
  13. package/dist/src/trails/add-surface.js.map +1 -1
  14. package/dist/src/trails/add-trail.d.ts +3 -1
  15. package/dist/src/trails/add-trail.d.ts.map +1 -1
  16. package/dist/src/trails/add-trail.js +38 -11
  17. package/dist/src/trails/add-trail.js.map +1 -1
  18. package/dist/src/trails/add-trailhead.d.ts +1 -1
  19. package/dist/src/trails/add-trailhead.d.ts.map +1 -1
  20. package/dist/src/trails/add-trailhead.js +4 -4
  21. package/dist/src/trails/add-verify.d.ts +1 -1
  22. package/dist/src/trails/add-verify.d.ts.map +1 -1
  23. package/dist/src/trails/add-verify.js +7 -6
  24. package/dist/src/trails/add-verify.js.map +1 -1
  25. package/dist/src/trails/create-scaffold.d.ts +1 -1
  26. package/dist/src/trails/create-scaffold.d.ts.map +1 -1
  27. package/dist/src/trails/create-scaffold.js +10 -3
  28. package/dist/src/trails/create-scaffold.js.map +1 -1
  29. package/dist/src/trails/create.d.ts +10 -14
  30. package/dist/src/trails/create.d.ts.map +1 -1
  31. package/dist/src/trails/create.js +42 -37
  32. package/dist/src/trails/create.js.map +1 -1
  33. package/dist/src/trails/dev-clean.d.ts +4 -4
  34. package/dist/src/trails/dev-clean.d.ts.map +1 -1
  35. package/dist/src/trails/dev-clean.js +22 -21
  36. package/dist/src/trails/dev-clean.js.map +1 -1
  37. package/dist/src/trails/dev-reset.d.ts +1 -1
  38. package/dist/src/trails/dev-reset.d.ts.map +1 -1
  39. package/dist/src/trails/dev-reset.js +3 -2
  40. package/dist/src/trails/dev-reset.js.map +1 -1
  41. package/dist/src/trails/dev-stats.d.ts +4 -4
  42. package/dist/src/trails/dev-stats.d.ts.map +1 -1
  43. package/dist/src/trails/dev-stats.js +19 -19
  44. package/dist/src/trails/dev-stats.js.map +1 -1
  45. package/dist/src/trails/dev-support.d.ts +17 -17
  46. package/dist/src/trails/dev-support.d.ts.map +1 -1
  47. package/dist/src/trails/dev-support.js +60 -57
  48. package/dist/src/trails/dev-support.js.map +1 -1
  49. package/dist/src/trails/draft-promote.d.ts +1 -1
  50. package/dist/src/trails/draft-promote.d.ts.map +1 -1
  51. package/dist/src/trails/draft-promote.js +30 -16
  52. package/dist/src/trails/draft-promote.js.map +1 -1
  53. package/dist/src/trails/guide.d.ts +2 -2
  54. package/dist/src/trails/guide.d.ts.map +1 -1
  55. package/dist/src/trails/guide.js +1 -4
  56. package/dist/src/trails/guide.js.map +1 -1
  57. package/dist/src/trails/load-app.d.ts +7 -1
  58. package/dist/src/trails/load-app.d.ts.map +1 -1
  59. package/dist/src/trails/load-app.js +374 -26
  60. package/dist/src/trails/load-app.js.map +1 -1
  61. package/dist/src/trails/survey.d.ts +5 -5
  62. package/dist/src/trails/survey.d.ts.map +1 -1
  63. package/dist/src/trails/survey.js +51 -29
  64. package/dist/src/trails/survey.js.map +1 -1
  65. package/dist/src/trails/topo-export.d.ts +5 -4
  66. package/dist/src/trails/topo-export.d.ts.map +1 -1
  67. package/dist/src/trails/topo-export.js +4 -7
  68. package/dist/src/trails/topo-export.js.map +1 -1
  69. package/dist/src/trails/topo-history.d.ts +6 -10
  70. package/dist/src/trails/topo-history.d.ts.map +1 -1
  71. package/dist/src/trails/topo-history.js +7 -8
  72. package/dist/src/trails/topo-history.js.map +1 -1
  73. package/dist/src/trails/topo-pin.d.ts +5 -9
  74. package/dist/src/trails/topo-pin.d.ts.map +1 -1
  75. package/dist/src/trails/topo-pin.js +5 -9
  76. package/dist/src/trails/topo-pin.js.map +1 -1
  77. package/dist/src/trails/topo-read-support.d.ts +12 -8
  78. package/dist/src/trails/topo-read-support.d.ts.map +1 -1
  79. package/dist/src/trails/topo-read-support.js +71 -82
  80. package/dist/src/trails/topo-read-support.js.map +1 -1
  81. package/dist/src/trails/topo-reports.d.ts +15 -11
  82. package/dist/src/trails/topo-reports.d.ts.map +1 -1
  83. package/dist/src/trails/topo-reports.js +40 -34
  84. package/dist/src/trails/topo-reports.js.map +1 -1
  85. package/dist/src/trails/topo-show.d.ts +4 -4
  86. package/dist/src/trails/topo-show.d.ts.map +1 -1
  87. package/dist/src/trails/topo-show.js +8 -12
  88. package/dist/src/trails/topo-show.js.map +1 -1
  89. package/dist/src/trails/topo-store-support.d.ts +2 -2
  90. package/dist/src/trails/topo-store-support.d.ts.map +1 -1
  91. package/dist/src/trails/topo-store-support.js +22 -22
  92. package/dist/src/trails/topo-store-support.js.map +1 -1
  93. package/dist/src/trails/topo-support.d.ts +19 -30
  94. package/dist/src/trails/topo-support.d.ts.map +1 -1
  95. package/dist/src/trails/topo-support.js +63 -96
  96. package/dist/src/trails/topo-support.js.map +1 -1
  97. package/dist/src/trails/topo-unpin.d.ts +9 -4
  98. package/dist/src/trails/topo-unpin.d.ts.map +1 -1
  99. package/dist/src/trails/topo-unpin.js +9 -4
  100. package/dist/src/trails/topo-unpin.js.map +1 -1
  101. package/dist/src/trails/topo-verify.d.ts +2 -2
  102. package/dist/src/trails/topo-verify.d.ts.map +1 -1
  103. package/dist/src/trails/topo-verify.js +1 -5
  104. package/dist/src/trails/topo-verify.js.map +1 -1
  105. package/dist/src/trails/topo.d.ts +2 -2
  106. package/dist/src/trails/topo.d.ts.map +1 -1
  107. package/dist/src/trails/topo.js +6 -10
  108. package/dist/src/trails/topo.js.map +1 -1
  109. package/dist/src/trails/warden.d.ts +3 -2
  110. package/dist/src/trails/warden.d.ts.map +1 -1
  111. package/dist/src/trails/warden.js +11 -2
  112. package/dist/src/trails/warden.js.map +1 -1
  113. package/dist/src/versions.d.ts +12 -0
  114. package/dist/src/versions.d.ts.map +1 -0
  115. package/dist/src/versions.js +23 -0
  116. package/dist/src/versions.js.map +1 -0
  117. package/dist/tsconfig.tsbuildinfo +1 -1
  118. package/package.json +8 -8
  119. package/src/__tests__/add-trail.test.ts +97 -0
  120. package/src/__tests__/create.test.ts +91 -27
  121. package/src/__tests__/guide.test.ts +10 -5
  122. package/src/__tests__/load-app.test.ts +365 -4
  123. package/src/__tests__/survey.test.ts +143 -67
  124. package/src/__tests__/topo-dev.test.ts +48 -46
  125. package/src/app.ts +2 -2
  126. package/src/clack.ts +1 -1
  127. package/src/cli.ts +4 -3
  128. package/src/trails/add-surface.ts +150 -0
  129. package/src/trails/add-trail.ts +46 -10
  130. package/src/trails/add-verify.ts +11 -6
  131. package/src/trails/create-scaffold.ts +16 -3
  132. package/src/trails/create.ts +76 -71
  133. package/src/trails/dev-clean.ts +25 -21
  134. package/src/trails/dev-reset.ts +3 -2
  135. package/src/trails/dev-stats.ts +22 -19
  136. package/src/trails/dev-support.ts +88 -86
  137. package/src/trails/draft-promote.ts +70 -35
  138. package/src/trails/guide.ts +1 -4
  139. package/src/trails/load-app.ts +511 -32
  140. package/src/trails/survey.ts +60 -35
  141. package/src/trails/topo-export.ts +5 -9
  142. package/src/trails/topo-history.ts +8 -10
  143. package/src/trails/topo-pin.ts +9 -13
  144. package/src/trails/topo-read-support.ts +103 -106
  145. package/src/trails/topo-reports.ts +54 -47
  146. package/src/trails/topo-show.ts +8 -12
  147. package/src/trails/topo-store-support.ts +36 -28
  148. package/src/trails/topo-support.ts +90 -134
  149. package/src/trails/topo-unpin.ts +11 -6
  150. package/src/trails/topo-verify.ts +1 -5
  151. package/src/trails/topo.ts +6 -10
  152. package/src/trails/warden.ts +12 -3
  153. package/src/versions.ts +43 -0
  154. package/tsconfig.tests.json +10 -0
  155. package/src/trails/add-trailhead.ts +0 -121
@@ -1,11 +1,119 @@
1
- import { existsSync, rmSync } from 'node:fs';
2
- import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
2
+ import {
3
+ dirname,
4
+ extname,
5
+ isAbsolute,
6
+ join,
7
+ parse as parsePath,
8
+ relative,
9
+ resolve,
10
+ } from 'node:path';
3
11
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
12
 
5
13
  import type { Topo } from '@ontrails/core';
14
+ import { findAppModule } from '@ontrails/cli';
6
15
 
7
16
  const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
8
17
 
18
+ type TranspilerLoader = 'ts' | 'tsx' | 'js' | 'jsx';
19
+
20
+ /** Extension → Bun.Transpiler loader, so JSX-bearing files parse correctly. */
21
+ const LOADER_BY_EXTENSION: Record<string, TranspilerLoader> = {
22
+ '.cjs': 'js',
23
+ '.cts': 'ts',
24
+ '.js': 'js',
25
+ '.jsx': 'jsx',
26
+ '.mjs': 'js',
27
+ '.mts': 'ts',
28
+ '.ts': 'ts',
29
+ '.tsx': 'tsx',
30
+ };
31
+
32
+ const SCANNABLE_EXTENSIONS = new Set(Object.keys(LOADER_BY_EXTENSION));
33
+
34
+ const TRANSPILER_CACHE = new Map<TranspilerLoader, Bun.Transpiler>();
35
+
36
+ const getImportScanner = (loader: TranspilerLoader): Bun.Transpiler => {
37
+ const cached = TRANSPILER_CACHE.get(loader);
38
+ if (cached !== undefined) {
39
+ return cached;
40
+ }
41
+ const scanner = new Bun.Transpiler({ loader });
42
+ TRANSPILER_CACHE.set(loader, scanner);
43
+ return scanner;
44
+ };
45
+
46
+ /**
47
+ * Mirror roots kept alive for the lifetime of the process.
48
+ *
49
+ * @remarks
50
+ * A fresh-loaded module may expose functions whose deferred relative imports
51
+ * are resolved only when those functions run (for example inside a trail's
52
+ * `blaze`). If we deleted the mirror tree immediately after the initial
53
+ * `import()` resolved, those later resolutions would hit an ENOENT. We keep
54
+ * the mirrors on disk and clean them up once, on process exit.
55
+ */
56
+ const ACTIVE_MIRROR_ROOTS = new Set<string>();
57
+ const RETAINED_MIRROR_ROOTS = new Set<string>();
58
+
59
+ const cleanupAllMirrorRoots = (): void => {
60
+ for (const root of [...ACTIVE_MIRROR_ROOTS, ...RETAINED_MIRROR_ROOTS]) {
61
+ rmSync(root, { force: true, recursive: true });
62
+ }
63
+ ACTIVE_MIRROR_ROOTS.clear();
64
+ RETAINED_MIRROR_ROOTS.clear();
65
+ };
66
+
67
+ const mirrorCleanup = (() => {
68
+ let registered = false;
69
+ return {
70
+ ensureRegistered(): void {
71
+ if (registered) {
72
+ return;
73
+ }
74
+ registered = true;
75
+ process.once('exit', cleanupAllMirrorRoots);
76
+ },
77
+ };
78
+ })();
79
+
80
+ const ensureMirrorCleanupHook = (): void => {
81
+ mirrorCleanup.ensureRegistered();
82
+ };
83
+
84
+ /**
85
+ * Retain a fresh-import mirror for the lifetime of the process. A previously
86
+ * returned `loadApp` result may hold deferred relative `import()` calls whose
87
+ * resolution requires the mirror directory to still exist, so we cannot prune
88
+ * these by age without risking ENOENT in long-lived sessions (dev server,
89
+ * survey polling, concurrent fresh loads). Cleanup happens once on process
90
+ * exit via `cleanupAllMirrorRoots`.
91
+ */
92
+ const retainMirrorRoot = (mirrorRoot: string): void => {
93
+ if (RETAINED_MIRROR_ROOTS.has(mirrorRoot)) {
94
+ return;
95
+ }
96
+ RETAINED_MIRROR_ROOTS.add(mirrorRoot);
97
+ ensureMirrorCleanupHook();
98
+ };
99
+
100
+ const acquireMirrorLease = (mirrorRoot: string): (() => void) => {
101
+ ACTIVE_MIRROR_ROOTS.add(mirrorRoot);
102
+ ensureMirrorCleanupHook();
103
+
104
+ let released = false;
105
+
106
+ return () => {
107
+ if (released) {
108
+ return;
109
+ }
110
+
111
+ released = true;
112
+ ACTIVE_MIRROR_ROOTS.delete(mirrorRoot);
113
+ rmSync(mirrorRoot, { force: true, recursive: true });
114
+ };
115
+ };
116
+
9
117
  const resolveUrlModulePath = (modulePath: string): string => {
10
118
  const url = new URL(modulePath);
11
119
  return url.protocol === 'file:' ? fileURLToPath(url) : modulePath;
@@ -32,69 +140,440 @@ const resolveAbsoluteModulePath = (modulePath: string, cwd: string): string =>
32
140
  ? resolveUrlModulePath(modulePath)
33
141
  : resolveFilesystemModulePath(modulePath, cwd);
34
142
 
35
- const freshModuleCopyPath = (absolutePath: string): string =>
36
- join(
37
- dirname(absolutePath),
38
- `.__fresh-${Date.now()}-${Math.random().toString(36).slice(2)}-${basename(absolutePath)}`
143
+ const MIRROR_PARENT_DIRNAME = '.trails-tmp';
144
+
145
+ const MIRROR_ENTRY_PREFIX = 'load-app-fresh-';
146
+
147
+ /**
148
+ * Convert an absolute path to a drive-safe relative form before appending it
149
+ * to the mirror root. `path.parse(...).root` returns `'/'` on POSIX and
150
+ * `'C:\\'` (or similar) on Windows, so `relative` strips the platform root
151
+ * in both cases.
152
+ */
153
+ const freshMirrorPath = (absolutePath: string, mirrorRoot: string): string =>
154
+ join(mirrorRoot, relative(parsePath(absolutePath).root, absolutePath));
155
+
156
+ const isLocalFilesystemImport = (importPath: string): boolean =>
157
+ importPath.startsWith('.') ||
158
+ importPath.startsWith('/') ||
159
+ importPath.startsWith('file:');
160
+
161
+ const isScannableModule = (modulePath: string): boolean =>
162
+ SCANNABLE_EXTENSIONS.has(extname(modulePath));
163
+
164
+ const resolveImportedModulePath = (
165
+ importerPath: string,
166
+ importPath: string
167
+ ): string => {
168
+ const resolved = import.meta.resolve(
169
+ importPath,
170
+ pathToFileURL(importerPath).href
39
171
  );
172
+ return resolveFilesystemModulePath(
173
+ fileURLToPath(resolved),
174
+ dirname(importerPath)
175
+ );
176
+ };
177
+
178
+ const collectImportedModulePaths = (
179
+ modulePath: string,
180
+ source: string
181
+ ): readonly string[] => {
182
+ const extension = extname(modulePath);
183
+ const loader = LOADER_BY_EXTENSION[extension];
184
+ if (loader === undefined) {
185
+ return [];
186
+ }
187
+
188
+ return getImportScanner(loader)
189
+ .scanImports(source)
190
+ .map((entry) => entry.path)
191
+ .filter(isLocalFilesystemImport)
192
+ .map((importPath) => resolveImportedModulePath(modulePath, importPath));
193
+ };
40
194
 
41
195
  /**
42
- * Import a module bypassing the ESM cache for the entry file.
196
+ * Copy a single file into the mirror by raw bytes.
43
197
  *
44
198
  * @remarks
45
- * Cache-busting applies to the entry module only. Transitive imports resolved
46
- * by the entry file are still served from Bun's module cache. This is
47
- * acceptable for the draft promotion workflow (the only caller) because
48
- * promotion changes which modules the entry file imports, not the modules
49
- * themselves. If a deeper cache-bust is needed in the future, consider
50
- * Bun's `Loader.registry` or a full process restart.
199
+ * Reading via `.bytes()` rather than `.text()` preserves binary payloads
200
+ * (`.wasm`, `.node`, compiled assets) that may sit alongside source files in
201
+ * the app's graph. Text decoding would corrupt them on the way through the
202
+ * mirror.
51
203
  */
204
+ const copyFileToMirror = async (
205
+ sourcePath: string,
206
+ mirrorRoot: string,
207
+ copied: Set<string>
208
+ ): Promise<void> => {
209
+ if (copied.has(sourcePath)) {
210
+ return;
211
+ }
212
+ copied.add(sourcePath);
213
+
214
+ const mirrorPath = freshMirrorPath(sourcePath, mirrorRoot);
215
+ mkdirSync(dirname(mirrorPath), { recursive: true });
216
+ const bytes = await Bun.file(sourcePath).bytes();
217
+ await Bun.write(mirrorPath, bytes);
218
+ };
219
+
220
+ /**
221
+ * Directory basenames that are never worth mirroring.
222
+ *
223
+ * @remarks
224
+ * These directories are excluded because they can be large and are never
225
+ * sources of resolvable imports — they hold VCS metadata, package installs,
226
+ * prior mirror artifacts, or build/tooling output that module resolution
227
+ * should not touch.
228
+ */
229
+ const MIRROR_SKIP_DIRECTORIES = new Set([
230
+ '.cache',
231
+ '.git',
232
+ '.next',
233
+ '.nuxt',
234
+ '.output',
235
+ '.svelte-kit',
236
+ '.trails-tmp',
237
+ '.turbo',
238
+ 'build',
239
+ 'coverage',
240
+ 'dist',
241
+ 'node_modules',
242
+ ]);
243
+
244
+ /**
245
+ * Recursively copy every regular file inside `directoryPath` into the
246
+ * mirror, skipping well-known heavy directories.
247
+ *
248
+ * @remarks
249
+ * `Bun.Transpiler#scanImports` only surfaces statically analyzable import
250
+ * specifiers. Computed dynamic imports such as `import(\`./${name}.ts\`)`
251
+ * never appear, so their targets would otherwise be missing from the
252
+ * mirror. Shadowing each directory touched by the static walk with its
253
+ * full subtree keeps those sibling modules resolvable under the mirror
254
+ * root at runtime without pulling in package installs or nested mirror
255
+ * artifacts.
256
+ */
257
+ const readDirectoryEntries = (directoryPath: string): readonly string[] => {
258
+ try {
259
+ return readdirSync(directoryPath);
260
+ } catch {
261
+ return [];
262
+ }
263
+ };
264
+
265
+ const safeStat = (
266
+ entryPath: string
267
+ ): ReturnType<typeof statSync> | undefined => {
268
+ try {
269
+ return statSync(entryPath);
270
+ } catch {
271
+ return undefined;
272
+ }
273
+ };
274
+
275
+ /**
276
+ * Age threshold (ms) above which a mirror entry in `.trails-tmp/` is
277
+ * considered stale and safe to remove opportunistically.
278
+ *
279
+ * @remarks
280
+ * Fresh loads complete in seconds. Anything older than 10 minutes is almost
281
+ * certainly left over from a crashed or signal-killed process. We intentionally
282
+ * avoid registering SIGTERM/SIGINT handlers here because that would risk
283
+ * clobbering host-app signal handlers (and still wouldn't rescue SIGKILL).
284
+ * Opportunistic cleanup is self-healing across crashes from any cause.
285
+ */
286
+ const STALE_MIRROR_THRESHOLD_MS = 10 * 60 * 1000;
287
+
288
+ const isStaleMirrorEntry = (entryPath: string, now: number): boolean => {
289
+ if (
290
+ ACTIVE_MIRROR_ROOTS.has(entryPath) ||
291
+ RETAINED_MIRROR_ROOTS.has(entryPath)
292
+ ) {
293
+ return false;
294
+ }
295
+ const entryStat = safeStat(entryPath);
296
+ if (entryStat === undefined) {
297
+ return false;
298
+ }
299
+ const mtimeMs = Number(entryStat.mtimeMs);
300
+ return now - mtimeMs >= STALE_MIRROR_THRESHOLD_MS;
301
+ };
302
+
303
+ const removeStaleMirrorEntry = (entryPath: string): void => {
304
+ try {
305
+ rmSync(entryPath, { force: true, recursive: true });
306
+ } catch {
307
+ /*
308
+ * Another concurrent load may own it. Safe to ignore — the next sweep
309
+ * will retry.
310
+ */
311
+ }
312
+ };
313
+
314
+ /**
315
+ * Best-effort removal of stale mirror directories left by previous (crashed or
316
+ * signal-killed) processes. Called before creating a new mirror root.
317
+ */
318
+ const cleanupStaleMirrorRoots = (mirrorParent: string): void => {
319
+ const entries = readDirectoryEntries(mirrorParent);
320
+ if (entries.length === 0) {
321
+ return;
322
+ }
323
+ const now = Date.now();
324
+ for (const entry of entries) {
325
+ if (!entry.startsWith(MIRROR_ENTRY_PREFIX)) {
326
+ continue;
327
+ }
328
+ const entryPath = join(mirrorParent, entry);
329
+ if (isStaleMirrorEntry(entryPath, now)) {
330
+ removeStaleMirrorEntry(entryPath);
331
+ }
332
+ }
333
+ };
334
+
335
+ const freshMirrorRootPath = (cwd: string): string => {
336
+ const mirrorParent = join(cwd, MIRROR_PARENT_DIRNAME);
337
+ cleanupStaleMirrorRoots(mirrorParent);
338
+ return join(
339
+ mirrorParent,
340
+ `${MIRROR_ENTRY_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2)}`
341
+ );
342
+ };
343
+
344
+ interface MirrorWalkContext {
345
+ readonly mirrorRoot: string;
346
+ readonly copied: Set<string>;
347
+ readonly visitedDirectories: Set<string>;
348
+ }
349
+
350
+ type DirectoryEntryKind = 'directory' | 'file' | 'skip';
351
+
352
+ const classifyDirectoryEntry = (
353
+ entry: string,
354
+ entryPath: string
355
+ ): DirectoryEntryKind => {
356
+ const entryStat = safeStat(entryPath);
357
+ if (entryStat === undefined) {
358
+ return 'skip';
359
+ }
360
+ if (entryStat.isDirectory()) {
361
+ return MIRROR_SKIP_DIRECTORIES.has(entry) ? 'skip' : 'directory';
362
+ }
363
+ return entryStat.isFile() ? 'file' : 'skip';
364
+ };
365
+
366
+ const copyDirectoryTreeToMirror = async (
367
+ directoryPath: string,
368
+ context: MirrorWalkContext
369
+ ): Promise<void> => {
370
+ if (context.visitedDirectories.has(directoryPath)) {
371
+ return;
372
+ }
373
+ context.visitedDirectories.add(directoryPath);
374
+
375
+ for (const entry of readDirectoryEntries(directoryPath)) {
376
+ const entryPath = join(directoryPath, entry);
377
+ const kind = classifyDirectoryEntry(entry, entryPath);
378
+ if (kind === 'directory') {
379
+ await copyDirectoryTreeToMirror(entryPath, context);
380
+ } else if (kind === 'file') {
381
+ await copyFileToMirror(entryPath, context.mirrorRoot, context.copied);
382
+ }
383
+ }
384
+ };
385
+
386
+ const mirrorImportedModule = async (
387
+ modulePath: string,
388
+ context: MirrorWalkContext
389
+ ): Promise<void> => {
390
+ const moduleDirectory = dirname(modulePath);
391
+ if (context.visitedDirectories.has(moduleDirectory)) {
392
+ await copyFileToMirror(modulePath, context.mirrorRoot, context.copied);
393
+ return;
394
+ }
395
+ await copyDirectoryTreeToMirror(moduleDirectory, context);
396
+ };
397
+
398
+ const scanAndVisitLocalImports = async (
399
+ modulePath: string,
400
+ visit: (path: string) => Promise<void>
401
+ ): Promise<void> => {
402
+ if (!isScannableModule(modulePath)) {
403
+ return;
404
+ }
405
+ const source = await Bun.file(modulePath).text();
406
+ for (const importedPath of collectImportedModulePaths(modulePath, source)) {
407
+ await visit(importedPath);
408
+ }
409
+ };
410
+
411
+ const mirrorFreshImportGraph = async (
412
+ entryPath: string,
413
+ mirrorRoot: string
414
+ ): Promise<string> => {
415
+ const scanned = new Set<string>();
416
+ const context: MirrorWalkContext = {
417
+ copied: new Set<string>(),
418
+ mirrorRoot,
419
+ visitedDirectories: new Set<string>(),
420
+ };
421
+
422
+ const visit = async (modulePath: string): Promise<void> => {
423
+ if (scanned.has(modulePath)) {
424
+ return;
425
+ }
426
+ scanned.add(modulePath);
427
+ await scanAndVisitLocalImports(modulePath, visit);
428
+ await mirrorImportedModule(modulePath, context);
429
+ };
430
+
431
+ await visit(entryPath);
432
+ return freshMirrorPath(entryPath, mirrorRoot);
433
+ };
434
+
435
+ /**
436
+ * Import a module bypassing the ESM cache for the local filesystem import graph.
437
+ *
438
+ * @remarks
439
+ * External packages and built-in modules still resolve normally. Only local
440
+ * filesystem imports are mirrored into the fresh temp root. The mirror tree
441
+ * is retained for the lifetime of the process so that deferred relative
442
+ * `import()`/`require()` calls originating from the loaded module (e.g.
443
+ * inside a trail's `blaze`) can still resolve. If the graph walk itself
444
+ * fails, the partially-written mirror is removed immediately so failed
445
+ * loads do not leak disk space.
446
+ */
447
+ const importWithCacheBust = async (
448
+ absolutePath: string
449
+ ): Promise<Record<string, unknown>> => {
450
+ const url = new URL(absolutePath);
451
+ url.searchParams.set('t', Date.now().toString());
452
+ return (await import(url.href)) as Record<string, unknown>;
453
+ };
454
+
455
+ const prepareMirror = async (
456
+ absolutePath: string,
457
+ cwd: string
458
+ ): Promise<{ mirrorRoot: string; freshPath: string }> => {
459
+ const mirrorRoot = freshMirrorRootPath(cwd);
460
+ try {
461
+ const freshPath = await mirrorFreshImportGraph(absolutePath, mirrorRoot);
462
+ return { freshPath, mirrorRoot };
463
+ } catch (error) {
464
+ rmSync(mirrorRoot, { force: true, recursive: true });
465
+ throw error;
466
+ }
467
+ };
468
+
52
469
  const importFreshModule = async (
53
470
  modulePath: string,
54
471
  cwd: string
55
472
  ): Promise<Record<string, unknown>> => {
56
473
  const absolutePath = resolveAbsoluteModulePath(modulePath, cwd);
57
474
  if (URL_SCHEME.test(absolutePath) && !absolutePath.startsWith('/')) {
58
- const url = new URL(absolutePath);
59
- url.searchParams.set('t', Date.now().toString());
60
- return (await import(url.href)) as Record<string, unknown>;
475
+ return await importWithCacheBust(absolutePath);
476
+ }
477
+
478
+ const { mirrorRoot, freshPath } = await prepareMirror(absolutePath, cwd);
479
+ retainMirrorRoot(mirrorRoot);
480
+ return (await import(pathToFileURL(freshPath).href)) as Record<
481
+ string,
482
+ unknown
483
+ >;
484
+ };
485
+
486
+ const resolveLoadedTopo = (
487
+ effectivePath: string,
488
+ mod: Record<string, unknown>
489
+ ): Topo => {
490
+ const app = (mod['default'] ?? mod['graph'] ?? mod['app']) as
491
+ | Topo
492
+ | undefined;
493
+ if (!app?.trails) {
494
+ throw new Error(
495
+ `Could not find a Topo export in "${effectivePath}". ` +
496
+ "Expected a default, 'graph', or 'app' named export created with topo()."
497
+ );
61
498
  }
499
+ return app;
500
+ };
501
+
502
+ export interface FreshAppLease {
503
+ readonly app: Topo;
504
+ readonly mirrorRoot: string;
505
+ readonly release: () => void;
506
+ }
507
+
508
+ const noopRelease = (): void => undefined;
62
509
 
63
- const freshPath = freshModuleCopyPath(absolutePath);
64
- await Bun.write(freshPath, await Bun.file(absolutePath).text());
510
+ const createUrlSchemeLease = async (
511
+ absolutePath: string,
512
+ effectivePath: string
513
+ ): Promise<FreshAppLease> => ({
514
+ app: resolveLoadedTopo(
515
+ effectivePath,
516
+ await importWithCacheBust(absolutePath)
517
+ ),
518
+ mirrorRoot: absolutePath,
519
+ release: noopRelease,
520
+ });
521
+
522
+ const createFilesystemLease = async (
523
+ absolutePath: string,
524
+ cwd: string,
525
+ effectivePath: string
526
+ ): Promise<FreshAppLease> => {
527
+ const { mirrorRoot, freshPath } = await prepareMirror(absolutePath, cwd);
528
+ const release = acquireMirrorLease(mirrorRoot);
65
529
 
66
530
  try {
67
- return (await import(pathToFileURL(freshPath).href)) as Record<
531
+ const mod = (await import(pathToFileURL(freshPath).href)) as Record<
68
532
  string,
69
533
  unknown
70
534
  >;
71
- } finally {
72
- rmSync(freshPath, { force: true });
535
+
536
+ return {
537
+ app: resolveLoadedTopo(effectivePath, mod),
538
+ mirrorRoot,
539
+ release,
540
+ };
541
+ } catch (error) {
542
+ release();
543
+ throw error;
73
544
  }
74
545
  };
75
546
 
547
+ export const loadFreshAppLease = async (
548
+ modulePath: string | undefined,
549
+ cwd: string
550
+ ): Promise<FreshAppLease> => {
551
+ const effectivePath =
552
+ modulePath === undefined ? findAppModule(cwd) : modulePath;
553
+ const absolutePath = resolveAbsoluteModulePath(effectivePath, cwd);
554
+
555
+ return URL_SCHEME.test(absolutePath) && !absolutePath.startsWith('/')
556
+ ? await createUrlSchemeLease(absolutePath, effectivePath)
557
+ : await createFilesystemLease(absolutePath, cwd, effectivePath);
558
+ };
559
+
76
560
  /** Load a Topo export from a module path relative to cwd. */
77
561
  export const loadApp = async (
78
- modulePath: string,
562
+ modulePath: string | undefined,
79
563
  cwd: string,
80
564
  options: { fresh?: boolean | undefined } = {}
81
565
  ): Promise<Topo> => {
82
- const resolvedModulePath = resolveAbsoluteModulePath(modulePath, cwd);
566
+ const effectivePath =
567
+ modulePath === undefined ? findAppModule(cwd) : modulePath;
568
+ const resolvedModulePath = resolveAbsoluteModulePath(effectivePath, cwd);
83
569
  const mod =
84
570
  options.fresh === true
85
- ? await importFreshModule(modulePath, cwd)
571
+ ? await importFreshModule(resolvedModulePath, cwd)
86
572
  : ((await import(
87
573
  URL_SCHEME.test(resolvedModulePath) &&
88
574
  !resolvedModulePath.startsWith('/')
89
575
  ? new URL(resolvedModulePath).href
90
576
  : pathToFileURL(resolvedModulePath).href
91
577
  )) as Record<string, unknown>);
92
- const app = (mod['default'] ?? mod['app']) as Topo | undefined;
93
- if (!app?.trails) {
94
- throw new Error(
95
- `Could not find a Topo export in "${modulePath}". ` +
96
- "Expected a default or named 'app' export created with topo()."
97
- );
98
- }
99
- return app;
578
+ return resolveLoadedTopo(effectivePath, mod);
100
579
  };