@smallpen/local-package 0.1.0-alpha.3 → 0.1.0-alpha.8

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.
@@ -10,13 +10,25 @@ import {
10
10
  SmallPenError,
11
11
  } from "@smallpen/core";
12
12
 
13
+ import { createLibraryFetch } from "./library-network.mjs";
14
+
13
15
  const MAX_REMOTE_FILE_BYTES = 50 * 1024 * 1024;
16
+ const MAX_REMOTE_MANIFEST_BYTES = 4 * 1024 * 1024;
14
17
  const MAX_REMOTE_PACKAGE_BYTES = 200 * 1024 * 1024;
18
+ const REMOTE_REQUEST_TIMEOUT_MS = 30000;
19
+ const MAX_REMOTE_REDIRECTS = 5;
20
+ // Each entry and blob is one request: a manifest listing millions of tiny
21
+ // files must not turn one Library open into millions of downloads.
22
+ export const MAX_REMOTE_FILES = 10000;
15
23
 
16
24
  export function defaultLibraryCacheRoot() {
17
25
  return join(homedir(), ".smallpen", "library-cache");
18
26
  }
19
27
 
28
+ export function remoteManifestUrl(sourceUrl) {
29
+ return manifestUrl(sourceUrl).href;
30
+ }
31
+
20
32
  function manifestUrl(sourceUrl) {
21
33
  const parsed = new URL(sourceUrl);
22
34
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
@@ -33,43 +45,137 @@ function manifestUrl(sourceUrl) {
33
45
  return parsed;
34
46
  }
35
47
 
36
- async function responseBytes(response, label) {
48
+ function tooLarge(label, byteLength, url, limit) {
49
+ return new SmallPenError(
50
+ "remote_library_too_large",
51
+ `${label} exceeds the remote Library file limit`,
52
+ { byteLength, limit, url },
53
+ );
54
+ }
55
+
56
+ // Reads the body as a stream and stops as soon as it passes the file limit,
57
+ // so an oversized or endless response is never buffered whole.
58
+ async function responseBytes(response, label, url, limit) {
37
59
  if (!response.ok) {
60
+ await response.body?.cancel().catch(() => {});
38
61
  throw new SmallPenError(
39
62
  "remote_library_unavailable",
40
63
  `${label} returned HTTP ${response.status}`,
41
- { status: response.status, url: response.url },
64
+ { status: response.status, url },
42
65
  );
43
66
  }
44
67
  const declared = Number(response.headers.get("content-length"));
45
- if (Number.isFinite(declared) && declared > MAX_REMOTE_FILE_BYTES) {
46
- throw new SmallPenError(
47
- "remote_library_too_large",
48
- `${label} exceeds the remote Library file limit`,
49
- { byteLength: declared, url: response.url },
50
- );
68
+ if (Number.isFinite(declared) && declared > limit) {
69
+ await response.body?.cancel().catch(() => {});
70
+ throw tooLarge(label, declared, url, limit);
51
71
  }
52
- const bytes = new Uint8Array(await response.arrayBuffer());
53
- if (bytes.byteLength > MAX_REMOTE_FILE_BYTES) {
54
- throw new SmallPenError(
55
- "remote_library_too_large",
56
- `${label} exceeds the remote Library file limit`,
57
- { byteLength: bytes.byteLength, url: response.url },
58
- );
72
+ const reader = response.body?.getReader?.();
73
+ if (!reader) {
74
+ const bytes = new Uint8Array(await response.arrayBuffer());
75
+ if (bytes.byteLength > limit) {
76
+ throw tooLarge(label, bytes.byteLength, url, limit);
77
+ }
78
+ return bytes;
79
+ }
80
+ const chunks = [];
81
+ let byteLength = 0;
82
+ while (true) {
83
+ const { done, value } = await reader.read();
84
+ if (done) break;
85
+ byteLength += value.byteLength;
86
+ if (byteLength > limit) {
87
+ await reader.cancel().catch(() => {});
88
+ throw tooLarge(label, byteLength, url, limit);
89
+ }
90
+ chunks.push(value);
91
+ }
92
+ const bytes = new Uint8Array(byteLength);
93
+ let offset = 0;
94
+ for (const chunk of chunks) {
95
+ bytes.set(chunk, offset);
96
+ offset += chunk.byteLength;
59
97
  }
60
98
  return bytes;
61
99
  }
62
100
 
63
- async function fetchValue(fetchImpl, url, label) {
64
- const bytes = await responseBytes(await fetchImpl(url), label);
101
+ // Every request has a total time budget (headers and body) and follows
102
+ // redirects only within the Library's origin: a Library URL must not be able
103
+ // to steer Background requests to another host.
104
+ async function fetchBytes(
105
+ { fetchImpl, timeoutMs },
106
+ url,
107
+ label,
108
+ limit = MAX_REMOTE_FILE_BYTES,
109
+ ) {
110
+ const signal = AbortSignal.timeout(timeoutMs);
111
+ const origin = new URL(url).origin;
112
+ let current = url;
113
+ try {
114
+ for (let redirects = 0; ; redirects += 1) {
115
+ const response = await fetchImpl(current, { redirect: "manual", signal });
116
+ if (!(response.status >= 300 && response.status < 400)) {
117
+ return await responseBytes(response, label, current, limit);
118
+ }
119
+ await response.body?.cancel().catch(() => {});
120
+ const location = response.headers.get("location");
121
+ const next = location ? new URL(location, current) : undefined;
122
+ if (!next || next.origin !== origin || redirects >= MAX_REMOTE_REDIRECTS) {
123
+ throw new SmallPenError(
124
+ "remote_library_redirect",
125
+ `${label} redirected outside the Library origin`,
126
+ { status: response.status, url: current },
127
+ );
128
+ }
129
+ current = next.href;
130
+ }
131
+ } catch (error) {
132
+ if (signal.aborted && !(error instanceof SmallPenError)) {
133
+ throw new SmallPenError(
134
+ "remote_library_timeout",
135
+ `${label} did not finish within ${timeoutMs} ms`,
136
+ { url },
137
+ );
138
+ }
139
+ // Refused connections, DNS and TLS failures arrive as bare transport
140
+ // errors; name the Library and keep the system error code.
141
+ if (!(error instanceof SmallPenError)) {
142
+ const cause = networkErrorCode(error);
143
+ throw new SmallPenError(
144
+ "remote_library_unavailable",
145
+ `${label} could not be fetched: ${cause ?? error?.message ?? error}`,
146
+ cause ? { cause, url } : { url },
147
+ );
148
+ }
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ // fetch() wraps the system error in `cause`, and a failed multi-address
154
+ // connect is an AggregateError: find the first ECONNREFUSED/ENOTFOUND/
155
+ // CERT_*-style code anywhere in that chain.
156
+ function networkErrorCode(error, depth = 0) {
157
+ if (!error || typeof error !== "object" || depth > 4) return undefined;
158
+ if (typeof error.code === "string" && /^[A-Z][A-Z0-9_]+$/.test(error.code)) {
159
+ return error.code;
160
+ }
161
+ for (const child of [error.cause, ...(Array.isArray(error.errors) ? error.errors : [])]) {
162
+ const code = networkErrorCode(child, depth + 1);
163
+ if (code) return code;
164
+ }
165
+ return undefined;
166
+ }
167
+
168
+ async function fetchValue(request, url, label, limit) {
169
+ const bytes = await fetchBytes(request, url, label, limit);
65
170
  let value;
66
171
  try {
67
172
  value = JSON.parse(new TextDecoder().decode(bytes));
68
- } catch (error) {
173
+ } catch {
174
+ // JSON.parse messages quote the payload; never echo remote content.
69
175
  throw new SmallPenError(
70
176
  "invalid_remote_library",
71
177
  `${label} is not valid JSON`,
72
- { reason: error instanceof Error ? error.message : String(error), url },
178
+ { url },
73
179
  );
74
180
  }
75
181
  return { bytes, value };
@@ -91,20 +197,30 @@ function collectBlobPaths(value, result = new Set()) {
91
197
  return result;
92
198
  }
93
199
 
94
- async function downloadRemotePackage(sourceUrl, fetchImpl) {
200
+ function tooManyFiles(count, manifest) {
201
+ return new SmallPenError(
202
+ "remote_library_too_large",
203
+ "Remote Library lists more files than the package limit",
204
+ { count, limit: MAX_REMOTE_FILES, url: manifest.href },
205
+ );
206
+ }
207
+
208
+ async function downloadRemotePackage(sourceUrl, request) {
95
209
  const manifest = manifestUrl(sourceUrl);
96
210
  const base = new URL("./", manifest);
97
211
  const manifestResult = await fetchValue(
98
- fetchImpl,
212
+ request,
99
213
  manifest.href,
100
214
  "Remote Library manifest",
215
+ MAX_REMOTE_MANIFEST_BYTES,
101
216
  );
102
217
  const { entries } = listPackageEntries(manifestResult.value);
218
+ if (entries.length > MAX_REMOTE_FILES) throw tooManyFiles(entries.length, manifest);
103
219
  const values = new Map([["manifest.json", manifestResult.value]]);
104
220
  let totalBytes = manifestResult.bytes.byteLength;
105
221
  for (const entry of entries) {
106
222
  const result = await fetchValue(
107
- fetchImpl,
223
+ request,
108
224
  new URL(entry, base).href,
109
225
  `Remote Library entry ${entry}`,
110
226
  );
@@ -118,9 +234,14 @@ async function downloadRemotePackage(sourceUrl, fetchImpl) {
118
234
  }
119
235
  values.set(entry, result.value);
120
236
  }
121
- for (const blob of collectBlobPaths([...values.values()])) {
122
- const bytes = await responseBytes(
123
- await fetchImpl(new URL(blob, base).href),
237
+ const blobs = collectBlobPaths([...values.values()]);
238
+ if (entries.length + blobs.size > MAX_REMOTE_FILES) {
239
+ throw tooManyFiles(entries.length + blobs.size, manifest);
240
+ }
241
+ for (const blob of blobs) {
242
+ const bytes = await fetchBytes(
243
+ request,
244
+ new URL(blob, base).href,
124
245
  `Remote Library blob ${blob}`,
125
246
  );
126
247
  totalBytes += bytes.byteLength;
@@ -223,7 +344,13 @@ async function readSnapshotCache(cacheRoot, sourceUrl) {
223
344
  "remote_library_cache_corrupt",
224
345
  "Cached Library content failed integrity validation",
225
346
  {
226
- reason: error instanceof Error ? error.message : String(error),
347
+ // JSON.parse messages quote the (remote-origin) cached content.
348
+ reason:
349
+ error instanceof SyntaxError
350
+ ? "invalid JSON"
351
+ : error instanceof Error
352
+ ? error.message
353
+ : String(error),
227
354
  sourceUrl: manifestUrl(sourceUrl).href,
228
355
  },
229
356
  );
@@ -242,7 +369,15 @@ async function readSnapshotCache(cacheRoot, sourceUrl) {
242
369
 
243
370
  export async function openRemoteLibrary(
244
371
  sourceUrl,
245
- { cacheRoot, expectedPackageId, fetchImpl = fetch, refresh = false } = {},
372
+ {
373
+ cacheRoot,
374
+ allowPrivateNetwork,
375
+ expectedPackageId,
376
+ // A caller-supplied fetch owns its own destination policy.
377
+ fetchImpl = createLibraryFetch({ allowPrivateNetwork }),
378
+ refresh = false,
379
+ requestTimeoutMs = REMOTE_REQUEST_TIMEOUT_MS,
380
+ } = {},
246
381
  ) {
247
382
  let cached;
248
383
  let corruptCacheError;
@@ -263,7 +398,10 @@ export async function openRemoteLibrary(
263
398
  }
264
399
  }
265
400
  try {
266
- const snapshot = await downloadRemotePackage(sourceUrl, fetchImpl);
401
+ const snapshot = await downloadRemotePackage(sourceUrl, {
402
+ fetchImpl,
403
+ timeoutMs: requestTimeoutMs,
404
+ });
267
405
  if (
268
406
  expectedPackageId !== undefined &&
269
407
  snapshot.manifest.packageId !== expectedPackageId
@@ -295,6 +433,7 @@ export async function openRemoteLibrary(
295
433
  cache: "stale",
296
434
  sourceUrl: manifestUrl(sourceUrl).href,
297
435
  warning: error instanceof Error ? error.message : String(error),
436
+ warningCode: error?.code ?? "remote_library_unavailable",
298
437
  },
299
438
  };
300
439
  }