wrangler 0.0.0-e22f9d7 → 0.0.0-e2a26a0

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 (54) hide show
  1. package/bin/wrangler.js +2 -2
  2. package/package.json +15 -10
  3. package/pages/functions/buildWorker.ts +1 -1
  4. package/pages/functions/filepath-routing.test.ts +21 -13
  5. package/pages/functions/filepath-routing.ts +38 -28
  6. package/pages/functions/routes.ts +8 -6
  7. package/pages/functions/template-worker.ts +16 -24
  8. package/src/__tests__/dev.test.tsx +7 -3
  9. package/src/__tests__/{clipboardy-mock.js → helpers/clipboardy-mock.js} +0 -0
  10. package/src/__tests__/helpers/cmd-shim.d.ts +11 -0
  11. package/src/__tests__/helpers/mock-account-id.ts +30 -0
  12. package/src/__tests__/helpers/mock-bin.ts +33 -0
  13. package/src/__tests__/{mock-cfetch.ts → helpers/mock-cfetch.ts} +49 -13
  14. package/src/__tests__/helpers/mock-console.ts +56 -0
  15. package/src/__tests__/{mock-dialogs.ts → helpers/mock-dialogs.ts} +1 -1
  16. package/src/__tests__/helpers/mock-kv.ts +40 -0
  17. package/src/__tests__/helpers/mock-user.ts +27 -0
  18. package/src/__tests__/helpers/run-in-tmp.ts +29 -0
  19. package/src/__tests__/helpers/run-wrangler.ts +14 -0
  20. package/src/__tests__/index.test.ts +312 -56
  21. package/src/__tests__/jest.setup.ts +17 -2
  22. package/src/__tests__/kv.test.ts +344 -316
  23. package/src/__tests__/logout.test.ts +50 -0
  24. package/src/__tests__/package-manager.test.ts +206 -0
  25. package/src/__tests__/publish.test.ts +1222 -0
  26. package/src/__tests__/secret.test.ts +210 -0
  27. package/src/__tests__/whoami.test.tsx +86 -0
  28. package/src/api/form_data.ts +10 -7
  29. package/src/api/preview.ts +2 -2
  30. package/src/api/worker.ts +3 -3
  31. package/src/cfetch/index.ts +14 -14
  32. package/src/cfetch/internal.ts +40 -8
  33. package/src/config.ts +162 -8
  34. package/src/dev.tsx +103 -53
  35. package/src/dialogs.tsx +2 -2
  36. package/src/index.tsx +438 -311
  37. package/src/inspect.ts +80 -23
  38. package/src/kv.tsx +9 -1
  39. package/src/module-collection.ts +5 -6
  40. package/src/package-manager.ts +120 -0
  41. package/src/pages.tsx +41 -26
  42. package/src/paths.ts +26 -0
  43. package/src/proxy.ts +78 -10
  44. package/src/publish.ts +98 -52
  45. package/src/sites.tsx +175 -53
  46. package/src/user.tsx +51 -59
  47. package/src/whoami.tsx +69 -0
  48. package/templates/new-worker.js +15 -0
  49. package/templates/new-worker.ts +15 -0
  50. package/{static-asset-facade.js → templates/static-asset-facade.js} +0 -0
  51. package/wrangler-dist/cli.js +111479 -110187
  52. package/wrangler-dist/cli.js.map +3 -3
  53. package/src/__tests__/run-in-tmp.ts +0 -19
  54. package/src/__tests__/run-wrangler.ts +0 -32
package/src/inspect.ts CHANGED
@@ -1,12 +1,13 @@
1
- import assert from "assert";
2
- import type { MessageEvent } from "ws";
3
- import WebSocket, { WebSocketServer } from "ws";
4
- import type { IncomingMessage, Server, ServerResponse } from "http";
5
- import { createServer } from "http";
1
+ import assert from "node:assert";
2
+ import { createServer } from "node:http";
3
+ import { URL } from "node:url";
4
+
6
5
  import { useEffect, useRef, useState } from "react";
6
+ import WebSocket, { WebSocketServer } from "ws";
7
7
  import { version } from "../package.json";
8
-
9
8
  import type Protocol from "devtools-protocol";
