isolate-package 1.33.0-0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/index.mjs +1 -1
  2. package/dist/index.mjs.map +1 -1
  3. package/dist/{isolate-DTwgcMAN.mjs → isolate-DI3eUTci.mjs} +576 -242
  4. package/dist/isolate-DI3eUTci.mjs.map +1 -0
  5. package/dist/isolate-bin.mjs +5 -6
  6. package/dist/isolate-bin.mjs.map +1 -1
  7. package/package.json +23 -19
  8. package/src/get-internal-package-names.test.ts +1 -1
  9. package/src/get-internal-package-names.ts +2 -2
  10. package/src/isolate-bin.ts +5 -5
  11. package/src/isolate.ts +20 -17
  12. package/src/lib/config.test.ts +1 -1
  13. package/src/lib/config.ts +3 -3
  14. package/src/lib/lockfile/helpers/bun-lockfile.ts +21 -11
  15. package/src/lib/lockfile/helpers/generate-bun-lockfile.test.ts +3 -3
  16. package/src/lib/lockfile/helpers/generate-bun-lockfile.ts +7 -7
  17. package/src/lib/lockfile/helpers/generate-npm-lockfile.integration.test.ts +1 -5
  18. package/src/lib/lockfile/helpers/generate-npm-lockfile.test.ts +311 -16
  19. package/src/lib/lockfile/helpers/generate-npm-lockfile.ts +193 -22
  20. package/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts +2 -2
  21. package/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +6 -6
  22. package/src/lib/lockfile/helpers/generate-yarn-lockfile.ts +5 -5
  23. package/src/lib/lockfile/process-lockfile.test.ts +2 -2
  24. package/src/lib/manifest/helpers/adapt-internal-package-manifests.test.ts +3 -3
  25. package/src/lib/manifest/helpers/adapt-internal-package-manifests.ts +2 -2
  26. package/src/lib/manifest/helpers/adapt-manifest-internal-deps.ts +1 -1
  27. package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.test.ts +4 -4
  28. package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.ts +7 -7
  29. package/src/lib/manifest/helpers/resolve-catalog-dependencies.test.ts +410 -0
  30. package/src/lib/manifest/helpers/resolve-catalog-dependencies.ts +115 -27
  31. package/src/lib/manifest/io.ts +6 -2
  32. package/src/lib/manifest/validate-manifest.ts +2 -2
  33. package/src/lib/output/get-build-output-dir.ts +1 -1
  34. package/src/lib/output/pack-dependencies.ts +1 -1
  35. package/src/lib/output/process-build-output-files.ts +6 -17
  36. package/src/lib/package-manager/helpers/infer-from-files.ts +5 -5
  37. package/src/lib/package-manager/helpers/infer-from-manifest.ts +7 -8
  38. package/src/lib/package-manager/index.ts +1 -1
  39. package/src/lib/package-manager/names.ts +8 -10
  40. package/src/lib/patches/collect-installed-names-bun.test.ts +2 -2
  41. package/src/lib/patches/collect-installed-names-bun.ts +8 -8
  42. package/src/lib/patches/collect-installed-names-pnpm.test.ts +1 -1
  43. package/src/lib/patches/collect-installed-names-pnpm.ts +13 -12
  44. package/src/lib/patches/copy-patches.test.ts +5 -13
  45. package/src/lib/patches/copy-patches.ts +9 -9
  46. package/src/lib/patches/write-isolate-pnpm-workspace.test.ts +83 -3
  47. package/src/lib/patches/write-isolate-pnpm-workspace.ts +4 -4
  48. package/src/lib/registry/collect-reachable-package-names.test.ts +1 -1
  49. package/src/lib/registry/create-packages-registry.ts +34 -31
  50. package/src/lib/registry/helpers/find-packages-globs.ts +23 -19
  51. package/src/lib/registry/list-internal-packages.test.ts +2 -2
  52. package/src/lib/types.ts +2 -2
  53. package/src/lib/utils/filter-patched-dependencies.test.ts +1 -1
  54. package/src/lib/utils/filter-patched-dependencies.ts +2 -2
  55. package/src/lib/utils/get-dirname.ts +1 -1
  56. package/src/lib/utils/index.ts +1 -1
  57. package/src/lib/utils/json.ts +12 -14
  58. package/src/lib/utils/pack.ts +32 -22
  59. package/src/lib/utils/reset-isolate-dir.test.ts +165 -0
  60. package/src/lib/utils/reset-isolate-dir.ts +147 -0
  61. package/src/lib/utils/unpack.test.ts +76 -0
  62. package/src/lib/utils/unpack.ts +16 -10
  63. package/src/lib/utils/wait-for-complete-file.test.ts +105 -0
  64. package/src/lib/utils/wait-for-complete-file.ts +44 -0
  65. package/src/lib/utils/yaml.ts +8 -9
  66. package/src/testing/setup.ts +1 -1
  67. package/dist/isolate-DTwgcMAN.mjs.map +0 -1
