wrangler 2.0.6 → 2.0.9

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 (46) hide show
  1. package/README.md +1 -1
  2. package/bin/wrangler.js +16 -4
  3. package/package.json +6 -4
  4. package/pages/functions/buildPlugin.ts +13 -0
  5. package/pages/functions/buildWorker.ts +13 -0
  6. package/src/__tests__/configuration.test.ts +132 -60
  7. package/src/__tests__/dev.test.tsx +168 -67
  8. package/src/__tests__/helpers/mock-dialogs.ts +41 -1
  9. package/src/__tests__/index.test.ts +25 -10
  10. package/src/__tests__/init.test.ts +252 -131
  11. package/src/__tests__/kv.test.ts +16 -16
  12. package/src/__tests__/package-manager.test.ts +154 -7
  13. package/src/__tests__/pages.test.ts +442 -38
  14. package/src/__tests__/parse.test.ts +5 -1
  15. package/src/__tests__/publish.test.ts +377 -84
  16. package/src/__tests__/secret.test.ts +4 -4
  17. package/src/__tests__/whoami.test.tsx +34 -0
  18. package/src/abort.d.ts +3 -0
  19. package/src/cfetch/index.ts +21 -4
  20. package/src/cfetch/internal.ts +20 -18
  21. package/src/config/config.ts +1 -1
  22. package/src/config/index.ts +162 -0
  23. package/src/config/validation.ts +77 -29
  24. package/src/create-worker-preview.ts +32 -22
  25. package/src/dev/dev.tsx +6 -16
  26. package/src/dev/remote.tsx +40 -16
  27. package/src/dialogs.tsx +48 -0
  28. package/src/durable.ts +102 -0
  29. package/src/index.tsx +291 -207
  30. package/src/inspect.ts +39 -0
  31. package/src/kv.ts +74 -25
  32. package/src/open-in-browser.ts +5 -12
  33. package/src/package-manager.ts +50 -3
  34. package/src/pages.tsx +218 -61
  35. package/src/parse.ts +21 -4
  36. package/src/proxy.ts +38 -22
  37. package/src/publish.ts +166 -108
  38. package/src/sites.tsx +8 -8
  39. package/src/user.tsx +12 -1
  40. package/src/whoami.tsx +3 -2
  41. package/src/worker.ts +2 -1
  42. package/src/zones.ts +73 -0
  43. package/templates/new-worker-scheduled.js +17 -0
  44. package/templates/new-worker-scheduled.ts +32 -0
  45. package/templates/new-worker.ts +16 -1
  46. package/wrangler-dist/cli.js +33066 -20052
package/src/proxy.ts CHANGED
@@ -2,11 +2,13 @@ import { createServer as createHttpServer } from "node:http";
2
2
  import { connect } from "node:http2";
3
3
  import { createServer as createHttpsServer } from "node:https";
4
4
  import WebSocket from "faye-websocket";
5
+ import { createHttpTerminator } from "http-terminator";
5
6
  import { useEffect, useRef, useState } from "react";
6
7
  import serveStatic from "serve-static";
7
8
  import { getHttpsOptions } from "./https-options";
8
9
  import { logger } from "./logger";
9
10
  import type { CfPreviewToken } from "./create-worker-preview";
11
+ import type { HttpTerminator } from "http-terminator";
10
12
  import type {
11
13
  IncomingHttpHeaders,
12
14
  RequestListener,
@@ -67,6 +69,11 @@ function rewriteRemoteHostToLocalHostInHeaders(
67
69
  }
68
70
  }
69
71
 
