@hot-updater/cloudflare 0.36.7 → 1.0.0-rc.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.
Files changed (48) hide show
  1. package/dist/iac/index.cjs +988 -10
  2. package/dist/iac/index.mjs +991 -14
  3. package/dist/index.cjs +919 -6989
  4. package/dist/index.d.cts +11 -47
  5. package/dist/index.d.mts +11 -47
  6. package/dist/index.mjs +917 -7006
  7. package/dist/worker/index.cjs +898 -336
  8. package/dist/worker/index.d.cts +18 -33
  9. package/dist/worker/index.d.mts +18 -33
  10. package/dist/worker/index.mjs +899 -331
  11. package/package.json +7 -10
  12. package/sql/bundles.sql +183 -35
  13. package/src/d1Database.spec.ts +143 -392
  14. package/src/d1Database.ts +55 -650
  15. package/src/d1Implementation.spec.ts +252 -0
  16. package/src/d1Implementation.ts +742 -0
  17. package/src/d1Query.spec.ts +46 -0
  18. package/src/d1Query.ts +89 -0
  19. package/src/d1Rows.spec.ts +111 -0
  20. package/src/d1Rows.ts +326 -0
  21. package/src/d1Sql.spec.ts +30 -0
  22. package/src/d1Sql.ts +175 -0
  23. package/src/r2S3Storage.ts +93 -137
  24. package/src/r2Storage.spec.ts +120 -218
  25. package/src/r2Storage.ts +13 -61
  26. package/src/r2WorkerStorage.spec.ts +161 -50
  27. package/src/r2WorkerStorage.ts +85 -118
  28. package/src/worker/d1Database.spec.ts +33 -0
  29. package/src/worker/d1Database.ts +52 -0
  30. package/src/worker/index.ts +3 -31
  31. package/worker/dist/README.md +1 -1
  32. package/worker/dist/index.js +5968 -4701
  33. package/worker/dist/index.js.map +4 -4
  34. package/worker/migrations/0001_hot-updater_1.0.0.sql +205 -0
  35. package/worker/src/databaseMigration.integration.spec.ts +130 -0
  36. package/worker/src/databasePlugins.integration.spec.ts +292 -0
  37. package/worker/src/index.integration.spec.ts +102 -294
  38. package/worker/src/index.ts +14 -66
  39. package/worker/src/storagePlugin.integration.spec.ts +59 -0
  40. package/worker/wrangler.json +5 -2
  41. package/src/cloudflareWorkerDatabase.spec.ts +0 -260
  42. package/src/cloudflareWorkerDatabase.ts +0 -651
  43. package/src/r2WranglerStorage.ts +0 -193
  44. package/worker/migrations/0001_hot-updater_init.sql +0 -16
  45. package/worker/migrations/0002_hot-updater_0.13.0.sql +0 -8
  46. package/worker/migrations/0003_hot-updater_0.18.0.sql +0 -38
  47. package/worker/migrations/0004_hot-updater_0.29.0.sql +0 -11
  48. package/worker/migrations/0005_hot-updater_0.31.0.sql +0 -24