@@ -0,0 +1,147 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "fs-extra";
3
+ import path from "node:path";
4
+ import { useLogger } from "../logger";
5
+
6
+ /**
7
+ * Prefix used for trash directories created when resetting the isolate output
8
+ * directory. The leading dot keeps it hidden in file explorers and `ls`
9
+ * without `-a`, so users don't see a flash of two folders while the old
10
+ * contents are being reaped in the background.
11
+ */
12
+ const TRASH_PREFIX = ".";
13
+ const TRASH_INFIX = ".trash-";
14
+
15
+ export type ResetIsolateDirOptions = {
16
+ /**
17
+ * Directory in which to place the temporary "trash" sibling that
18
+ * `isolateDir` is renamed to before being deleted in the background.
19
+ * Defaults to `path.dirname(isolateDir)`.
20
+ *
21
+ * Callers that pack the parent of `isolateDir` (e.g. `isolate.ts`, which
22
+ * later runs `npm pack` on `targetPackageDir`) should point this at a
23
+ * location outside of what gets packed, so the trash can't leak into the
24
+ * packed output and so the background delete can't race with the pack
25
+ * step.
26
+ */
27
+ trashParentDir?: string;
28
+ };
29
+
30
+ /**
31
+ * Reset the isolate output directory to a fresh empty directory, avoiding the
32
+ * `ENOTEMPTY` race that occurs when another process (e.g. the Firebase
33
+ * functions emulator, a file watcher) writes into the directory while it is
34
+ * being recursively deleted.
35
+ *
36
+ * Strategy:
37
+ *
38
+ * 1. Sweep any leftover trash directories from previous runs that may have
39
+ * been killed mid-cleanup. Best-effort: ignore errors.
40
+ * 2. If the isolate directory exists, atomically `rename` it to a hidden
41
+ * sibling on the same filesystem. The rename is atomic, so the moment it
42
+ * returns the original path is free for a fresh empty directory and
43
+ * nothing a concurrent writer does inside the old tree can affect the
44
+ * new one.
45
+ * 3. Kick off the recursive delete of the trash directory in the background.
46
+ * We don't await it: it is the slowest part of an isolate run, and any
47
+ * failure (e.g. another process still holding files open) is harmless
48
+ * because the logical state is already correct. Stale trash dirs are
49
+ * reaped by the next run's sweep in step 1.
50
+ * 4. Ensure the (now-vacant) isolate directory exists.
51
+ */
52
+ export async function resetIsolateDir(
53
+ isolateDir: string,
54
+ options: ResetIsolateDirOptions = {},
55
+ ): Promise<void> {
56
+ const log = useLogger();
57
+ const trashParentDir = options.trashParentDir ?? path.dirname(isolateDir);
58
+ const trashStem = buildTrashStem(trashParentDir, isolateDir);
59
+ const trashGlobPrefix = `${TRASH_PREFIX}${trashStem}${TRASH_INFIX}`;
60
+
61
+ /** Best-effort sweep of leftover trash from previously killed runs. */
62
+ await sweepStaleTrash(trashParentDir, trashGlobPrefix);
63
+
64
+ if (fs.existsSync(isolateDir)) {
65
+ const trashDir = path.join(
66
+ trashParentDir,
67
+ `${trashGlobPrefix}${process.pid}-${randomBytes(4).toString("hex")}`,
68
+ );
69
+
70
+ try {
71
+ await fs.ensureDir(trashParentDir);
72
+ await fs.rename(isolateDir, trashDir);
73
+ log.debug("Moved existing isolate output directory to trash for cleanup");
74
+
75
+ /**
76
+ * Fire-and-forget. A concurrent writer can cause `ENOTEMPTY` or
77
+ * `EBUSY` here, but the logical state is already correct: the real
78
+ * `isolateDir` is gone. Any debris left behind will be swept on the
79
+ * next run.
80
+ */
81
+ void fs.remove(trashDir).catch((error: unknown) => {
82
+ log.debug(
83
+ "Background cleanup of trashed isolate directory did not complete:",
84
+ error instanceof Error ? error.message : String(error),
85
+ );
86
+ });
87
+ } catch (error) {
88
+ /**
89
+ * `rename` can fail with `EXDEV` if `trashParentDir` ends up on a
90
+ * different filesystem from `isolateDir`, or with `EPERM` on platforms
91
+ * that disallow renaming busy directories. Fall back to the original
92
+ * behaviour: a straight recursive delete. This preserves correctness
93
+ * at the cost of the race the rename was meant to avoid.
94
+ */
95
+ log.debug(
96
+ "Could not rename existing isolate output directory, falling back to recursive delete:",
97
+ error instanceof Error ? error.message : String(error),
98
+ );
99
+
100
+ await fs.remove(isolateDir);
101
+ }
102
+ }
103
+
104
+ await fs.ensureDir(isolateDir);
105
+ }
106
+
107
+ /**
108
+ * Build a stable name stem for the trash directory. When `trashParentDir` is
109
+ * the direct parent of `isolateDir` (the default), this is just the isolate
110
+ * dir's basename. When it isn't (e.g. callers placing trash outside the
111
+ * packed dir), include the relative path so multiple isolate dirs sharing
112
+ * the same trash parent don't collide in the sweep filter.
113
+ */
114
+ function buildTrashStem(trashParentDir: string, isolateDir: string): string {
115
+ const relative = path.relative(trashParentDir, isolateDir);
116
+
117
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
118
+ return path.basename(isolateDir);
119
+ }
120
+
121
+ return relative.split(path.sep).filter(Boolean).join("-");
122
+ }
123
+
124
+ /**
125
+ * Best-effort sweep of leftover trash directories matching this isolate dir's
126
+ * stem. Failures are swallowed: stale trash is debris, not state, and a
127
+ * subsequent run will get another chance to reap it.
128
+ */
129
+ async function sweepStaleTrash(parentDir: string, trashGlobPrefix: string) {
130
+ let entries: string[];
131
+ try {
132
+ entries = await fs.readdir(parentDir);
133
+ } catch {
134
+ /** Parent doesn't exist yet; nothing to sweep. */
135
+ return;
136
+ }
137
+
138
+ await Promise.all(
139
+ entries
140
+ .filter((entry) => entry.startsWith(trashGlobPrefix))
141
+ .map(async (entry) => {
142
+ await fs.remove(path.join(parentDir, entry)).catch(() => {
143
+ /** Best-effort. */
144
+ });
145
+ }),
146
+ );
147
+ }
@@ -0,0 +1,76 @@
1
+ import fs from "fs-extra";
2
+ import { createWriteStream } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { createGzip } from "node:zlib";
7
+ import { pack as packTar } from "tar-fs";
8
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
9
+ import { unpack } from "./unpack";
10
+
11
+ async function createTarball(srcDir: string, tarballPath: string) {
12
+ await pipeline(packTar(srcDir), createGzip(), createWriteStream(tarballPath));
13
+ }
14
+
15
+ describe("unpack", () => {
16
+ let tempDir: string;
17
+
18
+ beforeEach(async () => {
19
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "isolate-unpack-test-"));
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await fs.remove(tempDir);
24
+ });
25
+
26
+ it("extracts a valid gzipped tarball into the destination directory", async () => {
27
+ const srcDir = path.join(tempDir, "src");
28
+ await fs.ensureDir(path.join(srcDir, "nested"));
29
+ await fs.writeFile(path.join(srcDir, "root.txt"), "hello");
30
+ await fs.writeFile(path.join(srcDir, "nested", "leaf.txt"), "world");
31
+
32
+ const tarballPath = path.join(tempDir, "archive.tgz");
33
+ await createTarball(srcDir, tarballPath);
34
+
35
+ const unpackDir = path.join(tempDir, "out");
36
+ await unpack(tarballPath, unpackDir);
37
+
38
+ expect(await fs.readFile(path.join(unpackDir, "root.txt"), "utf8")).toBe(
39
+ "hello",
40
+ );
41
+ expect(
42
+ await fs.readFile(path.join(unpackDir, "nested", "leaf.txt"), "utf8"),
43
+ ).toBe("world");
44
+ });
45
+
46
+ it("rejects with an error when the tarball is truncated", async () => {
47
+ const srcDir = path.join(tempDir, "src");
48
+ await fs.ensureDir(srcDir);
49
+ await fs.writeFile(path.join(srcDir, "file.txt"), "a".repeat(8192));
50
+
51
+ const tarballPath = path.join(tempDir, "archive.tgz");
52
+ await createTarball(srcDir, tarballPath);
53
+
54
+ const truncatedPath = path.join(tempDir, "truncated.tgz");
55
+ const fullData = await fs.readFile(tarballPath);
56
+ await fs.writeFile(truncatedPath, fullData.subarray(0, 32));
57
+
58
+ const unpackDir = path.join(tempDir, "out");
59
+
60
+ /**
61
+ * Pre-fix, the gunzip error from a truncated archive surfaced as an
62
+ * unhandled stream error and crashed the process. With `pipeline` the
63
+ * same scenario must reject the returned promise so callers can handle
64
+ * it.
65
+ */
66
+ await expect(unpack(truncatedPath, unpackDir)).rejects.toThrow();
67
+ });
68
+
69
+ it("rejects when the source path does not exist", async () => {
70
+ const unpackDir = path.join(tempDir, "out");
71
+
72
+ await expect(
73
+ unpack(path.join(tempDir, "does-not-exist.tgz"), unpackDir),
74
+ ).rejects.toThrow();
75
+ });
76
+ });
@@ -1,13 +1,19 @@
1
- import fs from "fs-extra";
2
- import tar from "tar-fs";
3
- import { createGunzip } from "zlib";
1
+ import { createReadStream } from "node:fs";
2
+ import { pipeline } from "node:stream/promises";
3
+ import { createGunzip } from "node:zlib";
4
+ import { extract as extractTar } from "tar-fs";
4
5
 
