@le-space/orbitdb-storage-bridge 0.10.0 → 0.12.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 +46 -5
- package/lib/backends/aleph.js +10 -6
- package/lib/backends/choose.js +186 -0
- package/lib/backends/mirror.js +234 -0
- package/lib/backends/resolve.js +10 -0
- package/lib/backends/storacha.js +13 -8
- package/lib/backup-car.js +13 -6
- package/lib/gateway-fetch.js +65 -10
- package/lib/orbitdb-storacha-bridge.js +8 -9
- package/lib/peer-fetch.js +365 -0
- package/lib/ucan-bridge.js +1 -1
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
[](https://nodejs.org/)
|
|
8
|
-
[](https://github.com/NiKrause
|
|
8
|
+
[](https://github.com/NiKrause/orbitdb-storage-bridge/actions/workflows/ci.yml)
|
|
9
|
+
[](https://github.com/NiKrause/orbitdb-storage-bridge/actions/workflows/ci.yml)
|
|
10
10
|
[](https://www.npmjs.com/package/@le-space/orbitdb-storage-bridge)
|
|
11
11
|
|
|
12
12
|
> [!NOTE]
|
|
@@ -60,6 +60,18 @@
|
|
|
60
60
|
- [License](#license)
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
## Does this work from a browser? Measure it
|
|
64
|
+
|
|
65
|
+
`examples/browser/storage-probe/` is a static page that asks the services this package talks to,
|
|
66
|
+
from a real browser, with no server: do Aleph, Pinata and Lighthouse answer a page at all, does
|
|
67
|
+
their CORS survive a refusal as well as a success, and does anybody on IPFS hold a given CID at an
|
|
68
|
+
address a browser can dial. With your own key it does a real upload and reads it back. The key is
|
|
69
|
+
kept in that browser and sent only to the service it belongs to.
|
|
70
|
+
|
|
71
|
+
It is published from this repository's Pages, and it opens from a checkout just as well — there is
|
|
72
|
+
no build step, which is the point: what it measures is a browser talking to a service, with
|
|
73
|
+
nothing in between.
|
|
74
|
+
|
|
63
75
|
## Status: Storacha sunset (May 2026)
|
|
64
76
|
|
|
65
77
|
Storacha switched off user writes in **May 2026** and has since decommissioned the service.
|
|
@@ -73,6 +85,17 @@ Verified on 2026-09-05:
|
|
|
73
85
|
| the widget demo CID linked in the roadmap below | `504` on `w3s.link`, `dweb.link`, `ipfs.io` and `trustless-gateway.link` |
|
|
74
86
|
| `@storacha/client` on npm | last release `2.1.4`, 2026-05-15, not marked deprecated |
|
|
75
87
|
|
|
88
|
+
The gateway those redirects pointed at is gone too. On **2026-09-21** Protocol Labs retired
|
|
89
|
+
`ipfs.io` and `dweb.link`: both answer `429` with an RFC 8594 `Sunset` header and a link to
|
|
90
|
+
[gatewaychanges.ipfs.io](https://gatewaychanges.ipfs.io/), so `storacha.link` and `w3s.link`
|
|
91
|
+
now redirect to a closed door. Retrieval defaults here are `ipfs.aleph.cloud` — the one free
|
|
92
|
+
path gateway measured still serving arbitrary CIDs on 2026-09-23 — with
|
|
93
|
+
`trustless-gateway.link` available for verifiable single-block requests
|
|
94
|
+
(`Accept: application/vnd.ipld.raw`). One host is not a fallback chain, which is why `peer-fetch.js` fetches from the providers that
|
|
95
|
+
hold the blocks instead — bitswap over libp2p, measured from a real page at 0.73 s to dial and
|
|
96
|
+
0.26 s for the block, with no credential in the path. See
|
|
97
|
+
[docs/RECOVERY-ON-A-SECOND-DEVICE.md](docs/RECOVERY-ON-A-SECOND-DEVICE.md).
|
|
98
|
+
|
|
76
99
|
The shutdown is traceable in the open:
|
|
77
100
|
[`upload-service#708`](https://github.com/storacha/upload-service/pull/708) added a `writesDisabled`
|
|
78
101
|
kill switch that makes the eight user-initiated write capabilities
|
|
@@ -157,7 +180,7 @@ The project includes **Svelte components** for browser-based demos and integrati
|
|
|
157
180
|
## Roadmap
|
|
158
181
|
|
|
159
182
|
> Being re-based on a backend interface instead of a single vendor — the plan is
|
|
160
|
-
> [issue 54](https://github.com/NiKrause
|
|
183
|
+
> [issue 54](https://github.com/NiKrause/orbitdb-storage-bridge/issues/54), not here. The WebAuthn/varsig items below survive
|
|
161
184
|
> unchanged; the Storacha-named ones become backend-agnostic.
|
|
162
185
|
|
|
163
186
|
- [ ] Live parallel persistence: hand an open database a backend-backed OrbitDB `ComposedStorage`, so every block is written to a backend **as it is created** — during sync and after each update — rather than only when a backup runs.
|
|
@@ -169,11 +192,11 @@ The project includes **Svelte components** for browser-based demos and integrati
|
|
|
169
192
|
- [ ] v0.4.4 (Feb 2026): Latest-backup pointer (single CID) to avoid listing via the Storacha SDK and restore from the IPFS network for initial OrbitDB syncs.
|
|
170
193
|
- [ ] After each backup, write a small pointer record (JSON) that stores the latest metadata CID, CAR CID, and last heads (block CID).
|
|
171
194
|
- [ ] Store that pointer in a user-controlled place (local storage, QR/share link, WebAuthN largetBlog extension or file download).
|
|
172
|
-
- [ ] v0.5.0 (Feb 2026): OrbitDB CustomStorage (StorachaStorage) ([issue 23](https://github.com/NiKrause
|
|
195
|
+
- [ ] v0.5.0 (Feb 2026): OrbitDB CustomStorage (StorachaStorage) ([issue 23](https://github.com/NiKrause/orbitdb-storage-bridge/issues/23)).
|
|
173
196
|
- [ ] v0.6.0 (Mar 2026): WebAuthN + varsig signing/verification (Ed25519 and P-256) for OrbitDB oplog. https://github.com/ChainAgnostic/varsig/blob/main/README.md
|
|
174
197
|
- [ ] v0.6.1 (Mar 2026): WebAuthN + SimpleEncryption example that uses WebAuthN+PRF key material for encrypted backups and restore.
|
|
175
198
|
- [ ] v0.7.0 (Apr 2026): WebAuthN + OrbitDB AccessController (store a UCAN instead of only a DID for admin/write access).
|
|
176
|
-
- [ ] Alice (authenticated via UCAN or Storacha credentials) can delegate/revoke access for Bob with custom/default capabilities ([issue 16](https://github.com/NiKrause
|
|
199
|
+
- [ ] Alice (authenticated via UCAN or Storacha credentials) can delegate/revoke access for Bob with custom/default capabilities ([issue 16](https://github.com/NiKrause/orbitdb-storage-bridge/issues/16)). See [WebAuthN Upload Wall](https://github.com/NiKrause/ucan-upload-wall/tree/browser-only/web) and the [live demo](https://bafybeibdcnp7pr26okzr6kbygcounsz3klyg3vydxwwovmz2ljyzfmprre.ipfs.w3s.link/).
|
|
177
200
|
- [ ] v0.7.1 (May 2026): Storacha Backup & Restore Svelte widget with WebAuthN-varsig UCAN signing/verification (Ed25519/P-256).
|
|
178
201
|
- [ ] v0.7.2 (May 2026): Storacha Backup & Restore React widget with WebAuthN-varsig UCAN signing/verification (Ed25519/P-256).
|
|
179
202
|
- [ ] v0.7.3 (May 2026): Storacha Backup & Restore React widget with WebAuthN-varsig UCAN delegation (Ed25519/P-256).
|
|
@@ -224,6 +247,24 @@ The scripts written against Storacha's space and UCAN model are in
|
|
|
224
247
|
[`examples/storacha/`](examples/storacha/README.md). None of them runs end to
|
|
225
248
|
end since the uploads stopped, and the README there says what replaced each.
|
|
226
249
|
|
|
250
|
+
### In a browser: a database back on a device that has nothing
|
|
251
|
+
|
|
252
|
+
[**funkpost's recovery page**](https://nikrause.github.io/funkpost/recovery/) runs the whole
|
|
253
|
+
[recovery procedure](docs/RECOVERY-ON-A-SECOND-DEVICE.md) in a phone's browser, with a
|
|
254
|
+
security key and nothing else ([source](https://github.com/NiKrause/funkpost/tree/main/examples/recovery)):
|
|
255
|
+
|
|
256
|
+
1. **the key gives the identity** — the DID, and a signing key derived from its PRF output,
|
|
257
|
+
the same on every device;
|
|
258
|
+
2. **a list** is made and written to;
|
|
259
|
+
3. **`dehydrate`** backs it up to Aleph as a CAR, and publishes an IPNS pointer under a name
|
|
260
|
+
the key derives;
|
|
261
|
+
4. **on another device** — or the same one, wiped — the same key finds the pointer, **`hydrate`**
|
|
262
|
+
brings the list back, and the list takes new entries, because the writer is the same.
|
|
263
|
+
|
|
264
|
+
On 21 September 2026 it ran that way on two phones: a Galaxy Fold 5 backed up and was reset,
|
|
265
|
+
and a Galaxy A57 with the same key brought the list back and wrote to it. The page says at
|
|
266
|
+
every step which service it contacts; the technical details sit behind one button.
|
|
267
|
+
|
|
227
268
|
### Svelte Components
|
|
228
269
|
|
|
229
270
|
For browser-based integration, this project includes Svelte components for authentication, backup/restore, P2P replication, and WebAuthn biometric authentication. See [**SVELTE-COMPONENTS.md**](SVELTE-COMPONENTS.md) for complete documentation of all available components and demonstrations.
|
package/lib/backends/aleph.js
CHANGED
|
@@ -47,12 +47,16 @@
|
|
|
47
47
|
import { defineBackend, handleId, BackendError } from "./types.js";
|
|
48
48
|
import { fetchFromGateways } from "../gateway-fetch.js";
|
|
49
49
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Aleph's own, and only Aleph's own.
|
|
52
|
+
*
|
|
53
|
+
* `dweb.link` and `ipfs.io` were the two fallbacks behind it until Protocol
|
|
54
|
+
* Labs retired them on 2026-09-21; they answer 429 with a `Sunset` header now.
|
|
55
|
+
* Nothing free was found to replace them that will serve an arbitrary CID, so
|
|
56
|
+
* this driver's retrieval depends on one host until the peer path in #112
|
|
57
|
+
* lands. A caller with its own gateway passes `gateways`.
|
|
58
|
+
*/
|
|
59
|
+
export const ALEPH_GATEWAYS = Object.freeze(["https://ipfs.aleph.cloud/ipfs"]);
|
|
56
60
|
|
|
57
61
|
const ALEPH_INGEST = "https://ipfs.aleph.cloud/api/v0/add";
|
|
58
62
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview One backend from a plain choice — a name and, where a service
|
|
3
|
+
* needs one, a key.
|
|
4
|
+
*
|
|
5
|
+
* `resolveBackend` understands a ready backend or Storacha credentials, which is
|
|
6
|
+
* what a Node caller has. A page has something smaller and less trusting: a
|
|
7
|
+
* reader ticked *Lighthouse* and pasted their own key, and nothing else is
|
|
8
|
+
* known. This turns that into a driver.
|
|
9
|
+
*
|
|
10
|
+
* ## Every vendor module is imported lazily, and that is the point
|
|
11
|
+
*
|
|
12
|
+
* A page that chose Aleph must not ship the Pinata and Lighthouse drivers, and
|
|
13
|
+
* a page that chose none of them must ship no vendor code at all. Each branch
|
|
14
|
+
* therefore `import()`s exactly what it builds — the same reason
|
|
15
|
+
* `resolveBackend` loads Storacha only when it builds one, where the module
|
|
16
|
+
* costs about 88 kB gzipped.
|
|
17
|
+
*
|
|
18
|
+
* ## Several choices are a mirror, not a loop in the caller
|
|
19
|
+
*
|
|
20
|
+
* `kind: ["aleph", "lighthouse"]` builds both and returns
|
|
21
|
+
* {@link ../backends/mirror.js createMirrorBackend} over them, so the caller's
|
|
22
|
+
* code is the same whether one service was ticked or three.
|
|
23
|
+
*
|
|
24
|
+
* ## A gateway belongs to the service it came from
|
|
25
|
+
*
|
|
26
|
+
* Since the public path gateways were retired (#111), the gateway a reader can
|
|
27
|
+
* actually use is usually their own account's — `<name>.mypinata.cloud`,
|
|
28
|
+
* `<name>.lighthouseweb3.xyz` — and those are not interchangeable: Pinata's
|
|
29
|
+
* answers 401 for a Lighthouse CID, Lighthouse's answers 402. So `gateway` is
|
|
30
|
+
* a string only while one service is chosen, and an object keyed by service
|
|
31
|
+
* once several are. Handing one string to three drivers is refused rather than
|
|
32
|
+
* half-working.
|
|
33
|
+
*
|
|
34
|
+
* ## What it refuses
|
|
35
|
+
*
|
|
36
|
+
* A key that is missing is refused here, with the name of the service in the
|
|
37
|
+
* message, rather than at the first upload — a page can then keep the button
|
|
38
|
+
* disabled and say why. Nothing in this module reads an environment variable on
|
|
39
|
+
* a page's behalf: in a browser there is no environment, and a driver that
|
|
40
|
+
* silently finds a key somewhere else is a driver nobody can reason about. The
|
|
41
|
+
* per-driver fallbacks to `process.env` stay where they are, for Node.
|
|
42
|
+
*
|
|
43
|
+
* @author @NiKrause
|
|
44
|
+
* @requires ./types.js - the contract every branch returns
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { BackendError } from "./types.js";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Aleph's driver tries a list of `…/ipfs` prefixes, while a reader types a
|
|
51
|
+
* host. Accept either, and a bare domain as https, like the other drivers do.
|
|
52
|
+
*/
|
|
53
|
+
const alephGateway = (value) => {
|
|
54
|
+
const withScheme = /^https?:\/\//i.test(value) ? value : `https://${value}`;
|
|
55
|
+
const trimmed = withScheme.replace(/\/+$/, "");
|
|
56
|
+
return trimmed.endsWith("/ipfs") ? trimmed : `${trimmed}/ipfs`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The names a caller may ask for. */
|
|
60
|
+
export const BACKEND_KINDS = Object.freeze([
|
|
61
|
+
"aleph",
|
|
62
|
+
"pinata",
|
|
63
|
+
"lighthouse",
|
|
64
|
+
"storacha",
|
|
65
|
+
"memory",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build a backend from a choice.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} choice
|
|
72
|
+
* @param {string|string[]} choice.kind - one name, or several for a mirror
|
|
73
|
+
* @param {string} [choice.jwt] - Pinata
|
|
74
|
+
* @param {() => Promise<string>} [choice.getUploadUrl] - Pinata, without a secret in the page
|
|
75
|
+
* @param {string} [choice.apiKey] - Lighthouse
|
|
76
|
+
* @param {"shared"|"user"} [choice.keyOwnership] - Lighthouse: whose key it is,
|
|
77
|
+
* which is what decides `browserSafeAuth`
|
|
78
|
+
* @param {string|Record<string, string>} [choice.gateway] - retrieval gateway:
|
|
79
|
+
* a string for a single kind, or `{ pinata: "…", lighthouse: "…" }` for several.
|
|
80
|
+
* A bare domain is read as https, as each driver already accepts
|
|
81
|
+
* @param {string[]} [choice.gateways] - retrieval gateways, tried in order; wins
|
|
82
|
+
* over `gateway` where a driver takes a list
|
|
83
|
+
* @param {object} [choice.options] - passed through to the driver, for anything
|
|
84
|
+
* this signature does not name
|
|
85
|
+
* @param {"one"|"all"} [choice.require] - for several kinds: how many must accept a write
|
|
86
|
+
* @returns {Promise<import("./types.js").StorageBackend>}
|
|
87
|
+
*/
|
|
88
|
+
export async function createBackendFromChoice(choice = {}) {
|
|
89
|
+
const kinds = Array.isArray(choice.kind) ? choice.kind : [choice.kind];
|
|
90
|
+
const wanted = kinds.filter(Boolean);
|
|
91
|
+
|
|
92
|
+
if (wanted.length === 0) {
|
|
93
|
+
throw new BackendError(
|
|
94
|
+
"INVALID_BACKEND",
|
|
95
|
+
`Pick a backend: ${BACKEND_KINDS.join(", ")}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
for (const kind of wanted) {
|
|
99
|
+
if (!BACKEND_KINDS.includes(kind)) {
|
|
100
|
+
throw new BackendError(
|
|
101
|
+
"INVALID_BACKEND",
|
|
102
|
+
`Unknown backend "${kind}" — pick one of ${BACKEND_KINDS.join(", ")}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (wanted.length > 1 && typeof choice.gateway === "string") {
|
|
108
|
+
throw new BackendError(
|
|
109
|
+
"INVALID_BACKEND",
|
|
110
|
+
`A gateway belongs to one service — with several kinds pass an object: ` +
|
|
111
|
+
`{ gateway: { ${wanted.map((k) => `${k}: "…"`).join(", ")} } }`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (wanted.length > 1) {
|
|
116
|
+
const backends = [];
|
|
117
|
+
for (const kind of wanted) {
|
|
118
|
+
backends.push(await createBackendFromChoice({ ...choice, kind }));
|
|
119
|
+
}
|
|
120
|
+
const { createMirrorBackend } = await import("./mirror.js");
|
|
121
|
+
return createMirrorBackend(backends, { require: choice.require ?? "one" });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const [kind] = wanted;
|
|
125
|
+
const extra = choice.options ?? {};
|
|
126
|
+
const gateway =
|
|
127
|
+
typeof choice.gateway === "string" ? choice.gateway : choice.gateway?.[kind];
|
|
128
|
+
const gateways = choice.gateways ? { gateways: choice.gateways } : {};
|
|
129
|
+
// Pinata and Lighthouse take one gateway; Aleph takes the list it tries in
|
|
130
|
+
// order, so a single choice becomes a list of one.
|
|
131
|
+
const oneGateway = gateway ? { gateway } : {};
|
|
132
|
+
|
|
133
|
+
if (kind === "aleph") {
|
|
134
|
+
const { createAlephBackend } = await import("./aleph.js");
|
|
135
|
+
const fromOne =
|
|
136
|
+
gateway && !choice.gateways ? { gateways: [alephGateway(gateway)] } : {};
|
|
137
|
+
return createAlephBackend({ ...gateways, ...fromOne, ...extra });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (kind === "pinata") {
|
|
141
|
+
if (
|
|
142
|
+
!choice.jwt &&
|
|
143
|
+
!choice.getUploadUrl &&
|
|
144
|
+
!extra.jwt &&
|
|
145
|
+
!extra.getUploadUrl
|
|
146
|
+
) {
|
|
147
|
+
throw new BackendError(
|
|
148
|
+
"INVALID_BACKEND",
|
|
149
|
+
"Pinata needs a JWT, or a getUploadUrl function that mints a presigned URL",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const { createPinataBackend } = await import("./pinata.js");
|
|
153
|
+
return createPinataBackend({
|
|
154
|
+
...(choice.jwt ? { jwt: choice.jwt } : {}),
|
|
155
|
+
...(choice.getUploadUrl ? { getUploadUrl: choice.getUploadUrl } : {}),
|
|
156
|
+
...oneGateway,
|
|
157
|
+
...gateways,
|
|
158
|
+
...extra,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (kind === "lighthouse") {
|
|
163
|
+
if (!choice.apiKey && !extra.apiKey) {
|
|
164
|
+
throw new BackendError("INVALID_BACKEND", "Lighthouse needs an apiKey");
|
|
165
|
+
}
|
|
166
|
+
const { createLighthouseBackend } = await import("./lighthouse.js");
|
|
167
|
+
return createLighthouseBackend({
|
|
168
|
+
...(choice.apiKey ? { apiKey: choice.apiKey } : {}),
|
|
169
|
+
// Whose key it is decides browserSafeAuth, and a page should say which it got.
|
|
170
|
+
keyOwnership: choice.keyOwnership ?? "shared",
|
|
171
|
+
...oneGateway,
|
|
172
|
+
...gateways,
|
|
173
|
+
...extra,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (kind === "memory") {
|
|
178
|
+
const { createMemoryBackend } = await import("./memory.js");
|
|
179
|
+
return createMemoryBackend(extra);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { resolveBackend } = await import("./resolve.js");
|
|
183
|
+
return resolveBackend({ ...choice, ...extra, kind: undefined });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export default createBackendFromChoice;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview One backup, several services: the mirror backend.
|
|
3
|
+
*
|
|
4
|
+
* A page that wants its backup on Aleph *and* on Lighthouse had to call both
|
|
5
|
+
* and reconcile two handles itself. This does that once, behind the same
|
|
6
|
+
* contract every other driver keeps, so `dehydrate`, `backupDatabaseCAR` and
|
|
7
|
+
* `restoreFromCID` need to know nothing about it.
|
|
8
|
+
*
|
|
9
|
+
* ## What a mirror promises, and what it refuses to
|
|
10
|
+
*
|
|
11
|
+
* **A partial write is reported, never swallowed.** By default a write
|
|
12
|
+
* succeeds when at least one service took it — a backup that fails because the
|
|
13
|
+
* third service was down would be worse than useless — but the handle then
|
|
14
|
+
* names who holds it and who refused, and the caller decides what to say. Pass
|
|
15
|
+
* `require: "all"` when a copy everywhere is the point of the exercise.
|
|
16
|
+
*
|
|
17
|
+
* **A read asks in order and stops at the first answer.** Not a race: a race
|
|
18
|
+
* spends every service's bandwidth on every read, and on a gateway that bills
|
|
19
|
+
* by request that is somebody's money. The order is the order the backends
|
|
20
|
+
* were given, so "the fast one first" is the caller's decision to make.
|
|
21
|
+
*
|
|
22
|
+
* **Capabilities are the honest composition, not the flattering one.**
|
|
23
|
+
* `browserSafeAuth` is true only when *every* service is safe to hand a page,
|
|
24
|
+
* because the weakest one decides what a page leaks. `preservesInnerCids` and
|
|
25
|
+
* `carImport` likewise. `minBlobSize` is the largest of them, because a blob
|
|
26
|
+
* has to clear the strictest door. `pinByCid` is the exception: it is true when
|
|
27
|
+
* *any* service can pin, and `pinCid()` then asks exactly those.
|
|
28
|
+
*
|
|
29
|
+
* **It does not list and it does not delete.** Both questions have no single
|
|
30
|
+
* honest answer across services — a listing would be a union with duplicates,
|
|
31
|
+
* and a deletion that half succeeds leaves a copy behind while reporting
|
|
32
|
+
* success. A caller that wants either can ask the service it means.
|
|
33
|
+
*
|
|
34
|
+
* @author @NiKrause
|
|
35
|
+
* @requires ./types.js - the contract this keeps
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
defineBackend,
|
|
40
|
+
handleId,
|
|
41
|
+
BackendError,
|
|
42
|
+
DEFAULT_CAPABILITIES,
|
|
43
|
+
} from "./types.js";
|
|
44
|
+
import { logger } from "../logger.js";
|
|
45
|
+
|
|
46
|
+
/** The strictest door decides; an unstated minimum is no minimum. */
|
|
47
|
+
const largestMinimum = (backends) =>
|
|
48
|
+
backends.reduce(
|
|
49
|
+
(largest, backend) =>
|
|
50
|
+
Math.max(largest, backend.capabilities?.minBlobSize ?? 0),
|
|
51
|
+
0,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const everyOne = (backends, flag) =>
|
|
55
|
+
backends.every((backend) => Boolean(backend.capabilities?.[flag]));
|
|
56
|
+
const anyOne = (backends, flag) =>
|
|
57
|
+
backends.some((backend) => Boolean(backend.capabilities?.[flag]));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write one backup to several services and read it back from whichever answers.
|
|
61
|
+
*
|
|
62
|
+
* @param {import("./types.js").StorageBackend[]} backends - two or more drivers,
|
|
63
|
+
* in the order reads should try them
|
|
64
|
+
* @param {object} [options]
|
|
65
|
+
* @param {"one"|"all"} [options.require="one"] - how many services must accept a
|
|
66
|
+
* write for it to count as one
|
|
67
|
+
* @param {string} [options.name] - what the mirror calls itself; the default
|
|
68
|
+
* names its members, because a log line saying "mirror" says nothing
|
|
69
|
+
* @returns {import("./types.js").StorageBackend}
|
|
70
|
+
*/
|
|
71
|
+
export function createMirrorBackend(backends = [], options = {}) {
|
|
72
|
+
const members = backends.filter(Boolean);
|
|
73
|
+
if (members.length < 2) {
|
|
74
|
+
throw new BackendError(
|
|
75
|
+
"INVALID_BACKEND",
|
|
76
|
+
"createMirrorBackend needs at least two backends; one backend is not a mirror",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const require_ = options.require ?? "one";
|
|
81
|
+
if (require_ !== "one" && require_ !== "all") {
|
|
82
|
+
throw new BackendError(
|
|
83
|
+
"INVALID_BACKEND",
|
|
84
|
+
`require must be "one" or "all", not ${require_}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const name =
|
|
89
|
+
options.name ||
|
|
90
|
+
`mirror(${members.map((backend) => backend.name).join("+")})`;
|
|
91
|
+
const pinners = members.filter(
|
|
92
|
+
(backend) => typeof backend.pinCid === "function",
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
/** Run one operation against every member, keeping which of them said what. */
|
|
96
|
+
const acrossAll = async (operation) => {
|
|
97
|
+
const settled = await Promise.allSettled(
|
|
98
|
+
members.map((backend) => operation(backend)),
|
|
99
|
+
);
|
|
100
|
+
const copies = [];
|
|
101
|
+
const failures = [];
|
|
102
|
+
settled.forEach((result, index) => {
|
|
103
|
+
const backend = members[index];
|
|
104
|
+
if (result.status === "fulfilled") {
|
|
105
|
+
copies.push({ backend: backend.name, ...result.value });
|
|
106
|
+
} else {
|
|
107
|
+
failures.push({
|
|
108
|
+
backend: backend.name,
|
|
109
|
+
code: result.reason?.code ?? "FAILED",
|
|
110
|
+
message: result.reason?.message ?? String(result.reason),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return { copies, failures };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One handle for the copies. The id is the CID when every service agreed on
|
|
119
|
+
* one — which is the normal case, since the bytes decide it — and the first
|
|
120
|
+
* success otherwise, so a handle is always usable somewhere.
|
|
121
|
+
*/
|
|
122
|
+
const mergeHandles = ({ copies, failures }, what) => {
|
|
123
|
+
if (copies.length === 0) {
|
|
124
|
+
throw new BackendError(
|
|
125
|
+
failures[0]?.code === "TOO_SMALL" ? "TOO_SMALL" : "FAILED",
|
|
126
|
+
`${name}: no service accepted ${what} — ${failures
|
|
127
|
+
.map((failure) => `${failure.backend}: ${failure.message}`)
|
|
128
|
+
.join("; ")}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (require_ === "all" && failures.length > 0) {
|
|
132
|
+
throw new BackendError(
|
|
133
|
+
"FAILED",
|
|
134
|
+
`${name}: ${failures.length} of ${members.length} services refused ${what} and require is "all" — ${failures
|
|
135
|
+
.map((failure) => `${failure.backend}: ${failure.message}`)
|
|
136
|
+
.join("; ")}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (failures.length > 0) {
|
|
140
|
+
logger.warn(
|
|
141
|
+
`⚠️ ${name}: ${copies.length} of ${members.length} services hold ${what}; ` +
|
|
142
|
+
failures
|
|
143
|
+
.map((failure) => `${failure.backend} refused (${failure.code})`)
|
|
144
|
+
.join(", "),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const cids = new Set(copies.map((copy) => copy.cid).filter(Boolean));
|
|
149
|
+
const agreed = cids.size === 1 ? [...cids][0] : null;
|
|
150
|
+
return {
|
|
151
|
+
id: agreed ?? copies[0].id,
|
|
152
|
+
...(agreed ? { cid: agreed } : {}),
|
|
153
|
+
backend: name,
|
|
154
|
+
...(copies[0].size != null ? { size: copies[0].size } : {}),
|
|
155
|
+
...(copies[0].name ? { name: copies[0].name } : {}),
|
|
156
|
+
copies,
|
|
157
|
+
...(failures.length > 0 ? { failures } : {}),
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** A member's own id for this handle, since only the CID is shared. */
|
|
162
|
+
const idFor = (backend, handle) => {
|
|
163
|
+
const copy =
|
|
164
|
+
typeof handle === "object" && handle?.copies
|
|
165
|
+
? handle.copies.find((entry) => entry.backend === backend.name)
|
|
166
|
+
: null;
|
|
167
|
+
return copy?.id ?? handleId(handle);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const mirror = {
|
|
171
|
+
name,
|
|
172
|
+
/** The members, in read order — a caller that wants one service can reach it. */
|
|
173
|
+
backends: members,
|
|
174
|
+
capabilities: {
|
|
175
|
+
...DEFAULT_CAPABILITIES,
|
|
176
|
+
pinByCid: pinners.length > 0 && anyOne(members, "pinByCid"),
|
|
177
|
+
carImport: everyOne(members, "carImport"),
|
|
178
|
+
preservesInnerCids: everyOne(members, "preservesInnerCids"),
|
|
179
|
+
browserSafeAuth: everyOne(members, "browserSafeAuth"),
|
|
180
|
+
delegation: everyOne(members, "delegation"),
|
|
181
|
+
listing: false,
|
|
182
|
+
deletion: false,
|
|
183
|
+
minBlobSize: largestMinimum(members),
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
putBlob: async (bytes, meta = {}) =>
|
|
187
|
+
mergeHandles(
|
|
188
|
+
await acrossAll((backend) => backend.putBlob(bytes, meta)),
|
|
189
|
+
"the blob",
|
|
190
|
+
),
|
|
191
|
+
|
|
192
|
+
getBlob: async (handle) => {
|
|
193
|
+
const reasons = [];
|
|
194
|
+
for (const backend of members) {
|
|
195
|
+
try {
|
|
196
|
+
return await backend.getBlob(idFor(backend, handle));
|
|
197
|
+
} catch (error) {
|
|
198
|
+
reasons.push(`${backend.name}: ${error?.message ?? error}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
throw new BackendError(
|
|
202
|
+
"NOT_FOUND",
|
|
203
|
+
`${name}: no service returned ${handleId(handle)} — ${reasons.join("; ")}`,
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (mirror.capabilities.pinByCid) {
|
|
209
|
+
/** Only the services that can pin are asked; the others have nothing to say. */
|
|
210
|
+
mirror.pinCid = async (cid, meta = {}) => {
|
|
211
|
+
const settled = await Promise.allSettled(
|
|
212
|
+
pinners.map((backend) => backend.pinCid(cid, meta)),
|
|
213
|
+
);
|
|
214
|
+
const copies = [];
|
|
215
|
+
const failures = [];
|
|
216
|
+
settled.forEach((result, index) => {
|
|
217
|
+
const backend = pinners[index];
|
|
218
|
+
if (result.status === "fulfilled")
|
|
219
|
+
copies.push({ backend: backend.name, ...result.value });
|
|
220
|
+
else
|
|
221
|
+
failures.push({
|
|
222
|
+
backend: backend.name,
|
|
223
|
+
code: result.reason?.code ?? "FAILED",
|
|
224
|
+
message: result.reason?.message ?? String(result.reason),
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
return mergeHandles({ copies, failures }, `the pin for ${cid}`);
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return defineBackend(mirror);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export default createMirrorBackend;
|
package/lib/backends/resolve.js
CHANGED
|
@@ -20,6 +20,9 @@ import { logger } from "../logger.js";
|
|
|
20
20
|
*
|
|
21
21
|
* @param {Object} [config] - call options
|
|
22
22
|
* @param {Object} [config.backend] - any driver implementing the backend contract
|
|
23
|
+
* @param {string|string[]} [config.kind] - a backend by name, or several for a
|
|
24
|
+
* mirror, with the credentials each needs. What a page has, since a reader
|
|
25
|
+
* ticks a service and pastes a key rather than handing over a driver.
|
|
23
26
|
* @param {Object} [config.ucanClient] - a Storacha client authorised over UCAN
|
|
24
27
|
* @param {string} [config.spaceDID]
|
|
25
28
|
* @param {string} [config.storachaKey] - falls back to STORACHA_KEY
|
|
@@ -35,6 +38,13 @@ export async function resolveBackend(config = {}) {
|
|
|
35
38
|
return config.backend;
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
// A name and a key: loaded here so that one decision stays one decision, and
|
|
42
|
+
// lazily so a caller that never names a vendor never bundles one.
|
|
43
|
+
if (config.kind) {
|
|
44
|
+
const { createBackendFromChoice } = await import("./choose.js");
|
|
45
|
+
return createBackendFromChoice(config);
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
const gateways = config.gateways;
|
|
39
49
|
|
|
40
50
|
if (config.ucanClient) {
|
package/lib/backends/storacha.js
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* credentials against a self-hosted w3up deployment can still use it, and it is the
|
|
8
8
|
* reference for what a UCAN-delegated backend looked like when one existed.
|
|
9
9
|
*
|
|
10
|
-
* Retrieval
|
|
11
|
-
* answer 301 to `dweb.link
|
|
12
|
-
*
|
|
10
|
+
* Retrieval defaults to a gateway belonging to nobody in this story: `storacha.link`
|
|
11
|
+
* and `w3s.link` answer 301 to `dweb.link`, which Protocol Labs retired on 2026-09-21
|
|
12
|
+
* along with `ipfs.io`. A caller passes its own list — which is what the in-memory
|
|
13
|
+
* service does.
|
|
13
14
|
*
|
|
14
15
|
* @author @NiKrause
|
|
15
16
|
* @requires ./types.js - the backend contract
|
|
@@ -23,11 +24,15 @@ import * as Proof from "@storacha/client/proof";
|
|
|
23
24
|
import { CID } from "multiformats/cid";
|
|
24
25
|
import { defineBackend, handleId, BackendError } from "./types.js";
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Gateways tried in order by `getBlob()`, unless the caller supplies its own.
|
|
29
|
+
*
|
|
30
|
+
* Storacha's own hosts redirected here, and here was retired on 2026-09-21
|
|
31
|
+
* together with `ipfs.io`. What is left is a gateway belonging to a different
|
|
32
|
+
* service, which will serve a Storacha CID only while somebody still provides
|
|
33
|
+
* those blocks to the network.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_GATEWAYS = Object.freeze(["https://ipfs.aleph.cloud"]);
|
|
31
36
|
|
|
32
37
|
/**
|
|
33
38
|
* Build a Storacha client from key and proof, or adopt one that already exists.
|
package/lib/backup-car.js
CHANGED
|
@@ -33,13 +33,14 @@ const spaceHelpers = () => import("./orbitdb-storacha-bridge.js");
|
|
|
33
33
|
import { unixfs } from "@helia/unixfs";
|
|
34
34
|
import logger from "./logger.js";
|
|
35
35
|
import { readBlockBytes } from "./block-bytes.js";
|
|
36
|
+
import { DEFAULT_GATEWAYS, MAX_BACKOFF_MS } from "./gateway-fetch.js";
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* Default configuration options
|
|
39
40
|
*/
|
|
40
41
|
const DEFAULT_OPTIONS = {
|
|
41
42
|
timeout: 30000,
|
|
42
|
-
gateway: "https://w3s.link
|
|
43
|
+
gateway: "https://ipfs.aleph.cloud", // w3s.link redirects to a gateway retired on 2026-09-21
|
|
43
44
|
verbose: false,
|
|
44
45
|
// Network download options
|
|
45
46
|
useIPFSNetwork: true, // Enable network downloads by default
|
|
@@ -812,12 +813,11 @@ export async function restoreFromSpaceCAR(orbitdb, options = {}) {
|
|
|
812
813
|
|
|
813
814
|
// Fallback to gateway if network failed or disabled
|
|
814
815
|
if (!carBytes) {
|
|
815
|
-
//
|
|
816
|
+
// The configured gateway, then whatever the package still trusts.
|
|
817
|
+
// `storacha.link`, `dweb.link` and `ipfs.io` were all retired on
|
|
818
|
+
// 2026-09-21 and stood here until then.
|
|
816
819
|
const gateways = [
|
|
817
|
-
`${config.gateway}/ipfs`,
|
|
818
|
-
"https://storacha.link/ipfs",
|
|
819
|
-
"https://dweb.link/ipfs",
|
|
820
|
-
"https://ipfs.io/ipfs",
|
|
820
|
+
...new Set([`${config.gateway}/ipfs`, ...DEFAULT_GATEWAYS]),
|
|
821
821
|
];
|
|
822
822
|
|
|
823
823
|
let carResponse;
|
|
@@ -943,6 +943,13 @@ export async function restoreFromSpaceCAR(orbitdb, options = {}) {
|
|
|
943
943
|
}
|
|
944
944
|
}
|
|
945
945
|
|
|
946
|
+
if (waitTime > MAX_BACKOFF_MS) {
|
|
947
|
+
logger.warn(
|
|
948
|
+
` ⚠️ ${gateway} wants ${Math.round(waitTime / 1000)}s — treating it as closed`,
|
|
949
|
+
);
|
|
950
|
+
break; // try the next gateway rather than wait out a retirement
|
|
951
|
+
}
|
|
952
|
+
|
|
946
953
|
logger.warn(
|
|
947
954
|
` Waiting ${Math.round(waitTime / 1000)}s before retry (attempt ${attempts + 1}/${maxAttempts})...`,
|
|
948
955
|
);
|
package/lib/gateway-fetch.js
CHANGED
|
@@ -12,13 +12,39 @@
|
|
|
12
12
|
* @module gateway-fetch
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Tried in order.
|
|
17
|
+
*
|
|
18
|
+
* This used to be four entries and is now one, because on 2026-09-21 Protocol
|
|
19
|
+
* Labs retired `ipfs.io` and `dweb.link` — they answer 429 with an RFC 8594
|
|
20
|
+
* `Sunset` header — and `w3s.link` and `storacha.link` redirect to `dweb.link`.
|
|
21
|
+
* All four were dead at once, which is what a list of gateways run by one
|
|
22
|
+
* organisation is worth.
|
|
23
|
+
*
|
|
24
|
+
* Measured 2026-09-23: `ipfs.aleph.cloud` answers in 0.2 s with CORS, and no
|
|
25
|
+
* other free path gateway found would serve an arbitrary CID —
|
|
26
|
+
* `gateway.pinata.cloud` answers 403 for content it does not hold,
|
|
27
|
+
* `4everland.io` redirects after 15 s, the rest do not answer at all.
|
|
28
|
+
*
|
|
29
|
+
* One entry is not a fallback chain, and pretending otherwise is how this
|
|
30
|
+
* rotted unnoticed. The replacement is not a longer list: it is fetching from
|
|
31
|
+
* the providers that hold the blocks, over libp2p — see issue #112.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_GATEWAYS = ["https://ipfs.aleph.cloud/ipfs"];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Gateways that serve **verifiable** responses only: a single block, or a CAR.
|
|
37
|
+
*
|
|
38
|
+
* `trustless-gateway.link` was still healthy when the path gateways were
|
|
39
|
+
* retired, and it answers with CORS — but it refuses an ordinary path request
|
|
40
|
+
* with a 406, so it is only usable with `accept` set, and only for a CID whose
|
|
41
|
+
* bytes are one block. A UnixFS file split into chunks needs its DAG walked,
|
|
42
|
+
* which this module deliberately cannot do.
|
|
43
|
+
*/
|
|
44
|
+
export const TRUSTLESS_GATEWAYS = ["https://trustless-gateway.link/ipfs"];
|
|
45
|
+
|
|
46
|
+
/** `accept` for a single block from a trustless gateway. */
|
|
47
|
+
export const RAW_BLOCK = "application/vnd.ipld.raw";
|
|
22
48
|
|
|
23
49
|
/**
|
|
24
50
|
* Quiet by default. A restore reports itself through its return value and its
|
|
@@ -30,6 +56,15 @@ export const SILENT = { info() {}, warn() {}, debug() {} };
|
|
|
30
56
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
31
57
|
const MAX_ATTEMPTS_PER_GATEWAY = 3;
|
|
32
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Longest 429 wait worth honouring.
|
|
61
|
+
*
|
|
62
|
+
* A retired gateway asks for 900 s, and three attempts against four of them is
|
|
63
|
+
* most of an afternoon spent waiting for an answer that will not change. Past
|
|
64
|
+
* this, the gateway is not rate-limiting us, it is closed.
|
|
65
|
+
*/
|
|
66
|
+
export const MAX_BACKOFF_MS = 30_000;
|
|
67
|
+
|
|
33
68
|
/**
|
|
34
69
|
* A gateway that cannot serve a CID usually says so in HTML, with a 200.
|
|
35
70
|
*
|
|
@@ -73,9 +108,14 @@ const backoffFor = (response, attempt) => {
|
|
|
73
108
|
* @param {string[]} [options.gateways]
|
|
74
109
|
* @param {number} [options.timeout] per request, in ms
|
|
75
110
|
* @param {AbortSignal} [options.signal]
|
|
111
|
+
* @param {string} [options.accept] sent as `Accept`; `RAW_BLOCK` for a single
|
|
112
|
+
* block from a trustless gateway
|
|
113
|
+
* @param {number} [options.maxBackoff] longest 429 wait to honour, in ms
|
|
114
|
+
* @param {(ms: number) => Promise<void>} [options.sleep] injectable for tests
|
|
115
|
+
* @param {typeof fetch} [options.fetchImpl] injectable for tests
|
|
76
116
|
* @returns {Promise<Uint8Array>}
|
|
77
117
|
*/
|
|
78
|
-
export async function fetchFromGateways(cid, { gateways = DEFAULT_GATEWAYS, timeout = DEFAULT_TIMEOUT_MS, signal = null, log = SILENT } = {}) {
|
|
118
|
+
export async function fetchFromGateways(cid, { gateways = DEFAULT_GATEWAYS, timeout = DEFAULT_TIMEOUT_MS, signal = null, log = SILENT, accept = null, maxBackoff = MAX_BACKOFF_MS, sleep = waitFor, fetchImpl = fetch } = {}) {
|
|
79
119
|
let lastError = null;
|
|
80
120
|
|
|
81
121
|
for (const gateway of gateways) {
|
|
@@ -83,12 +123,27 @@ export async function fetchFromGateways(cid, { gateways = DEFAULT_GATEWAYS, time
|
|
|
83
123
|
for (let attempt = 0; attempt < MAX_ATTEMPTS_PER_GATEWAY; attempt++) {
|
|
84
124
|
const timer = AbortSignal.timeout ? AbortSignal.timeout(timeout) : null;
|
|
85
125
|
try {
|
|
86
|
-
const response = await
|
|
126
|
+
const response = await fetchImpl(url, {
|
|
127
|
+
signal: signal ?? timer ?? undefined,
|
|
128
|
+
...(accept ? { headers: { Accept: accept } } : {}),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// An RFC 8594 Sunset header means the gateway is going away or already
|
|
132
|
+
// has. Retrying is pointless, and so is coming back next time.
|
|
133
|
+
const sunset = response.headers?.get?.("Sunset");
|
|
134
|
+
if (sunset && !response.ok) {
|
|
135
|
+
log.warn(` ⚠️ ${gateway} is retired (Sunset: ${sunset})`);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
87
138
|
|
|
88
139
|
if (response.status === 429 && attempt < MAX_ATTEMPTS_PER_GATEWAY - 1) {
|
|
89
140
|
const wait = backoffFor(response, attempt);
|
|
141
|
+
if (wait > maxBackoff) {
|
|
142
|
+
log.warn(` ⚠️ ${gateway} wants ${Math.round(wait / 1000)}s — treating it as closed`);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
90
145
|
log.warn(` ⚠️ ${gateway} rate-limited; waiting ${Math.round(wait / 1000)}s`);
|
|
91
|
-
await
|
|
146
|
+
await sleep(wait);
|
|
92
147
|
continue;
|
|
93
148
|
}
|
|
94
149
|
if (!response.ok) {
|
|
@@ -17,6 +17,7 @@ import { sha256 } from "multiformats/hashes/sha2";
|
|
|
17
17
|
import { bases } from "multiformats/basics";
|
|
18
18
|
import { EventEmitter } from "events";
|
|
19
19
|
import { createHeliaOrbitDB, cleanupOrbitDBDirectories } from "./utils.js";
|
|
20
|
+
import { DEFAULT_GATEWAYS } from "./gateway-fetch.js";
|
|
20
21
|
import {
|
|
21
22
|
generateBackupPrefix,
|
|
22
23
|
getBackupFilenames,
|
|
@@ -47,7 +48,7 @@ function logHeliaActivity(message) {
|
|
|
47
48
|
const DEFAULT_OPTIONS = {
|
|
48
49
|
// Core configuration
|
|
49
50
|
timeout: 30000, // Timeout in milliseconds
|
|
50
|
-
gateway: "https://
|
|
51
|
+
gateway: "https://ipfs.aleph.cloud", // dweb.link was retired on 2026-09-21
|
|
51
52
|
verbose: false, // Enable verbose debug logging
|
|
52
53
|
|
|
53
54
|
// Network download options
|
|
@@ -823,14 +824,12 @@ export async function downloadBlock(cid, options = {}) {
|
|
|
823
824
|
}
|
|
824
825
|
}
|
|
825
826
|
|
|
826
|
-
// Fallback to gateway download
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
"https://ipfs.io/ipfs",
|
|
833
|
-
];
|
|
827
|
+
// Fallback to gateway download.
|
|
828
|
+
// `dweb.link` and `ipfs.io` stood here until Protocol Labs retired them on
|
|
829
|
+
// 2026-09-21; `storacha.link` and `w3s.link` had already become redirects to
|
|
830
|
+
// the first of those. What is left is one host, which is not a fallback
|
|
831
|
+
// chain — see issue #112 for the path that does not depend on one.
|
|
832
|
+
const gateways = [...new Set([`${config.gateway}/ipfs`, ...DEFAULT_GATEWAYS])];
|
|
834
833
|
|
|
835
834
|
for (const gateway of gateways) {
|
|
836
835
|
try {
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Fetching bytes by CID from peers, when a gateway will not do.
|
|
3
|
+
*
|
|
4
|
+
* `gateway-fetch.js` is the HTTP half of this: ask a host, hope it answers.
|
|
5
|
+
* That stopped being a plan on 2026-09-21, when the public path gateways were
|
|
6
|
+
* retired (#111) and every fallback list in this package turned out to be one
|
|
7
|
+
* live entry and a dead tail. This is the other half — ask the peers that
|
|
8
|
+
* actually hold the blocks.
|
|
9
|
+
*
|
|
10
|
+
* ## Measured before it was written
|
|
11
|
+
*
|
|
12
|
+
* From a real page (Chrome, no build step, Helia loaded as ES modules), on
|
|
13
|
+
* 2026-09-23:
|
|
14
|
+
*
|
|
15
|
+
* ```
|
|
16
|
+
* Pinata dial 729 ms over wss bitswap 263 ms
|
|
17
|
+
* Lighthouse dial 581 ms over webrtc-direct bitswap 323 ms
|
|
18
|
+
* Aleph dial 450 ms over webrtc-direct bitswap 345 ms
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* No credential in any of it, while both paid providers' HTTP gateways want
|
|
22
|
+
* that account's key and Lighthouse's shared gateway answers 402 for content
|
|
23
|
+
* it does not hold. Three services, two transports, about a second each.
|
|
24
|
+
*
|
|
25
|
+
* ## The peer list follows the backends, and nothing else
|
|
26
|
+
*
|
|
27
|
+
* There is no default provider here, and that is deliberate. Whoever stores
|
|
28
|
+
* with a service already shares their CIDs with it, so reading from it adds no
|
|
29
|
+
* new party — but a page that dials Pinata having never uploaded there would
|
|
30
|
+
* be telling Pinata what its reader is looking for, in exchange for nothing.
|
|
31
|
+
* The caller names the providers. {@link PINATA_BITSWAP} is a constant because
|
|
32
|
+
* Pinata publishes it in DNS; everyone else has to be looked up.
|
|
33
|
+
*
|
|
34
|
+
* ## What the caller has to bring
|
|
35
|
+
*
|
|
36
|
+
* A Helia with bitswap. This module has no libp2p of its own — the page that
|
|
37
|
+
* restores already has a node, and a second one would be a second identity on
|
|
38
|
+
* the network. Helia's browser defaults try to listen on `/webrtc` and
|
|
39
|
+
* `/p2p-circuit` and **throw on start** when no transport serves them, so a
|
|
40
|
+
* fetch-only node wants `addresses: { listen: [] }`.
|
|
41
|
+
*
|
|
42
|
+
* @module peer-fetch
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pinata's bitswap endpoint, published in DNS:
|
|
47
|
+
*
|
|
48
|
+
* ```
|
|
49
|
+
* $ dig +short TXT _dnsaddr.bitswap-v3.pinata.cloud
|
|
50
|
+
* "dnsaddr=/dns4/bitswap-v3.pinata.cloud/tcp/443/wss/p2p/Qmdv6yNikmUWUWXufLJLRNkv6Y9sY5cmgeX5RVWA4WNMz4"
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* A DNS name on 443 with a CA certificate is dialable from a page, which is
|
|
54
|
+
* what makes it worth naming here rather than looking up per CID.
|
|
55
|
+
*/
|
|
56
|
+
export const PINATA_BITSWAP =
|
|
57
|
+
"/dns4/bitswap-v3.pinata.cloud/tcp/443/wss/p2p/Qmdv6yNikmUWUWXufLJLRNkv6Y9sY5cmgeX5RVWA4WNMz4";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Aleph's own node, which took some finding.
|
|
61
|
+
*
|
|
62
|
+
* It publishes no `_dnsaddr` and answers `404` to `/api/v0/id`, so looking for
|
|
63
|
+
* it the way Pinata is found says it has no peer — which is what an earlier
|
|
64
|
+
* version of this file claimed. It does: ask a router for the providers of a
|
|
65
|
+
* CID Aleph holds, and the record is `46.255.204.211`, the address
|
|
66
|
+
* `ipfs.aleph.cloud` resolves to, with `webrtc-direct` and `webtransport`
|
|
67
|
+
* among its addresses. A page dialled it in 450 ms and had the block 345 ms
|
|
68
|
+
* later.
|
|
69
|
+
*
|
|
70
|
+
* Two caveats make this a starting point rather than a guarantee:
|
|
71
|
+
*
|
|
72
|
+
* - **The certhash rotates.** A `webrtc-direct` address carries the
|
|
73
|
+
* certificate's hash, and the node generates a new certificate when it
|
|
74
|
+
* restarts. When this address stops working, look the CID up again — the
|
|
75
|
+
* peer id is the stable part.
|
|
76
|
+
* - **Only one router will tell you.** Aleph announces over the DHT rather
|
|
77
|
+
* than IPNI, so `cid.contact` — the one router that answers a browser with
|
|
78
|
+
* CORS — does not know its CIDs, while `delegated-ipfs.dev` does and sends
|
|
79
|
+
* no CORS header for provider lookups. From a page, this constant is the way
|
|
80
|
+
* in; from Node, {@link providersFor} with `delegated-ipfs.dev` is better.
|
|
81
|
+
*/
|
|
82
|
+
export const ALEPH_BITSWAP = Object.freeze([
|
|
83
|
+
"/ip4/46.255.204.211/udp/4001/webrtc-direct/certhash/uEiCbM9yMfnP02vviIL26n8bI0-vU0DGUHx20POBLKGjEmg/p2p/12D3KooWACE5dRw5V9WXuDTcngjE3ZaDSZ4qYJGfuhXZbENnL54y",
|
|
84
|
+
"/ip4/46.255.204.211/udp/4001/quic-v1/webtransport/certhash/uEiDMxOK9kZFH5SW6zcmNpXU4EGgBgvZYqqlJhw1cci50KA/certhash/uEiCeRpuyQWojWx2cP8guNiY3EDfIF25D2k8CMTZd1qtSfw/p2p/12D3KooWACE5dRw5V9WXuDTcngjE3ZaDSZ4qYJGfuhXZbENnL54y",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** Aleph's peer id, which outlives the certificate hashes above. */
|
|
88
|
+
export const ALEPH_PEER_ID = "12D3KooWACE5dRw5V9WXuDTcngjE3ZaDSZ4qYJGfuhXZbENnL54y";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Routers that answer a page.
|
|
92
|
+
*
|
|
93
|
+
* `cid.contact` sends `access-control-allow-origin: *` for provider lookups;
|
|
94
|
+
* `delegated-ipfs.dev` did not, measured 2026-09-23 — it does for `/routing/v1/ipns`,
|
|
95
|
+
* which is why the pointer lookup can use it and this cannot.
|
|
96
|
+
*
|
|
97
|
+
* They also know different things, which matters more than the CORS header:
|
|
98
|
+
* `cid.contact` is an IPNI index and knows what is announced to it — Pinata's
|
|
99
|
+
* CIDs and Lighthouse's were there — while a CID that Aleph holds appeared
|
|
100
|
+
* only at `delegated-ipfs.dev`, because Aleph announces over the DHT. So from
|
|
101
|
+
* a page, a lookup finds the two paid services and not Aleph; that is what
|
|
102
|
+
* {@link ALEPH_BITSWAP} is for.
|
|
103
|
+
*/
|
|
104
|
+
export const DEFAULT_ROUTERS = Object.freeze(["https://cid.contact"]);
|
|
105
|
+
|
|
106
|
+
/** Everything a Node caller can ask, where CORS does not apply. */
|
|
107
|
+
export const ALL_ROUTERS = Object.freeze([
|
|
108
|
+
"https://cid.contact",
|
|
109
|
+
"https://delegated-ipfs.dev",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Transports a browser can dial.
|
|
114
|
+
*
|
|
115
|
+
* `tcp` and plain `quic-v1` are not here: a page has no raw sockets. `tls/ws`
|
|
116
|
+
* needs a name rather than an IP, since the certificate has to match — an
|
|
117
|
+
* AutoTLS `libp2p.direct` address qualifies only while that name resolves,
|
|
118
|
+
* which for one provider it did not on the day this was written.
|
|
119
|
+
*/
|
|
120
|
+
export const BROWSER_TRANSPORTS = Object.freeze([
|
|
121
|
+
"/wss",
|
|
122
|
+
"/tls/ws",
|
|
123
|
+
"/webtransport",
|
|
124
|
+
"/webrtc-direct",
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
128
|
+
|
|
129
|
+
/** Quiet by default, like `gateway-fetch.js`. */
|
|
130
|
+
export const SILENT = { info() {}, warn() {}, debug() {} };
|
|
131
|
+
|
|
132
|
+
/** Does this multiaddr use a transport a page can open? */
|
|
133
|
+
export function isBrowserDialable(addr) {
|
|
134
|
+
if (typeof addr !== "string") return false;
|
|
135
|
+
if (!BROWSER_TRANSPORTS.some((transport) => addr.includes(transport))) return false;
|
|
136
|
+
// A certificate cannot be issued for a bare IP, so ws/wss on one is not
|
|
137
|
+
// dialable however well-formed it looks; webtransport and webrtc-direct
|
|
138
|
+
// carry a certhash instead and are fine.
|
|
139
|
+
const needsName = addr.includes("/wss") || addr.includes("/tls/ws");
|
|
140
|
+
return !needsName || addr.includes("/dns");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Who says they hold this CID, and at what address.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} cid
|
|
147
|
+
* @param {Object} [options]
|
|
148
|
+
* @param {string[]} [options.routers] - delegated routing endpoints
|
|
149
|
+
* @param {boolean} [options.dialableOnly=true] - keep only what a page can open
|
|
150
|
+
* @param {AbortSignal} [options.signal]
|
|
151
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
152
|
+
* @returns {Promise<Array<{ id: string, addrs: string[] }>>}
|
|
153
|
+
*/
|
|
154
|
+
export async function providersFor(cid, {
|
|
155
|
+
routers = DEFAULT_ROUTERS,
|
|
156
|
+
dialableOnly = true,
|
|
157
|
+
signal = null,
|
|
158
|
+
fetchImpl = fetch,
|
|
159
|
+
log = SILENT,
|
|
160
|
+
} = {}) {
|
|
161
|
+
const found = new Map();
|
|
162
|
+
|
|
163
|
+
for (const router of routers) {
|
|
164
|
+
try {
|
|
165
|
+
const response = await fetchImpl(`${router}/routing/v1/providers/${cid}`, {
|
|
166
|
+
headers: { Accept: "application/json" },
|
|
167
|
+
signal,
|
|
168
|
+
});
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
log.debug(` ⚠️ ${router} answered ${response.status}`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const body = await response.text();
|
|
174
|
+
for (const record of parseProviderRecords(body)) {
|
|
175
|
+
const id = record.ID ?? record.id;
|
|
176
|
+
if (!id) continue;
|
|
177
|
+
const addrs = (record.Addrs ?? record.addrs ?? []).filter(
|
|
178
|
+
(addr) => !dialableOnly || isBrowserDialable(addr),
|
|
179
|
+
);
|
|
180
|
+
if (addrs.length === 0) continue;
|
|
181
|
+
const already = found.get(id);
|
|
182
|
+
if (already) {
|
|
183
|
+
for (const addr of addrs) if (!already.addrs.includes(addr)) already.addrs.push(addr);
|
|
184
|
+
} else {
|
|
185
|
+
found.set(id, { id, addrs: [...addrs] });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
log.debug(` ⚠️ ${router} failed: ${error.message}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return [...found.values()];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A routing answer is JSON, or one JSON object per line when the router
|
|
198
|
+
* streams — both shapes are in the spec, and both are in the wild.
|
|
199
|
+
*/
|
|
200
|
+
function parseProviderRecords(body) {
|
|
201
|
+
const records = [];
|
|
202
|
+
const push = (value) => {
|
|
203
|
+
if (!value) return;
|
|
204
|
+
if (Array.isArray(value.Providers)) records.push(...value.Providers);
|
|
205
|
+
else if (Array.isArray(value.providers)) records.push(...value.providers);
|
|
206
|
+
else records.push(value);
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
|
+
push(JSON.parse(body));
|
|
210
|
+
return records;
|
|
211
|
+
} catch {
|
|
212
|
+
// not one document — try it as a stream of them
|
|
213
|
+
}
|
|
214
|
+
for (const line of body.split("\n")) {
|
|
215
|
+
const trimmed = line.trim();
|
|
216
|
+
if (!trimmed) continue;
|
|
217
|
+
try {
|
|
218
|
+
push(JSON.parse(trimmed));
|
|
219
|
+
} catch {
|
|
220
|
+
// a partial line at the end of a stream; nothing to do with it
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return records;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Build a `fetchBytes(cid)` that goes over libp2p.
|
|
228
|
+
*
|
|
229
|
+
* Fits where `restoreFromCID`'s `fetchBytes` goes, so nothing downstream
|
|
230
|
+
* changes: the blocks are verified against their CIDs exactly as before, which
|
|
231
|
+
* is what makes a stranger's bytes as safe as a gateway's.
|
|
232
|
+
*
|
|
233
|
+
* @param {Object} options
|
|
234
|
+
* @param {Object} options.helia - a started Helia **with bitswap**
|
|
235
|
+
* @param {string[] | ((cid: string) => Promise<string[]>)} options.providers -
|
|
236
|
+
* multiaddrs to dial, or a function that finds them for a CID
|
|
237
|
+
* @param {number} [options.timeout]
|
|
238
|
+
* @param {(addr: string) => Promise<unknown>} [options.dial] - defaults to the node's
|
|
239
|
+
* @param {(cid: any, options?: Object) => AsyncIterable<Uint8Array>} [options.cat] -
|
|
240
|
+
* defaults to `@helia/unixfs`, which reads a chunked file as well as a single block
|
|
241
|
+
* @returns {(cid: string, options?: Object) => Promise<Uint8Array>}
|
|
242
|
+
*/
|
|
243
|
+
export function createPeerFetch({
|
|
244
|
+
helia,
|
|
245
|
+
providers = [],
|
|
246
|
+
timeout = DEFAULT_TIMEOUT_MS,
|
|
247
|
+
dial = null,
|
|
248
|
+
cat = null,
|
|
249
|
+
log = SILENT,
|
|
250
|
+
} = {}) {
|
|
251
|
+
if (!helia && (!dial || !cat)) {
|
|
252
|
+
throw new Error("createPeerFetch needs a Helia node, or both dial and cat");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return async function fetchOverPeers(cid, options = {}) {
|
|
256
|
+
const signal = options.signal ?? AbortSignal.timeout(options.timeout ?? timeout);
|
|
257
|
+
const addrs =
|
|
258
|
+
typeof providers === "function" ? await providers(cid) : [...providers];
|
|
259
|
+
|
|
260
|
+
if (addrs.length === 0) {
|
|
261
|
+
throw new Error(`No provider to dial for ${cid}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const dialOne = dial ?? (async (addr) => {
|
|
265
|
+
const { multiaddr } = await import("@multiformats/multiaddr");
|
|
266
|
+
return helia.libp2p.dial(multiaddr(addr), { signal });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
let dialed = 0;
|
|
270
|
+
for (const addr of addrs) {
|
|
271
|
+
try {
|
|
272
|
+
await dialOne(addr);
|
|
273
|
+
dialed += 1;
|
|
274
|
+
log.debug(` ✅ dialled ${addr}`);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
log.debug(` ⚠️ could not dial ${addr}: ${error.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (dialed === 0) {
|
|
280
|
+
throw new Error(`Could not dial any provider for ${cid} (tried ${addrs.length})`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const read = cat ?? (await unixfsCat(helia));
|
|
284
|
+
const parts = [];
|
|
285
|
+
let total = 0;
|
|
286
|
+
for await (const chunk of read(cid, { signal })) {
|
|
287
|
+
parts.push(chunk);
|
|
288
|
+
total += chunk.length;
|
|
289
|
+
}
|
|
290
|
+
const bytes = new Uint8Array(total);
|
|
291
|
+
let at = 0;
|
|
292
|
+
for (const part of parts) {
|
|
293
|
+
bytes.set(part, at);
|
|
294
|
+
at += part.length;
|
|
295
|
+
}
|
|
296
|
+
log.info(` ✅ ${bytes.length} bytes over libp2p from ${dialed} peer(s)`);
|
|
297
|
+
return bytes;
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* `@helia/unixfs` rather than the blockstore: a backup large enough to be
|
|
303
|
+
* chunked is a DAG, and `blockstore.get` would return its root block and call
|
|
304
|
+
* that the file. Imported here so a caller that supplies `cat` never loads it.
|
|
305
|
+
*/
|
|
306
|
+
async function unixfsCat(helia) {
|
|
307
|
+
const [{ unixfs }, { CID }] = await Promise.all([
|
|
308
|
+
import("@helia/unixfs"),
|
|
309
|
+
import("multiformats/cid"),
|
|
310
|
+
]);
|
|
311
|
+
const fs = unixfs(helia);
|
|
312
|
+
return (cid, options) => fs.cat(typeof cid === "string" ? CID.parse(cid) : cid, options);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Try HTTP first, then peers — and say which one delivered.
|
|
317
|
+
*
|
|
318
|
+
* The order is not a preference for HTTP: a warm gateway answered in 0.23 s
|
|
319
|
+
* against 0.86–1.9 s over libp2p, and a phone on a rationed connection should
|
|
320
|
+
* not open a swarm for a file one request would have fetched. But the timeout
|
|
321
|
+
* is short on purpose, because the other measurement from the same day is a
|
|
322
|
+
* gateway taking 29 s for a block a peer served in under one.
|
|
323
|
+
*
|
|
324
|
+
* @param {Object} options
|
|
325
|
+
* @param {(cid: string, options?: Object) => Promise<Uint8Array>} options.viaGateway
|
|
326
|
+
* @param {(cid: string, options?: Object) => Promise<Uint8Array>} options.viaPeers
|
|
327
|
+
* @param {number} [options.gatewayTimeout=3000] - before the peer path starts
|
|
328
|
+
* @param {(path: "gateway"|"peers", info: Object) => void} [options.onPath] -
|
|
329
|
+
* told which path was taken and how long it took, so a page can show it
|
|
330
|
+
* @returns {(cid: string, options?: Object) => Promise<Uint8Array>}
|
|
331
|
+
*/
|
|
332
|
+
export function createGatewayFirstFetch({
|
|
333
|
+
viaGateway,
|
|
334
|
+
viaPeers,
|
|
335
|
+
gatewayTimeout = 3000,
|
|
336
|
+
onPath = () => {},
|
|
337
|
+
now = () => Date.now(),
|
|
338
|
+
} = {}) {
|
|
339
|
+
return async function fetchBytes(cid, options = {}) {
|
|
340
|
+
const started = now();
|
|
341
|
+
try {
|
|
342
|
+
const bytes = await viaGateway(cid, { ...options, timeout: gatewayTimeout });
|
|
343
|
+
onPath("gateway", { cid, ms: now() - started, bytes: bytes.length });
|
|
344
|
+
return bytes;
|
|
345
|
+
} catch (error) {
|
|
346
|
+
const gatewayMs = now() - started;
|
|
347
|
+
const peersStarted = now();
|
|
348
|
+
try {
|
|
349
|
+
const bytes = await viaPeers(cid, options);
|
|
350
|
+
onPath("peers", {
|
|
351
|
+
cid,
|
|
352
|
+
ms: now() - peersStarted,
|
|
353
|
+
bytes: bytes.length,
|
|
354
|
+
after: { path: "gateway", ms: gatewayMs, error: error.message },
|
|
355
|
+
});
|
|
356
|
+
return bytes;
|
|
357
|
+
} catch (peerError) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`Could not fetch ${cid}: the gateway said "${error.message}" and the peers said "${peerError.message}"`,
|
|
360
|
+
{ cause: peerError },
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|
package/lib/ucan-bridge.js
CHANGED
|
@@ -102,7 +102,7 @@ function findCorrectManifest(analysis) {
|
|
|
102
102
|
*/
|
|
103
103
|
const DEFAULT_UCAN_OPTIONS = {
|
|
104
104
|
timeout: 30000,
|
|
105
|
-
gateway: "https://w3s.link
|
|
105
|
+
gateway: "https://ipfs.aleph.cloud", // w3s.link redirects to a gateway retired on 2026-09-21
|
|
106
106
|
batchSize: 10,
|
|
107
107
|
maxConcurrency: 3,
|
|
108
108
|
// UCAN-specific options
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@le-space/orbitdb-storage-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Back up, restore and replicate OrbitDB databases through pluggable storage backends, with hash and identity preservation",
|
|
5
5
|
"main": "lib/orbitdb-storacha-bridge.js",
|
|
6
6
|
"svelte": "dist/components/",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"./dehydrate": "./lib/dehydrate.js",
|
|
17
17
|
"./restore-cid": "./lib/restore-cid.js",
|
|
18
18
|
"./gateway-fetch": "./lib/gateway-fetch.js",
|
|
19
|
+
"./peer-fetch": "./lib/peer-fetch.js",
|
|
19
20
|
"./backends/types": "./lib/backends/types.js",
|
|
20
21
|
"./backends/memory": "./lib/backends/memory.js",
|
|
21
22
|
"./backends/storacha": "./lib/backends/storacha.js",
|
|
@@ -23,6 +24,9 @@
|
|
|
23
24
|
"./backends/aleph-pin": "./lib/backends/aleph-pin.js",
|
|
24
25
|
"./backends/pinata": "./lib/backends/pinata.js",
|
|
25
26
|
"./backends/lighthouse": "./lib/backends/lighthouse.js",
|
|
27
|
+
"./backends/mirror": "./lib/backends/mirror.js",
|
|
28
|
+
"./backends/choose": "./lib/backends/choose.js",
|
|
29
|
+
"./backends/resolve": "./lib/backends/resolve.js",
|
|
26
30
|
"./memory-courier": "./lib/memory-courier.js",
|
|
27
31
|
"./StorachaIntegration.svelte": "./dist/components/StorachaIntegration.svelte",
|
|
28
32
|
"./StorachaAuth.svelte": "./dist/components/StorachaAuth.svelte",
|
|
@@ -83,7 +87,6 @@
|
|
|
83
87
|
"dependencies": {
|
|
84
88
|
"@chainsafe/libp2p-noise": "^17.0.0",
|
|
85
89
|
"@chainsafe/libp2p-yamux": "^8.0.1",
|
|
86
|
-
"@helia/block-brokers": "^5.2.4",
|
|
87
90
|
"@helia/routers": "^5.1.1",
|
|
88
91
|
"@helia/unixfs": "^8.0.7",
|
|
89
92
|
"@ipld/car": "^5.4.1",
|
|
@@ -105,7 +108,7 @@
|
|
|
105
108
|
"cbor-web": "^10.0.11",
|
|
106
109
|
"datastore-level": "^13.0.1",
|
|
107
110
|
"dotenv": "^17.2.1",
|
|
108
|
-
"helia": "^7.1.
|
|
111
|
+
"helia": "^7.1.15",
|
|
109
112
|
"libp2p": "^3.3.6",
|
|
110
113
|
"multiformats": "^14.0.5"
|
|
111
114
|
},
|