wrangler 0.0.0-ece06ea → 0.0.0-ef13735

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 (63) hide show
  1. package/bin/wrangler.js +2 -2
  2. package/package.json +35 -21
  3. package/pages/functions/buildWorker.ts +62 -0
  4. package/pages/functions/filepath-routing.test.ts +66 -0
  5. package/pages/functions/filepath-routing.ts +219 -0
  6. package/pages/functions/identifiers.ts +78 -0
  7. package/pages/functions/routes.ts +151 -0
  8. package/pages/functions/template-worker.ts +130 -0
  9. package/src/__tests__/dev.test.tsx +73 -0
  10. package/src/__tests__/guess-worker-format.test.ts +51 -0
  11. package/src/__tests__/{clipboardy-mock.js → helpers/clipboardy-mock.js} +0 -0
  12. package/src/__tests__/helpers/cmd-shim.d.ts +11 -0
  13. package/src/__tests__/helpers/mock-account-id.ts +30 -0
  14. package/src/__tests__/helpers/mock-bin.ts +33 -0
  15. package/src/__tests__/helpers/mock-cfetch.ts +207 -0
  16. package/src/__tests__/helpers/mock-console.ts +56 -0
  17. package/src/__tests__/helpers/mock-dialogs.ts +65 -0
  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/run-in-tmp.ts +29 -0
  21. package/src/__tests__/helpers/run-wrangler.ts +14 -0
  22. package/src/__tests__/index.test.ts +715 -119
  23. package/src/__tests__/jest.setup.ts +37 -0
  24. package/src/__tests__/kv.test.ts +1139 -0
  25. package/src/__tests__/logout.test.ts +50 -0
  26. package/src/__tests__/package-manager.test.ts +206 -0
  27. package/src/__tests__/publish.test.ts +1381 -0
  28. package/src/__tests__/secret.test.ts +210 -0
  29. package/src/__tests__/whoami.test.tsx +86 -0
  30. package/src/api/form_data.ts +93 -100
  31. package/src/api/preview.ts +29 -24
  32. package/src/api/worker.ts +55 -47
  33. package/src/cfetch/index.ts +102 -0
  34. package/src/cfetch/internal.ts +101 -0
  35. package/src/cli.ts +6 -7
  36. package/src/config.ts +641 -112
  37. package/src/dev.tsx +476 -355
  38. package/src/dialogs.tsx +6 -14
  39. package/src/guess-worker-format.ts +66 -0
  40. package/src/index.tsx +828 -496
  41. package/src/inspect.ts +596 -0
  42. package/src/kv.tsx +118 -45
  43. package/src/module-collection.ts +63 -0
  44. package/src/open-in-browser.ts +13 -0
  45. package/src/package-manager.ts +120 -0
  46. package/src/pages.tsx +915 -215
  47. package/src/paths.ts +26 -0
  48. package/src/proxy.ts +370 -0
  49. package/src/publish.ts +349 -290
  50. package/src/sites.tsx +184 -55
  51. package/src/tail.tsx +5 -3
  52. package/src/user.tsx +81 -91
  53. package/src/whoami.tsx +69 -0
  54. package/templates/new-worker.js +15 -0
  55. package/templates/new-worker.ts +15 -0
  56. package/{static-asset-facade.js → templates/static-asset-facade.js} +0 -0
  57. package/wrangler-dist/cli.js +119231 -130682
  58. package/wrangler-dist/cli.js.map +3 -3
  59. package/src/__tests__/fixtures/init/.gitkeep +0 -0
  60. package/src/__tests__/mock-cfetch.js +0 -46
  61. package/src/api/inspect.ts +0 -441
  62. package/src/cfetch.ts +0 -72
  63. package/src/util/fetch.ts +0 -74
package/src/dev.tsx CHANGED
@@ -1,76 +1,86 @@
1
- import esbuild from "esbuild";
2
- import httpProxy from "http-proxy";
3
- import { readFile } from "fs/promises";
4
- import type { DirectoryResult } from "tmp-promise";
5
- import tmp from "tmp-promise";
6
- import type { CfPreviewToken } from "./api/preview";
7
- import { Box, Text, useInput } from "ink";
1
+ import assert from "node:assert";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { watch } from "chokidar";
7
+ import clipboardy from "clipboardy";
8
+ import commandExists from "command-exists";
9
+ import * as esbuild from "esbuild";
10
+ import { execaCommand } from "execa";
11
+ import { Box, Text, useApp, useInput } from "ink";
8
12
  import React, { useState, useEffect, useRef } from "react";
9
- import path from "path";
10
- import open from "open";
11
- import { DtInspector } from "./api/inspect";
12
- import type { CfModule, CfVariable } from "./api/worker";
13
- import { createWorker } from "./api/worker";
14
- import type { CfWorkerInit } from "./api/worker";
15
- import { spawn } from "child_process";
13
+ import { withErrorBoundary, useErrorHandler } from "react-error-boundary";
16
14
  import onExit from "signal-exit";
15
+ import tmp from "tmp-promise";
16
+ import { fetch } from "undici";
17
+ import { createWorker } from "./api/worker";
18
+ import guessWorkerFormat from "./guess-worker-format";
19
+ import useInspector from "./inspect";
20
+ import makeModuleCollector from "./module-collection";
21
+ import openInBrowser from "./open-in-browser";
22
+ import { usePreviewServer, waitForPortToBeAvailable } from "./proxy";
17
23
  import { syncAssets } from "./sites";
18
- import clipboardy from "clipboardy";
19
- import http from "node:http";
20
- import serveStatic from "serve-static";
21
- import commandExists from "command-exists";
22
- import assert from "assert";
23
24
  import { getAPIToken } from "./user";
