@tsdoctor/registry 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/LICENSE +21 -0
- package/PackageFetcher.js +241 -0
- package/PackageSpec.js +182 -0
- package/README.md +94 -0
- package/RegistryEvent.js +146 -0
- package/TsEnvironment.js +95 -0
- package/TypeCache.js +256 -0
- package/TypeRegistry.js +276 -0
- package/TypeResolver.js +183 -0
- package/Vfs.js +33 -0
- package/VirtualPackage.js +126 -0
- package/index.d.ts +990 -0
- package/index.js +11 -0
- package/internal/jsdelivr.js +62 -0
- package/internal/limits.js +16 -0
- package/internal/resolution.js +181 -0
- package/package.json +67 -0
- package/tsdoc-metadata.json +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { MAX_TYPE_BYTES_PER_PACKAGE, MAX_TYPE_FILES_PER_PACKAGE } from "./internal/limits.js";
|
|
2
|
+
import { FileTreeResponse, TYPE_FILE_PATTERN, VersionsResponse, fileTreeUrl, fileUrl, packageJsonUrl, versionsUrl } from "./internal/jsdelivr.js";
|
|
3
|
+
import { emit } from "./RegistryEvent.js";
|
|
4
|
+
import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Schema } from "effect";
|
|
5
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
6
|
+
import * as HttpClientError from "effect/unstable/http/HttpClientError";
|
|
7
|
+
|
|
8
|
+
//#region src/PackageFetcher.ts
|
|
9
|
+
/**
|
|
10
|
+
* Raised when an HTTP request or response fails at the jsDelivr boundary.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* `kind` classifies the failure structurally — `transport` (connection,
|
|
14
|
+
* timeout), `status` (non-2xx, with `status` populated), `body` (reading or
|
|
15
|
+
* bounding the body) or `schema` (response validation) — and `status` is a
|
|
16
|
+
* structured field, so classification consumers branch on typed data. v3
|
|
17
|
+
* folded the HTTP status into a message string and substring-matched `"404"`
|
|
18
|
+
* back out of it.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
var FetchError = class extends Schema.TaggedError()("FetchError", {
|
|
23
|
+
/** The request URL. */
|
|
24
|
+
url: Schema.String,
|
|
25
|
+
/** The HTTP status, when the failure has one. */
|
|
26
|
+
status: Schema.optionalKey(Schema.Number),
|
|
27
|
+
/** What failed, structurally. */
|
|
28
|
+
kind: Schema.Literals([
|
|
29
|
+
"transport",
|
|
30
|
+
"status",
|
|
31
|
+
"body",
|
|
32
|
+
"schema"
|
|
33
|
+
]),
|
|
34
|
+
/** The underlying failure, preserved structurally. */
|
|
35
|
+
cause: Schema.Defect()
|
|
36
|
+
}) {
|
|
37
|
+
get message() {
|
|
38
|
+
const statusPart = this.status !== void 0 ? ` (HTTP ${this.status})` : "";
|
|
39
|
+
return `Fetch ${this.kind} failure${statusPart} for ${this.url}`;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Raised when a pinned package version does not exist on the CDN (HTTP 404).
|
|
44
|
+
*
|
|
45
|
+
* @remarks
|
|
46
|
+
* The 404 → `PackageNotFoundError` promotion happens on the typed
|
|
47
|
+
* `FetchError` `status` field. Also raised by `TypeRegistry.getPackageVfs`
|
|
48
|
+
* on a cache miss with `autoFetch: false`.
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
var PackageNotFoundError = class extends Schema.TaggedError()("PackageNotFoundError", {
|
|
53
|
+
/** The package name. */
|
|
54
|
+
name: Schema.String,
|
|
55
|
+
/** The version reference that was requested. */
|
|
56
|
+
version: Schema.String
|
|
57
|
+
}) {
|
|
58
|
+
get message() {
|
|
59
|
+
return `Package ${this.name}@${this.version} was not found`;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Raised when local version resolution finds no published version matching
|
|
64
|
+
* the requested reference.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Raised by `TypeRegistry.resolveVersion` — typed, with the requested ref and
|
|
68
|
+
* bounded available-version context. v3 detected this case by
|
|
69
|
+
* substring-matching CDN error prose.
|
|
70
|
+
*
|
|
71
|
+
* @public
|
|
72
|
+
*/
|
|
73
|
+
var VersionNotFoundError = class extends Schema.TaggedError()("VersionNotFoundError", {
|
|
74
|
+
/** The package name. */
|
|
75
|
+
name: Schema.String,
|
|
76
|
+
/** The requested reference: a range, dist-tag or exact version. */
|
|
77
|
+
ref: Schema.String,
|
|
78
|
+
/** A bounded sample of the versions that ARE published. */
|
|
79
|
+
available: Schema.Array(Schema.String)
|
|
80
|
+
}) {
|
|
81
|
+
get message() {
|
|
82
|
+
return `No published version of ${this.name} matches "${this.ref}"`;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* The lenient `package.json` subset the type resolver reads.
|
|
87
|
+
*
|
|
88
|
+
* @remarks
|
|
89
|
+
* Deliberately NOT `@effected/package-json`: its schemas validate strictly
|
|
90
|
+
* (branded names, SPDX licenses), and the manifests this package decodes come
|
|
91
|
+
* off a CDN and include every historical malformation npm ever published.
|
|
92
|
+
* Validation here is lenient and scoped to exactly the fields resolution
|
|
93
|
+
* needs.
|
|
94
|
+
*
|
|
95
|
+
* @public
|
|
96
|
+
*/
|
|
97
|
+
const PackageManifest = Schema.Struct({
|
|
98
|
+
name: Schema.optionalKey(Schema.String),
|
|
99
|
+
version: Schema.optionalKey(Schema.String),
|
|
100
|
+
types: Schema.optionalKey(Schema.String),
|
|
101
|
+
typings: Schema.optionalKey(Schema.String),
|
|
102
|
+
main: Schema.optionalKey(Schema.String),
|
|
103
|
+
module: Schema.optionalKey(Schema.String),
|
|
104
|
+
exports: Schema.optionalKey(Schema.Union([
|
|
105
|
+
Schema.String,
|
|
106
|
+
Schema.Record(Schema.String, Schema.Unknown),
|
|
107
|
+
Schema.Array(Schema.Unknown)
|
|
108
|
+
])),
|
|
109
|
+
typesVersions: Schema.optionalKey(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Union([Schema.Array(Schema.String), Schema.String])))),
|
|
110
|
+
dependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
111
|
+
peerDependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
112
|
+
devDependencies: Schema.optionalKey(Schema.Record(Schema.String, Schema.String))
|
|
113
|
+
});
|
|
114
|
+
const retrySchedule = Schedule.exponential(Duration.millis(100));
|
|
115
|
+
/**
|
|
116
|
+
* Deadline for materializing a response body. `Effect.timeout` on the request
|
|
117
|
+
* ends once headers arrive; without a second deadline a stalled body stream
|
|
118
|
+
* would hang a fiber indefinitely.
|
|
119
|
+
*/
|
|
120
|
+
const bodyTimeout = Duration.seconds(30);
|
|
121
|
+
/** Retry only failures that can be transient: transport errors and timeouts. */
|
|
122
|
+
const isTransient = (error) => Cause.isTimeoutError(error) || HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError";
|
|
123
|
+
const make = Effect.gen(function* () {
|
|
124
|
+
const http = yield* HttpClient.HttpClient;
|
|
125
|
+
const fetchOk = (url) => http.get(url).pipe(Effect.timeout(Duration.seconds(30)), Effect.retry({
|
|
126
|
+
schedule: retrySchedule,
|
|
127
|
+
times: 3,
|
|
128
|
+
while: isTransient
|
|
129
|
+
}), Effect.mapError((cause) => new FetchError({
|
|
130
|
+
url,
|
|
131
|
+
kind: "transport",
|
|
132
|
+
cause
|
|
133
|
+
})), Effect.flatMap((response) => response.status >= 200 && response.status < 300 ? Effect.succeed(response) : response.text.pipe(Effect.timeout(bodyTimeout), Effect.match({
|
|
134
|
+
onFailure: (readFailure) => ({
|
|
135
|
+
bodySnippet: "",
|
|
136
|
+
cause: readFailure
|
|
137
|
+
}),
|
|
138
|
+
onSuccess: (body) => {
|
|
139
|
+
const bodySnippet = body.slice(0, 200);
|
|
140
|
+
return {
|
|
141
|
+
bodySnippet,
|
|
142
|
+
cause: bodySnippet
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}), Effect.flatMap(({ bodySnippet, cause }) => emit({
|
|
146
|
+
_tag: "FetchFailed",
|
|
147
|
+
url,
|
|
148
|
+
status: response.status,
|
|
149
|
+
bodySnippet
|
|
150
|
+
}).pipe(Effect.andThen(Effect.fail(new FetchError({
|
|
151
|
+
url,
|
|
152
|
+
status: response.status,
|
|
153
|
+
kind: "status",
|
|
154
|
+
cause
|
|
155
|
+
}))))))));
|
|
156
|
+
const fetchText = (url) => fetchOk(url).pipe(Effect.flatMap((response) => response.text.pipe(Effect.timeout(bodyTimeout), Effect.mapError((cause) => new FetchError({
|
|
157
|
+
url,
|
|
158
|
+
kind: "body",
|
|
159
|
+
cause
|
|
160
|
+
})))));
|
|
161
|
+
const fetchJson = (url, schema) => fetchOk(url).pipe(Effect.flatMap((response) => response.json.pipe(Effect.timeout(bodyTimeout), Effect.mapError((cause) => new FetchError({
|
|
162
|
+
url,
|
|
163
|
+
kind: "body",
|
|
164
|
+
cause
|
|
165
|
+
})))), Effect.flatMap((data) => Schema.decodeUnknownEffect(schema)(data).pipe(Effect.mapError((cause) => new FetchError({
|
|
166
|
+
url,
|
|
167
|
+
kind: "schema",
|
|
168
|
+
cause
|
|
169
|
+
})))));
|
|
170
|
+
/** Promote a typed 404 into the package-level not-found error. */
|
|
171
|
+
const promote404 = (pkg) => (error) => error.status === 404 ? new PackageNotFoundError({
|
|
172
|
+
name: pkg.name,
|
|
173
|
+
version: pkg.version
|
|
174
|
+
}) : error;
|
|
175
|
+
/** The decoded flat tree, with names normalized and per-file sizes kept. */
|
|
176
|
+
const fetchTree = (pkg) => fetchJson(fileTreeUrl(pkg), FileTreeResponse).pipe(Effect.mapError(promote404(pkg)), Effect.map((tree) => tree.files.map((file) => ({
|
|
177
|
+
name: file.name.replace(/^\/+/, ""),
|
|
178
|
+
size: file.size
|
|
179
|
+
}))));
|
|
180
|
+
const getFileTree = Effect.fn("PackageFetcher.getFileTree")(function* (pkg) {
|
|
181
|
+
return (yield* fetchTree(pkg)).map((file) => file.name);
|
|
182
|
+
});
|
|
183
|
+
const downloadFile = Effect.fn("PackageFetcher.downloadFile")(function* (pkg, filePath) {
|
|
184
|
+
const url = yield* Effect.try({
|
|
185
|
+
try: () => fileUrl(pkg, filePath),
|
|
186
|
+
catch: (cause) => new FetchError({
|
|
187
|
+
url: fileTreeUrl(pkg),
|
|
188
|
+
kind: "transport",
|
|
189
|
+
cause
|
|
190
|
+
})
|
|
191
|
+
});
|
|
192
|
+
return yield* fetchText(url).pipe(Effect.mapError(promote404(pkg)));
|
|
193
|
+
});
|
|
194
|
+
const getPackageJson = Effect.fn("PackageFetcher.getPackageJson")(function* (pkg) {
|
|
195
|
+
return yield* fetchJson(packageJsonUrl(pkg), PackageManifest).pipe(Effect.mapError(promote404(pkg)));
|
|
196
|
+
});
|
|
197
|
+
const getVersions = Effect.fn("PackageFetcher.getVersions")(function* (name) {
|
|
198
|
+
return yield* fetchJson(versionsUrl(name), VersionsResponse);
|
|
199
|
+
});
|
|
200
|
+
const overBudget = (pkg, detail) => new FetchError({
|
|
201
|
+
url: fileTreeUrl(pkg),
|
|
202
|
+
kind: "body",
|
|
203
|
+
cause: new Error(detail)
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
getVersions,
|
|
207
|
+
getFileTree,
|
|
208
|
+
downloadFile,
|
|
209
|
+
getPackageJson,
|
|
210
|
+
getTypeFiles: Effect.fn("PackageFetcher.getTypeFiles")(function* (pkg) {
|
|
211
|
+
const typeFiles = (yield* fetchTree(pkg)).filter((file) => TYPE_FILE_PATTERN.test(file.name));
|
|
212
|
+
if (typeFiles.length > 5e3) return yield* Effect.fail(overBudget(pkg, `package publishes ${typeFiles.length} declaration files, over the ${MAX_TYPE_FILES_PER_PACKAGE}-file budget`));
|
|
213
|
+
const declaredBytes = typeFiles.reduce((total, file) => total + (file.size ?? 0), 0);
|
|
214
|
+
if (declaredBytes > 67108864) return yield* Effect.fail(overBudget(pkg, `declared declaration files total ${declaredBytes} bytes, over the byte budget`));
|
|
215
|
+
const encoder = new TextEncoder();
|
|
216
|
+
const budget = yield* Ref.make(0);
|
|
217
|
+
const entries = yield* Effect.forEach(typeFiles, (file) => downloadFile(pkg, file.name).pipe(Effect.tap((content) => Ref.updateAndGet(budget, (total) => total + encoder.encode(content).length).pipe(Effect.flatMap((total) => total > 67108864 ? Effect.fail(overBudget(pkg, `declaration files exceed the ${MAX_TYPE_BYTES_PER_PACKAGE}-byte budget`)) : Effect.void))), Effect.map((content) => [file.name, content])), { concurrency: 10 });
|
|
218
|
+
return new Map(entries);
|
|
219
|
+
})
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
/**
|
|
223
|
+
* The jsDelivr CDN client.
|
|
224
|
+
*
|
|
225
|
+
* @remarks
|
|
226
|
+
* Requests time out after 30 seconds; transport and timeout failures retry
|
|
227
|
+
* up to 3 times with exponential back-off (starting at 100 ms). Non-2xx
|
|
228
|
+
* responses fail fast with a typed status and emit a `FetchFailed` event
|
|
229
|
+
* carrying the status and a body snippet. If a second registry backend ever
|
|
230
|
+
* appears it arrives as another layer for this service — the service seam is
|
|
231
|
+
* the extension point.
|
|
232
|
+
*
|
|
233
|
+
* @public
|
|
234
|
+
*/
|
|
235
|
+
var PackageFetcher = class PackageFetcher extends Context.Service()("type-registry-effect/PackageFetcher") {
|
|
236
|
+
/** The jsDelivr-backed layer; requires an `HttpClient`. */
|
|
237
|
+
static layer = Layer.effect(PackageFetcher, make);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
//#endregion
|
|
241
|
+
export { FetchError, PackageFetcher, PackageManifest, PackageNotFoundError, VersionNotFoundError };
|
package/PackageSpec.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Option, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/PackageSpec.ts
|
|
4
|
+
/**
|
|
5
|
+
* Node.js built-in base module names, used by
|
|
6
|
+
* {@link PackageSpec.normalizeSpecifier} to map built-in specifiers to the
|
|
7
|
+
* `node` types package. Matched against the specifier's FIRST path segment,
|
|
8
|
+
* so every built-in subpath (`fs/promises`, `readline/promises`,
|
|
9
|
+
* `util/types`, …) normalizes without enumerating them.
|
|
10
|
+
*/
|
|
11
|
+
const NODE_BUILTINS = /* @__PURE__ */ new Set([
|
|
12
|
+
"assert",
|
|
13
|
+
"async_hooks",
|
|
14
|
+
"buffer",
|
|
15
|
+
"child_process",
|
|
16
|
+
"cluster",
|
|
17
|
+
"console",
|
|
18
|
+
"constants",
|
|
19
|
+
"crypto",
|
|
20
|
+
"dgram",
|
|
21
|
+
"diagnostics_channel",
|
|
22
|
+
"dns",
|
|
23
|
+
"domain",
|
|
24
|
+
"events",
|
|
25
|
+
"fs",
|
|
26
|
+
"http",
|
|
27
|
+
"http2",
|
|
28
|
+
"https",
|
|
29
|
+
"inspector",
|
|
30
|
+
"module",
|
|
31
|
+
"net",
|
|
32
|
+
"os",
|
|
33
|
+
"path",
|
|
34
|
+
"perf_hooks",
|
|
35
|
+
"process",
|
|
36
|
+
"punycode",
|
|
37
|
+
"querystring",
|
|
38
|
+
"readline",
|
|
39
|
+
"repl",
|
|
40
|
+
"stream",
|
|
41
|
+
"string_decoder",
|
|
42
|
+
"timers",
|
|
43
|
+
"tls",
|
|
44
|
+
"trace_events",
|
|
45
|
+
"tty",
|
|
46
|
+
"url",
|
|
47
|
+
"util",
|
|
48
|
+
"v8",
|
|
49
|
+
"vm",
|
|
50
|
+
"wasi",
|
|
51
|
+
"worker_threads",
|
|
52
|
+
"zlib"
|
|
53
|
+
]);
|
|
54
|
+
/**
|
|
55
|
+
* A single name segment: no separators, no `@`, no whitespace, no cache-key
|
|
56
|
+
* or URL delimiters (`:` is the cacheKey delimiter — a version containing it
|
|
57
|
+
* would defeat `parseCacheKey`; `?`/`#` would truncate CDN URLs), and not a
|
|
58
|
+
* relative path component. Deliberately lenient beyond that — the CDN serves
|
|
59
|
+
* every historical malformation npm ever published — but strict enough that a
|
|
60
|
+
* name or version can never escape its cache directory when joined into a
|
|
61
|
+
* path.
|
|
62
|
+
*/
|
|
63
|
+
const SAFE_SEGMENT = /^(?!\.{1,2}$)[^/\\@\s:?#]+$/;
|
|
64
|
+
/** `name` or `@scope/name`, each segment {@link SAFE_SEGMENT}-shaped. */
|
|
65
|
+
const NAME_PATTERN = /^(@(?!\.{1,2}\/)[^/\\@\s:?#]+\/)?(?!\.{1,2}$)[^/\\@\s:?#]+$/;
|
|
66
|
+
/**
|
|
67
|
+
* Identifies a package at a version reference.
|
|
68
|
+
*
|
|
69
|
+
* @remarks
|
|
70
|
+
* `version` is the reference **as requested** — an exact version, a range or
|
|
71
|
+
* a dist-tag — and is pinned later by `TypeRegistry.resolveVersion`. Both
|
|
72
|
+
* fields are validated just enough that they can never traverse outside a
|
|
73
|
+
* cache directory when joined into a path; otherwise validation is lenient
|
|
74
|
+
* (CDN reality).
|
|
75
|
+
*
|
|
76
|
+
* Construct via `PackageSpec.make({ name, version })` or
|
|
77
|
+
* {@link PackageSpec.fromString} — never `new`.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* import { PackageSpec } from "@tsdoctor/registry";
|
|
82
|
+
*
|
|
83
|
+
* const pkg = PackageSpec.fromString("zod@3.23.8");
|
|
84
|
+
* console.log(pkg.name, pkg.version, pkg.cacheKey);
|
|
85
|
+
* // => "zod" "3.23.8" "zod:3.23.8"
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* @public
|
|
89
|
+
*/
|
|
90
|
+
var PackageSpec = class PackageSpec extends Schema.Class("PackageSpec")({
|
|
91
|
+
/** The npm package name (e.g. `"zod"`, `"@effect/schema"`). */
|
|
92
|
+
name: Schema.String.check(Schema.isPattern(NAME_PATTERN)),
|
|
93
|
+
/** The version reference as requested: exact, range, or dist-tag. */
|
|
94
|
+
version: Schema.String.check(Schema.isPattern(SAFE_SEGMENT))
|
|
95
|
+
}) {
|
|
96
|
+
/**
|
|
97
|
+
* Parse a `name@version` specifier (`"zod@3.23.8"`, `"@scope/pkg@^1.0.0"`).
|
|
98
|
+
*
|
|
99
|
+
* @remarks
|
|
100
|
+
* A specifier without a version part defaults to `"latest"`. An invalid
|
|
101
|
+
* specifier is developer wiring, not input — it throws (defect posture),
|
|
102
|
+
* exactly like `PackageSpec.make` with invalid fields.
|
|
103
|
+
*/
|
|
104
|
+
static fromString(spec) {
|
|
105
|
+
const at = spec.lastIndexOf("@");
|
|
106
|
+
if (at > 0) return PackageSpec.make({
|
|
107
|
+
name: spec.slice(0, at),
|
|
108
|
+
version: spec.slice(at + 1)
|
|
109
|
+
});
|
|
110
|
+
return PackageSpec.make({
|
|
111
|
+
name: spec,
|
|
112
|
+
version: "latest"
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Extract the npm package name from an arbitrary import specifier.
|
|
117
|
+
*
|
|
118
|
+
* @remarks
|
|
119
|
+
* `node:` specifiers and Node built-ins normalize to `"node"` (the
|
|
120
|
+
* `@types/node` convention); scoped specifiers keep scope and name but drop
|
|
121
|
+
* deep-import segments; bare specifiers keep only the first path segment.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```ts
|
|
125
|
+
* import { PackageSpec } from "@tsdoctor/registry";
|
|
126
|
+
*
|
|
127
|
+
* PackageSpec.normalizeSpecifier("node:fs"); // "node"
|
|
128
|
+
* PackageSpec.normalizeSpecifier("@effect/platform/Http"); // "@effect/platform"
|
|
129
|
+
* PackageSpec.normalizeSpecifier("lodash/fp"); // "lodash"
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
static normalizeSpecifier(specifier) {
|
|
133
|
+
if (specifier.startsWith("node:")) return "node";
|
|
134
|
+
if (specifier.startsWith("@")) {
|
|
135
|
+
const parts = specifier.split("/");
|
|
136
|
+
if (parts.length >= 2) return `${parts[0]}/${parts[1]}`;
|
|
137
|
+
return specifier;
|
|
138
|
+
}
|
|
139
|
+
const firstSlash = specifier.indexOf("/");
|
|
140
|
+
const base = firstSlash === -1 ? specifier : specifier.slice(0, firstSlash);
|
|
141
|
+
if (NODE_BUILTINS.has(base)) return "node";
|
|
142
|
+
return base;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Parse a {@link PackageSpec.cacheKey} back into a spec.
|
|
146
|
+
*
|
|
147
|
+
* @remarks
|
|
148
|
+
* Scoped keys (leading `@`) have three colon segments, unscoped keys two.
|
|
149
|
+
* `Option.none()` for keys matching neither shape — the metadata store may
|
|
150
|
+
* hold keys this package never wrote.
|
|
151
|
+
*/
|
|
152
|
+
static parseCacheKey(key) {
|
|
153
|
+
const parts = key.split(":");
|
|
154
|
+
const candidate = key.startsWith("@") ? parts.length === 3 ? {
|
|
155
|
+
name: `${parts[0]}/${parts[1]}`,
|
|
156
|
+
version: parts[2]
|
|
157
|
+
} : void 0 : parts.length === 2 ? {
|
|
158
|
+
name: parts[0],
|
|
159
|
+
version: parts[1]
|
|
160
|
+
} : void 0;
|
|
161
|
+
if (candidate === void 0 || !NAME_PATTERN.test(candidate.name) || !SAFE_SEGMENT.test(candidate.version)) return Option.none();
|
|
162
|
+
return Option.some(PackageSpec.make(candidate));
|
|
163
|
+
}
|
|
164
|
+
/** The `name@version` string form. */
|
|
165
|
+
toString() {
|
|
166
|
+
return `${this.name}@${this.version}`;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The colon-delimited metadata-store key: `@scope:name:version` for scoped
|
|
170
|
+
* packages, `name:version` otherwise.
|
|
171
|
+
*
|
|
172
|
+
* @remarks
|
|
173
|
+
* The scheme mirrors v3's on-disk layout but there is no compat contract
|
|
174
|
+
* with databases written by `type-registry-effect` — nothing was published.
|
|
175
|
+
*/
|
|
176
|
+
get cacheKey() {
|
|
177
|
+
return this.name.startsWith("@") ? `${this.name.replace("/", ":")}:${this.version}` : `${this.name}:${this.version}`;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
//#endregion
|
|
182
|
+
export { PackageSpec };
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @tsdoctor/registry
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@tsdoctor/registry)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
TypeScript virtual file systems for Effect: fetch, cache and resolve type definitions from npm via the jsDelivr CDN, and build `@typescript/vfs` environments for Twoslash-style documentation tooling.
|
|
9
|
+
|
|
10
|
+
## Why @tsdoctor/registry
|
|
11
|
+
|
|
12
|
+
Documentation tooling that typechecks code samples needs the declaration files for whatever packages those samples import, and needs them without a real `node_modules`. Fetching them by hand means writing a CDN client, a disk cache with expiry, and a module resolver that understands `exports`, `typesVersions` and the legacy `types` field. This package is those three things behind one service, with typed errors and no hidden IO — every filesystem, HTTP and database dependency is provided by you at the edge.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @tsdoctor/registry effect @effect/platform-node @effected/store @effected/semver
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm add @tsdoctor/registry effect @effect/platform-node @effected/store @effected/semver
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires Node.js >=24.11.0. Those four peers are required. The rest are optional and pull in only with the feature that uses them:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# for TypeCache.layerXdg
|
|
28
|
+
npm install @effected/xdg
|
|
29
|
+
# for TsEnvironment.make
|
|
30
|
+
npm install @effected/tsconfig-json typescript @typescript/vfs
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Every dependency here is a peer rather than a bundled dependency, including `@effected/semver`, whose types appear in no exported signature. That is deliberate: each `@effected/*` package pins an exact `effect` version as its own peer, so bundling one would create a second resolution site that can land on a different `effect` build than yours and fail at import. As peers they all resolve in your closure, against your `effect`.
|
|
34
|
+
|
|
35
|
+
Install `@effected/store` such that it resolves to one copy. `Cache` is keyed by package identity, so a duplicated install gives your `Cache.layerSqlite` a different key than the one `TypeCache` asks for, and the requirement goes unsatisfied with no error at the install site.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
Everything composes at the edge: this package builds no `FileSystem`, `HttpClient` or `Cache` layer of its own, so you pick the implementations.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { mkdtempSync } from "node:fs";
|
|
43
|
+
import { tmpdir } from "node:os";
|
|
44
|
+
import { join } from "node:path";
|
|
45
|
+
import { NodeFileSystem } from "@effect/platform-node";
|
|
46
|
+
import { Cache } from "@effected/store";
|
|
47
|
+
import { Effect, Layer, Path } from "effect";
|
|
48
|
+
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
49
|
+
import { PackageFetcher, PackageSpec, TypeCache, TypeRegistry } from "@tsdoctor/registry";
|
|
50
|
+
|
|
51
|
+
const RegistryLayer = TypeRegistry.layer.pipe(
|
|
52
|
+
Layer.provideMerge(
|
|
53
|
+
Layer.mergeAll(TypeCache.layer({ cacheDir: mkdtempSync(join(tmpdir(), "types-")) }), PackageFetcher.layer),
|
|
54
|
+
),
|
|
55
|
+
Layer.provide(Layer.mergeAll(Cache.layerTest(), NodeFileSystem.layer, Path.layer, FetchHttpClient.layer)),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const program = Effect.gen(function* () {
|
|
59
|
+
const registry = yield* TypeRegistry;
|
|
60
|
+
const version = yield* registry.resolveVersion("zod", "^3.23.0");
|
|
61
|
+
const vfs = yield* registry.getPackageVfs(PackageSpec.make({ name: "zod", version }));
|
|
62
|
+
console.log(version, vfs.size);
|
|
63
|
+
// the pinned version matching the range, then the file count (both vary by package)
|
|
64
|
+
return vfs;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await Effect.runPromise(program.pipe(Effect.provide(RegistryLayer)));
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`Cache.layerTest()` keeps the metadata plane in memory. For a persistent cache, swap it for `Cache.layerSqlite` and root the files under the XDG cache directory with `TypeCache.layerXdg` — see [getting started](docs/01-getting-started.md).
|
|
71
|
+
|
|
72
|
+
## Features
|
|
73
|
+
|
|
74
|
+
- `TypeRegistry` — the facade over cache, fetcher and resolver: `getVfs`, `getPackageVfs`, `fetchAndCache`, `resolveVersion`, `resolveImport`, `getTypeEntries`, `hasCached`, `clearCache`, `pruneCache`.
|
|
75
|
+
- `TypeCache` — a two-plane cache: declaration files on disk, per-package metadata in an `@effected/store` `Cache` with native TTL expiry and pruning.
|
|
76
|
+
- `PackageFetcher` — the jsDelivr-backed CDN client, requiring only an `HttpClient`.
|
|
77
|
+
- `TypeResolver` — static resolution of import specifiers and type entry points against a package manifest, covering `exports`, `typesVersions` and legacy fields.
|
|
78
|
+
- `TsEnvironment` — builds a `VirtualTypeScriptEnvironment` over a VFS from tsconfig-JSON compiler options, loading the optional `typescript` peers lazily so a consumer that never calls it never loads the compiler.
|
|
79
|
+
- `VirtualPackage` — synthesizes a package from locally supplied declaration content, for API Extractor output and hand-written ambient types.
|
|
80
|
+
- `RegistryEvent` and `RegistryObserver` — an opt-in, zero-cost progress channel; the library logs nothing on its own.
|
|
81
|
+
- Typed errors throughout: `FetchError`, `PackageNotFoundError`, `VersionNotFoundError`, `TypeCacheError`, `BatchLoadError`, `TsEnvironmentError`.
|
|
82
|
+
|
|
83
|
+
## Documentation
|
|
84
|
+
|
|
85
|
+
- [Getting started](docs/01-getting-started.md) — install, peer dependencies, and the edge-wiring recipes for temporary and XDG-rooted caches.
|
|
86
|
+
- [Caching](docs/02-caching.md) — the two-plane cache, TTL and the stale-vs-miss ladder, pruning, and choosing a cache root.
|
|
87
|
+
- [Observability](docs/03-observability.md) — the `RegistryEvent` catalogue, wiring an observer, and the tracing spans each method opens.
|
|
88
|
+
- [Architecture](docs/04-architecture.md) — how the services compose, why composition happens at the edge, and the error model.
|
|
89
|
+
- [API reference](docs/05-api-reference.md) — every exported service, schema, helper and error.
|
|
90
|
+
- [Troubleshooting](docs/06-troubleshooting.md) — missing services, optional peers, cache permissions and CDN failures.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
[MIT](LICENSE)
|
package/RegistryEvent.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/RegistryEvent.ts
|
|
4
|
+
/**
|
|
5
|
+
* Discriminated union of typed progress events emitted during registry
|
|
6
|
+
* operations.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* The consumer-facing progress surface. Emission is opt-in and zero-cost:
|
|
10
|
+
* internal call sites resolve the {@link RegistryObserver} via
|
|
11
|
+
* `Effect.serviceOption`, so no requirement is added to any signature and
|
|
12
|
+
* absence is a no-op. The library performs no `Effect.log` of its own — the
|
|
13
|
+
* host owns presentation.
|
|
14
|
+
*
|
|
15
|
+
* Schema-backed (the store `CacheEventPayload` precedent) because events
|
|
16
|
+
* cross the library/host boundary and hosts ship them to telemetry. Narrow
|
|
17
|
+
* with `switch (event._tag)` or `Match`.
|
|
18
|
+
*
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
21
|
+
const RegistryEvent = Schema.Union([
|
|
22
|
+
Schema.TaggedStruct("VersionResolved", {
|
|
23
|
+
package: Schema.String,
|
|
24
|
+
requested: Schema.String,
|
|
25
|
+
resolved: Schema.String
|
|
26
|
+
}),
|
|
27
|
+
Schema.TaggedStruct("VersionResolveFailed", {
|
|
28
|
+
package: Schema.String,
|
|
29
|
+
requested: Schema.String,
|
|
30
|
+
kind: Schema.Literals([
|
|
31
|
+
"not-found",
|
|
32
|
+
"no-match",
|
|
33
|
+
"network"
|
|
34
|
+
])
|
|
35
|
+
}),
|
|
36
|
+
Schema.TaggedStruct("CacheHit", {
|
|
37
|
+
package: Schema.String,
|
|
38
|
+
version: Schema.String,
|
|
39
|
+
/** How long ago the entry was cached. */
|
|
40
|
+
age: Schema.Duration
|
|
41
|
+
}),
|
|
42
|
+
Schema.TaggedStruct("CacheStale", {
|
|
43
|
+
package: Schema.String,
|
|
44
|
+
version: Schema.String
|
|
45
|
+
}),
|
|
46
|
+
Schema.TaggedStruct("CacheMiss", {
|
|
47
|
+
package: Schema.String,
|
|
48
|
+
version: Schema.String
|
|
49
|
+
}),
|
|
50
|
+
Schema.TaggedStruct("FetchStart", {
|
|
51
|
+
package: Schema.String,
|
|
52
|
+
version: Schema.String
|
|
53
|
+
}),
|
|
54
|
+
Schema.TaggedStruct("FetchFailed", {
|
|
55
|
+
url: Schema.String,
|
|
56
|
+
status: Schema.Number,
|
|
57
|
+
bodySnippet: Schema.String
|
|
58
|
+
}),
|
|
59
|
+
Schema.TaggedStruct("PackageLoaded", {
|
|
60
|
+
package: Schema.String,
|
|
61
|
+
version: Schema.String,
|
|
62
|
+
files: Schema.Number,
|
|
63
|
+
source: Schema.Literals(["cache", "network"]),
|
|
64
|
+
duration: Schema.Duration
|
|
65
|
+
}),
|
|
66
|
+
Schema.TaggedStruct("PackageLoadFailed", {
|
|
67
|
+
package: Schema.String,
|
|
68
|
+
version: Schema.String,
|
|
69
|
+
kind: Schema.Literals([
|
|
70
|
+
"not-found",
|
|
71
|
+
"version-range",
|
|
72
|
+
"schema",
|
|
73
|
+
"network",
|
|
74
|
+
"cache",
|
|
75
|
+
"unknown"
|
|
76
|
+
]),
|
|
77
|
+
/** The typed error itself, preserved structurally. */
|
|
78
|
+
error: Schema.Defect()
|
|
79
|
+
}),
|
|
80
|
+
Schema.TaggedStruct("BatchStart", {
|
|
81
|
+
total: Schema.Number,
|
|
82
|
+
packages: Schema.Array(Schema.String)
|
|
83
|
+
}),
|
|
84
|
+
Schema.TaggedStruct("BatchComplete", {
|
|
85
|
+
loaded: Schema.Number,
|
|
86
|
+
failed: Schema.Number,
|
|
87
|
+
total: Schema.Number,
|
|
88
|
+
totalFiles: Schema.Number,
|
|
89
|
+
duration: Schema.Duration
|
|
90
|
+
})
|
|
91
|
+
]);
|
|
92
|
+
/**
|
|
93
|
+
* The opt-in registry event observer.
|
|
94
|
+
*
|
|
95
|
+
* @remarks
|
|
96
|
+
* Providing no observer layer is the default and costs nothing — every
|
|
97
|
+
* internal emission site resolves this service via `Effect.serviceOption`
|
|
98
|
+
* and no-ops on absence. Events here are progress reporting for a host UI —
|
|
99
|
+
* a push callback with no subscription lifecycle or `Scope`, usable from
|
|
100
|
+
* non-Effect hosts. (The store `Cache` exposes a `PubSub` instead because
|
|
101
|
+
* its events are intrinsic to an eviction-bearing store; the two postures
|
|
102
|
+
* are deliberate and should not be unified.)
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```ts
|
|
106
|
+
* import { RegistryObserver } from "@tsdoctor/registry";
|
|
107
|
+
*
|
|
108
|
+
* const ObserverLayer = RegistryObserver.layerCallback((event) => {
|
|
109
|
+
* if (event._tag === "PackageLoadFailed") console.warn(event.package, event.kind);
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* @public
|
|
114
|
+
*/
|
|
115
|
+
var RegistryObserver = class RegistryObserver extends Context.Service()("type-registry-effect/RegistryObserver") {
|
|
116
|
+
/**
|
|
117
|
+
* Build an observer layer from a plain callback — the lowest-friction
|
|
118
|
+
* bridge for non-Effect hosts.
|
|
119
|
+
*
|
|
120
|
+
* @remarks
|
|
121
|
+
* A throwing callback is a programmer bug and stays a defect; it is not
|
|
122
|
+
* laundered into any typed error channel.
|
|
123
|
+
*/
|
|
124
|
+
static layerCallback(onEvent) {
|
|
125
|
+
return Layer.succeed(RegistryObserver, { emit: (event) => Effect.sync(() => onEvent(event)) });
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A no-op observer. Equivalent to providing nothing, but explicit — makes
|
|
129
|
+
* "events are intentionally dropped" visible in a composition.
|
|
130
|
+
*/
|
|
131
|
+
static layerNoop = Layer.succeed(RegistryObserver, { emit: () => Effect.void });
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Emit a {@link (RegistryEvent:type)} to the host's observer, if one is
|
|
135
|
+
* provided. Internal emission sites use this; it adds no requirement to the
|
|
136
|
+
* caller's signature and is a no-op when no observer layer is in scope.
|
|
137
|
+
*
|
|
138
|
+
* @internal
|
|
139
|
+
*/
|
|
140
|
+
const emit = (event) => Effect.serviceOption(RegistryObserver).pipe(Effect.flatMap(Option.match({
|
|
141
|
+
onNone: () => Effect.void,
|
|
142
|
+
onSome: (observer) => observer.emit(event)
|
|
143
|
+
})));
|
|
144
|
+
|
|
145
|
+
//#endregion
|
|
146
|
+
export { RegistryEvent, RegistryObserver, emit };
|