@nyaitter/nap 0.0.0-stage → 0.2.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.
@@ -0,0 +1,613 @@
1
+ // NAP のクライアント向け取得ライブラリ。
2
+ // 索引 nap/index から版を探し、タグ先のマニフェストを取得して、
3
+ // 全ファイルのハッシュ照合と版IDの再計算が通ったときだけ完全なファイル集合を返す。
4
+ // 取得先はソースで指定し、ソースは「<所有者>/<リポジトリ>」の形。
5
+ // 既定の URL は raw.githubusercontent.com の形式でソースから導出する。
6
+ // rawBase を渡したときは、その配信サーバーの専用 API を使う。
7
+
8
+ const textEncoder = new TextEncoder();
9
+ const textDecoder = new TextDecoder();
10
+ const ID_PATTERN = /^[0-9a-f]{64}$/;
11
+ const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
12
+
13
+ // TestVersion のラベル。git の参照名に使える形に限る。
14
+ function validateLabel(label) {
15
+ if (
16
+ typeof label !== 'string' ||
17
+ label.length < 1 ||
18
+ label.length > 32 ||
19
+ !LABEL_PATTERN.test(label) ||
20
+ label.endsWith('.') ||
21
+ label.endsWith('.lock') ||
22
+ label.includes('..')
23
+ ) {
24
+ throw new Error(`ラベルが不正です: ${label}`);
25
+ }
26
+ return label;
27
+ }
28
+
29
+ // 「<版ID>」または「<版ID>-<ラベル>」を分解する。版IDは完全な64桁が必要。
30
+ // 16進数には - が含まれないため、最初の - で一意に分かれる。
31
+ function parseVersionRef(ref) {
32
+ if (typeof ref !== 'string' || ref.length === 0) {
33
+ throw new Error(`版の指定が不正です: ${ref}`);
34
+ }
35
+ const dash = ref.indexOf('-');
36
+ const id = dash === -1 ? ref : ref.slice(0, dash);
37
+ const tag = dash === -1 ? null : ref.slice(dash + 1);
38
+ if (!ID_PATTERN.test(id)) {
39
+ throw new Error(`版の指定が不正です: ${ref}。版IDは16進数64桁で指定してください`);
40
+ }
41
+ if (tag !== null) validateLabel(tag);
42
+ return { id, tag };
43
+ }
44
+
45
+ // ソースの指定は「<所有者>/<リポジトリ>」だけ。GitHub 前提。
46
+ function isSourceSpec(source) {
47
+ if (typeof source !== 'string') return false;
48
+ const text = source.trim();
49
+ if (!text) return false;
50
+ const parts = text.split('/');
51
+ return parts.length === 2 && parts.every((part) => part.length > 0 && !/[\s:@]/.test(part));
52
+ }
53
+
54
+ // ソースから取得先の基底 URL を導出する。既定は GitHub の raw 配信。
55
+ // rawBase を渡すと、その配信を経由する。CDN など。レイアウトは同じ形。
56
+ function rawBaseFor(source, rawBase) {
57
+ const text = String(source || '').trim();
58
+ if (!isSourceSpec(text)) {
59
+ throw new Error(`ソースの指定が不正です: ${source}。<所有者>/<リポジトリ>で指定してください`);
60
+ }
61
+ if (rawBase) return `${String(rawBase).replace(/\/+$/, '')}/${text}`;
62
+ return `https://raw.githubusercontent.com/${text}`;
63
+ }
64
+
65
+ // パッケージの指定は「<所有者>/<リポジトリ>/<版>」の3要素で、ブランチの指定は含まない。
66
+ // 版は「<版ID>」または「<版ID>-<ラベル>」。
67
+ export function parseSpec(spec) {
68
+ if (typeof spec !== 'string') {
69
+ throw new Error(`パッケージの指定が不正です: ${spec}`);
70
+ }
71
+ const text = spec.trim();
72
+ const parts = text.split('/');
73
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
74
+ throw new Error(`パッケージの指定が不正です: ${spec}。<所有者>/<リポジトリ>/<版>の形で指定してください`);
75
+ }
76
+ const { id, tag } = parseVersionRef(parts[2]);
77
+ return { repo: `${parts[0]}/${parts[1]}`, ref: parts[2], id, tag };
78
+ }
79
+
80
+ const CODE_PATTERN = /^[0-9a-z]{3,}$/;
81
+
82
+ // 版コードの形かどうか。版IDの完全な形とは重ならない。
83
+ function isVersionCode(text) {
84
+ if (typeof text !== 'string' || !CODE_PATTERN.test(text)) return false;
85
+ return !/^[0-9a-f]{64}(-[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(text);
86
+ }
87
+
88
+ // 版を指すパス。配信サーバー経由では版IDそのものを使う。
89
+ // raw 配信ではタグ名で、正式版は nap/<版ID>、TestVersion は nap/<版ID>-<ラベル>。
90
+ function pathRefFor(id, tag, viaApi) {
91
+ if (viaApi) return tag ? `${id}-${tag}` : `${id}`;
92
+ return tag ? `nap/${id}-${tag}` : `nap/${id}`;
93
+ }
94
+
95
+ // ZIP の中央ディレクトリを読む。名前と圧縮方式と位置の表を返す。
96
+ function readZipDirectory(bytes) {
97
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
98
+ let eocd = -1;
99
+ const scanStart = Math.max(0, bytes.length - 65557);
100
+ for (let i = bytes.length - 22; i >= scanStart; i--) {
101
+ if (view.getUint32(i, true) === 0x06054b50) {
102
+ eocd = i;
103
+ break;
104
+ }
105
+ }
106
+ if (eocd === -1) throw new Error('ZIP の終端レコードが見つかりません。');
107
+ const count = view.getUint16(eocd + 10, true);
108
+ let offset = view.getUint32(eocd + 16, true);
109
+ const entries = [];
110
+ for (let i = 0; i < count; i++) {
111
+ if (view.getUint32(offset, true) !== 0x02014b50) {
112
+ throw new Error('ZIP の中央ディレクトリが不正です。');
113
+ }
114
+ const method = view.getUint16(offset + 10, true);
115
+ const size = view.getUint32(offset + 20, true);
116
+ const nameLen = view.getUint16(offset + 28, true);
117
+ const extraLen = view.getUint16(offset + 30, true);
118
+ const commentLen = view.getUint16(offset + 32, true);
119
+ const localOffset = view.getUint32(offset + 42, true);
120
+ const name = textDecoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLen));
121
+ entries.push({ name, method, size, localOffset });
122
+ offset += 46 + nameLen + extraLen + commentLen;
123
+ }
124
+ return entries;
125
+ }
126
+
127
+ async function inflateEntry(bytes, entry) {
128
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
129
+ const at = entry.localOffset;
130
+ if (view.getUint32(at, true) !== 0x04034b50) {
131
+ throw new Error('ZIP のローカルヘッダが不正です。');
132
+ }
133
+ const nameLen = view.getUint16(at + 26, true);
134
+ const extraLen = view.getUint16(at + 28, true);
135
+ const start = at + 30 + nameLen + extraLen;
136
+ const compressed = bytes.subarray(start, start + entry.size);
137
+ if (entry.method === 0) return new Uint8Array(compressed);
138
+ if (entry.method !== 8) {
139
+ throw new Error(`ZIP の圧縮方式に対応していません: ${entry.method}`);
140
+ }
141
+ if (typeof DecompressionStream === 'undefined') {
142
+ throw new Error('DecompressionStream が使えません。');
143
+ }
144
+ const stream = new Blob([compressed]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
145
+ return new Uint8Array(await new Response(stream).arrayBuffer());
146
+ }
147
+
148
+ async function sha256Hex(bytes) {
149
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
150
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
151
+ }
152
+
153
+ function compareTextBytes(a, b) {
154
+ const ba = textEncoder.encode(a);
155
+ const bb = textEncoder.encode(b);
156
+ const len = Math.min(ba.length, bb.length);
157
+ for (let i = 0; i < len; i++) {
158
+ if (ba[i] !== bb[i]) return ba[i] - bb[i];
159
+ }
160
+ return ba.length - bb.length;
161
+ }
162
+
163
+ // 版IDの再計算。ファイル行と構成員行をバイト順に並べたものの SHA-256。
164
+ async function computeVersionId(files, libraries) {
165
+ const lines = Object.entries(files).map(([rel, hash]) => `F ${rel} ${hash}`);
166
+ for (const [ref, lib] of Object.entries(libraries)) {
167
+ lines.push(`P ${ref} ${lib.version}`);
168
+ }
169
+ lines.sort(compareTextBytes);
170
+ return sha256Hex(textEncoder.encode(`${lines.join('\n')}\n`));
171
+ }
172
+
173
+ function validateManifest(manifest) {
174
+ if (!manifest || typeof manifest !== 'object') {
175
+ throw new Error('マニフェストの形式が不正です。');
176
+ }
177
+ const files = manifest.files || {};
178
+ const libraries = manifest.libraries || {};
179
+ for (const [rel, hash] of Object.entries(files)) {
180
+ if (!ID_PATTERN.test(hash)) {
181
+ throw new Error(`マニフェストのファイルハッシュが不正です: ${rel}`);
182
+ }
183
+ }
184
+ for (const [ref, lib] of Object.entries(libraries)) {
185
+ if (!lib || lib.name !== ref || !isSourceSpec(lib.repo) || !ID_PATTERN.test(lib.version || '')) {
186
+ throw new Error(`マニフェストの構成員が不正です: ${ref}`);
187
+ }
188
+ }
189
+ const sizes = manifest.sizes || {};
190
+ for (const [rel, size] of Object.entries(sizes)) {
191
+ if (!Number.isInteger(size) || size < 0) {
192
+ throw new Error(`マニフェストのファイルサイズが不正です: ${rel}`);
193
+ }
194
+ }
195
+ return { files, libraries, sizes };
196
+ }
197
+
198
+ // ハッシュ単位のキャッシュの単純な実装。差し替えの見本として置く。
199
+ export function createMemoryCache() {
200
+ const map = new Map();
201
+ return {
202
+ async get(hash) {
203
+ return map.has(hash) ? map.get(hash) : null;
204
+ },
205
+ async put(hash, bytes) {
206
+ map.set(hash, bytes);
207
+ },
208
+ };
209
+ }
210
+
211
+ export function createClient({ cache, fetch: fetchImpl, rawBase } = {}) {
212
+ const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
213
+ const store = cache || createMemoryCache();
214
+ const viaApi = Boolean(rawBase);
215
+
216
+ function apiUrlFor(repo, suffix) {
217
+ return `${String(rawBase).replace(/\/+$/, '')}/api/repos/${repo}${suffix}`;
218
+ }
219
+
220
+ // ソースから導出した URL で取得し、応答のバイト列を返す。
221
+ async function fetchBytes(repo, ref, filePath) {
222
+ const url = `${rawBaseFor(repo, rawBase)}/${ref}/${filePath}`;
223
+ let lastError = null;
224
+ try {
225
+ const res = await doFetch(url);
226
+ if (res && res.ok) return new Uint8Array(await res.arrayBuffer());
227
+ lastError = new Error(`HTTP ${res ? res.status : '?'}`);
228
+ } catch (error) {
229
+ lastError = error;
230
+ }
231
+ const error = new Error(
232
+ `取得できませんでした: ${repo} ${ref} ${filePath}: ${lastError ? lastError.message : '不明'}`,
233
+ );
234
+ error.napFetchFailed = true;
235
+ throw error;
236
+ }
237
+
238
+ async function fetchText(repo, ref, filePath) {
239
+ return textDecoder.decode(await fetchBytes(repo, ref, filePath));
240
+ }
241
+
242
+ // 索引から版の一覧を返す。latestOnly では先頭ファイルと最後のチャンクだけを読む。
243
+ // 各項目の tag は、正式版では null、TestVersion ではそのラベル。
244
+ async function listVersions(repo, { latestOnly = false } = {}) {
245
+ if (viaApi) {
246
+ try {
247
+ const entries = [];
248
+ let cursor = null;
249
+ for (;;) {
250
+ const suffix = cursor
251
+ ? `/versions?limit=100&cursor=${encodeURIComponent(cursor)}`
252
+ : '/versions?limit=100';
253
+ const res = await doFetch(apiUrlFor(repo, suffix));
254
+ if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '?'}`);
255
+ const data = await res.json();
256
+ const list = Array.isArray(data) ? data : data.versions || [];
257
+ for (const entry of list) {
258
+ entries.push({
259
+ id: entry.id,
260
+ commit: entry.commit,
261
+ at: entry.created_at || entry.at,
262
+ tag: entry.tag || null,
263
+ code: entry.code || null,
264
+ source: entry.source || null,
265
+ description: entry.description || '',
266
+ });
267
+ }
268
+ if (latestOnly || !data || !data.has_next || !data.next_cursor) break;
269
+ cursor = data.next_cursor;
270
+ }
271
+ // API は新しい順に返す。内部では索引と同じ古い順にそろえる。
272
+ entries.reverse();
273
+ return { total: entries.length, entries };
274
+ } catch (_) {}
275
+ }
276
+ const head = JSON.parse(await fetchText(repo, 'nap/index', 'index.json'));
277
+ const defs = latestOnly ? head.chunks.slice(-1) : head.chunks;
278
+ const entries = [];
279
+ for (const def of defs) {
280
+ const list = JSON.parse(await fetchText(repo, 'nap/index', def.file));
281
+ for (const entry of list) {
282
+ entries.push({
283
+ id: entry.id,
284
+ commit: entry.commit,
285
+ at: entry.at,
286
+ tag: entry.tag || null,
287
+ code: entry.code || null,
288
+ });
289
+ }
290
+ }
291
+ return { total: head.total, entries };
292
+ }
293
+
294
+ // 最新の正式版を返す。TestVersion は対象にしない。正式版が無ければ null。
295
+ // 後ろのチャンクから順に読み、正式版が見つかった時点で止める。
296
+ async function latestVersion(repo) {
297
+ if (viaApi) {
298
+ const { entries } = await listVersions(repo);
299
+ for (let i = entries.length - 1; i >= 0; i--) {
300
+ if (!entries[i].tag) {
301
+ return { id: entries[i].id, commit: entries[i].commit, at: entries[i].at, tag: null };
302
+ }
303
+ }
304
+ return null;
305
+ }
306
+ const head = JSON.parse(await fetchText(repo, 'nap/index', 'index.json'));
307
+ for (let i = head.chunks.length - 1; i >= 0; i--) {
308
+ const list = JSON.parse(await fetchText(repo, 'nap/index', head.chunks[i].file));
309
+ for (let j = list.length - 1; j >= 0; j--) {
310
+ if (!list[j].tag) {
311
+ return { id: list[j].id, commit: list[j].commit, at: list[j].at, tag: null };
312
+ }
313
+ }
314
+ }
315
+ return null;
316
+ }
317
+
318
+ // 配信サーバー経由では、版の詳細からマニフェストを受け取る。ファイルのサイズも一緒に返る。
319
+ async function fetchManifestRawViaApi(repo, versionId, tag) {
320
+ const ref = pathRefFor(versionId, tag, true);
321
+ let lastError = null;
322
+ try {
323
+ const res = await doFetch(apiUrlFor(repo, `/versions/${ref}`));
324
+ if (res && res.ok) {
325
+ const data = await res.json();
326
+ if (data && data.manifest) return data.manifest;
327
+ lastError = new Error('マニフェストが含まれていません');
328
+ } else {
329
+ lastError = new Error(`HTTP ${res ? res.status : '?'}`);
330
+ }
331
+ } catch (error) {
332
+ lastError = error;
333
+ }
334
+ const error = new Error(
335
+ `取得できませんでした: ${repo} ${ref} version-manifest.json: ${lastError ? lastError.message : '不明'}`,
336
+ );
337
+ error.napFetchFailed = true;
338
+ throw error;
339
+ }
340
+
341
+ async function fetchManifest(repo, versionId, tag, stats) {
342
+ let raw;
343
+ if (viaApi) {
344
+ raw = await fetchManifestRawViaApi(repo, versionId, tag);
345
+ } else {
346
+ const text = await fetchText(repo, pathRefFor(versionId, tag, viaApi), 'version-manifest.json');
347
+ try {
348
+ raw = JSON.parse(text);
349
+ } catch {
350
+ throw new Error('マニフェストが JSON として読めません。');
351
+ }
352
+ }
353
+ if (stats) stats.fetched += 1;
354
+ const manifest = validateManifest(raw);
355
+ const recomputed = await computeVersionId(manifest.files, manifest.libraries);
356
+ if (recomputed !== versionId) {
357
+ throw new Error(`版IDが一致しません: タグ ${versionId} / 再計算 ${recomputed}`);
358
+ }
359
+ return manifest;
360
+ }
361
+
362
+ // タグ先のマニフェストを取得し、版IDを再計算してタグと一致することを確かめる。
363
+ // ref は「<版ID>」または「<版ID>-<ラベル>」。
364
+ async function getManifest(repo, ref) {
365
+ const { id, tag } = await resolveRef(repo, ref);
366
+ return fetchManifest(repo, id, tag, null);
367
+ }
368
+
369
+ // 版の完全なファイル集合を取得する。構成員は、そのリポジトリから同じ手順でたどる。
370
+ // 照合が1つでも失敗したら例外にし、部分的な結果は返さない。
371
+ // 構成員は版IDで固定されている。正式版のタグで取得できない場合は、
372
+ // 索引で同じ版IDの TestVersion を探して取得する。固定は明示指定のため。
373
+ // ref が版コードなら、索引から版IDへ解決する。
374
+ async function resolveRef(repo, ref) {
375
+ if (!isVersionCode(ref)) return parseVersionRef(ref);
376
+ const { entries } = await listVersions(repo);
377
+ const hit = entries.find((entry) => entry.code === ref);
378
+ if (!hit) throw new Error(`版コードが見つかりません: ${ref}`);
379
+ return { id: hit.id, tag: hit.tag };
380
+ }
381
+
382
+ function notifyProgress(stats) {
383
+ if (!stats || typeof stats.onProgress !== 'function') return;
384
+ try {
385
+ stats.onProgress({
386
+ phase: stats.phase || 'download',
387
+ fetched: stats.fetched,
388
+ cached: stats.cached,
389
+ total: stats.total || 0,
390
+ bytes: stats.byteFetched || 0,
391
+ bytesTotal: stats.byteTotal || 0,
392
+ });
393
+ } catch (_) {}
394
+ }
395
+
396
+ // 版のファイルを ZIP で一括取得する。配信サーバー経由のときだけ使う。
397
+ // 進捗は download が受信バイト数、unpack が展開したファイル数で進む。
398
+ async function fetchPackageFilesViaZip(repo, versionId, tag, manifest, stats) {
399
+ const ref = pathRefFor(versionId, tag, true);
400
+ const res = await doFetch(apiUrlFor(repo, `/versions/${ref}/archive.zip`));
401
+ if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '?'}`);
402
+ let bytes;
403
+ if (res.body && typeof res.body.getReader === 'function') {
404
+ const reader = res.body.getReader();
405
+ const chunks = [];
406
+ let received = 0;
407
+ const baseFetched = stats ? stats.byteFetched || 0 : 0;
408
+ const baseTotal = stats ? stats.byteTotal || 0 : 0;
409
+ const lengthHeader =
410
+ res.headers && typeof res.headers.get === 'function'
411
+ ? Number(res.headers.get('content-length')) || 0
412
+ : 0;
413
+ for (;;) {
414
+ const { done, value } = await reader.read();
415
+ if (done) break;
416
+ chunks.push(value);
417
+ received += value.length;
418
+ if (stats) {
419
+ stats.phase = 'download';
420
+ stats.byteFetched = baseFetched + received;
421
+ stats.byteTotal = baseTotal + lengthHeader;
422
+ notifyProgress(stats);
423
+ }
424
+ }
425
+ bytes = new Uint8Array(received);
426
+ let at = 0;
427
+ for (const chunk of chunks) {
428
+ bytes.set(chunk, at);
429
+ at += chunk.length;
430
+ }
431
+ } else {
432
+ bytes = new Uint8Array(await res.arrayBuffer());
433
+ }
434
+ const byName = new Map(readZipDirectory(bytes).map((entry) => [entry.name, entry]));
435
+ const names = Object.keys(manifest.files);
436
+ const files = {};
437
+ let done = 0;
438
+ for (const rel of names) {
439
+ const entry = byName.get(rel);
440
+ if (!entry) throw new Error(`ZIP にファイルがありません: ${rel}`);
441
+ const data = await inflateEntry(bytes, entry);
442
+ const actual = await sha256Hex(data);
443
+ if (actual !== manifest.files[rel]) {
444
+ throw new Error(`照合に失敗しました: ${rel}`);
445
+ }
446
+ await store.put(manifest.files[rel], data);
447
+ files[rel] = data;
448
+ done += 1;
449
+ if (stats) {
450
+ stats.phase = 'unpack';
451
+ stats.fetched = done;
452
+ stats.total = names.length;
453
+ notifyProgress(stats);
454
+ }
455
+ }
456
+ if (stats) stats.phase = 'download';
457
+ return files;
458
+ }
459
+
460
+ async function fetchPackage(repo, versionId, tag, stats, fallbackToTest) {
461
+ let manifest;
462
+ try {
463
+ manifest = await fetchManifest(repo, versionId, tag, stats);
464
+ } catch (error) {
465
+ if (!fallbackToTest || !error || error.napFetchFailed !== true) throw error;
466
+ const { entries } = await listVersions(repo);
467
+ const hit = entries.find((entry) => entry.id === versionId && entry.tag);
468
+ if (!hit) throw error;
469
+ return fetchPackage(repo, versionId, hit.tag, stats, false);
470
+ }
471
+ let files = null;
472
+ if (viaApi) {
473
+ const byteFetchedBefore = stats ? stats.byteFetched || 0 : 0;
474
+ const byteTotalBefore = stats ? stats.byteTotal || 0 : 0;
475
+ try {
476
+ files = await fetchPackageFilesViaZip(repo, versionId, tag, manifest, stats);
477
+ } catch (_) {
478
+ files = null;
479
+ if (stats) {
480
+ stats.byteFetched = byteFetchedBefore;
481
+ stats.byteTotal = byteTotalBefore;
482
+ }
483
+ }
484
+ }
485
+ if (!files) {
486
+ if (stats) {
487
+ stats.phase = 'download';
488
+ stats.total = (stats.total || 0) + Object.keys(manifest.files).length;
489
+ let byteTotal = 0;
490
+ for (const rel of Object.keys(manifest.files)) {
491
+ const size = manifest.sizes ? manifest.sizes[rel] : undefined;
492
+ if (Number.isInteger(size) && size >= 0) byteTotal += size;
493
+ }
494
+ stats.byteTotal = (stats.byteTotal || 0) + byteTotal;
495
+ notifyProgress(stats);
496
+ }
497
+ files = {};
498
+ for (const rel of Object.keys(manifest.files).sort(compareTextBytes)) {
499
+ const expected = manifest.files[rel];
500
+ const size = manifest.sizes ? manifest.sizes[rel] : undefined;
501
+ const hit = await store.get(expected);
502
+ if (hit) {
503
+ files[rel] = hit;
504
+ stats.cached += 1;
505
+ if (stats && Number.isInteger(size)) {
506
+ stats.byteFetched = (stats.byteFetched || 0) + size;
507
+ }
508
+ notifyProgress(stats);
509
+ continue;
510
+ }
511
+ const bytes = await fetchBytes(repo, pathRefFor(versionId, tag, viaApi), rel);
512
+ const actual = await sha256Hex(bytes);
513
+ if (actual !== expected) {
514
+ throw new Error(
515
+ `照合に失敗しました: ${rel}。期待 ${expected} / 実際 ${actual}`,
516
+ );
517
+ }
518
+ await store.put(expected, bytes);
519
+ files[rel] = bytes;
520
+ stats.fetched += 1;
521
+ if (stats) stats.byteFetched = (stats.byteFetched || 0) + bytes.length;
522
+ notifyProgress(stats);
523
+ }
524
+ }
525
+ const libraries = {};
526
+ for (const ref of Object.keys(manifest.libraries).sort(compareTextBytes)) {
527
+ const lib = manifest.libraries[ref];
528
+ libraries[ref] = await fetchPackage(lib.repo, lib.version, null, stats, true);
529
+ }
530
+ return { versionId, tag, manifest, files, libraries };
531
+ }
532
+
533
+ // マニフェストだけを再帰的に取得する。ファイルのバイト列は取得しない。
534
+ // 構成員の固定解決は fetchVersion と同じで、正式版で取得できない場合は
535
+ // 索引の TestVersion からたどる。
536
+ async function fetchManifests(repo, versionId, tag, fallbackToTest) {
537
+ let manifest;
538
+ try {
539
+ manifest = await fetchManifest(repo, versionId, tag, null);
540
+ } catch (error) {
541
+ if (!fallbackToTest || !error || error.napFetchFailed !== true) throw error;
542
+ const { entries } = await listVersions(repo);
543
+ const hit = entries.find((entry) => entry.id === versionId && entry.tag);
544
+ if (!hit) throw error;
545
+ return fetchManifests(repo, versionId, hit.tag, false);
546
+ }
547
+ const libraries = {};
548
+ for (const ref of Object.keys(manifest.libraries).sort(compareTextBytes)) {
549
+ const lib = manifest.libraries[ref];
550
+ libraries[ref] = await fetchManifests(lib.repo, lib.version, null, true);
551
+ }
552
+ return { versionId, tag, files: manifest.files, libraries };
553
+ }
554
+
555
+ async function getManifests(repo, ref) {
556
+ const { id, tag } = await resolveRef(repo, ref);
557
+ return fetchManifests(repo, id, tag, false);
558
+ }
559
+
560
+ // 版に属するファイル1点をソースから取得する。照合は呼び出し側で行う。
561
+ async function fetchFile(repo, ref, filePath) {
562
+ const { id, tag } = await resolveRef(repo, ref);
563
+ return fetchBytes(repo, pathRefFor(id, tag, viaApi), filePath);
564
+ }
565
+
566
+ // ref は「<版ID>」または「<版ID>-<ラベル>」。TestVersion を使うときは明示指定する。
567
+ async function fetchVersion(repo, ref, options) {
568
+ const { id, tag } = await resolveRef(repo, ref);
569
+ const stats = { fetched: 0, cached: 0, total: 0, onProgress: options && options.onProgress };
570
+ const result = await fetchPackage(repo, id, tag, stats, false);
571
+ return { ...result, stats };
572
+ }
573
+
574
+ // 指定をソースと版の参照に分ける。版の参照は版IDでも版コードでもよい。
575
+ function splitSpec(spec) {
576
+ if (typeof spec !== 'string') {
577
+ throw new Error(`パッケージの指定が不正です: ${spec}`);
578
+ }
579
+ const parts = spec.trim().split('/');
580
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
581
+ throw new Error(`パッケージの指定が不正です: ${spec}。<所有者>/<リポジトリ>/<版>の形で指定してください`);
582
+ }
583
+ return { repo: `${parts[0]}/${parts[1]}`, ref: parts[2] };
584
+ }
585
+
586
+ // ソースと版の指定で版を取得する。指定は「<所有者>/<リポジトリ>/<版>」の形。
587
+ async function fetchSpec(spec) {
588
+ const { repo, ref } = splitSpec(spec);
589
+ return fetchVersion(repo, ref);
590
+ }
591
+
592
+ async function getSpecManifests(spec) {
593
+ const { repo, ref } = splitSpec(spec);
594
+ return getManifests(repo, ref);
595
+ }
596
+
597
+ return {
598
+ listVersions,
599
+ latestVersion,
600
+ getManifest,
601
+ getManifests,
602
+ fetchVersion,
603
+ fetchFile,
604
+ fetchSpec,
605
+ getSpecManifests,
606
+ };
607
+ }
608
+
609
+ if (typeof globalThis !== 'undefined') {
610
+ globalThis.NapClient = { createClient, createMemoryCache, parseSpec };
611
+ }
612
+
613
+ export default { createClient, createMemoryCache, parseSpec };