wrangler 0.0.13 → 0.0.17

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 (67) hide show
  1. package/bin/wrangler.js +2 -2
  2. package/package.json +20 -11
  3. package/pages/functions/buildWorker.ts +1 -1
  4. package/pages/functions/filepath-routing.test.ts +112 -28
  5. package/pages/functions/filepath-routing.ts +44 -51
  6. package/pages/functions/routes.ts +11 -18
  7. package/pages/functions/template-worker.ts +3 -9
  8. package/src/__tests__/dev.test.tsx +42 -5
  9. package/src/__tests__/guess-worker-format.test.ts +66 -0
  10. package/src/__tests__/{clipboardy-mock.js → helpers/clipboardy-mock.js} +0 -0
  11. package/src/__tests__/helpers/cmd-shim.d.ts +11 -0
  12. package/src/__tests__/helpers/faye-websocket.d.ts +6 -0
  13. package/src/__tests__/helpers/mock-account-id.ts +30 -0
  14. package/src/__tests__/helpers/mock-bin.ts +36 -0
  15. package/src/__tests__/{mock-cfetch.ts → helpers/mock-cfetch.ts} +43 -9
  16. package/src/__tests__/helpers/mock-console.ts +62 -0
  17. package/src/__tests__/{mock-dialogs.ts → helpers/mock-dialogs.ts} +1 -1
  18. package/src/__tests__/helpers/mock-kv.ts +40 -0
  19. package/src/__tests__/helpers/mock-user.ts +27 -0
  20. package/src/__tests__/helpers/mock-web-socket.ts +37 -0
  21. package/src/__tests__/{run-in-tmp.ts → helpers/run-in-tmp.ts} +1 -1
  22. package/src/__tests__/helpers/run-wrangler.ts +16 -0
  23. package/src/__tests__/helpers/write-wrangler-toml.ts +20 -0
  24. package/src/__tests__/index.test.ts +418 -71
  25. package/src/__tests__/jest.setup.ts +30 -2
  26. package/src/__tests__/kv.test.ts +147 -252
  27. package/src/__tests__/logout.test.ts +50 -0
  28. package/src/__tests__/package-manager.test.ts +206 -0
  29. package/src/__tests__/publish.test.ts +1136 -291
  30. package/src/__tests__/r2.test.ts +206 -0
  31. package/src/__tests__/secret.test.ts +210 -0
  32. package/src/__tests__/sentry.test.ts +146 -0
  33. package/src/__tests__/tail.test.ts +246 -0
  34. package/src/__tests__/whoami.test.tsx +6 -47
  35. package/src/api/form_data.ts +75 -25
  36. package/src/api/preview.ts +2 -2
  37. package/src/api/worker.ts +34 -15
  38. package/src/bundle.ts +127 -0
  39. package/src/cfetch/index.ts +7 -15
  40. package/src/cfetch/internal.ts +41 -6
  41. package/src/cli.ts +10 -0
  42. package/src/config.ts +125 -95
  43. package/src/dev.tsx +300 -193
  44. package/src/dialogs.tsx +2 -2
  45. package/src/guess-worker-format.ts +68 -0
  46. package/src/index.tsx +578 -192
  47. package/src/inspect.ts +29 -10
  48. package/src/kv.tsx +23 -17
  49. package/src/module-collection.ts +32 -12
  50. package/src/open-in-browser.ts +13 -0
  51. package/src/package-manager.ts +120 -0
  52. package/src/pages.tsx +28 -23
  53. package/src/paths.ts +26 -0
  54. package/src/proxy.ts +88 -14
  55. package/src/publish.ts +260 -297
  56. package/src/r2.ts +50 -0
  57. package/src/reporting.ts +115 -0
  58. package/src/sites.tsx +28 -27
  59. package/src/tail.tsx +178 -9
  60. package/src/user.tsx +58 -44
  61. package/templates/new-worker.js +15 -0
  62. package/templates/new-worker.ts +15 -0
  63. package/{static-asset-facade.js → templates/static-asset-facade.js} +0 -0
  64. package/wrangler-dist/cli.js +124315 -104677
  65. package/wrangler-dist/cli.js.map +3 -3
  66. package/src/__tests__/mock-console.ts +0 -34
  67. package/src/__tests__/run-wrangler.ts +0 -8
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
  /**
@@ -361,7 +367,10 @@ export default function useInspector(props: InspectorProps) {
361
367
  );
362
368
  remoteWebSocket.send(event.data);
363
369
  } catch (e) {
364
- if (e.message !== "WebSocket is not open: readyState 0 (CONNECTING)") {
370
+ if (
371
+ (e as Error).message !==
372
+ "WebSocket is not open: readyState 0 (CONNECTING)"
373
+ ) {
365
374
  /**
366
375
  * ^ this just means we haven't opened a websocket yet
367
376
  * usually happens until there's at least one request
@@ -581,8 +590,18 @@ function logConsoleMessage(evt: Protocol.Runtime.ConsoleAPICalledEvent): void {
581
590
  const method = mapConsoleAPIMessageTypeToConsoleMethod[evt.type];
582
591
 
583
592
  if (method in console) {
584
- // eslint-disable-next-line prefer-spread
585
- console[method].apply(console, args);
593
+ switch (method) {
594
+ case "dir":
595
+ console.dir(args);
596
+ break;
597
+ case "table":
598
+ console.table(args);
599
+ break;
600
+ default:
601
+ // eslint-disable-next-line prefer-spread
602
+ console[method].apply(console, args);
603
+ break;
604
+ }
586
605
  } else {
587
606
  console.warn(`Unsupported console method: ${method}`);
588
607
  console.log("console event:", evt);
package/src/kv.tsx CHANGED
@@ -1,13 +1,12 @@
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;
7
7
  "namespace-id"?: string;
8
8
  env?: string;
9
9
  preview?: boolean;
10
- config?: Config;
11
10
  };
12
11
 
13
12
  /**
@@ -115,6 +114,14 @@ export async function putKeyValue(
115
114
  );
116
115
  }
117
116
 
117
+ export async function getKeyValue(
118
+ accountId: string,
119
+ namespaceId: string,
120
+ key: string
121
+ ): Promise<string> {
122
+ return await fetchKVGetValue(accountId, namespaceId, key);
123
+ }
124
+
118
125
  export async function putBulkKeyValue(
119
126
  accountId: string,
120
127
  namespaceId: string,
@@ -145,13 +152,10 @@ export async function deleteBulkKeyValue(
145
152
  );
146
153
  }
147
154
 
148
- export function getNamespaceId({
149
- preview,
150
- binding,
151
- config,
152
- "namespace-id": namespaceId,
153
- env,
154
- }: KvArgs): string {
155
+ export function getNamespaceId(
156
+ { preview, binding, "namespace-id": namespaceId, env }: KvArgs,
157
+ config: Config
158
+ ): string {
155
159
  // nice
156
160
  if (namespaceId) {
157
161
  return namespaceId;
@@ -181,19 +185,21 @@ export function getNamespaceId({
181
185
  }
182
186
 
183
187
  // TODO: either a bespoke arg type for this function to avoid `undefined`s or an `EnvOrConfig` type
184
- return getNamespaceId({
185
- binding,
186
- "namespace-id": namespaceId,
187
- env: undefined,
188
- preview,
189
- config: {
188
+ return getNamespaceId(
189
+ {
190
+ binding,
191
+ "namespace-id": namespaceId,
192
+ env: undefined,
193
+ preview,
194
+ },
195
+ {
190
196
  env: undefined,
191
197
  build: undefined,
192
198
  name: undefined,
193
199
  account_id: undefined,
194
200
  ...config.env[env],
195
- },
196
- });
201
+ }
202
+ );
197
203
  }
198
204
 
199
205
  // there's no KV namespaces
@@ -1,8 +1,8 @@
1
- import type { CfModule } from "./api/worker";
2
- import type esbuild from "esbuild";
3
- import path from "node:path";
4
- import { readFile } from "node:fs/promises";
5
1
  import crypto from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { CfModule, CfScriptFormat } from "./api/worker";
5
+ import type esbuild from "esbuild";
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.
@@ -12,7 +12,9 @@ import crypto from "node:crypto";
12
12
  // plugin+array is used to collect references to these modules, reference
13
13
  // them correctly in the bundle, and add them to the form upload.
14
14
 
15
- export default function makeModuleCollector(): {
15
+ export default function makeModuleCollector(props: {
16
+ format: CfScriptFormat;
17
+ }): {
16
18
  modules: CfModule[];
17
19
  plugin: esbuild.Plugin;
18
20
  } {
@@ -23,18 +25,17 @@ export default function makeModuleCollector(): {
23
25
  name: "wrangler-module-collector",
24
26
  setup(build) {
25
27
  build.onStart(() => {
26
- // reset the moduels collection
28
+ // reset the module collection array
27
29
  modules.splice(0);
28
30
  });
29
31
 
30
32
  build.onResolve(
31
33
  // filter on "known" file types,
32
34
  // we can expand this list later
33
- { filter: /.*\.(pem|txt|html|wasm)$/ },
35
+ { filter: /.*\.(wasm)$/ },
34
36
  async (args: esbuild.OnResolveArgs) => {
35
37
  // take the file and massage it to a
36
38
  // transportable/manageable format
37
- const fileExt = path.extname(args.path);
38
39
  const filePath = path.join(args.resolveDir, args.path);
39
40
  const fileContent = await readFile(filePath);
40
41
  const fileHash = crypto
@@ -45,19 +46,38 @@ export default function makeModuleCollector(): {
45
46
 
46
47
  // add the module to the array
47
48
  modules.push({
48
- name: fileName,
49
+ name: "./" + fileName,
49
50
  content: fileContent,
50
- type: fileExt === ".wasm" ? "compiled-wasm" : "text",
51
+ type: "compiled-wasm",
51
52
  });
52
53
 
53
54
  return {
54
- path: fileName, // change the reference to the changed module
55
- external: true, // mark it as external in the bundle
55
+ path: "./" + fileName, // change the reference to the changed module
56
+ external: props.format === "modules", // mark it as external in the bundle
56
57
  namespace: "wrangler-module-collector-ns", // just a tag, this isn't strictly necessary
57
58
  watchFiles: [filePath], // we also add the file to esbuild's watch list
58
59
  };
59
60
  }
60
61
  );
62
+
63
+ if (props.format !== "modules") {
64
+ build.onLoad(
65
+ { filter: /.*\.(wasm)$/ },
66
+ async (args: esbuild.OnLoadArgs) => {
67
+ return {
68
+ // We replace the the wasm module with an identifier
69
+ // that we'll separately add to the form upload
70
+ // as part of [wasm_modules]. This identifier has to be a valid
71
+ // JS identifier, so we replace all non alphanumeric characters
72
+ // with an underscore.
73
+ contents: `export default ${args.path.replace(
74
+ /[^a-zA-Z0-9_$]/g,
75
+ "_"
76
+ )};`,
77
+ };
78
+ }
79
+ );
80
+ }
61
81
  },
62
82
  },
63
83
  };
@@ -0,0 +1,13 @@
1
+ import open from "open";
2
+ /**
3
+ * An extremely simple wrapper around the open command.
4
+ * Specifically, it adds an 'error' event handler so that when this function
5
+ * is called in environments where we can't open the browser (e.g. github codespaces,
6
+ * stackblitz, remote servers), it doesn't just crash the process.
7
+ */
8
+ export default async function openInBrowser(url: string): Promise<void> {
9
+ const childProcess = await open(url);
10
+ childProcess.on("error", () => {
11
+ console.warn(`Failed to open ${url} in a browser`);
12
+ });
13
+ }
@@ -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,25 +1,26 @@
1
1
  /* eslint-disable no-shadow */
