wrangler 0.0.0-e2a26a0 → 0.0.0-e322475

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 (157) hide show
  1. package/Cloudflare_CA.pem +18 -0
  2. package/README.md +27 -14
  3. package/bin/wrangler.js +49 -5
  4. package/kv-asset-handler.js +1 -0
  5. package/miniflare-dist/index.mjs +4886 -0
  6. package/package.json +54 -27
  7. package/pages/functions/buildPlugin.ts +105 -0
  8. package/pages/functions/buildWorker.ts +93 -4
  9. package/pages/functions/filepath-routing.test.ts +223 -36
  10. package/pages/functions/filepath-routing.ts +78 -120
  11. package/pages/functions/routes.ts +13 -17
  12. package/pages/functions/template-plugin.ts +147 -0
  13. package/pages/functions/template-worker.ts +24 -17
  14. package/src/__tests__/config-cache.test.ts +36 -0
  15. package/src/__tests__/configuration.test.ts +3548 -0
  16. package/src/__tests__/dev.test.tsx +1049 -65
  17. package/src/__tests__/guess-worker-format.test.ts +85 -0
  18. package/src/__tests__/helpers/faye-websocket.d.ts +6 -0
  19. package/src/__tests__/helpers/mock-account-id.ts +26 -8
  20. package/src/__tests__/helpers/mock-bin.ts +4 -1
  21. package/src/__tests__/helpers/mock-cfetch.ts +12 -8
  22. package/src/__tests__/helpers/mock-console.ts +49 -8
  23. package/src/__tests__/helpers/mock-dialogs.ts +99 -11
  24. package/src/__tests__/helpers/mock-http-server.ts +46 -0
  25. package/src/__tests__/helpers/mock-istty.ts +27 -0
  26. package/src/__tests__/helpers/mock-kv.ts +9 -16
  27. package/src/__tests__/helpers/mock-oauth-flow.ts +255 -0
  28. package/src/__tests__/helpers/mock-process.ts +24 -0
  29. package/src/__tests__/helpers/mock-stdin.ts +103 -0
  30. package/src/__tests__/helpers/mock-web-socket.ts +29 -0
  31. package/src/__tests__/helpers/run-in-tmp.ts +10 -0
  32. package/src/__tests__/helpers/run-wrangler.ts +5 -3
  33. package/src/__tests__/helpers/write-worker-source.ts +31 -0
  34. package/src/__tests__/helpers/write-wrangler-toml.ts +17 -0
  35. package/src/__tests__/https-options.test.ts +130 -0
  36. package/src/__tests__/index.test.ts +150 -435
  37. package/src/__tests__/init.test.ts +1992 -0
  38. package/src/__tests__/jest.setup.ts +75 -5
  39. package/src/__tests__/kv.test.ts +634 -253
  40. package/src/__tests__/logger.test.ts +141 -0
  41. package/src/__tests__/package-manager.test.ts +156 -9
  42. package/src/__tests__/pages.test.ts +754 -0
  43. package/src/__tests__/parse.test.ts +330 -0
  44. package/src/__tests__/publish.test.ts +5895 -961
  45. package/src/__tests__/r2.test.ts +233 -0
  46. package/src/__tests__/route.test.ts +45 -0
  47. package/src/__tests__/secret.test.ts +362 -91
  48. package/src/__tests__/tail.test.ts +690 -0
  49. package/src/__tests__/user.test.ts +132 -0
  50. package/src/__tests__/whoami.test.tsx +60 -7
  51. package/src/abort.d.ts +3 -0
  52. package/src/bundle-reporter.tsx +29 -0
  53. package/src/bundle.ts +204 -0
  54. package/src/cfetch/index.ts +27 -8
  55. package/src/cfetch/internal.ts +75 -34
  56. package/src/cli.ts +6 -2
  57. package/src/config/README.md +107 -0
  58. package/src/config/config.ts +243 -0
  59. package/src/config/diagnostics.ts +80 -0
  60. package/src/config/environment.ts +420 -0
  61. package/src/config/index.ts +225 -0
  62. package/src/config/validation-helpers.ts +584 -0
  63. package/src/config/validation.ts +1580 -0
  64. package/src/config-cache.ts +45 -0
  65. package/src/create-worker-preview.ts +185 -0
  66. package/src/create-worker-upload-form.ts +254 -0
  67. package/src/dev/dev-vars.ts +36 -0
  68. package/src/dev/dev.tsx +399 -0
  69. package/src/dev/local.tsx +321 -0
  70. package/src/dev/remote.tsx +249 -0
  71. package/src/dev/use-esbuild.ts +112 -0
  72. package/src/dialogs.tsx +64 -6
  73. package/src/durable.ts +102 -0
  74. package/src/entry.ts +328 -0
  75. package/src/environment-variables.ts +35 -0
  76. package/src/errors.ts +11 -0
  77. package/src/generate-auth-url.ts +33 -0
  78. package/src/generate-random-state.ts +16 -0
  79. package/src/git-client.ts +42 -0
  80. package/src/https-options.ts +123 -0
  81. package/src/index.tsx +1804 -1009
  82. package/src/inspect.ts +80 -14
  83. package/src/intl-polyfill.d.ts +139 -0
  84. package/src/{kv.tsx → kv.ts} +193 -69
  85. package/src/logger.ts +73 -0
  86. package/src/miniflare-cli/README.md +30 -0
  87. package/src/miniflare-cli/enum-keys.ts +17 -0
  88. package/src/miniflare-cli/index.ts +41 -0
  89. package/src/miniflare-cli/request-context.ts +40 -0
  90. package/src/module-collection.ts +226 -31
  91. package/src/open-in-browser.ts +17 -0
  92. package/src/package-manager.ts +79 -25
  93. package/src/pages.tsx +1131 -84
  94. package/src/parse.ts +222 -0
  95. package/src/proxy.ts +176 -70
  96. package/src/publish.ts +637 -308
  97. package/src/r2.ts +50 -0
  98. package/src/selfsigned.d.ts +29 -0
  99. package/src/sites.tsx +185 -57
  100. package/src/tail/filters.ts +279 -0
  101. package/src/tail/index.ts +299 -0
  102. package/src/tail/printing.ts +67 -0
  103. package/src/update-check.ts +19 -0
  104. package/src/user.tsx +349 -166
  105. package/src/whoami.tsx +5 -3
  106. package/src/{api/worker.ts → worker.ts} +65 -39
  107. package/src/zones.ts +73 -0
  108. package/templates/gitignore +171 -0
  109. package/templates/new-worker-scheduled.js +17 -0
  110. package/templates/new-worker-scheduled.ts +32 -0
  111. package/templates/new-worker.js +1 -1
  112. package/templates/new-worker.ts +17 -2
  113. package/templates/no-op-worker.js +10 -0
  114. package/templates/static-asset-facade.js +3 -7
  115. package/templates/tsconfig.json +105 -0
  116. package/wrangler-dist/cli.js +77463 -75999
  117. package/src/__tests__/helpers/mock-user.ts +0 -27
  118. package/src/__tests__/logout.test.ts +0 -50
  119. package/src/api/form_data.ts +0 -134
  120. package/src/api/preview.ts +0 -128
  121. package/src/config.ts +0 -641
  122. package/src/dev.tsx +0 -834
  123. package/src/tail.tsx +0 -73
  124. package/vendor/@cloudflare/kv-asset-handler/CHANGELOG.md +0 -332
  125. package/vendor/@cloudflare/kv-asset-handler/LICENSE_APACHE +0 -176
  126. package/vendor/@cloudflare/kv-asset-handler/LICENSE_MIT +0 -25
  127. package/vendor/@cloudflare/kv-asset-handler/README.md +0 -245
  128. package/vendor/@cloudflare/kv-asset-handler/dist/index.d.ts +0 -32
  129. package/vendor/@cloudflare/kv-asset-handler/dist/index.js +0 -354
  130. package/vendor/@cloudflare/kv-asset-handler/dist/mocks.d.ts +0 -13
  131. package/vendor/@cloudflare/kv-asset-handler/dist/mocks.js +0 -148
  132. package/vendor/@cloudflare/kv-asset-handler/dist/test/getAssetFromKV.d.ts +0 -1
  133. package/vendor/@cloudflare/kv-asset-handler/dist/test/getAssetFromKV.js +0 -436
  134. package/vendor/@cloudflare/kv-asset-handler/dist/test/mapRequestToAsset.d.ts +0 -1
  135. package/vendor/@cloudflare/kv-asset-handler/dist/test/mapRequestToAsset.js +0 -40
  136. package/vendor/@cloudflare/kv-asset-handler/dist/test/serveSinglePageApp.d.ts +0 -1
  137. package/vendor/@cloudflare/kv-asset-handler/dist/test/serveSinglePageApp.js +0 -42
  138. package/vendor/@cloudflare/kv-asset-handler/dist/types.d.ts +0 -26
  139. package/vendor/@cloudflare/kv-asset-handler/dist/types.js +0 -31
  140. package/vendor/@cloudflare/kv-asset-handler/package.json +0 -52
  141. package/vendor/@cloudflare/kv-asset-handler/src/index.ts +0 -296
  142. package/vendor/@cloudflare/kv-asset-handler/src/mocks.ts +0 -136
  143. package/vendor/@cloudflare/kv-asset-handler/src/test/getAssetFromKV.ts +0 -464
  144. package/vendor/@cloudflare/kv-asset-handler/src/test/mapRequestToAsset.ts +0 -33
  145. package/vendor/@cloudflare/kv-asset-handler/src/test/serveSinglePageApp.ts +0 -42
  146. package/vendor/@cloudflare/kv-asset-handler/src/types.ts +0 -39
  147. package/vendor/wrangler-mime/CHANGELOG.md +0 -289
  148. package/vendor/wrangler-mime/LICENSE +0 -21
  149. package/vendor/wrangler-mime/Mime.js +0 -97
  150. package/vendor/wrangler-mime/README.md +0 -187
  151. package/vendor/wrangler-mime/cli.js +0 -46
  152. package/vendor/wrangler-mime/index.js +0 -4
  153. package/vendor/wrangler-mime/lite.js +0 -4
  154. package/vendor/wrangler-mime/package.json +0 -52
  155. package/vendor/wrangler-mime/types/other.js +0 -1
  156. package/vendor/wrangler-mime/types/standard.js +0 -1
  157. package/wrangler-dist/cli.js.map +0 -7
