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.
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{isolate-DTwgcMAN.mjs → isolate-DI3eUTci.mjs} +576 -242
- package/dist/isolate-DI3eUTci.mjs.map +1 -0
- package/dist/isolate-bin.mjs +5 -6
- package/dist/isolate-bin.mjs.map +1 -1
- package/package.json +23 -19
- package/src/get-internal-package-names.test.ts +1 -1
- package/src/get-internal-package-names.ts +2 -2
- package/src/isolate-bin.ts +5 -5
- package/src/isolate.ts +20 -17
- package/src/lib/config.test.ts +1 -1
- package/src/lib/config.ts +3 -3
- package/src/lib/lockfile/helpers/bun-lockfile.ts +21 -11
- package/src/lib/lockfile/helpers/generate-bun-lockfile.test.ts +3 -3
- package/src/lib/lockfile/helpers/generate-bun-lockfile.ts +7 -7
- package/src/lib/lockfile/helpers/generate-npm-lockfile.integration.test.ts +1 -5
- package/src/lib/lockfile/helpers/generate-npm-lockfile.test.ts +311 -16
- package/src/lib/lockfile/helpers/generate-npm-lockfile.ts +193 -22
- package/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts +2 -2
- package/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +6 -6
- package/src/lib/lockfile/helpers/generate-yarn-lockfile.ts +5 -5
- package/src/lib/lockfile/process-lockfile.test.ts +2 -2
- package/src/lib/manifest/helpers/adapt-internal-package-manifests.test.ts +3 -3
- package/src/lib/manifest/helpers/adapt-internal-package-manifests.ts +2 -2
- package/src/lib/manifest/helpers/adapt-manifest-internal-deps.ts +1 -1
- package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.test.ts +4 -4
- package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.ts +7 -7
- package/src/lib/manifest/helpers/resolve-catalog-dependencies.test.ts +410 -0
- package/src/lib/manifest/helpers/resolve-catalog-dependencies.ts +115 -27
- package/src/lib/manifest/io.ts +6 -2
- package/src/lib/manifest/validate-manifest.ts +2 -2
- package/src/lib/output/get-build-output-dir.ts +1 -1
- package/src/lib/output/pack-dependencies.ts +1 -1
- package/src/lib/output/process-build-output-files.ts +6 -17
- package/src/lib/package-manager/helpers/infer-from-files.ts +5 -5
- package/src/lib/package-manager/helpers/infer-from-manifest.ts +7 -8
- package/src/lib/package-manager/index.ts +1 -1
- package/src/lib/package-manager/names.ts +8 -10
- package/src/lib/patches/collect-installed-names-bun.test.ts +2 -2
- package/src/lib/patches/collect-installed-names-bun.ts +8 -8
- package/src/lib/patches/collect-installed-names-pnpm.test.ts +1 -1
- package/src/lib/patches/collect-installed-names-pnpm.ts +13 -12
- package/src/lib/patches/copy-patches.test.ts +5 -13
- package/src/lib/patches/copy-patches.ts +9 -9
- package/src/lib/patches/write-isolate-pnpm-workspace.test.ts +83 -3
- package/src/lib/patches/write-isolate-pnpm-workspace.ts +4 -4
- package/src/lib/registry/collect-reachable-package-names.test.ts +1 -1
- package/src/lib/registry/create-packages-registry.ts +34 -31
- package/src/lib/registry/helpers/find-packages-globs.ts +23 -19
- package/src/lib/registry/list-internal-packages.test.ts +2 -2
- package/src/lib/types.ts +2 -2
- package/src/lib/utils/filter-patched-dependencies.test.ts +1 -1
- package/src/lib/utils/filter-patched-dependencies.ts +2 -2
- package/src/lib/utils/get-dirname.ts +1 -1
- package/src/lib/utils/index.ts +1 -1
- package/src/lib/utils/json.ts +12 -14
- package/src/lib/utils/pack.ts +32 -22
- package/src/lib/utils/reset-isolate-dir.test.ts +165 -0
- package/src/lib/utils/reset-isolate-dir.ts +147 -0
- package/src/lib/utils/unpack.test.ts +76 -0
- package/src/lib/utils/unpack.ts +16 -10
- package/src/lib/utils/wait-for-complete-file.test.ts +105 -0
- package/src/lib/utils/wait-for-complete-file.ts +44 -0
- package/src/lib/utils/yaml.ts +8 -9
- package/src/testing/setup.ts +1 -1
- 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
|
+
});
|
package/src/lib/utils/unpack.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
}
|
package/src/lib/utils/yaml.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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(
|
|
14
|
-
{ cause:
|
|
12
|
+
`Failed to read YAML from ${filePath}: ${getErrorMessage(error)}`,
|
|
13
|
+
{ cause: error },
|
|
15
14
|
);
|
|
16
15
|
}
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
}
|
package/src/testing/setup.ts
CHANGED