@hot-updater/cloudflare 0.31.4 → 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/iac/index.cjs +206 -92
- package/dist/iac/index.mjs +198 -84
- package/dist/index.cjs +223 -63
- package/dist/index.d.cts +46 -6
- package/dist/index.d.mts +46 -6
- package/dist/index.mjs +218 -59
- package/dist/worker/index.cjs +9 -12
- package/dist/worker/index.d.cts +4 -3
- package/dist/worker/index.d.mts +3 -2
- package/dist/worker/index.mjs +9 -12
- package/package.json +10 -7
- package/src/cloudflareWorkerDatabase.spec.ts +260 -0
- package/src/cloudflareWorkerDatabase.ts +23 -19
- package/src/d1Database.spec.ts +16 -2
- package/src/d1Database.ts +23 -19
- package/src/r2S3Storage.ts +197 -0
- package/src/r2Storage.spec.ts +316 -2
- package/src/r2Storage.ts +50 -110
- package/src/r2WranglerStorage.ts +193 -0
- package/worker/dist/README.md +1 -1
- package/worker/dist/index.js +249 -58
- package/worker/dist/index.js.map +4 -4
- package/worker/src/index.ts +0 -1
- package/worker/src/getUpdateInfo.ts +0 -194
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { DatabasePlugin } from "@hot-updater/plugin-core";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { d1Database, type RequestEnvContext } from "./worker";
|
|
5
|
+
|
|
6
|
+
type WorkerBundleRow = {
|
|
7
|
+
id: string;
|
|
8
|
+
channel: string;
|
|
9
|
+
enabled: number;
|
|
10
|
+
should_force_update: number;
|
|
11
|
+
file_hash: string;
|
|
12
|
+
git_commit_hash: string | null;
|
|
13
|
+
message: string | null;
|
|
14
|
+
platform: "ios" | "android";
|
|
15
|
+
target_app_version: string | null;
|
|
16
|
+
storage_uri: string;
|
|
17
|
+
fingerprint_hash: string | null;
|
|
18
|
+
metadata: string;
|
|
19
|
+
manifest_storage_uri: string | null;
|
|
20
|
+
manifest_file_hash: string | null;
|
|
21
|
+
asset_base_storage_uri: string | null;
|
|
22
|
+
rollout_cohort_count: number | null;
|
|
23
|
+
target_cohorts: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type WorkerPatchRow = {
|
|
27
|
+
id: string;
|
|
28
|
+
bundle_id: string;
|
|
29
|
+
base_bundle_id: string;
|
|
30
|
+
base_file_hash: string;
|
|
31
|
+
patch_file_hash: string;
|
|
32
|
+
patch_storage_uri: string;
|
|
33
|
+
order_index: number | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type TestEnv = {
|
|
37
|
+
DB: ReturnType<typeof createD1Binding>;
|
|
38
|
+
JWT_SECRET: string;
|
|
39
|
+
BUCKET: {
|
|
40
|
+
get: (key: string) => Promise<{ text: () => Promise<string> } | null>;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const rows = new Map<string, WorkerBundleRow>();
|
|
45
|
+
const patchRows = new Map<string, WorkerPatchRow>();
|
|
46
|
+
|
|
47
|
+
const createBundleRow = (index: number): WorkerBundleRow => {
|
|
48
|
+
const id = `00000000-0000-0000-0000-${String(index).padStart(12, "0")}`;
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
channel: "production",
|
|
52
|
+
enabled: 1,
|
|
53
|
+
should_force_update: 0,
|
|
54
|
+
file_hash: `hash-${index}`,
|
|
55
|
+
git_commit_hash: null,
|
|
56
|
+
message: null,
|
|
57
|
+
platform: "ios",
|
|
58
|
+
target_app_version: `>=0.${index}.0`,
|
|
59
|
+
storage_uri: `r2://bucket/${id}.zip`,
|
|
60
|
+
fingerprint_hash: null,
|
|
61
|
+
metadata: "{}",
|
|
62
|
+
manifest_storage_uri: null,
|
|
63
|
+
manifest_file_hash: null,
|
|
64
|
+
asset_base_storage_uri: null,
|
|
65
|
+
rollout_cohort_count: 1000,
|
|
66
|
+
target_cohorts: null,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const normalizeSql = (sql: string) => sql.replace(/\s+/g, " ").trim();
|
|
71
|
+
|
|
72
|
+
const filterRows = (sql: string, params: unknown[]) => {
|
|
73
|
+
let filteredRows = Array.from(rows.values());
|
|
74
|
+
let index = 0;
|
|
75
|
+
|
|
76
|
+
if (sql.includes("channel = ?")) {
|
|
77
|
+
const channel = params[index++];
|
|
78
|
+
filteredRows = filteredRows.filter((row) => row.channel === channel);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (sql.includes("platform = ?")) {
|
|
82
|
+
const platform = params[index++];
|
|
83
|
+
filteredRows = filteredRows.filter((row) => row.platform === platform);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (sql.includes("enabled = ?")) {
|
|
87
|
+
const enabled = Number(params[index++]);
|
|
88
|
+
filteredRows = filteredRows.filter((row) => row.enabled === enabled);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (sql.includes("id >= ?")) {
|
|
92
|
+
const id = String(params[index++]);
|
|
93
|
+
filteredRows = filteredRows.filter((row) => row.id.localeCompare(id) >= 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (sql.includes("target_app_version IS NOT NULL")) {
|
|
97
|
+
filteredRows = filteredRows.filter(
|
|
98
|
+
(row) => row.target_app_version !== null,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const inMatch = sql.match(/target_app_version IN \(([^)]+)\)/);
|
|
103
|
+
if (inMatch) {
|
|
104
|
+
const body = inMatch[1] ?? "";
|
|
105
|
+
const inValues = body.includes("json_each(")
|
|
106
|
+
? (JSON.parse(String(params[index++])) as unknown[])
|
|
107
|
+
: params.slice(index, index + (body.match(/\?/g) ?? []).length);
|
|
108
|
+
const values = new Set(inValues);
|
|
109
|
+
filteredRows = filteredRows.filter((row) =>
|
|
110
|
+
values.has(row.target_app_version),
|
|
111
|
+
);
|
|
112
|
+
if (!body.includes("json_each(")) {
|
|
113
|
+
index += inValues.length;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { filteredRows, index };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function createD1Binding() {
|
|
121
|
+
return {
|
|
122
|
+
prepare(sql: string) {
|
|
123
|
+
return {
|
|
124
|
+
bind(...params: unknown[]) {
|
|
125
|
+
if (params.length > 100) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
"D1_ERROR: too many SQL variables at offset 386: SQLITE_ERROR",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
async all<T>() {
|
|
133
|
+
const normalizedSql = normalizeSql(sql).toLowerCase();
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
normalizedSql.startsWith(
|
|
137
|
+
"select target_app_version from bundles",
|
|
138
|
+
)
|
|
139
|
+
) {
|
|
140
|
+
const { filteredRows } = filterRows(sql, params);
|
|
141
|
+
const targetAppVersions = Array.from(
|
|
142
|
+
new Set(
|
|
143
|
+
filteredRows
|
|
144
|
+
.map((row) => row.target_app_version)
|
|
145
|
+
.filter(
|
|
146
|
+
(targetAppVersion): targetAppVersion is string =>
|
|
147
|
+
targetAppVersion !== null,
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
).map((targetAppVersion) => ({
|
|
151
|
+
target_app_version: targetAppVersion,
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
return { results: targetAppVersions as T[] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (
|
|
158
|
+
normalizedSql.startsWith(
|
|
159
|
+
"select count(*) as total from bundles",
|
|
160
|
+
)
|
|
161
|
+
) {
|
|
162
|
+
const { filteredRows } = filterRows(sql, params);
|
|
163
|
+
return { results: [{ total: filteredRows.length }] as T[] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (normalizedSql.startsWith("select * from bundles")) {
|
|
167
|
+
const { filteredRows, index } = filterRows(sql, params);
|
|
168
|
+
const limit = Number(params[index] ?? filteredRows.length);
|
|
169
|
+
const offset = Number(params[index + 1] ?? 0);
|
|
170
|
+
const result = filteredRows
|
|
171
|
+
.sort((left, right) => right.id.localeCompare(left.id))
|
|
172
|
+
.slice(offset, offset + limit);
|
|
173
|
+
|
|
174
|
+
return { results: result as T[] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (
|
|
178
|
+
normalizedSql.startsWith(
|
|
179
|
+
"select * from bundle_patches where bundle_id in",
|
|
180
|
+
)
|
|
181
|
+
) {
|
|
182
|
+
const selectedBundleIds = new Set(
|
|
183
|
+
normalizedSql.includes("json_each")
|
|
184
|
+
? (JSON.parse(String(params[0])) as unknown[]).map(String)
|
|
185
|
+
: params.map(String),
|
|
186
|
+
);
|
|
187
|
+
const result = Array.from(patchRows.values()).filter((row) =>
|
|
188
|
+
selectedBundleIds.has(row.bundle_id),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return { results: result as T[] };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
throw new Error(`Unsupported SQL in D1 worker mock: ${sql}`);
|
|
195
|
+
},
|
|
196
|
+
async first<T>() {
|
|
197
|
+
return (await this.all<T>()).results?.[0] ?? null;
|
|
198
|
+
},
|
|
199
|
+
async run() {
|
|
200
|
+
return {};
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
describe("cloudflare worker d1Database", () => {
|
|
210
|
+
let plugin: DatabasePlugin<RequestEnvContext<TestEnv>>;
|
|
211
|
+
let context: RequestEnvContext<TestEnv>;
|
|
212
|
+
|
|
213
|
+
beforeEach(() => {
|
|
214
|
+
rows.clear();
|
|
215
|
+
patchRows.clear();
|
|
216
|
+
plugin = d1Database<RequestEnvContext<TestEnv>>()();
|
|
217
|
+
context = {
|
|
218
|
+
env: {
|
|
219
|
+
DB: createD1Binding(),
|
|
220
|
+
JWT_SECRET: "test-secret",
|
|
221
|
+
BUCKET: {
|
|
222
|
+
get: async () => null,
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("queries getUpdateInfo with 200 distinct target_app_versions without exceeding D1's 100-bind cap", async () => {
|
|
229
|
+
for (let index = 0; index < 200; index++) {
|
|
230
|
+
const row = createBundleRow(index);
|
|
231
|
+
rows.set(row.id, row);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const result = await plugin.getUpdateInfo?.(
|
|
235
|
+
{
|
|
236
|
+
appVersion: "1.0.0",
|
|
237
|
+
bundleId: "00000000-0000-0000-0000-000000000000",
|
|
238
|
+
platform: "ios",
|
|
239
|
+
channel: "production",
|
|
240
|
+
minBundleId: "00000000-0000-0000-0000-000000000000",
|
|
241
|
+
_updateStrategy: "appVersion",
|
|
242
|
+
},
|
|
243
|
+
context,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
expect(result).not.toBeNull();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("queries patches for 200 listed bundles without exceeding D1's 100-bind cap", async () => {
|
|
250
|
+
for (let index = 0; index < 200; index++) {
|
|
251
|
+
const row = createBundleRow(index);
|
|
252
|
+
rows.set(row.id, row);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const result = await plugin.getBundles({ limit: 200 }, context);
|
|
256
|
+
|
|
257
|
+
expect(result.data).toHaveLength(200);
|
|
258
|
+
expect(result.pagination.total).toBe(200);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -52,6 +52,19 @@ interface BuildQueryResult {
|
|
|
52
52
|
params: unknown[];
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const buildJsonEachInClause = (
|
|
56
|
+
columnName: string,
|
|
57
|
+
values: string[],
|
|
58
|
+
params: unknown[],
|
|
59
|
+
) => {
|
|
60
|
+
if (values.length === 0) {
|
|
61
|
+
return "1 = 0";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
params.push(JSON.stringify(values));
|
|
65
|
+
return `${columnName} IN (SELECT value FROM json_each(?))`;
|
|
66
|
+
};
|
|
67
|
+
|
|
55
68
|
interface D1WorkerBundleRow {
|
|
56
69
|
id: string;
|
|
57
70
|
channel: string;
|
|
@@ -108,12 +121,7 @@ function buildWhereClause(
|
|
|
108
121
|
}
|
|
109
122
|
|
|
110
123
|
if (conditions.id?.in) {
|
|
111
|
-
|
|
112
|
-
clauses.push("1 = 0");
|
|
113
|
-
} else {
|
|
114
|
-
clauses.push(`id IN (${conditions.id.in.map(() => "?").join(", ")})`);
|
|
115
|
-
params.push(...conditions.id.in);
|
|
116
|
-
}
|
|
124
|
+
clauses.push(buildJsonEachInClause("id", conditions.id.in, params));
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
if (conditions.id?.eq) {
|
|
@@ -155,16 +163,13 @@ function buildWhereClause(
|
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
if (conditions.targetAppVersionIn) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
);
|
|
166
|
-
params.push(...conditions.targetAppVersionIn);
|
|
167
|
-
}
|
|
166
|
+
clauses.push(
|
|
167
|
+
buildJsonEachInClause(
|
|
168
|
+
"target_app_version",
|
|
169
|
+
conditions.targetAppVersionIn,
|
|
170
|
+
params,
|
|
171
|
+
),
|
|
172
|
+
);
|
|
168
173
|
}
|
|
169
174
|
|
|
170
175
|
if (conditions.fingerprintHash !== undefined) {
|
|
@@ -335,15 +340,14 @@ export const d1WorkerDatabase = <
|
|
|
335
340
|
return patchMap;
|
|
336
341
|
}
|
|
337
342
|
|
|
338
|
-
const placeholders = bundleIds.map(() => "?").join(", ");
|
|
339
343
|
const rows = await queryAll<D1WorkerBundlePatchRow>(
|
|
340
344
|
`
|
|
341
345
|
SELECT *
|
|
342
346
|
FROM bundle_patches
|
|
343
|
-
WHERE bundle_id IN (
|
|
347
|
+
WHERE bundle_id IN (SELECT value FROM json_each(?))
|
|
344
348
|
ORDER BY order_index ASC, base_bundle_id ASC
|
|
345
349
|
`,
|
|
346
|
-
bundleIds,
|
|
350
|
+
[JSON.stringify(bundleIds)],
|
|
347
351
|
context,
|
|
348
352
|
);
|
|
349
353
|
|
package/src/d1Database.spec.ts
CHANGED
|
@@ -64,7 +64,13 @@ const getFilteredRows = (sql: string, params: any[]) => {
|
|
|
64
64
|
return null;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
const
|
|
67
|
+
const body = match[1] ?? "";
|
|
68
|
+
if (body.includes("json_each(")) {
|
|
69
|
+
const values = JSON.parse(String(params[index++])) as unknown;
|
|
70
|
+
return Array.isArray(values) ? values : [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const count = (body.match(/\?/g) ?? []).length;
|
|
68
74
|
const values = params.slice(index, index + count);
|
|
69
75
|
index += count;
|
|
70
76
|
return values;
|
|
@@ -169,6 +175,12 @@ vi.mock("cloudflare", () => ({
|
|
|
169
175
|
params?: any[];
|
|
170
176
|
},
|
|
171
177
|
) => {
|
|
178
|
+
if (params.length > 100) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
"D1_ERROR: too many SQL variables at offset 386: SQLITE_ERROR",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
172
184
|
const normalizedSql = sql.replace(/\s+/g, " ").trim().toLowerCase();
|
|
173
185
|
|
|
174
186
|
if (
|
|
@@ -190,7 +202,9 @@ vi.mock("cloudflare", () => ({
|
|
|
190
202
|
)
|
|
191
203
|
) {
|
|
192
204
|
const selectedBundleIds = new Set(
|
|
193
|
-
|
|
205
|
+
normalizedSql.includes("json_each")
|
|
206
|
+
? (JSON.parse(String(params[0])) as unknown[]).map(String)
|
|
207
|
+
: params.map((value) => String(value)),
|
|
194
208
|
);
|
|
195
209
|
const result = Array.from(patchRows.values())
|
|
196
210
|
.filter((row) => selectedBundleIds.has(row.bundle_id))
|
package/src/d1Database.ts
CHANGED
|
@@ -34,6 +34,19 @@ interface BuildQueryResult {
|
|
|
34
34
|
params: any[];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
const buildJsonEachInClause = (
|
|
38
|
+
columnName: string,
|
|
39
|
+
values: string[],
|
|
40
|
+
params: any[],
|
|
41
|
+
) => {
|
|
42
|
+
if (values.length === 0) {
|
|
43
|
+
return "1 = 0";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
params.push(JSON.stringify(values));
|
|
47
|
+
return `${columnName} IN (SELECT value FROM json_each(?))`;
|
|
48
|
+
};
|
|
49
|
+
|
|
37
50
|
interface D1BundleRow {
|
|
38
51
|
id: string;
|
|
39
52
|
channel: string;
|
|
@@ -94,12 +107,7 @@ function buildWhereClause(conditions: QueryConditions): BuildQueryResult {
|
|
|
94
107
|
}
|
|
95
108
|
|
|
96
109
|
if (conditions.id?.in) {
|
|
97
|
-
|
|
98
|
-
clauses.push("1 = 0");
|
|
99
|
-
} else {
|
|
100
|
-
clauses.push(`id IN (${conditions.id.in.map(() => "?").join(", ")})`);
|
|
101
|
-
params.push(...conditions.id.in);
|
|
102
|
-
}
|
|
110
|
+
clauses.push(buildJsonEachInClause("id", conditions.id.in, params));
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
if (conditions.id?.eq) {
|
|
@@ -141,16 +149,13 @@ function buildWhereClause(conditions: QueryConditions): BuildQueryResult {
|
|
|
141
149
|
}
|
|
142
150
|
|
|
143
151
|
if (conditions.targetAppVersionIn) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
);
|
|
152
|
-
params.push(...conditions.targetAppVersionIn);
|
|
153
|
-
}
|
|
152
|
+
clauses.push(
|
|
153
|
+
buildJsonEachInClause(
|
|
154
|
+
"target_app_version",
|
|
155
|
+
conditions.targetAppVersionIn,
|
|
156
|
+
params,
|
|
157
|
+
),
|
|
158
|
+
);
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
if (conditions.fingerprintHash !== undefined) {
|
|
@@ -275,18 +280,17 @@ export const d1Database = createDatabasePlugin<D1DatabaseConfig>({
|
|
|
275
280
|
return patchMap;
|
|
276
281
|
}
|
|
277
282
|
|
|
278
|
-
const placeholders = bundleIds.map(() => "?").join(", ");
|
|
279
283
|
const sql = minify(`
|
|
280
284
|
SELECT *
|
|
281
285
|
FROM bundle_patches
|
|
282
|
-
WHERE bundle_id IN (
|
|
286
|
+
WHERE bundle_id IN (SELECT value FROM json_each(?))
|
|
283
287
|
ORDER BY order_index ASC, base_bundle_id ASC
|
|
284
288
|
`);
|
|
285
289
|
|
|
286
290
|
const result = await cf.d1.database.query(config.databaseId, {
|
|
287
291
|
account_id: config.accountId,
|
|
288
292
|
sql,
|
|
289
|
-
params: bundleIds,
|
|
293
|
+
params: [JSON.stringify(bundleIds)],
|
|
290
294
|
});
|
|
291
295
|
const rows = await resolvePage<D1BundlePatchRow>(result);
|
|
292
296
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DeleteObjectCommand,
|
|
6
|
+
GetObjectCommand,
|
|
7
|
+
HeadObjectCommand,
|
|
8
|
+
S3Client,
|
|
9
|
+
type S3ClientConfig,
|
|
10
|
+
} from "@aws-sdk/client-s3";
|
|
11
|
+
import { Upload } from "@aws-sdk/lib-storage";
|
|
12
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
13
|
+
import {
|
|
14
|
+
createStorageKeyBuilder,
|
|
15
|
+
getContentType,
|
|
16
|
+
type NodeStorageProfile,
|
|
17
|
+
parseStorageUri,
|
|
18
|
+
type RuntimeStorageProfile,
|
|
19
|
+
} from "@hot-updater/plugin-core";
|
|
20
|
+
|
|
21
|
+
export interface R2S3StorageConfig extends S3ClientConfig {
|
|
22
|
+
accountId: string;
|
|
23
|
+
bucketName: string;
|
|
24
|
+
credentials: NonNullable<S3ClientConfig["credentials"]>;
|
|
25
|
+
/**
|
|
26
|
+
* Base path where bundles will be stored in the bucket
|
|
27
|
+
*/
|
|
28
|
+
basePath?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ensureExpectedR2Bucket = (bucket: string, bucketName: string) => {
|
|
32
|
+
if (bucket !== bucketName) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Bucket name mismatch: expected "${bucketName}", but found "${bucket}".`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const isS3ObjectNotFoundError = (error: unknown) => {
|
|
40
|
+
if (error instanceof Error) {
|
|
41
|
+
return error.name === "NotFound" || error.name === "NoSuchKey";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof error === "object" && error !== null && "$metadata" in error) {
|
|
45
|
+
return (
|
|
46
|
+
(error as { $metadata?: { httpStatusCode?: number } }).$metadata
|
|
47
|
+
?.httpStatusCode === 404
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return false;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const createS3Client = (config: R2S3StorageConfig) => {
|
|
55
|
+
const {
|
|
56
|
+
accountId,
|
|
57
|
+
basePath: _basePath,
|
|
58
|
+
bucketName: _bucketName,
|
|
59
|
+
endpoint,
|
|
60
|
+
forcePathStyle,
|
|
61
|
+
region,
|
|
62
|
+
...s3Config
|
|
63
|
+
} = config;
|
|
64
|
+
|
|
65
|
+
return new S3Client({
|
|
66
|
+
...s3Config,
|
|
67
|
+
endpoint: endpoint ?? `https://${accountId}.r2.cloudflarestorage.com`,
|
|
68
|
+
forcePathStyle: forcePathStyle ?? true,
|
|
69
|
+
region: region ?? "auto",
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const createS3StorageProfile = (
|
|
74
|
+
config: R2S3StorageConfig,
|
|
75
|
+
): NodeStorageProfile => {
|
|
76
|
+
const { bucketName } = config;
|
|
77
|
+
const client = createS3Client(config);
|
|
78
|
+
const getStorageKey = createStorageKeyBuilder(config.basePath);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
async delete(storageUri) {
|
|
82
|
+
const { bucket, key } = parseStorageUri(storageUri, "r2");
|
|
83
|
+
ensureExpectedR2Bucket(bucket, bucketName);
|
|
84
|
+
|
|
85
|
+
await client.send(
|
|
86
|
+
new DeleteObjectCommand({ Bucket: bucketName, Key: key }),
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
async upload(key, filePath) {
|
|
90
|
+
const Body = await fs.readFile(filePath);
|
|
91
|
+
const ContentType = getContentType(filePath);
|
|
92
|
+
const filename = path.basename(filePath);
|
|
93
|
+
const Key = getStorageKey(key, filename);
|
|
94
|
+
|
|
95
|
+
const upload = new Upload({
|
|
96
|
+
client,
|
|
97
|
+
params: {
|
|
98
|
+
Body,
|
|
99
|
+
Bucket: bucketName,
|
|
100
|
+
CacheControl: "max-age=31536000",
|
|
101
|
+
ContentType,
|
|
102
|
+
Key,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
await upload.done();
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
storageUri: `r2://${bucketName}/${Key}`,
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
async exists(storageUri: string) {
|
|
112
|
+
const { bucket, key } = parseStorageUri(storageUri, "r2");
|
|
113
|
+
ensureExpectedR2Bucket(bucket, bucketName);
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await client.send(
|
|
117
|
+
new HeadObjectCommand({ Bucket: bucketName, Key: key }),
|
|
118
|
+
);
|
|
119
|
+
return true;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (isS3ObjectNotFoundError(error)) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
async downloadFile(storageUri, filePath) {
|
|
129
|
+
const { bucket, key } = parseStorageUri(storageUri, "r2");
|
|
130
|
+
ensureExpectedR2Bucket(bucket, bucketName);
|
|
131
|
+
|
|
132
|
+
const response = await client.send(
|
|
133
|
+
new GetObjectCommand({ Bucket: bucketName, Key: key }),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (!response.Body) {
|
|
137
|
+
throw new Error("R2 object body is empty");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
141
|
+
await fs.writeFile(filePath, await response.Body.transformToByteArray());
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const createS3RuntimeStorageProfile = (
|
|
147
|
+
config: R2S3StorageConfig,
|
|
148
|
+
): RuntimeStorageProfile => {
|
|
149
|
+
const { bucketName } = config;
|
|
150
|
+
const client = createS3Client(config);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
async readText(storageUri) {
|
|
154
|
+
const { bucket, key } = parseStorageUri(storageUri, "r2");
|
|
155
|
+
ensureExpectedR2Bucket(bucket, bucketName);
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const response = await client.send(
|
|
159
|
+
new GetObjectCommand({ Bucket: bucketName, Key: key }),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
if (!response.Body) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return response.Body.transformToString();
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (isS3ObjectNotFoundError(error)) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
async getDownloadUrl(storageUri) {
|
|
176
|
+
const { bucket, key } = parseStorageUri(storageUri, "r2");
|
|
177
|
+
ensureExpectedR2Bucket(bucket, bucketName);
|
|
178
|
+
|
|
179
|
+
const command = new GetObjectCommand({ Bucket: bucketName, Key: key });
|
|
180
|
+
const signedUrl = await getSignedUrl(
|
|
181
|
+
client as unknown as Parameters<typeof getSignedUrl>[0],
|
|
182
|
+
command,
|
|
183
|
+
{
|
|
184
|
+
expiresIn: 3600,
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (!signedUrl) {
|
|
189
|
+
throw new Error("Failed to presign R2 URL");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
fileUrl: signedUrl,
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
};
|