6
+ /**
7
+ * Extract a gzipped tar archive into the given directory.
8
+ *
9
+ * Uses `stream/promises.pipeline` so that errors at any stage (file read,
10
+ * gunzip, tar extract) propagate as a rejected promise rather than crashing
11
+ * the process as unhandled stream errors.
12
+ */
5
13
  export async function unpack(filePath: string, unpackDir: string) {
6
- await new Promise<void>((resolve, reject) => {
7
- fs.createReadStream(filePath)
8
- .pipe(createGunzip())
9
- .pipe(tar.extract(unpackDir))
10
- .on("finish", () => resolve())
11
- .on("error", (err) => reject(err));
12
- });
14
+ await pipeline(
15
+ createReadStream(filePath),
16
+ createGunzip(),
17
+ extractTar(unpackDir),
18
+ );
13
19
  }
@@ -0,0 +1,105 @@
1
+ import fs from "fs-extra";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { waitForCompleteFile } from "./wait-for-complete-file";
6
+
7
+ describe("waitForCompleteFile", () => {
8
+ let tempDir: string;
9
+
10
+ beforeEach(async () => {
11
+ tempDir = await fs.mkdtemp(
12
+ path.join(os.tmpdir(), "isolate-wait-for-file-"),
13
+ );
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await fs.remove(tempDir);
18
+ });
19
+
20
+ it("resolves for a file that already exists with stable size", async () => {
21
+ const filePath = path.join(tempDir, "ready.bin");
22
+ await fs.writeFile(filePath, Buffer.alloc(1024, 0x42));
23
+
24
+ await expect(
25
+ waitForCompleteFile(filePath, { timeoutMs: 1000, pollMs: 20 }),
26
+ ).resolves.toBeUndefined();
27
+ });
28
+
29
+ it("waits until a file that appears late is written", async () => {
30
+ const filePath = path.join(tempDir, "late.bin");
31
+
32
+ const write = setTimeout(() => {
33
+ void fs.writeFile(filePath, Buffer.alloc(512, 0x01));
34
+ }, 60);
35
+
36
+ try {
37
+ await expect(
38
+ waitForCompleteFile(filePath, { timeoutMs: 1000, pollMs: 20 }),
39
+ ).resolves.toBeUndefined();
40
+
41
+ const { size } = await fs.stat(filePath);
42
+ expect(size).toBe(512);
43
+ } finally {
44
+ clearTimeout(write);
45
+ }
46
+ });
47
+
48
+ it("waits for size to stabilize when a file grows in chunks", async () => {
49
+ const filePath = path.join(tempDir, "growing.bin");
50
+ const pollMs = 100;
51
+ /**
52
+ * The total write window (`chunkIntervalMs * chunkCount` = 150ms) is
53
+ * shorter than two poll intervals, so the wait cannot observe two
54
+ * consecutive equal sizes until after the final chunk has landed. That
55
+ * is what guards against a false-positive return at a partial size, not
56
+ * the per-chunk spacing.
57
+ */
58
+ const chunkIntervalMs = 30;
59
+ const chunkCount = 5;
60
+
61
+ const writes = Array.from({ length: chunkCount }, (_, i) =>
62
+ setTimeout(
63
+ () => {
64
+ const op =
65
+ i === 0
66
+ ? fs.writeFile(filePath, Buffer.alloc(100, i + 1))
67
+ : fs.appendFile(filePath, Buffer.alloc(100, i + 1));
68
+ void op;
69
+ },
70
+ chunkIntervalMs * (i + 1),
71
+ ),
72
+ );
73
+
74
+ try {
75
+ await waitForCompleteFile(filePath, { timeoutMs: 2000, pollMs });
76
+
77
+ /**
78
+ * Returning before the file finished growing would leave size below
79
+ * chunkCount * 100. Observing the full size confirms the wait did not
80
+ * exit mid-write.
81
+ */
82
+ const { size } = await fs.stat(filePath);
83
+ expect(size).toBe(chunkCount * 100);
84
+ } finally {
85
+ writes.forEach((t) => clearTimeout(t));
86
+ }
87
+ });
88
+
89
+ it("rejects with a timeout error when the file never appears", async () => {
90
+ const filePath = path.join(tempDir, "missing.bin");
91
+
92
+ await expect(
93
+ waitForCompleteFile(filePath, { timeoutMs: 150, pollMs: 20 }),
94
+ ).rejects.toThrow(/Timed out after 150ms/);
95
+ });
96
+
97
+ it("rejects with a timeout error when the file stays empty", async () => {
98
+ const filePath = path.join(tempDir, "empty.bin");
99
+ await fs.writeFile(filePath, "");
100
+
101
+ await expect(
102
+ waitForCompleteFile(filePath, { timeoutMs: 150, pollMs: 20 }),
103
+ ).rejects.toThrow(/Timed out/);
104
+ });
105
+ });
@@ -0,0 +1,44 @@
1
+ import fs from "node:fs";
2
+
3
+ /**
4
+ * Wait until the given file exists and its size has stopped changing across
5
+ * two consecutive polls. Resolves once the file is considered fully written,
6
+ * rejects with a timeout error otherwise.
7
+ *
8
+ * This is a cheap proxy for "the writer has finished flushing" without
9
+ * inspecting file contents or relying on platform-specific signals. It is
10
+ * intended for cases where an external process (e.g. `pnpm pack`) may report
11
+ * completion before its output is fully visible on disk.
12
+ */
13
+ export async function waitForCompleteFile(
14
+ filePath: string,
15
+ { timeoutMs, pollMs }: { timeoutMs: number; pollMs: number },
16
+ ) {
17
+ const deadline = Date.now() + timeoutMs;
18
+ let lastSize = -1;
19
+
20
+ while (Date.now() < deadline) {
21
+ try {
22
+ const { size } = await fs.promises.stat(filePath);
23
+
24
+ if (size > 0 && size === lastSize) {
25
+ return;
26
+ }
27
+
28
+ lastSize = size;
29
+ } catch (error) {
30
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
31
+ throw error;
32
+ }
33
+ /** File not visible yet; keep polling. */
34
+ }
35
+
36
+ await new Promise<void>((resolve) => {
37
+ setTimeout(resolve, pollMs);
38
+ });
39
+ }
40
+
41
+ throw new Error(
42
+ `Timed out after ${timeoutMs}ms waiting for file to be written: ${filePath}`,
43
+ );
44
+ }
@@ -2,21 +2,20 @@ import fs from "fs-extra";
2
2
  import yaml from "yaml";
