@packall/core 0.0.1 → 0.2.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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/archive-6sqTn1C4.js +274 -0
  4. package/dist/archive-6sqTn1C4.js.map +1 -0
  5. package/dist/archive.d.ts +64 -0
  6. package/dist/archive.d.ts.map +1 -0
  7. package/dist/{Bundle.d.ts → bundle.d.ts} +56 -38
  8. package/dist/bundle.d.ts.map +1 -0
  9. package/dist/{DependencyRange.d.ts → dependency-range.d.ts} +2 -2
  10. package/dist/dependency-range.d.ts.map +1 -0
  11. package/dist/{Download.d.ts → download.d.ts} +9 -8
  12. package/dist/download.d.ts.map +1 -0
  13. package/dist/enums/artifact-kind.d.ts +8 -0
  14. package/dist/enums/artifact-kind.d.ts.map +1 -0
  15. package/dist/enums/edge-kind.d.ts +29 -0
  16. package/dist/enums/edge-kind.d.ts.map +1 -0
  17. package/dist/enums/input-file-kind.d.ts +12 -0
  18. package/dist/enums/input-file-kind.d.ts.map +1 -0
  19. package/dist/enums/layout.d.ts +23 -0
  20. package/dist/enums/layout.d.ts.map +1 -0
  21. package/dist/enums/lockfile-format.d.ts +9 -0
  22. package/dist/enums/lockfile-format.d.ts.map +1 -0
  23. package/dist/enums/phase.d.ts +11 -0
  24. package/dist/enums/phase.d.ts.map +1 -0
  25. package/dist/{Errors.d.ts → errors.d.ts} +49 -7
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/index.d.ts +46 -28
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2121 -1060
  30. package/dist/index.js.map +1 -1
  31. package/dist/input-file.d.ts +77 -0
  32. package/dist/input-file.d.ts.map +1 -0
  33. package/dist/integrity.d.ts +80 -0
  34. package/dist/integrity.d.ts.map +1 -0
  35. package/dist/layout.d.ts +87 -0
  36. package/dist/layout.d.ts.map +1 -0
  37. package/dist/locked-resolve.d.ts +47 -0
  38. package/dist/locked-resolve.d.ts.map +1 -0
  39. package/dist/lockfile/bun.d.ts +3 -0
  40. package/dist/lockfile/bun.d.ts.map +1 -0
  41. package/dist/lockfile/detect.d.ts +31 -0
  42. package/dist/lockfile/detect.d.ts.map +1 -0
  43. package/dist/lockfile/json.d.ts +23 -0
  44. package/dist/lockfile/json.d.ts.map +1 -0
  45. package/dist/lockfile/names.d.ts +16 -0
  46. package/dist/lockfile/names.d.ts.map +1 -0
  47. package/dist/lockfile/npm.d.ts +3 -0
  48. package/dist/lockfile/npm.d.ts.map +1 -0
  49. package/dist/lockfile/parse.d.ts +23 -0
  50. package/dist/lockfile/parse.d.ts.map +1 -0
  51. package/dist/lockfile/pnpm.d.ts +20 -0
  52. package/dist/lockfile/pnpm.d.ts.map +1 -0
  53. package/dist/lockfile/tree-builder.d.ts +70 -0
  54. package/dist/lockfile/tree-builder.d.ts.map +1 -0
  55. package/dist/lockfile/types.d.ts +70 -0
  56. package/dist/lockfile/types.d.ts.map +1 -0
  57. package/dist/{Manifest.d.ts → manifest.d.ts} +7 -15
  58. package/dist/manifest.d.ts.map +1 -0
  59. package/dist/node/archiver.d.ts +5 -0
  60. package/dist/node/archiver.d.ts.map +1 -0
  61. package/dist/node/index.d.ts +13 -0
  62. package/dist/node/index.d.ts.map +1 -0
  63. package/dist/node/index.js +55 -0
  64. package/dist/node/index.js.map +1 -0
  65. package/dist/{Options.d.ts → options.d.ts} +12 -30
  66. package/dist/options.d.ts.map +1 -0
  67. package/dist/{Platform.d.ts → platform.d.ts} +14 -19
  68. package/dist/platform.d.ts.map +1 -0
  69. package/dist/{Progress.d.ts → progress.d.ts} +10 -9
  70. package/dist/progress.d.ts.map +1 -0
  71. package/dist/{Registry.d.ts → registry.d.ts} +42 -44
  72. package/dist/registry.d.ts.map +1 -0
  73. package/dist/{Resolve.d.ts → resolve.d.ts} +25 -26
  74. package/dist/resolve.d.ts.map +1 -0
  75. package/dist/schemas/lenient.d.ts +38 -0
  76. package/dist/schemas/lenient.d.ts.map +1 -0
  77. package/dist/schemas/package-json.d.ts +44 -0
  78. package/dist/schemas/package-json.d.ts.map +1 -0
  79. package/dist/{Spec.d.ts → spec.d.ts} +4 -4
  80. package/dist/spec.d.ts.map +1 -0
  81. package/dist/types/value-of.d.ts +9 -0
  82. package/dist/types/value-of.d.ts.map +1 -0
  83. package/dist/utils/is-record.d.ts +8 -0
  84. package/dist/utils/is-record.d.ts.map +1 -0
  85. package/package.json +13 -11
  86. package/src/archive.ts +95 -0
  87. package/src/bundle.ts +736 -0
  88. package/src/dependency-range.ts +121 -0
  89. package/src/download.ts +155 -0
  90. package/src/enums/artifact-kind.ts +9 -0
  91. package/src/enums/edge-kind.ts +32 -0
  92. package/src/enums/input-file-kind.ts +13 -0
  93. package/src/enums/layout.ts +25 -0
  94. package/src/enums/lockfile-format.ts +10 -0
  95. package/src/enums/phase.ts +12 -0
  96. package/src/errors.ts +300 -0
  97. package/src/index.ts +146 -35
  98. package/src/input-file.ts +324 -0
  99. package/src/integrity.ts +165 -0
  100. package/src/layout.ts +137 -0
  101. package/src/locked-resolve.ts +471 -0
  102. package/src/lockfile/bun.ts +214 -0
  103. package/src/lockfile/detect.ts +80 -0
  104. package/src/lockfile/json.ts +131 -0
  105. package/src/lockfile/names.ts +20 -0
  106. package/src/lockfile/npm.ts +328 -0
  107. package/src/lockfile/parse.ts +56 -0
  108. package/src/lockfile/pnpm.ts +230 -0
  109. package/src/lockfile/tree-builder.ts +166 -0
  110. package/src/lockfile/types.ts +74 -0
  111. package/src/manifest.ts +129 -0
  112. package/src/node/archiver.ts +85 -0
  113. package/src/node/index.ts +13 -0
  114. package/src/options.ts +103 -0
  115. package/src/platform.ts +178 -0
  116. package/src/progress.ts +105 -0
  117. package/src/registry.ts +116 -0
  118. package/src/resolve.ts +560 -0
  119. package/src/schemas/lenient.ts +130 -0
  120. package/src/schemas/package-json.ts +28 -0
  121. package/src/spec.ts +182 -0
  122. package/src/types/value-of.ts +8 -0
  123. package/src/utils/is-record.ts +8 -0
  124. package/dist/Archive.d.ts +0 -32
  125. package/dist/Archive.d.ts.map +0 -1
  126. package/dist/Bundle.d.ts.map +0 -1
  127. package/dist/DependencyRange.d.ts.map +0 -1
  128. package/dist/Download.d.ts.map +0 -1
  129. package/dist/Errors.d.ts.map +0 -1
  130. package/dist/InputFile.d.ts +0 -46
  131. package/dist/InputFile.d.ts.map +0 -1
  132. package/dist/Integrity.d.ts +0 -48
  133. package/dist/Integrity.d.ts.map +0 -1
  134. package/dist/Layout.d.ts +0 -53
  135. package/dist/Layout.d.ts.map +0 -1
  136. package/dist/Manifest.d.ts.map +0 -1
  137. package/dist/Options.d.ts.map +0 -1
  138. package/dist/Platform.d.ts.map +0 -1
  139. package/dist/Progress.d.ts.map +0 -1
  140. package/dist/Registry.d.ts.map +0 -1
  141. package/dist/Resolve.d.ts.map +0 -1
  142. package/dist/Spec.d.ts.map +0 -1
  143. package/src/Archive.ts +0 -92
  144. package/src/Bundle.ts +0 -570
  145. package/src/DependencyRange.ts +0 -115
  146. package/src/Download.ts +0 -132
  147. package/src/Errors.ts +0 -216
  148. package/src/InputFile.ts +0 -229
  149. package/src/Integrity.ts +0 -122
  150. package/src/Layout.ts +0 -103
  151. package/src/Manifest.ts +0 -133
  152. package/src/Options.ts +0 -123
  153. package/src/Platform.ts +0 -182
  154. package/src/Progress.ts +0 -106
  155. package/src/Registry.ts +0 -121
  156. package/src/Resolve.ts +0 -528
  157. package/src/Spec.ts +0 -186
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Classification of the right-hand side of a dependency entry.
3
+ *
4
+ * `"dependencies": { "x": "^1.0.0" }` is the easy case. Real package.json files
5
+ * also contain aliases, git URLs, tarball URLs, `file:` links and
6
+ * `workspace:` protocols — none of which can be fetched from a registry.
7
+ *
8
+ * Rather than crashing or silently producing a bundle that will not install,
9
+ * we classify each edge and let the caller report precisely what was skipped
10
+ * and why.
11
+ */
12
+ import semver from "semver";
13
+
14
+ import type { Selector } from "./spec.js";
15
+
16
+ /** What a dependency edge points at. */
17
+ export type DependencyTarget =
18
+ /** A normal registry dependency. `name` may differ from the key under an alias. */
19
+ | {
20
+ readonly _tag: "Registry";
21
+ readonly name: string;
22
+ readonly selector: Selector;
23
+ /** Set when the edge is an `npm:` alias, e.g. `"lodash4": "npm:lodash@^4"`. */
24
+ readonly aliasOf?: string | undefined;
25
+ }
26
+ /** Something we cannot fetch from a registry. */
27
+ | {
28
+ readonly _tag: "Unsupported";
29
+ readonly name: string;
30
+ readonly raw: string;
31
+ readonly reason: string;
32
+ };
33
+
34
+ const UNSUPPORTED_PROTOCOLS: ReadonlyArray<readonly [string, string]> = [
35
+ ["file:", "local file path"],
36
+ ["link:", "local link"],
37
+ ["workspace:", "workspace protocol"],
38
+ ["portal:", "portal protocol"],
39
+ ["patch:", "patch protocol"],
40
+ ["git:", "git dependency"],
41
+ ["git+", "git dependency"],
42
+ ["github:", "GitHub shorthand"],
43
+ ["gitlab:", "GitLab shorthand"],
44
+ ["bitbucket:", "Bitbucket shorthand"],
45
+ ["http:", "remote tarball URL"],
46
+ ["https:", "remote tarball URL"],
47
+ ];
48
+
49
+ /**
50
+ * Classifies one `name -> range` entry.
51
+ *
52
+ * An empty range, `*`, and `latest` all mean "any published version"; npm
53
+ * treats them interchangeably and so do we.
54
+ */
55
+ export const parseDependencyTarget = (name: string, raw: string): DependencyTarget => {
56
+ const text = raw.trim();
57
+
58
+ if (text.length === 0 || text === "*" || text === "x" || text === "latest") {
59
+ return { _tag: "Registry", name, selector: { _tag: "Range", range: "*" } };
60
+ }
61
+
62
+ if (text.startsWith("npm:")) {
63
+ return parseAlias(name, text);
64
+ }
65
+
66
+ for (const [prefix, reason] of UNSUPPORTED_PROTOCOLS) {
67
+ if (text.startsWith(prefix)) {
68
+ return { _tag: "Unsupported", name, raw: text, reason };
69
+ }
70
+ }
71
+
72
+ // Bare `owner/repo` is GitHub shorthand, but only when it is not a valid
73
+ // range — `>=1.0.0/2` is not a thing, so this ordering is safe.
74
+ if (semver.validRange(text, { loose: true }) === null && /^[\w.-]+\/[\w.-]+/.test(text)) {
75
+ return { _tag: "Unsupported", name, raw: text, reason: "git shorthand" };
76
+ }
77
+
78
+ const exact = semver.valid(text, { loose: true });
79
+ if (exact !== null) {
80
+ return { _tag: "Registry", name, selector: { _tag: "Exact", version: exact } };
81
+ }
82
+
83
+ if (semver.validRange(text, { loose: true }) !== null) {
84
+ return { _tag: "Registry", name, selector: { _tag: "Range", range: text } };
85
+ }
86
+
87
+ // Whatever is left is most plausibly a dist-tag published by a private
88
+ // registry, e.g. `"@acme/sdk": "stable"`.
89
+ if (/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(text)) {
90
+ return { _tag: "Registry", name, selector: { _tag: "Tag", tag: text } };
91
+ }
92
+
93
+ return { _tag: "Unsupported", name, raw: text, reason: "unrecognised version specifier" };
94
+ };
95
+
96
+ /**
97
+ * Parses `npm:<name>[@<range>]`.
98
+ *
99
+ * The nested name may itself be scoped, so the `@` split has the same
100
+ * "not at index 0" caveat as top-level spec parsing.
101
+ */
102
+ const parseAlias = (key: string, text: string): DependencyTarget => {
103
+ const body = text.slice("npm:".length);
104
+ if (body.length === 0) {
105
+ return { _tag: "Unsupported", name: key, raw: text, reason: "empty npm: alias" };
106
+ }
107
+
108
+ const at = body.lastIndexOf("@");
109
+ const target = at > 0 ? body.slice(0, at) : body;
110
+ const rangeText = at > 0 ? body.slice(at + 1) : "";
111
+
112
+ if (target.length === 0) {
113
+ return { _tag: "Unsupported", name: key, raw: text, reason: "empty npm: alias target" };
114
+ }
115
+
116
+ const inner = parseDependencyTarget(target, rangeText);
117
+ if (inner._tag === "Unsupported") {
118
+ return { ...inner, name: key, raw: text };
119
+ }
120
+ return { _tag: "Registry", name: target, selector: inner.selector, aliasOf: key };
121
+ };
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Fetching tarballs into the staging tree.
3
+ *
4
+ * Downloads run concurrently, every one is checksum-verified before it is
5
+ * written, and the destination is always the scoped temp directory — nothing
6
+ * lands next to the user's own files.
7
+ */
8
+ import type * as Crypto from "effect/Crypto";
9
+ import * as Effect from "effect/Effect";
10
+ import * as FileSystem from "effect/FileSystem";
11
+ import * as Path from "effect/Path";
12
+ import type { PlatformError } from "effect/PlatformError";
13
+
14
+ import { Phase } from "./enums/phase.js";
15
+ import type { BundlerError } from "./errors.js";
16
+ import { IntegrityError, OutputError } from "./errors.js";
17
+ import * as Integrity from "./integrity.js";
18
+ import { packagePath } from "./layout.js";
19
+ import * as Progress from "./progress.js";
20
+ import { Registry } from "./registry.js";
21
+ import type { ResolvedPackage } from "./resolve.js";
22
+
23
+ /** What a completed download run produced. */
24
+ export type DownloadReport = {
25
+ /** `name@version` -> bytes written. */
26
+ readonly sizes: ReadonlyMap<string, number>;
27
+ /** Packages the registry advertised no usable checksum for. */
28
+ readonly unverified: ReadonlyArray<string>;
29
+ readonly totalBytes: number;
30
+ };
31
+
32
+ /**
33
+ * Downloads every package into `destDir`, laid out the way a registry serves
34
+ * them.
35
+ *
36
+ * `verifyIntegrity` defaulting to on is deliberate: a corrupt tarball that
37
+ * makes it into a corporate registry is far more expensive than a failed run.
38
+ */
39
+ export const downloadAll = (
40
+ packages: ReadonlyArray<ResolvedPackage>,
41
+ destDir: string,
42
+ options: { readonly concurrency: number; readonly verifyIntegrity: boolean },
43
+ ): Effect.Effect<
44
+ DownloadReport,
45
+ BundlerError | PlatformError,
46
+ Registry | Progress.Progress | FileSystem.FileSystem | Path.Path | Crypto.Crypto
47
+ > =>
48
+ Effect.gen(function* () {
49
+ const fs = yield* FileSystem.FileSystem;
50
+ const path = yield* Path.Path;
51
+ const registry = yield* Registry;
52
+
53
+ yield* Progress.emit({
54
+ _tag: "PhaseStarted",
55
+ phase: Phase.Download,
56
+ total: packages.length,
57
+ });
58
+
59
+ let completed = 0;
60
+ const sizes = new Map<string, number>();
61
+ const unverified: Array<string> = [];
62
+
63
+ const results = yield* Effect.forEach(
64
+ packages,
65
+ (pkg) =>
66
+ Effect.gen(function* () {
67
+ const key = `${pkg.name}@${pkg.version}`;
68
+
69
+ yield* Progress.emit({
70
+ _tag: "DownloadStarted",
71
+ name: pkg.name,
72
+ version: pkg.version,
73
+ });
74
+
75
+ const bytes = yield* registry.download(pkg.manifest);
76
+
77
+ if (options.verifyIntegrity) {
78
+ const result = yield* Integrity.verify(bytes, pkg.manifest.dist);
79
+ if (result._tag === "Mismatch") {
80
+ return yield* Effect.fail(
81
+ new IntegrityError(
82
+ pkg.name,
83
+ pkg.version,
84
+ result.expected,
85
+ result.actual,
86
+ ),
87
+ );
88
+ }
89
+ if (result._tag === "Unverifiable") {
90
+ yield* Progress.emit({
91
+ _tag: "Warning",
92
+ message: `${key} could not be verified (${result.reason})`,
93
+ });
94
+ }
95
+ }
96
+
97
+ const verifiable =
98
+ (pkg.manifest.dist.integrity?.length ?? 0) > 0 ||
99
+ (pkg.manifest.dist.shasum?.length ?? 0) > 0;
100
+
101
+ const relative = packagePath(pkg.name, pkg.version);
102
+ const target = path.join(destDir, ...relative.split("/"));
103
+
104
+ yield* fs
105
+ .makeDirectory(path.dirname(target), { recursive: true })
106
+ .pipe(
107
+ Effect.mapError(
108
+ (cause) =>
109
+ new OutputError(
110
+ path.dirname(target),
111
+ "could not create directory",
112
+ { cause },
113
+ ),
114
+ ),
115
+ );
116
+ yield* fs
117
+ .writeFile(target, bytes)
118
+ .pipe(
119
+ Effect.mapError(
120
+ (cause) =>
121
+ new OutputError(target, "could not write tarball", { cause }),
122
+ ),
123
+ );
124
+
125
+ // Counting here rather than inside the concurrent body would be
126
+ // wrong; each fiber increments as it finishes, which is what makes
127
+ // the progress counter monotonic.
128
+ completed += 1;
129
+ yield* Progress.emit({
130
+ _tag: "DownloadCompleted",
131
+ name: pkg.name,
132
+ version: pkg.version,
133
+ bytes: bytes.byteLength,
134
+ completedCount: completed,
135
+ totalCount: packages.length,
136
+ });
137
+
138
+ return { key, bytes: bytes.byteLength, verifiable } as const;
139
+ }),
140
+ { concurrency: options.concurrency },
141
+ );
142
+
143
+ for (const result of results) {
144
+ sizes.set(result.key, result.bytes);
145
+ if (!result.verifiable) unverified.push(result.key);
146
+ }
147
+
148
+ yield* Progress.emit({ _tag: "PhaseCompleted", phase: Phase.Download });
149
+
150
+ return {
151
+ sizes,
152
+ unverified,
153
+ totalBytes: results.reduce((sum, result) => sum + result.bytes, 0),
154
+ };
155
+ });
@@ -0,0 +1,9 @@
1
+ import type { ValueOf } from "../types/value-of.js";
2
+
3
+ /** What a bundle run wrote to the output directory. */
4
+ export const ArtifactKind = {
5
+ Archive: "archive",
6
+ Directory: "directory",
7
+ } as const;
8
+
9
+ export type ArtifactKind = ValueOf<typeof ArtifactKind>;
@@ -0,0 +1,32 @@
1
+ import type { ValueOf } from "../types/value-of.js";
2
+
3
+ /**
4
+ * Why one package depends on another.
5
+ *
6
+ * `EdgeKind` and `LockedRootKind` are one concept in two parts, which is why
7
+ * they share a file: `Dev` exists only at the top level, because lockfiles
8
+ * record dev dependencies for the project alone — a transitive devDependency is
9
+ * never installed, so it is never locked.
10
+ */
11
+ export const EdgeKind = {
12
+ Prod: "prod",
13
+ Optional: "optional",
14
+ Peer: "peer",
15
+ } as const;
16
+
17
+ export type EdgeKind = ValueOf<typeof EdgeKind>;
18
+
19
+ /** The kind that only a direct dependency of the locked project can have. */
20
+ export const DirectOnlyKind = {
21
+ Dev: "dev",
22
+ } as const;
23
+
24
+ export type DirectOnlyKind = ValueOf<typeof DirectOnlyKind>;
25
+
26
+ /** Why a direct dependency of the locked project is a root. */
27
+ export const LockedRootKind = {
28
+ ...EdgeKind,
29
+ ...DirectOnlyKind,
30
+ } as const;
31
+
32
+ export type LockedRootKind = ValueOf<typeof LockedRootKind>;
@@ -0,0 +1,13 @@
1
+ import type { ValueOf } from "../types/value-of.js";
2
+
3
+ /** Which of the three shapes a `--file` input turned out to be. */
4
+ export const InputFileKind = {
5
+ /** Bundle everything a package.json depends on. */
6
+ PackageJson: "package.json",
7
+ /** Bundle the exact versions a lockfile pins. */
8
+ Lockfile: "lockfile",
9
+ /** A newline-delimited list of specs. */
10
+ List: "list",
11
+ } as const;
12
+
13
+ export type InputFileKind = ValueOf<typeof InputFileKind>;
@@ -0,0 +1,25 @@
1
+ import type { ValueOf } from "../types/value-of.js";
2
+
3
+ /** How the resulting tarball(s) are shaped. */
4
+ export const Layout = {
5
+ /**
6
+ * One tarball per requested spec, each carrying its own complete dependency
7
+ * closure. Shared dependencies repeat across tarballs, so the total is
8
+ * larger — but each one imports, rolls back and hands off independently.
9
+ */
10
+ PerSpec: "per-spec",
11
+ /**
12
+ * One tarball for everything, with the union of all closures deduplicated.
13
+ * Smallest output and a single import step; all-or-nothing to roll back.
14
+ */
15
+ Single: "single",
16
+ /**
17
+ * No archive at all — just the npm-layout directory tree, ready for
18
+ * `jf rt upload` or rsync.
19
+ */
20
+ Dir: "dir",
21
+ } as const;
22
+
23
+ export type Layout = ValueOf<typeof Layout>;
24
+
25
+ export const Layouts: ReadonlyArray<Layout> = Object.values(Layout);
@@ -0,0 +1,10 @@
1
+ import type { ValueOf } from "../types/value-of.js";
2
+
3
+ /** Which package manager wrote a lockfile. */
4
+ export const LockfileFormat = {
5
+ Npm: "npm",
6
+ Pnpm: "pnpm",
7
+ Bun: "bun",
8
+ } as const;
9
+
10
+ export type LockfileFormat = ValueOf<typeof LockfileFormat>;
@@ -0,0 +1,12 @@
1
+ import type { ValueOf } from "../types/value-of.js";
2
+
3
+ /** The phases a bundle run moves through, in order. */
4
+ export const Phase = {
5
+ Preflight: "preflight",
6
+ Resolve: "resolve",
7
+ Download: "download",
8
+ Archive: "archive",
9
+ Done: "done",
10
+ } as const;
11
+
12
+ export type Phase = ValueOf<typeof Phase>;
package/src/errors.ts ADDED
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Failure types for the bundler.
3
+ *
4
+ * These are deliberately plain classes carrying a literal `_tag` rather than
5
+ * `Data.TaggedError`. They work with `Effect.catchTag` / `Effect.catchTags`
6
+ * exactly the same way, and they keep the public error surface independent of
7
+ * any single Effect release — useful while Effect v4 is still in beta.
8
+ *
9
+ * Every error carries enough context to be actionable without a stack trace,
10
+ * because the primary consumer is somebody staring at a terminal on a
11
+ * locked-down network trying to work out why a download did not happen.
12
+ */
13
+
14
+ /** Discriminant union of every failure the bundler can produce. */
15
+ export type BundlerError =
16
+ | InvalidSpecError
17
+ | InvalidInputFileError
18
+ | LockfileError
19
+ | LockfileIncompleteError
20
+ | LockfileOutOfDateError
21
+ | RegistryUnreachableError
22
+ | PackageNotFoundError
23
+ | VersionNotFoundError
24
+ | NoMatchingVersionsError
25
+ | RegistryResponseError
26
+ | AuthenticationError
27
+ | IntegrityError
28
+ | ArchiveError
29
+ | OutputError;
30
+
31
+ abstract class BundlerErrorBase extends Error {
32
+ abstract readonly _tag: string;
33
+ constructor(message: string, options?: ErrorOptions) {
34
+ super(message, options);
35
+ this.name = new.target.name;
36
+ }
37
+ }
38
+
39
+ /** A package spec on the command line or in an input file could not be parsed. */
40
+ export class InvalidSpecError extends BundlerErrorBase {
41
+ readonly _tag = "InvalidSpecError" as const;
42
+ readonly spec: string;
43
+ readonly reason: string;
44
+ constructor(spec: string, reason: string) {
45
+ super(`Invalid package spec ${JSON.stringify(spec)}: ${reason}`);
46
+ this.spec = spec;
47
+ this.reason = reason;
48
+ }
49
+ }
50
+
51
+ /** `--file` pointed at something that is neither a usable package.json nor a spec list. */
52
+ export class InvalidInputFileError extends BundlerErrorBase {
53
+ readonly _tag = "InvalidInputFileError" as const;
54
+ readonly path: string;
55
+ readonly reason: string;
56
+ constructor(path: string, reason: string, options?: { cause?: unknown }) {
57
+ super(`Cannot read specs from ${path}: ${reason}`, options);
58
+ this.path = path;
59
+ this.reason = reason;
60
+ }
61
+ }
62
+
63
+ /** A lockfile could not be read, or is in a format we do not support. */
64
+ export class LockfileError extends BundlerErrorBase {
65
+ readonly _tag = "LockfileError" as const;
66
+ readonly path: string;
67
+ readonly reason: string;
68
+ constructor(path: string, reason: string, options?: { cause?: unknown }) {
69
+ // Opens with "Lockfile" so the renderer's tag prefix does not stutter, and
70
+ // because not every reason is a read failure — "does not cover this
71
+ // package" is about a file that read perfectly well.
72
+ super(`Lockfile ${path}: ${reason}`, options);
73
+ this.path = path;
74
+ this.reason = reason;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The lockfile parsed, but does not pin everything the bundle needs.
80
+ *
81
+ * Falling back to a range here would defeat the entire point of reading a
82
+ * lockfile — you would get a bundle that is *mostly* what CI installed, with no
83
+ * indication of which parts were guessed. So this is fatal, and it names every
84
+ * gap at once so one `npm install` fixes all of them.
85
+ */
86
+ export class LockfileIncompleteError extends BundlerErrorBase {
87
+ readonly _tag = "LockfileIncompleteError" as const;
88
+ readonly path: string;
89
+ readonly missing: ReadonlyArray<string>;
90
+ constructor(path: string, missing: ReadonlyArray<string>, detail: string, remedy: string) {
91
+ const shown = missing
92
+ .slice(0, 10)
93
+ .map((entry) => ` ${entry}`)
94
+ .join("\n");
95
+ const rest = missing.length > 10 ? `\n … and ${missing.length - 10} more` : "";
96
+ super(
97
+ `${path} is incomplete — ${detail}:\n${shown}${rest}\n` +
98
+ ` ${remedy}\n` +
99
+ ` Or pass --lockfile off to resolve version ranges fresh instead.`,
100
+ );
101
+ this.path = path;
102
+ this.missing = missing;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * The lockfile pins versions the registry no longer serves.
108
+ *
109
+ * Unpublished, or never mirrored into a private registry. Either way the
110
+ * bundle cannot be built as specified, and quietly substituting a nearby
111
+ * version would produce exactly the mismatch this feature exists to prevent.
112
+ */
113
+ export class LockfileOutOfDateError extends BundlerErrorBase {
114
+ readonly _tag = "LockfileOutOfDateError" as const;
115
+ readonly path: string;
116
+ readonly registry: string;
117
+ readonly missing: ReadonlyArray<{ readonly name: string; readonly version: string }>;
118
+ constructor(
119
+ path: string,
120
+ registry: string,
121
+ missing: ReadonlyArray<{
122
+ readonly name: string;
123
+ readonly version: string;
124
+ readonly detail: string;
125
+ }>,
126
+ ) {
127
+ const shown = missing
128
+ .slice(0, 10)
129
+ .map((entry) => ` ${entry.name}@${entry.version} — ${entry.detail}`)
130
+ .join("\n");
131
+ const rest = missing.length > 10 ? `\n … and ${missing.length - 10} more` : "";
132
+ super(
133
+ `${path} pins ${missing.length} version${missing.length === 1 ? "" : "s"} ` +
134
+ `that ${registry} does not serve:\n${shown}${rest}\n` +
135
+ ` Refresh the lockfile against this registry, or pass --lockfile off to\n` +
136
+ ` resolve version ranges fresh instead.`,
137
+ );
138
+ this.path = path;
139
+ this.registry = registry;
140
+ this.missing = missing.map(({ name, version }) => ({ name, version }));
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The registry could not be reached at all — DNS failure, refused connection,
146
+ * proxy blackhole, or a preflight that timed out.
147
+ *
148
+ * This is the error that fixes "hangs forever with no network": we surface it
149
+ * quickly and say which host we could not reach.
150
+ */
151
+ export class RegistryUnreachableError extends BundlerErrorBase {
152
+ readonly _tag = "RegistryUnreachableError" as const;
153
+ readonly registry: string;
154
+ readonly timeoutMs: number | undefined;
155
+ constructor(
156
+ registry: string,
157
+ detail: string,
158
+ options?: { cause?: unknown; timeoutMs?: number },
159
+ ) {
160
+ super(
161
+ `Cannot reach registry ${registry}: ${detail}.\n` +
162
+ ` Check your network connection, VPN, and the proxy settings in your .npmrc.`,
163
+ options,
164
+ );
165
+ this.registry = registry;
166
+ this.timeoutMs = options?.timeoutMs;
167
+ }
168
+ }
169
+
170
+ /** The registry answered, but has never heard of this package. */
171
+ export class PackageNotFoundError extends BundlerErrorBase {
172
+ readonly _tag = "PackageNotFoundError" as const;
173
+ readonly packageName: string;
174
+ readonly registry: string;
175
+ constructor(packageName: string, registry: string) {
176
+ super(`Package "${packageName}" was not found on ${registry}`);
177
+ this.packageName = packageName;
178
+ this.registry = registry;
179
+ }
180
+ }
181
+
182
+ /** The package exists but the exact version requested does not. */
183
+ export class VersionNotFoundError extends BundlerErrorBase {
184
+ readonly _tag = "VersionNotFoundError" as const;
185
+ readonly packageName: string;
186
+ readonly version: string;
187
+ readonly available: ReadonlyArray<string>;
188
+ constructor(packageName: string, version: string, available: ReadonlyArray<string>) {
189
+ const tail = available.slice(-5).join(", ");
190
+ super(
191
+ `${packageName}@${version} does not exist.` +
192
+ (tail.length > 0 ? ` Most recent published versions: ${tail}` : ""),
193
+ );
194
+ this.packageName = packageName;
195
+ this.version = version;
196
+ this.available = available;
197
+ }
198
+ }
199
+
200
+ /** A range (or dist-tag) matched nothing that is actually published. */
201
+ export class NoMatchingVersionsError extends BundlerErrorBase {
202
+ readonly _tag = "NoMatchingVersionsError" as const;
203
+ readonly packageName: string;
204
+ readonly selector: string;
205
+ readonly available: ReadonlyArray<string>;
206
+ constructor(packageName: string, selector: string, available: ReadonlyArray<string>) {
207
+ const tail = available.slice(-5).join(", ");
208
+ super(
209
+ `No published version of ${packageName} satisfies "${selector}".` +
210
+ (tail.length > 0 ? ` Most recent published versions: ${tail}` : ""),
211
+ );
212
+ this.packageName = packageName;
213
+ this.selector = selector;
214
+ this.available = available;
215
+ }
216
+ }
217
+
218
+ /** The registry responded, but with something we cannot use. */
219
+ export class RegistryResponseError extends BundlerErrorBase {
220
+ readonly _tag = "RegistryResponseError" as const;
221
+ readonly url: string;
222
+ readonly status: number | undefined;
223
+ constructor(url: string, detail: string, options?: { cause?: unknown; status?: number }) {
224
+ super(`Unexpected response from ${url}: ${detail}`, options);
225
+ this.url = url;
226
+ this.status = options?.status;
227
+ }
228
+ }
229
+
230
+ /** 401/403 — almost always a missing or stale token in `.npmrc`. */
231
+ export class AuthenticationError extends BundlerErrorBase {
232
+ readonly _tag = "AuthenticationError" as const;
233
+ readonly registry: string;
234
+ readonly status: number;
235
+ constructor(registry: string, status: number, packageName?: string) {
236
+ super(
237
+ `Registry ${registry} rejected the request with HTTP ${status}` +
238
+ (packageName ? ` while fetching "${packageName}"` : "") +
239
+ `.\n Add credentials to your .npmrc, e.g.\n` +
240
+ ` //${safeHost(registry)}/:_authToken=\${NPM_TOKEN}`,
241
+ );
242
+ this.registry = registry;
243
+ this.status = status;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * A downloaded tarball did not match the checksum the registry advertised.
249
+ *
250
+ * Never soft-fail this: a bundle is a supply-chain artifact and a corrupt or
251
+ * substituted tarball is exactly what integrity checking exists to catch.
252
+ */
253
+ export class IntegrityError extends BundlerErrorBase {
254
+ readonly _tag = "IntegrityError" as const;
255
+ readonly packageName: string;
256
+ readonly version: string;
257
+ readonly expected: string;
258
+ readonly actual: string;
259
+ constructor(packageName: string, version: string, expected: string, actual: string) {
260
+ super(
261
+ `Integrity check failed for ${packageName}@${version}.\n` +
262
+ ` expected: ${expected}\n` +
263
+ ` actual: ${actual}\n` +
264
+ ` The download was discarded. This is either corruption in transit or a tampered artifact.`,
265
+ );
266
+ this.packageName = packageName;
267
+ this.version = version;
268
+ this.expected = expected;
269
+ this.actual = actual;
270
+ }
271
+ }
272
+
273
+ /** Something went wrong writing the `.tgz`. */
274
+ export class ArchiveError extends BundlerErrorBase {
275
+ readonly _tag = "ArchiveError" as const;
276
+ readonly path: string;
277
+ constructor(path: string, detail: string, options?: { cause?: unknown }) {
278
+ super(`Failed to create archive ${path}: ${detail}`, options);
279
+ this.path = path;
280
+ }
281
+ }
282
+
283
+ /** Something went wrong preparing or writing to the output directory. */
284
+ export class OutputError extends BundlerErrorBase {
285
+ readonly _tag = "OutputError" as const;
286
+ readonly path: string;
287
+ constructor(path: string, detail: string, options?: { cause?: unknown }) {
288
+ super(`Output error at ${path}: ${detail}`, options);
289
+ this.path = path;
290
+ }
291
+ }
292
+
293
+ const safeHost = (registry: string): string => {
294
+ try {
295
+ const url = new URL(registry);
296
+ return url.host + url.pathname.replace(/\/+$/, "");
297
+ } catch {
298
+ return registry;
299
+ }
300
+ };