24
- import fetch from "node-fetch";
25
- import NodeModulesPolyfills from "@esbuild-plugins/node-modules-polyfill";
26
- import NodeGlobalsPolyfills from "@esbuild-plugins/node-globals-polyfill";
27
-
28
- type CfScriptFormat = void | "modules" | "service-worker";
25
+ import type { CfPreviewToken } from "./api/preview";
26
+ import type { CfModule, CfWorkerInit, CfScriptFormat } from "./api/worker";
27
+ import type { AssetPaths } from "./sites";
28
+ import type { DirectoryResult } from "tmp-promise";
29
29
 
30
- type Props = {
30
+ export type DevProps = {
31
31
  name?: string;
32
32
  entry: string;
33
33
  port?: number;
34
- format: CfScriptFormat;
35
- accountId: void | string;
34
+ format: CfScriptFormat | undefined;
35
+ accountId: undefined | string;
36
36
  initialMode: "local" | "remote";
37
- jsxFactory: void | string;
38
- jsxFragment: void | string;
39
- variables: { [name: string]: CfVariable };
40
- public: void | string;
41
- site: void | string;
42
- compatibilityDate: void | string;
43
- compatibilityFlags: void | string[];
44
- usageModel: void | "bundled" | "unbound";
45
- polyfillNode: void | boolean;
37
+ jsxFactory: undefined | string;
38
+ jsxFragment: undefined | string;
39
+ enableLocalPersistence: boolean;
40
+ bindings: CfWorkerInit["bindings"];
41
+ public: undefined | string;
42
+ assetPaths: undefined | AssetPaths;
43
+ compatibilityDate: undefined | string;
44
+ compatibilityFlags: undefined | string[];
45
+ usageModel: undefined | "bundled" | "unbound";
46
+ buildCommand: {
47
+ command?: undefined | string;
48
+ cwd?: undefined | string;
49
+ watch_dir?: undefined | string;
50
+ };
51
+ env: string | undefined;
46
52
  };
47
53
 
48
- export function Dev(props: Props): JSX.Element {
49
- if (props.public && props.format === "service-worker") {
50
- throw new Error(
51
- "You cannot use the service worker format with a `public` directory."
52
- );
53
- }
54
- const port = props.port || 8787;
55
- const apiToken = getAPIToken();
54
+ function Dev(props: DevProps): JSX.Element {
55
+ const port = props.port ?? 8787;
56
+ const apiToken = props.initialMode === "remote" ? getAPIToken() : undefined;
56
57
  const directory = useTmpDir();
57
58
 
59
+ // if there isn't a build command, we just return the entry immediately
60
+ // ideally there would be a conditional here, but the rules of hooks
61
+ // kinda forbid that, so we thread the entry through useCustomBuild
62
+ const entry = useCustomBuild(props.entry, props.buildCommand);
63
+
58
64
  const bundle = useEsbuild({
59
- entry: props.entry,
65
+ entry,
60
66
  destination: directory,
61
67
  staticRoot: props.public,
62
68
  jsxFactory: props.jsxFactory,
63
69
  jsxFragment: props.jsxFragment,
64
- polyfillNode: props.polyfillNode,
65
70
  });
66
- if (bundle && bundle.type === "commonjs" && !props.format && props.public) {
71
+
72
+ const format = useWorkerFormat({ file: entry, format: props.format });
73
+ if (format && props.public && format === "service-worker") {
67
74
  throw new Error(
68
75
  "You cannot use the service worker format with a `public` directory."
69
76
  );
70
77
  }
71
78
 
72
- // @ts-expect-error whack
73
- useDevtoolsRefresh(bundle?.id ?? 0);
79
+ if (props.bindings.wasm_modules && format === "modules") {
80
+ throw new Error(
81
+ "You cannot configure [wasm_modules] with an ES Module worker. Instead, import the .wasm module directly in your code"
82
+ );
83
+ }
74
84
 
75
85
  const toggles = useHotkeys(
76
86
  {
@@ -88,26 +98,28 @@ export function Dev(props: Props): JSX.Element {
88
98
  <Local
89
99
  name={props.name}
90
100
  bundle={bundle}
91
- format={props.format}
92
- variables={props.variables}
93
- site={props.site}
101
+ format={format}
102
+ bindings={props.bindings}
103
+ assetPaths={props.assetPaths}
94
104
  public={props.public}
95
- port={props.port}
105
+ port={port}
106
+ enableLocalPersistence={props.enableLocalPersistence}
96
107
  />
97
108
  ) : (
98
109
  <Remote
99
110
  name={props.name}
100
111
  bundle={bundle}
101
- format={props.format}
112
+ format={format}
102
113
  accountId={props.accountId}
103
114
  apiToken={apiToken}
104
- variables={props.variables}
105
- site={props.site}
115
+ bindings={props.bindings}
116
+ assetPaths={props.assetPaths}
106
117
  public={props.public}
107
- port={props.port}
118
+ port={port}
108
119
  compatibilityDate={props.compatibilityDate}
109
120
  compatibilityFlags={props.compatibilityFlags}
110
121
  usageModel={props.usageModel}
122
+ env={props.env}
111
123
  />
112
124
  )}
113
125
  <Box borderStyle="round" paddingLeft={1} paddingRight={1}>
@@ -123,156 +135,193 @@ export function Dev(props: Props): JSX.Element {
123
135
  );
124
136
  }
125
137
 
126
- function useDevtoolsRefresh(bundleId: number) {
127
- // TODO: this is a hack while we figure out
128
- // a better cleaner solution to get devtools to reconnect
129
- // without having to do a full refresh
130
- const ref = useRef();
131
- // @ts-expect-error whack
132
- ref.current = bundleId;
133
-
138
+ function useWorkerFormat(props: {
139
+ file: string | undefined;
140
+ format: undefined | CfScriptFormat;
141
+ }): CfScriptFormat | undefined {
142
+ const [format, setFormat] = useState<CfScriptFormat | undefined>();
134
143
  useEffect(() => {
135
- const server = http.createServer((req, res) => {
136
- res.setHeader("Access-Control-Allow-Origin", "*");
137
- res.setHeader("Access-Control-Request-Method", "*");
138
- res.setHeader("Access-Control-Allow-Methods", "OPTIONS, GET");
139
- res.setHeader("Access-Control-Allow-Headers", "*");
140
- if (req.method === "OPTIONS") {
141
- res.writeHead(200);
142
- res.end();
144
+ async function validateFormat() {
145
+ if (!props.file || format) {
143
146
  return;
144
147
  }
145
- res.writeHead(200, { "Content-Type": "application/json" });
146
- res.end(JSON.stringify({ value: ref.current }));
148
+ setFormat(await guessWorkerFormat(props.file, props.format));
149
+ }
150
+ validateFormat().catch((err) => {
151
+ console.error("Failed to validate worker format:", err);
147
152
  });
148
-
149
- server.listen(3142);
150
- return () => {
151
- server.close();
152
- };
153
- }, []);
153
+ }, [props.file, props.format, format]);
154
+ return format;
154
155
  }
155
156
 
156
157
  function Remote(props: {
157
- name: void | string;
158
- bundle: EsbuildBundle | void;
159
- format: CfScriptFormat;
160
- public: void | string;
161
- site: void | string;
158
+ name: undefined | string;
159
+ bundle: EsbuildBundle | undefined;
160
+ format: CfScriptFormat | undefined;
161
+ public: undefined | string;
162
+ assetPaths: undefined | AssetPaths;
162
163
  port: number;
163
- accountId: void | string;
164
- apiToken: void | string;
165
- variables: { [name: string]: CfVariable };
166
- compatibilityDate: string | void;
167
- compatibilityFlags: void | string[];
168
- usageModel: void | "bundled" | "unbound";
164
+ accountId: undefined | string;
165
+ apiToken: undefined | string;
166
+ bindings: CfWorkerInit["bindings"];
167
+ compatibilityDate: string | undefined;
168
+ compatibilityFlags: undefined | string[];
169
+ usageModel: undefined | "bundled" | "unbound";
170
+ env: string | undefined;
169
171
  }) {
170
172
  assert(props.accountId, "accountId is required");
171
173
  assert(props.apiToken, "apiToken is required");
172
- const token = useWorker({
174
+ const previewToken = useWorker({
173
175
  name: props.name,
174
176
  bundle: props.bundle,
175
177
  format: props.format,
176
- modules: [],
178
+ modules: props.bundle ? props.bundle.modules : [],
177
179
  accountId: props.accountId,
178
180
  apiToken: props.apiToken,
179
- variables: props.variables,
180
- sitesFolder: props.site,
181
+ bindings: props.bindings,
182
+ assetPaths: props.assetPaths,
181
183
  port: props.port,
182
184
  compatibilityDate: props.compatibilityDate,
183
185
  compatibilityFlags: props.compatibilityFlags,
184
186
  usageModel: props.usageModel,
187
+ env: props.env,
185
188
  });
186
189
 
187
- useProxy({ token, publicRoot: props.public, port: props.port });
190
+ usePreviewServer({
191
+ previewToken,
192
+ publicRoot: props.public,
193
+ port: props.port,
194
+ });
188
195
 
189
- useInspector(token ? token.inspectorUrl.href : undefined);
196
+ useInspector({
197
+ inspectorUrl: previewToken ? previewToken.inspectorUrl.href : undefined,
198
+ port: 9229,
199
+ logToTerminal: true,
200
+ });
190
201
  return null;
191
202
  }
192
203
  function Local(props: {
193
- name: void | string;
194
- bundle: EsbuildBundle | void;
195
- format: CfScriptFormat;
196
- variables: { [name: string]: CfVariable };
197
- public: void | string;
198
- site: void | string;
204
+ name: undefined | string;
205
+ bundle: EsbuildBundle | undefined;
206
+ format: CfScriptFormat | undefined;
207
+ bindings: CfWorkerInit["bindings"];
208
+ assetPaths: undefined | AssetPaths;
209
+ public: undefined | string;
199
210
  port: number;
211
+ enableLocalPersistence: boolean;
200
212
  }) {
201
213
  const { inspectorUrl } = useLocalWorker({
202
214
  name: props.name,
203
215
  bundle: props.bundle,
204
216
  format: props.format,
205
- variables: props.variables,
217
+ bindings: props.bindings,
218
+ assetPaths: props.assetPaths,
219
+ public: props.public,
206
220
  port: props.port,
221
+ enableLocalPersistence: props.enableLocalPersistence,
207
222
  });
208
- useInspector(inspectorUrl);
223
+ useInspector({ inspectorUrl, port: 9229, logToTerminal: false });
209
224
  return null;
210
225
  }
211
226
 
212
227
  function useLocalWorker(props: {
213
- name: void | string;
214
- bundle: EsbuildBundle | void;
215
- format: CfScriptFormat;
216
- variables: { [name: string]: CfVariable };
228
+ name: undefined | string;
229
+ bundle: EsbuildBundle | undefined;
230
+ format: CfScriptFormat | undefined;
231
+ bindings: CfWorkerInit["bindings"];
232
+ assetPaths: undefined | AssetPaths;
233
+ public: undefined | string;
217
234
  port: number;
235
+ enableLocalPersistence: boolean;
218
236
  }) {
219
237
  // TODO: pass vars via command line
220
- const { bundle, format, variables, port } = props;
238
+ const { bundle, format, bindings, port, assetPaths } = props;
221
239
  const local = useRef<ReturnType<typeof spawn>>();
222
240
  const removeSignalExitListener = useRef<() => void>();
223
- const [inspectorUrl, setInspectorUrl] = useState<string | void>();
241
+ const [inspectorUrl, setInspectorUrl] = useState<string | undefined>();
224
242
  useEffect(() => {
225
243
  async function startLocalWorker() {
226
- if (!bundle) return;
227
- if (format === "modules" && bundle.type === "commonjs") {
228
- console.error("⎔ Cannot use modules with a commonjs bundle.");
229
- // TODO: a much better error message here, with what to do next
230
- return;
244
+ if (!bundle || !format) return;
245
+
246
+ await waitForPortToBeAvailable(port, { retryPeriod: 200, timeout: 2000 });
247
+ if (props.public) {
248
+ throw new Error(
249
+ '⎔ A "public" folder is not yet supported in local mode.'
250
+ );
231
251
  }
232
- if (format === "service-worker" && bundle.type !== "esm") {
233
- console.error("⎔ Cannot use service-worker with a esm bundle.");
234
- // TODO: a much better error message here, with what to do next
235
- return;
252
+
253
+ // In local mode, we want to copy all referenced modules into
254
+ // the output bundle directory before starting up
255
+ for (const module of bundle.modules) {
256
+ await writeFile(
257
+ path.join(path.dirname(bundle.path), module.name),
258
+ module.content
259
+ );
236
260
  }
237
261
 
238
262
  console.log("⎔ Starting a local server...");
239
- local.current = spawn("node", [
240
- "--experimental-vm-modules",
241
- "--inspect",
242
- require.resolve("miniflare/cli"),
243
- bundle.path,
244
- "--watch",
245
- "--wrangler-config",
246
- path.join(__dirname, "../miniflare-config-stubs/wrangler.empty.toml"),
247
- "--env",
248
- path.join(__dirname, "../miniflare-config-stubs/.env.empty"),
249
- "--package",
250
- path.join(__dirname, "../miniflare-config-stubs/package.empty.json"),
251
- "--port",
252
- port.toString(),
253
- "--kv-persist",
254
- "--cache-persist",
255
- "--do-persist",
256
- ...Object.entries(variables)
257
- .map(([varKey, varVal]) => {
258
- if (typeof varVal === "string") {
259
- return `--binding ${varKey}=${varVal}`;
260
- } else if (
261
- "namespaceId" in varVal &&
262
- typeof varVal.namespaceId === "string"
263
- ) {
264
- return `--kv ${varKey}`;
265
- } else if ("class_name" in varVal) {
266
- return `--do ${varKey}=${varVal.class_name}`;
263
+ // TODO: just use execa for this
264
+ local.current = spawn(
265
+ "node",
266
+ [
267
+ "--experimental-vm-modules",
268
+ "--inspect",
269
+ require.resolve("miniflare/cli"),
270
+ bundle.path,
271
+ "--watch",
272
+ "--wrangler-config",
273
+ path.join(__dirname, "../miniflare-config-stubs/wrangler.empty.toml"),
274
+ "--env",
275
+ path.join(__dirname, "../miniflare-config-stubs/.env.empty"),
276
+ "--package",
277
+ path.join(__dirname, "../miniflare-config-stubs/package.empty.json"),
278
+ "--port",
279
+ port.toString(),
280
+ ...(assetPaths
281
+ ? [
282
+ "--site",
283
+ path.join(process.cwd(), assetPaths.baseDirectory),
284
+ ...assetPaths.includePatterns.map((pattern) => [
285
+ "--site-include",
286
+ pattern,
287
+ ]),
288
+ ...assetPaths.excludePatterns.map((pattern) => [
289
+ "--site-exclude",
290
+ pattern,
291
+ ]),
292
+ ].flatMap((x) => x)
293
+ : []),
294
+ ...(props.enableLocalPersistence
295
+ ? ["--kv-persist", "--cache-persist", "--do-persist"]
296
+ : []),
297
+ ...Object.entries(bindings.vars || {}).flatMap(([key, value]) => {
298
+ return ["--binding", `${key}=${value}`];
299
+ }),
300
+ ...(bindings.kv_namespaces || []).flatMap(({ binding }) => {
301
+ return ["--kv", binding];
302
+ }),
303
+ ...(bindings.durable_objects?.bindings || []).flatMap(
304
+ ({ name, class_name }) => {
305
+ return ["--do", `${name}=${class_name}`];
267
306
  }
268
- })
269
- .filter(Boolean),
270
- "--modules",
271
- format ||
272
- (bundle.type === "esm" ? "modules" : "service-worker") === "modules"
273
- ? "true"
274
- : "false",
275
- ]);
307
+ ),
308
+ ...Object.entries(bindings.wasm_modules || {}).flatMap(
309
+ ([name, filePath]) => {
310
+ return [
311
+ "--wasm",
312
+ `${name}=${path.join(process.cwd(), filePath)}`,
313
+ ];
314
+ }
315
+ ),
316
+ "--modules",
317
+ String(format === "modules"),
318
+ "--modules-rule",
319
+ "CompiledWasm=**/*.wasm",
320
+ ],
321
+ {
322
+ cwd: path.dirname(bundle.path),
323
+ }
324
+ );
276
325
  console.log(`⬣ Listening at http://localhost:${port}`);
277
326
 
278
327
  local.current.on("close", (code) => {
@@ -281,21 +330,32 @@ function useLocalWorker(props: {
281
330
  }
282
331
  });
283
332
 
284
- local.current.stdout.on("data", (_data: string) => {
285
- // console.log(`stdout: ${data}`);
333
+ local.current.stdout?.on("data", (data: Buffer) => {
334
+ console.log(`${data.toString()}`);
286
335
  });
287
336
 
288
- local.current.stderr.on("data", (data: string) => {
289
- // console.error(`stderr: ${data}`);
337
+ local.current.stderr?.on("data", (data: Buffer) => {
338
+ console.error(`${data.toString()}`);
290
339
  const matches =
291
340
  /Debugger listening on (ws:\/\/127\.0\.0\.1:9229\/[A-Za-z0-9-]+)/.exec(
292
- data
341
+ data.toString()
293
342
  );
294
343
  if (matches) {
295
344
  setInspectorUrl(matches[1]);
296
345
  }
297
346
  });
298
347
 
348
+ local.current.on("exit", (code) => {
349
+ if (code !== 0) {
350
+ console.error(`miniflare process exited with code ${code}`);
351
+ }
352
+ });
353
+
354
+ local.current.on("error", (error: Error) => {
355
+ console.error(`miniflare process failed to spawn`);
356
+ console.error(error);
357
+ });
358
+
299
359
  removeSignalExitListener.current = onExit((_code, _signal) => {
300
360
  console.log("⎔ Shutting down local server.");
301
361
  local.current?.kill();
@@ -303,7 +363,9 @@ function useLocalWorker(props: {
303
363
  });
304
364
  }
305
365
 
306
- startLocalWorker();
366
+ startLocalWorker().catch((err) => {
367
+ console.error("local worker:", err);
368
+ });
307
369
 
308
370
  return () => {
309
371
  if (local.current) {
@@ -314,12 +376,24 @@ function useLocalWorker(props: {
314
376
  removeSignalExitListener.current = undefined;
315
377
  }
316
378
  };
317
- }, [bundle, format, port]);
379
+ }, [
380
+ bundle,
381
+ format,
382
+ port,
383
+ bindings.durable_objects?.bindings,
384
+ bindings.kv_namespaces,
385
+ bindings.vars,
386
+ props.enableLocalPersistence,
387
+ assetPaths,
388
+ props.public,
389
+ bindings.wasm_modules,
390
+ ]);
318
391
  return { inspectorUrl };
319
392
  }
320
393
 
321
- function useTmpDir(): string | void {
394
+ function useTmpDir(): string | undefined {
322
395
  const [directory, setDirectory] = useState<DirectoryResult>();
396
+ const handleError = useErrorHandler();
323
397
  useEffect(() => {
324
398
  let dir: DirectoryResult;
325
399
  async function create() {
@@ -329,52 +403,120 @@ function useTmpDir(): string | void {
329
403
  return;
330
404
  } catch (err) {
331
405
  console.error("failed to create tmp dir");
332
- console.error(err);
333
406
  throw err;
334
407
  }
335
408
  }
336
- create();
409
+ create().catch((err) => {
410
+ // we want to break here
411
+ // we can't do much without a temp dir anyway
412
+ handleError(err);
413
+ });
337
414
  return () => {
338
- dir.cleanup();
415
+ dir.cleanup().catch(() => {
416
+ // extremely unlikely,
417
+ // but it's 2022 after all
418
+ console.error("failed to cleanup tmp dir");
419
+ });
339
420
  };
340
- }, []);
421
+ }, [handleError]);
341
422
  return directory?.path;
342
423
  }
343
424
 
425
+ function useCustomBuild(
426
+ expectedEntry: string,
427
+ props: {
428
+ command?: undefined | string;
429
+ cwd?: undefined | string;
430
+ watch_dir?: undefined | string;
431
+ }
432
+ ): undefined | string {
433
+ const [entry, setEntry] = useState<string | undefined>(
434
+ // if there's no build command, just return the expected entry
435
+ props.command || expectedEntry
436
+ );
437
+ const { command, cwd, watch_dir } = props;
438
+ useEffect(() => {
439
+ if (!command) return;
440
+ let cmd, interval;
441
+ console.log("running:", command);
442
+ cmd = execaCommand(command, {
443
+ ...(cwd && { cwd }),
444
+ shell: true,
445
+ stderr: "inherit",
446
+ stdout: "inherit",
447
+ });
448
+ if (watch_dir) {
449
+ watch(watch_dir, { persistent: true, ignoreInitial: true }).on(
450
+ "all",
451
+ (_event, filePath) => {
452
+ console.log(`The file ${filePath} changed, restarting build...`);
453
+ cmd.kill();
454
+ cmd = execaCommand(command, {
455
+ ...(cwd && { cwd }),
456
+ shell: true,
457
+ stderr: "inherit",
458
+ stdout: "inherit",
459
+ });
460
+ }
461
+ );
462
+ }
463
+
464
+ // check every so often whether `expectedEntry` exists
465
+ // if it does, we're done
466
+ const startedAt = Date.now();
467
+ interval = setInterval(() => {
468
+ if (existsSync(expectedEntry)) {
469
+ clearInterval(interval);
470
+ setEntry(expectedEntry);
471
+ } else {
472
+ const elapsed = Date.now() - startedAt;
473
+ // timeout after 30 seconds of waiting
474
+ if (elapsed > 1000 * 60 * 30) {
475
+ console.error("⎔ Build timed out.");
476
+ clearInterval(interval);
477
+ cmd.kill();
478
+ }
479
+ }
480
+ }, 200);
481
+ // TODO: we could probably timeout here after a while
482
+
483
+ return () => {
484
+ if (cmd) {
485
+ cmd.kill();
486
+ cmd = undefined;
487
+ }
488
+ clearInterval(interval);
489
+ interval = undefined;
490
+ };
491
+ }, [command, cwd, expectedEntry, watch_dir]);
492
+ return entry;
493
+ }
494
+
344
495
  type EsbuildBundle = {
345
496
  id: number;
346
497
  path: string;
347
498
  entry: string;
348
499
  type: "esm" | "commonjs";
349
500
  exports: string[];
501
+ modules: CfModule[];
350
502
  };
351
503
 
352
504
  function useEsbuild(props: {
353
- entry: string;
354
- destination: string | void;
355
- staticRoot: void | string;
356
- jsxFactory: string | void;
357
- jsxFragment: string | void;
358
- polyfillNode: boolean | void;
359
- }): EsbuildBundle | void {
360
- const {
361
- entry,
362
- destination,
363
- staticRoot,
364
- jsxFactory,
365
- jsxFragment,
366
- polyfillNode,
367
- } = props;
505
+ entry: undefined | string;
506
+ destination: string | undefined;
507
+ staticRoot: undefined | string;
508
+ jsxFactory: string | undefined;
509
+ jsxFragment: string | undefined;
510
+ }): EsbuildBundle | undefined {
511
+ const { entry, destination, staticRoot, jsxFactory, jsxFragment } = props;
368
512
  const [bundle, setBundle] = useState<EsbuildBundle>();
369
513
  useEffect(() => {
370
- let result: esbuild.BuildResult;
514
+ let result: esbuild.BuildResult | undefined;
371
515
  async function build() {
372
- if (!destination) return;
516
+ if (!destination || !entry) return;
517
+ const moduleCollector = makeModuleCollector();
373
518
  result = await esbuild.build({
374
519
  entryPoints: [entry],
375
- define: {
376
- ...(polyfillNode && { global: "globalThis" }),
377
- },
378
520
  bundle: true,
379
521
  outdir: destination,
380
522
  metafile: true,
@@ -382,13 +524,15 @@ function useEsbuild(props: {
382
524
  sourcemap: true,
383
525
  loader: {
384
526
  ".js": "jsx",
527
+ ".html": "text",
528
+ ".pem": "text",
529
+ ".txt": "text",
385
530
  },
386
- plugins: polyfillNode
387
- ? [NodeGlobalsPolyfills({ buffer: true }), NodeModulesPolyfills()]
388
- : undefined,
389
531
  ...(jsxFactory && { jsxFactory }),
390
532
  ...(jsxFragment && { jsxFragment }),
391
533
  external: ["__STATIC_CONTENT_MANIFEST"],
534
+ conditions: ["worker", "browser"],
535
+ plugins: [moduleCollector.plugin],
392
536
  // TODO: import.meta.url
393
537
  watch: {
394
538
  async onRebuild(error) {
@@ -396,46 +540,68 @@ function useEsbuild(props: {
396
540
  else {
397
541
  // nothing really changes here, so let's increment the id
398
542
  // to change the return object's identity
399
- setBundle((bundle) => ({ ...bundle, id: bundle.id + 1 }));
543
+ setBundle((previousBundle) => {
544
+ if (previousBundle === undefined) {
545
+ assert.fail(
546
+ "Rebuild triggered with no previous build available"
547
+ );
548
+ }
549
+ return { ...previousBundle, id: previousBundle.id + 1 };
550
+ });
400
551
  }
401
552
  },
402
553
  },
403
554
  });
404
555
 
405
- const chunks = Object.entries(result.metafile.outputs).find(
556
+ // result.metafile is defined because of the `metafile: true` option above.
557
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
558
+ const metafile = result.metafile!;
559
+ const outputEntry = Object.entries(metafile.outputs).find(
406
560
  ([_path, { entryPoint }]) => entryPoint === entry
407
561
  ); // assumedly only one entry point
408
562
 
563
+ if (outputEntry === undefined) {
564
+ throw new Error(
565
+ `Cannot find entry-point "${entry}" in generated bundle.`
566
+ );
567
+ }
409
568
  setBundle({
410
569
  id: 0,
411
570
  entry,
412
- path: chunks[0],
413
- type: chunks[1].exports.length > 0 ? "esm" : "commonjs",
414
- exports: chunks[1].exports,
571
+ path: outputEntry[0],
572
+ type: outputEntry[1].exports.length > 0 ? "esm" : "commonjs",
573
+ exports: outputEntry[1].exports,
574
+ modules: moduleCollector.modules,
415
575
  });
416
576
  }
417
- build();
577
+ build().catch((_err) => {
578
+ // esbuild already logs errors to stderr
579
+ // and we don't want to end the process
580
+ // on build errors anyway
581
+ // so this is a no-op error handler
582
+ });
418
583
  return () => {
419
- result?.stop();
584
+ result?.stop?.();
420
585
  };
421
- }, [entry, destination, staticRoot, jsxFactory, jsxFragment, polyfillNode]);
586
+ }, [entry, destination, staticRoot, jsxFactory, jsxFragment]);
422
587
  return bundle;
423
588
  }
424
589
 
425
590
  function useWorker(props: {
426
- name: void | string;
427
- bundle: EsbuildBundle | void;
428
- format: CfScriptFormat;
591
+ name: undefined | string;
592
+ bundle: EsbuildBundle | undefined;
593
+ format: CfScriptFormat | undefined;
429
594
  modules: CfModule[];
430
595
  accountId: string;
431
596
  apiToken: string;
432
- variables: { [name: string]: CfVariable };
433
- sitesFolder: void | string;
597
+ bindings: CfWorkerInit["bindings"];
598
+ assetPaths: undefined | AssetPaths;
434
599
  port: number;
435
- compatibilityDate: string | void;
436
- compatibilityFlags: string[] | void;
437
- usageModel: void | "bundled" | "unbound";
438
- }): CfPreviewToken | void {
600
+ compatibilityDate: string | undefined;
601
+ compatibilityFlags: string[] | undefined;
602
+ usageModel: undefined | "bundled" | "unbound";
603
+ env: string | undefined;
604
+ }): CfPreviewToken | undefined {
439
605
  const {
440
606
  name,
441
607
  bundle,
@@ -443,68 +609,71 @@ function useWorker(props: {
443
609
  modules,
444
610
  accountId,
445
611
  apiToken,
446
- variables,
447
- sitesFolder,
612
+ bindings,
613
+ assetPaths,
448
614
  compatibilityDate,
449
615
  compatibilityFlags,
450
616
  usageModel,
451
617
  port,
452
618
  } = props;
453
- const [token, setToken] = useState<CfPreviewToken>();
619
+ const [token, setToken] = useState<CfPreviewToken | undefined>();
620
+
621
+ // This is the most reliable way to detect whether
622
+ // something's "happened" in our system; We make a ref and
623
+ // mark it once we log our initial message. Refs are vars!
624
+ const startedRef = useRef(false);
625
+
454
626
  useEffect(() => {
455
627
  async function start() {
456
- if (!bundle) return;
457
- if (format === "modules" && bundle.type === "commonjs") {
458
- console.error("⎔ Cannot use modules with a commonjs bundle.");
459
- // TODO: a much better error message here, with what to do next
460
- return;
461
- }
462
- if (format === "service-worker" && bundle.type !== "esm") {
463
- console.error("⎔ Cannot use service-worker with a esm bundle.");
464
- // TODO: a much better error message here, with what to do next
465
- return;
466
- }
628
+ setToken(undefined); // reset token in case we're re-running
629
+
630
+ if (!bundle || !format) return;
467
631
 
468
- if (token) {
469
- console.log("⎔ Detected changes, restarting server...");
632
+ if (!startedRef.current) {
633
+ startedRef.current = true;
470
634
  } else {
471
- console.log("⎔ Starting server...");
635
+ console.log("⎔ Detected changes, restarted server.");
636
+ }
637
+
638
+ const assets = await syncAssets(
639
+ accountId,
640
+ name || path.basename(bundle.path),
641
+ assetPaths,
642
+ true,
643
+ props.env
644
+ ); // TODO: cancellable?
645
+
646
+ let content = await readFile(bundle.path, "utf-8");
647
+ if (format === "service-worker" && assets.manifest) {
648
+ content = `const __STATIC_CONTENT_MANIFEST = ${JSON.stringify(
649
+ assets.manifest
650
+ )};\n${content}`;
472
651
  }
473
652
 
474
- const assets = sitesFolder
475
- ? await syncAssets(
476
- accountId,
477
- path.basename(bundle.path),
478
- sitesFolder,
479
- true,
480
- undefined // TODO: env
481
- )
482
- : {
483
- manifest: undefined,
484
- namespace: undefined,
485
- }; // TODO: cancellable?
486
-
487
- const content = await readFile(bundle.path, "utf-8");
488
653
  const init: CfWorkerInit = {
489
- name: name || path.basename(bundle.path),
654
+ name,
490
655
  main: {
491
656
  name: path.basename(bundle.path),
492
- type: format || bundle.type === "esm" ? "esm" : "commonjs",
657
+ type: format === "modules" ? "esm" : "commonjs",
493
658
  content,
494
659
  },
495
- modules: assets.manifest
496
- ? modules.concat({
497
- name: "__STATIC_CONTENT_MANIFEST",
498
- content: JSON.stringify(assets.manifest),
499
- type: "text",
500
- })
501
- : modules,
502
- variables: assets.namespace
503
- ? {
504
- ...variables,
505
- __STATIC_CONTENT: { namespaceId: assets.namespace },
506
- }
507
- : variables,
660
+ modules: modules.concat(
661
+ assets.manifest && format === "modules"
662
+ ? {
663
+ name: "__STATIC_CONTENT_MANIFEST",
664
+ content: JSON.stringify(assets.manifest),
665
+ type: "text",
666
+ }
667
+ : []
668
+ ),
669
+ bindings: {
670
+ ...bindings,
671
+ kv_namespaces: (bindings.kv_namespaces || []).concat(
672
+ assets.namespace
673
+ ? { binding: "__STATIC_CONTENT", id: assets.namespace }
674
+ : []
675
+ ),
676
+ },
508
677
  migrations: undefined, // no migrations in dev
509
678
  compatibility_date: compatibilityDate,
510
679
  compatibility_flags: compatibilityFlags,
@@ -516,9 +685,12 @@ function useWorker(props: {
516
685
  apiToken,
517
686
  })
518
687
  );
519
- console.log(`⬣ Listening at http://localhost:${port}`);
520
688
  }
521
- start();
689
+ start().catch((err) => {
690
+ // we want to log the error, but not end the process
691
+ // since it could recover after the developer fixes whatever's wrong
692
+ console.error("remote worker:", err);
693
+ });
522
694
  }, [
523
695
  name,
524
696
  bundle,
@@ -526,83 +698,17 @@ function useWorker(props: {
526
698
  accountId,
527
699
  apiToken,
528
700
  port,
529
- sitesFolder,
701
+ assetPaths,
530
702
  compatibilityDate,
531
703
  compatibilityFlags,
532
704
  usageModel,
705
+ bindings,
706
+ modules,
707
+ props.env,
533
708
  ]);
534
709
  return token;
535
710
  }
536
711
 
537
- function useProxy({
538
- token,
539
- publicRoot,
540
- port,
541
- }: {
542
- token: CfPreviewToken | void;
543
- publicRoot: void | string;
544
- port: number;
545
- }) {
546
- useEffect(() => {
547
- if (!token) return;
548
- const proxy = httpProxy.createProxyServer({
549
- secure: false,
550
- changeOrigin: true,
551
- headers: {
552
- "cf-workers-preview-token": token.value,
553
- },
554
- target: `https://${token.host}`,
555
- // TODO: log websockets too? validate durables, etc
556
- });
557
-
558
- const servePublic =
559
- publicRoot &&
560
- serveStatic(publicRoot, {
561
- cacheControl: false,
562
- });
563
- const server = http
564
- .createServer((req, res) => {
565
- if (publicRoot) {
566
- servePublic(req, res, () => {
567
- proxy.web(req, res);
568
- });
569
- } else {
570
- proxy.web(req, res);
571
- }
572
- })
573
- .listen(port); // TODO: custom port
574
-
575
- proxy.on("proxyRes", function (proxyRes, req, res) {
576
- // log all requests
577
- console.log(
578
- new Date().toLocaleTimeString(),
579
- req.method,
580
- req.url,
581
- res.statusCode // TODO add a status message like Ok etc?
582
- );
583
- });
584
- // TODO: log errors?
585
-
586
- return () => {
587
- proxy.close();
588
- server.close();
589
- };
590
- }, [token, publicRoot, port]);
591
- }
592
-
593
- function useInspector(inspectorUrl: string | void) {
594
- useEffect(() => {
595
- if (!inspectorUrl) return;
596
-
597
- const inspector = new DtInspector(inspectorUrl);
598
- const abortController = inspector.proxyTo(9229);
599
- return () => {
600
- inspector.close();
601
- abortController.abort();
602
- };
603
- }, [inspectorUrl]);
604
- }
605
-
606
712
  function sleep(period: number) {
607
713
  return new Promise((resolve) => setTimeout(resolve, period));
608
714
  }
@@ -610,7 +716,7 @@ const SLEEP_DURATION = 2000;
610
716
  // really need a first class api for this
611
717
  const hostNameRegex = /userHostname="(.*)"/g;
612
718
  async function findTunnelHostname() {
613
- let hostName: string;
719
+ let hostName: string | undefined;
614
720
  while (!hostName) {
615
721
  try {
616
722
  const resp = await fetch("http://localhost:8789/metrics");
@@ -662,12 +768,14 @@ function useTunnel(toggle: boolean) {
662
768
  });
663
769
 
664
770
  const hostName = await findTunnelHostname();
665
- clipboardy.write(hostName);
771
+ await clipboardy.write(hostName);
666
772
  console.log(`⬣ Sharing at ${hostName}, copied to clipboard.`);
667
773
  }
668
774
  }
669
775
 
670
- startTunnel();
776
+ startTunnel().catch((err) => {
777
+ console.error("tunnel:", err);
778
+ });
671
779
 
672
780
  return () => {
673
781
  if (tunnel.current) {
@@ -689,41 +797,41 @@ function useHotkeys(initial: useHotkeysInitialState, port: number) {
689
797
  // UGH, we should put port in context instead
690
798
  const [toggles, setToggles] = useState(initial);
691
799
  useInput(
692
- (
800
+ async (
693
801
  input,
694
802
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
695
803
  key
696
804
  ) => {
697
- switch (input) {
698
- case "b": // open browser
699
- open(
700
- `http://localhost:${port}/`
701
- // {
702
- // app: {
703
- // name: open.apps.chrome, // TODO: fallback on other browsers
704
- // },
705
- // }
706
- );
805
+ switch (input.toLowerCase()) {
806
+ // open browser
807
+ case "b": {
808
+ await openInBrowser(`http://localhost:${port}`);
707
809
  break;
708
- case "d": // toggle inspector
709
- open(
810
+ }
811
+ // toggle inspector
812
+ case "d": {
813
+ await openInBrowser(
710
814
  `https://built-devtools.pages.dev/js_app?experiments=true&v8only=true&ws=localhost:9229/ws`
711
- // {
712
- // app: {
713
- // name: open.apps.chrome,
714
- // // todo - add firefox and edge fallbacks
715
- // },
716
- // }
717
815
  );
718
816
  break;
719
- case "s": // toggle tunnel
720
- setToggles((toggles) => ({ ...toggles, tunnel: !toggles.tunnel }));
817
+ }
818
+ // toggle tunnel
819
+ case "s":
820
+ setToggles((previousToggles) => ({
821
+ ...previousToggles,
822
+ tunnel: !previousToggles.tunnel,
823
+ }));
721
824
  break;
722
- case "l": // toggle local
723
- setToggles((toggles) => ({ ...toggles, local: !toggles.local }));
825
+ // toggle local
826
+ case "l":
827
+ setToggles((previousToggles) => ({
828
+ ...previousToggles,
829
+ local: !previousToggles.local,
830
+ }));
724
831
  break;
725
- case "q": // shut down
726
- case "x": // shut down
832
+ // shut down
833
+ case "q":
834
+ case "x":
727
835
  process.exit(0);
728
836
  break;
729
837
  default:
@@ -734,3 +842,16 @@ function useHotkeys(initial: useHotkeysInitialState, port: number) {
734
842
  );
735
843
  return toggles;
736
844
  }
845
+
846
+ function ErrorFallback(props: { error: Error }) {
847
+ const { exit } = useApp();
848
+ useEffect(() => exit(new Error()));
849
+ return (
850
+ <>
851
+ <Text>Something went wrong:</Text>
852
+ <Text>{props.error.stack}</Text>
853
+ </>
854
+ );
855
+ }
856
+
857
+ export default withErrorBoundary(Dev, { FallbackComponent: ErrorFallback });