dsh-plugin-shop 0.6.0 → 0.7.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/README.md +11 -4
- package/lib/client.js +243 -243
- package/lib/index.js +453 -40
- package/lib/typert.host.js +9 -9
- package/lib/typert.remote-client.js +9 -9
- package/lib/types/host/catalog.d.ts +19 -1
- package/lib/types/host/index.d.ts +7 -0
- package/lib/types/host/npm-origin.d.ts +24 -0
- package/lib/types/host/npmrc.d.ts +23 -0
- package/lib/types/host/origin.d.ts +40 -0
- package/lib/types/host/race.d.ts +41 -0
- package/lib/types/host/tar.d.ts +13 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -7,7 +7,9 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync,
|
|
|
7
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
10
11
|
import { z } from "zod";
|
|
12
|
+
import { gunzipSync } from "node:zlib";
|
|
11
13
|
import { JSON_SCHEMA, Type, dump, load } from "js-yaml";
|
|
12
14
|
//#region src/own-version.ts
|
|
13
15
|
/** The shop's own published version, read from the package.json that ships
|
|
@@ -19,8 +21,332 @@ import { JSON_SCHEMA, Type, dump, load } from "js-yaml";
|
|
|
19
21
|
function ownVersion() {
|
|
20
22
|
return JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
|
|
21
23
|
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/host/origin.ts
|
|
26
|
+
/** The transport seam under `loadCatalog` (design §3).
|
|
27
|
+
*
|
|
28
|
+
* An origin answers a cheap probe, then serves the pointer and the files the
|
|
29
|
+
* pointer names. HTTP and npm are interchangeable behind it, so every line of
|
|
30
|
+
* cache and validation logic in `catalog.ts` stays transport-blind. */
|
|
31
|
+
/** A failure of the link, not of the content: the wire threw, or answered
|
|
32
|
+
* non-2xx. This is the ONLY class `loadCatalog` retries on another origin.
|
|
33
|
+
* A bad schema, a sha mismatch, or a refused url is an interpretation
|
|
34
|
+
* failure and throws — masking a corrupt origin behind a healthy one is
|
|
35
|
+
* exactly the silent-wrongness this project refuses. */
|
|
36
|
+
var TransportError = class extends Error {
|
|
37
|
+
constructor(message, options) {
|
|
38
|
+
super(message, options);
|
|
39
|
+
this.name = "TransportError";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/** Resolve the pointer's data URL against the catalog base. An absolute URL —
|
|
43
|
+
* any scheme, or a protocol-relative `//host/...` — would hand the pointer a
|
|
44
|
+
* fetch primitive to arbitrary hosts, so it is refused loudly before any
|
|
45
|
+
* fetch (§9.2). The guard is the resolved origin, not the raw string: WHATWG
|
|
46
|
+
* normalization strips leading whitespace and accepts backslash spellings
|
|
47
|
+
* before the string could be inspected, so only comparing the resolved URL's
|
|
48
|
+
* origin to the base's closes every spelling class. */
|
|
49
|
+
function resolveDataUrl(baseUrl, url) {
|
|
50
|
+
const resolved = new URL(url, baseUrl);
|
|
51
|
+
if (resolved.origin !== new URL(baseUrl).origin) throw new Error("catalog data url must be relative to the catalog base");
|
|
52
|
+
return resolved.href;
|
|
53
|
+
}
|
|
54
|
+
/** The transport this project has always used: a static `v1/` tree. */
|
|
55
|
+
function httpOrigin(baseUrl, fetchImpl) {
|
|
56
|
+
const id = `http:${baseUrl}`;
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
async probe(signal) {
|
|
60
|
+
let response;
|
|
61
|
+
try {
|
|
62
|
+
response = await fetchImpl(new URL("index.json", baseUrl).href, { signal });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
65
|
+
throw new TransportError(`catalog pointer fetch failed for ${id}: ${detail}`, { cause: error });
|
|
66
|
+
}
|
|
67
|
+
if (!response.ok) throw new TransportError(`catalog pointer returned ${response.status} for ${id}`);
|
|
68
|
+
const pointerText = await response.text();
|
|
69
|
+
return {
|
|
70
|
+
id,
|
|
71
|
+
pointer: async () => pointerText,
|
|
72
|
+
file: async (url) => {
|
|
73
|
+
const resolved = resolveDataUrl(baseUrl, url);
|
|
74
|
+
let dataResponse;
|
|
75
|
+
try {
|
|
76
|
+
dataResponse = await fetchImpl(resolved);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
79
|
+
throw new TransportError(`catalog data fetch failed for ${id}: ${detail}`, { cause: error });
|
|
80
|
+
}
|
|
81
|
+
if (!dataResponse.ok) throw new TransportError(`catalog data returned ${dataResponse.status} for ${id}`);
|
|
82
|
+
return dataResponse.text();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/host/tar.ts
|
|
90
|
+
/** A read-only ustar parser: the npm transport's only way into a tarball.
|
|
91
|
+
*
|
|
92
|
+
* Pure — bytes in, a path-to-bytes map out. It handles exactly what `npm
|
|
93
|
+
* pack` emits and refuses everything else loudly, because the alternative to
|
|
94
|
+
* a small strict reader is a fourth runtime dependency (design §4). */
|
|
95
|
+
/** Bytes up to the first NUL, as ASCII. Tar pads its fixed-width text fields
|
|
96
|
+
* with NULs, so a plain toString would carry them into the path. */
|
|
97
|
+
function cstring(field) {
|
|
98
|
+
const end = field.indexOf(0);
|
|
99
|
+
return field.subarray(0, end === -1 ? field.length : end).toString("ascii");
|
|
100
|
+
}
|
|
101
|
+
/** Tar sizes are octal text, NUL- or space-terminated. An unparseable size
|
|
102
|
+
* would desynchronise every subsequent header, so it throws rather than
|
|
103
|
+
* guessing zero. */
|
|
104
|
+
function parseOctal(field) {
|
|
105
|
+
const text = cstring(field).trim();
|
|
106
|
+
if (text === "") return 0;
|
|
107
|
+
const value = Number.parseInt(text, 8);
|
|
108
|
+
if (!Number.isInteger(value) || value < 0) throw new Error(`tar: unparseable size field ${JSON.stringify(text)}`);
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
/** `..` in any position, or a leading `/`, would let an archive write outside
|
|
112
|
+
* the directory it claims. Nothing we publish contains either, so a tarball
|
|
113
|
+
* that does is hostile or corrupt — refuse it rather than filter it. */
|
|
114
|
+
function assertContained(path) {
|
|
115
|
+
if (path.startsWith("/") || path.split("/").includes("..")) throw new Error(`tar: ${JSON.stringify(path)} escapes the archive root`);
|
|
116
|
+
return path;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Parse an uncompressed tar archive into path → bytes.
|
|
120
|
+
*
|
|
121
|
+
* Directory entries and every non-regular type (symlinks, pax and GNU
|
|
122
|
+
* extension headers) are skipped: npm packs regular files under `package/`,
|
|
123
|
+
* and a catalog tarball that needs anything else is not one we published.
|
|
124
|
+
*/
|
|
125
|
+
function readTar(buffer) {
|
|
126
|
+
const files = /* @__PURE__ */ new Map();
|
|
127
|
+
let offset = 0;
|
|
128
|
+
while (offset + 512 <= buffer.length) {
|
|
129
|
+
const header = buffer.subarray(offset, offset + 512);
|
|
130
|
+
if (header.every((byte) => byte === 0)) break;
|
|
131
|
+
const name = cstring(header.subarray(0, 100));
|
|
132
|
+
const prefix = cstring(header.subarray(345, 500));
|
|
133
|
+
const size = parseOctal(header.subarray(124, 136));
|
|
134
|
+
const typeflag = String.fromCharCode(header[156] ?? 0);
|
|
135
|
+
const path = prefix === "" ? name : `${prefix}/${name}`;
|
|
136
|
+
offset += 512;
|
|
137
|
+
if (typeflag === "0" || typeflag === "\0") files.set(assertContained(path), buffer.subarray(offset, offset + size));
|
|
138
|
+
offset += Math.ceil(size / 512) * 512;
|
|
139
|
+
}
|
|
140
|
+
return files;
|
|
141
|
+
}
|
|
142
|
+
//#endregion
|
|
143
|
+
//#region src/host/npm-origin.ts
|
|
144
|
+
/** The npm transport (design §2, §3): the catalog as a package.
|
|
145
|
+
*
|
|
146
|
+
* Shell — this and `origin.ts`'s fetch half are the only places the catalog
|
|
147
|
+
* loader touches the network. The payoff is measured, not assumed: the same
|
|
148
|
+
* bytes reach a China-side machine at 12.53 MB/s from npmmirror against
|
|
149
|
+
* 0.03 MB/s from GitHub Pages. */
|
|
150
|
+
/** The abbreviated `latest` manifest. Non-strict: a registry may add keys,
|
|
151
|
+
* and stripping them is what keeps an old host working against a new one. */
|
|
152
|
+
const latestSchema = z.object({
|
|
153
|
+
version: z.string(),
|
|
154
|
+
dist: z.object({
|
|
155
|
+
tarball: z.string(),
|
|
156
|
+
integrity: z.string()
|
|
157
|
+
})
|
|
158
|
+
});
|
|
159
|
+
/** Where the published package keeps the catalog tree (design §2). */
|
|
160
|
+
const PACKAGE_ROOT = "package/v1/";
|
|
161
|
+
/** Verify tarball bytes against npm's own Subresource-Integrity string.
|
|
162
|
+
* `dist.integrity` may carry several space-separated digests; npm publishes
|
|
163
|
+
* one, and the first is the one we check. */
|
|
164
|
+
function verifyIntegrity(bytes, integrity, registryUrl) {
|
|
165
|
+
const first = integrity.trim().split(/\s+/)[0] ?? "";
|
|
166
|
+
const dash = first.indexOf("-");
|
|
167
|
+
const algorithm = dash === -1 ? "" : first.slice(0, dash);
|
|
168
|
+
const expected = dash === -1 ? "" : first.slice(dash + 1);
|
|
169
|
+
if (algorithm !== "sha512" && algorithm !== "sha256") throw new TransportError(`npm origin ${registryUrl}: unsupported dist.integrity algorithm ${JSON.stringify(algorithm)}`);
|
|
170
|
+
if (createHash(algorithm).update(bytes).digest("base64") !== expected) throw new Error(`npm origin ${registryUrl}: tarball failed dist.integrity check (${algorithm})`);
|
|
171
|
+
}
|
|
172
|
+
/** Normalise to a trailing slash so relative `URL` resolution against a
|
|
173
|
+
* registry that carries a path — every corporate registry, e.g.
|
|
174
|
+
* `https://artifactory.corp/api/npm/npm-repo` — keeps that path instead of
|
|
175
|
+
* eating its last segment; a host-root registry's trailing slash is already
|
|
176
|
+
* a no-op either way. Exported so `catalog.ts`'s dedupe compares against the
|
|
177
|
+
* same normalised form `npmOrigin` races on. */
|
|
178
|
+
function normalizeRegistryUrl(url) {
|
|
179
|
+
return url.endsWith("/") ? url : `${url}/`;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* An origin that reads the catalog out of `<registryUrl>`'s copy of
|
|
183
|
+
* `<packageName>`.
|
|
184
|
+
*
|
|
185
|
+
* The probe is the abbreviated `latest` manifest — 13.5 KB against the live
|
|
186
|
+
* registry — so the race is decided without downloading anything large. The
|
|
187
|
+
* tarball is fetched lazily on the first `pointer()` or `file()` and kept on
|
|
188
|
+
* the handle, so one origin download serves the whole load.
|
|
189
|
+
*/
|
|
190
|
+
function npmOrigin(rawRegistryUrl, packageName, fetchImpl) {
|
|
191
|
+
const registryUrl = normalizeRegistryUrl(rawRegistryUrl);
|
|
192
|
+
const id = `npm:${registryUrl}`;
|
|
193
|
+
return {
|
|
194
|
+
id,
|
|
195
|
+
async probe(signal) {
|
|
196
|
+
let url;
|
|
197
|
+
try {
|
|
198
|
+
url = new URL(`${encodeURIComponent(packageName)}/latest`, registryUrl).href;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
201
|
+
throw new TransportError(`npm origin ${registryUrl} is not a usable registry url: ${detail}`, { cause: error });
|
|
202
|
+
}
|
|
203
|
+
let response;
|
|
204
|
+
try {
|
|
205
|
+
response = await fetchImpl(url, { signal });
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
208
|
+
throw new TransportError(`npm origin ${registryUrl} probe failed: ${detail}`, { cause: error });
|
|
209
|
+
}
|
|
210
|
+
if (!response.ok) throw new TransportError(`npm origin ${registryUrl} returned ${response.status}`);
|
|
211
|
+
let manifest;
|
|
212
|
+
try {
|
|
213
|
+
manifest = latestSchema.parse(await response.json());
|
|
214
|
+
} catch (error) {
|
|
215
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
216
|
+
throw new TransportError(`npm origin ${registryUrl} returned an unparsable manifest: ${detail}`, { cause: error });
|
|
217
|
+
}
|
|
218
|
+
let files = null;
|
|
219
|
+
const load = async () => {
|
|
220
|
+
if (files !== null) return files;
|
|
221
|
+
let tarballUrl;
|
|
222
|
+
try {
|
|
223
|
+
tarballUrl = new URL(manifest.dist.tarball);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
226
|
+
throw new TransportError(`npm origin ${registryUrl}: dist.tarball is not a valid url: ${detail}`, { cause: error });
|
|
227
|
+
}
|
|
228
|
+
if (tarballUrl.origin !== new URL(registryUrl).origin) throw new TransportError(`npm origin ${registryUrl}: dist.tarball host ${tarballUrl.origin} is not the registry's`);
|
|
229
|
+
let tarballResponse;
|
|
230
|
+
try {
|
|
231
|
+
tarballResponse = await fetchImpl(tarballUrl.href);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
234
|
+
throw new TransportError(`npm origin ${registryUrl} tarball fetch failed: ${detail}`, { cause: error });
|
|
235
|
+
}
|
|
236
|
+
if (!tarballResponse.ok) throw new TransportError(`npm origin ${registryUrl} tarball returned ${tarballResponse.status}`);
|
|
237
|
+
let bytes;
|
|
238
|
+
try {
|
|
239
|
+
bytes = Buffer.from(await tarballResponse.arrayBuffer());
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
242
|
+
throw new TransportError(`npm origin ${registryUrl} tarball body read failed: ${detail}`, { cause: error });
|
|
243
|
+
}
|
|
244
|
+
verifyIntegrity(bytes, manifest.dist.integrity, registryUrl);
|
|
245
|
+
let parsed;
|
|
246
|
+
try {
|
|
247
|
+
parsed = readTar(gunzipSync(bytes));
|
|
248
|
+
} catch (error) {
|
|
249
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
250
|
+
throw new TransportError(`npm origin ${registryUrl} served an unparsable tarball: ${detail}`, { cause: error });
|
|
251
|
+
}
|
|
252
|
+
files = parsed;
|
|
253
|
+
return files;
|
|
254
|
+
};
|
|
255
|
+
const read = async (name) => {
|
|
256
|
+
const entry = (await load()).get(`${PACKAGE_ROOT}${name}`);
|
|
257
|
+
if (entry === void 0) throw new TransportError(`npm origin ${registryUrl}: ${name} is not in the catalog package`);
|
|
258
|
+
return entry.toString("utf8");
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
id,
|
|
262
|
+
pointer: async () => read("index.json"),
|
|
263
|
+
file: async (url) => {
|
|
264
|
+
if (url.includes("/") || url.startsWith(".")) throw new Error(`npm origin ${registryUrl}: ${JSON.stringify(url)} must be a plain file name`);
|
|
265
|
+
return read(url);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/host/race.ts
|
|
273
|
+
/**
|
|
274
|
+
* Yield each promise's outcome as it settles, tagged with its argument index.
|
|
275
|
+
*
|
|
276
|
+
* Deliberately NOT an `async function*`. Two properties depend on that:
|
|
277
|
+
*
|
|
278
|
+
* 1. **Handlers attach synchronously, at call time.** An async generator's
|
|
279
|
+
* body does not run until its first `next()`, so wiring the handlers
|
|
280
|
+
* inside one would leave a rejection unhandled for as long as the caller
|
|
281
|
+
* waits before iterating — which crashes the process under Node's default
|
|
282
|
+
* unhandled-rejection policy.
|
|
283
|
+
* 2. **Order is recorded when each promise settles**, not when a consumer
|
|
284
|
+
* asks. Re-racing the survivors on every turn tie-breaks on argument
|
|
285
|
+
* order instead: `Promise.race` over promises that are ALREADY settled
|
|
286
|
+
* resolves with the first in iteration order, not the first to have
|
|
287
|
+
* settled — and a consumer doing any work between yields, which is
|
|
288
|
+
* exactly this module's use case, is what lets two settle inside one turn.
|
|
289
|
+
*/
|
|
290
|
+
function inCompletionOrder(promises) {
|
|
291
|
+
const settled = [];
|
|
292
|
+
let wake = null;
|
|
293
|
+
const record = (outcome) => {
|
|
294
|
+
settled.push(outcome);
|
|
295
|
+
const resume = wake;
|
|
296
|
+
wake = null;
|
|
297
|
+
resume?.();
|
|
298
|
+
};
|
|
299
|
+
for (const [index, promise] of promises.entries()) promise.then((value) => {
|
|
300
|
+
record({
|
|
301
|
+
index,
|
|
302
|
+
value
|
|
303
|
+
});
|
|
304
|
+
}, (reason) => {
|
|
305
|
+
record({
|
|
306
|
+
index,
|
|
307
|
+
reason
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
return (async function* () {
|
|
311
|
+
for (let delivered = 0; delivered < promises.length; delivered += 1) {
|
|
312
|
+
if (settled.length === delivered) await new Promise((resolve) => {
|
|
313
|
+
wake = resolve;
|
|
314
|
+
});
|
|
315
|
+
const outcome = settled[delivered];
|
|
316
|
+
if (outcome === void 0) throw new Error("inCompletionOrder: woke with nothing settled");
|
|
317
|
+
yield outcome;
|
|
318
|
+
}
|
|
319
|
+
})();
|
|
320
|
+
}
|
|
22
321
|
/** A cached catalog younger than this is served without touching the network. */
|
|
23
322
|
const FRESH_MS = 3e5;
|
|
323
|
+
/** How long a probe may take before the race gives up on that origin. Long
|
|
324
|
+
* enough for a slow but working link, short enough that a black-holed origin
|
|
325
|
+
* does not hold the shelf closed. */
|
|
326
|
+
const PROBE_TIMEOUT_MS = 1e4;
|
|
327
|
+
/** How long the committed origin has to produce its pointer. `httpOrigin`
|
|
328
|
+
* answers instantly — its probe already fetched the bytes — but `npmOrigin`
|
|
329
|
+
* downloads its tarball here, so this is a bulk-transfer budget, not a probe
|
|
330
|
+
* one. Without it a winner that stalls mid-body parks the race forever while
|
|
331
|
+
* healthy origins sit settled and unread, which is the exact failure the race
|
|
332
|
+
* exists to prevent. Generous against every measured npm origin (12.53 MB/s
|
|
333
|
+
* mirror -> 0.12 s for 1.5 MB; npmjs direct 1.99 MB/s -> 0.75 s). */
|
|
334
|
+
const COMMIT_TIMEOUT_MS = 3e4;
|
|
335
|
+
/** Reject with a TransportError if `work` outlives `COMMIT_TIMEOUT_MS`. The
|
|
336
|
+
* underlying fetch is left to finish or fail on its own and its result is
|
|
337
|
+
* discarded: aborting it would need a signal threaded through OriginHandle,
|
|
338
|
+
* and a stalled origin we have already abandoned costs nothing but its own
|
|
339
|
+
* socket. */
|
|
340
|
+
async function withCommitTimeout(work, id) {
|
|
341
|
+
let timer;
|
|
342
|
+
try {
|
|
343
|
+
return await Promise.race([work, new Promise((_resolve, reject) => {
|
|
344
|
+
timer = setTimeout(() => reject(new TransportError(`${id} did not produce a pointer within ${COMMIT_TIMEOUT_MS} ms`)), COMMIT_TIMEOUT_MS);
|
|
345
|
+
})]);
|
|
346
|
+
} finally {
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
24
350
|
/** Records when the loader itself wrote the cache; the pointer's `builtAt` is
|
|
25
351
|
* the catalog's build time, not the cache's fetch time. */
|
|
26
352
|
const META_FILE = "index.meta.json";
|
|
@@ -122,18 +448,6 @@ const nodeFs$1 = {
|
|
|
122
448
|
writeFileSync(path, data);
|
|
123
449
|
}
|
|
124
450
|
};
|
|
125
|
-
/** Resolve the pointer's data URL against the catalog base. An absolute URL —
|
|
126
|
-
* any scheme, or a protocol-relative `//host/...` — would hand the pointer a
|
|
127
|
-
* fetch primitive to arbitrary hosts, so it is refused loudly before any
|
|
128
|
-
* fetch (§9.2). The guard is the resolved origin, not the raw string: WHATWG
|
|
129
|
-
* normalization strips leading whitespace and accepts backslash spellings
|
|
130
|
-
* before the string could be inspected, so only comparing the resolved URL's
|
|
131
|
-
* origin to the base's closes every spelling class. */
|
|
132
|
-
function resolveDataUrl(baseUrl, url) {
|
|
133
|
-
const resolved = new URL(url, baseUrl);
|
|
134
|
-
if (resolved.origin !== new URL(baseUrl).origin) throw new Error("catalog data url must be relative to the catalog base");
|
|
135
|
-
return resolved.href;
|
|
136
|
-
}
|
|
137
451
|
/** Read and verify a cached/fetched stars sidecar; ANY irregularity degrades
|
|
138
452
|
* to an empty map — stars are advisory (spec §5). */
|
|
139
453
|
function parseStarsText(text) {
|
|
@@ -158,7 +472,10 @@ function parseStarsText(text) {
|
|
|
158
472
|
* included, degrades to no stars (spec §5).
|
|
159
473
|
*/
|
|
160
474
|
async function loadCatalog(options) {
|
|
161
|
-
const {
|
|
475
|
+
const { cacheDir, refresh = false, fetchImpl = fetch, now = () => /* @__PURE__ */ new Date(), fsImpl = nodeFs$1 } = options;
|
|
476
|
+
if (options.baseUrl === void 0 === (options.origins === void 0)) throw new Error("loadCatalog: exactly one of baseUrl or origins is required");
|
|
477
|
+
const originList = options.origins ?? [httpOrigin(options.baseUrl, fetchImpl)];
|
|
478
|
+
if (originList.length === 0) throw new Error("loadCatalog: no origins");
|
|
162
479
|
const indexPath = join(cacheDir, "index.json");
|
|
163
480
|
const metaPath = join(cacheDir, META_FILE);
|
|
164
481
|
/** The timestamp freshness is measured from: the sidecar's fetch time when
|
|
@@ -211,34 +528,41 @@ async function loadCatalog(options) {
|
|
|
211
528
|
};
|
|
212
529
|
}
|
|
213
530
|
}
|
|
214
|
-
|
|
215
|
-
try {
|
|
216
|
-
const response = await fetchImpl(new URL("index.json", baseUrl).href);
|
|
217
|
-
if (!response.ok) throw new Error(`catalog pointer returned ${response.status}`);
|
|
218
|
-
pointerText = await response.text();
|
|
219
|
-
} catch (error) {
|
|
531
|
+
const cachedOrThrow = (error) => {
|
|
220
532
|
const cached = readCached();
|
|
221
533
|
if (cached !== null) return {
|
|
222
534
|
snapshot: cached,
|
|
223
535
|
stale: true
|
|
224
536
|
};
|
|
225
537
|
throw error;
|
|
538
|
+
};
|
|
539
|
+
let handle = null;
|
|
540
|
+
let pointerText = "";
|
|
541
|
+
let lastTransportError = new TransportError("no catalog origin was reachable");
|
|
542
|
+
for await (const settled of inCompletionOrder(originList.map((origin) => origin.probe(AbortSignal.timeout(PROBE_TIMEOUT_MS))))) {
|
|
543
|
+
if (!("value" in settled)) {
|
|
544
|
+
if (!(settled.reason instanceof TransportError)) throw settled.reason;
|
|
545
|
+
lastTransportError = settled.reason;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
pointerText = await withCommitTimeout(settled.value.pointer(), settled.value.id);
|
|
550
|
+
handle = settled.value;
|
|
551
|
+
break;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (!(error instanceof TransportError)) throw error;
|
|
554
|
+
lastTransportError = error;
|
|
555
|
+
}
|
|
226
556
|
}
|
|
557
|
+
if (handle === null) return cachedOrThrow(lastTransportError);
|
|
227
558
|
const pointer = pointerSchema.parse(JSON.parse(pointerText));
|
|
228
559
|
if (pointer.schemaVersion > 6) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (6)`);
|
|
229
|
-
const dataUrl = resolveDataUrl(baseUrl, pointer.plugins.url);
|
|
230
560
|
let dataText;
|
|
231
561
|
try {
|
|
232
|
-
|
|
233
|
-
if (!dataResponse.ok) throw new Error(`catalog data returned ${dataResponse.status}`);
|
|
234
|
-
dataText = await dataResponse.text();
|
|
562
|
+
dataText = await handle.file(pointer.plugins.url);
|
|
235
563
|
} catch (error) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
snapshot: cached,
|
|
239
|
-
stale: true
|
|
240
|
-
};
|
|
241
|
-
throw error;
|
|
564
|
+
if (!(error instanceof TransportError)) throw error;
|
|
565
|
+
return cachedOrThrow(error);
|
|
242
566
|
}
|
|
243
567
|
const actual = createHash("sha256").update(dataText).digest("hex");
|
|
244
568
|
if (actual !== pointer.plugins.sha256) throw new Error(`catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
|
|
@@ -247,13 +571,10 @@ async function loadCatalog(options) {
|
|
|
247
571
|
validateEntryCoherence(data.plugins);
|
|
248
572
|
let stars = {};
|
|
249
573
|
if (pointer.stars !== void 0) try {
|
|
250
|
-
const
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
stars = parseStarsText(starsText);
|
|
255
|
-
fsImpl.write(join(cacheDir, basename(pointer.stars.url)), starsText);
|
|
256
|
-
}
|
|
574
|
+
const starsText = await handle.file(pointer.stars.url);
|
|
575
|
+
if (createHash("sha256").update(starsText).digest("hex") === pointer.stars.sha256) {
|
|
576
|
+
stars = parseStarsText(starsText);
|
|
577
|
+
fsImpl.write(join(cacheDir, basename(pointer.stars.url)), starsText);
|
|
257
578
|
}
|
|
258
579
|
} catch {}
|
|
259
580
|
const snapshot = {
|
|
@@ -271,6 +592,76 @@ async function loadCatalog(options) {
|
|
|
271
592
|
stale: false
|
|
272
593
|
};
|
|
273
594
|
}
|
|
595
|
+
/** The npm package carrying the same `v1/` tree (design §2). */
|
|
596
|
+
const CATALOG_PACKAGE = "dsh-plugin-shop-catalog";
|
|
597
|
+
/** Registries raced by default: the domestic mirror first for legibility —
|
|
598
|
+
* the race, not the order, decides the winner. */
|
|
599
|
+
const DEFAULT_REGISTRIES = ["https://registry.npmmirror.com/", "https://registry.npmjs.org/"];
|
|
600
|
+
/**
|
|
601
|
+
* The origins to race for this installation (design §3).
|
|
602
|
+
*
|
|
603
|
+
* @param catalogUrl - the row's configured base.
|
|
604
|
+
* @param npmRegistry - the user's own registry from `~/.npmrc`, or null.
|
|
605
|
+
*/
|
|
606
|
+
function catalogOrigins(catalogUrl, fetchImpl, npmRegistry) {
|
|
607
|
+
if (catalogUrl !== "https://LivXue.github.io/dsh-plugin-shop/v1/") return [httpOrigin(catalogUrl, fetchImpl)];
|
|
608
|
+
const registries = [...DEFAULT_REGISTRIES];
|
|
609
|
+
const normalizedNpmRegistry = npmRegistry === null ? null : normalizeRegistryUrl(npmRegistry);
|
|
610
|
+
if (normalizedNpmRegistry !== null && !registries.includes(normalizedNpmRegistry)) registries.unshift(normalizedNpmRegistry);
|
|
611
|
+
return [...registries.map((registry) => npmOrigin(registry, CATALOG_PACKAGE, fetchImpl)), httpOrigin(catalogUrl, fetchImpl)];
|
|
612
|
+
}
|
|
613
|
+
//#endregion
|
|
614
|
+
//#region src/host/npmrc.ts
|
|
615
|
+
/** The user's configured npm registry, if they have one (design §3).
|
|
616
|
+
*
|
|
617
|
+
* Pure: the caller injects the read. This is a deliberately partial reading
|
|
618
|
+
* of npm's config resolution — only the user-level `registry=` line — and
|
|
619
|
+
* that is safe precisely because the origin list is raced: a registry we
|
|
620
|
+
* guess wrong about loses a 400-byte request and nothing else.
|
|
621
|
+
*
|
|
622
|
+
* That property only holds for a value that is actually a URL, which is why
|
|
623
|
+
* the value is VALIDATED here rather than left to the caller. `npmOrigin`
|
|
624
|
+
* addresses its probe with `new URL(<pkg>/latest, registryUrl)`, which
|
|
625
|
+
* throws a raw `TypeError` — not a `TransportError` — for anything that is
|
|
626
|
+
* not an absolute URL, and `catalog.ts`'s race loop rethrows everything that
|
|
627
|
+
* is not a `TransportError`. An unvalidated `registry=` line would therefore
|
|
628
|
+
* fail the WHOLE load with npmmirror, npmjs and Pages all healthy and no
|
|
629
|
+
* cache fallback: the opposite of the stated property. Not a hypothetical
|
|
630
|
+
* shape either — `registry=${NPM_REGISTRY}/` is npm's own documented config
|
|
631
|
+
* expansion, it works perfectly for npm, and a reader that does not expand
|
|
632
|
+
* it captures the literal. */
|
|
633
|
+
/** The value, if it is an absolute `http:`/`https:` URL; otherwise null.
|
|
634
|
+
*
|
|
635
|
+
* Both halves earn their place. The parse rejects a bare host, a relative
|
|
636
|
+
* path, and an unexpanded `${VAR}`. The scheme check then rejects what
|
|
637
|
+
* `new URL` happily accepts but the raced origins cannot fetch from —
|
|
638
|
+
* `file:` and `ftp:` parse fine and are not registries a `fetch` can read.
|
|
639
|
+
* The raw string is returned rather than the parsed href, so the value the
|
|
640
|
+
* user wrote is what reaches `normalizeRegistryUrl` and the origin id. */
|
|
641
|
+
function asRegistryUrl(value) {
|
|
642
|
+
let parsed;
|
|
643
|
+
try {
|
|
644
|
+
parsed = new URL(value);
|
|
645
|
+
} catch {
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? value : null;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* @param readFile - returns the file's text, or null when it does not exist.
|
|
652
|
+
* @param home - the user's home directory.
|
|
653
|
+
*/
|
|
654
|
+
function npmrcRegistry(readFile, home) {
|
|
655
|
+
const text = readFile(join(home, ".npmrc"));
|
|
656
|
+
if (text === null) return null;
|
|
657
|
+
for (const line of text.split("\n")) {
|
|
658
|
+
const value = /^\s*registry\s*=\s*(\S+)\s*$/.exec(line)?.[1];
|
|
659
|
+
if (value === void 0) continue;
|
|
660
|
+
const url = asRegistryUrl(value);
|
|
661
|
+
if (url !== null) return url;
|
|
662
|
+
}
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
274
665
|
//#endregion
|
|
275
666
|
//#region src/host/install.ts
|
|
276
667
|
/**
|
|
@@ -1334,6 +1725,9 @@ let ShopGateway = (() => {
|
|
|
1334
1725
|
/** The install gate runs against the last loaded snapshot, never a fresh
|
|
1335
1726
|
* fetch per request (§7.2: the Host's cached snapshot is the truth). */
|
|
1336
1727
|
lastSnapshot = null;
|
|
1728
|
+
/** The origin list built for the last-seen `catalogUrl`, memoised so the
|
|
1729
|
+
* user's npmrc is read at most once per gateway (see `originsFor`). */
|
|
1730
|
+
originCache = null;
|
|
1337
1731
|
/** The incompatibility map already computed for `lastSnapshot`, keyed by
|
|
1338
1732
|
* that snapshot's own object identity. Design §3 asks for the verdict
|
|
1339
1733
|
* once per loaded snapshot, not once per RPC call: `loadCatalog` serves
|
|
@@ -1520,6 +1914,25 @@ let ShopGateway = (() => {
|
|
|
1520
1914
|
cacheDir
|
|
1521
1915
|
};
|
|
1522
1916
|
}
|
|
1917
|
+
/** The origins to race for this row's catalog. Read once per gateway: the
|
|
1918
|
+
* user's npmrc does not change under a running dsh, and re-reading it on
|
|
1919
|
+
* every catalog call would put a filesystem read on the hot path. */
|
|
1920
|
+
originsFor(catalogUrl) {
|
|
1921
|
+
if (this.originCache?.catalogUrl === catalogUrl) return this.originCache.origins;
|
|
1922
|
+
const registry = npmrcRegistry((path) => {
|
|
1923
|
+
try {
|
|
1924
|
+
return readFileSync(path, "utf8");
|
|
1925
|
+
} catch {
|
|
1926
|
+
return null;
|
|
1927
|
+
}
|
|
1928
|
+
}, homedir());
|
|
1929
|
+
const origins = catalogOrigins(catalogUrl, fetch, registry);
|
|
1930
|
+
this.originCache = {
|
|
1931
|
+
catalogUrl,
|
|
1932
|
+
origins
|
|
1933
|
+
};
|
|
1934
|
+
return origins;
|
|
1935
|
+
}
|
|
1523
1936
|
/** The explicit restart override. Only the row's `config:` sub-object is
|
|
1524
1937
|
* passed to a plugin — a top-level `allowRestart:` beside `name:` would be
|
|
1525
1938
|
* silently ignored by the loader (dsh-market README, #227). */
|
|
@@ -1531,7 +1944,7 @@ let ShopGateway = (() => {
|
|
|
1531
1944
|
async catalog(args) {
|
|
1532
1945
|
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
1533
1946
|
const { snapshot, stale } = await (this.options.loadCatalog ?? loadCatalog)({
|
|
1534
|
-
|
|
1947
|
+
origins: this.originsFor(catalogUrl),
|
|
1535
1948
|
cacheDir,
|
|
1536
1949
|
refresh: args?.refresh ?? false
|
|
1537
1950
|
});
|
|
@@ -1569,7 +1982,7 @@ let ShopGateway = (() => {
|
|
|
1569
1982
|
if (this.lastSnapshot === null) {
|
|
1570
1983
|
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
1571
1984
|
const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
|
|
1572
|
-
|
|
1985
|
+
origins: this.originsFor(catalogUrl),
|
|
1573
1986
|
cacheDir
|
|
1574
1987
|
});
|
|
1575
1988
|
this.lastSnapshot = snapshot;
|
|
@@ -1677,7 +2090,7 @@ let ShopGateway = (() => {
|
|
|
1677
2090
|
if (this.lastSnapshot === null) {
|
|
1678
2091
|
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
1679
2092
|
const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
|
|
1680
|
-
|
|
2093
|
+
origins: this.originsFor(catalogUrl),
|
|
1681
2094
|
cacheDir
|
|
1682
2095
|
});
|
|
1683
2096
|
this.lastSnapshot = snapshot;
|
|
@@ -1755,7 +2168,7 @@ let ShopGateway = (() => {
|
|
|
1755
2168
|
if (this.lastSnapshot === null) {
|
|
1756
2169
|
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
1757
2170
|
const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
|
|
1758
|
-
|
|
2171
|
+
origins: this.originsFor(catalogUrl),
|
|
1759
2172
|
cacheDir
|
|
1760
2173
|
});
|
|
1761
2174
|
this.lastSnapshot = snapshot;
|
package/lib/typert.host.js
CHANGED
|
@@ -154,7 +154,7 @@ export const TYPERT = {
|
|
|
154
154
|
typeSymbol: 'dsh-plugin-shop/types#ShopCatalogResult',
|
|
155
155
|
schema: dsh_plugin_shop_shop_catalog_result$schema,
|
|
156
156
|
},
|
|
157
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
157
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":568,"column":9},
|
|
158
158
|
},
|
|
159
159
|
{
|
|
160
160
|
id: 'dsh-plugin-shop#shop/installed',
|
|
@@ -169,7 +169,7 @@ export const TYPERT = {
|
|
|
169
169
|
typeSymbol: 'dsh-plugin-shop#shop/installed:result',
|
|
170
170
|
schema: dsh_plugin_shop_shop_installed_result$schema,
|
|
171
171
|
},
|
|
172
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
172
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":748,"column":9},
|
|
173
173
|
},
|
|
174
174
|
{
|
|
175
175
|
id: 'dsh-plugin-shop#shop/installStart',
|
|
@@ -195,7 +195,7 @@ export const TYPERT = {
|
|
|
195
195
|
typeSymbol: 'dsh-plugin-shop/types#ShopInstallResult',
|
|
196
196
|
schema: dsh_plugin_shop_shop_installStart_result$schema,
|
|
197
197
|
},
|
|
198
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
198
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":618,"column":9},
|
|
199
199
|
},
|
|
200
200
|
{
|
|
201
201
|
id: 'dsh-plugin-shop#shop/installStatus',
|
|
@@ -220,7 +220,7 @@ export const TYPERT = {
|
|
|
220
220
|
typeSymbol: 'dsh-plugin-shop/types#ShopInstallStatusResult',
|
|
221
221
|
schema: dsh_plugin_shop_shop_installStatus_result$schema,
|
|
222
222
|
},
|
|
223
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
223
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":737,"column":3},
|
|
224
224
|
},
|
|
225
225
|
{
|
|
226
226
|
id: 'dsh-plugin-shop#shop/restart',
|
|
@@ -235,7 +235,7 @@ export const TYPERT = {
|
|
|
235
235
|
typeSymbol: 'dsh-plugin-shop/types#ShopRestartResult',
|
|
236
236
|
schema: dsh_plugin_shop_shop_restart_result$schema,
|
|
237
237
|
},
|
|
238
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
238
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":898,"column":9},
|
|
239
239
|
},
|
|
240
240
|
{
|
|
241
241
|
id: 'dsh-plugin-shop#shop/setEnabled',
|
|
@@ -260,7 +260,7 @@ export const TYPERT = {
|
|
|
260
260
|
typeSymbol: 'dsh-plugin-shop/types#ShopSetEnabledResult',
|
|
261
261
|
schema: dsh_plugin_shop_shop_setEnabled_result$schema,
|
|
262
262
|
},
|
|
263
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
263
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":466,"column":9},
|
|
264
264
|
},
|
|
265
265
|
{
|
|
266
266
|
id: 'dsh-plugin-shop#shop/uninstallStart',
|
|
@@ -286,7 +286,7 @@ export const TYPERT = {
|
|
|
286
286
|
typeSymbol: 'dsh-plugin-shop/types#ShopUninstallResult',
|
|
287
287
|
schema: dsh_plugin_shop_shop_uninstallStart_result$schema,
|
|
288
288
|
},
|
|
289
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
289
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":840,"column":9},
|
|
290
290
|
},
|
|
291
291
|
{
|
|
292
292
|
id: 'dsh-plugin-shop#shop/updateStart',
|
|
@@ -311,7 +311,7 @@ export const TYPERT = {
|
|
|
311
311
|
typeSymbol: 'dsh-plugin-shop/types#ShopUpdateResult',
|
|
312
312
|
schema: dsh_plugin_shop_shop_updateStart_result$schema,
|
|
313
313
|
},
|
|
314
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
314
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":958,"column":9},
|
|
315
315
|
},
|
|
316
316
|
{
|
|
317
317
|
id: 'dsh-plugin-shop#shop/version',
|
|
@@ -326,7 +326,7 @@ export const TYPERT = {
|
|
|
326
326
|
typeSymbol: 'dsh-plugin-shop/types#ShopVersionResult',
|
|
327
327
|
schema: dsh_plugin_shop_shop_version_result$schema,
|
|
328
328
|
},
|
|
329
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
329
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":942,"column":9},
|
|
330
330
|
},
|
|
331
331
|
],
|
|
332
332
|
model: {
|