dsh-plugin-shop 0.6.0-beta.2 → 0.7.0-beta.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 +31 -10
- package/lib/index.js +453 -40
- package/lib/typert.host.js +9 -9
- package/lib/typert.remote-client.js +9 -9
- package/lib/types/client/present.d.ts +19 -0
- 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/README.md
CHANGED
|
@@ -52,9 +52,9 @@ install them once with `npm install -g @deepseek-ai/dsh pnpm` and verify with
|
|
|
52
52
|
# holds back very recent releases, so a bare `add dsh-plugin-shop` can hand
|
|
53
53
|
# you an older version for a while. Pin the current release — refresh it
|
|
54
54
|
# with `npm view dsh-plugin-shop version`.
|
|
55
|
-
dsh plugin --profile web add dsh-plugin-shop@0.
|
|
55
|
+
dsh plugin --profile web add dsh-plugin-shop@0.6.0
|
|
56
56
|
# or straight through npx, nothing installed:
|
|
57
|
-
npx -y @deepseek-ai/dsh plugin --profile web add dsh-plugin-shop@0.
|
|
57
|
+
npx -y @deepseek-ai/dsh plugin --profile web add dsh-plugin-shop@0.6.0
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
Replace `web` with your profile if you use another one. Then restart `dsh` once — a
|
|
@@ -84,7 +84,7 @@ ls -1 "${DSH_HOME:-$HOME/.dsh}/profiles" | grep -v '^node_modules$'
|
|
|
84
84
|
cooldown, and deterministic installs are the point of the agent path.
|
|
85
85
|
|
|
86
86
|
```sh
|
|
87
|
-
dsh plugin --profile <profile> add dsh-plugin-shop@0.
|
|
87
|
+
dsh plugin --profile <profile> add dsh-plugin-shop@0.6.0
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
**3. Verify — do not skip this.** A zero exit from step 2 means pnpm resolved the
|
|
@@ -138,9 +138,16 @@ Compromising the browser half buys an attacker those nine calls and nothing more
|
|
|
138
138
|
|
|
139
139
|
## ⚙️ Configuration
|
|
140
140
|
|
|
141
|
+
The shop reads its catalog from whichever source answers first: the npm
|
|
142
|
+
package `dsh-plugin-shop-catalog` (via your configured registry, npmmirror,
|
|
143
|
+
or npmjs) or `https://LivXue.github.io/dsh-plugin-shop/v1/`. All of them
|
|
144
|
+
carry the same bytes; the race exists because the link to one of them can be
|
|
145
|
+
far slower than the link to another. Setting `DSH_SHOP_CATALOG_URL` opts out
|
|
146
|
+
of the race and uses only what you name.
|
|
147
|
+
|
|
141
148
|
| Variable | Effect |
|
|
142
149
|
|---|---|
|
|
143
|
-
| `DSH_SHOP_CATALOG_URL` |
|
|
150
|
+
| `DSH_SHOP_CATALOG_URL` | Read the catalog only from this base, instead of racing the default sources |
|
|
144
151
|
|
|
145
152
|
## 📚 The catalog
|
|
146
153
|
|
package/lib/client.js
CHANGED
|
@@ -4437,7 +4437,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4437
4437
|
},
|
|
4438
4438
|
sourceLocation: {
|
|
4439
4439
|
"file": "packages/dsh-plugin-shop/src/host/index.ts",
|
|
4440
|
-
"line":
|
|
4440
|
+
"line": 568,
|
|
4441
4441
|
"column": 9
|
|
4442
4442
|
}
|
|
4443
4443
|
},
|
|
@@ -4455,7 +4455,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4455
4455
|
},
|
|
4456
4456
|
sourceLocation: {
|
|
4457
4457
|
"file": "packages/dsh-plugin-shop/src/host/index.ts",
|
|
4458
|
-
"line":
|
|
4458
|
+
"line": 748,
|
|
4459
4459
|
"column": 9
|
|
4460
4460
|
}
|
|
4461
4461
|
},
|
|
@@ -4483,7 +4483,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4483
4483
|
},
|
|
4484
4484
|
sourceLocation: {
|
|
4485
4485
|
"file": "packages/dsh-plugin-shop/src/host/index.ts",
|
|
4486
|
-
"line":
|
|
4486
|
+
"line": 618,
|
|
4487
4487
|
"column": 9
|
|
4488
4488
|
}
|
|
4489
4489
|
},
|
|
@@ -4510,7 +4510,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4510
4510
|
},
|
|
4511
4511
|
sourceLocation: {
|
|
4512
4512
|
"file": "packages/dsh-plugin-shop/src/host/index.ts",
|
|
4513
|
-
"line":
|
|
4513
|
+
"line": 737,
|
|
4514
4514
|
"column": 3
|
|
4515
4515
|
}
|
|
4516
4516
|
},
|
|
@@ -4528,7 +4528,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4528
4528
|
},
|
|
4529
4529
|
sourceLocation: {
|
|
4530
4530
|
"file": "packages/dsh-plugin-shop/src/host/index.ts",
|
|
4531
|
-
"line":
|
|
4531
|
+
"line": 898,
|
|
4532
4532
|
"column": 9
|
|
4533
4533
|
}
|
|
4534
4534
|
},
|
|
@@ -4555,7 +4555,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4555
4555
|
},
|
|
4556
4556
|
sourceLocation: {
|
|
4557
4557
|
"file": "packages/dsh-plugin-shop/src/host/index.ts",
|
|
4558
|
-
"line":
|
|
4558
|
+
"line": 466,
|
|
4559
4559
|
"column": 9
|
|
4560
4560
|
}
|
|
4561
4561
|
},
|
|
@@ -4583,7 +4583,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4583
4583
|
},
|
|
4584
4584
|
sourceLocation: {
|
|
4585
4585
|
"file": "packages/dsh-plugin-shop/src/host/index.ts",
|
|
4586
|
-
"line":
|
|
4586
|
+
"line": 840,
|
|
4587
4587
|
"column": 9
|
|
4588
4588
|
}
|
|
4589
4589
|
},
|
|
@@ -4610,7 +4610,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4610
4610
|
},
|
|
4611
4611
|
sourceLocation: {
|
|
4612
4612
|
"file": "packages/dsh-plugin-shop/src/host/index.ts",
|
|
4613
|
-
"line":
|
|
4613
|
+
"line": 958,
|
|
4614
4614
|
"column": 9
|
|
4615
4615
|
}
|
|
4616
4616
|
},
|
|
@@ -4628,7 +4628,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4628
4628
|
},
|
|
4629
4629
|
sourceLocation: {
|
|
4630
4630
|
"file": "packages/dsh-plugin-shop/src/host/index.ts",
|
|
4631
|
-
"line":
|
|
4631
|
+
"line": 942,
|
|
4632
4632
|
"column": 9
|
|
4633
4633
|
}
|
|
4634
4634
|
}
|
|
@@ -4895,6 +4895,27 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
4895
4895
|
return `https://www.npmjs.com/package/${name}`;
|
|
4896
4896
|
}
|
|
4897
4897
|
/**
|
|
4898
|
+
* One entry's install identity — the value the catalog guarantees unique, and
|
|
4899
|
+
* therefore the only safe React key for a shelf card.
|
|
4900
|
+
*
|
|
4901
|
+
* `name` is NOT unique. The catalog's uniqueness invariant is the install
|
|
4902
|
+
* identity (registry `emit.ts` assertCatalogInvariants): `npm:<name>` for an
|
|
4903
|
+
* npm entry, `github:<repo>#<subdir>` for a repo one — so two GitHub
|
|
4904
|
+
* repositories publishing the same `package.json` name are two legitimate
|
|
4905
|
+
* entries under one name, as are two subpackages of one monorepo. The live
|
|
4906
|
+
* catalog holds 151 such names over 243 entries, five of them cookiecutter
|
|
4907
|
+
* templates that all name themselves `{{PKG_NAME}}`.
|
|
4908
|
+
*
|
|
4909
|
+
* Keying the shelf by name handed React duplicate keys, and React could then
|
|
4910
|
+
* no longer match a card to its DOM node: changing the filter left every
|
|
4911
|
+
* duplicate orphaned on the page — hundreds of stale cards from the previous
|
|
4912
|
+
* category, accumulating with each switch until the tab stopped responding.
|
|
4913
|
+
* This mirrors the registry's identity verbatim; the two must not drift.
|
|
4914
|
+
*/
|
|
4915
|
+
function entryKey(entry) {
|
|
4916
|
+
return entry.source === "npm" ? `npm:${entry.name}` : `github:${entry.repo ?? entry.name}#${entry.subdir ?? ""}`;
|
|
4917
|
+
}
|
|
4918
|
+
/**
|
|
4898
4919
|
* One entry's star count from the sidecar, or undefined when it has none.
|
|
4899
4920
|
*
|
|
4900
4921
|
* The sidecar keys a github entry by its repo full name and an npm entry by
|
|
@@ -6435,7 +6456,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
6435
6456
|
restart,
|
|
6436
6457
|
restartSupported,
|
|
6437
6458
|
setEnabled
|
|
6438
|
-
}) }, entry
|
|
6459
|
+
}) }, entryKey(entry))), incremental && visibleCount < filtered.length && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
|
|
6439
6460
|
ref: sentinelRef,
|
|
6440
6461
|
className: _dsh_plugin_shop_css_e3675a89_default.cardsSentry,
|
|
6441
6462
|
"data-shop-sentry": true,
|
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: {
|
|
@@ -151,7 +151,7 @@ export const TYPERT_REMOTE = {
|
|
|
151
151
|
typeSymbol: 'dsh-plugin-shop/types#ShopCatalogResult',
|
|
152
152
|
schema: dsh_plugin_shop_shop_catalog_result$schema,
|
|
153
153
|
},
|
|
154
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
154
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":568,"column":9},
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
id: 'dsh-plugin-shop#shop/installed',
|
|
@@ -166,7 +166,7 @@ export const TYPERT_REMOTE = {
|
|
|
166
166
|
typeSymbol: 'dsh-plugin-shop#shop/installed:result',
|
|
167
167
|
schema: dsh_plugin_shop_shop_installed_result$schema,
|
|
168
168
|
},
|
|
169
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
169
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":748,"column":9},
|
|
170
170
|
},
|
|
171
171
|
{
|
|
172
172
|
id: 'dsh-plugin-shop#shop/installStart',
|
|
@@ -192,7 +192,7 @@ export const TYPERT_REMOTE = {
|
|
|
192
192
|
typeSymbol: 'dsh-plugin-shop/types#ShopInstallResult',
|
|
193
193
|
schema: dsh_plugin_shop_shop_installStart_result$schema,
|
|
194
194
|
},
|
|
195
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
195
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":618,"column":9},
|
|
196
196
|
},
|
|
197
197
|
{
|
|
198
198
|
id: 'dsh-plugin-shop#shop/installStatus',
|
|
@@ -217,7 +217,7 @@ export const TYPERT_REMOTE = {
|
|
|
217
217
|
typeSymbol: 'dsh-plugin-shop/types#ShopInstallStatusResult',
|
|
218
218
|
schema: dsh_plugin_shop_shop_installStatus_result$schema,
|
|
219
219
|
},
|
|
220
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
220
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":737,"column":3},
|
|
221
221
|
},
|
|
222
222
|
{
|
|
223
223
|
id: 'dsh-plugin-shop#shop/restart',
|
|
@@ -232,7 +232,7 @@ export const TYPERT_REMOTE = {
|
|
|
232
232
|
typeSymbol: 'dsh-plugin-shop/types#ShopRestartResult',
|
|
233
233
|
schema: dsh_plugin_shop_shop_restart_result$schema,
|
|
234
234
|
},
|
|
235
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
235
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":898,"column":9},
|
|
236
236
|
},
|
|
237
237
|
{
|
|
238
238
|
id: 'dsh-plugin-shop#shop/setEnabled',
|
|
@@ -257,7 +257,7 @@ export const TYPERT_REMOTE = {
|
|
|
257
257
|
typeSymbol: 'dsh-plugin-shop/types#ShopSetEnabledResult',
|
|
258
258
|
schema: dsh_plugin_shop_shop_setEnabled_result$schema,
|
|
259
259
|
},
|
|
260
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
260
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":466,"column":9},
|
|
261
261
|
},
|
|
262
262
|
{
|
|
263
263
|
id: 'dsh-plugin-shop#shop/uninstallStart',
|
|
@@ -283,7 +283,7 @@ export const TYPERT_REMOTE = {
|
|
|
283
283
|
typeSymbol: 'dsh-plugin-shop/types#ShopUninstallResult',
|
|
284
284
|
schema: dsh_plugin_shop_shop_uninstallStart_result$schema,
|
|
285
285
|
},
|
|
286
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
286
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":840,"column":9},
|
|
287
287
|
},
|
|
288
288
|
{
|
|
289
289
|
id: 'dsh-plugin-shop#shop/updateStart',
|
|
@@ -308,7 +308,7 @@ export const TYPERT_REMOTE = {
|
|
|
308
308
|
typeSymbol: 'dsh-plugin-shop/types#ShopUpdateResult',
|
|
309
309
|
schema: dsh_plugin_shop_shop_updateStart_result$schema,
|
|
310
310
|
},
|
|
311
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
311
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":958,"column":9},
|
|
312
312
|
},
|
|
313
313
|
{
|
|
314
314
|
id: 'dsh-plugin-shop#shop/version',
|
|
@@ -323,7 +323,7 @@ export const TYPERT_REMOTE = {
|
|
|
323
323
|
typeSymbol: 'dsh-plugin-shop/types#ShopVersionResult',
|
|
324
324
|
schema: dsh_plugin_shop_shop_version_result$schema,
|
|
325
325
|
},
|
|
326
|
-
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":
|
|
326
|
+
sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":942,"column":9},
|
|
327
327
|
},
|
|
328
328
|
],
|
|
329
329
|
}
|
|
@@ -182,6 +182,25 @@ export declare function hasGithubHome(entry: CatalogEntry): boolean;
|
|
|
182
182
|
*/
|
|
183
183
|
export declare function authorOf(entry: CatalogEntry): string | null;
|
|
184
184
|
export declare function npmPageUrl(entry: CatalogEntry): string | null;
|
|
185
|
+
/**
|
|
186
|
+
* One entry's install identity — the value the catalog guarantees unique, and
|
|
187
|
+
* therefore the only safe React key for a shelf card.
|
|
188
|
+
*
|
|
189
|
+
* `name` is NOT unique. The catalog's uniqueness invariant is the install
|
|
190
|
+
* identity (registry `emit.ts` assertCatalogInvariants): `npm:<name>` for an
|
|
191
|
+
* npm entry, `github:<repo>#<subdir>` for a repo one — so two GitHub
|
|
192
|
+
* repositories publishing the same `package.json` name are two legitimate
|
|
193
|
+
* entries under one name, as are two subpackages of one monorepo. The live
|
|
194
|
+
* catalog holds 151 such names over 243 entries, five of them cookiecutter
|
|
195
|
+
* templates that all name themselves `{{PKG_NAME}}`.
|
|
196
|
+
*
|
|
197
|
+
* Keying the shelf by name handed React duplicate keys, and React could then
|
|
198
|
+
* no longer match a card to its DOM node: changing the filter left every
|
|
199
|
+
* duplicate orphaned on the page — hundreds of stale cards from the previous
|
|
200
|
+
* category, accumulating with each switch until the tab stopped responding.
|
|
201
|
+
* This mirrors the registry's identity verbatim; the two must not drift.
|
|
202
|
+
*/
|
|
203
|
+
export declare function entryKey(entry: CatalogEntry): string;
|
|
185
204
|
/**
|
|
186
205
|
* One entry's star count from the sidecar, or undefined when it has none.
|
|
187
206
|
*
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Catalog fetch, verification, and disk cache — the Host's only network path. */
|
|
2
|
+
import { type CatalogOrigin } from './origin.ts';
|
|
2
3
|
import type { CatalogEntry, DeniedEntry } from './types.ts';
|
|
3
4
|
/** Highest schemaVersion this build understands; a higher one is refused (§10).
|
|
4
5
|
* 3 adds `source` and repo entries (github install channel); 4 adds `subdir`
|
|
@@ -27,7 +28,11 @@ export interface CatalogFs {
|
|
|
27
28
|
write: (path: string, data: string) => void;
|
|
28
29
|
}
|
|
29
30
|
export interface LoadCatalogOptions {
|
|
30
|
-
|
|
31
|
+
/** A single HTTP origin — the explicit-override spelling. Mutually
|
|
32
|
+
* exclusive with `origins`; exactly one must be given. */
|
|
33
|
+
baseUrl?: string;
|
|
34
|
+
/** Origins to race (design §3). */
|
|
35
|
+
origins?: CatalogOrigin[];
|
|
31
36
|
cacheDir: string;
|
|
32
37
|
refresh?: boolean;
|
|
33
38
|
fetchImpl?: typeof fetch;
|
|
@@ -46,3 +51,16 @@ export interface LoadCatalogOptions {
|
|
|
46
51
|
* included, degrades to no stars (spec §5).
|
|
47
52
|
*/
|
|
48
53
|
export declare function loadCatalog(options: LoadCatalogOptions): Promise<CatalogResult>;
|
|
54
|
+
/** The catalog base the shipped `cordis.patch.yml` names. A row carrying
|
|
55
|
+
* exactly this value expresses no preference, so the loader races its
|
|
56
|
+
* defaults; anything else is a deliberate override and is used alone. */
|
|
57
|
+
export declare const DEFAULT_CATALOG_URL = "https://LivXue.github.io/dsh-plugin-shop/v1/";
|
|
58
|
+
/** The npm package carrying the same `v1/` tree (design §2). */
|
|
59
|
+
export declare const CATALOG_PACKAGE = "dsh-plugin-shop-catalog";
|
|
60
|
+
/**
|
|
61
|
+
* The origins to race for this installation (design §3).
|
|
62
|
+
*
|
|
63
|
+
* @param catalogUrl - the row's configured base.
|
|
64
|
+
* @param npmRegistry - the user's own registry from `~/.npmrc`, or null.
|
|
65
|
+
*/
|
|
66
|
+
export declare function catalogOrigins(catalogUrl: string, fetchImpl: typeof fetch, npmRegistry: string | null): CatalogOrigin[];
|
|
@@ -238,6 +238,9 @@ export declare class ShopGateway extends TypertRemoteService {
|
|
|
238
238
|
/** The install gate runs against the last loaded snapshot, never a fresh
|
|
239
239
|
* fetch per request (§7.2: the Host's cached snapshot is the truth). */
|
|
240
240
|
private lastSnapshot;
|
|
241
|
+
/** The origin list built for the last-seen `catalogUrl`, memoised so the
|
|
242
|
+
* user's npmrc is read at most once per gateway (see `originsFor`). */
|
|
243
|
+
private originCache;
|
|
241
244
|
/** The incompatibility map already computed for `lastSnapshot`, keyed by
|
|
242
245
|
* that snapshot's own object identity. Design §3 asks for the verdict
|
|
243
246
|
* once per loaded snapshot, not once per RPC call: `loadCatalog` serves
|
|
@@ -294,6 +297,10 @@ export declare class ShopGateway extends TypertRemoteService {
|
|
|
294
297
|
enabled: boolean;
|
|
295
298
|
}): Promise<ShopSetEnabledResult>;
|
|
296
299
|
private rowConfig;
|
|
300
|
+
/** The origins to race for this row's catalog. Read once per gateway: the
|
|
301
|
+
* user's npmrc does not change under a running dsh, and re-reading it on
|
|
302
|
+
* every catalog call would put a filesystem read on the hot path. */
|
|
303
|
+
private originsFor;
|
|
297
304
|
/** The explicit restart override. Only the row's `config:` sub-object is
|
|
298
305
|
* passed to a plugin — a top-level `allowRestart:` beside `name:` would be
|
|
299
306
|
* silently ignored by the loader (dsh-market README, #227). */
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The npm transport (design §2, §3): the catalog as a package.
|
|
2
|
+
*
|
|
3
|
+
* Shell — this and `origin.ts`'s fetch half are the only places the catalog
|
|
4
|
+
* loader touches the network. The payoff is measured, not assumed: the same
|
|
5
|
+
* bytes reach a China-side machine at 12.53 MB/s from npmmirror against
|
|
6
|
+
* 0.03 MB/s from GitHub Pages. */
|
|
7
|
+
import { type CatalogOrigin } from './origin.ts';
|
|
8
|
+
/** Normalise to a trailing slash so relative `URL` resolution against a
|
|
9
|
+
* registry that carries a path — every corporate registry, e.g.
|
|
10
|
+
* `https://artifactory.corp/api/npm/npm-repo` — keeps that path instead of
|
|
11
|
+
* eating its last segment; a host-root registry's trailing slash is already
|
|
12
|
+
* a no-op either way. Exported so `catalog.ts`'s dedupe compares against the
|
|
13
|
+
* same normalised form `npmOrigin` races on. */
|
|
14
|
+
export declare function normalizeRegistryUrl(url: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* An origin that reads the catalog out of `<registryUrl>`'s copy of
|
|
17
|
+
* `<packageName>`.
|
|
18
|
+
*
|
|
19
|
+
* The probe is the abbreviated `latest` manifest — 13.5 KB against the live
|
|
20
|
+
* registry — so the race is decided without downloading anything large. The
|
|
21
|
+
* tarball is fetched lazily on the first `pointer()` or `file()` and kept on
|
|
22
|
+
* the handle, so one origin download serves the whole load.
|
|
23
|
+
*/
|
|
24
|
+
export declare function npmOrigin(rawRegistryUrl: string, packageName: string, fetchImpl: typeof fetch): CatalogOrigin;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The user's configured npm registry, if they have one (design §3).
|
|
2
|
+
*
|
|
3
|
+
* Pure: the caller injects the read. This is a deliberately partial reading
|
|
4
|
+
* of npm's config resolution — only the user-level `registry=` line — and
|
|
5
|
+
* that is safe precisely because the origin list is raced: a registry we
|
|
6
|
+
* guess wrong about loses a 400-byte request and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* That property only holds for a value that is actually a URL, which is why
|
|
9
|
+
* the value is VALIDATED here rather than left to the caller. `npmOrigin`
|
|
10
|
+
* addresses its probe with `new URL(<pkg>/latest, registryUrl)`, which
|
|
11
|
+
* throws a raw `TypeError` — not a `TransportError` — for anything that is
|
|
12
|
+
* not an absolute URL, and `catalog.ts`'s race loop rethrows everything that
|
|
13
|
+
* is not a `TransportError`. An unvalidated `registry=` line would therefore
|
|
14
|
+
* fail the WHOLE load with npmmirror, npmjs and Pages all healthy and no
|
|
15
|
+
* cache fallback: the opposite of the stated property. Not a hypothetical
|
|
16
|
+
* shape either — `registry=${NPM_REGISTRY}/` is npm's own documented config
|
|
17
|
+
* expansion, it works perfectly for npm, and a reader that does not expand
|
|
18
|
+
* it captures the literal. */
|
|
19
|
+
/**
|
|
20
|
+
* @param readFile - returns the file's text, or null when it does not exist.
|
|
21
|
+
* @param home - the user's home directory.
|
|
22
|
+
*/
|
|
23
|
+
export declare function npmrcRegistry(readFile: (path: string) => string | null, home: string): string | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** The transport seam under `loadCatalog` (design §3).
|
|
2
|
+
*
|
|
3
|
+
* An origin answers a cheap probe, then serves the pointer and the files the
|
|
4
|
+
* pointer names. HTTP and npm are interchangeable behind it, so every line of
|
|
5
|
+
* cache and validation logic in `catalog.ts` stays transport-blind. */
|
|
6
|
+
/** A failure of the link, not of the content: the wire threw, or answered
|
|
7
|
+
* non-2xx. This is the ONLY class `loadCatalog` retries on another origin.
|
|
8
|
+
* A bad schema, a sha mismatch, or a refused url is an interpretation
|
|
9
|
+
* failure and throws — masking a corrupt origin behind a healthy one is
|
|
10
|
+
* exactly the silent-wrongness this project refuses. */
|
|
11
|
+
export declare class TransportError extends Error {
|
|
12
|
+
constructor(message: string, options?: {
|
|
13
|
+
cause?: unknown;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export interface OriginHandle {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
/** The pointer JSON text. Cheap: the probe has already paid for it. */
|
|
19
|
+
pointer: () => Promise<string>;
|
|
20
|
+
/** One file named by the pointer, by the pointer's own raw url string.
|
|
21
|
+
* Callers pass that string verbatim — never a basename, which would strip
|
|
22
|
+
* a hostile absolute url into a fetchable relative one. */
|
|
23
|
+
file: (url: string) => Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
export interface CatalogOrigin {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
/** Cheap reachability + identity request. Resolving means this origin can
|
|
28
|
+
* serve; the expensive work happens on the returned handle. */
|
|
29
|
+
probe: (signal: AbortSignal) => Promise<OriginHandle>;
|
|
30
|
+
}
|
|
31
|
+
/** Resolve the pointer's data URL against the catalog base. An absolute URL —
|
|
32
|
+
* any scheme, or a protocol-relative `//host/...` — would hand the pointer a
|
|
33
|
+
* fetch primitive to arbitrary hosts, so it is refused loudly before any
|
|
34
|
+
* fetch (§9.2). The guard is the resolved origin, not the raw string: WHATWG
|
|
35
|
+
* normalization strips leading whitespace and accepts backslash spellings
|
|
36
|
+
* before the string could be inspected, so only comparing the resolved URL's
|
|
37
|
+
* origin to the base's closes every spelling class. */
|
|
38
|
+
export declare function resolveDataUrl(baseUrl: string, url: string): string;
|
|
39
|
+
/** The transport this project has always used: a static `v1/` tree. */
|
|
40
|
+
export declare function httpOrigin(baseUrl: string, fetchImpl: typeof fetch): CatalogOrigin;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Settle a set of promises in the order they finish (design §3).
|
|
2
|
+
*
|
|
3
|
+
* Pure and timer-free. `Promise.any` would give only the first success and
|
|
4
|
+
* discard the rest; the origin race needs the losers too, in order, so a
|
|
5
|
+
* probe that "won" but then fails to produce a pointer can fall through to
|
|
6
|
+
* the next-finishing probe instead of failing the whole load — this is how
|
|
7
|
+
* an npm origin's bulk tarball download, which happens inside `pointer()`,
|
|
8
|
+
* gets a fall-through on failure.
|
|
9
|
+
*
|
|
10
|
+
* That is deliberately narrower than "any bulk fetch falls through to
|
|
11
|
+
* another origin." This module only ever wraps `probe()` promises; once
|
|
12
|
+
* `catalog.ts` has committed to a handle and calls `file()` for the data
|
|
13
|
+
* file, that call happens outside this generator entirely, and a
|
|
14
|
+
* TransportError there falls back to the disk cache instead (see
|
|
15
|
+
* `catalog.ts`'s `cachedOrThrow`), never back into a race. Do not "fix"
|
|
16
|
+
* that path to match this comment — the split is intentional. */
|
|
17
|
+
export type Settled<T> = {
|
|
18
|
+
index: number;
|
|
19
|
+
value: T;
|
|
20
|
+
} | {
|
|
21
|
+
index: number;
|
|
22
|
+
reason: unknown;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Yield each promise's outcome as it settles, tagged with its argument index.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately NOT an `async function*`. Two properties depend on that:
|
|
28
|
+
*
|
|
29
|
+
* 1. **Handlers attach synchronously, at call time.** An async generator's
|
|
30
|
+
* body does not run until its first `next()`, so wiring the handlers
|
|
31
|
+
* inside one would leave a rejection unhandled for as long as the caller
|
|
32
|
+
* waits before iterating — which crashes the process under Node's default
|
|
33
|
+
* unhandled-rejection policy.
|
|
34
|
+
* 2. **Order is recorded when each promise settles**, not when a consumer
|
|
35
|
+
* asks. Re-racing the survivors on every turn tie-breaks on argument
|
|
36
|
+
* order instead: `Promise.race` over promises that are ALREADY settled
|
|
37
|
+
* resolves with the first in iteration order, not the first to have
|
|
38
|
+
* settled — and a consumer doing any work between yields, which is
|
|
39
|
+
* exactly this module's use case, is what lets two settle inside one turn.
|
|
40
|
+
*/
|
|
41
|
+
export declare function inCompletionOrder<T>(promises: readonly Promise<T>[]): AsyncGenerator<Settled<T>>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A read-only ustar parser: the npm transport's only way into a tarball.
|
|
2
|
+
*
|
|
3
|
+
* Pure — bytes in, a path-to-bytes map out. It handles exactly what `npm
|
|
4
|
+
* pack` emits and refuses everything else loudly, because the alternative to
|
|
5
|
+
* a small strict reader is a fourth runtime dependency (design §4). */
|
|
6
|
+
/**
|
|
7
|
+
* Parse an uncompressed tar archive into path → bytes.
|
|
8
|
+
*
|
|
9
|
+
* Directory entries and every non-regular type (symlinks, pax and GNU
|
|
10
|
+
* extension headers) are skipped: npm packs regular files under `package/`,
|
|
11
|
+
* and a catalog tarball that needs anything else is not one we published.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readTar(buffer: Buffer): Map<string, Buffer>;
|
package/package.json
CHANGED