@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/TsEnvironment.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { isTypeDefinition } from "./internal/resolution.js";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/TsEnvironment.ts
|
|
5
|
+
/**
|
|
6
|
+
* Raised when building a virtual TypeScript environment fails — including
|
|
7
|
+
* when the optional `typescript` / `@typescript/vfs` /
|
|
8
|
+
* `@effected/tsconfig-json` peers are not installed.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
var TsEnvironmentError = class extends Schema.TaggedError()("TsEnvironmentError", {
|
|
13
|
+
/** The underlying failure, preserved structurally. */
|
|
14
|
+
cause: Schema.Defect() }) {
|
|
15
|
+
get message() {
|
|
16
|
+
return "Failed to create the virtual TypeScript environment";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The `@typescript/vfs` seam: builds a `VirtualTypeScriptEnvironment` over a
|
|
21
|
+
* {@link Vfs} plus the TypeScript default lib files.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* The ONLY module touching the optional `typescript` / `@typescript/vfs` /
|
|
25
|
+
* `@effected/tsconfig-json` peers, and it loads all three lazily inside
|
|
26
|
+
* {@link TsEnvironment.make} — a consumer that never calls it never loads
|
|
27
|
+
* the compiler, and a missing peer fails typed as
|
|
28
|
+
* {@link TsEnvironmentError} instead of crashing at import time. Keep every
|
|
29
|
+
* one of them behind that dynamic `import()`: a static value import here is
|
|
30
|
+
* reachable from `index.ts`, so it would turn an omitted optional peer into
|
|
31
|
+
* an `ERR_MODULE_NOT_FOUND` on the entry graph for consumers who never
|
|
32
|
+
* touch this module. Only the type-only `CompilerOptions` import is safe
|
|
33
|
+
* statically, because it erases. The underlying `createDefaultMapFromNodeModules` /
|
|
34
|
+
* `createFSBackedSystem` read the real filesystem through TypeScript's own
|
|
35
|
+
* `sys`, outside the Effect `FileSystem` service — accepted and documented;
|
|
36
|
+
* this module is why the package is integrated tier on its own surface.
|
|
37
|
+
*
|
|
38
|
+
* No cache map (v3's `createTypeScriptCache` returned a one-entry `Map`
|
|
39
|
+
* keyed by `JSON.stringify(compilerOptions)`): a consumer that wants keyed
|
|
40
|
+
* reuse holds its own map.
|
|
41
|
+
*
|
|
42
|
+
* `VirtualTypeScriptEnvironment` is deliberately not re-exported — import
|
|
43
|
+
* the type from `@typescript/vfs`, which consumers of this module already
|
|
44
|
+
* declare.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* import { TsEnvironment } from "@tsdoctor/registry";
|
|
49
|
+
*
|
|
50
|
+
* const environment = TsEnvironment.make({
|
|
51
|
+
* vfs,
|
|
52
|
+
* compilerOptions: { strict: true, target: "es2022" },
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
var TsEnvironment = class {
|
|
59
|
+
constructor() {}
|
|
60
|
+
/** Build a `VirtualTypeScriptEnvironment` over a {@link Vfs}. */
|
|
61
|
+
static make(options) {
|
|
62
|
+
return Effect.gen(function* () {
|
|
63
|
+
const [tsModule, tsVfs, { TsEnumCodec }] = yield* Effect.tryPromise({
|
|
64
|
+
try: () => Promise.all([
|
|
65
|
+
import("typescript"),
|
|
66
|
+
import("@typescript/vfs"),
|
|
67
|
+
import("@effected/tsconfig-json")
|
|
68
|
+
]),
|
|
69
|
+
catch: (cause) => new TsEnvironmentError({ cause })
|
|
70
|
+
});
|
|
71
|
+
return yield* Effect.try({
|
|
72
|
+
try: () => {
|
|
73
|
+
const typescript = tsModule.default;
|
|
74
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
75
|
+
const compilerOptions = TsEnumCodec.encodeCompilerOptions(options.compilerOptions);
|
|
76
|
+
const executing = typescript.sys?.getExecutingFilePath?.();
|
|
77
|
+
const libDirectory = executing === void 0 ? void 0 : executing.slice(0, Math.max(executing.lastIndexOf("/"), executing.lastIndexOf("\\")));
|
|
78
|
+
const system = new Map(tsVfs.createDefaultMapFromNodeModules(compilerOptions, typescript, libDirectory));
|
|
79
|
+
const rootFiles = [];
|
|
80
|
+
for (const [path, content] of options.vfs) {
|
|
81
|
+
const rooted = path.startsWith("/") ? path : `${projectRoot}/${path}`;
|
|
82
|
+
system.set(rooted, content);
|
|
83
|
+
if (isTypeDefinition(rooted)) rootFiles.push(rooted);
|
|
84
|
+
}
|
|
85
|
+
const sys = tsVfs.createFSBackedSystem(system, projectRoot, typescript, libDirectory);
|
|
86
|
+
return tsVfs.createVirtualTypeScriptEnvironment(sys, rootFiles, typescript, compilerOptions);
|
|
87
|
+
},
|
|
88
|
+
catch: (cause) => new TsEnvironmentError({ cause })
|
|
89
|
+
});
|
|
90
|
+
}).pipe(Effect.withSpan("TsEnvironment.make"));
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
//#endregion
|
|
95
|
+
export { TsEnvironment, TsEnvironmentError };
|
package/TypeCache.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { isSafeRelativePath } from "./internal/resolution.js";
|
|
2
|
+
import { PackageSpec } from "./PackageSpec.js";
|
|
3
|
+
import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
|
|
4
|
+
import { Cache } from "@effected/store";
|
|
5
|
+
|
|
6
|
+
//#region src/TypeCache.ts
|
|
7
|
+
/**
|
|
8
|
+
* Per-package cache metadata: the pinned version, when it was cached and how
|
|
9
|
+
* long it lives.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Stored JSON-encoded in the metadata plane (`@effected/store`'s `Cache`);
|
|
13
|
+
* `ttl` is also forwarded to the store's native TTL so expiry happens there
|
|
14
|
+
* (evict-on-read, bulk prune). An absent `ttl` means the entry never expires.
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
var TypeCacheMetadata = class extends Schema.Class("TypeCacheMetadata")({
|
|
19
|
+
/** The pinned version the files on disk belong to. */
|
|
20
|
+
version: Schema.String,
|
|
21
|
+
/** When the package was cached. */
|
|
22
|
+
cachedAt: Schema.DateTimeUtcFromString,
|
|
23
|
+
/** Time-to-live; absent = never expires. */
|
|
24
|
+
ttl: Schema.optionalKey(Schema.DurationFromMillis)
|
|
25
|
+
}) {};
|
|
26
|
+
/** JSON codec for the metadata plane's stored bytes. */
|
|
27
|
+
const MetadataFromJson = Schema.fromJsonString(TypeCacheMetadata);
|
|
28
|
+
/**
|
|
29
|
+
* Raised when a cache operation fails: disk IO, metadata-store IO, or a file
|
|
30
|
+
* path that tries to escape the cache directory.
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* `cause` carries the underlying failure structurally (a `PlatformError`,
|
|
34
|
+
* the store's `CacheError`, or a `SchemaError` from metadata decoding); v3
|
|
35
|
+
* flattened everything to `message: String(error)`.
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
var TypeCacheError = class extends Schema.TaggedError()("TypeCacheError", {
|
|
40
|
+
/** The cache operation that failed. */
|
|
41
|
+
operation: Schema.Literals([
|
|
42
|
+
"exists",
|
|
43
|
+
"read",
|
|
44
|
+
"write",
|
|
45
|
+
"list",
|
|
46
|
+
"readMetadata",
|
|
47
|
+
"writeMetadata",
|
|
48
|
+
"getVfs",
|
|
49
|
+
"remove",
|
|
50
|
+
"prune"
|
|
51
|
+
]),
|
|
52
|
+
/** The file path or metadata key involved. */
|
|
53
|
+
path: Schema.String,
|
|
54
|
+
/** The underlying failure, preserved structurally. */
|
|
55
|
+
cause: Schema.Defect()
|
|
56
|
+
}) {
|
|
57
|
+
get message() {
|
|
58
|
+
return `Type cache ${this.operation} failed for "${this.path}"`;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const make = (cacheDir) => Effect.gen(function* () {
|
|
62
|
+
const fs = yield* FileSystem.FileSystem;
|
|
63
|
+
const cache = yield* Cache;
|
|
64
|
+
const path = yield* Path.Path;
|
|
65
|
+
const pkgDir = (pkg) => path.join(cacheDir, pkg.name, pkg.version);
|
|
66
|
+
const stagingDir = (pkg) => path.join(cacheDir, pkg.name, `.staging-${pkg.version}`);
|
|
67
|
+
const fail = (operation, target) => (cause) => new TypeCacheError({
|
|
68
|
+
operation,
|
|
69
|
+
path: target,
|
|
70
|
+
cause
|
|
71
|
+
});
|
|
72
|
+
const safeJoin = (operation, root, filePath) => isSafeRelativePath(filePath) ? Effect.succeed(path.join(root, filePath)) : Effect.fail(new TypeCacheError({
|
|
73
|
+
operation,
|
|
74
|
+
path: filePath,
|
|
75
|
+
cause: /* @__PURE__ */ new Error("path escapes the package cache directory")
|
|
76
|
+
}));
|
|
77
|
+
const safePath = (operation, pkg, filePath) => safeJoin(operation, pkgDir(pkg), filePath);
|
|
78
|
+
const listRecursive = (dir, relativeTo, depth) => Effect.gen(function* () {
|
|
79
|
+
if (depth > 256) return yield* Effect.fail(fail("list", dir)(/* @__PURE__ */ new Error("cache tree exceeds the nesting-depth limit")));
|
|
80
|
+
const entries = yield* fs.readDirectory(dir).pipe(Effect.mapError(fail("list", dir)));
|
|
81
|
+
const files = [];
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const fullPath = path.join(dir, entry);
|
|
84
|
+
if ((yield* fs.stat(fullPath).pipe(Effect.mapError(fail("list", fullPath)))).type === "Directory") files.push(...yield* listRecursive(fullPath, relativeTo, depth + 1));
|
|
85
|
+
else files.push(path.relative(relativeTo, fullPath));
|
|
86
|
+
}
|
|
87
|
+
return files;
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
exists: Effect.fn("TypeCache.exists")(function* (pkg) {
|
|
91
|
+
return yield* fs.exists(pkgDir(pkg)).pipe(Effect.mapError(fail("exists", pkgDir(pkg))));
|
|
92
|
+
}),
|
|
93
|
+
read: Effect.fn("TypeCache.read")(function* (pkg, filePath) {
|
|
94
|
+
const fullPath = yield* safePath("read", pkg, filePath);
|
|
95
|
+
return yield* fs.readFileString(fullPath).pipe(Effect.mapError(fail("read", fullPath)));
|
|
96
|
+
}),
|
|
97
|
+
write: Effect.fn("TypeCache.write")(function* (pkg, filePath, content) {
|
|
98
|
+
const fullPath = yield* safePath("write", pkg, filePath);
|
|
99
|
+
const dirPath = path.dirname(fullPath);
|
|
100
|
+
yield* fs.makeDirectory(dirPath, { recursive: true }).pipe(Effect.mapError(fail("write", dirPath)));
|
|
101
|
+
yield* fs.writeFileString(fullPath, content).pipe(Effect.mapError(fail("write", fullPath)));
|
|
102
|
+
}),
|
|
103
|
+
writePackage: Effect.fn("TypeCache.writePackage")(function* (pkg, files) {
|
|
104
|
+
const staging = stagingDir(pkg);
|
|
105
|
+
const live = pkgDir(pkg);
|
|
106
|
+
yield* fs.remove(staging, {
|
|
107
|
+
recursive: true,
|
|
108
|
+
force: true
|
|
109
|
+
}).pipe(Effect.mapError(fail("write", staging)));
|
|
110
|
+
yield* fs.makeDirectory(staging, { recursive: true }).pipe(Effect.mapError(fail("write", staging)));
|
|
111
|
+
for (const [filePath, content] of files) {
|
|
112
|
+
const fullPath = yield* safeJoin("write", staging, filePath);
|
|
113
|
+
const dirPath = path.dirname(fullPath);
|
|
114
|
+
yield* fs.makeDirectory(dirPath, { recursive: true }).pipe(Effect.mapError(fail("write", dirPath)));
|
|
115
|
+
yield* fs.writeFileString(fullPath, content).pipe(Effect.mapError(fail("write", fullPath)));
|
|
116
|
+
}
|
|
117
|
+
yield* fs.remove(live, {
|
|
118
|
+
recursive: true,
|
|
119
|
+
force: true
|
|
120
|
+
}).pipe(Effect.mapError(fail("write", live)));
|
|
121
|
+
yield* fs.rename(staging, live).pipe(Effect.mapError(fail("write", live)));
|
|
122
|
+
}),
|
|
123
|
+
listFiles: Effect.fn("TypeCache.listFiles")(function* (pkg) {
|
|
124
|
+
return yield* listRecursive(pkgDir(pkg), pkgDir(pkg), 0);
|
|
125
|
+
}),
|
|
126
|
+
readMetadata: Effect.fn("TypeCache.readMetadata")(function* (pkg) {
|
|
127
|
+
const key = pkg.cacheKey;
|
|
128
|
+
const entry = yield* cache.get(key).pipe(Effect.mapError(fail("readMetadata", key)));
|
|
129
|
+
if (Option.isNone(entry)) return Option.none();
|
|
130
|
+
const decoded = yield* Schema.decodeUnknownEffect(MetadataFromJson)(new TextDecoder().decode(entry.value.value)).pipe(Effect.mapError(fail("readMetadata", key)));
|
|
131
|
+
return Option.some(decoded);
|
|
132
|
+
}),
|
|
133
|
+
writeMetadata: Effect.fn("TypeCache.writeMetadata")(function* (pkg, metadata) {
|
|
134
|
+
const key = pkg.cacheKey;
|
|
135
|
+
const encoded = yield* Schema.encodeEffect(MetadataFromJson)(metadata).pipe(Effect.mapError(fail("writeMetadata", key)));
|
|
136
|
+
yield* cache.set({
|
|
137
|
+
key,
|
|
138
|
+
value: new TextEncoder().encode(encoded),
|
|
139
|
+
contentType: "application/json",
|
|
140
|
+
tags: [pkg.name],
|
|
141
|
+
...metadata.ttl !== void 0 ? { ttl: metadata.ttl } : {}
|
|
142
|
+
}).pipe(Effect.mapError(fail("writeMetadata", key)));
|
|
143
|
+
}),
|
|
144
|
+
getVfs: Effect.fn("TypeCache.getVfs")(function* (pkg) {
|
|
145
|
+
const dir = pkgDir(pkg);
|
|
146
|
+
const files = yield* listRecursive(dir, dir, 0);
|
|
147
|
+
const vfs = /* @__PURE__ */ new Map();
|
|
148
|
+
for (const file of files) {
|
|
149
|
+
const fullPath = path.join(dir, file);
|
|
150
|
+
const content = yield* fs.readFileString(fullPath).pipe(Effect.mapError(fail("getVfs", fullPath)));
|
|
151
|
+
vfs.set(`node_modules/${pkg.name}/${file.replace(/\\/g, "/")}`, content);
|
|
152
|
+
}
|
|
153
|
+
return vfs;
|
|
154
|
+
}),
|
|
155
|
+
remove: Effect.fn("TypeCache.remove")(function* (pkg) {
|
|
156
|
+
const key = pkg.cacheKey;
|
|
157
|
+
const dir = pkgDir(pkg);
|
|
158
|
+
yield* cache.invalidate(key).pipe(Effect.mapError(fail("remove", key)));
|
|
159
|
+
yield* fs.remove(dir, {
|
|
160
|
+
recursive: true,
|
|
161
|
+
force: true
|
|
162
|
+
}).pipe(Effect.mapError(fail("remove", dir)));
|
|
163
|
+
}),
|
|
164
|
+
prune: Effect.gen(function* () {
|
|
165
|
+
const result = yield* cache.prune().pipe(Effect.mapError(fail("prune", cacheDir)));
|
|
166
|
+
const removed = [];
|
|
167
|
+
for (const key of result.keys) {
|
|
168
|
+
const parsed = PackageSpec.parseCacheKey(key);
|
|
169
|
+
if (Option.isNone(parsed)) continue;
|
|
170
|
+
const dir = pkgDir(parsed.value);
|
|
171
|
+
if (yield* fs.remove(dir, {
|
|
172
|
+
recursive: true,
|
|
173
|
+
force: true
|
|
174
|
+
}).pipe(Effect.as(true), Effect.orElseSucceed(() => false))) removed.push({
|
|
175
|
+
name: parsed.value.name,
|
|
176
|
+
version: parsed.value.version
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
count: result.count,
|
|
181
|
+
removed
|
|
182
|
+
};
|
|
183
|
+
}).pipe(Effect.withSpan("TypeCache.prune"))
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
/**
|
|
187
|
+
* The two-plane cache for fetched type definitions: files on disk under
|
|
188
|
+
* `<cacheDir>/<name>/<version>/`, metadata in `@effected/store`'s `Cache`
|
|
189
|
+
* with native TTL expiry.
|
|
190
|
+
*
|
|
191
|
+
* @remarks
|
|
192
|
+
* The layer statics are parameterized factories — bind the built layer to a
|
|
193
|
+
* `const` and provide that, or two provide sites mint two caches (the layer
|
|
194
|
+
* memoization discipline). The metadata plane is swappable in tests: store's
|
|
195
|
+
* `Cache.layerTest` (`:memory:`) satisfies {@link TypeCache.layer} with no
|
|
196
|
+
* real database file.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```ts
|
|
200
|
+
* import { TypeCache } from "@tsdoctor/registry";
|
|
201
|
+
*
|
|
202
|
+
* const TypeCacheLayer = TypeCache.layer({ cacheDir: "/var/cache/my-app/types" });
|
|
203
|
+
* ```
|
|
204
|
+
*
|
|
205
|
+
* @public
|
|
206
|
+
*/
|
|
207
|
+
var TypeCache = class TypeCache extends Context.Service()("type-registry-effect/TypeCache") {
|
|
208
|
+
/**
|
|
209
|
+
* A cache rooted at an explicit directory.
|
|
210
|
+
*
|
|
211
|
+
* @remarks
|
|
212
|
+
* `cacheDir` must be an absolute path — a relative one is developer wiring
|
|
213
|
+
* and dies at layer construction.
|
|
214
|
+
*/
|
|
215
|
+
static layer(options) {
|
|
216
|
+
return Layer.effect(TypeCache, Effect.gen(function* () {
|
|
217
|
+
if (!(yield* Path.Path).isAbsolute(options.cacheDir)) return yield* Effect.die(/* @__PURE__ */ new Error(`TypeCache.layer: cacheDir must be an absolute path, received "${options.cacheDir}"`));
|
|
218
|
+
return yield* make(options.cacheDir);
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* A cache rooted under the application's XDG cache directory:
|
|
223
|
+
* `<AppDirs cache>/<namespace>/`.
|
|
224
|
+
*
|
|
225
|
+
* @remarks
|
|
226
|
+
* Uses `AppDirs.ensureCache`, which also discharges the store's recorded
|
|
227
|
+
* constraint that the database directory must exist before
|
|
228
|
+
* `SqliteClient.layer` is built. This package never builds the store layer
|
|
229
|
+
* itself — the consumer composes `Cache.layerSqlite` (or `layerTest`) at
|
|
230
|
+
* the edge.
|
|
231
|
+
*/
|
|
232
|
+
static layerXdg(options) {
|
|
233
|
+
const namespace = options?.namespace ?? "ts-vfs";
|
|
234
|
+
return Layer.effect(TypeCache, Effect.gen(function* () {
|
|
235
|
+
if (namespace.length === 0 || /[/\\]/.test(namespace) || namespace === "." || namespace === "..") return yield* Effect.die(/* @__PURE__ */ new Error(`TypeCache.layerXdg: \`namespace\` must be a single path component, received ${JSON.stringify(namespace)}`));
|
|
236
|
+
const xdg = yield* Effect.tryPromise({
|
|
237
|
+
try: () => import("@effected/xdg"),
|
|
238
|
+
catch: (cause) => new Error("TypeCache.layerXdg requires the optional `@effected/xdg` peer to be installed", { cause })
|
|
239
|
+
}).pipe(Effect.orDie);
|
|
240
|
+
const appDirs = yield* xdg.AppDirs;
|
|
241
|
+
const fs = yield* FileSystem.FileSystem;
|
|
242
|
+
const path = yield* Path.Path;
|
|
243
|
+
const base = yield* appDirs.ensureCache;
|
|
244
|
+
const cacheDir = path.join(base, namespace);
|
|
245
|
+
yield* fs.makeDirectory(cacheDir, { recursive: true }).pipe(Effect.mapError((cause) => new xdg.AppDirsError({
|
|
246
|
+
directory: "cache",
|
|
247
|
+
path: cacheDir,
|
|
248
|
+
cause
|
|
249
|
+
})));
|
|
250
|
+
return yield* make(cacheDir);
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
//#endregion
|
|
256
|
+
export { TypeCache, TypeCacheError, TypeCacheMetadata };
|
package/TypeRegistry.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { packageJsonUrl } from "./internal/jsdelivr.js";
|
|
2
|
+
import { emit } from "./RegistryEvent.js";
|
|
3
|
+
import { FetchError, PackageFetcher, PackageManifest, PackageNotFoundError, VersionNotFoundError } from "./PackageFetcher.js";
|
|
4
|
+
import { TypeCache, TypeCacheMetadata } from "./TypeCache.js";
|
|
5
|
+
import { TypeResolver } from "./TypeResolver.js";
|
|
6
|
+
import { mergeVfs } from "./Vfs.js";
|
|
7
|
+
import { Context, DateTime, Effect, Layer, Option, Schema, Semaphore } from "effect";
|
|
8
|
+
import { Range, SemVer } from "@effected/semver";
|
|
9
|
+
|
|
10
|
+
//#region src/TypeRegistry.ts
|
|
11
|
+
/**
|
|
12
|
+
* Raised by {@link TypeRegistryShape.getVfs} when **every** requested package
|
|
13
|
+
* fails.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* Carries the per-package failures structurally. v3 abused
|
|
17
|
+
* `PackageNotFoundError` for this case, with a comma-joined `name` and an
|
|
18
|
+
* empty `version`.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
var BatchLoadError = class extends Schema.TaggedError()("BatchLoadError", {
|
|
23
|
+
/** One entry per failed package, with its typed error preserved. */
|
|
24
|
+
failures: Schema.Array(Schema.Struct({
|
|
25
|
+
name: Schema.String,
|
|
26
|
+
version: Schema.String,
|
|
27
|
+
error: Schema.Defect()
|
|
28
|
+
})) }) {
|
|
29
|
+
get message() {
|
|
30
|
+
return `Failed to load type definitions for all ${this.failures.length} requested package(s)`;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Classify a per-package load failure into a stable `PackageLoadFailed`
|
|
35
|
+
* event kind — from typed error tags and structured fields, never from
|
|
36
|
+
* message substrings (v3's `classifyLoadError` did substring matching over
|
|
37
|
+
* stringified errors; it is dead).
|
|
38
|
+
*/
|
|
39
|
+
const classify = (error) => {
|
|
40
|
+
if (typeof error !== "object" || error === null || !("_tag" in error)) return "unknown";
|
|
41
|
+
switch (error._tag) {
|
|
42
|
+
case "PackageNotFoundError": return "not-found";
|
|
43
|
+
case "VersionNotFoundError": return "version-range";
|
|
44
|
+
case "TypeCacheError": return "cache";
|
|
45
|
+
case "FetchError": {
|
|
46
|
+
const fetchError = error;
|
|
47
|
+
if (fetchError.status === 404) return "not-found";
|
|
48
|
+
if (fetchError.kind === "schema") return "schema";
|
|
49
|
+
return "network";
|
|
50
|
+
}
|
|
51
|
+
default: return "unknown";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const make = Effect.gen(function* () {
|
|
55
|
+
const cache = yield* TypeCache;
|
|
56
|
+
const fetcher = yield* PackageFetcher;
|
|
57
|
+
const mutations = yield* Semaphore.make(1);
|
|
58
|
+
const fetchAndCacheImpl = (pkg, options) => Effect.gen(function* () {
|
|
59
|
+
yield* emit({
|
|
60
|
+
_tag: "FetchStart",
|
|
61
|
+
package: pkg.name,
|
|
62
|
+
version: pkg.version
|
|
63
|
+
});
|
|
64
|
+
const manifest = yield* fetcher.getPackageJson(pkg);
|
|
65
|
+
const typeFiles = yield* fetcher.getTypeFiles(pkg);
|
|
66
|
+
const files = [["package.json", JSON.stringify(manifest, null, 2)]];
|
|
67
|
+
for (const [filePath, content] of typeFiles) {
|
|
68
|
+
const normalized = filePath.replace(/^\/+/, "");
|
|
69
|
+
if (normalized !== "package.json") files.push([normalized, content]);
|
|
70
|
+
}
|
|
71
|
+
const now = yield* DateTime.now;
|
|
72
|
+
yield* mutations.withPermits(1)(Effect.gen(function* () {
|
|
73
|
+
yield* cache.writePackage(pkg, files);
|
|
74
|
+
yield* cache.writeMetadata(pkg, TypeCacheMetadata.make({
|
|
75
|
+
version: pkg.version,
|
|
76
|
+
cachedAt: now,
|
|
77
|
+
...options?.ttl !== void 0 ? { ttl: options.ttl } : {}
|
|
78
|
+
}));
|
|
79
|
+
}));
|
|
80
|
+
});
|
|
81
|
+
const getPackageVfsImpl = (pkg, options) => Effect.gen(function* () {
|
|
82
|
+
const autoFetch = options?.autoFetch ?? true;
|
|
83
|
+
const [duration, result] = yield* Effect.timed(Effect.gen(function* () {
|
|
84
|
+
const metadata = yield* cache.readMetadata(pkg);
|
|
85
|
+
const diskExists = yield* cache.exists(pkg);
|
|
86
|
+
let source = "cache";
|
|
87
|
+
if (Option.isSome(metadata) && diskExists) {
|
|
88
|
+
const now = yield* DateTime.now;
|
|
89
|
+
yield* emit({
|
|
90
|
+
_tag: "CacheHit",
|
|
91
|
+
package: pkg.name,
|
|
92
|
+
version: pkg.version,
|
|
93
|
+
age: DateTime.distance(metadata.value.cachedAt, now)
|
|
94
|
+
});
|
|
95
|
+
} else if (Option.isNone(metadata) && diskExists) {
|
|
96
|
+
yield* emit({
|
|
97
|
+
_tag: "CacheStale",
|
|
98
|
+
package: pkg.name,
|
|
99
|
+
version: pkg.version
|
|
100
|
+
});
|
|
101
|
+
if (autoFetch) {
|
|
102
|
+
yield* fetchAndCacheImpl(pkg, options);
|
|
103
|
+
source = "network";
|
|
104
|
+
}
|
|
105
|
+
} else if (autoFetch) {
|
|
106
|
+
yield* emit({
|
|
107
|
+
_tag: "CacheMiss",
|
|
108
|
+
package: pkg.name,
|
|
109
|
+
version: pkg.version
|
|
110
|
+
});
|
|
111
|
+
yield* fetchAndCacheImpl(pkg, options);
|
|
112
|
+
source = "network";
|
|
113
|
+
} else return yield* Effect.fail(new PackageNotFoundError({
|
|
114
|
+
name: pkg.name,
|
|
115
|
+
version: pkg.version
|
|
116
|
+
}));
|
|
117
|
+
return {
|
|
118
|
+
vfs: yield* cache.getVfs(pkg),
|
|
119
|
+
source
|
|
120
|
+
};
|
|
121
|
+
}));
|
|
122
|
+
yield* emit({
|
|
123
|
+
_tag: "PackageLoaded",
|
|
124
|
+
package: pkg.name,
|
|
125
|
+
version: pkg.version,
|
|
126
|
+
files: result.vfs.size,
|
|
127
|
+
source: result.source,
|
|
128
|
+
duration
|
|
129
|
+
});
|
|
130
|
+
return result.vfs;
|
|
131
|
+
});
|
|
132
|
+
const readManifest = (pkg) => Effect.gen(function* () {
|
|
133
|
+
const content = yield* cache.read(pkg, "package.json");
|
|
134
|
+
const parsed = yield* Effect.try({
|
|
135
|
+
try: () => JSON.parse(content),
|
|
136
|
+
catch: (cause) => new FetchError({
|
|
137
|
+
url: packageJsonUrl(pkg),
|
|
138
|
+
kind: "schema",
|
|
139
|
+
cause
|
|
140
|
+
})
|
|
141
|
+
});
|
|
142
|
+
return yield* Schema.decodeUnknownEffect(PackageManifest)(parsed).pipe(Effect.mapError((cause) => new FetchError({
|
|
143
|
+
url: packageJsonUrl(pkg),
|
|
144
|
+
kind: "schema",
|
|
145
|
+
cause
|
|
146
|
+
})));
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
hasCached: Effect.fn("TypeRegistry.hasCached")(function* (pkg) {
|
|
150
|
+
return yield* cache.exists(pkg);
|
|
151
|
+
}),
|
|
152
|
+
fetchAndCache: Effect.fn("TypeRegistry.fetchAndCache")(function* (pkg, options) {
|
|
153
|
+
return yield* fetchAndCacheImpl(pkg, options);
|
|
154
|
+
}),
|
|
155
|
+
getPackageVfs: Effect.fn("TypeRegistry.getPackageVfs")(function* (pkg, options) {
|
|
156
|
+
return yield* getPackageVfsImpl(pkg, options);
|
|
157
|
+
}),
|
|
158
|
+
getVfs: Effect.fn("TypeRegistry.getVfs")(function* (packages, options) {
|
|
159
|
+
yield* emit({
|
|
160
|
+
_tag: "BatchStart",
|
|
161
|
+
total: packages.length,
|
|
162
|
+
packages: packages.map((pkg) => pkg.toString())
|
|
163
|
+
});
|
|
164
|
+
const [duration, results] = yield* Effect.timed(Effect.forEach(packages, (pkg) => getPackageVfsImpl(pkg, options).pipe(Effect.map((vfs) => ({
|
|
165
|
+
ok: true,
|
|
166
|
+
pkg,
|
|
167
|
+
vfs
|
|
168
|
+
})), Effect.catch((error) => emit({
|
|
169
|
+
_tag: "PackageLoadFailed",
|
|
170
|
+
package: pkg.name,
|
|
171
|
+
version: pkg.version,
|
|
172
|
+
kind: classify(error),
|
|
173
|
+
error
|
|
174
|
+
}).pipe(Effect.as({
|
|
175
|
+
ok: false,
|
|
176
|
+
pkg,
|
|
177
|
+
error
|
|
178
|
+
})))), { concurrency: 5 }));
|
|
179
|
+
const succeeded = results.filter((result) => result.ok);
|
|
180
|
+
const failed = results.filter((result) => !result.ok);
|
|
181
|
+
const merged = mergeVfs(...succeeded.map((result) => result.vfs));
|
|
182
|
+
yield* emit({
|
|
183
|
+
_tag: "BatchComplete",
|
|
184
|
+
loaded: succeeded.length,
|
|
185
|
+
failed: failed.length,
|
|
186
|
+
total: packages.length,
|
|
187
|
+
totalFiles: merged.size,
|
|
188
|
+
duration
|
|
189
|
+
});
|
|
190
|
+
if (failed.length === packages.length && packages.length > 0) return yield* Effect.fail(new BatchLoadError({ failures: failed.map((result) => ({
|
|
191
|
+
name: result.pkg.name,
|
|
192
|
+
version: result.pkg.version,
|
|
193
|
+
error: result.error
|
|
194
|
+
})) }));
|
|
195
|
+
return merged;
|
|
196
|
+
}),
|
|
197
|
+
resolveImport: Effect.fn("TypeRegistry.resolveImport")(function* (pkg, specifier) {
|
|
198
|
+
const manifest = yield* readManifest(pkg);
|
|
199
|
+
return TypeResolver.resolveImport(specifier, manifest, pkg);
|
|
200
|
+
}),
|
|
201
|
+
getTypeEntries: Effect.fn("TypeRegistry.getTypeEntries")(function* (pkg) {
|
|
202
|
+
const manifest = yield* readManifest(pkg);
|
|
203
|
+
return TypeResolver.resolveTypeEntries(manifest, pkg);
|
|
204
|
+
}),
|
|
205
|
+
resolveVersion: Effect.fn("TypeRegistry.resolveVersion")(function* (name, ref) {
|
|
206
|
+
const resolved = yield* Effect.gen(function* () {
|
|
207
|
+
const meta = yield* fetcher.getVersions(name);
|
|
208
|
+
if (Object.hasOwn(meta.tags, ref)) {
|
|
209
|
+
const tagged = meta.tags[ref];
|
|
210
|
+
if (typeof tagged === "string") return tagged;
|
|
211
|
+
}
|
|
212
|
+
if (meta.versions.includes(ref)) return ref;
|
|
213
|
+
const range = yield* Range.parse(ref).pipe(Effect.mapError(() => new VersionNotFoundError({
|
|
214
|
+
name,
|
|
215
|
+
ref,
|
|
216
|
+
available: meta.versions.slice(0, 20)
|
|
217
|
+
})));
|
|
218
|
+
const published = yield* Effect.forEach(meta.versions, (version) => SemVer.parse(version).pipe(Effect.option));
|
|
219
|
+
const best = Range.maxSatisfying(published.filter(Option.isSome).map((option) => option.value), range);
|
|
220
|
+
if (Option.isNone(best)) return yield* Effect.fail(new VersionNotFoundError({
|
|
221
|
+
name,
|
|
222
|
+
ref,
|
|
223
|
+
available: meta.versions.slice(0, 20)
|
|
224
|
+
}));
|
|
225
|
+
return best.value.toString();
|
|
226
|
+
}).pipe(Effect.tapError((error) => emit({
|
|
227
|
+
_tag: "VersionResolveFailed",
|
|
228
|
+
package: name,
|
|
229
|
+
requested: ref,
|
|
230
|
+
kind: error._tag === "VersionNotFoundError" ? "no-match" : error.status === 404 ? "not-found" : "network"
|
|
231
|
+
})));
|
|
232
|
+
yield* emit({
|
|
233
|
+
_tag: "VersionResolved",
|
|
234
|
+
package: name,
|
|
235
|
+
requested: ref,
|
|
236
|
+
resolved
|
|
237
|
+
});
|
|
238
|
+
return resolved;
|
|
239
|
+
}),
|
|
240
|
+
clearCache: Effect.fn("TypeRegistry.clearCache")(function* (pkg) {
|
|
241
|
+
return yield* mutations.withPermits(1)(cache.remove(pkg));
|
|
242
|
+
}),
|
|
243
|
+
pruneCache: mutations.withPermits(1)(cache.prune).pipe(Effect.withSpan("TypeRegistry.pruneCache"))
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
/**
|
|
247
|
+
* The facade: one service collapsing the cache, fetcher and resolver behind
|
|
248
|
+
* the operations documentation tooling actually calls.
|
|
249
|
+
*
|
|
250
|
+
* @remarks
|
|
251
|
+
* `yield* TypeRegistry` replaces the v3 floating-function namespace (which
|
|
252
|
+
* the rspress consumer immediately re-wrapped in its own service). Per-method
|
|
253
|
+
* error unions stay precise. Compose at the edge: platform layers + store
|
|
254
|
+
* `Cache.layerSqlite` + `TypeCache.layerXdg` + `PackageFetcher.layer` +
|
|
255
|
+
* `TypeRegistry.layer`.
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* ```ts
|
|
259
|
+
* import { PackageSpec, TypeRegistry } from "@tsdoctor/registry";
|
|
260
|
+
* import { Effect } from "effect";
|
|
261
|
+
*
|
|
262
|
+
* const program = Effect.gen(function* () {
|
|
263
|
+
* const registry = yield* TypeRegistry;
|
|
264
|
+
* return yield* registry.getVfs([PackageSpec.fromString("zod@3.23.8")]);
|
|
265
|
+
* });
|
|
266
|
+
* ```
|
|
267
|
+
*
|
|
268
|
+
* @public
|
|
269
|
+
*/
|
|
270
|
+
var TypeRegistry = class TypeRegistry extends Context.Service()("type-registry-effect/TypeRegistry") {
|
|
271
|
+
/** The live facade over {@link TypeCache} and {@link PackageFetcher}. */
|
|
272
|
+
static layer = Layer.effect(TypeRegistry, make);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
//#endregion
|
|
276
|
+
export { BatchLoadError, TypeRegistry };
|