2
2
 
3
- import type { BuilderCallback } from "yargs";
4
- import { join } from "path";
5
- import { tmpdir } from "os";
6
- import { existsSync, lstatSync, readFileSync, writeFileSync } from "fs";
7
- import { execSync, spawn } from "child_process";
8
- import { URL } from "url";
9
- import { getType } from "mime";
10
- import open from "open";
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";
11
8
  import { watch } from "chokidar";
12
- import type { BuildResult } from "esbuild";
9
+ import { getType } from "mime";
13
10
  import { buildWorker } from "../pages/functions/buildWorker";
14
- import type { Config } from "../pages/functions/routes";
15
- import { writeRoutesModule } from "../pages/functions/routes";
16
11
  import { generateConfigFromFileTree } from "../pages/functions/filepath-routing";
12
+ import { writeRoutesModule } from "../pages/functions/routes";
13
+ import openInBrowser from "./open-in-browser";
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";
17
20
 
18
21
  // Defer importing miniflare until we really need it. This takes ~0.5s
19
22
  // and also modifies some `stream/web` and `undici` prototypes, so we
20
23
  // don't want to do this if pages commands aren't being called.
21
- import type { Headers, Request, fetch } from "@miniflare/core";
22
- import type { MiniflareOptions } from "miniflare";
23
24
 
