@dmgnr/dcdn 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/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @dmgnr/dcdn
2
+
3
+ Small, conservative TypeScript client for DCDN. It works with Bun and modern Node `fetch`, and keeps canonical bearer APIs separate from the S3-shaped Basic-auth API.
4
+
5
+ ```ts
6
+ import { DcdnClient, bytesRange } from '@dmgnr/dcdn';
7
+
8
+ const dcdn = DcdnClient({ baseUrl: 'https://dcdn.example', token: process.env.DCDN_TOKEN });
9
+ const file = dcdn.file('images/logo.png');
10
+ const response = await file.get();
11
+ await Bun.write('logo.png', await response.bytes());
12
+ await file.put(await Bun.file('new-logo.png').arrayBuffer());
13
+
14
+ for await (const path of dcdn.files.list({ prefix: 'images/' })) console.log(path);
15
+ ```
16
+
17
+ S3-shaped access uses Basic auth and has ListV2 pagination:
18
+
19
+ ```ts
20
+ const baseUrl = process.env.DCDN_BASE_URL ?? 'https://dcdn.example';
21
+ const password = process.env.DCDN_PASSWORD ?? 'change-me';
22
+ const bucket = DcdnClient({ baseUrl, basicAuth: { username: 'dcdn', password } }).bucket('media');
23
+ const object = bucket.object('photos/one.jpg');
24
+ const image = await object.get({ range: bytesRange({ start: 0, end: 1023 }) });
25
+ for await (const entry of bucket.list({ delimiter: '/' })) console.log(entry);
26
+ ```
27
+
28
+ GET/HEAD/list retry transient network and HTTP failures with bounded exponential full jitter. PUT retries are off by default: enable them only for replayable bodies (strings, bytes, `Blob`, `ArrayBuffer`) and remember that a timeout can leave an ambiguous write on the server. Stream failures after headers are never silently restarted.
29
+
30
+ The service provides local durable acknowledgement with eventual cross-node consistency. Ranges are limited by the server configuration (64 MiB by default). This package does not implement rename, SigV4, multipart, versioning, ACLs, or other unsupported S3 features.
@@ -0,0 +1,181 @@
1
+ export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
2
+ export type DcdnBody = BodyInit | AsyncIterable<Uint8Array>;
3
+ export type ReplayableBody = string | Blob | ArrayBuffer | ArrayBufferView<ArrayBufferLike>;
4
+ export type BodySource = DcdnBody | (() => DcdnBody);
5
+ export interface BasicAuth {
6
+ username: string;
7
+ password: string;
8
+ }
9
+ export interface RetryOptions {
10
+ maxAttempts?: number;
11
+ baseDelayMs?: number;
12
+ maxDelayMs?: number;
13
+ put?: boolean;
14
+ random?: () => number;
15
+ }
16
+ export interface DcdnClientOptions {
17
+ baseUrl: string | URL;
18
+ token?: string;
19
+ basicAuth?: BasicAuth;
20
+ fetch?: FetchLike;
21
+ timeoutMs?: number;
22
+ retry?: RetryOptions | false;
23
+ userAgent?: string;
24
+ }
25
+ export interface RequestOptions {
26
+ signal?: AbortSignal;
27
+ headers?: HeadersInit;
28
+ range?: ByteRange | string;
29
+ ifMatch?: string;
30
+ ifNoneMatch?: string;
31
+ }
32
+ export interface PutOptions extends RequestOptions {
33
+ contentType?: string;
34
+ contentMD5?: string;
35
+ retry?: boolean;
36
+ }
37
+ export interface ByteRange {
38
+ start?: number;
39
+ end?: number;
40
+ suffix?: number;
41
+ }
42
+ export interface DcdnMetadata {
43
+ status: number;
44
+ headers: Headers;
45
+ size?: number;
46
+ contentType?: string;
47
+ etag?: string;
48
+ lastModified?: Date;
49
+ range?: string;
50
+ }
51
+ export interface DcdnResponse extends DcdnMetadata {
52
+ body: ReadableStream<Uint8Array> | null;
53
+ bytes(): Promise<Uint8Array>;
54
+ arrayBuffer(): Promise<ArrayBuffer>;
55
+ text(): Promise<string>;
56
+ }
57
+ export interface CanonicalListPage {
58
+ entries: string[];
59
+ continuationToken?: string;
60
+ }
61
+ export interface S3ObjectMetadata extends DcdnMetadata {
62
+ key: string;
63
+ }
64
+ export interface S3ObjectEntry {
65
+ key: string;
66
+ size: number;
67
+ etag?: string;
68
+ lastModified?: Date;
69
+ }
70
+ export interface S3ListPage {
71
+ bucket: string;
72
+ prefix: string;
73
+ keyCount: number;
74
+ maxKeys: number;
75
+ isTruncated: boolean;
76
+ nextContinuationToken?: string;
77
+ objects: S3ObjectEntry[];
78
+ commonPrefixes: string[];
79
+ }
80
+ export interface CanonicalListOptions {
81
+ prefix?: string;
82
+ limit?: number;
83
+ signal?: AbortSignal;
84
+ }
85
+ export interface S3ListOptions {
86
+ prefix?: string;
87
+ delimiter?: '/';
88
+ maxKeys?: number;
89
+ continuationToken?: string;
90
+ startAfter?: string;
91
+ encodingType?: 'url';
92
+ signal?: AbortSignal;
93
+ }
94
+ export type DcdnErrorCode = 'HTTP_ERROR' | 'NETWORK_ERROR' | 'ABORTED' | 'TIMEOUT' | 'INVALID_INPUT' | 'STREAM_ERROR';
95
+ export interface S3ErrorInfo {
96
+ code?: string;
97
+ requestId?: string;
98
+ hostId?: string;
99
+ }
100
+ export declare class DcdnError extends Error {
101
+ readonly status?: number;
102
+ readonly code: DcdnErrorCode;
103
+ readonly retryable: boolean;
104
+ readonly attempt: number;
105
+ readonly cause?: unknown;
106
+ readonly s3?: S3ErrorInfo;
107
+ constructor(message: string, fields: {
108
+ code: DcdnErrorCode;
109
+ status?: number;
110
+ retryable?: boolean;
111
+ attempt?: number;
112
+ cause?: unknown;
113
+ s3?: S3ErrorInfo;
114
+ });
115
+ }
116
+ export declare function bytesRange(range: ByteRange): string;
117
+ export declare const byteRange: typeof bytesRange;
118
+ type InternalOptions = DcdnClientOptions & {
119
+ fetcher: FetchLike;
120
+ base: URL;
121
+ retry: RetryOptions;
122
+ };
123
+ interface RequestLease {
124
+ body: ReadableStream<Uint8Array> | null;
125
+ release(): void;
126
+ }
127
+ declare class ClientCore {
128
+ protected readonly options: InternalOptions;
129
+ constructor(options: InternalOptions);
130
+ protected request(method: string, path: string, init?: RequestInit, kind?: 'read' | 'write', allowWriteRetry?: boolean, bodyFactory?: () => DcdnBody): Promise<{
131
+ response: Response;
132
+ attempt: number;
133
+ lease: RequestLease;
134
+ }>;
135
+ }
136
+ declare class Resource extends ClientCore {
137
+ protected view(response: Response, attempt: number, body?: ReadableStream<Uint8Array<ArrayBufferLike>> | null): DcdnResponse;
138
+ protected call(method: string, path: string, options?: RequestOptions, body?: BodySource, kind?: 'read' | 'write', allowWriteRetry?: boolean): Promise<DcdnResponse>;
139
+ }
140
+ declare class CanonicalFile extends Resource {
141
+ private readonly path;
142
+ constructor(options: InternalOptions, path: string);
143
+ get(options?: RequestOptions): Promise<DcdnResponse>;
144
+ head(options?: RequestOptions): Promise<DcdnResponse>;
145
+ put(body: BodySource, options?: PutOptions): Promise<DcdnResponse>;
146
+ delete(options?: RequestOptions): Promise<void>;
147
+ }
148
+ declare class CanonicalFiles extends ClientCore {
149
+ listPage(options?: CanonicalListOptions, token?: string): Promise<{
150
+ entries: string[];
151
+ continuationToken: string | undefined;
152
+ }>;
153
+ list(options?: CanonicalListOptions): AsyncGenerator<string, void, unknown>;
154
+ }
155
+ declare class S3Object extends Resource {
156
+ private readonly bucketName;
157
+ private readonly objectKey;
158
+ constructor(options: InternalOptions, bucketName: string, objectKey: string);
159
+ private path;
160
+ get(options?: RequestOptions): Promise<DcdnResponse>;
161
+ head(options?: RequestOptions): Promise<DcdnResponse>;
162
+ put(body: BodySource, options?: PutOptions): Promise<{
163
+ etag: string | undefined;
164
+ response: DcdnResponse;
165
+ }>;
166
+ delete(options?: RequestOptions): Promise<void>;
167
+ }
168
+ declare class S3Bucket extends ClientCore {
169
+ readonly name: string;
170
+ constructor(options: InternalOptions, name: string);
171
+ object(key: string): S3Object;
172
+ listPage(options?: S3ListOptions, token?: string): Promise<S3ListPage>;
173
+ list(options?: S3ListOptions): AsyncGenerator<string | S3ObjectEntry, void, unknown>;
174
+ }
175
+ export interface DcdnClientInstance {
176
+ file(path: string): CanonicalFile;
177
+ files: CanonicalFiles;
178
+ bucket(name: string): S3Bucket;
179
+ }
180
+ export declare function DcdnClient(input: DcdnClientOptions): DcdnClientInstance;
181
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,585 @@
1
+ // @bun
2
+ // src/index.ts
3
+ class DcdnError extends Error {
4
+ status;
5
+ code;
6
+ retryable;
7
+ attempt;
8
+ cause;
9
+ s3;
10
+ constructor(message, fields) {
11
+ super(message, { cause: fields.cause });
12
+ this.name = "DcdnError";
13
+ this.code = fields.code;
14
+ this.status = fields.status;
15
+ this.retryable = fields.retryable ?? false;
16
+ this.attempt = fields.attempt ?? 1;
17
+ this.cause = fields.cause;
18
+ this.s3 = fields.s3;
19
+ }
20
+ }
21
+ function bytesRange(range) {
22
+ if (range.suffix !== undefined)
23
+ return `bytes=-${positiveInt(range.suffix, "suffix")}`;
24
+ if (range.start === undefined)
25
+ throw invalid("range start is required");
26
+ if (range.start < 0 || !Number.isInteger(range.start))
27
+ throw invalid("range start must be a non-negative integer");
28
+ if (range.end !== undefined && (range.end < range.start || !Number.isInteger(range.end)))
29
+ throw invalid("range end is invalid");
30
+ return `bytes=${range.start}-${range.end ?? ""}`;
31
+ }
32
+ var byteRange = bytesRange;
33
+ var retryStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
34
+
35
+ class ResponseView {
36
+ response;
37
+ attempt;
38
+ stream;
39
+ constructor(response, attempt, stream) {
40
+ this.response = response;
41
+ this.attempt = attempt;
42
+ this.stream = stream;
43
+ }
44
+ get status() {
45
+ return this.response.status;
46
+ }
47
+ get headers() {
48
+ return this.response.headers;
49
+ }
50
+ get body() {
51
+ return this.stream;
52
+ }
53
+ async bytes() {
54
+ return readBody(this.stream, this.attempt, (v) => new Uint8Array(v));
55
+ }
56
+ async arrayBuffer() {
57
+ return readBody(this.stream, this.attempt, (v) => v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength));
58
+ }
59
+ async text() {
60
+ return readBody(this.stream, this.attempt, (v) => new TextDecoder().decode(v));
61
+ }
62
+ }
63
+
64
+ class ClientCore {
65
+ options;
66
+ constructor(options) {
67
+ this.options = options;
68
+ }
69
+ async request(method, path, init = {}, kind = "read", allowWriteRetry = false, bodyFactory) {
70
+ if (init.signal?.aborted)
71
+ throw new DcdnError("request aborted", { code: "ABORTED", cause: init.signal.reason });
72
+ const url = new URL(path.replace(/^\//, ""), this.options.base);
73
+ const headers = new Headers(init.headers);
74
+ if (this.options.userAgent)
75
+ headers.set("user-agent", this.options.userAgent);
76
+ const s3Request = path.startsWith("/s3/");
77
+ if (!s3Request && this.options.token && !headers.has("authorization"))
78
+ headers.set("authorization", `Bearer ${this.options.token}`);
79
+ if (s3Request && this.options.basicAuth && !headers.has("authorization")) {
80
+ const encoded = btoa(`${this.options.basicAuth.username}:${this.options.basicAuth.password}`);
81
+ headers.set("authorization", `Basic ${encoded}`);
82
+ }
83
+ const attempts = kind === "read" ? this.options.retry.maxAttempts ?? 3 : allowWriteRetry && this.options.retry.put ? this.options.retry.maxAttempts ?? 3 : 1;
84
+ const deadline = Date.now() + (this.options.timeoutMs ?? 30000);
85
+ let attempt = 0;
86
+ while (true) {
87
+ attempt++;
88
+ const remaining = deadline - Date.now();
89
+ if (remaining <= 0)
90
+ throw new DcdnError("request timed out", { code: "TIMEOUT", attempt, retryable: false });
91
+ const controller = new AbortController;
92
+ const onAbort = () => controller.abort(init.signal?.reason);
93
+ init.signal?.addEventListener("abort", onAbort, { once: true });
94
+ const timer = setTimeout(() => controller.abort(new Error("dcdn timeout")), remaining);
95
+ let returned = false;
96
+ try {
97
+ const requestInit = { ...init, body: bodyFactory?.(), method, headers, signal: controller.signal };
98
+ if (requestInit.body && (requestInit.body instanceof ReadableStream || typeof requestInit.body === "object" && (Symbol.asyncIterator in requestInit.body)))
99
+ requestInit.duplex = "half";
100
+ const response = await this.options.fetcher(url, requestInit);
101
+ if (response.ok || response.status === 304 || response.status === 204) {
102
+ returned = true;
103
+ return { response, attempt, lease: bodyLease(response, controller, init.signal ?? undefined, timer, onAbort, attempt) };
104
+ }
105
+ const retryable = retryStatuses.has(response.status) && attempt < attempts && (method !== "PUT" || allowWriteRetry);
106
+ if (retryable) {
107
+ await cancelResponseBody(response);
108
+ await waitRetry(response.headers, attempt, deadline, this.options.retry, init.signal ?? undefined);
109
+ continue;
110
+ }
111
+ throw await responseError(response, attempt);
112
+ } catch (error) {
113
+ if (error instanceof DcdnError) {
114
+ throw error;
115
+ }
116
+ const callerAborted = init.signal?.aborted;
117
+ const timedOut = controller.signal.aborted && !callerAborted;
118
+ if (callerAborted)
119
+ throw new DcdnError("request aborted", { code: "ABORTED", attempt, cause: error });
120
+ if (timedOut)
121
+ throw new DcdnError("request timed out", { code: "TIMEOUT", attempt, cause: error });
122
+ if (attempt >= attempts)
123
+ throw new DcdnError("network request failed", { code: "NETWORK_ERROR", attempt, retryable: true, cause: error });
124
+ await waitRetry(undefined, attempt, deadline, this.options.retry, init.signal ?? undefined);
125
+ } finally {
126
+ if (!returned) {
127
+ clearTimeout(timer);
128
+ init.signal?.removeEventListener("abort", onAbort);
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ class Resource extends ClientCore {
136
+ view(response, attempt, body = response.body) {
137
+ const range = response.headers.get("content-range") ?? undefined;
138
+ const view = new ResponseView(response, attempt, body);
139
+ return { ...metadata(response, range), body: view.body, bytes: () => view.bytes(), arrayBuffer: () => view.arrayBuffer(), text: () => view.text() };
140
+ }
141
+ async call(method, path, options = {}, body, kind = "read", allowWriteRetry = false) {
142
+ const headers = new Headers(options.headers);
143
+ if (options.range)
144
+ headers.set("range", typeof options.range === "string" ? options.range : bytesRange(options.range));
145
+ if (options.ifMatch)
146
+ headers.set("if-match", normalizeEtagInput(options.ifMatch));
147
+ if (options.ifNoneMatch)
148
+ headers.set("if-none-match", normalizeEtagInput(options.ifNoneMatch));
149
+ const source = typeof body === "function" ? body : () => body;
150
+ const result = await this.request(method, path, { headers, signal: options.signal }, kind, allowWriteRetry, source);
151
+ return this.view(result.response, result.attempt, result.lease.body);
152
+ }
153
+ }
154
+
155
+ class CanonicalFile extends Resource {
156
+ path;
157
+ constructor(options, path) {
158
+ super(options);
159
+ this.path = path;
160
+ }
161
+ get(options) {
162
+ return this.call("GET", `/__/files/${pathSegments(this.path)}`, options);
163
+ }
164
+ head(options) {
165
+ return this.call("HEAD", `/__/files/${pathSegments(this.path)}`, options);
166
+ }
167
+ async put(body, options = {}) {
168
+ if (options.retry && typeof body !== "function" && !isReplayable(body))
169
+ throw invalid("PUT retries require a replayable body or a body factory");
170
+ const headers = new Headers(options.headers);
171
+ if (options.contentType)
172
+ headers.set("content-type", options.contentType);
173
+ if (options.contentMD5)
174
+ headers.set("content-md5", options.contentMD5);
175
+ return this.call("PUT", `/__/files/${pathSegments(this.path)}`, { ...options, headers }, body, "write", options.retry === true);
176
+ }
177
+ async delete(options) {
178
+ await this.call("DELETE", `/__/files/${pathSegments(this.path)}`, options, undefined, "write");
179
+ }
180
+ }
181
+
182
+ class CanonicalFiles extends ClientCore {
183
+ listPage(options = {}, token) {
184
+ const query = new URLSearchParams;
185
+ if (options.prefix !== undefined)
186
+ query.set("prefix", validatePrefix(options.prefix));
187
+ query.set("limit", String(Math.min(1000, Math.max(1, options.limit ?? 1000))));
188
+ if (token)
189
+ query.set("continuation-token", token);
190
+ return this.request("GET", `/__/files?${query}`, { signal: options.signal }).then(async ({ response, attempt, lease }) => {
191
+ if (!lease.body)
192
+ throw new DcdnError("listing response has no body", { code: "STREAM_ERROR", attempt });
193
+ try {
194
+ const entries = JSON.parse(await new Response(lease.body).text());
195
+ if (!Array.isArray(entries) || !entries.every((entry) => typeof entry === "string"))
196
+ throw new TypeError("canonical listing must be an array of strings");
197
+ return { entries, continuationToken: response.headers.get("x-dcdn-continuation-token") ?? undefined };
198
+ } catch (cause) {
199
+ if (cause instanceof DcdnError)
200
+ throw cause;
201
+ throw new DcdnError("invalid canonical listing response", { code: "STREAM_ERROR", attempt, cause });
202
+ }
203
+ });
204
+ }
205
+ async* list(options = {}) {
206
+ let token;
207
+ do {
208
+ const page = await this.listPage(options, token);
209
+ yield* page.entries;
210
+ token = page.continuationToken;
211
+ } while (token);
212
+ }
213
+ }
214
+
215
+ class S3Object extends Resource {
216
+ bucketName;
217
+ objectKey;
218
+ constructor(options, bucketName, objectKey) {
219
+ super(options);
220
+ this.bucketName = bucketName;
221
+ this.objectKey = objectKey;
222
+ }
223
+ path() {
224
+ return `/s3/${pathSegment(this.bucketName)}/${pathSegments(this.objectKey)}`;
225
+ }
226
+ get(options) {
227
+ return this.call("GET", this.path(), options);
228
+ }
229
+ head(options) {
230
+ return this.call("HEAD", this.path(), options);
231
+ }
232
+ async put(body, options = {}) {
233
+ if (options.retry && typeof body !== "function" && !isReplayable(body))
234
+ throw invalid("PUT retries require a replayable body or a body factory");
235
+ const headers = new Headers(options.headers);
236
+ if (options.contentType)
237
+ headers.set("content-type", options.contentType);
238
+ if (options.contentMD5)
239
+ headers.set("content-md5", options.contentMD5);
240
+ const result = await this.call("PUT", this.path(), { ...options, headers }, body, "write", options.retry === true);
241
+ return { etag: result.headers.get("etag") ? normalizeEtag(result.headers.get("etag")) : undefined, response: result };
242
+ }
243
+ async delete(options) {
244
+ await this.call("DELETE", this.path(), options, undefined, "write");
245
+ }
246
+ }
247
+
248
+ class S3Bucket extends ClientCore {
249
+ name;
250
+ constructor(options, name) {
251
+ super(options);
252
+ this.name = name;
253
+ }
254
+ object(key) {
255
+ validateKey(key);
256
+ return new S3Object(this.options, this.name, key);
257
+ }
258
+ listPage(options = {}, token) {
259
+ const query = new URLSearchParams({ "list-type": "2" });
260
+ if (options.prefix)
261
+ query.set("prefix", options.prefix);
262
+ if (options.delimiter)
263
+ query.set("delimiter", options.delimiter);
264
+ query.set("max-keys", String(Math.min(1000, Math.max(1, options.maxKeys ?? 1000))));
265
+ if (token ?? options.continuationToken)
266
+ query.set("continuation-token", token ?? options.continuationToken);
267
+ if (options.startAfter)
268
+ query.set("start-after", options.startAfter);
269
+ if (options.encodingType)
270
+ query.set("encoding-type", options.encodingType);
271
+ return this.request("GET", `/s3/${pathSegment(this.name)}?${query}`, { signal: options.signal }).then(async ({ response, attempt, lease }) => {
272
+ if (!lease.body)
273
+ throw new DcdnError("listing response has no body", { code: "STREAM_ERROR", attempt });
274
+ return parseS3List(await new Response(lease.body).text(), attempt, options.encodingType === "url");
275
+ });
276
+ }
277
+ async* list(options = {}) {
278
+ let token;
279
+ do {
280
+ const page = await this.listPage(options, token);
281
+ yield* page.objects;
282
+ yield* page.commonPrefixes;
283
+ token = page.nextContinuationToken;
284
+ } while (token);
285
+ }
286
+ }
287
+ function DcdnClient(input) {
288
+ const base = new URL(input.baseUrl);
289
+ if (!/^https?:$/.test(base.protocol))
290
+ throw invalid("baseUrl must use http or https");
291
+ base.pathname = base.pathname.replace(/\/*$/, "/");
292
+ const options = { ...input, base, fetcher: input.fetch ?? globalThis.fetch, retry: input.retry === false ? { maxAttempts: 1 } : { ...input.retry } };
293
+ if (!options.fetcher)
294
+ throw invalid("fetch is unavailable");
295
+ return { file: (path) => {
296
+ validatePath(path);
297
+ return new CanonicalFile(options, path);
298
+ }, files: new CanonicalFiles(options), bucket: (name) => {
299
+ validateBucket(name);
300
+ return new S3Bucket(options, name);
301
+ } };
302
+ }
303
+ function validatePath(value) {
304
+ if (!value || value.startsWith("/") || value.includes("\\") || value.includes("\x00") || value.split("/").some((x) => !x || x === "." || x === ".."))
305
+ throw invalid("path must be a non-empty relative slash-separated path");
306
+ return value;
307
+ }
308
+ function validatePrefix(value) {
309
+ if (value === "")
310
+ return value;
311
+ if (value.startsWith("/") || value.includes("\\") || value.includes("\x00"))
312
+ throw invalid("prefix must be a relative slash-separated path");
313
+ const parts = value.split("/");
314
+ if (parts.at(-1) === "")
315
+ parts.pop();
316
+ if (!parts.length || parts.some((part) => !part || part === "." || part === ".."))
317
+ throw invalid("prefix must be a relative slash-separated path");
318
+ return value;
319
+ }
320
+ function validateKey(value) {
321
+ if (!value || value.startsWith("/") || value.includes("\\") || value.includes("\x00") || value.split("/").some((x) => x === "." || x === ".."))
322
+ throw invalid("key is invalid");
323
+ return value;
324
+ }
325
+ function validateBucket(value) {
326
+ if (!value || value.includes("/") || value.includes("\\") || value.includes("\x00") || value === "." || value === "..")
327
+ throw invalid("bucket is invalid");
328
+ return value;
329
+ }
330
+ function pathSegment(value) {
331
+ return encodeURIComponent(value);
332
+ }
333
+ function pathSegments(value) {
334
+ return value.split("/").map(pathSegment).join("/");
335
+ }
336
+ function positiveInt(value, name) {
337
+ if (!Number.isInteger(value) || value <= 0)
338
+ throw invalid(`${name} must be positive`);
339
+ return value;
340
+ }
341
+ function invalid(message) {
342
+ return new DcdnError(message, { code: "INVALID_INPUT" });
343
+ }
344
+ function isReplayable(body) {
345
+ return typeof body === "string" || body instanceof Blob || body instanceof ArrayBuffer || ArrayBuffer.isView(body);
346
+ }
347
+ function normalizeEtag(value) {
348
+ return value.trim().replace(/^"|"$/g, "");
349
+ }
350
+ function normalizeEtagInput(value) {
351
+ const trimmed = value.trim();
352
+ return trimmed === "*" ? "*" : `"${normalizeEtag(trimmed)}"`;
353
+ }
354
+ function metadata(response, range) {
355
+ const date = response.headers.get("last-modified");
356
+ return { status: response.status, headers: response.headers, size: Number(response.headers.get("content-length")) || undefined, contentType: response.headers.get("content-type") ?? undefined, etag: response.headers.get("etag") ? normalizeEtag(response.headers.get("etag")) : undefined, lastModified: date ? new Date(date) : undefined, range };
357
+ }
358
+ function bodyLease(response, controller, callerSignal, timer, requestAbort, attempt) {
359
+ const source = response.body;
360
+ let released = false;
361
+ let onAbort = () => {
362
+ return;
363
+ };
364
+ const release = () => {
365
+ if (released)
366
+ return;
367
+ released = true;
368
+ clearTimeout(timer);
369
+ callerSignal?.removeEventListener("abort", requestAbort);
370
+ controller.signal.removeEventListener("abort", onAbort);
371
+ };
372
+ if (!source) {
373
+ release();
374
+ return { body: null, release };
375
+ }
376
+ const reader = source.getReader();
377
+ let streamController;
378
+ onAbort = () => {
379
+ const error = callerSignal?.aborted ? new DcdnError("response body aborted", { code: "ABORTED", attempt, cause: callerSignal.reason }) : new DcdnError("response body timed out", { code: "TIMEOUT", attempt, cause: controller.signal.reason });
380
+ if (!released) {
381
+ release();
382
+ streamController?.error(error);
383
+ }
384
+ reader.cancel(error).catch(() => {
385
+ return;
386
+ });
387
+ };
388
+ controller.signal.addEventListener("abort", onAbort, { once: true });
389
+ const body = new ReadableStream({
390
+ start(c) {
391
+ streamController = c;
392
+ },
393
+ async pull(c) {
394
+ try {
395
+ const result = await reader.read();
396
+ if (result.done) {
397
+ release();
398
+ c.close();
399
+ } else
400
+ c.enqueue(result.value);
401
+ } catch (cause) {
402
+ release();
403
+ c.error(cause instanceof DcdnError ? cause : new DcdnError("response stream failed", { code: "STREAM_ERROR", attempt, cause }));
404
+ }
405
+ },
406
+ async cancel(reason) {
407
+ release();
408
+ await reader.cancel(reason);
409
+ }
410
+ });
411
+ if (callerSignal?.aborted || controller.signal.aborted)
412
+ onAbort();
413
+ return { body, release };
414
+ }
415
+ async function readBody(body, attempt, map) {
416
+ try {
417
+ if (!body)
418
+ return map(new Uint8Array);
419
+ return map(new Uint8Array(await new Response(body).arrayBuffer()));
420
+ } catch (cause) {
421
+ if (cause instanceof DcdnError)
422
+ throw cause;
423
+ throw new DcdnError("response stream failed", { code: "STREAM_ERROR", attempt, cause });
424
+ }
425
+ }
426
+ async function cancelResponseBody(response) {
427
+ await response.body?.cancel().catch(() => {
428
+ return;
429
+ });
430
+ }
431
+ async function waitRetry(headers, attempt, deadline, retry, signal) {
432
+ if (signal?.aborted)
433
+ throw new DcdnError("request aborted", { code: "ABORTED", attempt, cause: signal.reason });
434
+ const retryAfter = headers?.get("retry-after");
435
+ let delay = retryAfter ? parseRetryAfter(retryAfter) : undefined;
436
+ const base = retry.baseDelayMs ?? 100;
437
+ const max = retry.maxDelayMs ?? 1e4;
438
+ delay ??= Math.min(max, base * 2 ** (attempt - 1)) * (retry.random?.() ?? Math.random());
439
+ const remaining = deadline - Date.now();
440
+ if (remaining <= 0)
441
+ throw new DcdnError("request timed out", { code: "TIMEOUT", attempt });
442
+ const wait = Math.min(delay, remaining);
443
+ await new Promise((resolve, reject) => {
444
+ let settled = false;
445
+ const finish = (error) => {
446
+ if (settled)
447
+ return;
448
+ settled = true;
449
+ clearTimeout(timer);
450
+ signal?.removeEventListener("abort", onAbort);
451
+ error ? reject(error) : resolve();
452
+ };
453
+ const onAbort = () => finish(new DcdnError("request aborted", { code: "ABORTED", attempt, cause: signal?.reason }));
454
+ const timer = setTimeout(() => finish(wait < remaining ? undefined : new DcdnError("request timed out", { code: "TIMEOUT", attempt })), wait);
455
+ signal?.addEventListener("abort", onAbort, { once: true });
456
+ });
457
+ }
458
+ function parseRetryAfter(value) {
459
+ const seconds = Number(value);
460
+ if (Number.isFinite(seconds))
461
+ return Math.max(0, seconds * 1000);
462
+ const date = Date.parse(value);
463
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
464
+ }
465
+ async function responseError(response, attempt) {
466
+ let s3;
467
+ let message = `DCDN request failed with HTTP ${response.status}`;
468
+ if (response.headers.get("content-type")?.includes("xml")) {
469
+ const xml = await response.text().catch(() => "");
470
+ s3 = { code: tag(xml, "Code"), requestId: tag(xml, "RequestId"), hostId: tag(xml, "HostId") };
471
+ message = tag(xml, "Message") ?? s3.code ?? message;
472
+ }
473
+ return new DcdnError(message, { code: "HTTP_ERROR", status: response.status, retryable: retryStatuses.has(response.status), attempt, s3 });
474
+ }
475
+ function tag(xml, name) {
476
+ const match = xml.match(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, "i"));
477
+ return match ? decodeXml(match[1]) : undefined;
478
+ }
479
+ function decodeXml(value) {
480
+ return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
481
+ }
482
+ function parseS3List(xml, attempt, encoded) {
483
+ try {
484
+ validateXml(xml);
485
+ const rootMatch = xml.match(/^\s*(?:<\?xml[^>]*>\s*)?<ListBucketResult(?:\s[^>]*)?>([\s\S]*)<\/ListBucketResult>\s*$/i);
486
+ if (!rootMatch)
487
+ throw new SyntaxError("invalid ListBucketResult XML");
488
+ const root = rootMatch[1];
489
+ const required = (name) => {
490
+ const value = directTag(root, name);
491
+ if (value === undefined)
492
+ throw new TypeError(`missing S3 field ${name}`);
493
+ return value;
494
+ };
495
+ const decode = (value) => {
496
+ if (!encoded)
497
+ return value;
498
+ try {
499
+ return decodeURIComponent(value);
500
+ } catch (cause) {
501
+ throw new URIError(`invalid URL encoding in S3 listing: ${value}`, { cause });
502
+ }
503
+ };
504
+ const bucket = required("Name");
505
+ const prefix = decode(required("Prefix"));
506
+ const keyCount = Number(required("KeyCount"));
507
+ const maxKeys = Number(required("MaxKeys"));
508
+ const truncatedValue = required("IsTruncated");
509
+ if (!bucket || !Number.isInteger(keyCount) || keyCount < 0 || !Number.isInteger(maxKeys) || maxKeys < 0 || !/^(true|false)$/.test(truncatedValue))
510
+ throw new TypeError("invalid S3 pagination fields");
511
+ const isTruncated = truncatedValue === "true";
512
+ const next = directTag(root, "NextContinuationToken");
513
+ if (isTruncated && next === undefined)
514
+ throw new TypeError("missing S3 field NextContinuationToken");
515
+ const objects = [...root.matchAll(/<Contents(?:\s[^>]*)?>([\s\S]*?)<\/Contents>/gi)].map((match) => {
516
+ const content = match[1];
517
+ const field = (name) => {
518
+ const value = directTag(content, name);
519
+ if (value === undefined)
520
+ throw new TypeError(`missing S3 object field ${name}`);
521
+ return value;
522
+ };
523
+ const size = Number(field("Size"));
524
+ if (!Number.isInteger(size) || size < 0)
525
+ throw new TypeError("invalid S3 object size");
526
+ const lastModified = field("LastModified");
527
+ const date = new Date(lastModified);
528
+ if (Number.isNaN(date.valueOf()))
529
+ throw new TypeError("invalid S3 object date");
530
+ const etag = directTag(content, "ETag");
531
+ return { key: decode(field("Key")), size, etag: etag === undefined ? undefined : normalizeEtag(etag), lastModified: date };
532
+ });
533
+ const commonPrefixes = [...root.matchAll(/<CommonPrefixes(?:\s[^>]*)?>([\s\S]*?)<\/CommonPrefixes>/gi)].map((match) => {
534
+ const value = directTag(match[1], "Prefix");
535
+ if (value === undefined)
536
+ throw new TypeError("missing S3 common prefix");
537
+ return decode(value);
538
+ });
539
+ return { bucket, prefix, keyCount, maxKeys, isTruncated, nextContinuationToken: next === undefined ? undefined : decode(next), objects, commonPrefixes };
540
+ } catch (cause) {
541
+ if (cause instanceof DcdnError)
542
+ throw cause;
543
+ throw new DcdnError("invalid S3 XML response", { code: "STREAM_ERROR", attempt, cause });
544
+ }
545
+ }
546
+ function validateXml(xml) {
547
+ const stack = [];
548
+ for (const match of xml.matchAll(/<!--[\s\S]*?-->|<\?[^>]*>|<![^>]*>|<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\/?\s*>/g)) {
549
+ const token = match[0];
550
+ if (token.startsWith("<!--") || token.startsWith("<?") || token.startsWith("<!"))
551
+ continue;
552
+ const name = match[1];
553
+ if (token.startsWith("</")) {
554
+ if (stack.pop() !== name)
555
+ throw new SyntaxError("malformed XML");
556
+ } else if (!/\/\s*>$/.test(token))
557
+ stack.push(name);
558
+ }
559
+ if (stack.length || /<[^>]*$/.test(xml))
560
+ throw new SyntaxError("malformed XML");
561
+ }
562
+ function directTag(xml, name) {
563
+ const tags = [...xml.matchAll(/<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\/?\s*>/g)];
564
+ const stack = [];
565
+ let valueStart = 0;
566
+ for (const match of tags) {
567
+ const token = match[0];
568
+ if (token.startsWith("</")) {
569
+ if (stack.length === 1 && stack[0] === name)
570
+ return decodeXml(xml.slice(valueStart, match.index));
571
+ stack.pop();
572
+ } else if (!/\/\s*>$/.test(token)) {
573
+ if (stack.length === 0 && match[1] === name)
574
+ valueStart = (match.index ?? 0) + token.length;
575
+ stack.push(match[1]);
576
+ }
577
+ }
578
+ return;
579
+ }
580
+ export {
581
+ bytesRange,
582
+ byteRange,
583
+ DcdnError,
584
+ DcdnClient
585
+ };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@dmgnr/dcdn",
3
+ "version": "0.1.0",
4
+ "description": "Conservative TypeScript client for DCDN canonical files and S3-compatible objects",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "README.md"],
16
+ "scripts": {
17
+ "build": "bun run clean && bun build src/index.ts --outdir dist --target bun && tsc -p tsconfig.build.json",
18
+ "clean": "rm -rf dist",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "bun test"
21
+ },
22
+ "engines": {
23
+ "node": ">=18",
24
+ "bun": ">=1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "latest",
28
+ "typescript": "^5.7.0"
29
+ }
30
+ }