@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.
Files changed (51) hide show
  1. package/dist/index.cjs +90 -924
  2. package/dist/index.d.cts +16 -6
  3. package/dist/index.d.mts +16 -6
  4. package/dist/index.mjs +91 -926
  5. package/dist/init/index.cjs +11777 -0
  6. package/dist/init/index.d.cts +5 -1
  7. package/dist/init/index.d.mts +5 -1
  8. package/dist/init/index.mjs +11771 -1
  9. package/dist/worker/index.cjs +117 -916
  10. package/dist/worker/index.d.cts +45 -11
  11. package/dist/worker/index.d.mts +45 -11
  12. package/dist/worker/index.mjs +118 -917
  13. package/package.json +9 -14
  14. package/sql/bundles.sql +121 -212
  15. package/src/d1Database.ts +58 -43
  16. package/src/d1Executor.ts +84 -0
  17. package/src/d1Schema.ts +26 -0
  18. package/src/presignR2Url.ts +103 -0
  19. package/src/r2S3Storage.ts +20 -19
  20. package/src/r2Storage.ts +5 -3
  21. package/src/r2WorkerStorage.ts +55 -17
  22. package/src/worker/d1Database.ts +21 -39
  23. package/worker/dist/README.md +1 -1
  24. package/worker/dist/index.js +9859 -5696
  25. package/worker/dist/index.js.map +4 -4
  26. package/worker/migrations/0001_hot-updater_1.0.0.sql +121 -222
  27. package/worker/src/index.ts +15 -9
  28. package/worker/wrangler.json +1 -0
  29. package/dist/iac/index.cjs +0 -12687
  30. package/dist/iac/index.d.cts +0 -5
  31. package/dist/iac/index.d.mts +0 -5
  32. package/dist/iac/index.mjs +0 -12679
  33. package/src/d1Database.spec.ts +0 -277
  34. package/src/d1Implementation.spec.ts +0 -342
  35. package/src/d1Implementation.ts +0 -781
  36. package/src/d1Query.spec.ts +0 -46
  37. package/src/d1Query.ts +0 -92
  38. package/src/d1Rows.spec.ts +0 -143
  39. package/src/d1Rows.ts +0 -357
  40. package/src/d1Sql.spec.ts +0 -30
  41. package/src/d1Sql.ts +0 -175
  42. package/src/r2Storage.spec.ts +0 -346
  43. package/src/r2WorkerStorage.spec.ts +0 -184
  44. package/src/utils/createWrangler.spec.ts +0 -75
  45. package/src/worker/d1Database.spec.ts +0 -69
  46. package/worker/src/databaseMigration.integration.spec.ts +0 -191
  47. package/worker/src/databasePlugins.integration.spec.ts +0 -326
  48. package/worker/src/index.integration.spec.ts +0 -190
  49. package/worker/src/insightsMovement.integration.spec.ts +0 -73
  50. package/worker/src/storagePlugin.integration.spec.ts +0 -59
  51. package/worker/src/testWorker.ts +0 -5
