@parity/product-sdk-bulletin 0.1.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/src/errors.ts ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Base class for all Bulletin Chain errors.
3
+ *
4
+ * Use `instanceof BulletinError` to catch any bulletin-related error.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * try {
9
+ * await bulletin.upload(data);
10
+ * } catch (e) {
11
+ * if (e instanceof BulletinError) {
12
+ * console.error("Bulletin operation failed:", e.message);
13
+ * }
14
+ * }
15
+ * ```
16
+ */
17
+ export class BulletinError extends Error {
18
+ constructor(message: string, options?: ErrorOptions) {
19
+ super(message, options);
20
+ this.name = "BulletinError";
21
+ }
22
+ }
23
+
24
+ /**
25
+ * The host preimage API is unavailable.
26
+ *
27
+ * Thrown when bulletin operations require the host container but it's not available.
28
+ * This typically means the SDK is running outside of Polkadot Browser/Desktop.
29
+ */
30
+ export class BulletinHostUnavailableError extends BulletinError {
31
+ constructor(operation: "upload" | "query") {
32
+ super(
33
+ `Host preimage API unavailable for ${operation}. Ensure you are running inside a host container (Polkadot Browser / Desktop).`,
34
+ );
35
+ this.name = "BulletinHostUnavailableError";
36
+ }
37
+ }
38
+
39
+ /**
40
+ * The host preimage lookup timed out.
41
+ *
42
+ * The host was unable to retrieve the requested content within the timeout period.
43
+ * The content may still become available later.
44
+ */
45
+ export class BulletinLookupTimeoutError extends BulletinError {
46
+ /** The CID that was being looked up. */
47
+ readonly cid: string;
48
+ /** The timeout duration in milliseconds. */
49
+ readonly timeoutMs: number;
50
+
51
+ constructor(cid: string, timeoutMs: number) {
52
+ super(`Host preimage lookup timed out after ${timeoutMs}ms for CID: ${cid}`);
53
+ this.name = "BulletinLookupTimeoutError";
54
+ this.cid = cid;
55
+ this.timeoutMs = timeoutMs;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The host interrupted the preimage lookup.
61
+ *
62
+ * The host terminated the lookup subscription, typically after repeated failures
63
+ * or when the host determines the content is unavailable.
64
+ */
65
+ export class BulletinLookupInterruptedError extends BulletinError {
66
+ /** The CID that was being looked up. */
67
+ readonly cid: string;
68
+
69
+ constructor(cid: string) {
70
+ super(`Host preimage lookup was interrupted for CID: ${cid}`);
71
+ this.name = "BulletinLookupInterruptedError";
72
+ this.cid = cid;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Failed to check authorization status for an account.
78
+ *
79
+ * Wraps RPC or query errors that occur when checking if an account
80
+ * is authorized to store data on the Bulletin Chain.
81
+ */
82
+ export class BulletinAuthorizationError extends BulletinError {
83
+ /** The address that was being checked. */
84
+ readonly address: string;
85
+
86
+ constructor(address: string, cause?: unknown) {
87
+ super(`Failed to check authorization for ${address}`, { cause });
88
+ this.name = "BulletinAuthorizationError";
89
+ this.address = address;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The IPFS gateway for the specified environment is not available.
95
+ *
96
+ * Thrown when attempting to use gateway features for a network that
97
+ * doesn't have a live bulletin gateway yet.
98
+ */
99
+ export class BulletinGatewayUnavailableError extends BulletinError {
100
+ /** The environment that was requested. */
101
+ readonly environment: string;
102
+
103
+ constructor(environment: string) {
104
+ super(`Bulletin gateway for "${environment}" is not yet available`);
105
+ this.name = "BulletinGatewayUnavailableError";
106
+ this.environment = environment;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * An IPFS gateway request failed.
112
+ *
113
+ * Thrown when a fetch to the IPFS gateway returns a non-OK response.
114
+ */
115
+ export class BulletinGatewayFetchError extends BulletinError {
116
+ /** The CID that was being fetched. */
117
+ readonly cid: string;
118
+ /** The HTTP status code returned by the gateway. */
119
+ readonly status: number;
120
+ /** The HTTP status text returned by the gateway. */
121
+ readonly statusText: string;
122
+
123
+ constructor(cid: string, status: number, statusText: string) {
124
+ super(`Gateway fetch failed for ${cid}: ${status} ${statusText}`);
125
+ this.name = "BulletinGatewayFetchError";
126
+ this.cid = cid;
127
+ this.status = status;
128
+ this.statusText = statusText;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Invalid CID format or version.
134
+ *
135
+ * Thrown when a CID string cannot be parsed or has an unexpected version/codec.
136
+ */
137
+ export class BulletinCidError extends BulletinError {
138
+ /** The invalid CID string, if available. */
139
+ readonly cid?: string;
140
+
141
+ constructor(message: string, cid?: string) {
142
+ super(message);
143
+ this.name = "BulletinCidError";
144
+ this.cid = cid;
145
+ }
146
+ }
147
+
148
+ if (import.meta.vitest) {
149
+ const { describe, test, expect } = import.meta.vitest;
150
+
151
+ describe("BulletinError hierarchy", () => {
152
+ test("BulletinError is instanceof Error", () => {
153
+ const err = new BulletinError("test");
154
+ expect(err).toBeInstanceOf(Error);
155
+ expect(err).toBeInstanceOf(BulletinError);
156
+ expect(err.name).toBe("BulletinError");
157
+ });
158
+
159
+ test("BulletinHostUnavailableError", () => {
160
+ const err = new BulletinHostUnavailableError("upload");
161
+ expect(err).toBeInstanceOf(BulletinError);
162
+ expect(err.name).toBe("BulletinHostUnavailableError");
163
+ expect(err.message).toContain("upload");
164
+ expect(err.message).toContain("Host preimage API unavailable");
165
+ });
166
+
167
+ test("BulletinLookupTimeoutError", () => {
168
+ const err = new BulletinLookupTimeoutError("bafyabc123", 30000);
169
+ expect(err).toBeInstanceOf(BulletinError);
170
+ expect(err.name).toBe("BulletinLookupTimeoutError");
171
+ expect(err.cid).toBe("bafyabc123");
172
+ expect(err.timeoutMs).toBe(30000);
173
+ expect(err.message).toContain("30000ms");
174
+ expect(err.message).toContain("bafyabc123");
175
+ });
176
+
177
+ test("BulletinLookupInterruptedError", () => {
178
+ const err = new BulletinLookupInterruptedError("bafyabc123");
179
+ expect(err).toBeInstanceOf(BulletinError);
180
+ expect(err.name).toBe("BulletinLookupInterruptedError");
181
+ expect(err.cid).toBe("bafyabc123");
182
+ expect(err.message).toContain("interrupted");
183
+ });
184
+
185
+ test("BulletinAuthorizationError with cause", () => {
186
+ const cause = new Error("RPC timeout");
187
+ const err = new BulletinAuthorizationError("5GrwvaEF...", cause);
188
+ expect(err).toBeInstanceOf(BulletinError);
189
+ expect(err.name).toBe("BulletinAuthorizationError");
190
+ expect(err.address).toBe("5GrwvaEF...");
191
+ expect(err.cause).toBe(cause);
192
+ });
193
+
194
+ test("BulletinGatewayUnavailableError", () => {
195
+ const err = new BulletinGatewayUnavailableError("polkadot");
196
+ expect(err).toBeInstanceOf(BulletinError);
197
+ expect(err.name).toBe("BulletinGatewayUnavailableError");
198
+ expect(err.environment).toBe("polkadot");
199
+ expect(err.message).toContain("polkadot");
200
+ });
201
+
202
+ test("BulletinGatewayFetchError", () => {
203
+ const err = new BulletinGatewayFetchError("bafyabc", 404, "Not Found");
204
+ expect(err).toBeInstanceOf(BulletinError);
205
+ expect(err.name).toBe("BulletinGatewayFetchError");
206
+ expect(err.cid).toBe("bafyabc");
207
+ expect(err.status).toBe(404);
208
+ expect(err.statusText).toBe("Not Found");
209
+ expect(err.message).toContain("404");
210
+ });
211
+
212
+ test("BulletinCidError", () => {
213
+ const err = new BulletinCidError("Expected CIDv1, got CIDv0", "Qmabc");
214
+ expect(err).toBeInstanceOf(BulletinError);
215
+ expect(err.name).toBe("BulletinCidError");
216
+ expect(err.cid).toBe("Qmabc");
217
+ });
218
+
219
+ test("all errors can be caught with BulletinError", () => {
220
+ const errors = [
221
+ new BulletinHostUnavailableError("query"),
222
+ new BulletinLookupTimeoutError("cid", 1000),
223
+ new BulletinLookupInterruptedError("cid"),
224
+ new BulletinAuthorizationError("addr"),
225
+ new BulletinGatewayUnavailableError("env"),
226
+ new BulletinGatewayFetchError("cid", 500, "Error"),
227
+ new BulletinCidError("bad cid"),
228
+ ];
229
+
230
+ for (const err of errors) {
231
+ expect(err).toBeInstanceOf(BulletinError);
232
+ }
233
+ });
234
+ });
235
+ }
package/src/gateway.ts ADDED
@@ -0,0 +1,209 @@
1
+ import { BulletinGatewayFetchError, BulletinGatewayUnavailableError } from "./errors.js";
2
+ import type { Environment, FetchOptions } from "./types.js";
3
+
4
+ /** Add entries here as bulletin gateways go live on each network. */
5
+ const GATEWAYS: Partial<Record<Environment, string>> = {
6
+ paseo: "https://paseo-ipfs.polkadot.io/ipfs/",
7
+ };
8
+
9
+ const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
10
+
11
+ /**
12
+ * Get the IPFS gateway URL for an environment.
13
+ * @throws {BulletinGatewayUnavailableError} If the network doesn't have a live gateway yet.
14
+ */
15
+ export function getGateway(env: Environment): string {
16
+ const gw = GATEWAYS[env];
17
+ if (!gw) {
18
+ throw new BulletinGatewayUnavailableError(env);
19
+ }
20
+ return gw;
21
+ }
22
+
23
+ /** Build the full gateway URL for a CID. */
24
+ export function gatewayUrl(cid: string, gateway: string): string {
25
+ return `${gateway}${cid}`;
26
+ }
27
+
28
+ /** Check if a CID exists on the gateway (HEAD request). Returns false on any error or timeout. */
29
+ export async function cidExists(
30
+ cid: string,
31
+ gateway: string,
32
+ options?: FetchOptions,
33
+ ): Promise<boolean> {
34
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
37
+ try {
38
+ const response = await fetch(gatewayUrl(cid, gateway), {
39
+ method: "HEAD",
40
+ signal: controller.signal,
41
+ });
42
+ return response.ok;
43
+ } catch {
44
+ return false;
45
+ } finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Fetch raw bytes from the gateway.
52
+ * @throws {BulletinGatewayFetchError} If the gateway returns a non-OK response.
53
+ */
54
+ export async function fetchBytes(
55
+ cid: string,
56
+ gateway: string,
57
+ options?: FetchOptions,
58
+ ): Promise<Uint8Array> {
59
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
60
+ const controller = new AbortController();
61
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
62
+
63
+ try {
64
+ const response = await fetch(gatewayUrl(cid, gateway), { signal: controller.signal });
65
+ if (!response.ok) {
66
+ throw new BulletinGatewayFetchError(cid, response.status, response.statusText);
67
+ }
68
+ return new Uint8Array(await response.arrayBuffer());
69
+ } finally {
70
+ clearTimeout(timer);
71
+ }
72
+ }
73
+
74
+ /** Fetch and parse JSON from the gateway. */
75
+ export async function fetchJson<T>(
76
+ cid: string,
77
+ gateway: string,
78
+ options?: FetchOptions,
79
+ ): Promise<T> {
80
+ const bytes = await fetchBytes(cid, gateway, options);
81
+ return JSON.parse(new TextDecoder().decode(bytes)) as T;
82
+ }
83
+
84
+ if (import.meta.vitest) {
85
+ const { describe, test, expect, vi, afterEach } = import.meta.vitest;
86
+
87
+ afterEach(() => {
88
+ vi.restoreAllMocks();
89
+ });
90
+
91
+ describe("getGateway", () => {
92
+ test("returns known URL for paseo", () => {
93
+ const gw = getGateway("paseo");
94
+ expect(gw).toMatch(/^https:\/\//);
95
+ expect(gw).toMatch(/\/ipfs\/$/);
96
+ });
97
+
98
+ test("throws BulletinGatewayUnavailableError for environments without a live gateway", () => {
99
+ expect(() => getGateway("polkadot")).toThrow(BulletinGatewayUnavailableError);
100
+ expect(() => getGateway("kusama")).toThrow(BulletinGatewayUnavailableError);
101
+ });
102
+ });
103
+
104
+ describe("gatewayUrl", () => {
105
+ test("concatenates gateway and CID", () => {
106
+ expect(gatewayUrl("bafyabc", "https://gw.example/ipfs/")).toBe(
107
+ "https://gw.example/ipfs/bafyabc",
108
+ );
109
+ });
110
+ });
111
+
112
+ describe("cidExists", () => {
113
+ test("returns true for 200 response", async () => {
114
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
115
+ expect(await cidExists("bafyabc", "https://gw/ipfs/")).toBe(true);
116
+ });
117
+
118
+ test("returns false for 404 response", async () => {
119
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 }));
120
+ expect(await cidExists("bafyabc", "https://gw/ipfs/")).toBe(false);
121
+ });
122
+
123
+ test("returns false on network error", async () => {
124
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")));
125
+ expect(await cidExists("bafyabc", "https://gw/ipfs/")).toBe(false);
126
+ });
127
+
128
+ test("returns false on timeout", async () => {
129
+ vi.stubGlobal(
130
+ "fetch",
131
+ vi.fn().mockImplementation(
132
+ (_url: string, init: { signal: AbortSignal }) =>
133
+ new Promise((_resolve, reject) => {
134
+ init.signal.addEventListener("abort", () =>
135
+ reject(new DOMException("aborted", "AbortError")),
136
+ );
137
+ }),
138
+ ),
139
+ );
140
+ expect(await cidExists("bafyabc", "https://gw/ipfs/", { timeoutMs: 10 })).toBe(false);
141
+ });
142
+ });
143
+
144
+ describe("fetchBytes", () => {
145
+ test("returns bytes from response", async () => {
146
+ const payload = new Uint8Array([1, 2, 3]);
147
+ vi.stubGlobal(
148
+ "fetch",
149
+ vi.fn().mockResolvedValue({
150
+ ok: true,
151
+ arrayBuffer: () => Promise.resolve(payload.buffer),
152
+ }),
153
+ );
154
+ const result = await fetchBytes("bafyabc", "https://gw/ipfs/");
155
+ expect(result).toEqual(payload);
156
+ });
157
+
158
+ test("throws BulletinGatewayFetchError on non-ok response", async () => {
159
+ vi.stubGlobal(
160
+ "fetch",
161
+ vi.fn().mockResolvedValue({
162
+ ok: false,
163
+ status: 500,
164
+ statusText: "Internal Server Error",
165
+ }),
166
+ );
167
+ const err = await fetchBytes("bafyabc", "https://gw/ipfs/").catch((e) => e);
168
+ expect(err).toBeInstanceOf(BulletinGatewayFetchError);
169
+ expect(err.cid).toBe("bafyabc");
170
+ expect(err.status).toBe(500);
171
+ });
172
+
173
+ test("throws on timeout", async () => {
174
+ vi.stubGlobal(
175
+ "fetch",
176
+ vi.fn().mockImplementation(
177
+ (_url: string, init: { signal: AbortSignal }) =>
178
+ new Promise((_resolve, reject) => {
179
+ init.signal.addEventListener("abort", () =>
180
+ reject(new DOMException("aborted", "AbortError")),
181
+ );
182
+ }),
183
+ ),
184
+ );
185
+ await expect(
186
+ fetchBytes("bafyabc", "https://gw/ipfs/", { timeoutMs: 10 }),
187
+ ).rejects.toThrow();
188
+ });
189
+ });
190
+
191
+ describe("fetchJson", () => {
192
+ test("parses JSON from response", async () => {
193
+ const obj = { name: "test", value: 42 };
194
+ const bytes = new TextEncoder().encode(JSON.stringify(obj));
195
+ vi.stubGlobal(
196
+ "fetch",
197
+ vi.fn().mockResolvedValue({
198
+ ok: true,
199
+ arrayBuffer: () => Promise.resolve(bytes.buffer),
200
+ }),
201
+ );
202
+ const result = await fetchJson<{ name: string; value: number }>(
203
+ "bafyabc",
204
+ "https://gw/ipfs/",
205
+ );
206
+ expect(result).toEqual(obj);
207
+ });
208
+ });
209
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ export { BulletinClient } from "./client.js";
2
+ export { checkAuthorization } from "./authorization.js";
3
+ export {
4
+ computeCid,
5
+ cidToPreimageKey,
6
+ hashToCid,
7
+ HashAlgorithm,
8
+ CidCodec,
9
+ } from "./cid.js";
10
+ export {
11
+ BulletinError,
12
+ BulletinHostUnavailableError,
13
+ BulletinLookupTimeoutError,
14
+ BulletinLookupInterruptedError,
15
+ BulletinAuthorizationError,
16
+ BulletinGatewayUnavailableError,
17
+ BulletinGatewayFetchError,
18
+ BulletinCidError,
19
+ } from "./errors.js";
20
+ export { getGateway, gatewayUrl, cidExists, fetchBytes, fetchJson } from "./gateway.js";
21
+ export { resolveQueryStrategy } from "./resolve-query.js";
22
+ export { queryBytes, queryJson } from "./query.js";
23
+ export { resolveUploadStrategy } from "./resolve-signer.js";
24
+ export { upload, batchUpload } from "./upload.js";
25
+ export type {
26
+ AuthorizationStatus,
27
+ BulletinApi,
28
+ Environment,
29
+ UploadOptions,
30
+ UploadResult,
31
+ BatchUploadItem,
32
+ BatchUploadResult,
33
+ BatchUploadOptions,
34
+ FetchOptions,
35
+ QueryOptions,
36
+ } from "./types.js";
37
+ export type { UploadStrategy } from "./resolve-signer.js";
38
+ export type { QueryStrategy } from "./resolve-query.js";
package/src/query.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { createLogger } from "@parity/product-sdk-logger";
2
+
3
+ import { resolveQueryStrategy, type QueryStrategy } from "./resolve-query.js";
4
+ import type { QueryOptions } from "./types.js";
5
+
6
+ const log = createLogger("bulletin");
7
+
8
+ /**
9
+ * Fetch raw bytes for a CID using the host preimage lookup.
10
+ *
11
+ * Uses local cache + managed IPFS polling via the host container.
12
+ *
13
+ * @param cid - CIDv1 string to fetch.
14
+ * @param options - Query options (lookupTimeoutMs for host).
15
+ * @returns Raw bytes of the content.
16
+ * @throws {Error} If the host preimage API is unavailable.
17
+ */
18
+ export async function queryBytes(cid: string, options?: QueryOptions): Promise<Uint8Array> {
19
+ const strategy = await resolveQueryStrategy();
20
+ return executeQuery(strategy, cid, options);
21
+ }
22
+
23
+ /**
24
+ * Fetch and parse JSON for a CID, auto-resolving the query path.
25
+ *
26
+ * Delegates to {@link queryBytes} and parses the result as JSON.
27
+ *
28
+ * @param cid - CIDv1 string to fetch.
29
+ * @param options - Query options.
30
+ * @returns Parsed JSON value.
31
+ * @throws {Error} If the host preimage API is unavailable.
32
+ */
33
+ export async function queryJson<T>(cid: string, options?: QueryOptions): Promise<T> {
34
+ const bytes = await queryBytes(cid, options);
35
+ return JSON.parse(new TextDecoder().decode(bytes)) as T;
36
+ }
37
+
38
+ /**
39
+ * Execute a query using a pre-resolved strategy.
40
+ *
41
+ * Exposed so that {@link BulletinClient} can resolve the strategy once and
42
+ * reuse it across multiple calls without re-detecting the environment.
43
+ *
44
+ * @param strategy - Pre-resolved query strategy.
45
+ * @param cid - CIDv1 string to fetch.
46
+ * @param options - Query options.
47
+ * @returns Raw bytes of the content.
48
+ */
49
+ export async function executeQuery(
50
+ strategy: QueryStrategy,
51
+ cid: string,
52
+ options?: QueryOptions,
53
+ ): Promise<Uint8Array> {
54
+ log.info("querying via host preimage lookup", { cid });
55
+ return strategy.lookup(cid, options?.lookupTimeoutMs);
56
+ }
57
+
58
+ if (import.meta.vitest) {
59
+ const { describe, test, expect, vi } = import.meta.vitest;
60
+
61
+ // Note: queryBytes and queryJson tests require e2e testing as they
62
+ // depend on the host container environment for strategy resolution.
63
+
64
+ describe("executeQuery", () => {
65
+ const testData = new Uint8Array([1, 2, 3]);
66
+
67
+ test("executes host-lookup strategy", async () => {
68
+ const lookup = vi.fn().mockResolvedValue(testData);
69
+ const strategy: QueryStrategy = { kind: "host-lookup", lookup };
70
+ const result = await executeQuery(strategy, "bafytest");
71
+ expect(result).toBe(testData);
72
+ expect(lookup).toHaveBeenCalledWith("bafytest", undefined);
73
+ });
74
+
75
+ test("passes lookupTimeoutMs to host-lookup", async () => {
76
+ const lookup = vi.fn().mockResolvedValue(testData);
77
+ const strategy: QueryStrategy = { kind: "host-lookup", lookup };
78
+ await executeQuery(strategy, "bafytest", { lookupTimeoutMs: 5000 });
79
+ expect(lookup).toHaveBeenCalledWith("bafytest", 5000);
80
+ });
81
+ });
82
+ }