pinnace 0.0.0 → 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.
- package/LICENSE +661 -0
- package/README.md +164 -0
- package/dist/car/car-build.d.ts +26 -0
- package/dist/car/car-build.d.ts.map +1 -0
- package/dist/car/car-build.js +113 -0
- package/dist/car/car-build.js.map +1 -0
- package/dist/ci/ci-emit.d.ts +116 -0
- package/dist/ci/ci-emit.d.ts.map +1 -0
- package/dist/ci/ci-emit.js +190 -0
- package/dist/ci/ci-emit.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +17 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/run.d.ts +94 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +731 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/config/config-resolution.d.ts +186 -0
- package/dist/config/config-resolution.d.ts.map +1 -0
- package/dist/config/config-resolution.js +137 -0
- package/dist/config/config-resolution.js.map +1 -0
- package/dist/deploy/deploy.d.ts +121 -0
- package/dist/deploy/deploy.d.ts.map +1 -0
- package/dist/deploy/deploy.js +150 -0
- package/dist/deploy/deploy.js.map +1 -0
- package/dist/derive/ipns-key-derivation.d.ts +37 -0
- package/dist/derive/ipns-key-derivation.d.ts.map +1 -0
- package/dist/derive/ipns-key-derivation.js +130 -0
- package/dist/derive/ipns-key-derivation.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/node/node-commands.d.ts +115 -0
- package/dist/node/node-commands.d.ts.map +1 -0
- package/dist/node/node-commands.js +231 -0
- package/dist/node/node-commands.js.map +1 -0
- package/dist/provision/cloud-init.d.ts +162 -0
- package/dist/provision/cloud-init.d.ts.map +1 -0
- package/dist/provision/cloud-init.js +460 -0
- package/dist/provision/cloud-init.js.map +1 -0
- package/dist/publisher/key-import.d.ts +105 -0
- package/dist/publisher/key-import.d.ts.map +1 -0
- package/dist/publisher/key-import.js +76 -0
- package/dist/publisher/key-import.js.map +1 -0
- package/dist/publisher/record-sequence.d.ts +90 -0
- package/dist/publisher/record-sequence.d.ts.map +1 -0
- package/dist/publisher/record-sequence.js +250 -0
- package/dist/publisher/record-sequence.js.map +1 -0
- package/dist/rpc/kubo-rpc-client.d.ts +154 -0
- package/dist/rpc/kubo-rpc-client.d.ts.map +1 -0
- package/dist/rpc/kubo-rpc-client.js +207 -0
- package/dist/rpc/kubo-rpc-client.js.map +1 -0
- package/dist/rpc/mock-kubo.d.ts +87 -0
- package/dist/rpc/mock-kubo.d.ts.map +1 -0
- package/dist/rpc/mock-kubo.js +129 -0
- package/dist/rpc/mock-kubo.js.map +1 -0
- package/dist/site/site-management.d.ts +126 -0
- package/dist/site/site-management.d.ts.map +1 -0
- package/dist/site/site-management.js +123 -0
- package/dist/site/site-management.js.map +1 -0
- package/dist/status/status-report.d.ts +145 -0
- package/dist/status/status-report.d.ts.map +1 -0
- package/dist/status/status-report.js +192 -0
- package/dist/status/status-report.js.map +1 -0
- package/package.json +45 -2
- package/src/car/car-build.ts +135 -0
- package/src/ci/ci-emit.ts +284 -0
- package/src/cli/bin.ts +20 -0
- package/src/cli/run.ts +970 -0
- package/src/config/config-resolution.ts +264 -0
- package/src/deploy/deploy.ts +250 -0
- package/src/derive/ipns-key-derivation.ts +173 -0
- package/src/index.ts +146 -0
- package/src/node/node-commands.ts +395 -0
- package/src/provision/cloud-init.ts +646 -0
- package/src/publisher/key-import.ts +141 -0
- package/src/publisher/record-sequence.ts +336 -0
- package/src/rpc/kubo-rpc-client.ts +281 -0
- package/src/rpc/mock-kubo.ts +194 -0
- package/src/site/site-management.ts +241 -0
- package/src/status/status-report.ts +291 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** The verbs, in a stable order (for help text / iteration). */
|
|
2
|
+
export const SITE_VERBS = ['list', 'remove', 'add'];
|
|
3
|
+
/** The MFS directory sites live under. */
|
|
4
|
+
const DEFAULT_SITES_DIR = '/sites';
|
|
5
|
+
/**
|
|
6
|
+
* **list** — enumerate the sites the node currently serves (MFS `/sites/*`),
|
|
7
|
+
* annotating each with its current CID (from `files/stat`) and, when a keystore
|
|
8
|
+
* key of the same name exists, its IPNS id (from `key/list -l`). A fresh box
|
|
9
|
+
* with no `/sites` dir yields an empty list, not an error.
|
|
10
|
+
*/
|
|
11
|
+
export async function listSites(input) {
|
|
12
|
+
const sitesDir = input.sitesDir ?? DEFAULT_SITES_DIR;
|
|
13
|
+
const entries = await lsSites(input.client, sitesDir);
|
|
14
|
+
if (entries.length === 0)
|
|
15
|
+
return [];
|
|
16
|
+
const keys = await listKeys(input.client);
|
|
17
|
+
const sites = [];
|
|
18
|
+
for (const id of entries) {
|
|
19
|
+
const cid = await statCid(input.client, `${sitesDir}/${id}`);
|
|
20
|
+
if (!cid)
|
|
21
|
+
continue; // an entry with no resolvable CID is skipped, not fatal.
|
|
22
|
+
sites.push({ id, cid, ipns: keys.get(id) });
|
|
23
|
+
}
|
|
24
|
+
return sites;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* **remove** — delete a site: `files/rm /sites/<name>` FIRST (so it immediately
|
|
28
|
+
* drops out of MFS auto-discovery and stops being served/announced/warmed),
|
|
29
|
+
* THEN `pin/rm <cid>` to unpin the content so its storage is reclaimed. The CID
|
|
30
|
+
* is resolved up-front with `files/stat` so we know what to unpin after the
|
|
31
|
+
* entry is gone.
|
|
32
|
+
*
|
|
33
|
+
* The MFS removal is the load-bearing step and is always attempted. The unpin
|
|
34
|
+
* is best-effort: if the content was never pinned (or is pinned indirectly),
|
|
35
|
+
* `pin/rm` errors, which is REPORTED (`unpinned: false`) rather than thrown, so
|
|
36
|
+
* a partially-pinned site still removes cleanly.
|
|
37
|
+
*/
|
|
38
|
+
export async function removeSite(input) {
|
|
39
|
+
const sitesDir = input.sitesDir ?? DEFAULT_SITES_DIR;
|
|
40
|
+
const path = `${sitesDir}/${input.id}`;
|
|
41
|
+
// Resolve the CID before we remove the entry (afterwards it is gone).
|
|
42
|
+
const cid = await statCid(input.client, path);
|
|
43
|
+
// Remove the MFS entry first: it stops being discovered/served/announced.
|
|
44
|
+
await input.client.filesRm(path, { recursive: true, force: true });
|
|
45
|
+
// Then reclaim storage by unpinning the content. Best-effort: a site that
|
|
46
|
+
// was never pinned (or pinned indirectly) must not fail the removal.
|
|
47
|
+
let unpinned = false;
|
|
48
|
+
if (cid) {
|
|
49
|
+
try {
|
|
50
|
+
await input.client.pinRm(cid);
|
|
51
|
+
unpinned = true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
unpinned = false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { id: input.id, cid, unpinned };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* **add** — expose an existing CID as a served site by placing it into MFS at
|
|
61
|
+
* `/sites/<name>` (the discoverable location warm/republish/status read). This
|
|
62
|
+
* is deploy's MFS-placement step in isolation (see the module DESIGN NOTE): it
|
|
63
|
+
* does NOT build or import a CAR and does NOT pin (the CID is assumed already
|
|
64
|
+
* imported/pinned on the node).
|
|
65
|
+
*/
|
|
66
|
+
export async function addSite(input) {
|
|
67
|
+
const sitesDir = input.sitesDir ?? DEFAULT_SITES_DIR;
|
|
68
|
+
await placeInMfs(input.client, sitesDir, input.id, input.cid);
|
|
69
|
+
return { id: input.id, cid: input.cid };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The MFS-placement step: `files/mkdir /sites --parents`, `files/rm
|
|
73
|
+
* /sites/<id> --recursive --force` (clear any prior content), then `files/cp
|
|
74
|
+
* /ipfs/<cid> /sites/<id>`. Ported from the reference prototype's deploy MFS
|
|
75
|
+
* placement (`~/searches/ipfs-hetzner/deploy-car.mjs`). Exported so `deploy`
|
|
76
|
+
* can REUSE this exact sequence rather than forking it.
|
|
77
|
+
*/
|
|
78
|
+
export async function placeInMfs(client, sitesDir, id, cid) {
|
|
79
|
+
await client.filesMkdir(sitesDir, { parents: true });
|
|
80
|
+
await client.filesRm(`${sitesDir}/${id}`, { recursive: true, force: true });
|
|
81
|
+
await client.filesCp(`/ipfs/${cid}`, `${sitesDir}/${id}`);
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Small shared Kubo reads (fail-soft where a missing dir is not an error).
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
/** List the entry NAMES under the sites dir; empty on a fresh box (no dir). */
|
|
87
|
+
async function lsSites(client, sitesDir) {
|
|
88
|
+
let listing;
|
|
89
|
+
try {
|
|
90
|
+
listing = await client.filesLs(sitesDir);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// No /sites dir yet (fresh box) — nothing to list, not an error.
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const names = [];
|
|
97
|
+
for (const entry of listing.Entries ?? []) {
|
|
98
|
+
if (entry?.Name)
|
|
99
|
+
names.push(entry.Name);
|
|
100
|
+
}
|
|
101
|
+
return names;
|
|
102
|
+
}
|
|
103
|
+
/** Stat an MFS path for its current CID, or undefined if it cannot be resolved. */
|
|
104
|
+
async function statCid(client, path) {
|
|
105
|
+
try {
|
|
106
|
+
const stat = await client.filesStat(path);
|
|
107
|
+
return stat?.Hash;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Map site/key name -> IPNS id from `key/list -l` (same shape as node-commands). */
|
|
114
|
+
async function listKeys(client) {
|
|
115
|
+
const res = await client.keyList();
|
|
116
|
+
const map = new Map();
|
|
117
|
+
for (const k of res.Keys ?? []) {
|
|
118
|
+
if (k?.Name && k?.Id)
|
|
119
|
+
map.set(k.Name, k.Id);
|
|
120
|
+
}
|
|
121
|
+
return map;
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=site-management.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"site-management.js","sourceRoot":"","sources":["../../src/site/site-management.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `status` core operation — pinnace's discoverability VERIFICATION surface
|
|
3
|
+
* (CONTEXT.md `gateway warming`; spec user story 18).
|
|
4
|
+
*
|
|
5
|
+
* For every site auto-discovered from MFS `/sites/*` (over the Kubo RPC seam) it
|
|
6
|
+
* reports FOUR fields so an operator can confirm a deploy actually landed
|
|
7
|
+
* everywhere:
|
|
8
|
+
*
|
|
9
|
+
* 1. current CID — `files/stat --hash` (via {@link discoverSites}).
|
|
10
|
+
* 2. IPNS id — from `key/list -l` IF a key of the SAME name exists
|
|
11
|
+
* (ipfs-mode sites have none).
|
|
12
|
+
* 3. network-announce — does the NETWORK announce THIS node for the CID? The
|
|
13
|
+
* external delegated-routing providers list for the
|
|
14
|
+
* CID contains this node's PeerID (`id`).
|
|
15
|
+
* 4. gateway-serves — does a COLD public gateway serve it? An HTTP
|
|
16
|
+
* range/HEAD result from a public gateway.
|
|
17
|
+
*
|
|
18
|
+
* The two checks in (3) and (4) reach OUTSIDE the node. They are INJECTABLE
|
|
19
|
+
* ({@link ProvidersLookup}, {@link GatewayProbe}) so tests run against a fake
|
|
20
|
+
* HTTP layer and never the live network; production supplies the default
|
|
21
|
+
* `fetch`-backed implementations below.
|
|
22
|
+
*
|
|
23
|
+
* Behaviour ported (NOT copied) from the reference prototype
|
|
24
|
+
* `~/searches/ipfs-hetzner/status.sh`: the delegated-routing providers lookup at
|
|
25
|
+
* `https://delegated-ipfs.dev/routing/v1/providers/<cid>` (does
|
|
26
|
+
* `.Providers[].ID` include our PeerID?) and the
|
|
27
|
+
* `https://<cid>.ipfs.dweb.link/` range-request cold-gateway probe. The shell's
|
|
28
|
+
* `jq .Providers[].ID == $PEERID` and `curl -r 0-0 -w %{http_code}` shapes
|
|
29
|
+
* become the two TS predicates here.
|
|
30
|
+
*
|
|
31
|
+
* SEAM NOTE: this module OWNS the per-site status checks; the on-box `status`
|
|
32
|
+
* verb (`../node/node-commands.ts`) consumes them via its injectable
|
|
33
|
+
* `NodeCommandOps.status` seam — {@link makeStatusOp} adapts this core into that
|
|
34
|
+
* seam. The dashboard persistence (`status.json`) is the command layer's job,
|
|
35
|
+
* not this module's: this module PRODUCES the report and stops.
|
|
36
|
+
*
|
|
37
|
+
* A failed external check is a REPORTED outcome, never a throw: a
|
|
38
|
+
* delegated-routing lookup that errors reports `announced=false` (not findable
|
|
39
|
+
* right now) and a gateway probe that errors reports `gatewayServes=false`. One
|
|
40
|
+
* cold gateway or one flaky routing endpoint must not fail the whole report.
|
|
41
|
+
*/
|
|
42
|
+
import { type DiscoveredSite } from '../node/node-commands.js';
|
|
43
|
+
import type { KuboRpcClient } from '../rpc/kubo-rpc-client.js';
|
|
44
|
+
import type { NodeCommandContext, NodeOpResult } from '../node/node-commands.js';
|
|
45
|
+
/**
|
|
46
|
+
* The subset of the delegated-routing `/routing/v1/providers/<cid>` JSON we
|
|
47
|
+
* read: the `Providers` array, each with an `ID` (a PeerID). Other fields are
|
|
48
|
+
* ignored.
|
|
49
|
+
*/
|
|
50
|
+
export interface ProvidersResponse {
|
|
51
|
+
Providers?: Array<{
|
|
52
|
+
ID?: string;
|
|
53
|
+
}> | null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* An injectable delegated-routing providers lookup: given a CID, return the
|
|
57
|
+
* providers response (whose `.Providers[].ID` we scan for our PeerID). Tests
|
|
58
|
+
* inject a fake; production uses {@link defaultProvidersLookup}. Throwing is
|
|
59
|
+
* treated as "not findable" (announced=false), never propagated.
|
|
60
|
+
*/
|
|
61
|
+
export type ProvidersLookup = (cid: string) => Promise<ProvidersResponse>;
|
|
62
|
+
/**
|
|
63
|
+
* An injectable cold-gateway probe: given a CID, return the HTTP status a
|
|
64
|
+
* public gateway responds with (a 2xx/206 means it served). Tests inject a
|
|
65
|
+
* fake; production uses {@link defaultGatewayProbe}. Throwing is treated as
|
|
66
|
+
* "did not serve" (gatewayServes=false), never propagated.
|
|
67
|
+
*/
|
|
68
|
+
export type GatewayProbe = (cid: string) => Promise<number>;
|
|
69
|
+
/** The status of one site: the four report fields plus its `id`. */
|
|
70
|
+
export interface SiteStatus {
|
|
71
|
+
/** The site's single `id` (its MFS entry under `/sites/`). */
|
|
72
|
+
id: string;
|
|
73
|
+
/** The current content root CID (`files/stat --hash`). */
|
|
74
|
+
cid: string;
|
|
75
|
+
/** The IPNS id, if a same-named keystore key exists (else undefined). */
|
|
76
|
+
ipns?: string;
|
|
77
|
+
/** True when the delegated-routing providers list for the CID has our PeerID. */
|
|
78
|
+
announced: boolean;
|
|
79
|
+
/** The HTTP status the cold public gateway probe returned (undefined on error). */
|
|
80
|
+
gatewayHttp?: number;
|
|
81
|
+
/** True when the gateway probe status indicates the CID was served (2xx/206). */
|
|
82
|
+
gatewayServes: boolean;
|
|
83
|
+
}
|
|
84
|
+
/** The full status report: this node's PeerID plus a per-site status line. */
|
|
85
|
+
export interface StatusReport {
|
|
86
|
+
/** This node's PeerID (from Kubo `id`); the announce check looks for it. */
|
|
87
|
+
peerId: string;
|
|
88
|
+
/** One {@link SiteStatus} per site discovered under MFS `/sites/*`. */
|
|
89
|
+
sites: SiteStatus[];
|
|
90
|
+
}
|
|
91
|
+
/** Inputs to {@link statusReport}. */
|
|
92
|
+
export interface StatusReportInput {
|
|
93
|
+
/** The Kubo RPC client for THIS node (files/ls, files/stat, key/list, id). */
|
|
94
|
+
client: KuboRpcClient;
|
|
95
|
+
/** The MFS directory sites live under (default `/sites`). */
|
|
96
|
+
sitesDir?: string;
|
|
97
|
+
/**
|
|
98
|
+
* The delegated-routing providers lookup (injectable; defaults to the live
|
|
99
|
+
* `delegated-ipfs.dev` fetch via {@link defaultProvidersLookup}).
|
|
100
|
+
*/
|
|
101
|
+
providersLookup?: ProvidersLookup;
|
|
102
|
+
/**
|
|
103
|
+
* The cold-gateway probe (injectable; defaults to the live `dweb.link`
|
|
104
|
+
* range-request via {@link defaultGatewayProbe}).
|
|
105
|
+
*/
|
|
106
|
+
gatewayProbe?: GatewayProbe;
|
|
107
|
+
/**
|
|
108
|
+
* Pre-discovered sites (optional). When set, discovery is SKIPPED and these
|
|
109
|
+
* are used as-is — the node-command layer already walked MFS, so the adapter
|
|
110
|
+
* ({@link makeStatusOp}) passes them straight through rather than re-listing.
|
|
111
|
+
*/
|
|
112
|
+
sites?: DiscoveredSite[];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build the per-site status report. Discovers sites (unless supplied), reads
|
|
116
|
+
* this node's PeerID and its keystore once, then for each site runs the two
|
|
117
|
+
* external checks (through the injected/default fakes) to fill the four fields.
|
|
118
|
+
* External-check failures degrade to `false`, never throw.
|
|
119
|
+
*/
|
|
120
|
+
export declare function statusReport(input: StatusReportInput): Promise<StatusReport>;
|
|
121
|
+
/**
|
|
122
|
+
* Adapt {@link statusReport} into a {@link NodeCommandContext} `status` op so
|
|
123
|
+
* the on-box `status` verb can inject it (its `ops.status` seam). The command
|
|
124
|
+
* layer discovers sites and passes them in; this adapter runs the four-field
|
|
125
|
+
* checks over them and flattens the result into a {@link NodeOpResult} (its
|
|
126
|
+
* per-site outcomes carry the announce + gateway outcomes too).
|
|
127
|
+
*/
|
|
128
|
+
export declare function makeStatusOp(checks?: {
|
|
129
|
+
providersLookup?: ProvidersLookup;
|
|
130
|
+
gatewayProbe?: GatewayProbe;
|
|
131
|
+
}): (ctx: NodeCommandContext, sites: DiscoveredSite[]) => Promise<NodeOpResult>;
|
|
132
|
+
/**
|
|
133
|
+
* The default delegated-routing lookup: GET
|
|
134
|
+
* `https://delegated-ipfs.dev/routing/v1/providers/<cid>` and parse its JSON.
|
|
135
|
+
* (Injected fakes replace this in tests; it is the only place the live routing
|
|
136
|
+
* endpoint is named.)
|
|
137
|
+
*/
|
|
138
|
+
export declare const defaultProvidersLookup: ProvidersLookup;
|
|
139
|
+
/**
|
|
140
|
+
* The default cold-gateway probe: a single-byte range request to
|
|
141
|
+
* `https://<cid>.ipfs.dweb.link/` returning the HTTP status. (Injected fakes
|
|
142
|
+
* replace this in tests; it is the only place the live gateway is named.)
|
|
143
|
+
*/
|
|
144
|
+
export declare const defaultGatewayProbe: GatewayProbe;
|
|
145
|
+
//# sourceMappingURL=status-report.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status-report.d.ts","sourceRoot":"","sources":["../../src/status/status-report.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,OAAO,EAAgB,KAAK,cAAc,EAAC,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,2BAA2B,CAAC;AAC7D,OAAO,KAAK,EAAC,kBAAkB,EAAE,YAAY,EAAC,MAAM,0BAA0B,CAAC;AAS/E;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IACjC,SAAS,CAAC,EAAE,KAAK,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAC,CAAC,GAAG,IAAI,CAAC;CACxC;AAED;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAAC;AAE1E;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAE5D,oEAAoE;AACpE,MAAM,WAAW,UAAU;IAC1B,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC;IACX,0DAA0D;IAC1D,GAAG,EAAE,MAAM,CAAC;IACZ,yEAAyE;IACzE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,SAAS,EAAE,OAAO,CAAC;IACnB,mFAAmF;IACnF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,aAAa,EAAE,OAAO,CAAC;CACvB;AAED,8EAA8E;AAC9E,MAAM,WAAW,YAAY;IAC5B,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,KAAK,EAAE,UAAU,EAAE,CAAC;CACpB;AAED,sCAAsC;AACtC,MAAM,WAAW,iBAAiB;IACjC,8EAA8E;IAC9E,MAAM,EAAE,aAAa,CAAC;IACtB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC;;;OAGG;IACH,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B;;;;OAIG;IACH,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;CACzB;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CACjC,KAAK,EAAE,iBAAiB,GACtB,OAAO,CAAC,YAAY,CAAC,CAwBvB;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC3B,MAAM,GAAE;IAAC,eAAe,CAAC,EAAE,eAAe,CAAC;IAAC,YAAY,CAAC,EAAE,YAAY,CAAA;CAAM,GAC3E,CAAC,GAAG,EAAE,kBAAkB,EAAE,KAAK,EAAE,cAAc,EAAE,KAAK,OAAO,CAAC,YAAY,CAAC,CAqB7E;AA6ED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,EAAE,eAMpC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,EAAE,YAKjC,CAAC"}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `status` core operation — pinnace's discoverability VERIFICATION surface
|
|
3
|
+
* (CONTEXT.md `gateway warming`; spec user story 18).
|
|
4
|
+
*
|
|
5
|
+
* For every site auto-discovered from MFS `/sites/*` (over the Kubo RPC seam) it
|
|
6
|
+
* reports FOUR fields so an operator can confirm a deploy actually landed
|
|
7
|
+
* everywhere:
|
|
8
|
+
*
|
|
9
|
+
* 1. current CID — `files/stat --hash` (via {@link discoverSites}).
|
|
10
|
+
* 2. IPNS id — from `key/list -l` IF a key of the SAME name exists
|
|
11
|
+
* (ipfs-mode sites have none).
|
|
12
|
+
* 3. network-announce — does the NETWORK announce THIS node for the CID? The
|
|
13
|
+
* external delegated-routing providers list for the
|
|
14
|
+
* CID contains this node's PeerID (`id`).
|
|
15
|
+
* 4. gateway-serves — does a COLD public gateway serve it? An HTTP
|
|
16
|
+
* range/HEAD result from a public gateway.
|
|
17
|
+
*
|
|
18
|
+
* The two checks in (3) and (4) reach OUTSIDE the node. They are INJECTABLE
|
|
19
|
+
* ({@link ProvidersLookup}, {@link GatewayProbe}) so tests run against a fake
|
|
20
|
+
* HTTP layer and never the live network; production supplies the default
|
|
21
|
+
* `fetch`-backed implementations below.
|
|
22
|
+
*
|
|
23
|
+
* Behaviour ported (NOT copied) from the reference prototype
|
|
24
|
+
* `~/searches/ipfs-hetzner/status.sh`: the delegated-routing providers lookup at
|
|
25
|
+
* `https://delegated-ipfs.dev/routing/v1/providers/<cid>` (does
|
|
26
|
+
* `.Providers[].ID` include our PeerID?) and the
|
|
27
|
+
* `https://<cid>.ipfs.dweb.link/` range-request cold-gateway probe. The shell's
|
|
28
|
+
* `jq .Providers[].ID == $PEERID` and `curl -r 0-0 -w %{http_code}` shapes
|
|
29
|
+
* become the two TS predicates here.
|
|
30
|
+
*
|
|
31
|
+
* SEAM NOTE: this module OWNS the per-site status checks; the on-box `status`
|
|
32
|
+
* verb (`../node/node-commands.ts`) consumes them via its injectable
|
|
33
|
+
* `NodeCommandOps.status` seam — {@link makeStatusOp} adapts this core into that
|
|
34
|
+
* seam. The dashboard persistence (`status.json`) is the command layer's job,
|
|
35
|
+
* not this module's: this module PRODUCES the report and stops.
|
|
36
|
+
*
|
|
37
|
+
* A failed external check is a REPORTED outcome, never a throw: a
|
|
38
|
+
* delegated-routing lookup that errors reports `announced=false` (not findable
|
|
39
|
+
* right now) and a gateway probe that errors reports `gatewayServes=false`. One
|
|
40
|
+
* cold gateway or one flaky routing endpoint must not fail the whole report.
|
|
41
|
+
*/
|
|
42
|
+
import { discoverSites } from '../node/node-commands.js';
|
|
43
|
+
/** The delegated-routing providers endpoint (path takes the CID). */
|
|
44
|
+
const DELEGATED_ROUTING_BASE = 'https://delegated-ipfs.dev/routing/v1/providers';
|
|
45
|
+
/** The public gateway used for the cold-serve probe (`<cid>.ipfs.<host>`). */
|
|
46
|
+
const DWEB_LINK_HOST = 'ipfs.dweb.link';
|
|
47
|
+
/**
|
|
48
|
+
* Build the per-site status report. Discovers sites (unless supplied), reads
|
|
49
|
+
* this node's PeerID and its keystore once, then for each site runs the two
|
|
50
|
+
* external checks (through the injected/default fakes) to fill the four fields.
|
|
51
|
+
* External-check failures degrade to `false`, never throw.
|
|
52
|
+
*/
|
|
53
|
+
export async function statusReport(input) {
|
|
54
|
+
const sitesDir = input.sitesDir ?? '/sites';
|
|
55
|
+
const providersLookup = input.providersLookup ?? defaultProvidersLookup;
|
|
56
|
+
const gatewayProbe = input.gatewayProbe ?? defaultGatewayProbe;
|
|
57
|
+
const sites = input.sites ?? (await discoverSites(input.client, sitesDir));
|
|
58
|
+
const peerId = await readPeerId(input.client);
|
|
59
|
+
const keys = await listKeys(input.client);
|
|
60
|
+
const statuses = [];
|
|
61
|
+
for (const site of sites) {
|
|
62
|
+
const announced = await checkAnnounced(providersLookup, site.cid, peerId);
|
|
63
|
+
const gatewayHttp = await probeGateway(gatewayProbe, site.cid);
|
|
64
|
+
statuses.push({
|
|
65
|
+
id: site.id,
|
|
66
|
+
cid: site.cid,
|
|
67
|
+
ipns: keys.get(site.id),
|
|
68
|
+
announced,
|
|
69
|
+
gatewayHttp,
|
|
70
|
+
gatewayServes: gatewayHttp !== undefined && servesStatus(gatewayHttp),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return { peerId, sites: statuses };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Adapt {@link statusReport} into a {@link NodeCommandContext} `status` op so
|
|
77
|
+
* the on-box `status` verb can inject it (its `ops.status` seam). The command
|
|
78
|
+
* layer discovers sites and passes them in; this adapter runs the four-field
|
|
79
|
+
* checks over them and flattens the result into a {@link NodeOpResult} (its
|
|
80
|
+
* per-site outcomes carry the announce + gateway outcomes too).
|
|
81
|
+
*/
|
|
82
|
+
export function makeStatusOp(checks = {}) {
|
|
83
|
+
return async (ctx, sites) => {
|
|
84
|
+
const report = await statusReport({
|
|
85
|
+
client: ctx.client,
|
|
86
|
+
sitesDir: ctx.sitesDir,
|
|
87
|
+
sites,
|
|
88
|
+
providersLookup: checks.providersLookup,
|
|
89
|
+
gatewayProbe: checks.gatewayProbe,
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
sites: report.sites.map((s) => ({
|
|
93
|
+
id: s.id,
|
|
94
|
+
cid: s.cid,
|
|
95
|
+
ipns: s.ipns ?? '',
|
|
96
|
+
announced: s.announced,
|
|
97
|
+
gatewayServes: s.gatewayServes,
|
|
98
|
+
gatewayHttp: s.gatewayHttp,
|
|
99
|
+
status: s.gatewayServes && s.announced ? 'ok' : 'unverified',
|
|
100
|
+
})),
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Per-site checks (fail-soft).
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
/**
|
|
108
|
+
* Ask delegated routing who provides the CID and answer: is OUR PeerID among
|
|
109
|
+
* them? A lookup error is "not findable right now" -> false, never thrown (one
|
|
110
|
+
* flaky routing endpoint must not fail the report). Ports the shell's
|
|
111
|
+
* `jq '.Providers[]|select(.ID==$p)'`.
|
|
112
|
+
*/
|
|
113
|
+
async function checkAnnounced(lookup, cid, peerId) {
|
|
114
|
+
if (!peerId)
|
|
115
|
+
return false;
|
|
116
|
+
try {
|
|
117
|
+
const res = await lookup(cid);
|
|
118
|
+
return (res.Providers ?? []).some((p) => p?.ID === peerId);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Probe a cold public gateway for the CID and return its HTTP status, or
|
|
126
|
+
* undefined if the probe itself errored (network down, DNS, ...). Ports the
|
|
127
|
+
* shell's `curl -r 0-0 -w %{http_code}`.
|
|
128
|
+
*/
|
|
129
|
+
async function probeGateway(probe, cid) {
|
|
130
|
+
try {
|
|
131
|
+
return await probe(cid);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** A gateway HTTP status counts as "served" iff it is 2xx (including 206). */
|
|
138
|
+
function servesStatus(status) {
|
|
139
|
+
return status >= 200 && status < 300;
|
|
140
|
+
}
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Kubo reads.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
/** Read this node's PeerID from Kubo `id` (empty string if unavailable). */
|
|
145
|
+
async function readPeerId(client) {
|
|
146
|
+
try {
|
|
147
|
+
const res = await client.id();
|
|
148
|
+
return res?.ID ?? '';
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return '';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Map site/key name -> IPNS id from `key/list -l` (same shape as node-commands). */
|
|
155
|
+
async function listKeys(client) {
|
|
156
|
+
const res = await client.keyList();
|
|
157
|
+
const map = new Map();
|
|
158
|
+
for (const k of res.Keys ?? []) {
|
|
159
|
+
if (k?.Name && k?.Id)
|
|
160
|
+
map.set(k.Name, k.Id);
|
|
161
|
+
}
|
|
162
|
+
return map;
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Default (LIVE) external checks. Production wiring; tests inject fakes instead.
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
/**
|
|
168
|
+
* The default delegated-routing lookup: GET
|
|
169
|
+
* `https://delegated-ipfs.dev/routing/v1/providers/<cid>` and parse its JSON.
|
|
170
|
+
* (Injected fakes replace this in tests; it is the only place the live routing
|
|
171
|
+
* endpoint is named.)
|
|
172
|
+
*/
|
|
173
|
+
export const defaultProvidersLookup = async (cid) => {
|
|
174
|
+
const res = await fetch(`${DELEGATED_ROUTING_BASE}/${cid}`, {
|
|
175
|
+
headers: { accept: 'application/json' },
|
|
176
|
+
});
|
|
177
|
+
if (!res.ok)
|
|
178
|
+
return { Providers: [] };
|
|
179
|
+
return (await res.json());
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* The default cold-gateway probe: a single-byte range request to
|
|
183
|
+
* `https://<cid>.ipfs.dweb.link/` returning the HTTP status. (Injected fakes
|
|
184
|
+
* replace this in tests; it is the only place the live gateway is named.)
|
|
185
|
+
*/
|
|
186
|
+
export const defaultGatewayProbe = async (cid) => {
|
|
187
|
+
const res = await fetch(`https://${cid}.${DWEB_LINK_HOST}/`, {
|
|
188
|
+
headers: { range: 'bytes=0-0' },
|
|
189
|
+
});
|
|
190
|
+
return res.status;
|
|
191
|
+
};
|
|
192
|
+
//# sourceMappingURL=status-report.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status-report.js","sourceRoot":"","sources":["../../src/status/status-report.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,4 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pinnace",
|
|
3
|
-
"version": "0.
|
|
4
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "AGPL-3.0-only",
|
|
5
|
+
"description": "Self-host a static website on IPFS across one or more self-owned Kubo nodes.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/wighawag/pinnace.git",
|
|
9
|
+
"directory": "packages/pinnace"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"pinnace": "./dist/cli/bin.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"files-from-path": "^1.1.4",
|
|
30
|
+
"ipfs-car": "^3.1.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@ipld/car": "^5.4.6",
|
|
34
|
+
"@ipld/dag-pb": "^4.1.7",
|
|
35
|
+
"@types/node": "^25.2.0",
|
|
36
|
+
"as-soon": "^0.1.5",
|
|
37
|
+
"tsx": "^4.21.0",
|
|
38
|
+
"typescript": "^5.3.3",
|
|
39
|
+
"vitest": "^4.0.18"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc",
|
|
43
|
+
"dev": "as-soon -w src pnpm build",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process **CAR** (Content Addressable aRchive) builder — the default and
|
|
3
|
+
* primary deploy artifact (CONTEXT.md `CAR`; spec user stories 4-6).
|
|
4
|
+
*
|
|
5
|
+
* Given a site SOURCE DIRECTORY, this builds a CAR whose root is a real UnixFS
|
|
6
|
+
* DIRECTORY preserving the site structure (`index.html`, `assets/...`). The DAG
|
|
7
|
+
* is built entirely IN-PROCESS via the `ipfs-car` library (no external CLI, no
|
|
8
|
+
* output scraping), so the root CID is authoritative and the build reproducible.
|
|
9
|
+
*
|
|
10
|
+
* The scheme (ported, not copied, from the reference prototype
|
|
11
|
+
* `~/searches/ipfs-hetzner/deploy-car.mjs`):
|
|
12
|
+
*
|
|
13
|
+
* 1. `filesFromPaths([sourceDir])` yields the directory's files with
|
|
14
|
+
* SITE-RELATIVE paths (`index.html`, `assets/s.css`) and NO wrapping
|
|
15
|
+
* segment. We pass them straight in: the CAR root IS the site dir. (An
|
|
16
|
+
* earlier prototype wrongly stripped a leading segment; that bug is fixed
|
|
17
|
+
* here and guarded by a test asserting `index.html` sits at the root.)
|
|
18
|
+
* 2. Pipe the files through `createDirectoryEncoderStream`, buffering every
|
|
19
|
+
* emitted block and capturing the LAST block's CID as the directory root
|
|
20
|
+
* (authoritative — captured from the encoder, NEVER scraped from text).
|
|
21
|
+
* 3. Re-encode the buffered blocks with `new CAREncoderStream([rootCid])` so
|
|
22
|
+
* the CAR HEADER carries the root. That is what makes
|
|
23
|
+
* `dag/import?pin-roots=true` pin the site root on every node.
|
|
24
|
+
*
|
|
25
|
+
* The build BUFFERS blocks in memory (fine for normal static sites; streaming
|
|
26
|
+
* very large sites is explicitly out of scope for v1 — see the spec).
|
|
27
|
+
*/
|
|
28
|
+
import {writeFile} from 'node:fs/promises';
|
|
29
|
+
import {
|
|
30
|
+
createDirectoryEncoderStream,
|
|
31
|
+
CAREncoderStream,
|
|
32
|
+
type Block,
|
|
33
|
+
} from 'ipfs-car';
|
|
34
|
+
import {filesFromPaths} from 'files-from-path';
|
|
35
|
+
|
|
36
|
+
/** The result of building a CAR: its raw bytes and its authoritative root CID. */
|
|
37
|
+
export interface BuiltCar {
|
|
38
|
+
/** The complete CAR file bytes (header carries {@link BuiltCar.rootCid}). */
|
|
39
|
+
readonly carBytes: Uint8Array;
|
|
40
|
+
/** The site's UnixFS directory root CID, as the LAST encoder block. */
|
|
41
|
+
readonly rootCid: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a CAR for a site source directory, entirely in-process.
|
|
46
|
+
*
|
|
47
|
+
* @param sourceDir the site's built output directory (its CONTENTS become the
|
|
48
|
+
* CAR root — the directory itself is NOT wrapped in an extra segment).
|
|
49
|
+
* @returns the CAR bytes plus the authoritative root CID (the last block the
|
|
50
|
+
* directory encoder emits, mirrored into the CAR header).
|
|
51
|
+
* @throws if no files are found under `sourceDir` (nothing to deploy).
|
|
52
|
+
*/
|
|
53
|
+
export async function buildCar(sourceDir: string): Promise<BuiltCar> {
|
|
54
|
+
// filesFromPaths([sourceDir]) already yields SITE-RELATIVE paths
|
|
55
|
+
// ("index.html", "assets/s.css"). Do NOT strip a leading segment.
|
|
56
|
+
const files = await filesFromPaths([sourceDir]);
|
|
57
|
+
if (files.length === 0) {
|
|
58
|
+
throw new Error(`no files found under ${sourceDir}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Pass 1: encode the directory, buffering blocks and capturing the root CID
|
|
62
|
+
// as the LAST emitted block. The header roots are patched in pass 2, so we
|
|
63
|
+
// encode with an empty root list here and discard the resulting stream.
|
|
64
|
+
const blocks: Block[] = [];
|
|
65
|
+
let rootBlock: Block | undefined;
|
|
66
|
+
await createDirectoryEncoderStream(files)
|
|
67
|
+
.pipeThrough(
|
|
68
|
+
new TransformStream({
|
|
69
|
+
transform(block: Block, controller) {
|
|
70
|
+
// The directory root is the LAST block the encoder emits.
|
|
71
|
+
rootBlock = block;
|
|
72
|
+
blocks.push(block);
|
|
73
|
+
controller.enqueue(block);
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
.pipeThrough(new CAREncoderStream([]))
|
|
78
|
+
.pipeTo(new WritableStream());
|
|
79
|
+
|
|
80
|
+
if (!rootBlock) {
|
|
81
|
+
// Unreachable given files.length > 0, but keeps the root authoritative.
|
|
82
|
+
throw new Error(`encoder produced no blocks for ${sourceDir}`);
|
|
83
|
+
}
|
|
84
|
+
const rootCid = rootBlock.cid.toString();
|
|
85
|
+
|
|
86
|
+
// Pass 2: re-encode the SAME buffered blocks WITH the known root in the CAR
|
|
87
|
+
// header, so `dag/import?pin-roots=true` pins the site root.
|
|
88
|
+
const chunks: Uint8Array[] = [];
|
|
89
|
+
const pending = blocks.slice();
|
|
90
|
+
const source = new ReadableStream<Block>({
|
|
91
|
+
pull(controller) {
|
|
92
|
+
const next = pending.shift();
|
|
93
|
+
if (next) controller.enqueue(next);
|
|
94
|
+
else controller.close();
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
await source.pipeThrough(new CAREncoderStream([rootBlock.cid])).pipeTo(
|
|
98
|
+
new WritableStream({
|
|
99
|
+
write(chunk) {
|
|
100
|
+
chunks.push(chunk);
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return {carBytes: concatChunks(chunks), rootCid};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build a CAR for `sourceDir` and write it to `outPath`.
|
|
110
|
+
*
|
|
111
|
+
* A thin convenience over {@link buildCar} for the deploy path that persists a
|
|
112
|
+
* CAR to disk before importing it into each node. Returns the same
|
|
113
|
+
* {@link BuiltCar} (its `carBytes` are exactly the bytes written).
|
|
114
|
+
*/
|
|
115
|
+
export async function writeCar(
|
|
116
|
+
sourceDir: string,
|
|
117
|
+
outPath: string,
|
|
118
|
+
): Promise<BuiltCar> {
|
|
119
|
+
const built = await buildCar(sourceDir);
|
|
120
|
+
await writeFile(outPath, built.carBytes);
|
|
121
|
+
return built;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Concatenate CAR stream chunks into one contiguous byte array. */
|
|
125
|
+
function concatChunks(chunks: Uint8Array[]): Uint8Array {
|
|
126
|
+
let total = 0;
|
|
127
|
+
for (const chunk of chunks) total += chunk.length;
|
|
128
|
+
const out = new Uint8Array(total);
|
|
129
|
+
let offset = 0;
|
|
130
|
+
for (const chunk of chunks) {
|
|
131
|
+
out.set(chunk, offset);
|
|
132
|
+
offset += chunk.length;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|