72
+ type PreviewProxy = {
73
+ server: HttpServer | HttpsServer;
74
+ terminator: HttpTerminator;
75
+ };
76
+
70
77
  export function usePreviewServer({
71
78
  previewToken,
72
79
  publicRoot,
@@ -81,21 +88,26 @@ export function usePreviewServer({
81
88
  ip: string;
82
89
  }) {
83
90
  /** Creates an HTTP/1 proxy that sends requests over HTTP/2. */
84
- const [proxyServer, setProxyServer] = useState<HttpServer | HttpsServer>();
91
+ const [proxy, setProxy] = useState<PreviewProxy>();
85
92
 
86
93
  /**
87
94
  * Create the instance of the local proxy server that will pass on
88
95
  * requests to the preview worker.
89
96
  */
90
97
  useEffect(() => {
91
- if (proxyServer === undefined) {
98
+ if (proxy === undefined) {
92
99
  createProxyServer(localProtocol)
93
- .then((proxy) => setProxyServer(proxy))
100
+ .then((server) => {
101
+ setProxy({
102
+ server,
103
+ terminator: createHttpTerminator({ server }),
104
+ });
105
+ })
94
106
  .catch(async (err) => {
95
107
  logger.error("Failed to create proxy server:", err);
96
108
  });
97
109
  }
98
- }, [proxyServer, localProtocol]);
110
+ }, [proxy, localProtocol]);
99
111
 
100
112
  /**
101
113
  * When we're not connected / getting a fresh token on changes,
@@ -123,7 +135,7 @@ export function usePreviewServer({
123
135
  }
124
136
 
125
137
  useEffect(() => {
126
- if (proxyServer === undefined) {
138
+ if (proxy === undefined) {
127
139
  return;
128
140
  }
129
141
 
@@ -138,8 +150,8 @@ export function usePreviewServer({
138
150
  // store the stream in a buffer so we can replay it later
139
151
  streamBufferRef.current.push({ stream, headers });
140
152
  };
141
- proxyServer.on("stream", bufferStream);
142
- cleanupListeners.push(() => proxyServer.off("stream", bufferStream));
153
+ proxy.server.on("stream", bufferStream);
154
+ cleanupListeners.push(() => proxy.server.off("stream", bufferStream));
143
155
 
144
156
  const bufferRequestResponse = (
145
157
  request: IncomingMessage,
@@ -149,9 +161,9 @@ export function usePreviewServer({
149
161
  requestResponseBufferRef.current.push({ request, response });
150
162
  };
151
163
 
152
- proxyServer.on("request", bufferRequestResponse);
164
+ proxy.server.on("request", bufferRequestResponse);
153
165
  cleanupListeners.push(() =>
154
- proxyServer.off("request", bufferRequestResponse)
166
+ proxy.server.off("request", bufferRequestResponse)
155
167
  );
156
168
  return () => {
157
169
  cleanupListeners.forEach((cleanup) => cleanup());
@@ -179,8 +191,8 @@ export function usePreviewServer({
179
191
  port,
180
192
  localProtocol
181
193
  );
182
- proxyServer.on("stream", handleStream);
183
- cleanupListeners.push(() => proxyServer.off("stream", handleStream));
194
+ proxy.server.on("stream", handleStream);
195
+ cleanupListeners.push(() => proxy.server.off("stream", handleStream));
184
196
 
185
197
  // flush and replay buffered streams
186
198
  streamBufferRef.current.forEach((buffer) =>
@@ -236,9 +248,9 @@ export function usePreviewServer({
236
248
  ? createHandleAssetsRequest(assetPath, handleRequest)
237
249
  : handleRequest;
238
250
 
239
- proxyServer.on("request", actualHandleRequest);
251
+ proxy.server.on("request", actualHandleRequest);
240
252
  cleanupListeners.push(() =>
241
- proxyServer.off("request", actualHandleRequest)
253
+ proxy.server.off("request", actualHandleRequest)
242
254
  );
243
255
 
244
256
  // flush and replay buffered requests
@@ -270,8 +282,8 @@ export function usePreviewServer({
270
282
  remoteWebsocketClient.close();
271
283
  });
272
284
  };
273
- proxyServer.on("upgrade", handleUpgrade);
274
- cleanupListeners.push(() => proxyServer.off("upgrade", handleUpgrade));
285
+ proxy.server.on("upgrade", handleUpgrade);
286
+ cleanupListeners.push(() => proxy.server.off("upgrade", handleUpgrade));
275
287
 
276
288
  return () => {
277
289
  cleanupListeners.forEach((cleanup) => cleanup());
@@ -281,7 +293,7 @@ export function usePreviewServer({
281
293
  publicRoot,
282
294
  port,
283
295
  localProtocol,
284
- proxyServer,
296
+ proxy,
285
297
  // We use a state value as a sigil to trigger reconnecting the server.
286
298
  // It's not used inside the effect, so react-hooks/exhaustive-deps
287
299
  // doesn't complain if it's not included in the dependency array.
@@ -293,7 +305,7 @@ export function usePreviewServer({
293
305
  // containing component is mounted/unmounted.
294
306
  useEffect(() => {
295
307
  const abortController = new AbortController();
296
- if (proxyServer === undefined) {
308
+ if (proxy === undefined) {
297
309
  return;
298
310
  }
299
311
 
@@ -303,8 +315,10 @@ export function usePreviewServer({
303
315
  abortSignal: abortController.signal,
304
316
  })
305
317
  .then(() => {
306
- proxyServer.listen(port, ip);
307
- logger.log(`⬣ Listening at ${localProtocol}://${ip}:${port}`);
318
+ proxy.server.on("listening", () => {
319
+ logger.log(`⬣ Listening at ${localProtocol}://${ip}:${port}`);
320
+ });
321
+ proxy.server.listen(port, ip);
308
322
  })
309
323
  .catch((err) => {
310
324
  if ((err as { code: string }).code !== "ABORT_ERR") {
@@ -313,10 +327,12 @@ export function usePreviewServer({
313
327
  });
314
328
 
315
329
  return () => {
316
- proxyServer.close();
317
330
  abortController.abort();
331
+ // Running `proxy.server.close()` does not close open connections, preventing the process from exiting.
332
+ // So we use this `terminator` to close all the connections and force the server to shutdown.
333
+ proxy.terminator.terminate();
318
334
  };
319
- }, [port, ip, proxyServer, localProtocol]);
335
+ }, [port, ip, proxy, localProtocol]);
320
336
  }
321
337
 
322
338
  function createHandleAssetsRequest(
@@ -404,7 +420,7 @@ export async function waitForPortToBeAvailable(
404
420
  ): Promise<void> {
405
421
  return new Promise((resolve, reject) => {
406
422
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
407
- (options.abortSignal as any).addEventListener("abort", () => {
423
+ options.abortSignal.addEventListener("abort", () => {
408
424
  const abortError = new Error("waitForPortToBeAvailable() aborted");
409
425
  (abortError as Error & { code: string }).code = "ABORT_ERR";
410
426
  doReject(abortError);
package/src/publish.ts CHANGED
@@ -4,11 +4,15 @@ import path from "node:path";
4
4
  import { URLSearchParams } from "node:url";
5
5
  import tmp from "tmp-promise";
6
6
  import { bundleWorker } from "./bundle";
7
- import { fetchResult } from "./cfetch";
7
+ import { fetchListResult, fetchResult } from "./cfetch";
8
+ import { printBindings } from "./config";
8
9
  import { createWorkerUploadForm } from "./create-worker-upload-form";
9
10
  import { confirm } from "./dialogs";
11
+ import { getMigrationsToUpload } from "./durable";
10
12
  import { logger } from "./logger";
13
+ import { ParseError } from "./parse";
11
14
  import { syncAssets } from "./sites";
15
+ import { getZoneForRoute } from "./zones";
12
16
  import type { Config } from "./config";
13
17
  import type {
14
18
  Route,
@@ -258,7 +262,7 @@ export default async function publish(props: Props): Promise<void> {
258
262
  const nodeCompat = props.nodeCompat ?? config.node_compat;
259
263
  if (nodeCompat) {
260
264
  logger.warn(
261
- "Enabling node.js compatibility mode for builtins and globals. This is experimental and has serious tradeoffs. Please see https://github.com/ionic-team/rollup-plugin-node-polyfills/ for more details."
265
+ "Enabling node.js compatibility mode for built-ins and globals. This is experimental and has serious tradeoffs. Please see https://github.com/ionic-team/rollup-plugin-node-polyfills/ for more details."
262
266
  );
263
267
  }
264
268
 
@@ -289,7 +293,7 @@ export default async function publish(props: Props): Promise<void> {
289
293
  const envName = props.env ?? "production";
290
294
 
291
295
  const start = Date.now();
292
- const notProd = !props.legacyEnv && props.env;
296
+ const notProd = Boolean(!props.legacyEnv && props.env);
293
297
  const workerName = notProd ? `${scriptName} (${envName})` : scriptName;
294
298
  const workerUrl = notProd
295
299
  ? `/accounts/${accountId}/workers/services/${scriptName}/environments/${envName}`
@@ -337,103 +341,19 @@ export default async function publish(props: Props): Promise<void> {
337
341
  }
338
342
  );
339
343
 
340
- // Some validation of durable objects + migrations
341
- if (config.durable_objects.bindings.length > 0) {
342
- // intrinsic [durable_objects] implies [migrations]
343
- const exportedDurableObjects = config.durable_objects.bindings.filter(
344
- (binding) => !binding.script_name
345
- );
346
- if (exportedDurableObjects.length > 0 && config.migrations.length === 0) {
347
- logger.warn(
348
- `In wrangler.toml, you have configured [durable_objects] exported by this Worker (${exportedDurableObjects.map(
349
- (durable) => durable.class_name
350
- )}), but no [migrations] for them. This may not work as expected until you add a [migrations] section to your wrangler.toml. Refer to https://developers.cloudflare.com/workers/learning/using-durable-objects/#durable-object-migrations-in-wranglertoml for more details.`
351
- );
352
- }
353
- }
354
-
355
344
  const content = readFileSync(resolvedEntryPointPath, {
356
345
  encoding: "utf-8",
357
346
  });
358
347
 
359
- // if config.migrations
360
- let migrations;
361
- if (!props.dryRun && config.migrations.length > 0) {
362
- // get current migration tag
363
- type ScriptData = { id: string; migration_tag?: string };
364
- let script: ScriptData | undefined;
365
- if (!props.legacyEnv) {
366
- try {
367
- if (props.env) {
368
- const scriptData = await fetchResult<{
369
- script: ScriptData;
370
- }>(
371
- `/accounts/${accountId}/workers/services/${scriptName}/environments/${props.env}`
372
- );
373
- script = scriptData.script;
374
- } else {
375
- const scriptData = await fetchResult<{
376
- default_environment: {
377
- script: ScriptData;
378
- };
379
- }>(`/accounts/${accountId}/workers/services/${scriptName}`);
380
- script = scriptData.default_environment.script;
381
- }
382
- } catch (err) {
383
- if (
384
- ![
385
- 10090, // corresponds to workers.api.error.service_not_found, so the script wasn't previously published at all
386
- 10092, // workers.api.error.environment_not_found, so the script wasn't published to this environment yet
387
- ].includes((err as { code: number }).code)
388
- ) {
389
- throw err;
390
- }
391
- // else it's a 404, no script found, and we can proceed
392
- }
393
- } else {
394
- const scripts = await fetchResult<ScriptData[]>(
395
- `/accounts/${accountId}/workers/scripts`
396
- );
397
- script = scripts.find(({ id }) => id === scriptName);
398
- }
399
-
400
- if (script?.migration_tag) {
401
- // was already published once
402
- const scriptMigrationTag = script.migration_tag;
403
- const foundIndex = config.migrations.findIndex(
404
- (migration) => migration.tag === scriptMigrationTag
405
- );
406
- if (foundIndex === -1) {
407
- logger.warn(
408
- `The published script ${scriptName} has a migration tag "${script.migration_tag}, which was not found in wrangler.toml. You may have already deleted it. Applying all available migrations to the script...`
409
- );
410
- migrations = {
411
- old_tag: script.migration_tag,
412
- new_tag: config.migrations[config.migrations.length - 1].tag,
413
- steps: config.migrations.map(({ tag: _tag, ...rest }) => rest),
414
- };
415
- } else {
416
- if (foundIndex !== config.migrations.length - 1) {
417
- // there are new migrations to send up
418
- migrations = {
419
- old_tag: script.migration_tag,
420
- new_tag: config.migrations[config.migrations.length - 1].tag,
421
- steps: config.migrations
422
- .slice(foundIndex + 1)
423
- .map(({ tag: _tag, ...rest }) => rest),
424
- };
425
- }
426
- // else, we're up to date, no migrations to send
427
- }
428
- } else {
429
- // first time publishing durable objects to this script,
430
- // so we send all the migrations
431
- migrations = {
432
- new_tag: config.migrations[config.migrations.length - 1].tag,
433
- steps: config.migrations.map(({ tag: _tag, ...rest }) => rest),
434
- };
435
- }
436
- }
348
+ // durable object migrations
349
+ const migrations = !props.dryRun
350
+ ? await getMigrationsToUpload(scriptName, {
351
+ accountId,
352
+ config,
353
+ legacyEnv: props.legacyEnv,
354
+ env: props.env,
355
+ })
356
+ : undefined;
437
357
 
438
358
  const assets = await syncAssets(
439
359
  accountId,
@@ -493,6 +413,13 @@ export default async function publish(props: Props): Promise<void> {
493
413
  usage_model: config.usage_model,
494
414
  };
495
415
 
416
+ const withoutStaticAssets = {
417
+ ...bindings,
418
+ kv_namespaces: config.kv_namespaces,
419
+ text_blobs: config.text_blobs,
420
+ };
421
+ printBindings(withoutStaticAssets);
422
+
496
423
  if (!props.dryRun) {
497
424
  // Upload the script so it has time to propagate.
498
425
  // We can also now tell whether available_on_subdomain is set
@@ -572,18 +499,7 @@ export default async function publish(props: Props): Promise<void> {
572
499
  // Update routing table for the script.
573
500
  if (routesOnly.length > 0) {
574
501
  deployments.push(
575
- fetchResult(`${workerUrl}/routes`, {
576
- // Note: PUT will delete previous routes on this script.
577
- method: "PUT",
578
- body: JSON.stringify(
579
- routesOnly.map((route) =>
580
- typeof route !== "object" ? { pattern: route } : route
581
- )
582
- ),
583
- headers: {
584
- "Content-Type": "application/json",
585
- },
586
- }).then(() => {
502
+ publishRoutes(routesOnly, { workerUrl, scriptName, notProd }).then(() => {
587
503
  if (routesOnly.length > 10) {
588
504
  return routesOnly
589
505
  .slice(0, 9)
@@ -656,3 +572,145 @@ async function getSubdomain(accountId: string): Promise<string> {
656
572
  }
657
573
  }
658
574
  }
575
+
576
+ /**
577
+ * Associate the newly deployed Worker with the given routes.
578
+ */
579
+ async function publishRoutes(
580
+ routes: Route[],
581
+ {
582
+ workerUrl,
583
+ scriptName,
584
+ notProd,
585
+ }: { workerUrl: string; scriptName: string; notProd: boolean }
586
+ ): Promise<string[]> {
587
+ try {
588
+ return await fetchResult(`${workerUrl}/routes`, {
589
+ // Note: PUT will delete previous routes on this script.
590
+ method: "PUT",
591
+ body: JSON.stringify(
592
+ routes.map((route) =>
593
+ typeof route !== "object" ? { pattern: route } : route
594
+ )
595
+ ),
596
+ headers: {
597
+ "Content-Type": "application/json",
598
+ },
599
+ });
600
+ } catch (e) {
601
+ if (isAuthenticationError(e)) {
602
+ // An authentication error is probably due to a known issue,
603
+ // where the user is logged in via an API token that does not have "All Zones".
604
+ return await publishRoutesFallback(routes, { scriptName, notProd });
605
+ } else {
606
+ throw e;
607
+ }
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Try updating routes for the Worker using a less optimal zone-based API.
613
+ *
614
+ * Compute match zones to the routes, then for each route attempt to connect it to the Worker via the zone.
615
+ */
616
+ async function publishRoutesFallback(
617
+ routes: Route[],
618
+ { scriptName, notProd }: { scriptName: string; notProd: boolean }
619
+ ) {
620
+ if (notProd) {
621
+ throw new Error(
622
+ "Service environments combined with an API token that doesn't have 'All Zones' permissions is not supported.\n" +
623
+ "Either turn off service environments by setting `legacy_env = true`, creating an API token with 'All Zones' permissions, or logging in via OAuth"
624
+ );
625
+ }
626
+ logger.warn(
627
+ "The current authentication token does not have 'All Zones' permissions.\n" +
628
+ "Falling back to using the zone-based API endpoint to update each route individually.\n" +
629
+ "Note that there is no access to routes associated with zones that the API token does not have permission for.\n" +
630
+ "Existing routes for this Worker in such zones will not be deleted."
631
+ );
632
+
633
+ const deployedRoutes: string[] = [];
634
+
635
+ // Collect the routes (and their zones) that will be deployed.
636
+ const activeZones = new Map<string, string>();
637
+ const routesToDeploy = new Map<string, string>();
638
+ for (const route of routes) {
639
+ const zone = await getZoneForRoute(route);
640
+ if (zone) {
641
+ activeZones.set(zone.id, zone.host);
642
+ routesToDeploy.set(
643
+ typeof route === "string" ? route : route.pattern,
644
+ zone.id
645
+ );
646
+ }
647
+ }
648
+
649
+ // Collect the routes that are already deployed.
650
+ const allRoutes = new Map<string, string>();
651
+ const alreadyDeployedRoutes = new Set<string>();
652
+ for (const [zone, host] of activeZones) {
653
+ try {
654
+ for (const { pattern, script } of await fetchListResult<{
655
+ pattern: string;
656
+ script: string;
657
+ }>(`/zones/${zone}/workers/routes`)) {
658
+ allRoutes.set(pattern, script);
659
+ if (script === scriptName) {
660
+ alreadyDeployedRoutes.add(pattern);
661
+ }
662
+ }
663
+ } catch (e) {
664
+ if (isAuthenticationError(e)) {
665
+ e.notes.push({
666
+ text: `This could be because the API token being used does not have permission to access the zone "${host}" (${zone}).`,
667
+ });
668
+ }
669
+ throw e;
670
+ }
671
+ }
672
+
673
+ // Deploy each route that is not already deployed.
674
+ for (const [routePattern, zoneId] of routesToDeploy.entries()) {
675
+ if (allRoutes.has(routePattern)) {
676
+ const knownScript = allRoutes.get(routePattern);
677
+ if (knownScript === scriptName) {
678
+ // This route is already associated with this worker, so no need to hit the API.
679
+ alreadyDeployedRoutes.delete(routePattern);
680
+ continue;
681
+ } else {
682
+ throw new Error(
683
+ `The route with pattern "${routePattern}" is already associated with another worker called "${knownScript}".`
684
+ );
685
+ }
686
+ }
687
+
688
+ const { pattern } = await fetchResult(`/zones/${zoneId}/workers/routes`, {
689
+ method: "POST",
690
+ body: JSON.stringify({
691
+ pattern: routePattern,
692
+ script: scriptName,
693
+ }),
694
+ headers: {
695
+ "Content-Type": "application/json",
696
+ },
697
+ });
698
+
699
+ deployedRoutes.push(pattern);
700
+ }
701
+
702
+ if (alreadyDeployedRoutes.size) {
703
+ logger.warn(
704
+ "Previously deployed routes:\n" +
705
+ "The following routes were already associated with this worker, and have not been deleted:\n" +
706
+ [...alreadyDeployedRoutes.values()].map((route) => ` - "${route}"\n`) +
707
+ "If these routes are not wanted then you can remove them in the dashboard."
708
+ );
709
+ }
710
+
711
+ return deployedRoutes;
712
+ }
713
+
714
+ function isAuthenticationError(e: unknown): e is ParseError {
715
+ return e instanceof ParseError && (e as { code?: number }).code === 10000;
716
+ }
package/src/sites.tsx CHANGED
@@ -177,7 +177,12 @@ export async function syncAssets(
177
177
 
178
178
  // remove the key from the set so we know what we've already uploaded
179
179
  namespaceKeys.delete(assetKey);
180
- manifest[path.relative(siteAssets.assetDirectory, absAssetFile)] = assetKey;
180
+
181
+ // prevent causing different manifest keys on windows
182
+ const maifestKey = urlSafe(
183
+ path.relative(siteAssets.assetDirectory, absAssetFile)
184
+ );
185
+ manifest[maifestKey] = assetKey;
181
186
  }
182
187
 
183
188
  // keys now contains all the files we're deleting
@@ -187,14 +192,9 @@ export async function syncAssets(
187
192
 
188
193
  await Promise.all([
189
194
  // upload all the new assets
190
- putKVBulkKeyValue(accountId, namespace, toUpload, () => {}),
195
+ putKVBulkKeyValue(accountId, namespace, toUpload),
191
196
  // delete all the unused assets
192
- deleteKVBulkKeyValue(
193
- accountId,
194
- namespace,
195
- Array.from(namespaceKeys),
196
- () => {}
197
- ),
197
+ deleteKVBulkKeyValue(accountId, namespace, Array.from(namespaceKeys)),
198
198
  ]);
199
199
 
200
200
  logger.log("↗️ Done syncing assets");
package/src/user.tsx CHANGED
@@ -233,7 +233,7 @@ import type { Response } from "undici";
233
233
  /**
234
234
  * Try to read the API token from the environment.
235
235
  */
236
- const getCloudflareAPITokenFromEnv = getEnvironmentVariableFactory({
236
+ export const getCloudflareAPITokenFromEnv = getEnvironmentVariableFactory({
237
237
  variableName: "CLOUDFLARE_API_TOKEN",
238
238
  deprecatedName: "CF_API_TOKEN",
239
239
  });
@@ -1201,3 +1201,14 @@ export async function requireAuth(config: {
1201
1201
 
1202
1202
  return accountId;
1203
1203
  }
1204
+
1205
+ /**
1206
+ * Throw an error if there is no API token available.
1207
+ */
1208
+ export function requireApiToken(): string {
1209
+ const authToken = getAPIToken();
1210
+ if (!authToken) {
1211
+ throw new Error("No API token found.");
1212
+ }
1213
+ return authToken;
1214
+ }
package/src/whoami.tsx CHANGED
@@ -3,7 +3,7 @@ import Table from "ink-table";
3
3
  import React from "react";
4
4
  import { fetchListResult, fetchResult } from "./cfetch";
5
5
  import { logger } from "./logger";
6
- import { getAPIToken } from "./user";
6
+ import { getAPIToken, getCloudflareAPITokenFromEnv } from "./user";
7
7
 
8
8
  export async function whoami() {
9
9
  logger.log("Getting User settings...");
@@ -48,10 +48,11 @@ export interface UserInfo {
48
48
 
49
49
  export async function getUserInfo(): Promise<UserInfo | undefined> {
50
50
  const apiToken = getAPIToken();
51
+ const apiTokenFromEnv = getCloudflareAPITokenFromEnv();
51
52
  return apiToken
52
53
  ? {
53
54
  apiToken,
54
- authType: "OAuth",
55
+ authType: apiTokenFromEnv ? "API" : "OAuth",
55
56
  email: await getEmail(),
56
57
  accounts: await getAccounts(),
57
58
  }
package/src/worker.ts CHANGED
@@ -176,5 +176,6 @@ export interface CfWorkerInit {
176
176
  export interface CfWorkerContext {
177
177
  env: string | undefined;
178
178
  legacyEnv: boolean | undefined;
179
- zone: { id: string; host: string } | undefined;
179
+ zone: string | undefined;
180
+ host: string | undefined;
180
181
  }
package/src/zones.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { fetchListResult } from "./cfetch";
2
+ import type { Route } from "./config/environment";
3
+
4
+ /**
5
+ * An object holding information about a zone for publishing.
6
+ */
7
+ export interface Zone {
8
+ id: string;
9
+ host: string;
10
+ }
11
+
12
+ /**
13
+ * Try to compute the a zone ID and host name for one or more routes.
14
+ *
15
+ * When we're given a route, we do 2 things:
16
+ * - We try to extract a host from it
17
+ * - We try to get a zone id from the host
18
+ */
19
+ export async function getZoneForRoute(route: Route): Promise<Zone | undefined> {
20
+ const host =
21
+ typeof route === "string"
22
+ ? getHostFromUrl(route)
23
+ : typeof route === "object"
24
+ ? "zone_name" in route
25
+ ? getHostFromUrl(route.zone_name)
26
+ : getHostFromUrl(route.pattern)
27
+ : undefined;
28
+ const id =
29
+ typeof route === "object" && "zone_id" in route
30
+ ? route.zone_id
31
+ : host
32
+ ? await getZoneIdFromHost(host)
33
+ : undefined;
34
+ return id && host ? { id, host } : undefined;
35
+ }
36
+
37
+ /**
38
+ * Given something that resembles a URL, try to extract a host from it.
39
+ */
40
+ function getHostFromUrl(urlLike: string): string | undefined {
41
+ // strip leading * / *.
42
+ urlLike = urlLike.replace(/^\*(\.)?/g, "");
43
+
44
+ if (!(urlLike.startsWith("http://") || urlLike.startsWith("https://"))) {
45
+ urlLike = "http://" + urlLike;
46
+ }
47
+ return new URL(urlLike).host;
48
+ }
49
+
50
+ /**
51
+ * Given something that resembles a host, try to infer a zone id from it.
52
+ *
53
+ * It's hard to get a 'valid' domain from a string, so we don't even try to validate TLDs, etc.
54
+ * For each domain-like part of the host (e.g. w.x.y.z) try to get a zone id for it by
55
+ * lopping off subdomains until we get a hit from the API.
56
+ */
57
+ export async function getZoneIdFromHost(host: string): Promise<string> {
58
+ const hostPieces = host.split(".");
59
+
60
+ while (hostPieces.length > 1) {
61
+ const zones = await fetchListResult<{ id: string }>(
62
+ `/zones`,
63
+ {},
64
+ new URLSearchParams({ name: hostPieces.join(".") })
65
+ );
66
+ if (zones.length > 0) {
67
+ return zones[0].id;
68
+ }
69
+ hostPieces.shift();
70
+ }
71
+
72
+ throw new Error(`Could not find zone for ${host}`);
73
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Welcome to Cloudflare Workers! This is your first scheduled worker.
3
+ *
4
+ * - Run `wrangler dev --local` in your terminal to start a development server
5
+ * - Run `curl "http://localhost:8787/cdn-cgi/mf/scheduled"` to trigger the scheduled event
6
+ * - Go back to the console to see what your worker has logged
7
+ * - Update the Cron trigger in wrangler.toml (see https://developers.cloudflare.com/workers/wrangler/configuration/#triggers)
8
+ * - Run `wrangler publish --name my-worker` to publish your worker
9
+ *
10
+ * Learn more at https://developers.cloudflare.com/workers/runtime-apis/scheduled-event/
11
+ */
12
+
13
+ export default {
14
+ async scheduled(controller, env, ctx) {
15
+ console.log(`Hello World!`);
16
+ },
17
+ };