@syncular/server 0.32.1 → 0.33.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/dist/blob-handlers.js +3 -3
- package/dist/bucket-blob-store.d.ts +77 -0
- package/dist/bucket-blob-store.js +266 -0
- package/dist/bucket-segment-store.d.ts +77 -0
- package/dist/bucket-segment-store.js +445 -0
- package/dist/context.d.ts +7 -0
- package/dist/handler.js +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/object-bucket.d.ts +78 -0
- package/dist/object-bucket.js +18 -0
- package/dist/operations-realtime.js +3 -0
- package/dist/realtime.d.ts +11 -0
- package/dist/realtime.js +25 -58
- package/dist/s3-blob-store.d.ts +9 -64
- package/dist/s3-blob-store.js +25 -359
- package/dist/s3-object-bucket.d.ts +39 -0
- package/dist/s3-object-bucket.js +164 -0
- package/dist/s3-segment-store.d.ts +9 -61
- package/dist/s3-segment-store.js +25 -503
- package/package.json +2 -2
- package/src/blob-handlers.ts +4 -4
- package/src/bucket-blob-store.ts +351 -0
- package/src/bucket-segment-store.ts +582 -0
- package/src/context.ts +10 -0
- package/src/handler.ts +11 -0
- package/src/index.ts +4 -0
- package/src/object-bucket.ts +83 -0
- package/src/operations-realtime.ts +7 -0
- package/src/realtime.ts +29 -58
- package/src/s3-blob-store.ts +25 -466
- package/src/s3-object-bucket.ts +233 -0
- package/src/s3-segment-store.ts +25 -646
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ObjectBucket` over the S3 REST API: hand-rolled SigV4 over `fetch`
|
|
3
|
+
* (sigv4.ts), zero dependencies, path-style requests
|
|
4
|
+
* (`{endpoint}/{bucket}/{key}`) that AWS S3, Cloudflare R2's S3 endpoint, and
|
|
5
|
+
* MinIO all accept.
|
|
6
|
+
*
|
|
7
|
+
* This module owns the transport only. The segment and blob store logic lives
|
|
8
|
+
* in `bucket-segment-store.ts` / `bucket-blob-store.ts` and runs over any
|
|
9
|
+
* `ObjectBucket`, so the same objects are reachable through an R2 binding
|
|
10
|
+
* (`R2SegmentStore` in `@syncular/server-workers`) with no copy between them.
|
|
11
|
+
*/
|
|
12
|
+
import type {
|
|
13
|
+
BucketListPage,
|
|
14
|
+
BucketObject,
|
|
15
|
+
BucketObjectHead,
|
|
16
|
+
BucketWriteOptions,
|
|
17
|
+
ObjectBucket,
|
|
18
|
+
} from './object-bucket';
|
|
19
|
+
import {
|
|
20
|
+
EMPTY_PAYLOAD_SHA256,
|
|
21
|
+
type SigV4Credentials,
|
|
22
|
+
sha256Hex,
|
|
23
|
+
signRequest,
|
|
24
|
+
} from './sigv4';
|
|
25
|
+
|
|
26
|
+
export interface S3ObjectBucketConfig {
|
|
27
|
+
/**
|
|
28
|
+
* Endpoint origin, no bucket, no trailing slash:
|
|
29
|
+
* AWS `https://s3.<region>.amazonaws.com`,
|
|
30
|
+
* R2 `https://<account-id>.r2.cloudflarestorage.com`,
|
|
31
|
+
* MinIO `http://127.0.0.1:9000`.
|
|
32
|
+
*/
|
|
33
|
+
readonly endpoint: string;
|
|
34
|
+
/** AWS region; R2 uses `auto`. */
|
|
35
|
+
readonly region: string;
|
|
36
|
+
readonly bucket: string;
|
|
37
|
+
readonly accessKeyId: string;
|
|
38
|
+
readonly secretAccessKey: string;
|
|
39
|
+
/** Optional STS session token. */
|
|
40
|
+
readonly sessionToken?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const META_HEADER_PREFIX = 'x-amz-meta-';
|
|
44
|
+
|
|
45
|
+
/** User metadata carried as `x-amz-meta-*` headers (lowercased by `Headers`). */
|
|
46
|
+
function metadataOf(headers: Headers): Readonly<Record<string, string>> {
|
|
47
|
+
const metadata: Record<string, string> = {};
|
|
48
|
+
for (const [name, value] of headers) {
|
|
49
|
+
if (name.startsWith(META_HEADER_PREFIX)) {
|
|
50
|
+
metadata[name.slice(META_HEADER_PREFIX.length)] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return metadata;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sizeOf(headers: Headers): number | undefined {
|
|
57
|
+
const value = headers.get('content-length');
|
|
58
|
+
if (value === null) return undefined;
|
|
59
|
+
const size = Number(value);
|
|
60
|
+
return Number.isFinite(size) ? size : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One `<Contents>` entry from a `ListObjectsV2` page. */
|
|
64
|
+
interface ListedObject {
|
|
65
|
+
readonly key: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract `<Key>` values (and the `<NextContinuationToken>`, if the listing
|
|
70
|
+
* is truncated) from a `ListObjectsV2` XML body. A tiny regex reader; the
|
|
71
|
+
* only XML the stores ever parse, and only on the GC path. `ListObjectsV2`
|
|
72
|
+
* returns keys XML-escaped for `& < >`; we unescape those.
|
|
73
|
+
*/
|
|
74
|
+
function parseListObjectsV2(xml: string): {
|
|
75
|
+
objects: ListedObject[];
|
|
76
|
+
nextToken: string | undefined;
|
|
77
|
+
} {
|
|
78
|
+
const objects: ListedObject[] = [];
|
|
79
|
+
for (const m of xml.matchAll(/<Key>([\s\S]*?)<\/Key>/g)) {
|
|
80
|
+
objects.push({ key: unescapeXml(m[1] ?? '') });
|
|
81
|
+
}
|
|
82
|
+
const truncated = /<IsTruncated>\s*true\s*<\/IsTruncated>/i.test(xml);
|
|
83
|
+
const tokenMatch =
|
|
84
|
+
/<NextContinuationToken>([\s\S]*?)<\/NextContinuationToken>/.exec(xml);
|
|
85
|
+
const nextToken =
|
|
86
|
+
truncated && tokenMatch !== null
|
|
87
|
+
? unescapeXml(tokenMatch[1] ?? '')
|
|
88
|
+
: undefined;
|
|
89
|
+
return { objects, nextToken };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function unescapeXml(value: string): string {
|
|
93
|
+
return value
|
|
94
|
+
.replaceAll('<', '<')
|
|
95
|
+
.replaceAll('>', '>')
|
|
96
|
+
.replaceAll('"', '"')
|
|
97
|
+
.replaceAll(''', "'")
|
|
98
|
+
.replaceAll('&', '&');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class S3ObjectBucket implements ObjectBucket {
|
|
102
|
+
readonly #config: S3ObjectBucketConfig;
|
|
103
|
+
readonly #credentials: SigV4Credentials;
|
|
104
|
+
|
|
105
|
+
constructor(config: S3ObjectBucketConfig) {
|
|
106
|
+
this.#config = config;
|
|
107
|
+
this.#credentials = {
|
|
108
|
+
accessKeyId: config.accessKeyId,
|
|
109
|
+
secretAccessKey: config.secretAccessKey,
|
|
110
|
+
...(config.sessionToken !== undefined
|
|
111
|
+
? { sessionToken: config.sessionToken }
|
|
112
|
+
: {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The object URL the presign helpers sign (`{endpoint}/{bucket}/{key}`). */
|
|
117
|
+
urlFor(key: string): URL {
|
|
118
|
+
const endpoint = this.#config.endpoint.replace(/\/+$/, '');
|
|
119
|
+
return new URL(`${endpoint}/${this.#config.bucket}/${key}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async #request(
|
|
123
|
+
method: 'GET' | 'HEAD' | 'PUT' | 'DELETE',
|
|
124
|
+
key: string,
|
|
125
|
+
options?: {
|
|
126
|
+
readonly body?: Uint8Array;
|
|
127
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
128
|
+
readonly query?: Iterable<readonly [string, string]>;
|
|
129
|
+
/** Return a `412` response instead of throwing (conditional writes). */
|
|
130
|
+
readonly allow412?: boolean;
|
|
131
|
+
},
|
|
132
|
+
): Promise<Response | undefined> {
|
|
133
|
+
const url = this.urlFor(key);
|
|
134
|
+
if (options?.query !== undefined) {
|
|
135
|
+
for (const [name, value] of options.query) {
|
|
136
|
+
url.searchParams.set(name, value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const body = options?.body;
|
|
140
|
+
const headers = await signRequest({
|
|
141
|
+
method,
|
|
142
|
+
url,
|
|
143
|
+
region: this.#config.region,
|
|
144
|
+
credentials: this.#credentials,
|
|
145
|
+
nowMs: Date.now(),
|
|
146
|
+
payloadHash:
|
|
147
|
+
body === undefined ? EMPTY_PAYLOAD_SHA256 : await sha256Hex(body),
|
|
148
|
+
...(options?.headers !== undefined ? { headers: options.headers } : {}),
|
|
149
|
+
});
|
|
150
|
+
const response = await fetch(url, {
|
|
151
|
+
method,
|
|
152
|
+
headers,
|
|
153
|
+
...(body !== undefined ? { body: body as BodyInit } : {}),
|
|
154
|
+
});
|
|
155
|
+
if (response.status === 404) {
|
|
156
|
+
await response.arrayBuffer();
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
if (options?.allow412 === true && response.status === 412) return response;
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
const detail = (await response.text()).slice(0, 300);
|
|
162
|
+
throw new Error(
|
|
163
|
+
`S3ObjectBucket: ${method} ${key} failed with ${response.status}: ${detail}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return response;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async get(key: string): Promise<BucketObject | undefined> {
|
|
170
|
+
const response = await this.#request('GET', key);
|
|
171
|
+
if (response === undefined) return undefined;
|
|
172
|
+
return {
|
|
173
|
+
body: response.body,
|
|
174
|
+
etag: response.headers.get('etag') ?? undefined,
|
|
175
|
+
size: sizeOf(response.headers),
|
|
176
|
+
metadata: metadataOf(response.headers),
|
|
177
|
+
arrayBuffer: () => response.arrayBuffer(),
|
|
178
|
+
text: () => response.text(),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async head(key: string): Promise<BucketObjectHead | undefined> {
|
|
183
|
+
const response = await this.#request('HEAD', key);
|
|
184
|
+
if (response === undefined) return undefined;
|
|
185
|
+
await response.arrayBuffer();
|
|
186
|
+
return {
|
|
187
|
+
etag: response.headers.get('etag') ?? undefined,
|
|
188
|
+
size: sizeOf(response.headers),
|
|
189
|
+
metadata: metadataOf(response.headers),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async put(key: string, options: BucketWriteOptions): Promise<boolean> {
|
|
194
|
+
const headers: Record<string, string> = {
|
|
195
|
+
'content-type': options.contentType,
|
|
196
|
+
};
|
|
197
|
+
if (options.createOnly === true) headers['if-none-match'] = '*';
|
|
198
|
+
if (options.ifMatch !== undefined) headers['if-match'] = options.ifMatch;
|
|
199
|
+
for (const [name, value] of Object.entries(options.metadata ?? {})) {
|
|
200
|
+
headers[`${META_HEADER_PREFIX}${name}`] = value;
|
|
201
|
+
}
|
|
202
|
+
const response = await this.#request('PUT', key, {
|
|
203
|
+
body: options.body,
|
|
204
|
+
headers,
|
|
205
|
+
allow412: true,
|
|
206
|
+
});
|
|
207
|
+
if (response === undefined) {
|
|
208
|
+
throw new Error(`S3ObjectBucket: PUT ${key} failed with 404`);
|
|
209
|
+
}
|
|
210
|
+
await response.arrayBuffer();
|
|
211
|
+
// A failed `If-Match` / `If-None-Match: *` is a `412`: the caller's CAS
|
|
212
|
+
// read superseded, so it re-reads instead of overwriting.
|
|
213
|
+
return response.status !== 412;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async delete(key: string): Promise<void> {
|
|
217
|
+
const response = await this.#request('DELETE', key);
|
|
218
|
+
await response?.arrayBuffer();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async list(prefix: string, cursor?: string): Promise<BucketListPage> {
|
|
222
|
+
const query: Array<readonly [string, string]> = [
|
|
223
|
+
['list-type', '2'],
|
|
224
|
+
['prefix', prefix],
|
|
225
|
+
];
|
|
226
|
+
if (cursor !== undefined) query.push(['continuation-token', cursor]);
|
|
227
|
+
// ListObjectsV2 is a bucket-level GET (`GET /{bucket}?list-type=2`).
|
|
228
|
+
const response = await this.#request('GET', '', { query });
|
|
229
|
+
if (response === undefined) return { keys: [], cursor: undefined };
|
|
230
|
+
const { objects, nextToken } = parseListObjectsV2(await response.text());
|
|
231
|
+
return { keys: objects.map((object) => object.key), cursor: nextToken };
|
|
232
|
+
}
|
|
233
|
+
}
|