lopata 0.0.1
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 +15 -0
- package/package.json +51 -0
- package/runtime/bindings/ai.ts +132 -0
- package/runtime/bindings/analytics-engine.ts +96 -0
- package/runtime/bindings/browser.ts +64 -0
- package/runtime/bindings/cache.ts +179 -0
- package/runtime/bindings/cf-streams.ts +56 -0
- package/runtime/bindings/container-docker.ts +225 -0
- package/runtime/bindings/container.ts +662 -0
- package/runtime/bindings/crypto-extras.ts +89 -0
- package/runtime/bindings/d1.ts +315 -0
- package/runtime/bindings/do-executor-inprocess.ts +140 -0
- package/runtime/bindings/do-executor-worker.ts +368 -0
- package/runtime/bindings/do-executor.ts +45 -0
- package/runtime/bindings/do-websocket-bridge.ts +70 -0
- package/runtime/bindings/do-worker-entry.ts +220 -0
- package/runtime/bindings/do-worker-env.ts +74 -0
- package/runtime/bindings/durable-object.ts +992 -0
- package/runtime/bindings/email.ts +180 -0
- package/runtime/bindings/html-rewriter.ts +84 -0
- package/runtime/bindings/hyperdrive.ts +130 -0
- package/runtime/bindings/images.ts +381 -0
- package/runtime/bindings/kv.ts +359 -0
- package/runtime/bindings/queue.ts +507 -0
- package/runtime/bindings/r2.ts +759 -0
- package/runtime/bindings/rpc-stub.ts +267 -0
- package/runtime/bindings/scheduled.ts +172 -0
- package/runtime/bindings/service-binding.ts +217 -0
- package/runtime/bindings/static-assets.ts +481 -0
- package/runtime/bindings/websocket-pair.ts +182 -0
- package/runtime/bindings/workflow.ts +858 -0
- package/runtime/bunflare-config.ts +56 -0
- package/runtime/cli/cache.ts +39 -0
- package/runtime/cli/context.ts +105 -0
- package/runtime/cli/d1.ts +163 -0
- package/runtime/cli/dev.ts +392 -0
- package/runtime/cli/kv.ts +84 -0
- package/runtime/cli/queues.ts +109 -0
- package/runtime/cli/r2.ts +140 -0
- package/runtime/cli/traces.ts +251 -0
- package/runtime/cli.ts +102 -0
- package/runtime/config.ts +148 -0
- package/runtime/d1-migrate.ts +37 -0
- package/runtime/dashboard/api.ts +174 -0
- package/runtime/dashboard/app.tsx +220 -0
- package/runtime/dashboard/components/breadcrumb.tsx +16 -0
- package/runtime/dashboard/components/buttons.tsx +13 -0
- package/runtime/dashboard/components/code-block.tsx +5 -0
- package/runtime/dashboard/components/detail-field.tsx +8 -0
- package/runtime/dashboard/components/empty-state.tsx +8 -0
- package/runtime/dashboard/components/filter-input.tsx +11 -0
- package/runtime/dashboard/components/index.ts +16 -0
- package/runtime/dashboard/components/key-value-table.tsx +23 -0
- package/runtime/dashboard/components/modal.tsx +23 -0
- package/runtime/dashboard/components/page-header.tsx +11 -0
- package/runtime/dashboard/components/pill-button.tsx +14 -0
- package/runtime/dashboard/components/refresh-button.tsx +7 -0
- package/runtime/dashboard/components/service-info.tsx +45 -0
- package/runtime/dashboard/components/status-badge.tsx +7 -0
- package/runtime/dashboard/components/table-link.tsx +5 -0
- package/runtime/dashboard/components/table.tsx +26 -0
- package/runtime/dashboard/components.tsx +19 -0
- package/runtime/dashboard/index.html +23 -0
- package/runtime/dashboard/lib.ts +45 -0
- package/runtime/dashboard/rpc/client.ts +20 -0
- package/runtime/dashboard/rpc/handlers/ai.ts +71 -0
- package/runtime/dashboard/rpc/handlers/analytics-engine.ts +53 -0
- package/runtime/dashboard/rpc/handlers/cache.ts +24 -0
- package/runtime/dashboard/rpc/handlers/config.ts +137 -0
- package/runtime/dashboard/rpc/handlers/containers.ts +194 -0
- package/runtime/dashboard/rpc/handlers/d1.ts +84 -0
- package/runtime/dashboard/rpc/handlers/do.ts +117 -0
- package/runtime/dashboard/rpc/handlers/email.ts +82 -0
- package/runtime/dashboard/rpc/handlers/errors.ts +32 -0
- package/runtime/dashboard/rpc/handlers/generations.ts +60 -0
- package/runtime/dashboard/rpc/handlers/kv.ts +76 -0
- package/runtime/dashboard/rpc/handlers/overview.ts +94 -0
- package/runtime/dashboard/rpc/handlers/queue.ts +79 -0
- package/runtime/dashboard/rpc/handlers/r2.ts +72 -0
- package/runtime/dashboard/rpc/handlers/scheduled.ts +91 -0
- package/runtime/dashboard/rpc/handlers/traces.ts +64 -0
- package/runtime/dashboard/rpc/handlers/workers.ts +65 -0
- package/runtime/dashboard/rpc/handlers/workflows.ts +171 -0
- package/runtime/dashboard/rpc/hooks.ts +132 -0
- package/runtime/dashboard/rpc/server.ts +70 -0
- package/runtime/dashboard/rpc/types.ts +396 -0
- package/runtime/dashboard/sql-browser/data-browser-tab.tsx +122 -0
- package/runtime/dashboard/sql-browser/editable-cell.tsx +117 -0
- package/runtime/dashboard/sql-browser/filter-row.tsx +99 -0
- package/runtime/dashboard/sql-browser/history-panels.tsx +110 -0
- package/runtime/dashboard/sql-browser/hooks.ts +137 -0
- package/runtime/dashboard/sql-browser/index.ts +4 -0
- package/runtime/dashboard/sql-browser/insert-row-form.tsx +85 -0
- package/runtime/dashboard/sql-browser/modals.tsx +116 -0
- package/runtime/dashboard/sql-browser/schema-browser-tab.tsx +67 -0
- package/runtime/dashboard/sql-browser/sql-browser.tsx +52 -0
- package/runtime/dashboard/sql-browser/sql-console-tab.tsx +124 -0
- package/runtime/dashboard/sql-browser/table-data-view.tsx +566 -0
- package/runtime/dashboard/sql-browser/table-sidebar.tsx +38 -0
- package/runtime/dashboard/sql-browser/types.ts +61 -0
- package/runtime/dashboard/sql-browser/utils.ts +167 -0
- package/runtime/dashboard/style.css +177 -0
- package/runtime/dashboard/views/ai.tsx +152 -0
- package/runtime/dashboard/views/analytics-engine.tsx +169 -0
- package/runtime/dashboard/views/cache.tsx +93 -0
- package/runtime/dashboard/views/containers.tsx +197 -0
- package/runtime/dashboard/views/d1.tsx +81 -0
- package/runtime/dashboard/views/do.tsx +168 -0
- package/runtime/dashboard/views/email.tsx +235 -0
- package/runtime/dashboard/views/errors.tsx +558 -0
- package/runtime/dashboard/views/home.tsx +287 -0
- package/runtime/dashboard/views/kv.tsx +273 -0
- package/runtime/dashboard/views/queue.tsx +193 -0
- package/runtime/dashboard/views/r2.tsx +202 -0
- package/runtime/dashboard/views/scheduled.tsx +89 -0
- package/runtime/dashboard/views/trace-waterfall.tsx +410 -0
- package/runtime/dashboard/views/traces.tsx +768 -0
- package/runtime/dashboard/views/workers.tsx +55 -0
- package/runtime/dashboard/views/workflows.tsx +473 -0
- package/runtime/db.ts +258 -0
- package/runtime/env.ts +362 -0
- package/runtime/error-page/app.tsx +394 -0
- package/runtime/error-page/build.ts +269 -0
- package/runtime/error-page/index.html +16 -0
- package/runtime/error-page/style.css +31 -0
- package/runtime/execution-context.ts +18 -0
- package/runtime/file-watcher.ts +57 -0
- package/runtime/generation-manager.ts +230 -0
- package/runtime/generation.ts +411 -0
- package/runtime/plugin.ts +292 -0
- package/runtime/request-cf.ts +28 -0
- package/runtime/rpc-validate.ts +154 -0
- package/runtime/tracing/context.ts +40 -0
- package/runtime/tracing/db.ts +73 -0
- package/runtime/tracing/frames.ts +75 -0
- package/runtime/tracing/instrument.ts +186 -0
- package/runtime/tracing/span.ts +138 -0
- package/runtime/tracing/store.ts +499 -0
- package/runtime/tracing/types.ts +47 -0
- package/runtime/vite-plugin/config-plugin.ts +68 -0
- package/runtime/vite-plugin/dev-server-plugin.ts +493 -0
- package/runtime/vite-plugin/dist/index.mjs +52333 -0
- package/runtime/vite-plugin/globals-plugin.ts +94 -0
- package/runtime/vite-plugin/index.ts +43 -0
- package/runtime/vite-plugin/modules-plugin.ts +88 -0
- package/runtime/vite-plugin/react-router-plugin.ts +95 -0
- package/runtime/worker-registry.ts +52 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { join, dirname, resolve } from "node:path";
|
|
3
|
+
import { mkdirSync, rmSync, existsSync, statSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
// --- Limits ---
|
|
6
|
+
|
|
7
|
+
export interface R2Limits {
|
|
8
|
+
maxKeySize?: number; // default 1024 bytes
|
|
9
|
+
maxCustomMetadataSize?: number; // default 2048 bytes
|
|
10
|
+
maxBatchDeleteKeys?: number; // default 1000
|
|
11
|
+
maxMultipartPartSize?: number; // default 5 GiB (not enforced, just documented)
|
|
12
|
+
minMultipartPartSize?: number; // default 5 MiB (last part exempt)
|
|
13
|
+
maxMultipartParts?: number; // default 10000
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const R2_DEFAULTS: Required<R2Limits> = {
|
|
17
|
+
maxKeySize: 1024,
|
|
18
|
+
maxCustomMetadataSize: 2048,
|
|
19
|
+
maxBatchDeleteKeys: 1000,
|
|
20
|
+
maxMultipartPartSize: 5 * 1024 * 1024 * 1024,
|
|
21
|
+
minMultipartPartSize: 5 * 1024 * 1024,
|
|
22
|
+
maxMultipartParts: 10000,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// --- Interfaces ---
|
|
26
|
+
|
|
27
|
+
export interface R2Conditional {
|
|
28
|
+
etagMatches?: string | string[];
|
|
29
|
+
etagDoesNotMatch?: string | string[];
|
|
30
|
+
uploadedBefore?: Date;
|
|
31
|
+
uploadedAfter?: Date;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface R2Range {
|
|
35
|
+
offset?: number;
|
|
36
|
+
length?: number;
|
|
37
|
+
suffix?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface R2Checksums {
|
|
41
|
+
md5?: ArrayBuffer;
|
|
42
|
+
sha1?: ArrayBuffer;
|
|
43
|
+
sha256?: ArrayBuffer;
|
|
44
|
+
sha384?: ArrayBuffer;
|
|
45
|
+
sha512?: ArrayBuffer;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface R2GetOptions {
|
|
49
|
+
onlyIf?: R2Conditional;
|
|
50
|
+
range?: R2Range;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface R2PutOptions {
|
|
54
|
+
httpMetadata?: Record<string, string>;
|
|
55
|
+
customMetadata?: Record<string, string>;
|
|
56
|
+
onlyIf?: R2Conditional;
|
|
57
|
+
md5?: ArrayBuffer | string;
|
|
58
|
+
sha1?: ArrayBuffer | string;
|
|
59
|
+
sha256?: ArrayBuffer | string;
|
|
60
|
+
sha384?: ArrayBuffer | string;
|
|
61
|
+
sha512?: ArrayBuffer | string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface R2ListOptions {
|
|
65
|
+
prefix?: string;
|
|
66
|
+
limit?: number;
|
|
67
|
+
cursor?: string;
|
|
68
|
+
delimiter?: string;
|
|
69
|
+
include?: ("httpMetadata" | "customMetadata")[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface R2ObjectMeta {
|
|
73
|
+
key: string;
|
|
74
|
+
size: number;
|
|
75
|
+
etag: string;
|
|
76
|
+
version: string;
|
|
77
|
+
uploaded: Date;
|
|
78
|
+
httpMetadata: Record<string, string>;
|
|
79
|
+
customMetadata: Record<string, string>;
|
|
80
|
+
checksums: R2Checksums;
|
|
81
|
+
range?: { offset: number; length: number };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- R2Object ---
|
|
85
|
+
|
|
86
|
+
export class R2Object {
|
|
87
|
+
readonly key: string;
|
|
88
|
+
readonly size: number;
|
|
89
|
+
readonly etag: string;
|
|
90
|
+
readonly httpEtag: string;
|
|
91
|
+
readonly version: string;
|
|
92
|
+
readonly uploaded: Date;
|
|
93
|
+
readonly httpMetadata: Record<string, string>;
|
|
94
|
+
readonly customMetadata: Record<string, string>;
|
|
95
|
+
readonly checksums: R2Checksums;
|
|
96
|
+
readonly storageClass: string;
|
|
97
|
+
readonly range?: { offset: number; length: number };
|
|
98
|
+
|
|
99
|
+
constructor(meta: R2ObjectMeta) {
|
|
100
|
+
this.key = meta.key;
|
|
101
|
+
this.size = meta.size;
|
|
102
|
+
this.etag = meta.etag;
|
|
103
|
+
this.httpEtag = `"${meta.etag}"`;
|
|
104
|
+
this.version = meta.version;
|
|
105
|
+
this.uploaded = meta.uploaded;
|
|
106
|
+
this.httpMetadata = meta.httpMetadata;
|
|
107
|
+
this.customMetadata = meta.customMetadata;
|
|
108
|
+
this.checksums = meta.checksums;
|
|
109
|
+
this.storageClass = "Standard";
|
|
110
|
+
this.range = meta.range;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
writeHttpMetadata(headers: Headers): void {
|
|
114
|
+
for (const [k, v] of Object.entries(this.httpMetadata)) {
|
|
115
|
+
headers.set(k, v);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- R2ObjectBody ---
|
|
121
|
+
|
|
122
|
+
export class R2ObjectBody extends R2Object {
|
|
123
|
+
private data: ArrayBuffer;
|
|
124
|
+
readonly bodyUsed: boolean = false;
|
|
125
|
+
|
|
126
|
+
constructor(meta: R2ObjectMeta, data: ArrayBuffer) {
|
|
127
|
+
super(meta);
|
|
128
|
+
this.data = data;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
get body(): ReadableStream<Uint8Array> {
|
|
132
|
+
const data = this.data;
|
|
133
|
+
return new ReadableStream({
|
|
134
|
+
start(controller) {
|
|
135
|
+
controller.enqueue(new Uint8Array(data));
|
|
136
|
+
controller.close();
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
142
|
+
return this.data;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async text(): Promise<string> {
|
|
146
|
+
return new TextDecoder().decode(this.data);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async json<T = unknown>(): Promise<T> {
|
|
150
|
+
return JSON.parse(await this.text());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async blob(): Promise<Blob> {
|
|
154
|
+
return new Blob([this.data]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- DB row ---
|
|
159
|
+
|
|
160
|
+
interface R2Row {
|
|
161
|
+
key: string;
|
|
162
|
+
size: number;
|
|
163
|
+
etag: string;
|
|
164
|
+
version: string;
|
|
165
|
+
uploaded: string;
|
|
166
|
+
http_metadata: string | null;
|
|
167
|
+
custom_metadata: string | null;
|
|
168
|
+
checksums: string | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function rowToMeta(row: R2Row): R2ObjectMeta {
|
|
172
|
+
return {
|
|
173
|
+
key: row.key,
|
|
174
|
+
size: row.size,
|
|
175
|
+
etag: row.etag,
|
|
176
|
+
version: row.version ?? row.etag,
|
|
177
|
+
uploaded: new Date(row.uploaded),
|
|
178
|
+
httpMetadata: row.http_metadata ? JSON.parse(row.http_metadata) : {},
|
|
179
|
+
customMetadata: row.custom_metadata ? JSON.parse(row.custom_metadata) : {},
|
|
180
|
+
checksums: row.checksums ? deserializeChecksums(JSON.parse(row.checksums)) : {},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function serializeChecksums(c: R2Checksums): Record<string, string> {
|
|
185
|
+
const result: Record<string, string> = {};
|
|
186
|
+
for (const [k, v] of Object.entries(c)) {
|
|
187
|
+
if (v instanceof ArrayBuffer) {
|
|
188
|
+
result[k] = Buffer.from(v).toString("hex");
|
|
189
|
+
} else if (typeof v === "string") {
|
|
190
|
+
result[k] = v;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function deserializeChecksums(c: Record<string, string>): R2Checksums {
|
|
197
|
+
const result: R2Checksums = {};
|
|
198
|
+
for (const [k, v] of Object.entries(c)) {
|
|
199
|
+
(result as Record<string, ArrayBuffer>)[k] = Buffer.from(v, "hex").buffer as ArrayBuffer;
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- Conditional check ---
|
|
205
|
+
|
|
206
|
+
function evaluateConditional(cond: R2Conditional, etag: string, uploaded: Date): boolean {
|
|
207
|
+
if (cond.etagMatches !== undefined) {
|
|
208
|
+
const tags = Array.isArray(cond.etagMatches) ? cond.etagMatches : [cond.etagMatches];
|
|
209
|
+
if (!tags.some((t) => t === etag || t === `"${etag}"` || t === "*")) return false;
|
|
210
|
+
}
|
|
211
|
+
if (cond.etagDoesNotMatch !== undefined) {
|
|
212
|
+
const tags = Array.isArray(cond.etagDoesNotMatch) ? cond.etagDoesNotMatch : [cond.etagDoesNotMatch];
|
|
213
|
+
if (tags.some((t) => t === etag || t === `"${etag}"`)) return false;
|
|
214
|
+
}
|
|
215
|
+
if (cond.uploadedBefore !== undefined) {
|
|
216
|
+
if (uploaded >= cond.uploadedBefore) return false;
|
|
217
|
+
}
|
|
218
|
+
if (cond.uploadedAfter !== undefined) {
|
|
219
|
+
if (uploaded <= cond.uploadedAfter) return false;
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- Multipart Upload ---
|
|
225
|
+
|
|
226
|
+
interface MultipartRow {
|
|
227
|
+
upload_id: string;
|
|
228
|
+
bucket: string;
|
|
229
|
+
key: string;
|
|
230
|
+
http_metadata: string | null;
|
|
231
|
+
custom_metadata: string | null;
|
|
232
|
+
created_at: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
interface MultipartPartRow {
|
|
236
|
+
upload_id: string;
|
|
237
|
+
part_number: number;
|
|
238
|
+
etag: string;
|
|
239
|
+
size: number;
|
|
240
|
+
file_path: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export class R2MultipartUpload {
|
|
244
|
+
readonly key: string;
|
|
245
|
+
readonly uploadId: string;
|
|
246
|
+
private db: Database;
|
|
247
|
+
private bucket: string;
|
|
248
|
+
private baseDir: string;
|
|
249
|
+
private limits: Required<R2Limits>;
|
|
250
|
+
|
|
251
|
+
constructor(
|
|
252
|
+
db: Database,
|
|
253
|
+
bucket: string,
|
|
254
|
+
baseDir: string,
|
|
255
|
+
key: string,
|
|
256
|
+
uploadId: string,
|
|
257
|
+
limits: Required<R2Limits>,
|
|
258
|
+
) {
|
|
259
|
+
this.db = db;
|
|
260
|
+
this.bucket = bucket;
|
|
261
|
+
this.baseDir = baseDir;
|
|
262
|
+
this.key = key;
|
|
263
|
+
this.uploadId = uploadId;
|
|
264
|
+
this.limits = limits;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async uploadPart(partNumber: number, data: ArrayBuffer | Uint8Array | string | ReadableStream): Promise<{ partNumber: number; etag: string }> {
|
|
268
|
+
// Verify upload exists and is not aborted/completed
|
|
269
|
+
const upload = this.db
|
|
270
|
+
.query<MultipartRow, [string, string]>(
|
|
271
|
+
`SELECT * FROM r2_multipart_uploads WHERE upload_id = ? AND bucket = ?`,
|
|
272
|
+
)
|
|
273
|
+
.get(this.uploadId, this.bucket);
|
|
274
|
+
if (!upload) throw new Error("Multipart upload not found or already completed/aborted");
|
|
275
|
+
|
|
276
|
+
let buf: ArrayBuffer;
|
|
277
|
+
if (typeof data === "string") {
|
|
278
|
+
buf = new TextEncoder().encode(data).buffer as ArrayBuffer;
|
|
279
|
+
} else if (data instanceof Uint8Array) {
|
|
280
|
+
buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
|
|
281
|
+
} else if (data instanceof ArrayBuffer) {
|
|
282
|
+
buf = data;
|
|
283
|
+
} else {
|
|
284
|
+
// ReadableStream
|
|
285
|
+
const chunks: Uint8Array[] = [];
|
|
286
|
+
const reader = data.getReader();
|
|
287
|
+
while (true) {
|
|
288
|
+
const { done, value } = await reader.read();
|
|
289
|
+
if (done) break;
|
|
290
|
+
chunks.push(value);
|
|
291
|
+
}
|
|
292
|
+
const total = chunks.reduce((s, c) => s + c.length, 0);
|
|
293
|
+
const combined = new Uint8Array(total);
|
|
294
|
+
let offset = 0;
|
|
295
|
+
for (const c of chunks) {
|
|
296
|
+
combined.set(c, offset);
|
|
297
|
+
offset += c.length;
|
|
298
|
+
}
|
|
299
|
+
buf = combined.buffer as ArrayBuffer;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const hasher = new Bun.CryptoHasher("md5");
|
|
303
|
+
hasher.update(new Uint8Array(buf));
|
|
304
|
+
const etag = hasher.digest("hex");
|
|
305
|
+
|
|
306
|
+
// Store part data to a temp file
|
|
307
|
+
const partDir = join(this.baseDir, "__multipart__", this.uploadId);
|
|
308
|
+
mkdirSync(partDir, { recursive: true });
|
|
309
|
+
const partPath = join(partDir, `part-${partNumber}`);
|
|
310
|
+
await Bun.write(partPath, buf);
|
|
311
|
+
|
|
312
|
+
// Upsert part record
|
|
313
|
+
this.db.run(
|
|
314
|
+
`INSERT OR REPLACE INTO r2_multipart_parts (upload_id, part_number, etag, size, file_path)
|
|
315
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
316
|
+
[this.uploadId, partNumber, etag, buf.byteLength, partPath],
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
return { partNumber, etag };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async complete(parts: { partNumber: number; etag: string }[]): Promise<R2Object> {
|
|
323
|
+
const upload = this.db
|
|
324
|
+
.query<MultipartRow, [string, string]>(
|
|
325
|
+
`SELECT * FROM r2_multipart_uploads WHERE upload_id = ? AND bucket = ?`,
|
|
326
|
+
)
|
|
327
|
+
.get(this.uploadId, this.bucket);
|
|
328
|
+
if (!upload) throw new Error("Multipart upload not found or already completed/aborted");
|
|
329
|
+
|
|
330
|
+
// Sort parts by partNumber
|
|
331
|
+
const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
332
|
+
|
|
333
|
+
// Load all part data
|
|
334
|
+
const allParts: Uint8Array[] = [];
|
|
335
|
+
let totalSize = 0;
|
|
336
|
+
for (const p of sorted) {
|
|
337
|
+
const partRow = this.db
|
|
338
|
+
.query<MultipartPartRow, [string, number]>(
|
|
339
|
+
`SELECT * FROM r2_multipart_parts WHERE upload_id = ? AND part_number = ?`,
|
|
340
|
+
)
|
|
341
|
+
.get(this.uploadId, p.partNumber);
|
|
342
|
+
if (!partRow) throw new Error(`Part ${p.partNumber} not found`);
|
|
343
|
+
if (partRow.etag !== p.etag) throw new Error(`Part ${p.partNumber} etag mismatch`);
|
|
344
|
+
const data = await Bun.file(partRow.file_path).arrayBuffer();
|
|
345
|
+
allParts.push(new Uint8Array(data));
|
|
346
|
+
totalSize += data.byteLength;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Concatenate
|
|
350
|
+
const combined = new Uint8Array(totalSize);
|
|
351
|
+
let offset = 0;
|
|
352
|
+
for (const part of allParts) {
|
|
353
|
+
combined.set(part, offset);
|
|
354
|
+
offset += part.length;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Write final file
|
|
358
|
+
const filePath = join(this.baseDir, this.key);
|
|
359
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
360
|
+
await Bun.write(filePath, combined);
|
|
361
|
+
|
|
362
|
+
const hasher = new Bun.CryptoHasher("md5");
|
|
363
|
+
hasher.update(combined);
|
|
364
|
+
const etag = hasher.digest("hex");
|
|
365
|
+
const uploaded = new Date();
|
|
366
|
+
const version = crypto.randomUUID();
|
|
367
|
+
|
|
368
|
+
const httpMeta = upload.http_metadata ? JSON.parse(upload.http_metadata) : {};
|
|
369
|
+
const customMeta = upload.custom_metadata ? JSON.parse(upload.custom_metadata) : {};
|
|
370
|
+
|
|
371
|
+
// Insert object record
|
|
372
|
+
this.db.run(
|
|
373
|
+
`INSERT OR REPLACE INTO r2_objects (bucket, key, size, etag, version, uploaded, http_metadata, custom_metadata, checksums)
|
|
374
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
375
|
+
[
|
|
376
|
+
this.bucket,
|
|
377
|
+
this.key,
|
|
378
|
+
totalSize,
|
|
379
|
+
etag,
|
|
380
|
+
version,
|
|
381
|
+
uploaded.toISOString(),
|
|
382
|
+
upload.http_metadata,
|
|
383
|
+
upload.custom_metadata,
|
|
384
|
+
null,
|
|
385
|
+
],
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// Clean up multipart data
|
|
389
|
+
this.cleanupMultipart();
|
|
390
|
+
|
|
391
|
+
return new R2Object({
|
|
392
|
+
key: this.key,
|
|
393
|
+
size: totalSize,
|
|
394
|
+
etag,
|
|
395
|
+
version,
|
|
396
|
+
uploaded,
|
|
397
|
+
httpMetadata: httpMeta,
|
|
398
|
+
customMetadata: customMeta,
|
|
399
|
+
checksums: {},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async abort(): Promise<void> {
|
|
404
|
+
this.cleanupMultipart();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private cleanupMultipart(): void {
|
|
408
|
+
// Delete part files
|
|
409
|
+
const partDir = join(this.baseDir, "__multipart__", this.uploadId);
|
|
410
|
+
if (existsSync(partDir)) {
|
|
411
|
+
rmSync(partDir, { recursive: true, force: true });
|
|
412
|
+
}
|
|
413
|
+
// Delete DB records
|
|
414
|
+
this.db.run(`DELETE FROM r2_multipart_parts WHERE upload_id = ?`, [this.uploadId]);
|
|
415
|
+
this.db.run(`DELETE FROM r2_multipart_uploads WHERE upload_id = ?`, [this.uploadId]);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// --- FileR2Bucket ---
|
|
420
|
+
|
|
421
|
+
export class FileR2Bucket {
|
|
422
|
+
private db: Database;
|
|
423
|
+
private bucket: string;
|
|
424
|
+
private baseDir: string;
|
|
425
|
+
private limits: Required<R2Limits>;
|
|
426
|
+
|
|
427
|
+
constructor(db: Database, bucket: string, dataDir: string, limits?: R2Limits) {
|
|
428
|
+
this.db = db;
|
|
429
|
+
this.bucket = bucket;
|
|
430
|
+
this.baseDir = join(dataDir, "r2", bucket);
|
|
431
|
+
this.limits = { ...R2_DEFAULTS, ...limits };
|
|
432
|
+
mkdirSync(this.baseDir, { recursive: true });
|
|
433
|
+
|
|
434
|
+
// Ensure version and checksums columns exist (migration for existing DBs)
|
|
435
|
+
this.ensureColumns();
|
|
436
|
+
this.ensureMultipartTables();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private ensureColumns(): void {
|
|
440
|
+
try {
|
|
441
|
+
this.db.run(`ALTER TABLE r2_objects ADD COLUMN version TEXT NOT NULL DEFAULT ''`);
|
|
442
|
+
} catch {
|
|
443
|
+
// Column already exists
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
this.db.run(`ALTER TABLE r2_objects ADD COLUMN checksums TEXT`);
|
|
447
|
+
} catch {
|
|
448
|
+
// Column already exists
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private ensureMultipartTables(): void {
|
|
453
|
+
this.db.run(`
|
|
454
|
+
CREATE TABLE IF NOT EXISTS r2_multipart_uploads (
|
|
455
|
+
upload_id TEXT PRIMARY KEY,
|
|
456
|
+
bucket TEXT NOT NULL,
|
|
457
|
+
key TEXT NOT NULL,
|
|
458
|
+
http_metadata TEXT,
|
|
459
|
+
custom_metadata TEXT,
|
|
460
|
+
created_at TEXT NOT NULL
|
|
461
|
+
)
|
|
462
|
+
`);
|
|
463
|
+
this.db.run(`
|
|
464
|
+
CREATE TABLE IF NOT EXISTS r2_multipart_parts (
|
|
465
|
+
upload_id TEXT NOT NULL,
|
|
466
|
+
part_number INTEGER NOT NULL,
|
|
467
|
+
etag TEXT NOT NULL,
|
|
468
|
+
size INTEGER NOT NULL,
|
|
469
|
+
file_path TEXT NOT NULL,
|
|
470
|
+
PRIMARY KEY (upload_id, part_number)
|
|
471
|
+
)
|
|
472
|
+
`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private validateKey(key: string): void {
|
|
476
|
+
const keyBytes = new TextEncoder().encode(key);
|
|
477
|
+
if (keyBytes.length > this.limits.maxKeySize) {
|
|
478
|
+
throw new Error(`Key exceeds max size of ${this.limits.maxKeySize} bytes`);
|
|
479
|
+
}
|
|
480
|
+
if (key.includes("..")) {
|
|
481
|
+
throw new Error(`Invalid key: path traversal not allowed`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private validateCustomMetadata(metadata: Record<string, string> | undefined): void {
|
|
486
|
+
if (!metadata) return;
|
|
487
|
+
const serialized = JSON.stringify(metadata);
|
|
488
|
+
if (new TextEncoder().encode(serialized).length > this.limits.maxCustomMetadataSize) {
|
|
489
|
+
throw new Error(`Custom metadata exceeds max size of ${this.limits.maxCustomMetadataSize} bytes`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private filePath(key: string): string {
|
|
494
|
+
return join(this.baseDir, key);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private async readValue(
|
|
498
|
+
value: string | ArrayBuffer | ReadableStream | Blob | null,
|
|
499
|
+
): Promise<ArrayBuffer> {
|
|
500
|
+
if (value === null) return new ArrayBuffer(0);
|
|
501
|
+
if (typeof value === "string") return new TextEncoder().encode(value).buffer as ArrayBuffer;
|
|
502
|
+
if (value instanceof ArrayBuffer) return value;
|
|
503
|
+
if (value instanceof Blob) return await value.arrayBuffer();
|
|
504
|
+
// ReadableStream
|
|
505
|
+
const chunks: Uint8Array[] = [];
|
|
506
|
+
const reader = value.getReader();
|
|
507
|
+
while (true) {
|
|
508
|
+
const { done, value: chunk } = await reader.read();
|
|
509
|
+
if (done) break;
|
|
510
|
+
chunks.push(chunk);
|
|
511
|
+
}
|
|
512
|
+
const total = chunks.reduce((s, c) => s + c.length, 0);
|
|
513
|
+
const buf = new Uint8Array(total);
|
|
514
|
+
let offset = 0;
|
|
515
|
+
for (const c of chunks) {
|
|
516
|
+
buf.set(c, offset);
|
|
517
|
+
offset += c.length;
|
|
518
|
+
}
|
|
519
|
+
return buf.buffer as ArrayBuffer;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async put(
|
|
523
|
+
key: string,
|
|
524
|
+
value: string | ArrayBuffer | ReadableStream | Blob | null,
|
|
525
|
+
options?: R2PutOptions,
|
|
526
|
+
): Promise<R2Object | null> {
|
|
527
|
+
this.validateKey(key);
|
|
528
|
+
this.validateCustomMetadata(options?.customMetadata);
|
|
529
|
+
|
|
530
|
+
// Check conditional before writing
|
|
531
|
+
if (options?.onlyIf) {
|
|
532
|
+
const existing = this.getRow(key);
|
|
533
|
+
if (existing) {
|
|
534
|
+
const meta = rowToMeta(existing);
|
|
535
|
+
if (!evaluateConditional(options.onlyIf, meta.etag, meta.uploaded)) {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const data = await this.readValue(value);
|
|
542
|
+
|
|
543
|
+
const fp = this.filePath(key);
|
|
544
|
+
mkdirSync(dirname(fp), { recursive: true });
|
|
545
|
+
await Bun.write(fp, data);
|
|
546
|
+
|
|
547
|
+
const hasher = new Bun.CryptoHasher("md5");
|
|
548
|
+
hasher.update(new Uint8Array(data));
|
|
549
|
+
const etag = hasher.digest("hex");
|
|
550
|
+
const uploaded = new Date();
|
|
551
|
+
const version = crypto.randomUUID();
|
|
552
|
+
|
|
553
|
+
// Build checksums from provided hashes
|
|
554
|
+
const checksums: R2Checksums = { md5: Buffer.from(etag, "hex").buffer as ArrayBuffer };
|
|
555
|
+
for (const algo of ["sha1", "sha256", "sha384", "sha512"] as const) {
|
|
556
|
+
const provided = options?.[algo];
|
|
557
|
+
if (provided) {
|
|
558
|
+
(checksums as Record<string, ArrayBuffer>)[algo] =
|
|
559
|
+
typeof provided === "string" ? Buffer.from(provided, "hex").buffer as ArrayBuffer : provided;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
this.db.run(
|
|
564
|
+
`INSERT OR REPLACE INTO r2_objects (bucket, key, size, etag, version, uploaded, http_metadata, custom_metadata, checksums)
|
|
565
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
566
|
+
[
|
|
567
|
+
this.bucket,
|
|
568
|
+
key,
|
|
569
|
+
data.byteLength,
|
|
570
|
+
etag,
|
|
571
|
+
version,
|
|
572
|
+
uploaded.toISOString(),
|
|
573
|
+
options?.httpMetadata ? JSON.stringify(options.httpMetadata) : null,
|
|
574
|
+
options?.customMetadata ? JSON.stringify(options.customMetadata) : null,
|
|
575
|
+
JSON.stringify(serializeChecksums(checksums)),
|
|
576
|
+
],
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
return new R2Object({
|
|
580
|
+
key,
|
|
581
|
+
size: data.byteLength,
|
|
582
|
+
etag,
|
|
583
|
+
version,
|
|
584
|
+
uploaded,
|
|
585
|
+
httpMetadata: options?.httpMetadata ?? {},
|
|
586
|
+
customMetadata: options?.customMetadata ?? {},
|
|
587
|
+
checksums,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private getRow(key: string): R2Row | null {
|
|
592
|
+
return this.db
|
|
593
|
+
.query<R2Row, [string, string]>(
|
|
594
|
+
`SELECT key, size, etag, version, uploaded, http_metadata, custom_metadata, checksums FROM r2_objects WHERE bucket = ? AND key = ?`,
|
|
595
|
+
)
|
|
596
|
+
.get(this.bucket, key);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | R2Object | null> {
|
|
600
|
+
const row = this.getRow(key);
|
|
601
|
+
if (!row) return null;
|
|
602
|
+
|
|
603
|
+
const meta = rowToMeta(row);
|
|
604
|
+
|
|
605
|
+
// Check conditional
|
|
606
|
+
if (options?.onlyIf) {
|
|
607
|
+
if (!evaluateConditional(options.onlyIf, meta.etag, meta.uploaded)) {
|
|
608
|
+
// Return R2Object (metadata only, no body) when condition fails
|
|
609
|
+
return new R2Object(meta);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const fp = this.filePath(key);
|
|
614
|
+
const file = Bun.file(fp);
|
|
615
|
+
let data = await file.arrayBuffer();
|
|
616
|
+
|
|
617
|
+
// Handle range reads
|
|
618
|
+
if (options?.range) {
|
|
619
|
+
const range = options.range;
|
|
620
|
+
let offset: number;
|
|
621
|
+
let length: number;
|
|
622
|
+
|
|
623
|
+
if ("suffix" in range && range.suffix !== undefined) {
|
|
624
|
+
// suffix: last N bytes
|
|
625
|
+
offset = Math.max(0, data.byteLength - range.suffix);
|
|
626
|
+
length = data.byteLength - offset;
|
|
627
|
+
} else {
|
|
628
|
+
offset = range.offset ?? 0;
|
|
629
|
+
length = range.length ?? (data.byteLength - offset);
|
|
630
|
+
// Clamp to actual data size
|
|
631
|
+
if (offset + length > data.byteLength) {
|
|
632
|
+
length = data.byteLength - offset;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
data = data.slice(offset, offset + length);
|
|
637
|
+
meta.range = { offset, length };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return new R2ObjectBody(meta, data);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async head(key: string): Promise<R2Object | null> {
|
|
644
|
+
const row = this.getRow(key);
|
|
645
|
+
if (!row) return null;
|
|
646
|
+
return new R2Object(rowToMeta(row));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async delete(key: string | string[]) {
|
|
650
|
+
const keys = Array.isArray(key) ? key : [key];
|
|
651
|
+
if (keys.length > this.limits.maxBatchDeleteKeys) {
|
|
652
|
+
throw new Error(`Cannot delete more than ${this.limits.maxBatchDeleteKeys} keys at once`);
|
|
653
|
+
}
|
|
654
|
+
for (const k of keys) {
|
|
655
|
+
this.db.run(`DELETE FROM r2_objects WHERE bucket = ? AND key = ?`, [this.bucket, k]);
|
|
656
|
+
const fp = this.filePath(k);
|
|
657
|
+
if (existsSync(fp)) {
|
|
658
|
+
rmSync(fp);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async list(options?: R2ListOptions) {
|
|
664
|
+
const prefix = options?.prefix ?? "";
|
|
665
|
+
const limit = options?.limit ?? 1000;
|
|
666
|
+
const delimiter = options?.delimiter;
|
|
667
|
+
const include = options?.include;
|
|
668
|
+
const cursorOffset = options?.cursor ? parseInt(options.cursor, 10) : 0;
|
|
669
|
+
|
|
670
|
+
const rows = this.db
|
|
671
|
+
.query<R2Row, [string, string, number, number]>(
|
|
672
|
+
`SELECT key, size, etag, version, uploaded, http_metadata, custom_metadata, checksums
|
|
673
|
+
FROM r2_objects WHERE bucket = ? AND key LIKE ? ORDER BY key LIMIT ? OFFSET ?`,
|
|
674
|
+
)
|
|
675
|
+
.all(this.bucket, prefix + "%", limit + 1, cursorOffset);
|
|
676
|
+
|
|
677
|
+
if (delimiter) {
|
|
678
|
+
// With delimiter: group keys by delimiter, return delimitedPrefixes
|
|
679
|
+
const prefixLen = prefix.length;
|
|
680
|
+
const delimitedPrefixes = new Set<string>();
|
|
681
|
+
const objects: R2Object[] = [];
|
|
682
|
+
|
|
683
|
+
for (const row of rows.slice(0, limit)) {
|
|
684
|
+
const rest = row.key.slice(prefixLen);
|
|
685
|
+
const delimIdx = rest.indexOf(delimiter);
|
|
686
|
+
if (delimIdx !== -1) {
|
|
687
|
+
delimitedPrefixes.add(prefix + rest.slice(0, delimIdx + delimiter.length));
|
|
688
|
+
} else {
|
|
689
|
+
const meta = rowToMeta(row);
|
|
690
|
+
objects.push(buildListObject(meta, include));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const truncated = rows.length > limit;
|
|
695
|
+
return {
|
|
696
|
+
objects,
|
|
697
|
+
truncated,
|
|
698
|
+
cursor: truncated ? String(cursorOffset + limit) : "",
|
|
699
|
+
delimitedPrefixes: [...delimitedPrefixes].sort(),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const truncated = rows.length > limit;
|
|
704
|
+
const resultRows = truncated ? rows.slice(0, limit) : rows;
|
|
705
|
+
const objects = resultRows.map((row) => {
|
|
706
|
+
const meta = rowToMeta(row);
|
|
707
|
+
return buildListObject(meta, include);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
return {
|
|
711
|
+
objects,
|
|
712
|
+
truncated,
|
|
713
|
+
cursor: truncated ? String(cursorOffset + limit) : "",
|
|
714
|
+
delimitedPrefixes: [] as string[],
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async createMultipartUpload(
|
|
719
|
+
key: string,
|
|
720
|
+
options?: { httpMetadata?: Record<string, string>; customMetadata?: Record<string, string> },
|
|
721
|
+
): Promise<R2MultipartUpload> {
|
|
722
|
+
this.validateKey(key);
|
|
723
|
+
this.validateCustomMetadata(options?.customMetadata);
|
|
724
|
+
|
|
725
|
+
const uploadId = crypto.randomUUID();
|
|
726
|
+
this.db.run(
|
|
727
|
+
`INSERT INTO r2_multipart_uploads (upload_id, bucket, key, http_metadata, custom_metadata, created_at)
|
|
728
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
729
|
+
[
|
|
730
|
+
uploadId,
|
|
731
|
+
this.bucket,
|
|
732
|
+
key,
|
|
733
|
+
options?.httpMetadata ? JSON.stringify(options.httpMetadata) : null,
|
|
734
|
+
options?.customMetadata ? JSON.stringify(options.customMetadata) : null,
|
|
735
|
+
new Date().toISOString(),
|
|
736
|
+
],
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
return new R2MultipartUpload(this.db, this.bucket, this.baseDir, key, uploadId, this.limits);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload {
|
|
743
|
+
return new R2MultipartUpload(this.db, this.bucket, this.baseDir, key, uploadId, this.limits);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function buildListObject(meta: R2ObjectMeta, include?: ("httpMetadata" | "customMetadata")[]): R2Object {
|
|
748
|
+
if (include) {
|
|
749
|
+
// Only include requested metadata
|
|
750
|
+
const filtered: R2ObjectMeta = {
|
|
751
|
+
...meta,
|
|
752
|
+
httpMetadata: include.includes("httpMetadata") ? meta.httpMetadata : {},
|
|
753
|
+
customMetadata: include.includes("customMetadata") ? meta.customMetadata : {},
|
|
754
|
+
};
|
|
755
|
+
return new R2Object(filtered);
|
|
756
|
+
}
|
|
757
|
+
// By default, include both
|
|
758
|
+
return new R2Object(meta);
|
|
759
|
+
}
|