9
+ import type { IncomingMessage, Server, ServerResponse } from "node:http";
10
+ import type { MessageEvent } from "ws";
10
11
 
11
12
  /**
12
13
  * `useInspector` is a hook for debugging Workers applications
@@ -315,9 +316,14 @@ export default function useInspector(props: InspectorProps) {
315
316
  };
316
317
  }, [
317
318
  props.inspectorUrl,
318
- retryRemoteWebSocketConnectionSigil,
319
319
  props.logToTerminal,
320
320
  wsServer,
321
+ // We use a state value as a sigil to trigger a retry of the
322
+ // remote websocket connection. It's not used inside the effect,
323
+ // so react-hooks/exhaustive-deps doesn't complain if it's not
324
+ // included in the dependency array. But its presence is critical,
325
+ // so do NOT remove it from the dependency list.
326
+ retryRemoteWebSocketConnectionSigil,
321
327
  ]);
322
328
 
323
329
  /**
@@ -432,6 +438,33 @@ function randomId(): string {
432
438
  * we're just doing a little bit of the work of the devtools console,
433
439
  * directly in the terminal.
434
440
  */
441
+
442
+ export const mapConsoleAPIMessageTypeToConsoleMethod: {
443
+ [key in Protocol.Runtime.ConsoleAPICalledEvent["type"]]: Exclude<
444
+ keyof Console,
445
+ "Console"
446
+ >;
447
+ } = {
448
+ log: "log",
449
+ debug: "debug",
450
+ info: "info",
451
+ warning: "warn",
452
+ error: "error",
453
+ dir: "dir",
454
+ dirxml: "dirxml",
455
+ table: "table",
456
+ trace: "trace",
457
+ clear: "clear",
458
+ count: "count",
459
+ assert: "assert",
460
+ profile: "profile",
461
+ profileEnd: "profileEnd",
462
+ timeEnd: "timeEnd",
463
+ startGroup: "group",
464
+ startGroupCollapsed: "groupCollapsed",
465
+ endGroup: "groupEnd",
466
+ };
467
+
435
468
  function logConsoleMessage(evt: Protocol.Runtime.ConsoleAPICalledEvent): void {
436
469
  const args: string[] = [];
437
470
  for (const ro of evt.args) {
@@ -449,7 +482,11 @@ function logConsoleMessage(evt: Protocol.Runtime.ConsoleAPICalledEvent): void {
449
482
  break;
450
483
  case "object":
451
484
  if (!ro.preview) {
452
- args.push(ro.description ?? "<no-description>");
485
+ args.push(
486
+ ro.subtype === "null"
487
+ ? "null"
488
+ : ro.description ?? "<no-description>"
489
+ );
453
490
  } else {
454
491
  args.push(ro.preview.description ?? "<no-description>");
455
492
 
@@ -467,11 +504,14 @@ function logConsoleMessage(evt: Protocol.Runtime.ConsoleAPICalledEvent): void {
467
504
  );
468
505
 
469
506
  break;
507
+ case "weakmap":
470
508
  case "map":
471
509
  args.push(
472
510
  "{\n" +
473
- ro.preview.entries
474
- .map(({ key, value }) => {
511
+ // Maps always have entries
512
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
513
+ ro.preview
514
+ .entries!.map(({ key, value }) => {
475
515
  return ` ${key?.description ?? "<unknown>"} => ${
476
516
  value.description
477
517
  }`;
@@ -482,38 +522,47 @@ function logConsoleMessage(evt: Protocol.Runtime.ConsoleAPICalledEvent): void {
482
522
  );
483
523
 
484
524
  break;
525
+ case "weakset":
485
526
  case "set":
486
527
  args.push(
487
528
  "{ " +
488
- ro.preview.entries
489
- .map(({ value }) => {
529
+ // Sets always have entries
530
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
531
+ ro.preview
532
+ .entries!.map(({ value }) => {
490
533
  return `${value.description}`;
491
534
  })
492
535
  .join(", ") +
493
536
  (ro.preview.overflow ? ", ..." : "") +
494
537
  " }"
495
538
  );
496
-
497
539
  break;
498
- case "null":
499
- args.push("null");
500
- break;
501
- case "node":
502
540
  case "regexp":
541
+ break;
503
542
  case "date":
504
- case "weakmap":
505
- case "weakset":
506
- case "iterator":
543
+ break;
507
544
  case "generator":
508
- case "error":
509
- case "proxy":
545
+ args.push(ro.preview.properties[0].value || "");
546
+ break;
510
547
  case "promise":
548
+ if (ro.preview.properties[0].value === "pending") {
549
+ args.push(`{<${ro.preview.properties[0].value}>}`);
550
+ } else {
551
+ args.push(
552
+ `{<${ro.preview.properties[0].value}>: ${ro.preview.properties[1].value}}`
553
+ );
554
+ }
555
+ break;
556
+ case "node":
557
+ case "iterator":
558
+ case "proxy":
511
559
  case "typedarray":
512
560
  case "arraybuffer":
513
561
  case "dataview":
514
562
  case "webassemblymemory":
515
563
  case "wasmvalue":
516
564
  break;
565
+ case "error":
517
566
  default:
518
567
  // just a pojo
519
568
  args.push(
@@ -535,5 +584,13 @@ function logConsoleMessage(evt: Protocol.Runtime.ConsoleAPICalledEvent): void {
535
584
  }
536
585
  }
537
586
 
538
- console[evt.type](...args);
587
+ const method = mapConsoleAPIMessageTypeToConsoleMethod[evt.type];
588
+
589
+ if (method in console) {
590
+ // eslint-disable-next-line prefer-spread
591
+ console[method].apply(console, args);
592
+ } else {
593
+ console.warn(`Unsupported console method: ${method}`);
594
+ console.log("console event:", evt);
595
+ }
539
596
  }
package/src/kv.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { URLSearchParams } from "node:url";
2
+ import { fetchListResult, fetchResult, fetchKVGetValue } from "./cfetch";
2
3
  import type { Config } from "./config";
3
- import { fetchListResult, fetchResult } from "./cfetch";
4
4
 
5
5
  type KvArgs = {
6
6
  binding?: string;
@@ -115,6 +115,14 @@ export async function putKeyValue(
115
115
  );
116
116
  }
117
117
 
118
+ export async function getKeyValue(
119
+ accountId: string,
120
+ namespaceId: string,
121
+ key: string
122
+ ): Promise<string> {
123
+ return await fetchKVGetValue(accountId, namespaceId, key);
124
+ }
125
+
118
126
  export async function putBulkKeyValue(
119
127
  accountId: string,
120
128
  namespaceId: string,
@@ -1,8 +1,8 @@
1
+ import crypto from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
1
4
  import type { CfModule } from "./api/worker";
2
5
  import type esbuild from "esbuild";
3
- import path from "node:path";
4
- import { readFile } from "node:fs/promises";
5
- import crypto from "node:crypto";
6
6
 
7
7
  // This is a combination of an esbuild plugin and a mutable array
8
8
  // that we use to collect module references from source code.
@@ -30,11 +30,10 @@ export default function makeModuleCollector(): {
30
30
  build.onResolve(
31
31
  // filter on "known" file types,
32
32
  // we can expand this list later
33
- { filter: /.*\.(pem|txt|html|wasm)$/ },
33
+ { filter: /.*\.(wasm)$/ },
34
34
  async (args: esbuild.OnResolveArgs) => {
35
35
  // take the file and massage it to a
36
36
  // transportable/manageable format
37
- const fileExt = path.extname(args.path);
38
37
  const filePath = path.join(args.resolveDir, args.path);
39
38
  const fileContent = await readFile(filePath);
40
39
  const fileHash = crypto
@@ -47,7 +46,7 @@ export default function makeModuleCollector(): {
47
46
  modules.push({
48
47
  name: fileName,
49
48
  content: fileContent,
50
- type: fileExt === ".wasm" ? "compiled-wasm" : "text",
49
+ type: "compiled-wasm",
51
50
  });
52
51
 
53
52
  return {
@@ -0,0 +1,120 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { execa, execaCommandSync } from "execa";
4
+
5
+ export interface PackageManager {
6
+ addDevDeps(...packages: string[]): Promise<void>;
7
+ install(): Promise<void>;
8
+ }
9
+
10
+ export async function getPackageManager(root: string): Promise<PackageManager> {
11
+ const [hasYarn, hasNpm] = await Promise.all([supportsYarn(), supportsNpm()]);
12
+ const hasYarnLock = existsSync(join(root, "yarn.lock"));
13
+ const hasNpmLock = existsSync(join(root, "package-lock.json"));
14
+
15
+ if (hasNpmLock) {
16
+ if (hasNpm) {
17
+ console.log(
18
+ "Using npm as package manager, as there is already a package-lock.json file."
19
+ );
20
+ return NpmPackageManager;
21
+ } else if (hasYarn) {
22
+ console.log("Using yarn as package manager.");
23
+ console.warn(
24
+ "There is already a package-lock.json file but could not find npm on the PATH."
25
+ );
26
+ return YarnPackageManager;
27
+ }
28
+ } else if (hasYarnLock) {
29
+ if (hasYarn) {
30
+ console.log(
31
+ "Using yarn as package manager, as there is already a yarn.lock file."
32
+ );
33
+ return YarnPackageManager;
34
+ } else if (hasNpm) {
35
+ console.log("Using npm as package manager.");
36
+ console.warn(
37
+ "There is already a yarn.lock file but could not find yarn on the PATH."
38
+ );
39
+ return NpmPackageManager;
40
+ }
41
+ }
42
+
43
+ if (hasNpm) {
44
+ console.log("Using npm as package manager.");
45
+ return NpmPackageManager;
46
+ } else if (hasYarn) {
47
+ console.log("Using yarn as package manager.");
48
+ return YarnPackageManager;
49
+ } else {
50
+ throw new Error(
51
+ "Unable to find a package manager. Supported managers are: npm and yarn."
52
+ );
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Get the name of the given `packageManager`.
58
+ */
59
+ export function getPackageManagerName(packageManager: unknown): string {
60
+ return packageManager === NpmPackageManager
61
+ ? "npm"
62
+ : packageManager === YarnPackageManager
63
+ ? "yarn"
64
+ : "unknown";
65
+ }
66
+
67
+ /**
68
+ * Manage packages using npm
69
+ */
70
+ const NpmPackageManager: PackageManager = {
71
+ /** Add and install a new devDependency into the local package.json. */
72
+ async addDevDeps(...packages: string[]): Promise<void> {
73
+ await execa("npm", ["install", ...packages, "--save-dev"], {
74
+ stdio: "inherit",
75
+ });
76
+ },
77
+
78
+ /** Install all the dependencies in the local package.json. */
79
+ async install(): Promise<void> {
80
+ await execa("npm", ["install"], {
81
+ stdio: "inherit",
82
+ });
83
+ },
84
+ };
85
+
86
+ /**
87
+ * Manage packages using yarn
88
+ */
89
+ const YarnPackageManager: PackageManager = {
90
+ /** Add and install a new devDependency into the local package.json. */
91
+ async addDevDeps(...packages: string[]): Promise<void> {
92
+ await execa("yarn", ["add", ...packages, "--dev"], {
93
+ stdio: "inherit",
94
+ });
95
+ },
96
+
97
+ /** Install all the dependencies in the local package.json. */
98
+ async install(): Promise<void> {
99
+ await execa("yarn", ["install"], {
100
+ stdio: "inherit",
101
+ });
102
+ },
103
+ };
104
+
105
+ async function supports(name: string): Promise<boolean> {
106
+ try {
107
+ execaCommandSync(`${name} --version`, { stdio: "ignore" });
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ function supportsYarn(): Promise<boolean> {
115
+ return supports("yarn");
116
+ }
117
+
118
+ function supportsNpm(): Promise<boolean> {
119
+ return supports("npm");
120
+ }
package/src/pages.tsx CHANGED
@@ -1,28 +1,28 @@
1
1
  /* eslint-disable no-shadow */
2
2
 
3
- import assert from "assert";
4
- import type { BuilderCallback } from "yargs";
5
- import { join } from "path";
6
- import { tmpdir } from "os";
7
- import { existsSync, lstatSync, readFileSync, writeFileSync } from "fs";
8
- import { execSync, spawn } from "child_process";
9
- import { URL } from "url";
3
+ import { execSync, spawn } from "node:child_process";
4
+ import { existsSync, lstatSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { URL } from "node:url";
8
+ import { watch } from "chokidar";
10
9
  import { getType } from "mime";
11
10
  import open from "open";
12
- import { watch } from "chokidar";
13
- import type { BuildResult } from "esbuild";
14
11
  import { buildWorker } from "../pages/functions/buildWorker";
15
- import type { Config } from "../pages/functions/routes";
16
- import { writeRoutesModule } from "../pages/functions/routes";
17
12
  import { generateConfigFromFileTree } from "../pages/functions/filepath-routing";
13
+ import { writeRoutesModule } from "../pages/functions/routes";
14
+ import { toUrlPath } from "./paths";
15
+ import type { Config } from "../pages/functions/routes";
16
+ import type { Headers, Request, fetch } from "@miniflare/core";
17
+ import type { BuildResult } from "esbuild";
18
+ import type { MiniflareOptions } from "miniflare";
19
+ import type { BuilderCallback } from "yargs";
18
20
 
19
21
  // Defer importing miniflare until we really need it. This takes ~0.5s
20
22
  // and also modifies some `stream/web` and `undici` prototypes, so we
21
23
  // don't want to do this if pages commands aren't being called.
22
- import type { Headers, Request, fetch } from "@miniflare/core";
23
- import type { MiniflareOptions } from "miniflare";
24
24
 
25
- const EXIT_CALLBACKS = [];
25
+ const EXIT_CALLBACKS: (() => void)[] = [];
26
26
  const EXIT = (message?: string, code?: number) => {
27
27
  if (message) console.log(message);
28
28
  if (code) process.exitCode = code;
@@ -122,7 +122,9 @@ async function spawnProxyProcess({
122
122
  },
123
123
  }
124
124
  );
125
- EXIT_CALLBACKS.push(() => proxy.kill());
125
+ EXIT_CALLBACKS.push(() => {
126
+ proxy.kill();
127
+ });
126
128
 
127
129
  proxy.stdout.on("data", (data) => {
128
130
  console.log(`[proxy]: ${data}`);
@@ -664,16 +666,17 @@ async function buildFunctions({
664
666
  );
665
667
 
666
668
  const routesModule = join(tmpdir(), "./functionsRoutes.mjs");
669
+ const baseURL = toUrlPath("/");
667
670
 
668
671
  const config: Config = await generateConfigFromFileTree({
669
672
  baseDir: functionsDirectory,
670
- baseURL: "/",
673
+ baseURL,
671
674
  });
672
675
 
673
676
  if (outputConfigPath) {
674
677
  writeFileSync(
675
678
  outputConfigPath,
676
- JSON.stringify({ ...config, baseURL: "/" }, null, 2)
679
+ JSON.stringify({ ...config, baseURL }, null, 2)
677
680
  );
678
681
  }
679
682
 
@@ -827,6 +830,8 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
827
830
  scriptPath,
828
831
  };
829
832
  } else {
833
+ scriptReadyResolve();
834
+
830
835
  const scriptPath =
831
836
  directory !== undefined
832
837
  ? join(directory, singleWorkerScriptPath)
@@ -862,11 +867,13 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
862
867
  // internally just waits for that promise to resolve.
863
868
  await scriptReadyPromise;
864
869
 
865
- // Should only be called if no proxyPort, using `assert.fail()` here
866
- // means the type of `assetsFetch` is still `typeof fetch`
867
- const assetsFetch = proxyPort
868
- ? () => assert.fail()
869
- : await generateAssetsFetch(directory);
870
+ // `assetsFetch()` will only be called if there is `proxyPort` defined.
871
+ // We only define `proxyPort`, above, when there is no `directory` defined.
872
+ const assetsFetch =
873
+ directory !== undefined
874
+ ? await generateAssetsFetch(directory)
875
+ : invalidAssetsFetch;
876
+
870
877
  const miniflare = new Miniflare({
871
878
  port,
872
879
  watch: true,
@@ -897,7 +904,7 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
897
904
  if (proxyPort) {
898
905
  try {
899
906
  const url = new URL(request.url);
900
- url.host = `127.0.0.1:${proxyPort}`;
907
+ url.host = `localhost:${proxyPort}`;
901
908
  return await fetch(url, request);
902
909
  } catch (thrown) {
903
910
  console.error(`Could not proxy request: ${thrown}`);
@@ -935,10 +942,10 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
935
942
  try {
936
943
  // `startServer` might throw if user code contains errors
937
944
  const server = await miniflare.startServer();
938
- console.log(`Serving at http://127.0.0.1:${port}/`);
945
+ console.log(`Serving at http://localhost:${port}/`);
939
946
 
940
947
  if (process.env.BROWSER !== "none") {
941
- const childProcess = await open(`http://127.0.0.1:${port}/`);
948
+ const childProcess = await open(`http://localhost:${port}/`);
942
949
  // fail silently if the open command doesn't work (e.g. in GitHub Codespaces)
943
950
  childProcess.on("error", (_err) => {});
944
951
  }
@@ -962,7 +969,9 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
962
969
  }
963
970
  }
964
971
  )
965
- .command("functions", "Cloudflare Pages Functions", (yargs) =>
972
+ .command("functions", false, (yargs) =>
973
+ // we hide this command from help output because
974
+ // it's not meant to be used directly right now
966
975
  yargs.command(
967
976
  "build [directory]",
968
977
  "Compile a folder of Cloudflare Pages Functions into a single Worker",
@@ -1029,3 +1038,9 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
1029
1038
  )
1030
1039
  );
1031
1040
  };
1041
+
1042
+ const invalidAssetsFetch: typeof fetch = () => {
1043
+ throw new Error(
1044
+ "Trying to fetch assets directly when there is no `directory` option specified, and not in `local` mode."
1045
+ );
1046
+ };
package/src/paths.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { assert } from "console";
2
+
3
+ type DiscriminatedPath<Discriminator extends string> = string & {
4
+ _discriminator: Discriminator;
5
+ };
6
+
7
+ /**
8
+ * A branded string that expects to be URL compatible.
9
+ *
10
+ * Require this type when you want callers to ensure that they have converted file-path strings into URL-safe paths.
11
+ */
12
+ export type UrlPath = DiscriminatedPath<"UrlPath">;
13
+
14
+ /**
15
+ * Convert a file-path string to a URL-path string.
16
+ *
17
+ * Use this helper to convert a `string` to a `UrlPath` when it is not clear whether the string needs normalizing.
18
+ * Replaces all back-slashes with forward-slashes, and throws an error if the path contains a drive letter (e.g. `C:`).
19
+ */
20
+ export function toUrlPath(path: string): UrlPath {
21
+ assert(
22
+ !/^[a-z]:/i.test(path),
23
+ "Tried to convert a Windows file path with a drive to a URL path."
24
+ );
25
+ return path.replace(/\\/g, "/") as UrlPath;
26
+ }
package/src/proxy.ts CHANGED
@@ -1,6 +1,9 @@
1
- import { connect } from "node:http2";
2
- import type { ClientHttp2Session, ServerHttp2Stream } from "node:http2";
3
1
  import { createServer } from "node:http";
2
+ import { connect } from "node:http2";
3
+ import WebSocket from "faye-websocket";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import serveStatic from "serve-static";
6
+ import type { CfPreviewToken } from "./api/preview";
4
7
  import type {
5
8
  IncomingHttpHeaders,
6
9
  RequestListener,
@@ -8,10 +11,7 @@ import type {
8
11
  ServerResponse,
9
12
  Server,
10
13
  } from "node:http";
11
- import WebSocket from "faye-websocket";
12
- import serveStatic from "serve-static";
13
- import type { CfPreviewToken } from "./api/preview";
14
- import { useEffect, useRef } from "react";
14
+ import type { ClientHttp2Session, ServerHttp2Stream } from "node:http2";
15
15
 
16
16
  /**
17
17
  * `usePreviewServer` is a React hook that creates a local development
@@ -80,6 +80,17 @@ export function usePreviewServer({
80
80
  { request: IncomingMessage; response: ServerResponse }[]
81
81
  >([]);
82
82
 
83
+ /**
84
+ * The session doesn't last forever, and will evetually drop
85
+ * (usually within 5-15 minutes). When that happens, we simply
86
+ * restart the effect, effectively restarting the server. We use
87
+ * a state sigil as an effect dependency to do so.
88
+ */
89
+ const [retryServerSetupSigil, setRetryServerSetupSigil] = useState<number>(0);
90
+ function retryServerSetup() {
91
+ setRetryServerSetupSigil((x) => x + 1);
92
+ }
93
+
83
94
  useEffect(() => {
84
95
  // If we don't have a token, that means either we're just starting up,
85
96
  // or we're refreshing the token.
@@ -119,6 +130,11 @@ export function usePreviewServer({
119
130
  const remote = connect(`https://${previewToken.host}`);
120
131
  cleanupListeners.push(() => remote.destroy());
121
132
 
133
+ // As mentioned above, the session may die at any point,
134
+ // so we need to restart the effect.
135
+ remote.on("close", retryServerSetup);
136
+ cleanupListeners.push(() => remote.off("close", retryServerSetup));
137
+
122
138
  /** HTTP/2 -> HTTP/2 */
123
139
  const handleStream = createStreamHandler(previewToken, remote, port);
124
140
  proxy.on("stream", handleStream);
@@ -209,15 +225,31 @@ export function usePreviewServer({
209
225
  cleanupListeners.push(() => proxy.off("upgrade", handleUpgrade));
210
226
 
211
227
  return () => {
212
- cleanupListeners.forEach((d) => d());
228
+ cleanupListeners.forEach((cleanup) => cleanup());
213
229
  };
214
- }, [previewToken, publicRoot, port, proxy]);
230
+ }, [
231
+ previewToken,
232
+ publicRoot,
233
+ port,
234
+ proxy,
235
+ // We use a state value as a sigil to trigger reconnecting the server.
236
+ // It's not used inside the effect, so react-hooks/exhaustive-deps
237
+ // doesn't complain if it's not included in the dependency array.
238
+ // But its presence is critical, so Do NOT remove it from the dependency list.
239
+ retryServerSetupSigil,
240
+ ]);
215
241
 
216
242
  // Start/stop the server whenever the
217
243
  // containing component is mounted/unmounted.
218
244
  useEffect(() => {
219
- proxy.listen(port);
220
- console.log(`⬣ Listening at http://localhost:${port}`);
245
+ waitForPortToBeAvailable(port, { retryPeriod: 200, timeout: 2000 })
246
+ .then(() => {
247
+ proxy.listen(port);
248
+ console.log(`⬣ Listening at http://localhost:${port}`);
249
+ })
250
+ .catch((err) => {
251
+ console.error(`⬣ Failed to start server: ${err}`);
252
+ });
221
253
 
222
254
  return () => {
223
255
  proxy.close();
@@ -300,3 +332,39 @@ function createStreamHandler(
300
332
  });
301
333
  };
302
334
  }
335
+
336
+ /**
337
+ * A helper function that waits for a port to be available.
338
+ */
339
+ export async function waitForPortToBeAvailable(
340
+ port: number,
341
+ options: { retryPeriod: number; timeout: number }
342
+ ): Promise<void> {
343
+ return new Promise((resolve, reject) => {
344
+ const timeout = setTimeout(() => {
345
+ reject(new Error(`Timed out waiting for port ${port}`));
346
+ }, options.timeout);
347
+
348
+ function checkPort() {
349
+ // Testing whether a port is 'available' involves simply
350
+ // trying to make a server listen on that port, and retrying
351
+ // until it succeeds.
352
+ const server = createServer();
353
+ server.on("error", (err) => {
354
+ // @ts-expect-error non standard property on Error
355
+ if (err.code === "EADDRINUSE") {
356
+ setTimeout(checkPort, options.retryPeriod);
357
+ } else {
358
+ reject(err);
359
+ }
360
+ });
361
+ server.listen(port, () => {
362
+ server.close();
363
+ clearTimeout(timeout);
364
+ resolve();
365
+ });
366
+ }
367
+
368
+ checkPort();
369
+ });
370
+ }