3
3
  import { getErrorMessage } from "./get-error-message";
4
4
 
5
- export function readTypedYamlSync<T>(filePath: string) {
5
+ /** @todo Add some zod validation maybe */
6
+ export function readTypedYamlSync(filePath: string): unknown {
6
7
  try {
7
8
  const rawContent = fs.readFileSync(filePath, "utf-8");
8
- const data = yaml.parse(rawContent);
9
- /** @todo Add some zod validation maybe */
10
- return data as T;
11
- } catch (err) {
9
+ return yaml.parse(rawContent) as unknown;
10
+ } catch (error) {
12
11
  throw new Error(
13
- `Failed to read YAML from ${filePath}: ${getErrorMessage(err)}`,
14
- { cause: err },
12
+ `Failed to read YAML from ${filePath}: ${getErrorMessage(error)}`,
13
+ { cause: error },
15
14
  );
16
15
  }
17
16
  }
18
17
 
19
- export function writeTypedYamlSync<T>(filePath: string, content: T) {
20
- /** @todo Add some zod validation maybe */
18
+ /** @todo Add some zod validation maybe */
19
+ export function writeTypedYamlSync(filePath: string, content: unknown) {
21
20
  fs.writeFileSync(filePath, yaml.stringify(content), "utf-8");
22
21
  }
@@ -1,7 +1,7 @@
1
1
  import { vi } from "vitest";
2
2
 
3
3
  /** Mock the logger for all tests to prevent console output during tests */
4
- vi.mock("~/lib/logger", () => ({
4
+ vi.mock("#/lib/logger", () => ({
5
5
  useLogger: () => ({
6
6
  debug: vi.fn(),
7
7
  info: vi.fn(),