@tsdoctor/bundle 0.1.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/Bundle.js +127 -0
- package/BundleDiscovery.js +233 -0
- package/BundleFetch.js +348 -0
- package/BundleHash.js +107 -0
- package/BundleManifest.js +183 -0
- package/BundleResolver.js +167 -0
- package/LICENSE +21 -0
- package/PlatformOverrides.js +48 -0
- package/README.md +65 -0
- package/index.d.ts +885 -0
- package/index.js +9 -0
- package/package.json +55 -0
- package/tsdoc-metadata.json +11 -0
package/BundleFetch.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { readBundle } from "./Bundle.js";
|
|
2
|
+
import { discoverBundle } from "./BundleDiscovery.js";
|
|
3
|
+
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
4
|
+
import { GitHubRelease, Repo, RepoRef } from "@effected/github";
|
|
5
|
+
import { NpmRegistry, PackageTarball, PublishedVersion } from "@effected/npm";
|
|
6
|
+
import { Cache } from "@effected/store";
|
|
7
|
+
import { AppDirs } from "@effected/xdg";
|
|
8
|
+
|
|
9
|
+
//#region src/BundleFetch.ts
|
|
10
|
+
/**
|
|
11
|
+
* Raised when a remote bundle cannot be fetched into the local cache.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* Fetch-plane failures speak in remote terms (`ref` names the coordinate the
|
|
15
|
+
* caller asked for, never a temp directory). Post-fetch READ failures — a
|
|
16
|
+
* fetched artifact whose layer files are malformed — surface as the same
|
|
17
|
+
* typed errors local reads produce (`BundleLayerError`,
|
|
18
|
+
* `BundleManifestError`), so a consumer handles one vocabulary for both.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
var BundleFetchError = class extends Schema.TaggedError()("BundleFetchError", {
|
|
23
|
+
/** Which fetcher failed. */
|
|
24
|
+
source: Schema.Literals(["npm", "github"]),
|
|
25
|
+
/** What went wrong, structurally. */
|
|
26
|
+
reason: Schema.Literals([
|
|
27
|
+
"invalidRef",
|
|
28
|
+
"versionNotFound",
|
|
29
|
+
"releaseNotFound",
|
|
30
|
+
"assetNotFound",
|
|
31
|
+
"assetAmbiguous",
|
|
32
|
+
"download",
|
|
33
|
+
"notABundle",
|
|
34
|
+
"cache"
|
|
35
|
+
]),
|
|
36
|
+
/** The remote coordinate: `name@version` or `owner/repo@tag#asset`. */
|
|
37
|
+
ref: Schema.String,
|
|
38
|
+
/** Human context for the failure. */
|
|
39
|
+
detail: Schema.optionalKey(Schema.String),
|
|
40
|
+
/** The underlying failure, when one exists, preserved structurally. */
|
|
41
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
42
|
+
}) {
|
|
43
|
+
get message() {
|
|
44
|
+
const detailPart = this.detail !== void 0 ? `: ${this.detail}` : "";
|
|
45
|
+
return `Bundle fetch failed (${this.reason}) for ${this.source} ${this.ref}${detailPart}`;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
/** The default asset suffix for the GitHub release bundle variant. */
|
|
49
|
+
const META_TGZ_SUFFIX = ".npm.meta.tgz";
|
|
50
|
+
/** Cache-record schema: which layer files a cached bundle dir holds. */
|
|
51
|
+
const CachedBundleRecord = Schema.Struct({ files: Schema.Array(Schema.String) });
|
|
52
|
+
/**
|
|
53
|
+
* One path segment safe to join into the cache tree: no separators, no
|
|
54
|
+
* traversal, no whitespace. Mirrors the registry's cache-key discipline —
|
|
55
|
+
* lenient about npm's historical malformations, strict enough that a
|
|
56
|
+
* coordinate can never escape its cache directory.
|
|
57
|
+
*/
|
|
58
|
+
const SAFE_SEGMENT = /^(?!\.{1,2}$)[^/\\\s]+$/;
|
|
59
|
+
const utf8Encode = (text) => new TextEncoder().encode(text);
|
|
60
|
+
const utf8Decode = (bytes) => new TextDecoder().decode(bytes);
|
|
61
|
+
/** Validate that every cache-path segment is traversal-safe. */
|
|
62
|
+
function validateSegments(source, ref, segments) {
|
|
63
|
+
for (const segment of segments) if (!SAFE_SEGMENT.test(segment)) return Effect.fail(new BundleFetchError({
|
|
64
|
+
source,
|
|
65
|
+
reason: "invalidRef",
|
|
66
|
+
ref,
|
|
67
|
+
detail: `unsafe path segment "${segment}"`
|
|
68
|
+
}));
|
|
69
|
+
return Effect.void;
|
|
70
|
+
}
|
|
71
|
+
/** The durable cache directory for one remote bundle coordinate. */
|
|
72
|
+
function bundleCacheDir(segments) {
|
|
73
|
+
return Effect.gen(function* () {
|
|
74
|
+
const appDirs = yield* AppDirs;
|
|
75
|
+
const path = yield* Path.Path;
|
|
76
|
+
const cacheRoot = yield* appDirs.ensureCache.pipe(Effect.orDie);
|
|
77
|
+
return path.join(cacheRoot, "bundles", ...segments);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The cache-hit probe: a metadata record exists AND every file it lists is
|
|
82
|
+
* still on disk. Any inconsistency — missing record, undecodable record,
|
|
83
|
+
* missing file, cache-read failure — reads as a miss, so a damaged cache
|
|
84
|
+
* self-heals by refetching.
|
|
85
|
+
*/
|
|
86
|
+
function cachedBundle(key, dir) {
|
|
87
|
+
return Effect.gen(function* () {
|
|
88
|
+
const cache = yield* Cache;
|
|
89
|
+
const fs = yield* FileSystem.FileSystem;
|
|
90
|
+
const path = yield* Path.Path;
|
|
91
|
+
const entry = yield* cache.get(key).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
92
|
+
if (Option.isNone(entry)) return Option.none();
|
|
93
|
+
const record = yield* Schema.decodeUnknownEffect(CachedBundleRecord)(yield* Effect.try(() => JSON.parse(utf8Decode(entry.value.value))).pipe(Effect.orElseSucceed(() => null))).pipe(Effect.option);
|
|
94
|
+
if (Option.isNone(record)) return Option.none();
|
|
95
|
+
for (const file of record.value.files) if (!(yield* fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false)))) return Option.none();
|
|
96
|
+
return yield* Effect.flatMap(discoverBundle(dir), readBundle).pipe(Effect.option);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Persist a fetched bundle's layer files from the (scoped, about-to-vanish)
|
|
101
|
+
* extraction directory into the durable cache dir, and record them in the
|
|
102
|
+
* metadata cache. The returned Bundle's descriptor points into the CACHE
|
|
103
|
+
* directory, which outlives the extraction scope.
|
|
104
|
+
*/
|
|
105
|
+
function persistAndRead(source, ref, key, extractedDir, cacheDir) {
|
|
106
|
+
return Effect.gen(function* () {
|
|
107
|
+
const cache = yield* Cache;
|
|
108
|
+
const fs = yield* FileSystem.FileSystem;
|
|
109
|
+
const path = yield* Path.Path;
|
|
110
|
+
const discovered = yield* discoverBundle(extractedDir).pipe(Effect.mapError((cause) => new BundleFetchError({
|
|
111
|
+
source,
|
|
112
|
+
reason: "notABundle",
|
|
113
|
+
ref,
|
|
114
|
+
detail: "the fetched artifact does not contain a readable bundle (no *.api.json model)",
|
|
115
|
+
cause
|
|
116
|
+
})));
|
|
117
|
+
const layerPaths = [
|
|
118
|
+
discovered.modelPath,
|
|
119
|
+
...discovered.packageJsonPath !== void 0 ? [discovered.packageJsonPath] : [],
|
|
120
|
+
...discovered.tsconfigPath !== void 0 ? [discovered.tsconfigPath] : [],
|
|
121
|
+
...discovered.manifestPath !== void 0 ? [discovered.manifestPath] : []
|
|
122
|
+
];
|
|
123
|
+
const failCache = (cause) => new BundleFetchError({
|
|
124
|
+
source,
|
|
125
|
+
reason: "cache",
|
|
126
|
+
ref,
|
|
127
|
+
cause
|
|
128
|
+
});
|
|
129
|
+
yield* fs.remove(cacheDir, { recursive: true }).pipe(Effect.ignore);
|
|
130
|
+
yield* fs.makeDirectory(cacheDir, { recursive: true }).pipe(Effect.mapError(failCache));
|
|
131
|
+
const files = [];
|
|
132
|
+
for (const layerPath of layerPaths) {
|
|
133
|
+
const fileName = path.basename(layerPath);
|
|
134
|
+
yield* fs.copyFile(layerPath, path.join(cacheDir, fileName)).pipe(Effect.mapError(failCache));
|
|
135
|
+
files.push(fileName);
|
|
136
|
+
}
|
|
137
|
+
yield* cache.set({
|
|
138
|
+
key,
|
|
139
|
+
value: utf8Encode(JSON.stringify({ files })),
|
|
140
|
+
contentType: "application/json"
|
|
141
|
+
}).pipe(Effect.mapError(failCache));
|
|
142
|
+
return yield* Effect.flatMap(discoverBundle(cacheDir), readBundle).pipe(Effect.catchTag("BundleDiscoveryError", (cause) => Effect.fail(failCache(cause))));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Fetch one published npm package version as a bundle, through the durable
|
|
147
|
+
* XDG cache.
|
|
148
|
+
*
|
|
149
|
+
* @remarks
|
|
150
|
+
* Works against any npm-protocol-family registry via `target` (the
|
|
151
|
+
* `type: "npm"` semantics of the manifest's registries block). The tarball is
|
|
152
|
+
* downloaded, integrity-verified and extracted by `@effected/npm`'s
|
|
153
|
+
* `PackageTarball`, its bundle layer files are copied into
|
|
154
|
+
* `<xdg-cache>/tsdoctor/bundles/npm/<name>/<version>/`, and the returned
|
|
155
|
+
* {@link Bundle}'s descriptor points at that durable directory. A published
|
|
156
|
+
* npm version is immutable, so a cache hit skips the network entirely;
|
|
157
|
+
* `refresh: true` forces a refetch.
|
|
158
|
+
*
|
|
159
|
+
* Provide the `NpmRegistry`, `PackageTarball`, `Cache` and `AppDirs`
|
|
160
|
+
* services (plus `FileSystem`/`Path`) at the application boundary.
|
|
161
|
+
*
|
|
162
|
+
* @public
|
|
163
|
+
*/
|
|
164
|
+
function fetchNpmBundle(options) {
|
|
165
|
+
const ref = `${options.name}@${options.version}`;
|
|
166
|
+
return Effect.gen(function* () {
|
|
167
|
+
const segments = [
|
|
168
|
+
"npm",
|
|
169
|
+
...options.name.split("/"),
|
|
170
|
+
options.version
|
|
171
|
+
];
|
|
172
|
+
yield* validateSegments("npm", ref, segments.slice(1));
|
|
173
|
+
const cacheDir = yield* bundleCacheDir(segments);
|
|
174
|
+
const key = `bundle:v1:npm:${ref}`;
|
|
175
|
+
if (options.refresh !== true) {
|
|
176
|
+
const hit = yield* cachedBundle(key, cacheDir);
|
|
177
|
+
if (Option.isSome(hit)) return hit.value;
|
|
178
|
+
}
|
|
179
|
+
const registry = yield* NpmRegistry;
|
|
180
|
+
const tarball = yield* PackageTarball;
|
|
181
|
+
const published = yield* registry.version(options.name, options.version, options.target).pipe(Effect.mapError((cause) => new BundleFetchError({
|
|
182
|
+
source: "npm",
|
|
183
|
+
reason: "download",
|
|
184
|
+
ref,
|
|
185
|
+
cause
|
|
186
|
+
})));
|
|
187
|
+
if (Option.isNone(published)) return yield* Effect.fail(new BundleFetchError({
|
|
188
|
+
source: "npm",
|
|
189
|
+
reason: "versionNotFound",
|
|
190
|
+
ref,
|
|
191
|
+
detail: "the registry reports no such published version (exact versions and dist-tags only; ranges are not resolved here)"
|
|
192
|
+
}));
|
|
193
|
+
return yield* Effect.gen(function* () {
|
|
194
|
+
const extractedDir = yield* tarball.extract(published.value).pipe(Effect.mapError((cause) => new BundleFetchError({
|
|
195
|
+
source: "npm",
|
|
196
|
+
reason: cause.reason === "notFound" ? "versionNotFound" : "download",
|
|
197
|
+
ref,
|
|
198
|
+
cause
|
|
199
|
+
})));
|
|
200
|
+
return yield* persistAndRead("npm", ref, key, extractedDir, cacheDir);
|
|
201
|
+
}).pipe(Effect.scoped);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Locate the bundle root inside an extracted release archive. npm-style
|
|
206
|
+
* archives unpack to `package/`; the real `*.npm.meta.tgz` release variant
|
|
207
|
+
* unpacks to `meta/` (verified against vitest-agent's published assets); a
|
|
208
|
+
* hand-rolled archive may put its files at the root. The first candidate
|
|
209
|
+
* containing a `*.api.json` wins: `package/`, the archive root, then each
|
|
210
|
+
* top-level subdirectory.
|
|
211
|
+
*/
|
|
212
|
+
function locateBundleRoot(extractedPackageDir) {
|
|
213
|
+
return Effect.gen(function* () {
|
|
214
|
+
const fs = yield* FileSystem.FileSystem;
|
|
215
|
+
const path = yield* Path.Path;
|
|
216
|
+
const archiveRoot = path.dirname(extractedPackageDir);
|
|
217
|
+
const candidates = [
|
|
218
|
+
extractedPackageDir,
|
|
219
|
+
archiveRoot,
|
|
220
|
+
...(yield* fs.readDirectory(archiveRoot).pipe(Effect.orElseSucceed(() => []))).filter((entry) => entry !== path.basename(extractedPackageDir)).map((entry) => path.join(archiveRoot, entry))
|
|
221
|
+
];
|
|
222
|
+
for (const candidate of candidates) if ((yield* fs.readDirectory(candidate).pipe(Effect.orElseSucceed(() => []))).some((name) => name.endsWith(".api.json"))) return Option.some(candidate);
|
|
223
|
+
return Option.none();
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
/** Pick the release asset: an exact name, or the single `*.npm.meta.tgz`. */
|
|
227
|
+
function pickAsset(ref, assets, wanted) {
|
|
228
|
+
if (wanted !== void 0) {
|
|
229
|
+
const found = assets.find((asset) => asset.name === wanted);
|
|
230
|
+
return found !== void 0 ? Effect.succeed(found) : Effect.fail(new BundleFetchError({
|
|
231
|
+
source: "github",
|
|
232
|
+
reason: "assetNotFound",
|
|
233
|
+
ref,
|
|
234
|
+
detail: `no asset named "${wanted}" (available: ${assets.map((asset) => asset.name).join(", ") || "none"})`
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
const candidates = assets.filter((asset) => asset.name.endsWith(META_TGZ_SUFFIX));
|
|
238
|
+
if (candidates.length === 1) return Effect.succeed(candidates[0]);
|
|
239
|
+
if (candidates.length === 0) return Effect.fail(new BundleFetchError({
|
|
240
|
+
source: "github",
|
|
241
|
+
reason: "assetNotFound",
|
|
242
|
+
ref,
|
|
243
|
+
detail: `no *${META_TGZ_SUFFIX} asset on the release; pass an explicit asset name`
|
|
244
|
+
}));
|
|
245
|
+
return Effect.fail(new BundleFetchError({
|
|
246
|
+
source: "github",
|
|
247
|
+
reason: "assetAmbiguous",
|
|
248
|
+
ref,
|
|
249
|
+
detail: `multiple *${META_TGZ_SUFFIX} assets (${candidates.map((asset) => asset.name).join(", ")}); pass an explicit asset name`
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Fetch a bundle attached to a GitHub release as a `*.npm.meta.tgz`-style
|
|
254
|
+
* asset, through the durable XDG cache.
|
|
255
|
+
*
|
|
256
|
+
* @remarks
|
|
257
|
+
* The release is looked up by tag via `@effected/github`'s `GitHubRelease`
|
|
258
|
+
* (the `Repo` context is provided internally from `owner`/`repo`), the chosen
|
|
259
|
+
* asset is downloaded and extracted through the same verified
|
|
260
|
+
* `PackageTarball` path the npm fetcher uses (no integrity is available for
|
|
261
|
+
* release assets, so the download is unverified — the extractor logs this),
|
|
262
|
+
* and the layer files land in
|
|
263
|
+
* `<xdg-cache>/tsdoctor/bundles/github/<owner>/<repo>/<tag>/<asset>/`.
|
|
264
|
+
*
|
|
265
|
+
* A tarball with an npm-style `package/` root and one with its files at the
|
|
266
|
+
* archive root are both accepted. Git tags CAN move; the cache treats them as
|
|
267
|
+
* immutable and `refresh: true` is the escape hatch. Private-repo assets are
|
|
268
|
+
* not supported yet: the asset's browser download URL is fetched directly,
|
|
269
|
+
* which works for public releases only.
|
|
270
|
+
*
|
|
271
|
+
* @public
|
|
272
|
+
*/
|
|
273
|
+
function fetchGitHubReleaseBundle(options) {
|
|
274
|
+
const ref = `${options.owner}/${options.repo}@${options.tag}${options.asset !== void 0 ? `#${options.asset}` : ""}`;
|
|
275
|
+
return Effect.gen(function* () {
|
|
276
|
+
yield* validateSegments("github", ref, [
|
|
277
|
+
options.owner,
|
|
278
|
+
options.repo,
|
|
279
|
+
options.tag,
|
|
280
|
+
...options.asset !== void 0 ? [options.asset] : []
|
|
281
|
+
]);
|
|
282
|
+
const cacheCoordinate = (assetName) => ({
|
|
283
|
+
segments: [
|
|
284
|
+
"github",
|
|
285
|
+
options.owner,
|
|
286
|
+
options.repo,
|
|
287
|
+
options.tag,
|
|
288
|
+
assetName
|
|
289
|
+
],
|
|
290
|
+
key: `bundle:v1:github:${options.owner}/${options.repo}@${options.tag}#${assetName}`
|
|
291
|
+
});
|
|
292
|
+
if (options.asset !== void 0 && options.refresh !== true) {
|
|
293
|
+
const coordinate = cacheCoordinate(options.asset);
|
|
294
|
+
const earlyHit = yield* cachedBundle(coordinate.key, yield* bundleCacheDir(coordinate.segments));
|
|
295
|
+
if (Option.isSome(earlyHit)) return earlyHit.value;
|
|
296
|
+
}
|
|
297
|
+
const releases = yield* GitHubRelease;
|
|
298
|
+
const repoRef = RepoRef.make({
|
|
299
|
+
owner: options.owner,
|
|
300
|
+
repo: options.repo
|
|
301
|
+
});
|
|
302
|
+
const withRepo = (effect) => Effect.provideService(effect, Repo, repoRef);
|
|
303
|
+
const failDownload = (cause) => new BundleFetchError({
|
|
304
|
+
source: "github",
|
|
305
|
+
reason: "download",
|
|
306
|
+
ref,
|
|
307
|
+
cause
|
|
308
|
+
});
|
|
309
|
+
const release = yield* withRepo(releases.getByTagOption(options.tag)).pipe(Effect.mapError(failDownload));
|
|
310
|
+
if (Option.isNone(release)) return yield* Effect.fail(new BundleFetchError({
|
|
311
|
+
source: "github",
|
|
312
|
+
reason: "releaseNotFound",
|
|
313
|
+
ref,
|
|
314
|
+
detail: "no release with that tag"
|
|
315
|
+
}));
|
|
316
|
+
const assets = yield* withRepo(releases.listAssets(release.value.id)).pipe(Effect.mapError(failDownload));
|
|
317
|
+
const asset = yield* pickAsset(ref, assets, options.asset);
|
|
318
|
+
const { segments, key } = cacheCoordinate(asset.name);
|
|
319
|
+
const cacheDir = yield* bundleCacheDir(segments);
|
|
320
|
+
if (options.refresh !== true && options.asset === void 0) {
|
|
321
|
+
const hit = yield* cachedBundle(key, cacheDir);
|
|
322
|
+
if (Option.isSome(hit)) return hit.value;
|
|
323
|
+
}
|
|
324
|
+
const tarball = yield* PackageTarball;
|
|
325
|
+
return yield* Effect.gen(function* () {
|
|
326
|
+
const bundleRoot = yield* locateBundleRoot(yield* tarball.extract(PublishedVersion.make({
|
|
327
|
+
name: asset.name,
|
|
328
|
+
version: options.tag,
|
|
329
|
+
tarball: asset.url
|
|
330
|
+
})).pipe(Effect.mapError((cause) => new BundleFetchError({
|
|
331
|
+
source: "github",
|
|
332
|
+
reason: cause.reason === "notFound" ? "assetNotFound" : "download",
|
|
333
|
+
ref,
|
|
334
|
+
cause
|
|
335
|
+
}))));
|
|
336
|
+
if (Option.isNone(bundleRoot)) return yield* Effect.fail(new BundleFetchError({
|
|
337
|
+
source: "github",
|
|
338
|
+
reason: "notABundle",
|
|
339
|
+
ref,
|
|
340
|
+
detail: "the release asset contains no *.api.json model at its root, in package/, or in any top-level directory"
|
|
341
|
+
}));
|
|
342
|
+
return yield* persistAndRead("github", ref, key, bundleRoot.value, cacheDir);
|
|
343
|
+
}).pipe(Effect.scoped);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
//#endregion
|
|
348
|
+
export { BundleFetchError, fetchGitHubReleaseBundle, fetchNpmBundle };
|
package/BundleHash.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
//#region src/BundleHash.ts
|
|
4
|
+
/**
|
|
5
|
+
* Serialize a JSON-shaped value canonically: object keys sorted recursively,
|
|
6
|
+
* `undefined`-valued keys dropped, arrays in declared order, no whitespace.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* The normalization half of the change-detection discipline: two values that
|
|
10
|
+
* differ only in key order or optional-key presence-as-`undefined` serialize
|
|
11
|
+
* identically, so their hashes match. Non-JSON leaves (functions, symbols)
|
|
12
|
+
* serialize as `null`, matching `JSON.stringify` semantics inside arrays.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
function canonicalJson(value) {
|
|
17
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") return JSON.stringify(value);
|
|
18
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item === void 0 ? null : item)).join(",")}]`;
|
|
19
|
+
if (typeof value === "object") {
|
|
20
|
+
const record = value;
|
|
21
|
+
return `{${Object.keys(record).filter((key) => record[key] !== void 0).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
|
|
22
|
+
}
|
|
23
|
+
return "null";
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Normalize text for hashing: CRLF/CR line endings become LF and trailing
|
|
27
|
+
* whitespace at the end of the content is trimmed.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* The same file checked out with different line-ending settings must hash
|
|
31
|
+
* identically — coarse layer-hash comparison otherwise reports every file
|
|
32
|
+
* changed on the first cross-platform build.
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
function normalizeText(text) {
|
|
37
|
+
return text.replace(/\r\n?/g, "\n").trimEnd();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The lowercase hex SHA-256 of a string, UTF-8 encoded.
|
|
41
|
+
*
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
function sha256Hex(text) {
|
|
45
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Hash text content: {@link normalizeText} then {@link sha256Hex}.
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
function hashText(text) {
|
|
53
|
+
return sha256Hex(normalizeText(text));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Hash a JSON-shaped value: {@link canonicalJson} then {@link sha256Hex}.
|
|
57
|
+
*
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
function hashJsonValue(value) {
|
|
61
|
+
return sha256Hex(canonicalJson(value));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Hash one bundle layer file's raw text — the COARSE half of change
|
|
65
|
+
* detection (all layer hashes match → skip resolution entirely).
|
|
66
|
+
*
|
|
67
|
+
* @remarks
|
|
68
|
+
* Total: text that parses as JSON hashes canonically (key order and
|
|
69
|
+
* formatting churn do not read as change); text that does not parse falls
|
|
70
|
+
* back to {@link hashText}, so a broken file still gets a stable hash
|
|
71
|
+
* rather than an error — hashing is bookkeeping, not validation.
|
|
72
|
+
*
|
|
73
|
+
* @public
|
|
74
|
+
*/
|
|
75
|
+
function hashLayerText(text) {
|
|
76
|
+
try {
|
|
77
|
+
return hashJsonValue(JSON.parse(text));
|
|
78
|
+
} catch {
|
|
79
|
+
return hashText(text);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Fingerprint every present field of a {@link ResolvedBundle} — the FINE
|
|
84
|
+
* half of change detection, hashing each field's `{ value, source }` pair.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* The source participates deliberately: an override flip (a field moving
|
|
88
|
+
* from `inferred` to an authored tier without changing value) is a
|
|
89
|
+
* semantically meaningful change and must read as one. Absent fields carry
|
|
90
|
+
* no fingerprint — their appearance or disappearance is itself the diff.
|
|
91
|
+
* The consuming store maps each key to its invalidation scope (version →
|
|
92
|
+
* version-embedding surfaces, tsconfig → all code blocks, …).
|
|
93
|
+
*
|
|
94
|
+
* @public
|
|
95
|
+
*/
|
|
96
|
+
function fingerprintResolvedBundle(resolved) {
|
|
97
|
+
const fingerprints = {};
|
|
98
|
+
const fields = { ...resolved };
|
|
99
|
+
for (const key of Object.keys(fields).sort()) {
|
|
100
|
+
const field = fields[key];
|
|
101
|
+
if (field !== void 0) fingerprints[key] = hashJsonValue(field);
|
|
102
|
+
}
|
|
103
|
+
return fingerprints;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
//#endregion
|
|
107
|
+
export { canonicalJson, fingerprintResolvedBundle, hashJsonValue, hashLayerText, hashText, normalizeText, sha256Hex };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/BundleManifest.ts
|
|
4
|
+
/**
|
|
5
|
+
* The registry protocol families this reader knows how to do more than link
|
|
6
|
+
* to. `"npm"` means an npm-compatible registry — install commands and tarball
|
|
7
|
+
* fetching work against any instance of it — and `"jsr"` the jsr protocol.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* The manifest's `type` field is deliberately NOT constrained to these
|
|
11
|
+
* values: unknown future types must degrade to link-only rendering, not
|
|
12
|
+
* reject the manifest. Use {@link isKnownRegistryType} to branch.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
const KNOWN_REGISTRY_TYPES = ["npm", "jsr"];
|
|
17
|
+
/**
|
|
18
|
+
* Whether a registry `type` value is a protocol family this reader
|
|
19
|
+
* recognizes. `false` means the registry entry should degrade to link-only
|
|
20
|
+
* rendering — it is never a validation failure.
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
function isKnownRegistryType(type) {
|
|
25
|
+
return KNOWN_REGISTRY_TYPES.includes(type);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* One registry the documented package is published to.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* `type` is the PROTOCOL FAMILY (`"npm"` covers every npm-compatible
|
|
32
|
+
* registry), `name` the human instance label, `url` the package's page on
|
|
33
|
+
* that instance. Unknown `type` values decode successfully and degrade to
|
|
34
|
+
* link-only rendering (see {@link isKnownRegistryType}).
|
|
35
|
+
*
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
const RegistryRef = Schema.Struct({
|
|
39
|
+
/** The protocol family, e.g. `"npm"` or `"jsr"`. Unknown values are accepted. */
|
|
40
|
+
type: Schema.String,
|
|
41
|
+
/** The human instance label, e.g. `"npm"` or `"Savvy Web Registry"`. */
|
|
42
|
+
name: Schema.String,
|
|
43
|
+
/** The package's URL on that registry instance. */
|
|
44
|
+
url: Schema.String
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* One Open Graph image declared by the manifest.
|
|
48
|
+
*
|
|
49
|
+
* @remarks
|
|
50
|
+
* Exactly ONE of `path` (a bundle-relative asset the consuming platform
|
|
51
|
+
* publishes and resolves to a URL) or `url` (an absolute external URL used
|
|
52
|
+
* verbatim) must be present — the schema enforces the XOR. `type` is a MIME
|
|
53
|
+
* type, inferred from the file extension by the resolver when omitted; `alt`
|
|
54
|
+
* has a documented inference chain (tagline → description →
|
|
55
|
+
* `"<name> API documentation"`).
|
|
56
|
+
*
|
|
57
|
+
* @public
|
|
58
|
+
*/
|
|
59
|
+
const OpenGraphImage = Schema.Struct({
|
|
60
|
+
/** Bundle-relative asset path. Mutually exclusive with `url`. */
|
|
61
|
+
path: Schema.optionalKey(Schema.String),
|
|
62
|
+
/** Absolute external URL, used verbatim. Mutually exclusive with `path`. */
|
|
63
|
+
url: Schema.optionalKey(Schema.String),
|
|
64
|
+
/** MIME type; inferred from the extension when omitted. */
|
|
65
|
+
type: Schema.optionalKey(Schema.String),
|
|
66
|
+
/** Pixel width; 1200×630 (1.91:1) is the cross-platform safe default. */
|
|
67
|
+
width: Schema.optionalKey(Schema.Int),
|
|
68
|
+
/** Pixel height. */
|
|
69
|
+
height: Schema.optionalKey(Schema.Int),
|
|
70
|
+
/** Alt text; inferred (tagline → description → fallback) when omitted. */
|
|
71
|
+
alt: Schema.optionalKey(Schema.String)
|
|
72
|
+
}).check(Schema.makeFilter((image) => image.path === void 0 !== (image.url === void 0) ? void 0 : "exactly one of \"path\" or \"url\" must be present", { title: "openGraph image source" }));
|
|
73
|
+
/**
|
|
74
|
+
* The manifest's Open Graph block: the asset-ish pieces only — most OG tags
|
|
75
|
+
* are page-level and derive at render time in the consuming platform.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* Multiple images follow OG array semantics: the first declared wins, extras
|
|
79
|
+
* are alternates (e.g. a portrait 1000×1500 variant).
|
|
80
|
+
*
|
|
81
|
+
* @public
|
|
82
|
+
*/
|
|
83
|
+
const OpenGraphConfig = Schema.Struct({
|
|
84
|
+
/** Declared images, first-wins per OG array semantics. */
|
|
85
|
+
images: Schema.optionalKey(Schema.Array(OpenGraphImage)),
|
|
86
|
+
/** Embed accent color (e.g. Discord), a CSS color string. */
|
|
87
|
+
themeColor: Schema.optionalKey(Schema.String)
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* A pointer to the bundle's SBOM, computed by the bundler at publish and
|
|
91
|
+
* served as a downloadable static asset.
|
|
92
|
+
*
|
|
93
|
+
* @public
|
|
94
|
+
*/
|
|
95
|
+
const SbomRef = Schema.Struct({
|
|
96
|
+
/** Bundle-relative path to the SBOM file. */
|
|
97
|
+
path: Schema.String,
|
|
98
|
+
/** SBOM format label, e.g. `"spdx-json"`. Unknown values are accepted. */
|
|
99
|
+
format: Schema.optionalKey(Schema.String)
|
|
100
|
+
});
|
|
101
|
+
/**
|
|
102
|
+
* The inherited project tier, flattened into the emitted manifest by the
|
|
103
|
+
* bundler (a fetched bundle has no parent directory to walk). Kept nested —
|
|
104
|
+
* structurally distinguishable from the leaf fields — because provenance is
|
|
105
|
+
* load-bearing for override detection.
|
|
106
|
+
*
|
|
107
|
+
* @public
|
|
108
|
+
*/
|
|
109
|
+
const ProjectIdentity = Schema.Struct({
|
|
110
|
+
/** The project display name, e.g. `"Effected"` over leaf `@effected/store`. */
|
|
111
|
+
name: Schema.optionalKey(Schema.String),
|
|
112
|
+
/** The project tagline. */
|
|
113
|
+
tagline: Schema.optionalKey(Schema.String)
|
|
114
|
+
});
|
|
115
|
+
/**
|
|
116
|
+
* The versioned `tsdoctor.json` sidecar manifest — bundle layer 3.
|
|
117
|
+
*
|
|
118
|
+
* @remarks
|
|
119
|
+
* `spec` is the only required field; every other field enriches. Unknown
|
|
120
|
+
* top-level fields are ignored on decode (additive fields are minor spec
|
|
121
|
+
* revisions) and unknown enum-ish values (registry `type`, sbom `format`)
|
|
122
|
+
* degrade gracefully instead of rejecting — an old reader must be able to
|
|
123
|
+
* consume a new bundle.
|
|
124
|
+
*
|
|
125
|
+
* @public
|
|
126
|
+
*/
|
|
127
|
+
const BundleManifest = Schema.Struct({
|
|
128
|
+
/** The integer spec version. This reader understands spec 1. */
|
|
129
|
+
spec: Schema.Literal(1),
|
|
130
|
+
/** Human display name (the npm name is dry; this one is SEO-friendly). */
|
|
131
|
+
name: Schema.optionalKey(Schema.String),
|
|
132
|
+
/** Short tagline. */
|
|
133
|
+
tagline: Schema.optionalKey(Schema.String),
|
|
134
|
+
/** Long description; overrides the package.json description when present. */
|
|
135
|
+
description: Schema.optionalKey(Schema.String),
|
|
136
|
+
/** The inherited project tier, flattened in at emit time. */
|
|
137
|
+
project: Schema.optionalKey(ProjectIdentity),
|
|
138
|
+
/** Open Graph assets. */
|
|
139
|
+
openGraph: Schema.optionalKey(OpenGraphConfig),
|
|
140
|
+
/** SBOM pointer. */
|
|
141
|
+
sbom: Schema.optionalKey(SbomRef),
|
|
142
|
+
/** Registries the package is published to. */
|
|
143
|
+
registries: Schema.optionalKey(Schema.Array(RegistryRef))
|
|
144
|
+
});
|
|
145
|
+
/**
|
|
146
|
+
* Raised when a present `tsdoctor.json` cannot be parsed or does not satisfy
|
|
147
|
+
* the {@link (BundleManifest:variable)} schema.
|
|
148
|
+
*
|
|
149
|
+
* @remarks
|
|
150
|
+
* Absence of the manifest is NEVER this error — layers enrich, never gate,
|
|
151
|
+
* so a missing sidecar is the normal case and reads as `Option.none()`.
|
|
152
|
+
*
|
|
153
|
+
* @public
|
|
154
|
+
*/
|
|
155
|
+
var BundleManifestError = class extends Schema.TaggedError()("BundleManifestError", {
|
|
156
|
+
/** The manifest file path, when the failure is tied to a file on disk. */
|
|
157
|
+
path: Schema.optionalKey(Schema.String),
|
|
158
|
+
/** The underlying failure (JSON syntax or schema decode), preserved structurally. */
|
|
159
|
+
cause: Schema.Defect()
|
|
160
|
+
}) {
|
|
161
|
+
get message() {
|
|
162
|
+
return `Invalid tsdoctor.json manifest${this.path !== void 0 ? ` at ${this.path}` : ""}`;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Decode an unknown value into a {@link (BundleManifest:type)}.
|
|
167
|
+
*
|
|
168
|
+
* @remarks
|
|
169
|
+
* The typed boundary for manifest input that has already been parsed from
|
|
170
|
+
* JSON (plugin options, fetched payloads). File-based reading lives in
|
|
171
|
+
* `readBundle`, which routes through this after parsing.
|
|
172
|
+
*
|
|
173
|
+
* @public
|
|
174
|
+
*/
|
|
175
|
+
function decodeBundleManifest(input, path) {
|
|
176
|
+
return Schema.decodeUnknownEffect(BundleManifest)(input).pipe(Effect.mapError((cause) => new BundleManifestError({
|
|
177
|
+
...path !== void 0 ? { path } : {},
|
|
178
|
+
cause
|
|
179
|
+
})));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
//#endregion
|
|
183
|
+
export { BundleManifest, BundleManifestError, KNOWN_REGISTRY_TYPES, OpenGraphConfig, OpenGraphImage, ProjectIdentity, RegistryRef, SbomRef, decodeBundleManifest, isKnownRegistryType };
|