wrangler 2.0.22 → 2.0.25

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 (75) hide show
  1. package/README.md +20 -2
  2. package/bin/wrangler.js +1 -1
  3. package/miniflare-dist/index.mjs +643 -7
  4. package/package.json +17 -5
  5. package/src/__tests__/configuration.test.ts +89 -17
  6. package/src/__tests__/dev.test.tsx +121 -8
  7. package/src/__tests__/generate.test.ts +93 -0
  8. package/src/__tests__/helpers/mock-cfetch.ts +54 -2
  9. package/src/__tests__/index.test.ts +10 -27
  10. package/src/__tests__/jest.setup.ts +31 -1
  11. package/src/__tests__/kv.test.ts +82 -61
  12. package/src/__tests__/metrics.test.ts +5 -0
  13. package/src/__tests__/publish.test.ts +573 -254
  14. package/src/__tests__/r2.test.ts +173 -71
  15. package/src/__tests__/tail.test.ts +93 -39
  16. package/src/__tests__/user.test.ts +1 -0
  17. package/src/__tests__/validate-dev-props.test.ts +56 -0
  18. package/src/__tests__/version.test.ts +35 -0
  19. package/src/__tests__/whoami.test.tsx +60 -1
  20. package/src/api/dev.ts +49 -9
  21. package/src/bundle.ts +298 -37
  22. package/src/cfetch/internal.ts +34 -2
  23. package/src/config/config.ts +15 -3
  24. package/src/config/environment.ts +40 -8
  25. package/src/config/index.ts +13 -0
  26. package/src/config/validation.ts +111 -9
  27. package/src/create-worker-preview.ts +3 -1
  28. package/src/create-worker-upload-form.ts +25 -0
  29. package/src/dev/dev.tsx +145 -31
  30. package/src/dev/local.tsx +116 -24
  31. package/src/dev/remote.tsx +39 -12
  32. package/src/dev/use-esbuild.ts +28 -0
  33. package/src/dev/validate-dev-props.ts +31 -0
  34. package/src/dev-registry.tsx +160 -0
  35. package/src/dev.tsx +148 -67
  36. package/src/generate.ts +112 -14
  37. package/src/index.tsx +252 -7
  38. package/src/inspect.ts +90 -5
  39. package/src/metrics/index.ts +1 -0
  40. package/src/metrics/metrics-dispatcher.ts +1 -0
  41. package/src/metrics/metrics-usage-headers.ts +24 -0
  42. package/src/metrics/send-event.ts +2 -2
  43. package/src/miniflare-cli/assets.ts +546 -0
  44. package/src/miniflare-cli/index.ts +157 -6
  45. package/src/module-collection.ts +3 -3
  46. package/src/pages/build.tsx +36 -28
  47. package/src/pages/constants.ts +4 -0
  48. package/src/pages/deployments.tsx +10 -10
  49. package/src/pages/dev.tsx +155 -651
  50. package/src/pages/functions/buildPlugin.ts +4 -0
  51. package/src/pages/functions/buildWorker.ts +4 -0
  52. package/src/pages/functions/routes-consolidation.test.ts +66 -0
  53. package/src/pages/functions/routes-consolidation.ts +29 -0
  54. package/src/pages/functions/routes-transformation.test.ts +271 -0
  55. package/src/pages/functions/routes-transformation.ts +125 -0
  56. package/src/pages/projects.tsx +9 -3
  57. package/src/pages/publish.tsx +57 -15
  58. package/src/pages/types.ts +9 -0
  59. package/src/pages/upload.tsx +38 -21
  60. package/src/publish.ts +139 -112
  61. package/src/r2.ts +81 -0
  62. package/src/tail/index.ts +15 -2
  63. package/src/tail/printing.ts +41 -3
  64. package/src/user/choose-account.tsx +20 -11
  65. package/src/user/user.tsx +20 -2
  66. package/src/whoami.tsx +79 -1
  67. package/src/worker.ts +12 -0
  68. package/templates/first-party-worker-module-facade.ts +18 -0
  69. package/templates/format-dev-errors.ts +32 -0
  70. package/templates/pages-shim.ts +9 -0
  71. package/templates/{static-asset-facade.js → serve-static-assets.ts} +21 -7
  72. package/templates/service-bindings-module-facade.js +51 -0
  73. package/templates/service-bindings-sw-facade.js +39 -0
  74. package/wrangler-dist/cli.d.ts +38 -3
  75. package/wrangler-dist/cli.js +45244 -25199
