@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 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
+ [![npm](https://img.shields.io/npm/v/@tsdoctor/registry?label=npm&color=cb3837)](https://www.npmjs.com/package/@tsdoctor/registry)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](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)
@@ -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 };