@@ -0,0 +1,26 @@
1
+ import {
2
+ coreTarget,
3
+ createTableStatements,
4
+ WRITE_GUARD_TABLE,
5
+ generateEngineSql,
6
+ type ToolingTarget,
7
+ } from "@hot-updater/plugin-core";
8
+
9
+ /**
10
+ * D1's schema: the guard table batch writes need, then the shared SQL
11
+ * schema of core and the target's plugins, settings last. Every statement can
12
+ * run again, so a migration for a server's plugin tables repeats core's.
13
+ */
14
+ export const d1SchemaStatements = ({
15
+ schema,
16
+ settings,
17
+ }: ToolingTarget = coreTarget): string[] => [
18
+ ...createTableStatements("sqlite", [WRITE_GUARD_TABLE]),
19
+ ...generateEngineSql("sqlite", schema, settings),
20
+ ];
21
+
22
+ /** The schema as a D1 migration, which `wrangler d1 migrations apply` runs. */
23
+ export const d1SchemaSql = (target?: ToolingTarget): string =>
24
+ `-- HotUpdater.schema\n\n${d1SchemaStatements(target)
25
+ .map((statement) => `${statement};`)
26
+ .join("\n\n")}\n`;
@@ -0,0 +1,103 @@
1
+ const encoder = new TextEncoder();
2
+
3
+ const hex = (bytes: ArrayBuffer) =>
4
+ Array.from(new Uint8Array(bytes), (byte) =>
5
+ byte.toString(16).padStart(2, "0"),
6
+ ).join("");
7
+
8
+ const hmac = async (key: BufferSource, value: string) =>
9
+ crypto.subtle.sign(
10
+ "HMAC",
11
+ await crypto.subtle.importKey(
12
+ "raw",
13
+ key,
14
+ { hash: "SHA-256", name: "HMAC" },
15
+ false,
16
+ ["sign"],
17
+ ),
18
+ encoder.encode(value),
19
+ );
20
+
21
+ /** RFC 3986 encoding, which S3 signs keys and query values in. */
22
+ const encodeRfc3986 = (value: string) =>
23
+ encodeURIComponent(value).replace(
24
+ /[!'()*]/g,
25
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
26
+ );
27
+
28
+ /** R2's S3-compatible credentials. */
29
+ export interface R2Credentials {
30
+ readonly accessKeyId: string;
31
+ readonly secretAccessKey: string;
32
+ }
33
+
34
+ /**
35
+ * A presigned GET URL for an object on R2's S3-compatible API: AWS Signature
36
+ * Version 4 on Web Crypto, in the form `getSignedUrl` from
37
+ * `@aws-sdk/s3-request-presigner` signs, so a Worker presigns without the
38
+ * AWS SDK.
39
+ */
40
+ export const presignR2GetUrl = async ({
41
+ accountId,
42
+ bucketName,
43
+ credentials,
44
+ expiresInSeconds,
45
+ key,
46
+ signingDate = new Date(),
47
+ }: {
48
+ readonly accountId: string;
49
+ readonly bucketName: string;
50
+ readonly credentials: R2Credentials;
51
+ readonly expiresInSeconds: number;
52
+ /** The object's key, unencoded. */
53
+ readonly key: string;
54
+ readonly signingDate?: Date;
55
+ }): Promise<string> => {
56
+ const host = `${accountId}.r2.cloudflarestorage.com`;
57
+ const path = `/${bucketName}/${key.split("/").map(encodeRfc3986).join("/")}`;
58
+ const amzDate = signingDate.toISOString().replace(/[-:]|\.\d{3}/g, "");
59
+ const scope = `${amzDate.slice(0, 8)}/auto/s3/aws4_request`;
60
+ // Sorted by name, as the canonical request orders them.
61
+ const query: [string, string][] = [
62
+ ["X-Amz-Algorithm", "AWS4-HMAC-SHA256"],
63
+ ["X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD"],
64
+ ["X-Amz-Credential", `${credentials.accessKeyId}/${scope}`],
65
+ ["X-Amz-Date", amzDate],
66
+ ["X-Amz-Expires", String(expiresInSeconds)],
67
+ ["X-Amz-SignedHeaders", "host"],
68
+ ["x-id", "GetObject"],
69
+ ];
70
+ const queryString = (entries: [string, string][]) =>
71
+ entries
72
+ .map(([name, value]) => `${encodeRfc3986(name)}=${encodeRfc3986(value)}`)
73
+ .join("&");
74
+ const canonicalRequest = [
75
+ "GET",
76
+ path,
77
+ queryString(query),
78
+ `host:${host}\n`,
79
+ "host",
80
+ "UNSIGNED-PAYLOAD",
81
+ ].join("\n");
82
+ const stringToSign = [
83
+ "AWS4-HMAC-SHA256",
84
+ amzDate,
85
+ scope,
86
+ hex(
87
+ await crypto.subtle.digest("SHA-256", encoder.encode(canonicalRequest)),
88
+ ),
89
+ ].join("\n");
90
+ let signingKey: ArrayBuffer = encoder.encode(
91
+ `AWS4${credentials.secretAccessKey}`,
92
+ ).buffer as ArrayBuffer;
93
+ for (const part of [amzDate.slice(0, 8), "auto", "s3", "aws4_request"]) {
94
+ signingKey = await hmac(signingKey, part);
95
+ }
96
+ const signature = hex(await hmac(signingKey, stringToSign));
97
+ const signed: [string, string][] = [
98
+ ...query.slice(0, 5),
99
+ ["X-Amz-Signature", signature],
100
+ ...query.slice(5),
101
+ ];
102
+ return `https://${host}${path}?${queryString(signed)}`;
103
+ };
@@ -6,13 +6,13 @@ import {
6
6
  type S3ClientConfig,
7
7
  } from "@aws-sdk/client-s3";
8
8
  import { Upload } from "@aws-sdk/lib-storage";
9
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
9
10
  import {
10
11
  createStorageKeyBuilder,
11
- createStorageDownloadUrl,
12
- createStoragePlugin,
12
+ createStorageAdapter,
13
13
  createStorageUri,
14
14
  parseStorageUri,
15
- type StoragePluginWith,
15
+ type StorageAdapterWith,
16
16
  } from "@hot-updater/plugin-core";
17
17
 
18
18
  export interface R2S3StorageConfig extends S3ClientConfig {
@@ -21,10 +21,11 @@ export interface R2S3StorageConfig extends S3ClientConfig {
21
21
  credentials: NonNullable<S3ClientConfig["credentials"]>;
22
22
  /** Base path where bundles will be stored in the bucket. */
23
23
  basePath?: string;
24
- /** Required when this storage serves downloads through createHotUpdater. */
25
- downloadUrlSigningKey?: string;
26
24
  }
27
25
 
26
+ /** Devices download right after the update check that returns the URL. */
27
+ const PRESIGNED_URL_EXPIRES_IN_SECONDS = 3600;
28
+
28
29
  const isObjectNotFoundError = (error: unknown) => {
29
30
  if (
30
31
  error instanceof Error &&
@@ -43,12 +44,13 @@ const isObjectNotFoundError = (error: unknown) => {
43
44
 
44
45
  export const createR2S3Storage = (
45
46
  config: R2S3StorageConfig,
46
- ): StoragePluginWith<"put" | "get" | "exists" | "delete"> => {
47
+ ): StorageAdapterWith<
48
+ "put" | "get" | "getDownloadUrl" | "exists" | "delete"
49
+ > => {
47
50
  const {
48
51
  accountId,
49
52
  basePath,
50
53
  bucketName,
51
- downloadUrlSigningKey,
52
54
  endpoint,
53
55
  forcePathStyle,
54
56
  region,
@@ -61,9 +63,6 @@ export const createR2S3Storage = (
61
63
  region: region ?? "auto",
62
64
  });
63
65
  const getStorageKey = createStorageKeyBuilder(basePath);
64
- const getDownloadUrl = downloadUrlSigningKey
65
- ? createStorageDownloadUrl(downloadUrlSigningKey)
66
- : undefined;
67
66
 
68
67
  const parseAndValidate = (storageUri: string) => {
69
68
  const parsed = parseStorageUri(storageUri, "r2");
@@ -75,7 +74,7 @@ export const createR2S3Storage = (
75
74
  return parsed;
76
75
  };
77
76
 
78
- return createStoragePlugin({
77
+ return createStorageAdapter({
79
78
  name: "r2Storage",
80
79
  protocol: "r2",
81
80
  async put({ key, body, contentLength, contentType }) {
@@ -122,14 +121,16 @@ export const createR2S3Storage = (
122
121
  throw error;
123
122
  }
124
123
  },
125
- ...(getDownloadUrl
126
- ? {
127
- async getDownloadUrl({ storageUri }: { storageUri: string }) {
128
- parseAndValidate(storageUri);
129
- return getDownloadUrl({ storageUri });
130
- },
131
- }
132
- : {}),
124
+ async getDownloadUrl({ storageUri }) {
125
+ const { key } = parseAndValidate(storageUri);
126
+ return {
127
+ url: await getSignedUrl(
128
+ client,
129
+ new GetObjectCommand({ Bucket: bucketName, Key: key }),
130
+ { expiresIn: PRESIGNED_URL_EXPIRES_IN_SECONDS },
131
+ ),
132
+ };
133
+ },
133
134
  async exists({ storageUri }) {
134
135
  const { key } = parseAndValidate(storageUri);
135
136
  try {
package/src/r2Storage.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { StoragePluginWith } from "@hot-updater/plugin-core";
1
+ import type { StorageAdapterWith } from "@hot-updater/plugin-core";
2
2
 
3
3
  import { createR2S3Storage, type R2S3StorageConfig } from "./r2S3Storage";
4
4
 
@@ -7,10 +7,12 @@ export type { R2S3StorageConfig };
7
7
 
8
8
  export const r2Storage = (
9
9
  config: R2S3StorageConfig,
10
- ): StoragePluginWith<"put" | "get" | "exists" | "delete"> => {
10
+ ): StorageAdapterWith<
11
+ "put" | "get" | "getDownloadUrl" | "exists" | "delete"
12
+ > => {
11
13
  if (!config.credentials) {
12
14
  throw new Error(
13
- "r2Storage requires S3-compatible credentials. The Wrangler fallback was removed.",
15
+ "r2Storage requires S3-compatible credentials: pass credentials with an R2 access key ID and secret access key.",
14
16
  );
15
17
  }
16
18
 
@@ -1,26 +1,52 @@
1
1
  import {
2
2
  createStorageKeyBuilder,
3
- createStorageDownloadUrl,
4
- createStoragePlugin,
3
+ createStorageAdapter,
5
4
  createStorageUri,
6
5
  parseStorageUri,
7
- type StoragePluginWith,
6
+ type StorageAdapterWith,
8
7
  } from "@hot-updater/plugin-core";
9
8
 
10
- export interface CloudflareWorkerStorageConfig {
9
+ import { presignR2GetUrl, type R2Credentials } from "./presignR2Url";
10
+
11
+ export type CloudflareWorkerStorageConfig = {
11
12
  readonly bucket: R2Bucket;
12
13
  readonly bucketName: string;
13
14
  readonly basePath?: string;
14
- readonly downloadUrlSigningKey: string;
15
- }
15
+ } & (
16
+ | {
17
+ /** The account whose R2 endpoint download URLs name. */
18
+ readonly accountId: string;
19
+ /**
20
+ * R2's S3-compatible credentials, which presign download URLs, as
21
+ * `r2Storage` from `@hot-updater/cloudflare` does: what a server needs
22
+ * to serve downloads.
23
+ */
24
+ readonly credentials: R2Credentials;
25
+ }
26
+ | { readonly accountId?: undefined; readonly credentials?: undefined }
27
+ );
28
+
29
+ /** Devices download right after the update check that returns the URL. */
30
+ const PRESIGNED_URL_EXPIRES_IN_SECONDS = 3600;
16
31
 
17
- export const r2WorkerStorage = (
32
+ /**
33
+ * Storage on the Worker's R2 binding. With R2's S3-compatible credentials,
34
+ * its download URLs are presigned and devices download from the bucket;
35
+ * without them, it has no `getDownloadUrl`, as a Console that only reads
36
+ * needs.
37
+ */
38
+ export function r2WorkerStorage(
39
+ config: CloudflareWorkerStorageConfig & {
40
+ readonly credentials: R2Credentials;
41
+ },
42
+ ): StorageAdapterWith<"put" | "get" | "getDownloadUrl" | "exists" | "delete">;
43
+ export function r2WorkerStorage(
18
44
  config: CloudflareWorkerStorageConfig,
19
- ): StoragePluginWith<
20
- "put" | "get" | "getDownloadUrl" | "exists" | "delete"
21
- > => {
45
+ ): StorageAdapterWith<"put" | "get" | "exists" | "delete">;
46
+ export function r2WorkerStorage(
47
+ config: CloudflareWorkerStorageConfig,
48
+ ): StorageAdapterWith<"put" | "get" | "exists" | "delete"> {
22
49
  const getStorageKey = createStorageKeyBuilder(config.basePath);
23
- const getDownloadUrl = createStorageDownloadUrl(config.downloadUrlSigningKey);
24
50
 
25
51
  const parseAndValidate = (storageUri: string) => {
26
52
  const parsed = parseStorageUri(storageUri, "r2");
@@ -32,7 +58,7 @@ export const r2WorkerStorage = (
32
58
  return parsed;
33
59
  };
34
60
 
35
- return createStoragePlugin({
61
+ return createStorageAdapter({
36
62
  name: "r2Storage",
37
63
  protocol: "r2",
38
64
  async put({ key, body, contentLength, contentType }) {
@@ -77,10 +103,22 @@ export const r2WorkerStorage = (
77
103
  headers.set("content-length", String(object.size));
78
104
  return { response: new Response(object.body, { headers }) };
79
105
  },
80
- async getDownloadUrl({ storageUri }) {
81
- parseAndValidate(storageUri);
82
- return getDownloadUrl({ storageUri });
83
- },
106
+ ...(config.credentials === undefined
107
+ ? {}
108
+ : {
109
+ async getDownloadUrl({ storageUri }: { storageUri: string }) {
110
+ const { key } = parseAndValidate(storageUri);
111
+ return {
112
+ url: await presignR2GetUrl({
113
+ accountId: config.accountId,
114
+ bucketName: config.bucketName,
115
+ credentials: config.credentials,
116
+ expiresInSeconds: PRESIGNED_URL_EXPIRES_IN_SECONDS,
117
+ key,
118
+ }),
119
+ };
120
+ },
121
+ }),
84
122
  async exists({ storageUri }) {
85
123
  const { key } = parseAndValidate(storageUri);
86
124
  return { exists: (await config.bucket.head(key)) !== null };
@@ -91,4 +129,4 @@ export const r2WorkerStorage = (
91
129
  return { deleted: true };
92
130
  },
93
131
  });
94
- };
132
+ }
@@ -1,15 +1,14 @@
1
- import { createDatabasePlugin } from "@hot-updater/plugin-core";
2
- import { createDatabasePluginAdapter } from "@hot-updater/plugin-core/internal";
1
+ import type { EngineDatabase } from "@hot-updater/plugin-core";
3
2
 
4
- import { createD1Implementation, D1ExecutionError } from "../d1Implementation";
5
-
6
- type D1Result = {
7
- readonly results?: readonly unknown[];
8
- readonly success?: boolean;
9
- };
3
+ import {
4
+ createD1Database,
5
+ D1ExecutionError,
6
+ type D1ResultLike,
7
+ toSqlResult,
8
+ } from "../d1Executor";
10
9
 
11
10
  type D1BoundStatement = {
12
- all: () => Promise<D1Result>;
11
+ all: () => Promise<D1ResultLike>;
13
12
  };
14
13
 
15
14
  type D1PreparedStatement = {
@@ -18,43 +17,26 @@ type D1PreparedStatement = {
18
17
 
19
18
  export type D1Like = {
20
19
  prepare(sql: string): D1PreparedStatement;
21
- batch(statements: D1BoundStatement[]): Promise<readonly D1Result[]>;
20
+ batch(statements: D1BoundStatement[]): Promise<readonly D1ResultLike[]>;
22
21
  };
23
22
 
24
- export interface CloudflareWorkerDatabaseEnv {
25
- readonly DB: D1Like;
26
- }
27
-
28
- export const d1Database = (database: D1Like) => {
29
- const implementation = createD1Implementation({
30
- async query(sql, params) {
31
- const result = await database
32
- .prepare(sql)
33
- .bind(...params)
34
- .all();
35
- if (result.success === false) throw new D1ExecutionError();
36
- return result.results ?? [];
37
- },
23
+ /** Hot Updater's database on a D1 binding, inside a Worker. */
24
+ export const d1Database = (database: D1Like): EngineDatabase =>
25
+ createD1Database({
26
+ query: async ({ sql, params }) =>
27
+ toSqlResult(
28
+ await database
29
+ .prepare(sql)
30
+ .bind(...params)
31
+ .all(),
32
+ ),
38
33
  async batch(statements) {
39
34
  const results = await database.batch(
40
35
  statements.map(({ sql, params }) =>
41
36
  database.prepare(sql).bind(...params),
42
37
  ),
43
38
  );
44
- if (
45
- results.length !== statements.length ||
46
- results.some(({ success }) => success === false)
47
- ) {
48
- throw new D1ExecutionError();
49
- }
50
- return results.map(({ results }) => results ?? []);
39
+ if (results.length !== statements.length) throw new D1ExecutionError();
40
+ return results.map(toSqlResult);
51
41
  },
52
42
  });
53
- const adapter = createDatabasePluginAdapter("d1Database", implementation);
54
- return createDatabasePlugin({
55
- name: "d1Database",
56
- models: adapter.models,
57
- commit: adapter.commit,
58
- ...(adapter.dispose ? { dispose: adapter.dispose } : {}),
59
- });
60
- };
@@ -1 +1 @@
1
- This folder contains the built output assets for the worker "hot-updater" generated at 2026-09-08T01:48:53.178Z.
1
+ This folder contains the built output assets for the worker "hot-updater" generated at 2026-10-11T01:49:38.431Z.