@tsdoctor/bundle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Bundle.js +127 -0
- package/BundleDiscovery.js +233 -0
- package/BundleFetch.js +348 -0
- package/BundleHash.js +107 -0
- package/BundleManifest.js +183 -0
- package/BundleResolver.js +167 -0
- package/LICENSE +21 -0
- package/PlatformOverrides.js +48 -0
- package/README.md +65 -0
- package/index.d.ts +885 -0
- package/index.js +9 -0
- package/package.json +55 -0
- package/tsdoc-metadata.json +11 -0
package/Bundle.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { BundleManifestError, decodeBundleManifest } from "./BundleManifest.js";
|
|
2
|
+
import { PackageJsonFile } from "@effected/package-json";
|
|
3
|
+
import { TsconfigLoader } from "@effected/tsconfig-json";
|
|
4
|
+
import { Effect, FileSystem, Option, Schema } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/Bundle.ts
|
|
7
|
+
/**
|
|
8
|
+
* The sidecar manifest's file name inside a bundle folder.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
const TSDOCTOR_MANIFEST_FILENAME = "tsdoctor.json";
|
|
13
|
+
/** The minimal api.json shape the layer-0 reader validates. */
|
|
14
|
+
const ApiModelHeader = Schema.Struct({ name: Schema.String });
|
|
15
|
+
/**
|
|
16
|
+
* Raised when a PRESENT bundle layer file cannot be read, parsed or decoded.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* Absence of layers 1–3 is the normal case (layers enrich, never gate) and
|
|
20
|
+
* reads as `Option.none()`, never as this error. A file that exists but is
|
|
21
|
+
* malformed is a real problem worth surfacing, not degrading past. Manifest
|
|
22
|
+
* (layer 3) failures use {@link BundleManifestError} instead, so manifest
|
|
23
|
+
* consumers handle one tag across the file and non-file boundaries.
|
|
24
|
+
*
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
var BundleLayerError = class extends Schema.TaggedError()("BundleLayerError", {
|
|
28
|
+
/** Which bundle layer failed. */
|
|
29
|
+
layer: Schema.Literals([
|
|
30
|
+
"apiModel",
|
|
31
|
+
"packageJson",
|
|
32
|
+
"tsconfig"
|
|
33
|
+
]),
|
|
34
|
+
/** The file that failed. */
|
|
35
|
+
path: Schema.String,
|
|
36
|
+
/** The underlying failure, preserved structurally. */
|
|
37
|
+
cause: Schema.Defect()
|
|
38
|
+
}) {
|
|
39
|
+
get message() {
|
|
40
|
+
return `Failed to read bundle layer "${this.layer}" at ${this.path}`;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Read the layer-0 model header — the package name — from a `*.api.json`
|
|
45
|
+
* file.
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* Parses the whole file as JSON but validates only the `name` field; see
|
|
49
|
+
* {@link ApiModelInfo} for why nothing more is read here.
|
|
50
|
+
*
|
|
51
|
+
* @public
|
|
52
|
+
*/
|
|
53
|
+
function readApiModelInfo(modelPath) {
|
|
54
|
+
return Effect.gen(function* () {
|
|
55
|
+
const text = yield* (yield* FileSystem.FileSystem).readFileString(modelPath);
|
|
56
|
+
const parsed = yield* Effect.try(() => JSON.parse(text));
|
|
57
|
+
return yield* Schema.decodeUnknownEffect(ApiModelHeader)(parsed);
|
|
58
|
+
}).pipe(Effect.mapError((cause) => new BundleLayerError({
|
|
59
|
+
layer: "apiModel",
|
|
60
|
+
path: modelPath,
|
|
61
|
+
cause
|
|
62
|
+
})));
|
|
63
|
+
}
|
|
64
|
+
/** Read layer 1 when the descriptor recorded a package.json. */
|
|
65
|
+
function readPackageJsonLayer(packageJsonPath) {
|
|
66
|
+
if (packageJsonPath === void 0) return Effect.succeed(Option.none());
|
|
67
|
+
return Effect.gen(function* () {
|
|
68
|
+
const manifest = yield* (yield* PackageJsonFile.make).readManifest(packageJsonPath);
|
|
69
|
+
return Option.some(manifest);
|
|
70
|
+
}).pipe(Effect.mapError((cause) => new BundleLayerError({
|
|
71
|
+
layer: "packageJson",
|
|
72
|
+
path: packageJsonPath,
|
|
73
|
+
cause
|
|
74
|
+
})));
|
|
75
|
+
}
|
|
76
|
+
/** Read layer 2 when the descriptor recorded a tsconfig.json. */
|
|
77
|
+
function readTsconfigLayer(tsconfigPath) {
|
|
78
|
+
if (tsconfigPath === void 0) return Effect.succeed(Option.none());
|
|
79
|
+
return TsconfigLoader.resolve(tsconfigPath).pipe(Effect.map(Option.some), Effect.mapError((cause) => new BundleLayerError({
|
|
80
|
+
layer: "tsconfig",
|
|
81
|
+
path: tsconfigPath,
|
|
82
|
+
cause
|
|
83
|
+
})));
|
|
84
|
+
}
|
|
85
|
+
/** Read layer 3 when the descriptor recorded a tsdoctor.json. */
|
|
86
|
+
function readManifestLayer(manifestPath) {
|
|
87
|
+
if (manifestPath === void 0) return Effect.succeed(Option.none());
|
|
88
|
+
return Effect.gen(function* () {
|
|
89
|
+
const text = yield* (yield* FileSystem.FileSystem).readFileString(manifestPath).pipe(Effect.mapError((cause) => new BundleManifestError({
|
|
90
|
+
path: manifestPath,
|
|
91
|
+
cause
|
|
92
|
+
})));
|
|
93
|
+
const parsed = yield* Effect.try(() => JSON.parse(text)).pipe(Effect.mapError((cause) => new BundleManifestError({
|
|
94
|
+
path: manifestPath,
|
|
95
|
+
cause
|
|
96
|
+
})));
|
|
97
|
+
const manifest = yield* decodeBundleManifest(parsed, manifestPath);
|
|
98
|
+
return Option.some(manifest);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Read all four layers of a discovered bundle into a {@link Bundle}.
|
|
103
|
+
*
|
|
104
|
+
* @remarks
|
|
105
|
+
* Layer 0 is required; layers 1–3 read to `Option.none()` when the
|
|
106
|
+
* descriptor recorded no file for them. A layer file that is present but
|
|
107
|
+
* malformed fails typed ({@link BundleLayerError}, or
|
|
108
|
+
* {@link BundleManifestError} for the manifest) — lenient about absence,
|
|
109
|
+
* strict about shape. `FileSystem` and `Path` stay in the `R` channel;
|
|
110
|
+
* provide the platform layer once at the application boundary.
|
|
111
|
+
*
|
|
112
|
+
* @public
|
|
113
|
+
*/
|
|
114
|
+
function readBundle(descriptor) {
|
|
115
|
+
return Effect.gen(function* () {
|
|
116
|
+
return {
|
|
117
|
+
descriptor,
|
|
118
|
+
apiModel: yield* readApiModelInfo(descriptor.modelPath),
|
|
119
|
+
packageJson: yield* readPackageJsonLayer(descriptor.packageJsonPath),
|
|
120
|
+
tsconfig: yield* readTsconfigLayer(descriptor.tsconfigPath),
|
|
121
|
+
manifest: yield* readManifestLayer(descriptor.manifestPath)
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
//#endregion
|
|
127
|
+
export { BundleLayerError, TSDOCTOR_MANIFEST_FILENAME, readApiModelInfo, readBundle };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { TSDOCTOR_MANIFEST_FILENAME, readApiModelInfo, readBundle } from "./Bundle.js";
|
|
2
|
+
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
|
+
import { GlobPatternOptions } from "@effected/glob";
|
|
4
|
+
import { compileAndExpand } from "@effected/walker";
|
|
5
|
+
|
|
6
|
+
//#region src/BundleDiscovery.ts
|
|
7
|
+
/**
|
|
8
|
+
* Raised when a directory cannot be resolved into a bundle descriptor.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Discovery failures are USER-facing wiring problems — a missing folder, no
|
|
12
|
+
* model file, an ambiguous model set — so the `reason` is a typed literal a
|
|
13
|
+
* consumer can branch on for actionable messaging.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
var BundleDiscoveryError = class extends Schema.TaggedError()("BundleDiscoveryError", {
|
|
18
|
+
/** The directory (or file) the failure is about. */
|
|
19
|
+
path: Schema.String,
|
|
20
|
+
/** What went wrong, structurally. */
|
|
21
|
+
reason: Schema.Literals([
|
|
22
|
+
"notFound",
|
|
23
|
+
"notADirectory",
|
|
24
|
+
"noApiModel",
|
|
25
|
+
"ambiguousApiModel",
|
|
26
|
+
"invalidPackageJson",
|
|
27
|
+
"unreadableDirectory",
|
|
28
|
+
"notABundleFolder",
|
|
29
|
+
"emptyParent"
|
|
30
|
+
]),
|
|
31
|
+
/** Human context for the failure (candidate lists, offending names). */
|
|
32
|
+
detail: Schema.optionalKey(Schema.String),
|
|
33
|
+
/** The underlying failure, when one exists, preserved structurally. */
|
|
34
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
35
|
+
}) {
|
|
36
|
+
get message() {
|
|
37
|
+
const detailPart = this.detail !== void 0 ? `: ${this.detail}` : "";
|
|
38
|
+
return `Bundle discovery failed (${this.reason}) at ${this.path}${detailPart}`;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
/** The lenient package.json subset discovery needs: name and version only. */
|
|
42
|
+
const DiscoveryPackageJson = Schema.Struct({
|
|
43
|
+
name: Schema.optionalKey(Schema.String),
|
|
44
|
+
version: Schema.optionalKey(Schema.String)
|
|
45
|
+
});
|
|
46
|
+
/** The unscoped tail of an npm package name (`@scope/pkg` → `pkg`). */
|
|
47
|
+
function unscopedName(packageName) {
|
|
48
|
+
const slash = packageName.indexOf("/");
|
|
49
|
+
return packageName.startsWith("@") && slash !== -1 ? packageName.slice(slash + 1) : packageName;
|
|
50
|
+
}
|
|
51
|
+
/** Assert `dir` exists and is a directory, or fail with a discovery error. */
|
|
52
|
+
function assertDirectory(dir) {
|
|
53
|
+
return Effect.gen(function* () {
|
|
54
|
+
if ((yield* (yield* FileSystem.FileSystem).stat(dir).pipe(Effect.mapError((cause) => new BundleDiscoveryError({
|
|
55
|
+
path: dir,
|
|
56
|
+
reason: "notFound",
|
|
57
|
+
cause
|
|
58
|
+
})))).type !== "Directory") return yield* Effect.fail(new BundleDiscoveryError({
|
|
59
|
+
path: dir,
|
|
60
|
+
reason: "notADirectory"
|
|
61
|
+
}));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** List the `*.api.json` files directly inside `dir`, sorted. */
|
|
65
|
+
function listModelFiles(dir) {
|
|
66
|
+
return compileAndExpand("*.api.json", {
|
|
67
|
+
cwd: dir,
|
|
68
|
+
glob: GlobPatternOptions.make({})
|
|
69
|
+
}).pipe(Effect.mapError((cause) => new BundleDiscoveryError({
|
|
70
|
+
path: dir,
|
|
71
|
+
reason: "unreadableDirectory",
|
|
72
|
+
cause
|
|
73
|
+
})));
|
|
74
|
+
}
|
|
75
|
+
/** Read the discovery-lenient name/version pair from a package.json, when present. */
|
|
76
|
+
function readDiscoveryPackageJson(packageJsonPath) {
|
|
77
|
+
return Effect.gen(function* () {
|
|
78
|
+
const fs = yield* FileSystem.FileSystem;
|
|
79
|
+
if (!(yield* fs.exists(packageJsonPath).pipe(Effect.orElseSucceed(() => false)))) return Option.none();
|
|
80
|
+
const parsed = yield* Effect.gen(function* () {
|
|
81
|
+
const text = yield* fs.readFileString(packageJsonPath);
|
|
82
|
+
const json = yield* Effect.try(() => JSON.parse(text));
|
|
83
|
+
return yield* Schema.decodeUnknownEffect(DiscoveryPackageJson)(json);
|
|
84
|
+
}).pipe(Effect.mapError((cause) => new BundleDiscoveryError({
|
|
85
|
+
path: packageJsonPath,
|
|
86
|
+
reason: "invalidPackageJson",
|
|
87
|
+
cause
|
|
88
|
+
})));
|
|
89
|
+
return Option.some(parsed);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Pick the bundle's model file from the `*.api.json` candidates in its
|
|
94
|
+
* folder.
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
97
|
+
* One candidate wins outright. Zero is a discovery failure — layer 0 is the
|
|
98
|
+
* one required layer. Among several, the file named `<unscoped>.api.json`
|
|
99
|
+
* for the bundle's package name wins; with no such match the set is
|
|
100
|
+
* ambiguous and discovery fails with guidance to pass an explicit
|
|
101
|
+
* `overrides.modelPath`.
|
|
102
|
+
*/
|
|
103
|
+
function pickModelFile(dir, candidates, packageName) {
|
|
104
|
+
return Effect.gen(function* () {
|
|
105
|
+
const path = yield* Path.Path;
|
|
106
|
+
if (candidates.length === 1) return path.join(dir, candidates[0]);
|
|
107
|
+
if (candidates.length === 0) return yield* Effect.fail(new BundleDiscoveryError({
|
|
108
|
+
path: dir,
|
|
109
|
+
reason: "noApiModel",
|
|
110
|
+
detail: "no *.api.json model found; pass an explicit overrides.modelPath"
|
|
111
|
+
}));
|
|
112
|
+
const preferred = packageName !== void 0 ? `${unscopedName(packageName)}.api.json` : void 0;
|
|
113
|
+
const match = preferred !== void 0 ? candidates.find((candidate) => candidate === preferred) : void 0;
|
|
114
|
+
if (match !== void 0) return path.join(dir, match);
|
|
115
|
+
return yield* Effect.fail(new BundleDiscoveryError({
|
|
116
|
+
path: dir,
|
|
117
|
+
reason: "ambiguousApiModel",
|
|
118
|
+
detail: `multiple *.api.json files (${candidates.join(", ")}) and none match "${preferred ?? "<name>.api.json"}"; pass an explicit overrides.modelPath`
|
|
119
|
+
}));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Discover a single bundle folder into a {@link BundleDescriptor}.
|
|
124
|
+
*
|
|
125
|
+
* @remarks
|
|
126
|
+
* Generalizes the RSPress plugin's `api.fromDir` helper, framework-neutral:
|
|
127
|
+
* no route derivation — that stays in the adapter. Layer 0 (`*.api.json`) is
|
|
128
|
+
* required; package.json, tsconfig.json and tsdoctor.json are recorded when
|
|
129
|
+
* present. The package name comes from package.json when it has one, falling
|
|
130
|
+
* back to the api.json model's own name; the version comes from package.json
|
|
131
|
+
* alone. Caller overrides win over discovery. `FileSystem` and `Path` stay
|
|
132
|
+
* in the `R` channel — provide the platform layer at the application
|
|
133
|
+
* boundary.
|
|
134
|
+
*
|
|
135
|
+
* @public
|
|
136
|
+
*/
|
|
137
|
+
function discoverBundle(dir, options) {
|
|
138
|
+
return Effect.gen(function* () {
|
|
139
|
+
const fs = yield* FileSystem.FileSystem;
|
|
140
|
+
const path = yield* Path.Path;
|
|
141
|
+
const abs = options?.cwd !== void 0 ? path.resolve(options.cwd, dir) : path.resolve(dir);
|
|
142
|
+
yield* assertDirectory(abs);
|
|
143
|
+
const packageJsonPath = path.join(abs, "package.json");
|
|
144
|
+
const packageJson = yield* readDiscoveryPackageJson(packageJsonPath);
|
|
145
|
+
const packageJsonName = Option.isSome(packageJson) ? packageJson.value.name : void 0;
|
|
146
|
+
const overriddenModel = options?.overrides?.modelPath;
|
|
147
|
+
const modelPath = overriddenModel !== void 0 ? path.resolve(abs, overriddenModel) : yield* Effect.flatMap(listModelFiles(abs), (candidates) => pickModelFile(abs, candidates, options?.overrides?.name ?? packageJsonName));
|
|
148
|
+
const name = options?.overrides?.name ?? packageJsonName ?? (yield* readApiModelInfo(modelPath)).name;
|
|
149
|
+
const version = options?.overrides?.version ?? (Option.isSome(packageJson) ? packageJson.value.version : void 0);
|
|
150
|
+
const tsconfigPath = path.join(abs, "tsconfig.json");
|
|
151
|
+
const manifestPath = path.join(abs, TSDOCTOR_MANIFEST_FILENAME);
|
|
152
|
+
const hasTsconfig = yield* fs.exists(tsconfigPath).pipe(Effect.orElseSucceed(() => false));
|
|
153
|
+
const hasManifest = yield* fs.exists(manifestPath).pipe(Effect.orElseSucceed(() => false));
|
|
154
|
+
return {
|
|
155
|
+
dir: abs,
|
|
156
|
+
dirname: path.basename(abs),
|
|
157
|
+
name,
|
|
158
|
+
...version !== void 0 ? { version } : {},
|
|
159
|
+
modelPath,
|
|
160
|
+
...Option.isSome(packageJson) ? { packageJsonPath } : {},
|
|
161
|
+
...hasTsconfig ? { tsconfigPath } : {},
|
|
162
|
+
...hasManifest ? { manifestPath } : {}
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Strictly scan a parent directory and discover one bundle per subfolder.
|
|
168
|
+
*
|
|
169
|
+
* @remarks
|
|
170
|
+
* Generalizes the RSPress plugin's `apis.fromDir`: every non-dotfile
|
|
171
|
+
* subdirectory MUST be a valid bundle folder (contain at least one
|
|
172
|
+
* `*.api.json`) — a stray subfolder fails discovery with guidance to use
|
|
173
|
+
* {@link discoverBundle} for selective inclusion — and an empty scan is a
|
|
174
|
+
* failure, not an empty array (the models have probably not been built).
|
|
175
|
+
* Subfolders are processed in sorted order.
|
|
176
|
+
*
|
|
177
|
+
* @public
|
|
178
|
+
*/
|
|
179
|
+
function discoverBundles(parentDir, options) {
|
|
180
|
+
return Effect.gen(function* () {
|
|
181
|
+
const fs = yield* FileSystem.FileSystem;
|
|
182
|
+
const path = yield* Path.Path;
|
|
183
|
+
const absParent = options?.cwd !== void 0 ? path.resolve(options.cwd, parentDir) : path.resolve(parentDir);
|
|
184
|
+
yield* assertDirectory(absParent);
|
|
185
|
+
const entries = yield* fs.readDirectory(absParent).pipe(Effect.mapError((cause) => new BundleDiscoveryError({
|
|
186
|
+
path: absParent,
|
|
187
|
+
reason: "unreadableDirectory",
|
|
188
|
+
cause
|
|
189
|
+
})));
|
|
190
|
+
const subdirs = [];
|
|
191
|
+
for (const entry of [...entries].sort()) {
|
|
192
|
+
if (entry.startsWith(".")) continue;
|
|
193
|
+
const entryPath = path.join(absParent, entry);
|
|
194
|
+
const info = yield* fs.stat(entryPath).pipe(Effect.option);
|
|
195
|
+
if (Option.isSome(info) && info.value.type === "Directory") subdirs.push(entry);
|
|
196
|
+
}
|
|
197
|
+
const descriptors = [];
|
|
198
|
+
for (const subdir of subdirs) {
|
|
199
|
+
const subdirPath = path.join(absParent, subdir);
|
|
200
|
+
if ((yield* listModelFiles(subdirPath)).length === 0) return yield* Effect.fail(new BundleDiscoveryError({
|
|
201
|
+
path: absParent,
|
|
202
|
+
reason: "notABundleFolder",
|
|
203
|
+
detail: `"${subdir}" has no *.api.json model; use discoverBundle for selective inclusion`
|
|
204
|
+
}));
|
|
205
|
+
descriptors.push(yield* discoverBundle(subdirPath));
|
|
206
|
+
}
|
|
207
|
+
if (descriptors.length === 0) return yield* Effect.fail(new BundleDiscoveryError({
|
|
208
|
+
path: absParent,
|
|
209
|
+
reason: "emptyParent",
|
|
210
|
+
detail: "no bundle folders found; have the package models been built?"
|
|
211
|
+
}));
|
|
212
|
+
return descriptors;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Discover and read a single bundle in one call.
|
|
217
|
+
*
|
|
218
|
+
* @public
|
|
219
|
+
*/
|
|
220
|
+
function loadBundle(dir, options) {
|
|
221
|
+
return Effect.flatMap(discoverBundle(dir, options), readBundle);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Discover and read every bundle under a parent directory in one call.
|
|
225
|
+
*
|
|
226
|
+
* @public
|
|
227
|
+
*/
|
|
228
|
+
function loadBundles(parentDir, options) {
|
|
229
|
+
return Effect.flatMap(discoverBundles(parentDir, options), (descriptors) => Effect.forEach(descriptors, readBundle));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
//#endregion
|
|
233
|
+
export { BundleDiscoveryError, discoverBundle, discoverBundles, loadBundle, loadBundles };
|