@@ -22,18 +22,16 @@ import {
22
22
  BULK_UPLOAD_CONCURRENCY,
23
23
  MAX_BUCKET_FILE_COUNT,
24
24
  MAX_BUCKET_SIZE,
25
+ MAX_CHECK_MISSING_ATTEMPTS,
25
26
  MAX_UPLOAD_ATTEMPTS,
26
27
  } from "./constants";
27
28
  import { pagesBetaWarning } from "./utils";
28
- import type { UploadPayloadFile } from "./types";
29
- import type { ArgumentsCamelCase, Argv } from "yargs";
29
+ import type { UploadPayloadFile, YargsOptionsToInterface } from "./types";
30
+ import type { Argv } from "yargs";
30
31
 
31
- type UploadArgs = {
32
- directory: string;
33
- "output-manifest-path"?: string;
34
- };
32
+ type UploadArgs = YargsOptionsToInterface<typeof Options>;
35
33
 
36
- export function Options(yargs: Argv): Argv<UploadArgs> {
34
+ export function Options(yargs: Argv) {
37
35
  return yargs
38
36
  .positional("directory", {
39
37
  type: "string",
@@ -52,7 +50,7 @@ export function Options(yargs: Argv): Argv<UploadArgs> {
52
50
  export const Handler = async ({
53
51
  directory,
54
52
  outputManifestPath,
55
- }: ArgumentsCamelCase<UploadArgs>) => {
53
+ }: UploadArgs) => {
56
54
  if (!directory) {
57
55
  throw new FatalError("Must specify a directory.", 1);
58
56
  }
@@ -105,6 +103,7 @@ export const upload = async (
105
103
  "_worker.js",
106
104
  "_redirects",
107
105
  "_headers",
106
+ "_routes.json",
108
107
  ".DS_Store",
109
108
  "node_modules",
110
109
  ".git",
@@ -190,19 +189,37 @@ export const upload = async (
190
189
 
191
190
  const start = Date.now();
192
191
 
193
- const missingHashes = await fetchResult<string[]>(
194
- `/pages/assets/check-missing`,
195
- {
196
- method: "POST",
197
- headers: {
198
- "Content-Type": "application/json",
199
- Authorization: `Bearer ${jwt}`,
200
- },
201
- body: JSON.stringify({
202
- hashes: files.map(({ hash }) => hash),
203
- }),
192
+ let attempts = 0;
193
+ const getMissingHashes = async (): Promise<string[]> => {
194
+ try {
195
+ return await fetchResult<string[]>(`/pages/assets/check-missing`, {
196
+ method: "POST",
197
+ headers: {
198
+ "Content-Type": "application/json",
199
+ Authorization: `Bearer ${jwt}`,
200
+ },
201
+ body: JSON.stringify({
202
+ hashes: files.map(({ hash }) => hash),
203
+ }),
204
+ });
205
+ } catch (e) {
206
+ if (attempts < MAX_CHECK_MISSING_ATTEMPTS) {
207
+ // Exponential backoff, 1 second first time, then 2 second, then 4 second etc.
208
+ await new Promise((resolvePromise) =>
209
+ setTimeout(resolvePromise, Math.pow(2, attempts++) * 1000)
210
+ );
211
+
212
+ if ((e as { code: number }).code === 8000013) {
213
+ // Looks like the JWT expired, fetch another one
214
+ jwt = await fetchJwt();
215
+ }
216
+ return getMissingHashes();
217
+ } else {
218
+ throw e;
219
+ }
204
220
  }
205
- );
221
+ };
222
+ const missingHashes = await getMissingHashes();
206
223
 
207
224
  const sortedFiles = files
208
225
  .filter((file) => missingHashes.includes(file.hash))
@@ -256,7 +273,7 @@ export const upload = async (
256
273
  // Don't upload empty buckets (can happen for tiny projects)
257
274
  if (bucket.files.length === 0) continue;
258
275
 
259
- let attempts = 0;
276
+ attempts = 0;
260
277
  const doUpload = async (): Promise<void> => {
261
278
  // Populate the payload only when actually uploading (this is limited to 3 concurrent uploads at 50 MiB per bucket meaning we'd only load in a max of ~150 MiB)
262
279
  // This is so we don't run out of memory trying to upload the files.
package/src/publish.ts CHANGED
@@ -11,6 +11,7 @@ import { createWorkerUploadForm } from "./create-worker-upload-form";
11
11
  import { confirm } from "./dialogs";
12
12
  import { getMigrationsToUpload } from "./durable";
13
13
  import { logger } from "./logger";
14
+ import { getMetricsUsageHeaders } from "./metrics";
14
15
  import { ParseError } from "./parse";
15
16
  import { syncAssets } from "./sites";
16
17
  import { getZoneForRoute } from "./zones";
@@ -51,6 +52,27 @@ type Props = {
51
52
 
52
53
  type RouteObject = ZoneIdRoute | ZoneNameRoute | CustomDomainRoute;
53
54
 
55
+ export type CustomDomain = {
56
+ id: string;
57
+ zone_id: string;
58
+ zone_name: string;
59
+ hostname: string;
60
+ service: string;
61
+ environment: string;
62
+ };
63
+ type UpdatedCustomDomain = CustomDomain & { modified: boolean };
64
+ type ConflictingCustomDomain = CustomDomain & {
65
+ external_dns_record_id?: string;
66
+ external_cert_id?: string;
67
+ };
68
+
69
+ export type CustomDomainChangeset = {
70
+ added: CustomDomain[];
71
+ removed: CustomDomain[];
72
+ updated: UpdatedCustomDomain[];
73
+ conflicting: ConflictingCustomDomain[];
74
+ };
75
+
54
76
  function sleep(ms: number) {
55
77
  return new Promise((resolve) => setTimeout(resolve, ms));
56
78
  }
@@ -79,58 +101,27 @@ function renderRoute(route: Route): string {
79
101
  return result;
80
102
  }
81
103
 
82
- // this function takes a string with quotes in it
83
- // (i.e. `hello "world", if that really is your name`)
84
- // and peels out the first instance of a substring
85
- // bounded by quotes (so, in the example above, `world`)
86
- //
87
- // this is useful because the /domains api will return
88
- // which domains conflicted in an error message, bounded
89
- // by a string, which we can use to provide helpful
90
- // messages to a user
91
- function getQuoteBoundedSubstring(content: string) {
92
- const matches = content.split('"');
93
- return matches[1] ?? "";
94
- }
95
-
96
- function isOriginConflictError(
97
- e: unknown
98
- ): e is { code: 100116; message: string; notes: Array<{ text: string }> } {
99
- return (
100
- typeof e === "object" &&
101
- e !== null &&
102
- (e as { code: number }).code === 100116
103
- );
104
- }
105
-
106
- function isDNSConflictError(
107
- e: unknown
108
- ): e is { code: 100117; message: string; notes: Array<{ text: string }> } {
109
- return (
110
- typeof e === "object" &&
111
- e !== null &&
112
- (e as { code: number }).code === 100117
113
- );
114
- }
115
-
116
- // empty error class to throw and then explicitly catch via `instanceof`
117
- class CustomDomainOverrideRejected extends Error {}
118
-
119
104
  // publishing to custom domains involves a few more steps than just updating
120
105
  // the routing table, and thus the api implementing it is fairly defensive -
121
106
  // it will error eagerly on conflicts against existing domains or existing
122
107
  // managed DNS records
123
- //
124
- // however, you can pass params to override the errors. we start on the
125
- // defensive path, and if one of these errors occur, we prompt the user
126
- // for confirmation that they do indeed want to override the conflicts, and
127
- // then retry the request with the right override added
108
+
109
+ // however, you can pass params to override the errors. to know if we should
110
+ // override the current state, we generate a "changeset" of required actions
111
+ // to get to the state we want (specified by the list of custom domains). the
112
+ // changeset returns an "updated" collection (existing custom domains
113
+ // connected to other scripts) and a "conflicting" collection (the requested
114
+ // custom domains that have a managed, conflicting DNS record preventing the
115
+ // host's use as a custom domain). with this information, we can prompt to
116
+ // the user what will occur if we create the custom domains requested, and
117
+ // add the override param if they confirm the action
128
118
  //
129
119
  // if a user does not confirm that they want to override, we skip publishing
130
120
  // to these custom domains, but continue on through the rest of the
131
121
  // publish stage
132
- function publishCustomDomains(
122
+ async function publishCustomDomains(
133
123
  workerUrl: string,
124
+ accountId: string,
134
125
  domains: Array<RouteObject>
135
126
  ): Promise<string[]> {
136
127
  const config = {
@@ -146,79 +137,81 @@ function publishCustomDomains(
146
137
  };
147
138
  });
148
139
 
140
+ const fail = () => {
141
+ return [
142
+ domains.length > 1
143
+ ? `Publishing to ${domains.length} Custom Domains was skipped, fix conflicts and try again`
144
+ : `Publishing to Custom Domain "${domains[0].pattern}" was skipped, fix conflict and try again`,
145
+ ];
146
+ };
147
+
149
148
  if (!process.stdout.isTTY) {
150
149
  // running in non-interactive mode.
151
150
  // existing origins / dns records are not indicative of errors,
152
151
  // so we aggressively update rather than aggressively fail
153
152
  config.override_existing_origin = true;
154
153
  config.override_existing_dns_record = true;
154
+ } else {
155
+ // get a changeset for operations required to achieve a state with the requested domains
156
+ const changeset = await fetchResult<CustomDomainChangeset>(
157
+ `${workerUrl}/domains/changeset?replace_state=true`,
158
+ {
159
+ method: "POST",
160
+ body: JSON.stringify(origins),
161
+ headers: {
162
+ "Content-Type": "application/json",
163
+ },
164
+ }
165
+ );
166
+
167
+ const updatesRequired = changeset.updated.filter(
168
+ (domain) => domain.modified
169
+ );
170
+ if (updatesRequired.length > 0) {
171
+ // find out which scripts the conflict domains are already attached to
172
+ // so we can provide that in the confirmation prompt
173
+ const existing = await Promise.all(
174
+ updatesRequired.map((domain) =>
175
+ fetchResult<CustomDomain>(
176
+ `/accounts/${accountId}/workers/domains/records/${domain.id}`
177
+ )
178
+ )
179
+ );
180
+ const existingRendered = existing
181
+ .map(
182
+ (domain) =>
183
+ `\t• ${domain.hostname} (used as a domain for "${domain.service}")`
184
+ )
185
+ .join("\n");
186
+ const message = `Custom Domains already exist for these domains:
187
+ ${existingRendered}
188
+ Update them to point to this script instead?`;
189
+ if (!(await confirm(message))) return fail();
190
+ config.override_existing_origin = true;
191
+ }
192
+
193
+ if (changeset.conflicting.length > 0) {
194
+ const conflicitingRendered = changeset.conflicting
195
+ .map((domain) => `\t• ${domain.hostname}`)
196
+ .join("\n");
197
+ const message = `You already have DNS records that conflict for these Custom Domains:
198
+ ${conflicitingRendered}
199
+ Update them to point to this script instead?`;
200
+ if (!(await confirm(message))) return fail();
201
+ config.override_existing_dns_record = true;
202
+ }
155
203
  }
156
204
 
157
- // Mixing promise chains with async/await is funky, but it allows us to keep related
158
- // logic synchronous-looking (i.e. prompting for confirmation and then re-requesting)
159
- // while retaining the flexibility of promise chain fall-throughs. We can group error
160
- // handling logic in dedicated catch calls, and all we have to do is re-throw an
161
- // error and it will pass down to the next catch call
162
- return fetchResult(`${workerUrl}/domains`, {
205
+ // publish to domains
206
+ await fetchResult(`${workerUrl}/domains/records`, {
163
207
  method: "PUT",
164
208
  body: JSON.stringify({ ...config, origins }),
165
209
  headers: {
166
210
  "Content-Type": "application/json",
167
211
  },
168
- })
169
- .catch(async (err) => {
170
- if (isOriginConflictError(err)) {
171
- const conflictingOrigins = getQuoteBoundedSubstring(err.notes[0].text);
172
- const shouldContinue = await confirm(
173
- `Custom Domains already exist for these domains: "${conflictingOrigins}"\nUpdate them to point to this script instead?`
174
- );
175
- if (!shouldContinue) {
176
- throw new CustomDomainOverrideRejected();
177
- }
178
- config.override_existing_origin = true;
179
- await fetchResult(`${workerUrl}/domains`, {
180
- method: "PUT",
181
- body: JSON.stringify({ ...config, origins }),
182
- headers: {
183
- "Content-Type": "application/json",
184
- },
185
- });
186
- } else {
187
- throw err;
188
- }
189
- })
190
- .catch(async (err) => {
191
- if (isDNSConflictError(err)) {
192
- const conflictingOrigins = getQuoteBoundedSubstring(err.notes[0].text);
193
- const shouldContinue = await confirm(
194
- `You already have conflicting DNS records for these domains: "${conflictingOrigins}"\nUpdate them to point to this script instead?`
195
- );
196
- if (!shouldContinue) {
197
- throw new CustomDomainOverrideRejected();
198
- }
199
- config.override_existing_dns_record = true;
200
- await fetchResult(`${workerUrl}/domains`, {
201
- method: "PUT",
202
- body: JSON.stringify({ ...config, origins }),
203
- headers: {
204
- "Content-Type": "application/json",
205
- },
206
- });
207
- } else {
208
- throw err;
209
- }
210
- })
211
- .then(() => domains.map((domain) => renderRoute(domain)))
212
- .catch((err) => {
213
- if (err instanceof CustomDomainOverrideRejected) {
214
- return [
215
- domains.length > 1
216
- ? `Publishing to ${domains.length} Custom Domains was skipped, fix conflicts and try again`
217
- : `Publishing to Custom Domain "${domains[0].pattern}" was skipped, fix conflict and try again`,
218
- ];
219
- }
220
- throw err;
221
- });
212
+ });
213
+
214
+ return domains.map((domain) => renderRoute(domain));
222
215
  }
223
216
 
224
217
  export default async function publish(props: Props): Promise<void> {
@@ -276,6 +269,19 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
276
269
  );
277
270
  }
278
271
 
272
+ // Warn if user tries minify or node-compat with no-bundle
273
+ if (props.noBundle && minify) {
274
+ logger.warn(
275
+ "`--minify` and `--no-bundle` can't be used together. If you want to minify your Worker and disable Wrangler's bundling, please minify as part of your own bundling process."
276
+ );
277
+ }
278
+
279
+ if (props.noBundle && nodeCompat) {
280
+ logger.warn(
281
+ "`--node-compat` and `--no-bundle` can't be used together. If you want to polyfill Node.js built-ins and disable Wrangler's bundling, please polyfill as part of your own bundling process."
282
+ );
283
+ }
284
+
279
285
  const scriptName = props.name;
280
286
  assert(
281
287
  scriptName,
@@ -378,6 +384,17 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
378
384
  nodeCompat,
379
385
  define: config.define,
380
386
  checkFetch: false,
387
+ assets: config.assets && {
388
+ ...config.assets,
389
+ // enable the cache when publishing
390
+ bypassCache: false,
391
+ },
392
+ services: config.services,
393
+ // We don't set workerDefinitions here,
394
+ // because we don't want to apply the dev-time
395
+ // facades on top of it
396
+ workerDefinitions: undefined,
397
+ firstPartyWorkerDevFacade: false,
381
398
  }
382
399
  );
383
400
 
@@ -427,6 +444,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
427
444
  r2_buckets: config.r2_buckets,
428
445
  services: config.services,
429
446
  worker_namespaces: config.worker_namespaces,
447
+ logfwdr: config.logfwdr,
430
448
  unsafe: config.unsafe?.bindings,
431
449
  };
432
450
 
@@ -479,6 +497,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
479
497
  {
480
498
  method: "PUT",
481
499
  body: createWorkerUploadForm(worker),
500
+ headers: await getMetricsUsageHeaders(config.send_metrics),
482
501
  },
483
502
  new URLSearchParams({
484
503
  include_subdomain_availability: "true",
@@ -490,13 +509,15 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
490
509
 
491
510
  available_on_subdomain = result.available_on_subdomain;
492
511
 
493
- // Print some useful information returned after publishing
494
- // Not all fields will be populated for every worker
495
- // These fields are likely to be scraped by tools, so do not rename
496
- if (result.id) logger.log("Worker ID: ", result.id);
497
- if (result.etag) logger.log("Worker ETag: ", result.etag);
498
- if (result.pipeline_hash)
499
- logger.log("Worker PipelineHash: ", result.pipeline_hash);
512
+ if (config.first_party_worker) {
513
+ // Print some useful information returned after publishing
514
+ // Not all fields will be populated for every worker
515
+ // These fields are likely to be scraped by tools, so do not rename
516
+ if (result.id) logger.log("Worker ID: ", result.id);
517
+ if (result.etag) logger.log("Worker ETag: ", result.etag);
518
+ if (result.pipeline_hash)
519
+ logger.log("Worker PipelineHash: ", result.pipeline_hash);
520
+ }
500
521
  }
501
522
  } finally {
502
523
  if (typeof destination !== "string") {
@@ -577,7 +598,9 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
577
598
 
578
599
  // Update custom domains for the script
579
600
  if (customDomainsOnly.length > 0) {
580
- deployments.push(publishCustomDomains(workerUrl, customDomainsOnly));
601
+ deployments.push(
602
+ publishCustomDomains(workerUrl, accountId, customDomainsOnly)
603
+ );
581
604
  }
582
605
 
583
606
  // Configure any schedules for the script.
@@ -601,7 +624,11 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
601
624
  if (deployments.length > 0) {
602
625
  logger.log("Published", workerName, formatTime(deployMs));
603
626
  for (const target of targets.flat()) {
604
- logger.log(" ", target);
627
+ // Append protocol only on workers.dev domains
628
+ logger.log(
629
+ " ",
630
+ (target.endsWith("workers.dev") ? "https://" : "") + target
631
+ );
605
632
  }
606
633
  } else {
607
634
  logger.log("No publish targets for", workerName, formatTime(deployMs));
package/src/r2.ts CHANGED
@@ -1,4 +1,7 @@
1
+ import { Readable } from "node:stream";
1
2
  import { fetchResult } from "./cfetch";
3
+ import { fetchR2Objects } from "./cfetch/internal";
4
+ import type { HeadersInit } from "undici";
2
5
 
3
6
  /**
4
7
  * Information about a bucket, returned from `listR2Buckets()`.
@@ -48,3 +51,81 @@ export async function deleteR2Bucket(
48
51
  { method: "DELETE" }
49
52
  );
50
53
  }
54
+
55
+ export function bucketAndKeyFromObjectPath(objectPath = ""): {
56
+ bucket: string;
57
+ key: string;
58
+ } {
59
+ const match = /^([^/]+)\/(.*)/.exec(objectPath);
60
+ if (match === null) {
61
+ throw new Error(
62
+ `The object path must be in the form of {bucket}/{key} you provided ${objectPath}`
63
+ );
64
+ }
65
+
66
+ return { bucket: match[1], key: match[2] };
67
+ }
68
+
69
+ /**
70
+ * Downloads an object
71
+ */
72
+ export async function getR2Object(
73
+ accountId: string,
74
+ bucketName: string,
75
+ objectName: string
76
+ ): Promise<Readable> {
77
+ const response = await fetchR2Objects(
78
+ `/accounts/${accountId}/r2/buckets/${bucketName}/objects/${objectName}`,
79
+ { method: "GET" }
80
+ );
81
+
82
+ return Readable.from(response.body);
83
+ }
84
+
85
+ /**
86
+ * Uploads an object
87
+ */
88
+ export async function putR2Object(
89
+ accountId: string,
90
+ bucketName: string,
91
+ objectName: string,
92
+ object: Readable | Buffer,
93
+ options: Record<string, unknown>
94
+ ): Promise<void> {
95
+ const headerKeys = [
96
+ "content-length",
97
+ "content-type",
98
+ "content-disposition",
99
+ "content-encoding",
100
+ "content-language",
101
+ "cache-control",
102
+ "expires",
103
+ ];
104
+ const headers: HeadersInit = {};
105
+ for (const key of headerKeys) {
106
+ const value = options[key] || "";
107
+ if (value && typeof value === "string") headers[key] = value;
108
+ }
109
+
110
+ await fetchR2Objects(
111
+ `/accounts/${accountId}/r2/buckets/${bucketName}/objects/${objectName}`,
112
+ {
113
+ body: object,
114
+ headers,
115
+ method: "PUT",
116
+ }
117
+ );
118
+ }
119
+ /**
120
+ * Delete an Object
121
+ */
122
+ export async function deleteR2Object(
123
+ accountId: string,
124
+ bucketName: string,
125
+ objectName: string
126
+ ): Promise<void> {
127
+ await fetchR2Objects(
128
+ `/accounts/${accountId}/r2/buckets/${bucketName}/objects/${objectName}`,
129
+ { method: "DELETE" }
130
+ );
131
+ }
package/src/tail/index.ts CHANGED
@@ -174,12 +174,12 @@ export type TailEventMessage = {
174
174
  /**
175
175
  * The event that triggered the worker. In the case of an HTTP request,
176
176
  * this will be a RequestEvent. If it's a cron trigger, it'll be a
177
- * ScheduledEvent.
177
+ * ScheduledEvent. If it's a durable object alarm, it's an AlarmEvent.
178
178
  *
179
179
  * Until workers-types exposes individual types for export, we'll have
180
180
  * to just re-define these types ourselves.
181
181
  */
182
- event: RequestEvent | ScheduledEvent | undefined | null;
182
+ event: RequestEvent | ScheduledEvent | AlarmEvent | undefined | null;
183
183
  };
184
184
 
185
185
  /**
@@ -297,3 +297,16 @@ export type ScheduledEvent = {
297
297
  */
298
298
  scheduledTime: number;
299
299
  };
300
+
301
+ /**
302
+ * A event that was triggered from a durable object alarm
303
+ */
304
+ export type AlarmEvent = {
305
+ /**
306
+ * The datetime the alarm was scheduled for.
307
+ *
308
+ * This is sent as an ISO timestamp string (different than ScheduledEvent.scheduledTime),
309
+ * you should parse it later on on your own.
310
+ */
311
+ scheduledTime: string;
312
+ };
@@ -1,6 +1,11 @@
1
1
  import { logger } from "../logger";
2
- import type { RequestEvent, ScheduledEvent, TailEventMessage } from ".";
3
2
  import type { Outcome } from "./filters";
3
+ import type {
4
+ AlarmEvent,
5
+ RequestEvent,
6
+ ScheduledEvent,
7
+ TailEventMessage,
8
+ } from "./index";
4
9
  import type WebSocket from "ws";
5
10
 
6
11
  export function prettyPrintLogs(data: WebSocket.RawData): void {
@@ -14,7 +19,7 @@ export function prettyPrintLogs(data: WebSocket.RawData): void {
14
19
  const outcome = prettifyOutcome(eventMessage.outcome);
15
20
 
16
21
  logger.log(`"${cronPattern}" @ ${datetime} - ${outcome}`);
17
- } else {
22
+ } else if (isRequestEvent(eventMessage.event)) {
18
23
  const requestMethod = eventMessage.event?.request.method.toUpperCase();
19
24
  const url = eventMessage.event?.request.url;
20
25
  const outcome = prettifyOutcome(eventMessage.outcome);
@@ -25,6 +30,19 @@ export function prettyPrintLogs(data: WebSocket.RawData): void {
25
30
  ? `${requestMethod} ${url} - ${outcome} @ ${datetime}`
26
31
  : `[missing request] - ${outcome} @ ${datetime}`
27
32
  );
33
+ } else if (isAlarmEvent(eventMessage.event)) {
34
+ const outcome = prettifyOutcome(eventMessage.outcome);
35
+ const datetime = new Date(
36
+ eventMessage.event.scheduledTime
37
+ ).toLocaleString();
38
+
39
+ logger.log(`Alarm @ ${datetime} - ${outcome}`);
40
+ } else {
41
+ // Unknown event type
42
+ const outcome = prettifyOutcome(eventMessage.outcome);
43
+ const datetime = new Date(eventMessage.eventTimestamp).toLocaleString();
44
+
45
+ logger.log(`Unknown Event - ${outcome} @ ${datetime}`);
28
46
  }
29
47
 
30
48
  if (eventMessage.logs.length > 0) {
@@ -44,12 +62,32 @@ export function jsonPrintLogs(data: WebSocket.RawData): void {
44
62
  console.log(JSON.stringify(JSON.parse(data.toString()), null, 2));
45
63
  }
46
64
 
65
+ function isRequestEvent(
66
+ event: TailEventMessage["event"]
67
+ ): event is RequestEvent {
68
+ return Boolean(event && "request" in event);
69
+ }
70
+
47
71
  function isScheduledEvent(
48
- event: RequestEvent | ScheduledEvent | undefined | null
72
+ event: TailEventMessage["event"]
49
73
  ): event is ScheduledEvent {
50
74
  return Boolean(event && "cron" in event);
51
75
  }
52
76
 
77
+ /**
78
+ * Check to see if an event sent from a worker is an AlarmEvent.
79
+ *
80
+ * Because the only property on `AlarmEvent` is "scheduledTime", which it
81
+ * shares with `ScheduledEvent`, `isAlarmEvent` checks if there's _not_
82
+ * a "cron" property in `event` to confirm it's an alarm event.
83
+ *
84
+ * @param event An event
85
+ * @returns true if the event is an AlarmEvent
86
+ */
87
+ function isAlarmEvent(event: TailEventMessage["event"]): event is AlarmEvent {
88
+ return Boolean(event && "scheduledTime" in event && !("cron" in event));
89
+ }
90
+
53
91
  function prettifyOutcome(outcome: Outcome): string {
54
92
  switch (outcome) {
55
93
  case "ok":
@@ -44,17 +44,26 @@ export async function getAccountChoices(): Promise<ChooseAccountItem[]> {
44
44
  if (accountIdFromEnv) {
45
45
  return [{ id: accountIdFromEnv, name: "" }];
46
46
  } else {
47
- const response = await fetchListResult<{
48
- account: ChooseAccountItem;
49
- }>(`/memberships`);
50
- const accounts = response.map((r) => r.account);
51
- if (accounts.length === 0) {
52
- throw new Error(
53
- "Failed to automatically retrieve account IDs for the logged in user.\n" +
54
- "In a non-interactive environment, it is mandatory to specify an account ID, either by assigning its value to CLOUDFLARE_ACCOUNT_ID, or as `account_id` in your `wrangler.toml` file."
55
- );
56
- } else {
57
- return accounts;
47
+ try {
48
+ const response = await fetchListResult<{
49
+ account: ChooseAccountItem;
50
+ }>(`/memberships`);
51
+ const accounts = response.map((r) => r.account);
52
+ if (accounts.length === 0) {
53
+ throw new Error(
54
+ "Failed to automatically retrieve account IDs for the logged in user.\n" +
55
+ "In a non-interactive environment, it is mandatory to specify an account ID, either by assigning its value to CLOUDFLARE_ACCOUNT_ID, or as `account_id` in your `wrangler.toml` file."
56
+ );
57
+ } else {
58
+ return accounts;
59
+ }
60
+ } catch (err) {
61
+ if ((err as { code: number }).code === 9109) {
62
+ throw new Error(
63
+ `Failed to automatically retrieve account IDs for the logged in user.
64
+ You may have incorrect permissions on your API token. You can skip this account check by adding an \`account_id\` in your \`wrangler.toml\`, or by setting the value of CLOUDFLARE_ACCOUNT_ID"`
65
+ );
66
+ } else throw err;
58
67
  }
59
68
  }
60
69
  }