package/src/pages.tsx CHANGED
@@ -2,36 +2,118 @@
2
2
 
3
3
  import { execSync, spawn } from "node:child_process";
4
4
  import { existsSync, lstatSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { readdir, readFile, stat } from "node:fs/promises";
5
6
  import { tmpdir } from "node:os";
6
- import { join } from "node:path";
7
+ import { dirname, join, sep, extname, basename } from "node:path";
8
+ import { cwd } from "node:process";
7
9
  import { URL } from "node:url";
10
+ import { hash } from "blake3-wasm";
8
11
  import { watch } from "chokidar";
12
+ import { render, Text } from "ink";
13
+ import SelectInput from "ink-select-input";
14
+ import Spinner from "ink-spinner";
15
+ import Table from "ink-table";
9
16
  import { getType } from "mime";
10
- import open from "open";
17
+ import PQueue from "p-queue";
18
+ import prettyBytes from "pretty-bytes";
19
+ import React from "react";
20
+ import { format as timeagoFormat } from "timeago.js";
21
+ import { File, FormData } from "undici";
22
+ import { buildPlugin } from "../pages/functions/buildPlugin";
11
23
  import { buildWorker } from "../pages/functions/buildWorker";
12
24
  import { generateConfigFromFileTree } from "../pages/functions/filepath-routing";
13
25
  import { writeRoutesModule } from "../pages/functions/routes";
26
+ import { fetchResult } from "./cfetch";
27
+ import { getConfigCache, saveToConfigCache } from "./config-cache";
28
+ import { prompt } from "./dialogs";
29
+ import { FatalError } from "./errors";
30
+ import { logger } from "./logger";
31
+ import { getRequestContextCheckOptions } from "./miniflare-cli/request-context";
32
+ import openInBrowser from "./open-in-browser";
14
33
  import { toUrlPath } from "./paths";
34
+ import { requireAuth } from "./user";
15
35
  import type { Config } from "../pages/functions/routes";
16
- import type { Headers, Request, fetch } from "@miniflare/core";
36
+ import type {
37
+ Headers as MiniflareHeaders,
38
+ Request as MiniflareRequest,
39
+ fetch as miniflareFetch,
40
+ } from "@miniflare/core";
17
41
  import type { BuildResult } from "esbuild";
18
42
  import type { MiniflareOptions } from "miniflare";
19
- import type { BuilderCallback } from "yargs";
43
+ import type { BuilderCallback, CommandModule } from "yargs";
44
+
45
+ export type Project = {
46
+ name: string;
47
+ subdomain: string;
48
+ domains: Array<string>;
49
+ source?: {
50
+ type: string;
51
+ };
52
+ latest_deployment?: {
53
+ modified_on: string;
54
+ };
55
+ created_on: string;
56
+ production_branch: string;
57
+ };
58
+
59
+ export type Deployment = {
60
+ id: string;
61
+ environment: string;
62
+ deployment_trigger: {
63
+ metadata: {
64
+ commit_hash: string;
65
+ branch: string;
66
+ };
67
+ };
68
+ url: string;
69
+ latest_stage: {
70
+ status: string;
71
+ ended_on: string;
72
+ };
73
+ project_name: string;
74
+ };
75
+
76
+ export type UploadPayloadFile = {
77
+ key: string;
78
+ value: string;
79
+ metadata: { contentType: string };
80
+ base64: boolean;
81
+ };
82
+
83
+ interface PagesConfigCache {
84
+ account_id?: string;
85
+ project_name?: string;
86
+ }
87
+
88
+ const PAGES_CONFIG_CACHE_FILENAME = "pages.json";
89
+ const MAX_BUCKET_SIZE = 50 * 1024 * 1024;
90
+ const MAX_BUCKET_FILE_COUNT = 5000;
91
+ const BULK_UPLOAD_CONCURRENCY = 3;
92
+ const MAX_UPLOAD_ATTEMPTS = 5;
20
93
 
21
94
  // Defer importing miniflare until we really need it. This takes ~0.5s
22
95
  // and also modifies some `stream/web` and `undici` prototypes, so we
23
96
  // don't want to do this if pages commands aren't being called.
24
97
 
25
- const EXIT_CALLBACKS: (() => void)[] = [];
26
- const EXIT = (message?: string, code?: number) => {
27
- if (message) console.log(message);
28
- if (code) process.exitCode = code;
29
- EXIT_CALLBACKS.forEach((callback) => callback());
30
- process.exit(code);
98
+ export const pagesBetaWarning =
99
+ "🚧 'wrangler pages <command>' is a beta command. Please report any issues to https://github.com/cloudflare/wrangler2/issues/new/choose";
100
+
101
+ const isInPagesCI = !!process.env.CF_PAGES;
102
+
103
+ const CLEANUP_CALLBACKS: (() => void)[] = [];
104
+ const CLEANUP = () => {
105
+ CLEANUP_CALLBACKS.forEach((callback) => callback());
106
+ RUNNING_BUILDERS.forEach((builder) => builder.stop?.());
31
107
  };
32
108
 
33
- process.on("SIGINT", () => EXIT());
34
- process.on("SIGTERM", () => EXIT());
109
+ process.on("SIGINT", () => {
110
+ CLEANUP();
111
+ process.exit();
112
+ });
113
+ process.on("SIGTERM", () => {
114
+ CLEANUP();
115
+ process.exit();
116
+ });
35
117
 
36
118
  function isWindows() {
37
119
  return process.platform === "win32";
@@ -91,7 +173,7 @@ function getPort(pid: number) {
91
173
  const match = matches[0];
92
174
  if (match) return parseInt(match[1]);
93
175
  } catch (thrown) {
94
- console.error(
176
+ logger.error(
95
177
  `Error scanning for ports of process with PID ${pid}: ${thrown}`
96
178
  );
97
179
  }
@@ -104,13 +186,15 @@ async function spawnProxyProcess({
104
186
  port?: number;
105
187
  command: (string | number)[];
106
188
  }): Promise<void | number> {
107
- if (command.length === 0)
108
- return EXIT(
189
+ if (command.length === 0) {
190
+ CLEANUP();
191
+ throw new FatalError(
109
192
  "Must specify a directory of static assets to serve or a command to run.",
110
193
  1
111
194
  );
195
+ }
112
196
 
113
- console.log(`Running ${command.join(" ")}...`);
197
+ logger.log(`Running ${command.join(" ")}...`);
114
198
  const proxy = spawn(
115
199
  command[0].toString(),
116
200
  command.slice(1).map((value) => value.toString()),
@@ -122,30 +206,30 @@ async function spawnProxyProcess({
122
206
  },
123
207
  }
124
208
  );
125
- EXIT_CALLBACKS.push(() => {
209
+ CLEANUP_CALLBACKS.push(() => {
126
210
  proxy.kill();
127
211
  });
128
212
 
129
213
  proxy.stdout.on("data", (data) => {
130
- console.log(`[proxy]: ${data}`);
214
+ logger.log(`[proxy]: ${data}`);
131
215
  });
132
216
 
133
217
  proxy.stderr.on("data", (data) => {
134
- console.error(`[proxy]: ${data}`);
218
+ logger.error(`[proxy]: ${data}`);
135
219
  });
136
220
 
137
221
  proxy.on("close", (code) => {
138
- console.error(`Proxy exited with status ${code}.`);
222
+ logger.error(`Proxy exited with status ${code}.`);
139
223
  });
140
224
 
141
225
  // Wait for proxy process to start...
142
226
  while (!proxy.pid) {}
143
227
 
144
228
  if (port === undefined) {
145
- console.log(
229
+ logger.log(
146
230
  `Sleeping ${SECONDS_TO_WAIT_FOR_PROXY} seconds to allow proxy process to start before attempting to automatically determine port...`
147
231
  );
148
- console.log("To skip, specify the proxy port with --proxy.");
232
+ logger.log("To skip, specify the proxy port with --proxy.");
149
233
  await sleep(SECONDS_TO_WAIT_FOR_PROXY * 1000);
150
234
 
151
235
  port = getPids(proxy.pid)
@@ -153,12 +237,13 @@ async function spawnProxyProcess({
153
237
  .filter((port) => port !== undefined)[0];
154
238
 
155
239
  if (port === undefined) {
156
- return EXIT(
240
+ CLEANUP();
241
+ throw new FatalError(
157
242
  "Could not automatically determine proxy port. Please specify the proxy port with --proxy.",
158
243
  1
159
244
  );
160
245
  } else {
161
- console.log(`Automatically determined the proxy port to be ${port}.`);
246
+ logger.log(`Automatically determined the proxy port to be ${port}.`);
162
247
  }
163
248
  }
164
249
 
@@ -215,7 +300,7 @@ function generateRulesMatcher<T>(
215
300
  T
216
301
  ][];
217
302
 
218
- return ({ request }: { request: Request }) => {
303
+ return ({ request }: { request: MiniflareRequest }) => {
219
304
  const { pathname, host } = new URL(request.url);
220
305
 
221
306
  return compiledRules
@@ -288,7 +373,7 @@ function generateHeadersMatcher(headersFile: string) {
288
373
  )
289
374
  );
290
375
 
291
- return (request: Request) => {
376
+ return (request: MiniflareRequest) => {
292
377
  const matches = rulesMatcher({
293
378
  request,
294
379
  });
@@ -337,7 +422,7 @@ function generateRedirectsMatcher(redirectsFile: string) {
337
422
  })
338
423
  );
339
424
 
340
- return (request: Request) => {
425
+ return (request: MiniflareRequest) => {
341
426
  const match = rulesMatcher({
342
427
  request,
343
428
  })[0];
@@ -392,7 +477,9 @@ function hasFileExtension(pathname: string) {
392
477
  return /\/.+\.[a-z0-9]+$/i.test(pathname);
393
478
  }
394
479
 
395
- async function generateAssetsFetch(directory: string): Promise<typeof fetch> {
480
+ async function generateAssetsFetch(
481
+ directory: string
482
+ ): Promise<typeof miniflareFetch> {
396
483
  // Defer importing miniflare until we really need it
397
484
  const { Headers, Request, Response } = await import("@miniflare/core");
398
485
 
@@ -425,12 +512,12 @@ async function generateAssetsFetch(directory: string): Promise<typeof fetch> {
425
512
  }).on("change", (path) => {
426
513
  switch (path) {
427
514
  case headersFile: {
428
- console.log("_headers modified. Re-evaluating...");
515
+ logger.log("_headers modified. Re-evaluating...");
429
516
  headersMatcher = generateHeadersMatcher(headersFile);
430
517
  break;
431
518
  }
432
519
  case redirectsFile: {
433
- console.log("_redirects modified. Re-evaluating...");
520
+ logger.log("_redirects modified. Re-evaluating...");
434
521
  redirectsMatcher = generateRedirectsMatcher(redirectsFile);
435
522
  break;
436
523
  }
@@ -441,12 +528,12 @@ async function generateAssetsFetch(directory: string): Promise<typeof fetch> {
441
528
  return readFileSync(file);
442
529
  };
443
530
 
444
- const generateResponse = (request: Request) => {
531
+ const generateResponse = (request: MiniflareRequest) => {
445
532
  const url = new URL(request.url);
446
533
 
447
534
  const deconstructedResponse: {
448
535
  status: number;
449
- headers: Headers;
536
+ headers: MiniflareHeaders;
450
537
  body?: Buffer;
451
538
  } = {
452
539
  status: 200,
@@ -598,8 +685,12 @@ async function generateAssetsFetch(directory: string): Promise<typeof fetch> {
598
685
  };
599
686
 
600
687
  const attachHeaders = (
601
- request: Request,
602
- deconstructedResponse: { status: number; headers: Headers; body?: Buffer }
688
+ request: MiniflareRequest,
689
+ deconstructedResponse: {
690
+ status: number;
691
+ headers: MiniflareHeaders;
692
+ body?: Buffer;
693
+ }
603
694
  ) => {
604
695
  const headers = deconstructedResponse.headers;
605
696
  const newHeaders = new Headers({});
@@ -643,7 +734,7 @@ async function generateAssetsFetch(directory: string): Promise<typeof fetch> {
643
734
  const RUNNING_BUILDERS: BuildResult[] = [];
644
735
 
645
736
  async function buildFunctions({
646
- scriptPath,
737
+ outfile,
647
738
  outputConfigPath,
648
739
  functionsDirectory,
649
740
  minify = false,
@@ -651,8 +742,11 @@ async function buildFunctions({
651
742
  fallbackService = "ASSETS",
652
743
  watch = false,
653
744
  onEnd,
745
+ plugin = false,
746
+ buildOutputDirectory,
747
+ nodeCompat,
654
748
  }: {
655
- scriptPath: string;
749
+ outfile: string;
656
750
  outputConfigPath?: string;
657
751
  functionsDirectory: string;
658
752
  minify?: boolean;
@@ -660,12 +754,15 @@ async function buildFunctions({
660
754
  fallbackService?: string;
661
755
  watch?: boolean;
662
756
  onEnd?: () => void;
757
+ plugin?: boolean;
758
+ buildOutputDirectory?: string;
759
+ nodeCompat?: boolean;
663
760
  }) {
664
761
  RUNNING_BUILDERS.forEach(
665
762
  (runningBuilder) => runningBuilder.stop && runningBuilder.stop()
666
763
  );
667
764
 
668
- const routesModule = join(tmpdir(), "./functionsRoutes.mjs");
765
+ const routesModule = join(tmpdir(), `./functionsRoutes-${Math.random()}.mjs`);
669
766
  const baseURL = toUrlPath("/");
670
767
 
671
768
  const config: Config = await generateConfigFromFileTree({
@@ -686,23 +783,644 @@ async function buildFunctions({
686
783
  outfile: routesModule,
687
784
  });
688
785
 
689
- RUNNING_BUILDERS.push(
690
- await buildWorker({
691
- routesModule,
692
- outfile: scriptPath,
693
- minify,
694
- sourcemap,
695
- fallbackService,
696
- watch,
697
- onEnd,
698
- })
699
- );
786
+ if (plugin) {
787
+ RUNNING_BUILDERS.push(
788
+ await buildPlugin({
789
+ routesModule,
790
+ outfile,
791
+ minify,
792
+ sourcemap,
793
+ watch,
794
+ nodeCompat,
795
+ onEnd,
796
+ })
797
+ );
798
+ } else {
799
+ RUNNING_BUILDERS.push(
800
+ await buildWorker({
801
+ routesModule,
802
+ outfile,
803
+ minify,
804
+ sourcemap,
805
+ fallbackService,
806
+ watch,
807
+ onEnd,
808
+ buildOutputDirectory,
809
+ nodeCompat,
810
+ })
811
+ );
812
+ }
813
+ }
814
+
815
+ interface CreateDeploymentArgs {
816
+ directory: string;
817
+ projectName?: string;
818
+ branch?: string;
819
+ commitHash?: string;
820
+ commitMessage?: string;
821
+ commitDirty?: boolean;
700
822
  }
701
823
 
824
+ const upload = async ({
825
+ directory,
826
+ accountId,
827
+ projectName,
828
+ }: {
829
+ directory: string;
830
+ accountId: string;
831
+ projectName: string;
832
+ }) => {
833
+ type FileContainer = {
834
+ content: string;
835
+ contentType: string;
836
+ sizeInBytes: number;
837
+ hash: string;
838
+ };
839
+
840
+ const IGNORE_LIST = [
841
+ "_worker.js",
842
+ "_redirects",
843
+ "_headers",
844
+ ".DS_Store",
845
+ "node_modules",
846
+ ".git",
847
+ ];
848
+
849
+ const walk = async (
850
+ dir: string,
851
+ fileMap: Map<string, FileContainer> = new Map(),
852
+ depth = 0
853
+ ) => {
854
+ const files = await readdir(dir);
855
+
856
+ await Promise.all(
857
+ files.map(async (file) => {
858
+ const filepath = join(dir, file);
859
+ const filestat = await stat(filepath);
860
+
861
+ if (IGNORE_LIST.includes(file)) {
862
+ return;
863
+ }
864
+
865
+ if (filestat.isSymbolicLink()) {
866
+ return;
867
+ }
868
+
869
+ if (filestat.isDirectory()) {
870
+ fileMap = await walk(filepath, fileMap, depth + 1);
871
+ } else {
872
+ let name;
873
+ if (depth) {
874
+ name = filepath.split(sep).slice(1).join("/");
875
+ } else {
876
+ name = file;
877
+ }
878
+
879
+ // TODO: Move this to later so we don't hold as much in memory
880
+ const fileContent = await readFile(filepath);
881
+
882
+ const base64Content = fileContent.toString("base64");
883
+ const extension = extname(basename(name)).substring(1);
884
+
885
+ if (filestat.size > 25 * 1024 * 1024) {
886
+ throw new Error(
887
+ `Error: Pages only supports files up to ${prettyBytes(
888
+ 25 * 1024 * 1024
889
+ )} in size\n${name} is ${prettyBytes(filestat.size)} in size`
890
+ );
891
+ }
892
+
893
+ fileMap.set(name, {
894
+ content: base64Content,
895
+ contentType: getType(name) || "application/octet-stream",
896
+ sizeInBytes: filestat.size,
897
+ hash: hash(base64Content + extension)
898
+ .toString("hex")
899
+ .slice(0, 32),
900
+ });
901
+ }
902
+ })
903
+ );
904
+
905
+ return fileMap;
906
+ };
907
+
908
+ const fileMap = await walk(directory);
909
+
910
+ if (fileMap.size > 20000) {
911
+ throw new FatalError(
912
+ `Error: Pages only supports up to 20,000 files in a deployment. Ensure you have specified your build output directory correctly.`,
913
+ 1
914
+ );
915
+ }
916
+
917
+ const files = [...fileMap.values()];
918
+
919
+ async function fetchJwt(): Promise<string> {
920
+ return (
921
+ await fetchResult<{ jwt: string }>(
922
+ `/accounts/${accountId}/pages/projects/${projectName}/upload-token`
923
+ )
924
+ ).jwt;
925
+ }
926
+
927
+ let jwt = await fetchJwt();
928
+
929
+ const start = Date.now();
930
+
931
+ const missingHashes = await fetchResult<string[]>(
932
+ `/pages/assets/check-missing`,
933
+ {
934
+ method: "POST",
935
+ headers: {
936
+ "Content-Type": "application/json",
937
+ Authorization: `Bearer ${jwt}`,
938
+ },
939
+ body: JSON.stringify({
940
+ hashes: files.map(({ hash }) => hash),
941
+ }),
942
+ }
943
+ );
944
+
945
+ const sortedFiles = files
946
+ .filter((file) => missingHashes.includes(file.hash))
947
+ .sort((a, b) => b.sizeInBytes - a.sizeInBytes);
948
+
949
+ // Start with a few buckets so small projects still get
950
+ // the benefit of multiple upload streams
951
+ const buckets: {
952
+ files: FileContainer[];
953
+ remainingSize: number;
954
+ }[] = new Array(BULK_UPLOAD_CONCURRENCY).fill(null).map(() => ({
955
+ files: [],
956
+ remainingSize: MAX_BUCKET_SIZE,
957
+ }));
958
+
959
+ let bucketOffset = 0;
960
+ for (const file of sortedFiles) {
961
+ let inserted = false;
962
+
963
+ for (let i = 0; i < buckets.length; i++) {
964
+ // Start at a different bucket for each new file
965
+ const bucket = buckets[(i + bucketOffset) % buckets.length];
966
+ if (
967
+ bucket.remainingSize >= file.sizeInBytes &&
968
+ bucket.files.length < MAX_BUCKET_FILE_COUNT
969
+ ) {
970
+ bucket.files.push(file);
971
+ bucket.remainingSize -= file.sizeInBytes;
972
+ inserted = true;
973
+ break;
974
+ }
975
+ }
976
+
977
+ if (!inserted) {
978
+ buckets.push({
979
+ files: [file],
980
+ remainingSize: MAX_BUCKET_SIZE - file.sizeInBytes,
981
+ });
982
+ }
983
+ bucketOffset++;
984
+ }
985
+
986
+ let counter = fileMap.size - sortedFiles.length;
987
+ const { rerender, unmount } = render(
988
+ <Progress done={counter} total={fileMap.size} />
989
+ );
990
+
991
+ const queue = new PQueue({ concurrency: BULK_UPLOAD_CONCURRENCY });
992
+
993
+ for (const bucket of buckets) {
994
+ // Don't upload empty buckets (can happen for tiny projects)
995
+ if (bucket.files.length === 0) continue;
996
+
997
+ const payload: UploadPayloadFile[] = bucket.files.map((file) => ({
998
+ key: file.hash,
999
+ value: file.content,
1000
+ metadata: {
1001
+ contentType: file.contentType,
1002
+ },
1003
+ base64: true,
1004
+ }));
1005
+
1006
+ let attempts = 0;
1007
+ const doUpload = async (): Promise<void> => {
1008
+ try {
1009
+ return await fetchResult(`/pages/assets/upload`, {
1010
+ method: "POST",
1011
+ headers: {
1012
+ "Content-Type": "application/json",
1013
+ Authorization: `Bearer ${jwt}`,
1014
+ },
1015
+ body: JSON.stringify(payload),
1016
+ });
1017
+ } catch (e) {
1018
+ if (attempts < MAX_UPLOAD_ATTEMPTS) {
1019
+ // Linear backoff, 0 second first time, then 1 second etc.
1020
+ await new Promise((resolve) =>
1021
+ setTimeout(resolve, attempts++ * 1000)
1022
+ );
1023
+
1024
+ if ((e as { code: number }).code === 8000013) {
1025
+ // Looks like the JWT expired, fetch another one
1026
+ jwt = await fetchJwt();
1027
+ }
1028
+ return doUpload();
1029
+ } else {
1030
+ throw e;
1031
+ }
1032
+ }
1033
+ };
1034
+
1035
+ queue.add(() =>
1036
+ doUpload().then(
1037
+ () => {
1038
+ counter += bucket.files.length;
1039
+ rerender(<Progress done={counter} total={fileMap.size} />);
1040
+ },
1041
+ (error) => {
1042
+ return Promise.reject(
1043
+ new FatalError(
1044
+ "Failed to upload files. Please try again.",
1045
+ error.code || 1
1046
+ )
1047
+ );
1048
+ }
1049
+ )
1050
+ );
1051
+ }
1052
+
1053
+ await queue.onIdle();
1054
+
1055
+ unmount();
1056
+
1057
+ const uploadMs = Date.now() - start;
1058
+
1059
+ const skipped = fileMap.size - missingHashes.length;
1060
+ const skippedMessage = skipped > 0 ? `(${skipped} already uploaded) ` : "";
1061
+
1062
+ logger.log(
1063
+ `✨ Success! Uploaded ${
1064
+ sortedFiles.length
1065
+ } files ${skippedMessage}${formatTime(uploadMs)}\n`
1066
+ );
1067
+
1068
+ const doUpsertHashes = async (): Promise<void> => {
1069
+ try {
1070
+ return await fetchResult(`/pages/assets/upsert-hashes`, {
1071
+ method: "POST",
1072
+ headers: {
1073
+ "Content-Type": "application/json",
1074
+ Authorization: `Bearer ${jwt}`,
1075
+ },
1076
+ body: JSON.stringify({
1077
+ hashes: files.map(({ hash }) => hash),
1078
+ }),
1079
+ });
1080
+ } catch (e) {
1081
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1082
+
1083
+ if ((e as { code: number }).code === 8000013) {
1084
+ // Looks like the JWT expired, fetch another one
1085
+ jwt = await fetchJwt();
1086
+ }
1087
+
1088
+ return await fetchResult(`/pages/assets/upsert-hashes`, {
1089
+ method: "POST",
1090
+ headers: {
1091
+ "Content-Type": "application/json",
1092
+ Authorization: `Bearer ${jwt}`,
1093
+ },
1094
+ body: JSON.stringify({
1095
+ hashes: files.map(({ hash }) => hash),
1096
+ }),
1097
+ });
1098
+ }
1099
+ };
1100
+
1101
+ try {
1102
+ await doUpsertHashes();
1103
+ } catch {
1104
+ logger.warn(
1105
+ "Failed to update file hashes. Every upload appeared to succeed for this deployment, but you might need to re-upload for future deployments. This shouldn't have any impact other than slowing the upload speed of your next deployment."
1106
+ );
1107
+ }
1108
+
1109
+ return Object.fromEntries(
1110
+ [...fileMap.entries()].map(([fileName, file]) => [
1111
+ `/${fileName}`,
1112
+ file.hash,
1113
+ ])
1114
+ );
1115
+ };
1116
+
1117
+ const createDeployment: CommandModule<
1118
+ CreateDeploymentArgs,
1119
+ CreateDeploymentArgs
1120
+ > = {
1121
+ describe: "🆙 Publish a directory of static assets as a Pages deployment",
1122
+ builder: (yargs) => {
1123
+ return yargs
1124
+ .positional("directory", {
1125
+ type: "string",
1126
+ demandOption: true,
1127
+ description: "The directory of static files to upload",
1128
+ })
1129
+ .options({
1130
+ "project-name": {
1131
+ type: "string",
1132
+ description: "The name of the project you want to deploy to",
1133
+ },
1134
+ branch: {
1135
+ type: "string",
1136
+ description: "The name of the branch you want to deploy to",
1137
+ },
1138
+ "commit-hash": {
1139
+ type: "string",
1140
+ description: "The SHA to attach to this deployment",
1141
+ },
1142
+ "commit-message": {
1143
+ type: "string",
1144
+ description: "The commit message to attach to this deployment",
1145
+ },
1146
+ "commit-dirty": {
1147
+ type: "boolean",
1148
+ description:
1149
+ "Whether or not the workspace should be considered dirty for this deployment",
1150
+ },
1151
+ })
1152
+ .epilogue(pagesBetaWarning);
1153
+ },
1154
+ handler: async ({
1155
+ directory,
1156
+ projectName,
1157
+ branch,
1158
+ commitHash,
1159
+ commitMessage,
1160
+ commitDirty,
1161
+ }) => {
1162
+ if (!directory) {
1163
+ throw new FatalError("Must specify a directory.", 1);
1164
+ }
1165
+
1166
+ const config = getConfigCache<PagesConfigCache>(
1167
+ PAGES_CONFIG_CACHE_FILENAME
1168
+ );
1169
+ const accountId = await requireAuth(config);
1170
+
1171
+ projectName ??= config.project_name;
1172
+
1173
+ const isInteractive = process.stdin.isTTY;
1174
+ if (!projectName && isInteractive) {
1175
+ const projects = (await listProjects({ accountId })).filter(
1176
+ (project) => !project.source
1177
+ );
1178
+
1179
+ let existingOrNew: "existing" | "new" = "new";
1180
+
1181
+ if (projects.length > 0) {
1182
+ existingOrNew = await new Promise<"new" | "existing">((resolve) => {
1183
+ const { unmount } = render(
1184
+ <>
1185
+ <Text>
1186
+ No project selected. Would you like to create one or use an
1187
+ existing project?
1188
+ </Text>
1189
+ <SelectInput
1190
+ items={[
1191
+ {
1192
+ key: "new",
1193
+ label: "Create a new project",
1194
+ value: "new",
1195
+ },
1196
+ {
1197
+ key: "existing",
1198
+ label: "Use an existing project",
1199
+ value: "existing",
1200
+ },
1201
+ ]}
1202
+ onSelect={async (selected) => {
1203
+ resolve(selected.value as "new" | "existing");
1204
+ unmount();
1205
+ }}
1206
+ />
1207
+ </>
1208
+ );
1209
+ });
1210
+ }
1211
+
1212
+ switch (existingOrNew) {
1213
+ case "existing": {
1214
+ projectName = await new Promise((resolve) => {
1215
+ const { unmount } = render(
1216
+ <>
1217
+ <Text>Select a project:</Text>
1218
+ <SelectInput
1219
+ items={projects.map((project) => ({
1220
+ key: project.name,
1221
+ label: project.name,
1222
+ value: project,
1223
+ }))}
1224
+ onSelect={async (selected) => {
1225
+ resolve(selected.value.name);
1226
+ unmount();
1227
+ }}
1228
+ />
1229
+ </>
1230
+ );
1231
+ });
1232
+ break;
1233
+ }
1234
+ case "new": {
1235
+ projectName = await prompt("Enter the name of your new project:");
1236
+
1237
+ if (!projectName) {
1238
+ throw new FatalError("Must specify a project name.", 1);
1239
+ }
1240
+
1241
+ let isGitDir = true;
1242
+ try {
1243
+ execSync(`git rev-parse --is-inside-work-tree`, {
1244
+ stdio: "ignore",
1245
+ });
1246
+ } catch (err) {
1247
+ isGitDir = false;
1248
+ }
1249
+
1250
+ const productionBranch = await prompt(
1251
+ "Enter the production branch name:",
1252
+ "text",
1253
+ isGitDir
1254
+ ? execSync(`git rev-parse --abbrev-ref HEAD`).toString().trim()
1255
+ : "production"
1256
+ );
1257
+
1258
+ if (!productionBranch) {
1259
+ throw new FatalError("Must specify a production branch.", 1);
1260
+ }
1261
+
1262
+ await fetchResult<Project>(`/accounts/${accountId}/pages/projects`, {
1263
+ method: "POST",
1264
+ body: JSON.stringify({
1265
+ name: projectName,
1266
+ production_branch: productionBranch,
1267
+ }),
1268
+ });
1269
+
1270
+ saveToConfigCache<PagesConfigCache>(PAGES_CONFIG_CACHE_FILENAME, {
1271
+ account_id: accountId,
1272
+ project_name: projectName,
1273
+ });
1274
+
1275
+ logger.log(`✨ Successfully created the '${projectName}' project.`);
1276
+ break;
1277
+ }
1278
+ }
1279
+ }
1280
+
1281
+ if (!projectName) {
1282
+ throw new FatalError("Must specify a project name.", 1);
1283
+ }
1284
+
1285
+ // We infer git info by default is not passed in
1286
+
1287
+ let isGitDir = true;
1288
+ try {
1289
+ execSync(`git rev-parse --is-inside-work-tree`, {
1290
+ stdio: "ignore",
1291
+ });
1292
+ } catch (err) {
1293
+ isGitDir = false;
1294
+ }
1295
+
1296
+ let isGitDirty = false;
1297
+
1298
+ if (isGitDir) {
1299
+ try {
1300
+ isGitDirty = Boolean(
1301
+ execSync(`git status --porcelain`).toString().length
1302
+ );
1303
+
1304
+ if (!branch) {
1305
+ branch = execSync(`git rev-parse --abbrev-ref HEAD`)
1306
+ .toString()
1307
+ .trim();
1308
+ }
1309
+
1310
+ if (!commitHash) {
1311
+ commitHash = execSync(`git rev-parse HEAD`).toString().trim();
1312
+ }
1313
+
1314
+ if (!commitMessage) {
1315
+ commitMessage = execSync(`git show -s --format=%B ${commitHash}`)
1316
+ .toString()
1317
+ .trim();
1318
+ }
1319
+ } catch (err) {}
1320
+
1321
+ if (isGitDirty && !commitDirty) {
1322
+ logger.warn(
1323
+ `Warning: Your working directory is a git repo and has uncommitted changes\nTo silence this warning, pass in --commit-dirty=true`
1324
+ );
1325
+ }
1326
+
1327
+ if (commitDirty === undefined) {
1328
+ commitDirty = isGitDirty;
1329
+ }
1330
+ }
1331
+
1332
+ let builtFunctions: string | undefined = undefined;
1333
+ const functionsDirectory = join(cwd(), "functions");
1334
+ if (existsSync(functionsDirectory)) {
1335
+ const outfile = join(tmpdir(), `./functionsWorker-${Math.random()}.js`);
1336
+
1337
+ await new Promise((resolve) =>
1338
+ buildFunctions({
1339
+ outfile,
1340
+ functionsDirectory,
1341
+ onEnd: () => resolve(null),
1342
+ buildOutputDirectory: dirname(outfile),
1343
+ })
1344
+ );
1345
+
1346
+ builtFunctions = readFileSync(outfile, "utf-8");
1347
+ }
1348
+
1349
+ const manifest = await upload({ directory, accountId, projectName });
1350
+
1351
+ const formData = new FormData();
1352
+
1353
+ formData.append("manifest", JSON.stringify(manifest));
1354
+
1355
+ if (branch) {
1356
+ formData.append("branch", branch);
1357
+ }
1358
+
1359
+ if (commitMessage) {
1360
+ formData.append("commit_message", commitMessage);
1361
+ }
1362
+
1363
+ if (commitHash) {
1364
+ formData.append("commit_hash", commitHash);
1365
+ }
1366
+
1367
+ if (commitDirty !== undefined) {
1368
+ formData.append("commit_dirty", commitDirty);
1369
+ }
1370
+
1371
+ let _headers: string | undefined,
1372
+ _redirects: string | undefined,
1373
+ _workerJS: string | undefined;
1374
+
1375
+ try {
1376
+ _headers = readFileSync(join(directory, "_headers"), "utf-8");
1377
+ } catch {}
1378
+
1379
+ try {
1380
+ _redirects = readFileSync(join(directory, "_redirects"), "utf-8");
1381
+ } catch {}
1382
+
1383
+ try {
1384
+ _workerJS = readFileSync(join(directory, "_worker.js"), "utf-8");
1385
+ } catch {}
1386
+
1387
+ if (_headers) {
1388
+ formData.append("_headers", new File([_headers], "_headers"));
1389
+ }
1390
+
1391
+ if (_redirects) {
1392
+ formData.append("_redirects", new File([_redirects], "_redirects"));
1393
+ }
1394
+
1395
+ if (builtFunctions) {
1396
+ formData.append("_worker.js", new File([builtFunctions], "_worker.js"));
1397
+ } else if (_workerJS) {
1398
+ formData.append("_worker.js", new File([_workerJS], "_worker.js"));
1399
+ }
1400
+
1401
+ const deploymentResponse = await fetchResult<Deployment>(
1402
+ `/accounts/${accountId}/pages/projects/${projectName}/deployments`,
1403
+ {
1404
+ method: "POST",
1405
+ body: formData,
1406
+ }
1407
+ );
1408
+
1409
+ saveToConfigCache<PagesConfigCache>(PAGES_CONFIG_CACHE_FILENAME, {
1410
+ account_id: accountId,
1411
+ project_name: projectName,
1412
+ });
1413
+
1414
+ logger.log(
1415
+ `✨ Deployment complete! Take a peek over at ${deploymentResponse.url}`
1416
+ );
1417
+ },
1418
+ };
1419
+
702
1420
  export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
703
1421
  return yargs
704
1422
  .command(
705
- "dev [directory] [-- command]",
1423
+ "dev [directory] [-- command..]",
706
1424
  "🧑‍💻 Develop your full-stack Pages application locally",
707
1425
  (yargs) => {
708
1426
  return yargs
@@ -758,8 +1476,15 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
758
1476
  default: false,
759
1477
  description: "Auto reload HTML pages when change is detected",
760
1478
  },
1479
+ "node-compat": {
1480
+ describe: "Enable node.js compatibility",
1481
+ default: false,
1482
+ type: "boolean",
1483
+ hidden: true,
1484
+ },
761
1485
  // TODO: Miniflare user options
762
- });
1486
+ })
1487
+ .epilogue(pagesBetaWarning);
763
1488
  },
764
1489
  async ({
765
1490
  local,
@@ -771,10 +1496,14 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
771
1496
  kv: kvs = [],
772
1497
  do: durableObjects = [],
773
1498
  "live-reload": liveReload,
774
- "--": remaining = [],
1499
+ "node-compat": nodeCompat,
1500
+ _: [_pages, _dev, ...remaining],
775
1501
  }) => {
1502
+ // Beta message for `wrangler pages <commands>` usage
1503
+ logger.log(pagesBetaWarning);
1504
+
776
1505
  if (!local) {
777
- console.error("Only local mode is supported at the moment.");
1506
+ logger.error("Only local mode is supported at the moment.");
778
1507
  return;
779
1508
  }
780
1509
 
@@ -795,42 +1524,58 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
795
1524
 
796
1525
  let miniflareArgs: MiniflareOptions = {};
797
1526
 
798
- let scriptReadyResolve;
799
- const scriptReadyPromise = new Promise(
1527
+ let scriptReadyResolve: () => void;
1528
+ const scriptReadyPromise = new Promise<void>(
800
1529
  (resolve) => (scriptReadyResolve = resolve)
801
1530
  );
802
1531
 
803
1532
  if (usingFunctions) {
804
- const scriptPath = join(tmpdir(), "./functionsWorker.js");
1533
+ const outfile = join(
1534
+ tmpdir(),
1535
+ `./functionsWorker-${Math.random()}.js`
1536
+ );
805
1537
 
806
- console.log(`Compiling worker to "${scriptPath}"...`);
1538
+ if (nodeCompat) {
1539
+ console.warn(
1540
+ "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."
1541
+ );
1542
+ }
807
1543
 
808
- await buildFunctions({
809
- scriptPath,
810
- functionsDirectory,
811
- sourcemap: true,
812
- watch: true,
813
- onEnd: () => scriptReadyResolve(),
814
- });
1544
+ logger.log(`Compiling worker to "${outfile}"...`);
1545
+
1546
+ try {
1547
+ await buildFunctions({
1548
+ outfile,
1549
+ functionsDirectory,
1550
+ sourcemap: true,
1551
+ watch: true,
1552
+ onEnd: () => scriptReadyResolve(),
1553
+ buildOutputDirectory: directory,
1554
+ nodeCompat,
1555
+ });
1556
+ } catch {}
815
1557
 
816
1558
  watch([functionsDirectory], {
817
1559
  persistent: true,
818
1560
  ignoreInitial: true,
819
1561
  }).on("all", async () => {
820
1562
  await buildFunctions({
821
- scriptPath,
1563
+ outfile,
822
1564
  functionsDirectory,
823
1565
  sourcemap: true,
824
1566
  watch: true,
825
1567
  onEnd: () => scriptReadyResolve(),
1568
+ buildOutputDirectory: directory,
1569
+ nodeCompat,
826
1570
  });
827
1571
  });
828
1572
 
829
1573
  miniflareArgs = {
830
- scriptPath,
1574
+ scriptPath: outfile,
831
1575
  };
832
1576
  } else {
833
- scriptReadyResolve();
1577
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1578
+ scriptReadyResolve!();
834
1579
 
835
1580
  const scriptPath =
836
1581
  directory !== undefined
@@ -842,8 +1587,11 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
842
1587
  scriptPath,
843
1588
  };
844
1589
  } else {
845
- console.log("No functions. Shimming...");
1590
+ logger.log("No functions. Shimming...");
846
1591
  miniflareArgs = {
1592
+ // cfFetch sets the `cf` object that a function could expect
1593
+ // If there are no functions, there's no reason to set this up (and not make that network call)
1594
+ cfFetch: false,
847
1595
  // TODO: The fact that these request/response hacks are necessary is ridiculous.
848
1596
  // We need to eliminate them from env.ASSETS.fetch (not sure if just local or prod as well)
849
1597
  script: `
@@ -874,6 +1622,9 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
874
1622
  ? await generateAssetsFetch(directory)
875
1623
  : invalidAssetsFetch;
876
1624
 
1625
+ const requestContextCheckOptions =
1626
+ await getRequestContextCheckOptions();
1627
+
877
1628
  const miniflare = new Miniflare({
878
1629
  port,
879
1630
  watch: true,
@@ -894,20 +1645,22 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
894
1645
  // User bindings
895
1646
  bindings: {
896
1647
  ...Object.fromEntries(
897
- bindings.map((binding) => binding.toString().split("="))
1648
+ bindings
1649
+ .map((binding) => binding.toString().split("="))
1650
+ .map(([key, ...values]) => [key, values.join("=")])
898
1651
  ),
899
1652
  },
900
1653
 
901
1654
  // env.ASSETS.fetch
902
1655
  serviceBindings: {
903
- async ASSETS(request) {
1656
+ async ASSETS(request: MiniflareRequest) {
904
1657
  if (proxyPort) {
905
1658
  try {
906
1659
  const url = new URL(request.url);
907
1660
  url.host = `localhost:${proxyPort}`;
908
1661
  return await fetch(url, request);
909
1662
  } catch (thrown) {
910
- console.error(`Could not proxy request: ${thrown}`);
1663
+ logger.error(`Could not proxy request: ${thrown}`);
911
1664
 
912
1665
  // TODO: Pretty error page
913
1666
  return new Response(
@@ -919,7 +1672,7 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
919
1672
  try {
920
1673
  return await assetsFetch(request);
921
1674
  } catch (thrown) {
922
- console.error(`Could not serve static asset: ${thrown}`);
1675
+ logger.error(`Could not serve static asset: ${thrown}`);
923
1676
 
924
1677
  // TODO: Pretty error page
925
1678
  return new Response(
@@ -936,18 +1689,17 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
936
1689
  cachePersist: true,
937
1690
  liveReload,
938
1691
 
1692
+ ...requestContextCheckOptions,
939
1693
  ...miniflareArgs,
940
1694
  });
941
1695
 
942
1696
  try {
943
1697
  // `startServer` might throw if user code contains errors
944
1698
  const server = await miniflare.startServer();
945
- console.log(`Serving at http://localhost:${port}/`);
1699
+ logger.log(`Serving at http://localhost:${port}/`);
946
1700
 
947
1701
  if (process.env.BROWSER !== "none") {
948
- const childProcess = await open(`http://localhost:${port}/`);
949
- // fail silently if the open command doesn't work (e.g. in GitHub Codespaces)
950
- childProcess.on("error", (_err) => {});
1702
+ await openInBrowser(`http://localhost:${port}/`);
951
1703
  }
952
1704
 
953
1705
  if (directory !== undefined && liveReload) {
@@ -959,13 +1711,14 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
959
1711
  });
960
1712
  }
961
1713
 
962
- EXIT_CALLBACKS.push(() => {
1714
+ CLEANUP_CALLBACKS.push(() => {
963
1715
  server.close();
964
1716
  miniflare.dispose().catch((err) => miniflare.log.error(err));
965
1717
  });
966
1718
  } catch (e) {
967
- miniflare.log.error(e);
968
- EXIT("Could not start Miniflare.", 1);
1719
+ miniflare.log.error(e as Error);
1720
+ CLEANUP();
1721
+ throw new FatalError("Could not start Miniflare.", 1);
969
1722
  }
970
1723
  }
971
1724
  )
@@ -983,7 +1736,7 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
983
1736
  description: "The directory of Pages Functions",
984
1737
  })
985
1738
  .options({
986
- "script-path": {
1739
+ outfile: {
987
1740
  type: "string",
988
1741
  default: "_worker.js",
989
1742
  description: "The location of the output Worker script",
@@ -1015,32 +1768,326 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
1015
1768
  description:
1016
1769
  "Watch for changes to the functions and automatically rebuild the Worker script",
1017
1770
  },
1018
- }),
1771
+ plugin: {
1772
+ type: "boolean",
1773
+ default: false,
1774
+ description: "Build a plugin rather than a Worker script",
1775
+ },
1776
+ "build-output-directory": {
1777
+ type: "string",
1778
+ description: "The directory to output static assets to",
1779
+ },
1780
+ "node-compat": {
1781
+ describe: "Enable node.js compatibility",
1782
+ default: false,
1783
+ type: "boolean",
1784
+ hidden: true,
1785
+ },
1786
+ })
1787
+ .epilogue(pagesBetaWarning),
1019
1788
  async ({
1020
1789
  directory,
1021
- "script-path": scriptPath,
1790
+ outfile,
1022
1791
  "output-config-path": outputConfigPath,
1023
1792
  minify,
1024
1793
  sourcemap,
1025
1794
  fallbackService,
1026
1795
  watch,
1796
+ plugin,
1797
+ "build-output-directory": buildOutputDirectory,
1798
+ "node-compat": nodeCompat,
1027
1799
  }) => {
1800
+ if (!isInPagesCI) {
1801
+ // Beta message for `wrangler pages <commands>` usage
1802
+ logger.log(pagesBetaWarning);
1803
+ }
1804
+
1805
+ if (nodeCompat) {
1806
+ console.warn(
1807
+ "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."
1808
+ );
1809
+ }
1810
+
1811
+ buildOutputDirectory ??= dirname(outfile);
1812
+
1028
1813
  await buildFunctions({
1029
- scriptPath,
1814
+ outfile,
1030
1815
  outputConfigPath,
1031
1816
  functionsDirectory: directory,
1032
1817
  minify,
1033
1818
  sourcemap,
1034
1819
  fallbackService,
1035
1820
  watch,
1821
+ plugin,
1822
+ buildOutputDirectory,
1823
+ nodeCompat,
1036
1824
  });
1037
1825
  }
1038
1826
  )
1039
- );
1827
+ )
1828
+ .command("project", "⚡️ Interact with your Pages projects", (yargs) =>
1829
+ yargs
1830
+ .command(
1831
+ "list",
1832
+ "List your Cloudflare Pages projects",
1833
+ (yargs) => yargs.epilogue(pagesBetaWarning),
1834
+ async () => {
1835
+ const config = getConfigCache<PagesConfigCache>(
1836
+ PAGES_CONFIG_CACHE_FILENAME
1837
+ );
1838
+
1839
+ const accountId = await requireAuth(config);
1840
+
1841
+ const projects: Array<Project> = await listProjects({ accountId });
1842
+
1843
+ const data = projects.map((project) => {
1844
+ return {
1845
+ "Project Name": project.name,
1846
+ "Project Domains": `${project.domains.join(", ")}`,
1847
+ "Git Provider": project.source ? "Yes" : "No",
1848
+ "Last Modified": project.latest_deployment
1849
+ ? timeagoFormat(project.latest_deployment.modified_on)
1850
+ : timeagoFormat(project.created_on),
1851
+ };
1852
+ });
1853
+
1854
+ saveToConfigCache<PagesConfigCache>(PAGES_CONFIG_CACHE_FILENAME, {
1855
+ account_id: accountId,
1856
+ });
1857
+
1858
+ render(<Table data={data}></Table>);
1859
+ }
1860
+ )
1861
+ .command(
1862
+ "create [project-name]",
1863
+ "Create a new Cloudflare Pages project",
1864
+ (yargs) =>
1865
+ yargs
1866
+ .positional("project-name", {
1867
+ type: "string",
1868
+ demandOption: true,
1869
+ description: "The name of your Pages project",
1870
+ })
1871
+ .options({
1872
+ "production-branch": {
1873
+ type: "string",
1874
+ description:
1875
+ "The name of the production branch of your project",
1876
+ },
1877
+ })
1878
+ .epilogue(pagesBetaWarning),
1879
+ async ({ productionBranch, projectName }) => {
1880
+ const config = getConfigCache<PagesConfigCache>(
1881
+ PAGES_CONFIG_CACHE_FILENAME
1882
+ );
1883
+ const accountId = await requireAuth(config);
1884
+
1885
+ const isInteractive = process.stdin.isTTY;
1886
+ if (!projectName && isInteractive) {
1887
+ projectName = await prompt("Enter the name of your new project:");
1888
+ }
1889
+
1890
+ if (!projectName) {
1891
+ throw new FatalError("Must specify a project name.", 1);
1892
+ }
1893
+
1894
+ if (!productionBranch && isInteractive) {
1895
+ let isGitDir = true;
1896
+ try {
1897
+ execSync(`git rev-parse --is-inside-work-tree`, {
1898
+ stdio: "ignore",
1899
+ });
1900
+ } catch (err) {
1901
+ isGitDir = false;
1902
+ }
1903
+
1904
+ productionBranch = await prompt(
1905
+ "Enter the production branch name:",
1906
+ "text",
1907
+ isGitDir
1908
+ ? execSync(`git rev-parse --abbrev-ref HEAD`)
1909
+ .toString()
1910
+ .trim()
1911
+ : "production"
1912
+ );
1913
+ }
1914
+
1915
+ if (!productionBranch) {
1916
+ throw new FatalError("Must specify a production branch.", 1);
1917
+ }
1918
+
1919
+ const { subdomain } = await fetchResult<Project>(
1920
+ `/accounts/${accountId}/pages/projects`,
1921
+ {
1922
+ method: "POST",
1923
+ body: JSON.stringify({
1924
+ name: projectName,
1925
+ production_branch: productionBranch,
1926
+ }),
1927
+ }
1928
+ );
1929
+
1930
+ saveToConfigCache<PagesConfigCache>(PAGES_CONFIG_CACHE_FILENAME, {
1931
+ account_id: accountId,
1932
+ project_name: projectName,
1933
+ });
1934
+
1935
+ logger.log(
1936
+ `✨ Successfully created the '${projectName}' project. It will be available at https://${subdomain}/ once you create your first deployment.`
1937
+ );
1938
+ logger.log(
1939
+ `To deploy a folder of assets, run 'wrangler pages publish [directory]'.`
1940
+ );
1941
+ }
1942
+ )
1943
+ .epilogue(pagesBetaWarning)
1944
+ )
1945
+ .command(
1946
+ "deployment",
1947
+ "🚀 Interact with the deployments of a project",
1948
+ (yargs) =>
1949
+ yargs
1950
+ .command(
1951
+ "list",
1952
+ "List deployments in your Cloudflare Pages project",
1953
+ (yargs) =>
1954
+ yargs
1955
+ .options({
1956
+ "project-name": {
1957
+ type: "string",
1958
+ description:
1959
+ "The name of the project you would like to list deployments for",
1960
+ },
1961
+ })
1962
+ .epilogue(pagesBetaWarning),
1963
+ async ({ projectName }) => {
1964
+ const config = getConfigCache<PagesConfigCache>(
1965
+ PAGES_CONFIG_CACHE_FILENAME
1966
+ );
1967
+ const accountId = await requireAuth(config);
1968
+
1969
+ projectName ??= config.project_name;
1970
+
1971
+ const isInteractive = process.stdin.isTTY;
1972
+ if (!projectName && isInteractive) {
1973
+ const projects = await listProjects({ accountId });
1974
+ projectName = await new Promise((resolve) => {
1975
+ const { unmount } = render(
1976
+ <>
1977
+ <Text>Select a project:</Text>
1978
+ <SelectInput
1979
+ items={projects.map((project) => ({
1980
+ key: project.name,
1981
+ label: project.name,
1982
+ value: project,
1983
+ }))}
1984
+ onSelect={async (selected) => {
1985
+ resolve(selected.value.name);
1986
+ unmount();
1987
+ }}
1988
+ />
1989
+ </>
1990
+ );
1991
+ });
1992
+ }
1993
+
1994
+ if (!projectName) {
1995
+ throw new FatalError("Must specify a project name.", 1);
1996
+ }
1997
+
1998
+ const deployments: Array<Deployment> = await fetchResult(
1999
+ `/accounts/${accountId}/pages/projects/${projectName}/deployments`
2000
+ );
2001
+
2002
+ const titleCase = (word: string) =>
2003
+ word.charAt(0).toUpperCase() + word.slice(1);
2004
+
2005
+ const shortSha = (sha: string) => sha.slice(0, 7);
2006
+
2007
+ const getStatus = (deployment: Deployment) => {
2008
+ // Return a pretty time since timestamp if successful otherwise the status
2009
+ if (deployment.latest_stage.status === `success`) {
2010
+ return timeagoFormat(deployment.latest_stage.ended_on);
2011
+ }
2012
+ return titleCase(deployment.latest_stage.status);
2013
+ };
2014
+
2015
+ const data = deployments.map((deployment) => {
2016
+ return {
2017
+ Environment: titleCase(deployment.environment),
2018
+ Branch: deployment.deployment_trigger.metadata.branch,
2019
+ Source: shortSha(
2020
+ deployment.deployment_trigger.metadata.commit_hash
2021
+ ),
2022
+ Deployment: deployment.url,
2023
+ Status: getStatus(deployment),
2024
+ // TODO: Use a url shortener
2025
+ Build: `https://dash.cloudflare.com/${accountId}/pages/view/${deployment.project_name}/${deployment.id}`,
2026
+ };
2027
+ });
2028
+
2029
+ saveToConfigCache<PagesConfigCache>(PAGES_CONFIG_CACHE_FILENAME, {
2030
+ account_id: accountId,
2031
+ });
2032
+
2033
+ render(<Table data={data}></Table>);
2034
+ }
2035
+ )
2036
+ .command({
2037
+ command: "create [directory]",
2038
+ ...createDeployment,
2039
+ } as CommandModule)
2040
+ .epilogue(pagesBetaWarning)
2041
+ )
2042
+ .command({
2043
+ command: "publish [directory]",
2044
+ ...createDeployment,
2045
+ } as CommandModule);
1040
2046
  };
1041
2047
 
1042
- const invalidAssetsFetch: typeof fetch = () => {
2048
+ const invalidAssetsFetch: typeof miniflareFetch = () => {
1043
2049
  throw new Error(
1044
2050
  "Trying to fetch assets directly when there is no `directory` option specified, and not in `local` mode."
1045
2051
  );
1046
2052
  };
2053
+
2054
+ const listProjects = async ({
2055
+ accountId,
2056
+ }: {
2057
+ accountId: string;
2058
+ }): Promise<Array<Project>> => {
2059
+ const pageSize = 10;
2060
+ let page = 1;
2061
+ const results = [];
2062
+ while (results.length % pageSize === 0) {
2063
+ const json: Array<Project> = await fetchResult(
2064
+ `/accounts/${accountId}/pages/projects`,
2065
+ {},
2066
+ new URLSearchParams({
2067
+ per_page: pageSize.toString(),
2068
+ page: page.toString(),
2069
+ })
2070
+ );
2071
+ page++;
2072
+ results.push(...json);
2073
+ if (json.length < pageSize) {
2074
+ break;
2075
+ }
2076
+ }
2077
+ return results;
2078
+ };
2079
+
2080
+ function formatTime(duration: number) {
2081
+ return `(${(duration / 1000).toFixed(2)} sec)`;
2082
+ }
2083
+
2084
+ function Progress({ done, total }: { done: number; total: number }) {
2085
+ return (
2086
+ <>
2087
+ <Text>
2088
+ <Spinner type="earth" />
2089
+ {` Uploading... (${done}/${total})\n`}
2090
+ </Text>
2091
+ </>
2092
+ );
2093
+ }