package/src/d1Sql.ts ADDED
@@ -0,0 +1,175 @@
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);
@@ -1,6 +1,3 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
-
4
1
  import {
5
2
  DeleteObjectCommand,
6
3
  GetObjectCommand,
@@ -9,189 +6,148 @@ import {
9
6
  type S3ClientConfig,
10
7
  } from "@aws-sdk/client-s3";
11
8
  import { Upload } from "@aws-sdk/lib-storage";
12
- import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
13
9
  import {
14
10
  createStorageKeyBuilder,
15
- getContentType,
16
- type NodeStorageProfile,
11
+ createStorageDownloadUrl,
12
+ createStoragePlugin,
13
+ createStorageUri,
17
14
  parseStorageUri,
18
- type RuntimeStorageProfile,
15
+ type StoragePluginWith,
19
16
  } from "@hot-updater/plugin-core";
20
17
 
21
18
  export interface R2S3StorageConfig extends S3ClientConfig {
22
19
  accountId: string;
23
20
  bucketName: string;
24
21
  credentials: NonNullable<S3ClientConfig["credentials"]>;
25
- /**
26
- * Base path where bundles will be stored in the bucket
27
- */
22
+ /** Base path where bundles will be stored in the bucket. */
28
23
  basePath?: string;
24
+ /** Required when this storage serves downloads through createHotUpdater. */
25
+ downloadUrlSigningKey?: string;
29
26
  }
30
27
 
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";
28
+ const isObjectNotFoundError = (error: unknown) => {
29
+ if (
30
+ error instanceof Error &&
31
+ (error.name === "NotFound" || error.name === "NoSuchKey")
32
+ ) {
33
+ return true;
42
34
  }
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;
35
+ return (
36
+ typeof error === "object" &&
37
+ error !== null &&
38
+ "$metadata" in error &&
39
+ (error as { $metadata?: { httpStatusCode?: number } }).$metadata
40
+ ?.httpStatusCode === 404
41
+ );
52
42
  };
53
43
 
54
- const createS3Client = (config: R2S3StorageConfig) => {
44
+ export const createR2S3Storage = (
45
+ config: R2S3StorageConfig,
46
+ ): StoragePluginWith<"put" | "get" | "exists" | "delete"> => {
55
47
  const {
56
48
  accountId,
57
- basePath: _basePath,
58
- bucketName: _bucketName,
49
+ basePath,
50
+ bucketName,
51
+ downloadUrlSigningKey,
59
52
  endpoint,
60
53
  forcePathStyle,
61
54
  region,
62
55
  ...s3Config
63
56
  } = config;
64
-
65
- return new S3Client({
57
+ const client = new S3Client({
66
58
  ...s3Config,
67
59
  endpoint: endpoint ?? `https://${accountId}.r2.cloudflarestorage.com`,
68
60
  forcePathStyle: forcePathStyle ?? true,
69
61
  region: region ?? "auto",
70
62
  });
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 }),
63
+ const getStorageKey = createStorageKeyBuilder(basePath);
64
+ const getDownloadUrl = downloadUrlSigningKey
65
+ ? createStorageDownloadUrl(downloadUrlSigningKey)
66
+ : undefined;
67
+
68
+ const parseAndValidate = (storageUri: string) => {
69
+ const parsed = parseStorageUri(storageUri, "r2");
70
+ if (parsed.bucket !== bucketName) {
71
+ throw new Error(
72
+ `Bucket name mismatch: expected "${bucketName}", but found "${parsed.bucket}".`,
87
73
  );
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);
74
+ }
75
+ return parsed;
76
+ };
94
77
 
95
- const upload = new Upload({
78
+ return createStoragePlugin({
79
+ name: "r2Storage",
80
+ protocol: "r2",
81
+ async put({ key, body, contentLength, contentType }) {
82
+ const storageKey = getStorageKey(key);
83
+ await new Upload({
96
84
  client,
97
85
  params: {
98
- Body,
86
+ Body: body,
99
87
  Bucket: bucketName,
100
88
  CacheControl: "max-age=31536000",
101
- ContentType,
102
- Key,
89
+ ContentLength: contentLength,
90
+ ContentType: contentType,
91
+ Key: storageKey,
103
92
  },
104
- });
105
- await upload.done();
106
-
93
+ }).done();
107
94
  return {
108
- storageUri: `r2://${bucketName}/${Key}`,
95
+ storageUri: createStorageUri({
96
+ protocol: "r2",
97
+ bucket: bucketName,
98
+ key: storageKey,
99
+ }),
109
100
  };
110
101
  },
111
- async exists(storageUri: string) {
112
- const { bucket, key } = parseStorageUri(storageUri, "r2");
113
- ensureExpectedR2Bucket(bucket, bucketName);
114
-
102
+ async get({ storageUri }) {
103
+ const { key } = parseAndValidate(storageUri);
115
104
  try {
116
- await client.send(
117
- new HeadObjectCommand({ Bucket: bucketName, Key: key }),
105
+ const response = await client.send(
106
+ new GetObjectCommand({ Bucket: bucketName, Key: key }),
118
107
  );
119
- return true;
120
- } catch (error) {
121
- if (isS3ObjectNotFoundError(error)) {
122
- return false;
108
+ if (!response.Body) return { response: null };
109
+ const headers = new Headers();
110
+ if (response.ContentType)
111
+ headers.set("content-type", response.ContentType);
112
+ if (response.ContentLength !== undefined) {
113
+ headers.set("content-length", String(response.ContentLength));
123
114
  }
124
-
115
+ return {
116
+ response: new Response(response.Body.transformToWebStream(), {
117
+ headers,
118
+ }),
119
+ };
120
+ } catch (error) {
121
+ if (isObjectNotFoundError(error)) return { response: null };
125
122
  throw error;
126
123
  }
127
124
  },
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
-
125
+ ...(getDownloadUrl
126
+ ? {
127
+ async getDownloadUrl({ storageUri }: { storageUri: string }) {
128
+ parseAndValidate(storageUri);
129
+ return getDownloadUrl({ storageUri });
130
+ },
131
+ }
132
+ : {}),
133
+ async exists({ storageUri }) {
134
+ const { key } = parseAndValidate(storageUri);
157
135
  try {
158
- const response = await client.send(
159
- new GetObjectCommand({ Bucket: bucketName, Key: key }),
136
+ await client.send(
137
+ new HeadObjectCommand({ Bucket: bucketName, Key: key }),
160
138
  );
161
-
162
- if (!response.Body) {
163
- return null;
164
- }
165
-
166
- return response.Body.transformToString();
139
+ return { exists: true };
167
140
  } catch (error) {
168
- if (isS3ObjectNotFoundError(error)) {
169
- return null;
170
- }
171
-
141
+ if (isObjectNotFoundError(error)) return { exists: false };
172
142
  throw error;
173
143
  }
174
144
  },
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
- },
145
+ async delete({ storageUri }) {
146
+ const { key } = parseAndValidate(storageUri);
147
+ await client.send(
148
+ new DeleteObjectCommand({ Bucket: bucketName, Key: key }),
186
149
  );
187
-
188
- if (!signedUrl) {
189
- throw new Error("Failed to presign R2 URL");
190
- }
191
-
192
- return {
193
- fileUrl: signedUrl,
194
- };
150
+ return { deleted: true };
195
151
  },
196
- };
152
+ });
197
153
  };