24
25
  const EXIT_CALLBACKS: (() => void)[] = [];
25
26
  const EXIT = (message?: string, code?: number) => {
@@ -665,16 +666,17 @@ async function buildFunctions({
665
666
  );
666
667
 
667
668
  const routesModule = join(tmpdir(), "./functionsRoutes.mjs");
669
+ const baseURL = toUrlPath("/");
668
670
 
669
671
  const config: Config = await generateConfigFromFileTree({
670
672
  baseDir: functionsDirectory,
671
- baseURL: "/",
673
+ baseURL,
672
674
  });
673
675
 
674
676
  if (outputConfigPath) {
675
677
  writeFileSync(
676
678
  outputConfigPath,
677
- JSON.stringify({ ...config, baseURL: "/" }, null, 2)
679
+ JSON.stringify({ ...config, baseURL }, null, 2)
678
680
  );
679
681
  }
680
682
 
@@ -793,8 +795,8 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
793
795
 
794
796
  let miniflareArgs: MiniflareOptions = {};
795
797
 
796
- let scriptReadyResolve;
797
- const scriptReadyPromise = new Promise(
798
+ let scriptReadyResolve: () => void;
799
+ const scriptReadyPromise = new Promise<void>(
798
800
  (resolve) => (scriptReadyResolve = resolve)
799
801
  );
800
802
 
@@ -828,6 +830,9 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
828
830
  scriptPath,
829
831
  };
830
832
  } else {
833
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
834
+ scriptReadyResolve!();
835
+
831
836
  const scriptPath =
832
837
  directory !== undefined
833
838
  ? join(directory, singleWorkerScriptPath)
@@ -896,7 +901,7 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
896
901
 
897
902
  // env.ASSETS.fetch
898
903
  serviceBindings: {
899
- async ASSETS(request) {
904
+ async ASSETS(request: Request) {
900
905
  if (proxyPort) {
901
906
  try {
902
907
  const url = new URL(request.url);
@@ -941,9 +946,7 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
941
946
  console.log(`Serving at http://localhost:${port}/`);
942
947
 
943
948
  if (process.env.BROWSER !== "none") {
944
- const childProcess = await open(`http://localhost:${port}/`);
945
- // fail silently if the open command doesn't work (e.g. in GitHub Codespaces)
946
- childProcess.on("error", (_err) => {});
949
+ await openInBrowser(`http://localhost:${port}/`);
947
950
  }
948
951
 
949
952
  if (directory !== undefined && liveReload) {
@@ -960,12 +963,14 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
960
963
  miniflare.dispose().catch((err) => miniflare.log.error(err));
961
964
  });
962
965
  } catch (e) {
963
- miniflare.log.error(e);
966
+ miniflare.log.error(e as Error);
964
967
  EXIT("Could not start Miniflare.", 1);
965
968
  }
966
969
  }
967
970
  )
968
- .command("functions", "Cloudflare Pages Functions", (yargs) =>
971
+ .command("functions", false, (yargs) =>
972
+ // we hide this command from help output because
973
+ // it's not meant to be used directly right now
969
974
  yargs.command(
970
975
  "build [directory]",
971
976
  "Compile a folder of Cloudflare Pages Functions into a single Worker",
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,13 @@ 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
+ import type ws from "ws";
16
+
17
+ interface IWebsocket extends ws {
18
+ // Pipe implements .on("message", ...)
19
+ pipe<T>(fn: T): IWebsocket;
20
+ }
15
21
 
16
22
  /**
17
23
  * `usePreviewServer` is a React hook that creates a local development
@@ -80,6 +86,17 @@ export function usePreviewServer({
80
86
  { request: IncomingMessage; response: ServerResponse }[]
81
87
  >([]);
82
88
 
89
+ /**
90
+ * The session doesn't last forever, and will evetually drop
91
+ * (usually within 5-15 minutes). When that happens, we simply
92
+ * restart the effect, effectively restarting the server. We use
93
+ * a state sigil as an effect dependency to do so.
94
+ */
95
+ const [retryServerSetupSigil, setRetryServerSetupSigil] = useState<number>(0);
96
+ function retryServerSetup() {
97
+ setRetryServerSetupSigil((x) => x + 1);
98
+ }
99
+
83
100
  useEffect(() => {
84
101
  // If we don't have a token, that means either we're just starting up,
85
102
  // or we're refreshing the token.
@@ -119,6 +136,11 @@ export function usePreviewServer({
119
136
  const remote = connect(`https://${previewToken.host}`);
120
137
  cleanupListeners.push(() => remote.destroy());
121
138
 
139
+ // As mentioned above, the session may die at any point,
140
+ // so we need to restart the effect.
141
+ remote.on("close", retryServerSetup);
142
+ cleanupListeners.push(() => remote.off("close", retryServerSetup));
143
+
122
144
  /** HTTP/2 -> HTTP/2 */
123
145
  const handleStream = createStreamHandler(previewToken, remote, port);
124
146
  proxy.on("stream", handleStream);
@@ -191,33 +213,49 @@ export function usePreviewServer({
191
213
  const { headers, url } = message;
192
214
  addCfPreviewTokenHeader(headers, previewToken.value);
193
215
  headers["host"] = previewToken.host;
194
- const localWebsocket = new WebSocket(message, socket, body);
216
+ const localWebsocket = new WebSocket(message, socket, body) as IWebsocket;
195
217
  // TODO(soon): Custom WebSocket protocol is not working?
196
218
  const remoteWebsocketClient = new WebSocket.Client(
197
219
  `wss://${previewToken.host}${url}`,
198
220
  [],
199
221
  { headers }
200
- );
222
+ ) as IWebsocket;
201
223
  localWebsocket.pipe(remoteWebsocketClient).pipe(localWebsocket);
202
224
  // We close down websockets whenever we refresh the token.
203
225
  cleanupListeners.push(() => {
204
- localWebsocket.destroy();
205
- remoteWebsocketClient.destroy();
226
+ localWebsocket.close();
227
+ remoteWebsocketClient.close();
206
228
  });
207
229
  };
208
230
  proxy.on("upgrade", handleUpgrade);
209
231
  cleanupListeners.push(() => proxy.off("upgrade", handleUpgrade));
210
232
 
211
233
  return () => {
212
- cleanupListeners.forEach((d) => d());
234
+ cleanupListeners.forEach((cleanup) => cleanup());
213
235
  };
214
- }, [previewToken, publicRoot, port, proxy]);
236
+ }, [
237
+ previewToken,
238
+ publicRoot,
239
+ port,
240
+ proxy,
241
+ // We use a state value as a sigil to trigger reconnecting the server.
242
+ // It's not used inside the effect, so react-hooks/exhaustive-deps
243
+ // doesn't complain if it's not included in the dependency array.
244
+ // But its presence is critical, so Do NOT remove it from the dependency list.
245
+ retryServerSetupSigil,
246
+ ]);
215
247
 
216
248
  // Start/stop the server whenever the
217
249
  // containing component is mounted/unmounted.
218
250
  useEffect(() => {
219
- proxy.listen(port);
220
- console.log(`⬣ Listening at http://localhost:${port}`);
251
+ waitForPortToBeAvailable(port, { retryPeriod: 200, timeout: 2000 })
252
+ .then(() => {
253
+ proxy.listen(port);
254
+ console.log(`⬣ Listening at http://localhost:${port}`);
255
+ })
256
+ .catch((err) => {
257
+ console.error(`⬣ Failed to start server: ${err}`);
258
+ });
221
259
 
222
260
  return () => {
223
261
  proxy.close();
@@ -300,3 +338,39 @@ function createStreamHandler(
300
338
  });
301
339
  };
302
340
  }
341
+
342
+ /**
343
+ * A helper function that waits for a port to be available.
344
+ */
345
+ export async function waitForPortToBeAvailable(
346
+ port: number,
347
+ options: { retryPeriod: number; timeout: number }
348
+ ): Promise<void> {
349
+ return new Promise((resolve, reject) => {
350
+ const timeout = setTimeout(() => {
351
+ reject(new Error(`Timed out waiting for port ${port}`));
352
+ }, options.timeout);
353
+
354
+ function checkPort() {
355
+ // Testing whether a port is 'available' involves simply
356
+ // trying to make a server listen on that port, and retrying
357
+ // until it succeeds.
358
+ const server = createServer();
359
+ server.on("error", (err) => {
360
+ // @ts-expect-error non standard property on Error
361
+ if (err.code === "EADDRINUSE") {
362
+ setTimeout(checkPort, options.retryPeriod);
363
+ } else {
364
+ reject(err);
365
+ }
366
+ });
367
+ server.listen(port, () => {
368
+ server.close();
369
+ clearTimeout(timeout);
370
+ resolve();
371
+ });
372
+ }
373
+
374
+ checkPort();
375
+ });
376
+ }