@hot-updater/cloudflare 1.0.0-rc.5 → 1.0.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/index.cjs +90 -924
- package/dist/index.d.cts +16 -6
- package/dist/index.d.mts +16 -6
- package/dist/index.mjs +91 -926
- package/dist/init/index.cjs +11777 -0
- package/dist/init/index.d.cts +5 -1
- package/dist/init/index.d.mts +5 -1
- package/dist/init/index.mjs +11771 -1
- package/dist/worker/index.cjs +117 -916
- package/dist/worker/index.d.cts +45 -11
- package/dist/worker/index.d.mts +45 -11
- package/dist/worker/index.mjs +118 -917
- package/package.json +9 -14
- package/sql/bundles.sql +121 -212
- package/src/d1Database.ts +58 -43
- package/src/d1Executor.ts +84 -0
- package/src/d1Schema.ts +26 -0
- package/src/presignR2Url.ts +103 -0
- package/src/r2S3Storage.ts +20 -19
- package/src/r2Storage.ts +5 -3
- package/src/r2WorkerStorage.ts +55 -17
- package/src/worker/d1Database.ts +21 -39
- package/worker/dist/README.md +1 -1
- package/worker/dist/index.js +9859 -5696
- package/worker/dist/index.js.map +4 -4
- package/worker/migrations/0001_hot-updater_1.0.0.sql +121 -222
- package/worker/src/index.ts +15 -9
- package/worker/wrangler.json +1 -0
- package/dist/iac/index.cjs +0 -12687
- package/dist/iac/index.d.cts +0 -5
- package/dist/iac/index.d.mts +0 -5
- package/dist/iac/index.mjs +0 -12679
- package/src/d1Database.spec.ts +0 -277
- package/src/d1Implementation.spec.ts +0 -342
- package/src/d1Implementation.ts +0 -781
- package/src/d1Query.spec.ts +0 -46
- package/src/d1Query.ts +0 -92
- package/src/d1Rows.spec.ts +0 -143
- package/src/d1Rows.ts +0 -357
- package/src/d1Sql.spec.ts +0 -30
- package/src/d1Sql.ts +0 -175
- package/src/r2Storage.spec.ts +0 -346
- package/src/r2WorkerStorage.spec.ts +0 -184
- package/src/utils/createWrangler.spec.ts +0 -75
- package/src/worker/d1Database.spec.ts +0 -69
- package/worker/src/databaseMigration.integration.spec.ts +0 -191
- package/worker/src/databasePlugins.integration.spec.ts +0 -326
- package/worker/src/index.integration.spec.ts +0 -190
- package/worker/src/insightsMovement.integration.spec.ts +0 -73
- package/worker/src/storagePlugin.integration.spec.ts +0 -59
- package/worker/src/testWorker.ts +0 -5
package/src/d1Sql.ts
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
DatabaseStringComparisonMode,
|
|
3
|
-
DatabaseWhereConnector,
|
|
4
|
-
DatabaseWhereOperator,
|
|
5
|
-
} from "@hot-updater/plugin-core/internal";
|
|
6
|
-
|
|
7
|
-
export type D1Query = {
|
|
8
|
-
readonly sql: string;
|
|
9
|
-
readonly params: readonly string[];
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
type D1Predicate = {
|
|
13
|
-
readonly field: string;
|
|
14
|
-
readonly operator?: DatabaseWhereOperator;
|
|
15
|
-
readonly value: unknown;
|
|
16
|
-
readonly connector?: DatabaseWhereConnector;
|
|
17
|
-
readonly mode?: DatabaseStringComparisonMode;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
type D1Sort = {
|
|
21
|
-
readonly field: string;
|
|
22
|
-
readonly direction: "asc" | "desc";
|
|
23
|
-
readonly nulls?: "first" | "last";
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
class InvalidD1PredicateError extends Error {
|
|
27
|
-
readonly name = "InvalidD1PredicateError";
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const encodeD1Value = (value: unknown): string => {
|
|
31
|
-
const encoded = JSON.stringify(value);
|
|
32
|
-
if (encoded === undefined) {
|
|
33
|
-
throw new TypeError("D1 values must be JSON-serializable.");
|
|
34
|
-
}
|
|
35
|
-
return encoded;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const bind = (value: unknown): D1Query => ({
|
|
39
|
-
sql: "json_extract(?, '$')",
|
|
40
|
-
params: [encodeD1Value(value)],
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const escapeLikePattern = (value: string): string =>
|
|
44
|
-
value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
45
|
-
|
|
46
|
-
const stringPredicate = (
|
|
47
|
-
condition: D1Predicate,
|
|
48
|
-
operator: "contains" | "ends_with" | "starts_with",
|
|
49
|
-
): D1Query => {
|
|
50
|
-
if (typeof condition.value !== "string") {
|
|
51
|
-
throw new InvalidD1PredicateError();
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (condition.mode !== "insensitive") {
|
|
55
|
-
const parameter = bind(condition.value);
|
|
56
|
-
const column = `${condition.field} COLLATE BINARY`;
|
|
57
|
-
const value = `${parameter.sql} COLLATE BINARY`;
|
|
58
|
-
if (operator === "contains" || operator === "starts_with") {
|
|
59
|
-
return {
|
|
60
|
-
sql: `instr(${column}, ${value}) ${operator === "contains" ? "> 0" : "= 1"}`,
|
|
61
|
-
params: parameter.params,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
return {
|
|
65
|
-
sql: `substr(${column}, length(${condition.field}) - length(${parameter.sql}) + 1) = ${value}`,
|
|
66
|
-
params: [...parameter.params, ...parameter.params],
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const literal = escapeLikePattern(condition.value);
|
|
71
|
-
const pattern =
|
|
72
|
-
operator === "contains"
|
|
73
|
-
? `%${literal}%`
|
|
74
|
-
: operator === "starts_with"
|
|
75
|
-
? `${literal}%`
|
|
76
|
-
: `%${literal}`;
|
|
77
|
-
const parameter = bind(pattern);
|
|
78
|
-
return {
|
|
79
|
-
sql: `lower(${condition.field}) LIKE lower(${parameter.sql}) ESCAPE '\\'`,
|
|
80
|
-
params: parameter.params,
|
|
81
|
-
};
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const predicate = (condition: D1Predicate): D1Query => {
|
|
85
|
-
const operator = condition.operator ?? "eq";
|
|
86
|
-
switch (operator) {
|
|
87
|
-
case "eq":
|
|
88
|
-
case "ne": {
|
|
89
|
-
if (condition.value === null) {
|
|
90
|
-
return {
|
|
91
|
-
sql: `${condition.field} IS ${operator === "ne" ? "NOT " : ""}NULL`,
|
|
92
|
-
params: [],
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
const parameter = bind(condition.value);
|
|
96
|
-
const insensitive =
|
|
97
|
-
"mode" in condition && condition.mode === "insensitive";
|
|
98
|
-
const column = insensitive
|
|
99
|
-
? `lower(${condition.field})`
|
|
100
|
-
: condition.field;
|
|
101
|
-
const value = insensitive ? `lower(${parameter.sql})` : parameter.sql;
|
|
102
|
-
return {
|
|
103
|
-
sql: `${column} ${operator === "eq" ? "=" : "<>"} ${value}`,
|
|
104
|
-
params: parameter.params,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
case "gt":
|
|
108
|
-
case "gte":
|
|
109
|
-
case "lt":
|
|
110
|
-
case "lte": {
|
|
111
|
-
const sqlOperator = {
|
|
112
|
-
gt: ">",
|
|
113
|
-
gte: ">=",
|
|
114
|
-
lt: "<",
|
|
115
|
-
lte: "<=",
|
|
116
|
-
}[operator];
|
|
117
|
-
const parameter = bind(condition.value);
|
|
118
|
-
return {
|
|
119
|
-
sql: `${condition.field} ${sqlOperator} ${parameter.sql}`,
|
|
120
|
-
params: parameter.params,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
case "in":
|
|
124
|
-
case "not_in": {
|
|
125
|
-
if (!Array.isArray(condition.value)) {
|
|
126
|
-
throw new InvalidD1PredicateError();
|
|
127
|
-
}
|
|
128
|
-
if (condition.value.length === 0) {
|
|
129
|
-
return { sql: operator === "not_in" ? "1 = 1" : "1 = 0", params: [] };
|
|
130
|
-
}
|
|
131
|
-
return {
|
|
132
|
-
sql: `${condition.field} ${operator === "not_in" ? "NOT " : ""}IN (SELECT value FROM json_each(?))`,
|
|
133
|
-
params: [encodeD1Value(condition.value)],
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
case "contains":
|
|
137
|
-
case "starts_with":
|
|
138
|
-
case "ends_with":
|
|
139
|
-
return stringPredicate(condition, operator);
|
|
140
|
-
}
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
export const buildD1Where = (
|
|
144
|
-
where: readonly D1Predicate[] | undefined,
|
|
145
|
-
): D1Query => {
|
|
146
|
-
const [first, ...rest] = where ?? [];
|
|
147
|
-
if (first === undefined) return { sql: "", params: [] };
|
|
148
|
-
const initial = predicate(first);
|
|
149
|
-
let sql = initial.sql;
|
|
150
|
-
const params = [...initial.params];
|
|
151
|
-
for (const condition of rest) {
|
|
152
|
-
const next = predicate(condition);
|
|
153
|
-
sql = `(${sql} ${condition.connector === "OR" ? "OR" : "AND"} ${next.sql})`;
|
|
154
|
-
params.push(...next.params);
|
|
155
|
-
}
|
|
156
|
-
return { sql: ` WHERE ${sql}`, params };
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
export const buildD1Order = (orderBy: readonly D1Sort[] | undefined): string =>
|
|
160
|
-
orderBy === undefined
|
|
161
|
-
? ""
|
|
162
|
-
: ` ORDER BY ${orderBy
|
|
163
|
-
.map(
|
|
164
|
-
(clause) =>
|
|
165
|
-
`${clause.field} ${clause.direction.toUpperCase()}${
|
|
166
|
-
clause.nulls ? ` NULLS ${clause.nulls.toUpperCase()}` : ""
|
|
167
|
-
}`,
|
|
168
|
-
)
|
|
169
|
-
.join(", ")}`;
|
|
170
|
-
|
|
171
|
-
export const d1Placeholders = (count: number): string =>
|
|
172
|
-
Array.from({ length: count }, () => "json_extract(?, '$')").join(", ");
|
|
173
|
-
|
|
174
|
-
export const encodeD1Values = (values: readonly unknown[]): readonly string[] =>
|
|
175
|
-
values.map(encodeD1Value);
|
package/src/r2Storage.spec.ts
DELETED
|
@@ -1,346 +0,0 @@
|
|
|
1
|
-
import { Buffer } from "buffer";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
DeleteObjectCommand,
|
|
5
|
-
GetObjectCommand,
|
|
6
|
-
HeadObjectCommand,
|
|
7
|
-
S3Client,
|
|
8
|
-
} from "@aws-sdk/client-s3";
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
-
|
|
11
|
-
import { r2Storage } from "./r2Storage";
|
|
12
|
-
|
|
13
|
-
let fakeStore: Record<string, Buffer> = {};
|
|
14
|
-
let deletedKeys: string[] = [];
|
|
15
|
-
let uploadedParams:
|
|
16
|
-
| {
|
|
17
|
-
Body: ReadableStream<Uint8Array>;
|
|
18
|
-
Bucket: string;
|
|
19
|
-
CacheControl?: string;
|
|
20
|
-
ContentLength?: number;
|
|
21
|
-
ContentType?: string;
|
|
22
|
-
Key: string;
|
|
23
|
-
}
|
|
24
|
-
| undefined;
|
|
25
|
-
|
|
26
|
-
const createBody = (value: string) => new Response(value).body!;
|
|
27
|
-
|
|
28
|
-
vi.mock("@aws-sdk/lib-storage", () => ({
|
|
29
|
-
Upload: class {
|
|
30
|
-
params: {
|
|
31
|
-
Body: ReadableStream<Uint8Array>;
|
|
32
|
-
Bucket: string;
|
|
33
|
-
CacheControl?: string;
|
|
34
|
-
ContentLength?: number;
|
|
35
|
-
ContentType?: string;
|
|
36
|
-
Key: string;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
constructor({
|
|
40
|
-
params,
|
|
41
|
-
}: {
|
|
42
|
-
params: {
|
|
43
|
-
Body: ReadableStream<Uint8Array>;
|
|
44
|
-
Bucket: string;
|
|
45
|
-
CacheControl?: string;
|
|
46
|
-
ContentLength?: number;
|
|
47
|
-
ContentType?: string;
|
|
48
|
-
Key: string;
|
|
49
|
-
};
|
|
50
|
-
}) {
|
|
51
|
-
this.params = params;
|
|
52
|
-
uploadedParams = params;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async done() {
|
|
56
|
-
fakeStore[this.params.Key] = Buffer.from(
|
|
57
|
-
await new Response(this.params.Body).arrayBuffer(),
|
|
58
|
-
);
|
|
59
|
-
return {
|
|
60
|
-
Bucket: this.params.Bucket,
|
|
61
|
-
Key: this.params.Key,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
}));
|
|
66
|
-
|
|
67
|
-
describe("r2Storage", () => {
|
|
68
|
-
beforeEach(() => {
|
|
69
|
-
fakeStore = {};
|
|
70
|
-
deletedKeys = [];
|
|
71
|
-
uploadedParams = undefined;
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
afterEach(() => {
|
|
75
|
-
vi.restoreAllMocks();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const mockS3Client = () => {
|
|
79
|
-
return vi
|
|
80
|
-
.spyOn(S3Client.prototype, "send")
|
|
81
|
-
.mockImplementation(async (command: any) => {
|
|
82
|
-
if (command instanceof HeadObjectCommand) {
|
|
83
|
-
const key = command.input.Key!;
|
|
84
|
-
if (fakeStore[key]) {
|
|
85
|
-
return {};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const error = new Error("Not found");
|
|
89
|
-
error.name = "NotFound";
|
|
90
|
-
throw error;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (command instanceof GetObjectCommand) {
|
|
94
|
-
const key = command.input.Key!;
|
|
95
|
-
const object = fakeStore[key];
|
|
96
|
-
if (!object) {
|
|
97
|
-
const error = new Error("No such key");
|
|
98
|
-
error.name = "NoSuchKey";
|
|
99
|
-
throw error;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
Body: {
|
|
104
|
-
transformToByteArray: async () => new Uint8Array(object),
|
|
105
|
-
transformToString: async () => object.toString("utf8"),
|
|
106
|
-
transformToWebStream: () => new Response(object).body!,
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (command instanceof DeleteObjectCommand) {
|
|
112
|
-
deletedKeys.push(command.input.Key!);
|
|
113
|
-
delete fakeStore[command.input.Key!];
|
|
114
|
-
return {};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
throw new Error("Unsupported command");
|
|
118
|
-
});
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
it("uploads R2 objects through the S3 API when credentials are provided", async () => {
|
|
122
|
-
mockS3Client();
|
|
123
|
-
|
|
124
|
-
const storage = r2Storage({
|
|
125
|
-
accountId: "account-id",
|
|
126
|
-
bucketName: "test-bucket",
|
|
127
|
-
credentials: {
|
|
128
|
-
accessKeyId: "access-key-id",
|
|
129
|
-
secretAccessKey: "secret-access-key",
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
await expect(
|
|
134
|
-
storage.put?.({
|
|
135
|
-
key: "releases/bundle-1/hot-updater-r2-upload.txt",
|
|
136
|
-
body: createBody("hello r2"),
|
|
137
|
-
contentLength: 8,
|
|
138
|
-
contentType: "text/plain",
|
|
139
|
-
}),
|
|
140
|
-
).resolves.toEqual({
|
|
141
|
-
storageUri:
|
|
142
|
-
"r2://test-bucket/releases/bundle-1/hot-updater-r2-upload.txt",
|
|
143
|
-
});
|
|
144
|
-
expect(
|
|
145
|
-
fakeStore["releases/bundle-1/hot-updater-r2-upload.txt"].toString(),
|
|
146
|
-
).toBe("hello r2");
|
|
147
|
-
expect(uploadedParams).toMatchObject({
|
|
148
|
-
ContentLength: 8,
|
|
149
|
-
ContentType: "text/plain",
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("round-trips spaces, Unicode, fragments, and percent signs in S3 keys", async () => {
|
|
154
|
-
mockS3Client();
|
|
155
|
-
const storage = r2Storage({
|
|
156
|
-
accountId: "account-id",
|
|
157
|
-
bucketName: "test-bucket",
|
|
158
|
-
credentials: {
|
|
159
|
-
accessKeyId: "access-key-id",
|
|
160
|
-
secretAccessKey: "secret-access-key",
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
const key = "릴리스 folder/logo@2x #100%/bundle.zip";
|
|
164
|
-
|
|
165
|
-
const uploaded = await storage.put?.({
|
|
166
|
-
key,
|
|
167
|
-
body: createBody("bundle"),
|
|
168
|
-
contentLength: 6,
|
|
169
|
-
contentType: "application/zip",
|
|
170
|
-
});
|
|
171
|
-
expect(uploaded?.storageUri).toContain("logo%402x");
|
|
172
|
-
expect(uploaded?.storageUri).not.toContain("#100%");
|
|
173
|
-
await expect(
|
|
174
|
-
storage.get?.({ storageUri: uploaded!.storageUri }),
|
|
175
|
-
).resolves.toEqual({ response: expect.any(Response) });
|
|
176
|
-
await storage.delete?.({ storageUri: uploaded!.storageUri });
|
|
177
|
-
await storage.delete?.({ storageUri: uploaded!.storageUri });
|
|
178
|
-
|
|
179
|
-
expect(deletedKeys).toEqual([key, key]);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("downloads R2 objects through the S3 API when credentials are provided", async () => {
|
|
183
|
-
mockS3Client();
|
|
184
|
-
fakeStore["releases/bundle-1/manifest.json"] = Buffer.from(
|
|
185
|
-
JSON.stringify({
|
|
186
|
-
assets: {},
|
|
187
|
-
bundleId: "bundle-1",
|
|
188
|
-
}),
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
const storage = r2Storage({
|
|
192
|
-
accountId: "account-id",
|
|
193
|
-
bucketName: "test-bucket",
|
|
194
|
-
credentials: {
|
|
195
|
-
accessKeyId: "access-key-id",
|
|
196
|
-
secretAccessKey: "secret-access-key",
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
const { response } = (await storage.get?.({
|
|
201
|
-
storageUri: "r2://test-bucket/releases/bundle-1/manifest.json",
|
|
202
|
-
})) ?? { response: null };
|
|
203
|
-
|
|
204
|
-
expect(JSON.parse((await response?.text()) ?? "")).toEqual({
|
|
205
|
-
assets: {},
|
|
206
|
-
bundleId: "bundle-1",
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("checks R2 object existence through the S3 API", async () => {
|
|
211
|
-
mockS3Client();
|
|
212
|
-
fakeStore["releases/logo.png"] = Buffer.from("logo");
|
|
213
|
-
|
|
214
|
-
const storage = r2Storage({
|
|
215
|
-
accountId: "account-id",
|
|
216
|
-
bucketName: "test-bucket",
|
|
217
|
-
credentials: {
|
|
218
|
-
accessKeyId: "access-key-id",
|
|
219
|
-
secretAccessKey: "secret-access-key",
|
|
220
|
-
},
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
await expect(
|
|
224
|
-
storage.exists?.({ storageUri: "r2://test-bucket/releases/logo.png" }),
|
|
225
|
-
).resolves.toEqual({ exists: true });
|
|
226
|
-
await expect(
|
|
227
|
-
storage.exists?.({ storageUri: "r2://test-bucket/releases/missing.png" }),
|
|
228
|
-
).resolves.toEqual({ exists: false });
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("deletes R2 objects through the S3 API", async () => {
|
|
232
|
-
mockS3Client();
|
|
233
|
-
fakeStore["releases/logo.png"] = Buffer.from("logo");
|
|
234
|
-
|
|
235
|
-
const storage = r2Storage({
|
|
236
|
-
accountId: "account-id",
|
|
237
|
-
bucketName: "test-bucket",
|
|
238
|
-
credentials: {
|
|
239
|
-
accessKeyId: "access-key-id",
|
|
240
|
-
secretAccessKey: "secret-access-key",
|
|
241
|
-
},
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
await expect(
|
|
245
|
-
storage.delete?.({
|
|
246
|
-
storageUri: "r2://test-bucket/releases/logo.png",
|
|
247
|
-
}),
|
|
248
|
-
).resolves.toEqual({
|
|
249
|
-
deleted: true,
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
expect(deletedKeys).toEqual(["releases/logo.png"]);
|
|
253
|
-
expect(fakeStore["releases/logo.png"]).toBeUndefined();
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it("reads R2 text through the runtime S3 API when credentials are provided", async () => {
|
|
257
|
-
mockS3Client();
|
|
258
|
-
fakeStore["releases/bundle-1/manifest.json"] = Buffer.from(
|
|
259
|
-
JSON.stringify({
|
|
260
|
-
assets: {},
|
|
261
|
-
bundleId: "bundle-1",
|
|
262
|
-
}),
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
const storage = r2Storage({
|
|
266
|
-
accountId: "account-id",
|
|
267
|
-
bucketName: "test-bucket",
|
|
268
|
-
credentials: {
|
|
269
|
-
accessKeyId: "access-key-id",
|
|
270
|
-
secretAccessKey: "secret-access-key",
|
|
271
|
-
},
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const { response } = (await storage.get?.({
|
|
275
|
-
storageUri: "r2://test-bucket/releases/bundle-1/manifest.json",
|
|
276
|
-
})) ?? { response: null };
|
|
277
|
-
expect(await response?.text()).toBe('{"assets":{},"bundleId":"bundle-1"}');
|
|
278
|
-
await expect(
|
|
279
|
-
storage.get?.({
|
|
280
|
-
storageUri: "r2://test-bucket/releases/missing.json",
|
|
281
|
-
}),
|
|
282
|
-
).resolves.toEqual({ response: null });
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it("rejects configs without S3 credentials", () => {
|
|
286
|
-
expect(() =>
|
|
287
|
-
r2Storage({
|
|
288
|
-
accountId: "account-id",
|
|
289
|
-
bucketName: "test-bucket",
|
|
290
|
-
} as never),
|
|
291
|
-
).toThrow("r2Storage requires S3-compatible credentials");
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it("does not add getDownloadUrl to a deploy-only configuration", () => {
|
|
295
|
-
const storage = r2Storage({
|
|
296
|
-
accountId: "account-id",
|
|
297
|
-
bucketName: "test-bucket",
|
|
298
|
-
credentials: {
|
|
299
|
-
accessKeyId: "access-key-id",
|
|
300
|
-
secretAccessKey: "secret-access-key",
|
|
301
|
-
},
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
expect(Reflect.has(storage, "getDownloadUrl")).toBe(false);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it("adds a signed download URL operation when configured for a server", async () => {
|
|
308
|
-
mockS3Client();
|
|
309
|
-
const storage = r2Storage({
|
|
310
|
-
accountId: "account-id",
|
|
311
|
-
bucketName: "test-bucket",
|
|
312
|
-
credentials: {
|
|
313
|
-
accessKeyId: "access-key-id",
|
|
314
|
-
secretAccessKey: "secret-access-key",
|
|
315
|
-
},
|
|
316
|
-
downloadUrlSigningKey: "test-signing-key",
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
await expect(
|
|
320
|
-
storage.getDownloadUrl?.({
|
|
321
|
-
storageUri: "r2://test-bucket/releases/bundle.zip",
|
|
322
|
-
}),
|
|
323
|
-
).resolves.toEqual({
|
|
324
|
-
url: expect.stringMatching(/^\/storage\//),
|
|
325
|
-
});
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it("rejects downloads from a different bucket", async () => {
|
|
329
|
-
const storage = r2Storage({
|
|
330
|
-
accountId: "account-id",
|
|
331
|
-
bucketName: "test-bucket",
|
|
332
|
-
credentials: {
|
|
333
|
-
accessKeyId: "access-key-id",
|
|
334
|
-
secretAccessKey: "secret-access-key",
|
|
335
|
-
},
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
await expect(
|
|
339
|
-
storage.get?.({
|
|
340
|
-
storageUri: "r2://other-bucket/releases/bundle-1/manifest.json",
|
|
341
|
-
}),
|
|
342
|
-
).rejects.toThrow(
|
|
343
|
-
'Bucket name mismatch: expected "test-bucket", but found "other-bucket".',
|
|
344
|
-
);
|
|
345
|
-
});
|
|
346
|
-
});
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { r2WorkerStorage } from "./r2WorkerStorage";
|
|
4
|
-
|
|
5
|
-
const createBucket = (
|
|
6
|
-
get: ReturnType<typeof vi.fn>,
|
|
7
|
-
overrides: Partial<R2Bucket> = {},
|
|
8
|
-
) =>
|
|
9
|
-
({
|
|
10
|
-
get,
|
|
11
|
-
head: vi.fn(),
|
|
12
|
-
put: vi.fn(),
|
|
13
|
-
delete: vi.fn(),
|
|
14
|
-
list: vi.fn(),
|
|
15
|
-
...overrides,
|
|
16
|
-
}) as unknown as R2Bucket;
|
|
17
|
-
|
|
18
|
-
describe("r2WorkerStorage", () => {
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
vi.unstubAllGlobals();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("reads bytes from the R2 binding captured by the implementation", async () => {
|
|
24
|
-
const get = vi.fn(async (key: string) => ({
|
|
25
|
-
body: new Response(`text:${key}`).body,
|
|
26
|
-
httpEtag: '"etag"',
|
|
27
|
-
size: `text:${key}`.length,
|
|
28
|
-
writeHttpMetadata: vi.fn(),
|
|
29
|
-
}));
|
|
30
|
-
const storage = r2WorkerStorage({
|
|
31
|
-
bucket: createBucket(get),
|
|
32
|
-
bucketName: "bundles",
|
|
33
|
-
downloadUrlSigningKey: "test-signing-key",
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
const { response } = await storage.get({
|
|
37
|
-
storageUri: "r2://bundles/app/manifest.json",
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
expect(await response?.text()).toBe("text:app/manifest.json");
|
|
41
|
-
expect(get).toHaveBeenCalledWith("app/manifest.json");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("rejects URIs owned by a different R2 binding", async () => {
|
|
45
|
-
const get = vi.fn();
|
|
46
|
-
const storage = r2WorkerStorage({
|
|
47
|
-
bucket: createBucket(get),
|
|
48
|
-
bucketName: "bundles",
|
|
49
|
-
downloadUrlSigningKey: "test-signing-key",
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
await expect(
|
|
53
|
-
storage.get({ storageUri: "r2://other/app/manifest.json" }),
|
|
54
|
-
).rejects.toThrow(
|
|
55
|
-
'Bucket name mismatch: expected "bundles", but found "other".',
|
|
56
|
-
);
|
|
57
|
-
expect(get).not.toHaveBeenCalled();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("writes and deletes the exact object key through the R2 binding", async () => {
|
|
61
|
-
let uploaded = "";
|
|
62
|
-
const put = vi.fn(async (_key: string, body: Uint8Array) => {
|
|
63
|
-
uploaded = new TextDecoder().decode(body);
|
|
64
|
-
return null;
|
|
65
|
-
});
|
|
66
|
-
const bucket = createBucket(vi.fn(), {
|
|
67
|
-
put: put as unknown as R2Bucket["put"],
|
|
68
|
-
});
|
|
69
|
-
const storage = r2WorkerStorage({
|
|
70
|
-
bucket,
|
|
71
|
-
bucketName: "bundles",
|
|
72
|
-
downloadUrlSigningKey: "test-signing-key",
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
await storage.put({
|
|
76
|
-
key: "releases/bundle.zip",
|
|
77
|
-
body: new Response("bundle").body!,
|
|
78
|
-
contentType: "application/zip",
|
|
79
|
-
});
|
|
80
|
-
await expect(
|
|
81
|
-
storage.delete({
|
|
82
|
-
storageUri: "r2://bundles/releases/bundle.zip",
|
|
83
|
-
}),
|
|
84
|
-
).resolves.toEqual({
|
|
85
|
-
deleted: true,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
expect(bucket.put).toHaveBeenCalledWith(
|
|
89
|
-
"releases/bundle.zip",
|
|
90
|
-
expect.any(Uint8Array),
|
|
91
|
-
{
|
|
92
|
-
httpMetadata: {
|
|
93
|
-
cacheControl: "max-age=31536000",
|
|
94
|
-
contentType: "application/zip",
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
);
|
|
98
|
-
expect(uploaded).toBe("bundle");
|
|
99
|
-
expect(bucket.delete).toHaveBeenCalledWith("releases/bundle.zip");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("round-trips reserved characters without changing the R2 binding key", async () => {
|
|
103
|
-
const bucket = createBucket(vi.fn());
|
|
104
|
-
const storage = r2WorkerStorage({
|
|
105
|
-
bucket,
|
|
106
|
-
bucketName: "bundles",
|
|
107
|
-
downloadUrlSigningKey: "test-signing-key",
|
|
108
|
-
});
|
|
109
|
-
const key = "릴리스 folder/logo@2x #100%/bundle.zip";
|
|
110
|
-
|
|
111
|
-
const uploaded = await storage.put({
|
|
112
|
-
key,
|
|
113
|
-
body: new Response("bundle").body!,
|
|
114
|
-
contentType: "application/zip",
|
|
115
|
-
});
|
|
116
|
-
expect(uploaded.storageUri).toContain("logo%402x");
|
|
117
|
-
await storage.delete({ storageUri: uploaded.storageUri });
|
|
118
|
-
await storage.delete({ storageUri: uploaded.storageUri });
|
|
119
|
-
|
|
120
|
-
expect(bucket.put).toHaveBeenCalledWith(
|
|
121
|
-
key,
|
|
122
|
-
expect.any(Uint8Array),
|
|
123
|
-
expect.any(Object),
|
|
124
|
-
);
|
|
125
|
-
expect(bucket.delete).toHaveBeenNthCalledWith(1, key);
|
|
126
|
-
expect(bucket.delete).toHaveBeenNthCalledWith(2, key);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("uses a fixed-length stream when the upload length is known", async () => {
|
|
130
|
-
const expectedLengths: number[] = [];
|
|
131
|
-
class TestFixedLengthStream extends TransformStream<
|
|
132
|
-
Uint8Array,
|
|
133
|
-
Uint8Array
|
|
134
|
-
> {
|
|
135
|
-
constructor(expectedLength: number) {
|
|
136
|
-
super();
|
|
137
|
-
expectedLengths.push(expectedLength);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
vi.stubGlobal("FixedLengthStream", TestFixedLengthStream);
|
|
141
|
-
let uploaded = "";
|
|
142
|
-
const put = vi.fn(
|
|
143
|
-
async (_key: string, body: ReadableStream<Uint8Array>) => {
|
|
144
|
-
uploaded = await new Response(body).text();
|
|
145
|
-
return null;
|
|
146
|
-
},
|
|
147
|
-
);
|
|
148
|
-
const storage = r2WorkerStorage({
|
|
149
|
-
bucket: createBucket(vi.fn(), {
|
|
150
|
-
put: put as unknown as R2Bucket["put"],
|
|
151
|
-
}),
|
|
152
|
-
bucketName: "bundles",
|
|
153
|
-
downloadUrlSigningKey: "test-signing-key",
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
await storage.put({
|
|
157
|
-
key: "releases/bundle.zip",
|
|
158
|
-
body: new Response("bundle").body!,
|
|
159
|
-
contentLength: 6,
|
|
160
|
-
contentType: "application/zip",
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
expect(expectedLengths).toEqual([6]);
|
|
164
|
-
expect(uploaded).toBe("bundle");
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("returns a stable signed download path for private R2 objects", async () => {
|
|
168
|
-
const storage = r2WorkerStorage({
|
|
169
|
-
bucket: createBucket(vi.fn()),
|
|
170
|
-
bucketName: "bundles",
|
|
171
|
-
downloadUrlSigningKey: "test-signing-key",
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
const first = await storage.getDownloadUrl({
|
|
175
|
-
storageUri: "r2://bundles/releases/bundle.zip",
|
|
176
|
-
});
|
|
177
|
-
const second = await storage.getDownloadUrl({
|
|
178
|
-
storageUri: "r2://bundles/releases/bundle.zip",
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
expect(first).toEqual(second);
|
|
182
|
-
expect(first.url).toMatch(/^\/storage\//);
|
|
183
|
-
});
|
|
184
|
-
});
|