@graffiti-garden/implementation-decentralized 0.1.0 → 0.1.2
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/dist/1-services/2-dids.d.ts +1 -0
- package/dist/1-services/2-dids.d.ts.map +1 -1
- package/dist/1-services/3-storage-buckets.d.ts +5 -0
- package/dist/1-services/3-storage-buckets.d.ts.map +1 -1
- package/dist/1-services/4-inboxes.d.ts +2 -1
- package/dist/1-services/4-inboxes.d.ts.map +1 -1
- package/dist/browser/index.js +7 -7
- package/dist/browser/index.js.map +3 -3
- package/dist/cjs/1-services/2-dids-tests.js +53 -0
- package/dist/cjs/1-services/2-dids-tests.js.map +3 -3
- package/dist/cjs/1-services/2-dids.js +14 -4
- package/dist/cjs/1-services/2-dids.js.map +2 -2
- package/dist/cjs/1-services/3-storage-buckets-tests.js +179 -0
- package/dist/cjs/1-services/3-storage-buckets-tests.js.map +3 -3
- package/dist/cjs/1-services/3-storage-buckets.js +91 -0
- package/dist/cjs/1-services/3-storage-buckets.js.map +3 -3
- package/dist/cjs/1-services/4-inboxes-tests.js +20 -0
- package/dist/cjs/1-services/4-inboxes-tests.js.map +3 -3
- package/dist/cjs/1-services/4-inboxes.js +36 -17
- package/dist/cjs/1-services/4-inboxes.js.map +2 -2
- package/dist/cjs/3-protocol/4-graffiti.js +1 -1
- package/dist/cjs/3-protocol/4-graffiti.js.map +1 -1
- package/dist/esm/1-services/2-dids-tests.js +54 -1
- package/dist/esm/1-services/2-dids-tests.js.map +3 -3
- package/dist/esm/1-services/2-dids.js +14 -4
- package/dist/esm/1-services/2-dids.js.map +2 -2
- package/dist/esm/1-services/3-storage-buckets-tests.js +180 -1
- package/dist/esm/1-services/3-storage-buckets-tests.js.map +2 -2
- package/dist/esm/1-services/3-storage-buckets.js +92 -1
- package/dist/esm/1-services/3-storage-buckets.js.map +2 -2
- package/dist/esm/1-services/4-inboxes-tests.js +20 -0
- package/dist/esm/1-services/4-inboxes-tests.js.map +2 -2
- package/dist/esm/1-services/4-inboxes.js +36 -17
- package/dist/esm/1-services/4-inboxes.js.map +2 -2
- package/dist/esm/3-protocol/4-graffiti.js +1 -1
- package/dist/esm/3-protocol/4-graffiti.js.map +1 -1
- package/package.json +1 -1
- package/src/1-services/2-dids-tests.ts +62 -1
- package/src/1-services/2-dids.ts +17 -5
- package/src/1-services/3-storage-buckets-tests.ts +199 -1
- package/src/1-services/3-storage-buckets.ts +129 -1
- package/src/1-services/4-inboxes-tests.ts +25 -0
- package/src/1-services/4-inboxes.ts +47 -19
- package/src/3-protocol/4-graffiti.ts +1 -1
|
@@ -4,10 +4,33 @@ import {
|
|
|
4
4
|
verifyHTTPSEndpoint,
|
|
5
5
|
} from "./utilities";
|
|
6
6
|
import { string, array, object, optional, nullable } from "zod/mini";
|
|
7
|
-
import { decode as dagCborDecode } from "@ipld/dag-cbor";
|
|
7
|
+
import { encode as dagCborEncode, decode as dagCborDecode } from "@ipld/dag-cbor";
|
|
8
|
+
import { GraffitiErrorNotFound, GraffitiErrorTooLarge } from "@graffiti-garden/api";
|
|
9
|
+
|
|
10
|
+
// All batch-capable bucket servers accept these transport limits.
|
|
11
|
+
const BATCH_KEYS = 32; // total number of keys in a batch request
|
|
12
|
+
const BATCH_TOTAL_VALUE_BYTES = 1024 * 1024; // total response limit
|
|
13
|
+
|
|
14
|
+
// This is a heuristic for how long to wait to fill a batch request
|
|
15
|
+
// before sending it. Just enough waiting to capture a few more requests,
|
|
16
|
+
// but not so long that it adds noticeable latency.
|
|
17
|
+
const BATCH_WAIT_MS = 2;
|
|
18
|
+
|
|
19
|
+
function batchWidth(maxBytes: number) {
|
|
20
|
+
return Math.min(BATCH_KEYS, Math.floor(BATCH_TOTAL_VALUE_BYTES / maxBytes));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type PendingGet = {
|
|
24
|
+
key: string;
|
|
25
|
+
resolve: (value: Uint8Array) => void;
|
|
26
|
+
reject: (reason: unknown) => void;
|
|
27
|
+
};
|
|
28
|
+
type BatchQueue = { requests: PendingGet[]; timer?: ReturnType<typeof setTimeout> };
|
|
8
29
|
|
|
9
30
|
export class StorageBuckets {
|
|
10
31
|
getAuthorizationEndpoint = getAuthorizationEndpoint;
|
|
32
|
+
private readonly queues = new Map<string, Map<number, BatchQueue>>();
|
|
33
|
+
private readonly unsupportedBatchEndpoints = new Set<string>();
|
|
11
34
|
|
|
12
35
|
async put(
|
|
13
36
|
storageBucketEndpoint: string,
|
|
@@ -50,6 +73,111 @@ export class StorageBuckets {
|
|
|
50
73
|
maxBytes?: number,
|
|
51
74
|
): Promise<Uint8Array> {
|
|
52
75
|
verifyHTTPSEndpoint(storageBucketEndpoint);
|
|
76
|
+
if (
|
|
77
|
+
maxBytes !== undefined &&
|
|
78
|
+
Number.isSafeInteger(maxBytes) &&
|
|
79
|
+
maxBytes > 0 &&
|
|
80
|
+
batchWidth(maxBytes) >= 2 &&
|
|
81
|
+
!this.unsupportedBatchEndpoints.has(storageBucketEndpoint)
|
|
82
|
+
) {
|
|
83
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
84
|
+
let queuesByMaxBytes = this.queues.get(storageBucketEndpoint);
|
|
85
|
+
if (!queuesByMaxBytes) {
|
|
86
|
+
queuesByMaxBytes = new Map();
|
|
87
|
+
this.queues.set(storageBucketEndpoint, queuesByMaxBytes);
|
|
88
|
+
}
|
|
89
|
+
let queue = queuesByMaxBytes.get(maxBytes);
|
|
90
|
+
if (!queue) {
|
|
91
|
+
queue = { requests: [] };
|
|
92
|
+
queuesByMaxBytes.set(maxBytes, queue);
|
|
93
|
+
}
|
|
94
|
+
queue.requests.push({ key, resolve, reject });
|
|
95
|
+
if (queue.requests.length === batchWidth(maxBytes)) {
|
|
96
|
+
this.flush(storageBucketEndpoint, maxBytes, queue);
|
|
97
|
+
} else if (queue.timer === undefined) {
|
|
98
|
+
queue.timer = setTimeout(
|
|
99
|
+
() => this.flush(storageBucketEndpoint, maxBytes, queue),
|
|
100
|
+
BATCH_WAIT_MS,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return this.getOne(storageBucketEndpoint, key, maxBytes);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private flush(endpoint: string, maxBytes: number, queue: BatchQueue) {
|
|
109
|
+
if (queue.timer !== undefined) clearTimeout(queue.timer);
|
|
110
|
+
const requests = queue.requests;
|
|
111
|
+
const queuesByMaxBytes = this.queues.get(endpoint)!;
|
|
112
|
+
queuesByMaxBytes.delete(maxBytes);
|
|
113
|
+
if (queuesByMaxBytes.size === 0) this.queues.delete(endpoint);
|
|
114
|
+
if (requests.length === 1 || this.unsupportedBatchEndpoints.has(endpoint)) {
|
|
115
|
+
requests.forEach(({ key, resolve, reject }) => {
|
|
116
|
+
void this.getOne(endpoint, key, maxBytes).then(resolve, reject);
|
|
117
|
+
});
|
|
118
|
+
} else {
|
|
119
|
+
void this.getBatch(endpoint, maxBytes, requests);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async getBatch(endpoint: string, maxBytes: number, requests: PendingGet[]) {
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(`${endpoint}/values`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "Content-Type": "application/cbor", Accept: "application/cbor" },
|
|
128
|
+
body: dagCborEncode({
|
|
129
|
+
keys: requests.map(({ key }) => key),
|
|
130
|
+
maxValueBytes: maxBytes,
|
|
131
|
+
}).slice(),
|
|
132
|
+
});
|
|
133
|
+
if (response.status === 404) {
|
|
134
|
+
// An older bucket server has no /values route but still serves GETs.
|
|
135
|
+
this.unsupportedBatchEndpoints.add(endpoint);
|
|
136
|
+
await Promise.all(requests.map(async ({ key, resolve, reject }) => {
|
|
137
|
+
try {
|
|
138
|
+
resolve(await this.getOne(endpoint, key, maxBytes));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
reject(error);
|
|
141
|
+
}
|
|
142
|
+
}));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!response.ok) throw new Error(await response.text());
|
|
146
|
+
const decoded = dagCborDecode(await response.arrayBuffer());
|
|
147
|
+
if (
|
|
148
|
+
!decoded || typeof decoded !== "object" ||
|
|
149
|
+
!("results" in decoded) || !Array.isArray(decoded.results) ||
|
|
150
|
+
decoded.results.length !== requests.length
|
|
151
|
+
) throw new Error("Invalid storage batch response");
|
|
152
|
+
|
|
153
|
+
decoded.results.forEach((result: unknown, index: number) => {
|
|
154
|
+
const { resolve, reject } = requests[index];
|
|
155
|
+
if (!result || typeof result !== "object" || !("status" in result)) {
|
|
156
|
+
reject(new Error("Invalid storage batch result"));
|
|
157
|
+
} else if (
|
|
158
|
+
result.status === 200 && "value" in result &&
|
|
159
|
+
result.value instanceof Uint8Array &&
|
|
160
|
+
result.value.byteLength <= maxBytes
|
|
161
|
+
) {
|
|
162
|
+
resolve(result.value);
|
|
163
|
+
} else if (result.status === 404) {
|
|
164
|
+
reject(new GraffitiErrorNotFound("Value not found"));
|
|
165
|
+
} else if (result.status === 413) {
|
|
166
|
+
reject(new GraffitiErrorTooLarge("Value exceeds maximum byte limit"));
|
|
167
|
+
} else {
|
|
168
|
+
reject(new Error("Invalid storage batch result"));
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
} catch (error) {
|
|
172
|
+
requests.forEach(({ reject }) => reject(error));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async getOne(
|
|
177
|
+
storageBucketEndpoint: string,
|
|
178
|
+
key: string,
|
|
179
|
+
maxBytes?: number,
|
|
180
|
+
): Promise<Uint8Array> {
|
|
53
181
|
const url = `${storageBucketEndpoint}/value/${encodeURIComponent(key)}`;
|
|
54
182
|
|
|
55
183
|
const response = await fetchWithErrorHandling(url);
|
|
@@ -3,6 +3,7 @@ import { Inboxes, LABELED_MESSAGE_LABEL_KEY } from "./4-inboxes";
|
|
|
3
3
|
import { GraffitiErrorUnauthorized } from "./utilities";
|
|
4
4
|
import { randomBytes } from "@noble/hashes/utils.js";
|
|
5
5
|
import type { GraffitiObjectBase } from "@graffiti-garden/api";
|
|
6
|
+
import { encode as dagCborEncode, decode as dagCborDecode } from "@ipld/dag-cbor";
|
|
6
7
|
|
|
7
8
|
export function inboxTests(inboxEndpoint: string, inboxToken: string) {
|
|
8
9
|
describe("Inboxes", async () => {
|
|
@@ -138,6 +139,30 @@ export function inboxTests(inboxEndpoint: string, inboxToken: string) {
|
|
|
138
139
|
});
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
let cursor: string | undefined;
|
|
143
|
+
for (const [page, expectedCount] of [10, 100, 101].entries()) {
|
|
144
|
+
const response = await fetch(
|
|
145
|
+
`${inboxEndpoint}/query${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`,
|
|
146
|
+
{
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: {
|
|
149
|
+
"Content-Type": "application/cbor",
|
|
150
|
+
Authorization: `Bearer ${inboxToken}`,
|
|
151
|
+
},
|
|
152
|
+
body: cursor ? undefined : dagCborEncode({ tags: [tags[1]], schema: {} }).slice(),
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
expect(response.status).toBe(200);
|
|
156
|
+
const pageResult = dagCborDecode(new Uint8Array(await response.arrayBuffer())) as {
|
|
157
|
+
results: unknown[];
|
|
158
|
+
hasMore: boolean;
|
|
159
|
+
cursor: string;
|
|
160
|
+
};
|
|
161
|
+
expect(pageResult.results).toHaveLength(expectedCount);
|
|
162
|
+
expect(pageResult.hasMore).toBe(page < 2);
|
|
163
|
+
cursor = pageResult.cursor;
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
const iterator = inboxes.query(
|
|
142
167
|
inboxEndpoint,
|
|
143
168
|
[randomBytes(), tags[1], randomBytes()],
|
|
@@ -138,6 +138,7 @@ export class Inboxes {
|
|
|
138
138
|
body: Uint8Array<ArrayBuffer> | undefined,
|
|
139
139
|
inboxToken?: string | null,
|
|
140
140
|
cursor?: string,
|
|
141
|
+
signal?: AbortSignal,
|
|
141
142
|
) {
|
|
142
143
|
const response = await fetchWithErrorHandling(
|
|
143
144
|
`${inboxUrl}/${type}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`,
|
|
@@ -152,6 +153,7 @@ export class Inboxes {
|
|
|
152
153
|
: {}),
|
|
153
154
|
},
|
|
154
155
|
body,
|
|
156
|
+
signal,
|
|
155
157
|
},
|
|
156
158
|
);
|
|
157
159
|
const retryAfterHeader = response.headers.get("Retry-After");
|
|
@@ -414,13 +416,11 @@ export class Inboxes {
|
|
|
414
416
|
);
|
|
415
417
|
|
|
416
418
|
// First cache the messages with their labels
|
|
417
|
-
await
|
|
418
|
-
labeledMessages.map((m: LabeledMessageBase) =>
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
),
|
|
423
|
-
),
|
|
419
|
+
await cache.messages.setMany(
|
|
420
|
+
labeledMessages.map((m: LabeledMessageBase) => [
|
|
421
|
+
getMessageCacheKey(inboxUrl, m[LABELED_MESSAGE_ID_KEY]),
|
|
422
|
+
m,
|
|
423
|
+
]),
|
|
424
424
|
);
|
|
425
425
|
// Then store all the messageids
|
|
426
426
|
messageIds = [
|
|
@@ -439,22 +439,38 @@ export class Inboxes {
|
|
|
439
439
|
// Update how many we've seen
|
|
440
440
|
cacheNumSeen += labeledMessages.length;
|
|
441
441
|
|
|
442
|
+
// Fetch the next page while the caller processes the current page.
|
|
443
|
+
const prefetchController = new AbortController();
|
|
444
|
+
// Otherwise get another response (after waiting for rate-limit)
|
|
445
|
+
const nextPage = hasMore
|
|
446
|
+
? waitFor(waitTil)
|
|
447
|
+
.then(() => this.fetchMessageBatch(
|
|
448
|
+
inboxUrl,
|
|
449
|
+
type,
|
|
450
|
+
undefined, // Body is never past the first time
|
|
451
|
+
inboxToken,
|
|
452
|
+
cursor,
|
|
453
|
+
prefetchController.signal,
|
|
454
|
+
))
|
|
455
|
+
// A consumer may stop before awaiting this request.
|
|
456
|
+
.then((value) => ({ value }), (error) => ({ error }))
|
|
457
|
+
: null;
|
|
458
|
+
|
|
442
459
|
// Return the values
|
|
443
|
-
|
|
460
|
+
let consumedPage = false;
|
|
461
|
+
try {
|
|
462
|
+
for (const m of labeledMessages) yield m;
|
|
463
|
+
consumedPage = true;
|
|
464
|
+
} finally {
|
|
465
|
+
if (!consumedPage) prefetchController.abort();
|
|
466
|
+
}
|
|
444
467
|
|
|
445
468
|
if (!hasMore) break;
|
|
446
469
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
type,
|
|
452
|
-
undefined, // Body is never past the first time
|
|
453
|
-
inboxToken,
|
|
454
|
-
cursor,
|
|
455
|
-
);
|
|
456
|
-
response = out.response;
|
|
457
|
-
waitTil = out.waitTil;
|
|
470
|
+
const out = await nextPage!;
|
|
471
|
+
if ("error" in out) throw out.error;
|
|
472
|
+
response = out.value.response;
|
|
473
|
+
waitTil = out.value.waitTil;
|
|
458
474
|
}
|
|
459
475
|
|
|
460
476
|
const outputCursor: infer_<typeof CursorSchema> = {
|
|
@@ -619,6 +635,7 @@ type Cache = {
|
|
|
619
635
|
messages: {
|
|
620
636
|
get(k: string): Promise<LabeledMessageBase | null | undefined>;
|
|
621
637
|
set(k: string, value: LabeledMessageBase | null): Promise<void>;
|
|
638
|
+
setMany(entries: [string, LabeledMessageBase][]): Promise<void>;
|
|
622
639
|
del(k: string): Promise<void>;
|
|
623
640
|
};
|
|
624
641
|
messageIds: {
|
|
@@ -684,6 +701,14 @@ async function createCache(): Promise<Cache> {
|
|
|
684
701
|
set: async (k, v) => {
|
|
685
702
|
await db.put("m", v, k);
|
|
686
703
|
},
|
|
704
|
+
setMany: async (entries) => {
|
|
705
|
+
if (entries.length === 0) return;
|
|
706
|
+
const tx = db.transaction("m", "readwrite");
|
|
707
|
+
await Promise.all([
|
|
708
|
+
...entries.map(([k, v]) => tx.store.put(v, k)),
|
|
709
|
+
tx.done,
|
|
710
|
+
]);
|
|
711
|
+
},
|
|
687
712
|
del: (k) => db.delete("m", k),
|
|
688
713
|
},
|
|
689
714
|
messageIds: {
|
|
@@ -712,6 +737,9 @@ async function createCache(): Promise<Cache> {
|
|
|
712
737
|
messages: {
|
|
713
738
|
get: async (k) => m.get(k),
|
|
714
739
|
set: async (k, v) => void m.set(k, v),
|
|
740
|
+
setMany: async (entries) => {
|
|
741
|
+
for (const [k, v] of entries) m.set(k, v);
|
|
742
|
+
},
|
|
715
743
|
del: async (k) => void m.delete(k),
|
|
716
744
|
},
|
|
717
745
|
messageIds: {
|
|
@@ -133,7 +133,7 @@ export interface GraffitiDecentralizedOptions {
|
|
|
133
133
|
defaultInboxEndpoints?: string[];
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
const CONCURRENCY =
|
|
136
|
+
const CONCURRENCY = 128;
|
|
137
137
|
const LOCAL_STORAGE_REMEMBERED_HANDLE_KEY = "graffiti-login-remembered-handle";
|
|
138
138
|
|
|
139
139
|
export class GraffitiDecentralized implements Graffiti {
|