wrangler 0.0.0-e1c615f → 0.0.0-e5d3690

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.
package/src/publish.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import assert from "node:assert";
2
2
  import path from "node:path";
3
3
  import { readFile } from "node:fs/promises";
4
- import esbuild from "esbuild";
4
+ import * as esbuild from "esbuild";
5
+ import type { Metafile } from "esbuild";
5
6
  import { execa } from "execa";
6
7
  import tmp from "tmp-promise";
7
8
  import type { CfWorkerInit } from "./api/worker";
@@ -9,23 +10,26 @@ import { toFormData } from "./api/form_data";
9
10
  import { fetchResult } from "./cfetch";
10
11
  import type { Config } from "./config";
11
12
  import makeModuleCollector from "./module-collection";
13
+ import type { AssetPaths } from "./sites";
12
14
  import { syncAssets } from "./sites";
13
15
 
14
- type CfScriptFormat = void | "modules" | "service-worker";
16
+ type CfScriptFormat = undefined | "modules" | "service-worker";
15
17
 
16
18
  type Props = {
17
19
  config: Config;
18
- format?: CfScriptFormat;
19
- script?: string;
20
- name?: string;
21
- env?: string;
22
- public?: string;
23
- site?: string;
24
- triggers?: (string | number)[];
25
- routes?: (string | number)[];
26
- legacyEnv?: boolean;
27
- jsxFactory: void | string;
28
- jsxFragment: void | string;
20
+ format: CfScriptFormat | undefined;
21
+ script: string | undefined;
22
+ name: string | undefined;
23
+ env: string | undefined;
24
+ compatibilityDate: string | undefined;
25
+ compatibilityFlags: string[] | undefined;
26
+ assetPaths: AssetPaths | undefined;
27
+ triggers: (string | number)[] | undefined;
28
+ routes: (string | number)[] | undefined;
29
+ legacyEnv: boolean | undefined;
30
+ jsxFactory: undefined | string;
31
+ jsxFragment: undefined | string;
32
+ experimentalPublic: boolean;
29
33
  };
30
34
 
31
35
  function sleep(ms: number) {
@@ -33,10 +37,10 @@ function sleep(ms: number) {
33
37
  }
34
38
 
35
39
  export default async function publish(props: Props): Promise<void> {
36
- if (props.public && props.format === "service-worker") {
40
+ if (props.experimentalPublic && props.format === "service-worker") {
37
41
  // TODO: check config too
38
42
  throw new Error(
39
- "You cannot use the service worker format with a public directory."
43
+ "You cannot publish in the service worker format with a public directory."
40
44
  );
41
45
  }
42
46
  // TODO: warn if git/hg has uncommitted changes
@@ -48,6 +52,18 @@ export default async function publish(props: Props): Promise<void> {
48
52
  __path__,
49
53
  } = config;
50
54
 
55
+ const envRootObj =
56
+ props.env && config.env ? config.env[props.env] || {} : config;
57
+
58
+ assert(
59
+ envRootObj.compatibility_date || props.compatibilityDate,
60
+ "A compatibility_date is required when publishing. Add one to your wrangler.toml file, or pass it in your terminal as --compatibility_date. See https://developers.cloudflare.com/workers/platform/compatibility-dates for more information."
61
+ );
62
+
63
+ if (accountId === undefined) {
64
+ throw new Error("No account_id provided.");
65
+ }
66
+
51
67
  const triggers = props.triggers || config.triggers?.crons;
52
68
  const routes = props.routes || config.routes;
53
69
 
@@ -64,10 +80,12 @@ export default async function publish(props: Props): Promise<void> {
64
80
 
65
81
  let file: string;
66
82
  if (props.script) {
67
- file = props.script;
83
+ // If the script name comes from the command line it is relative to the current working directory.
84
+ file = path.resolve(props.script);
68
85
  } else {
86
+ // If the script name comes from the config, then it is relative to the wrangler.toml file.
69
87
  assert(build?.upload?.main, "missing main file");
70
- file = path.join(path.dirname(__path__), build.upload.main);
88
+ file = path.resolve(path.dirname(__path__), build.upload.main);
71
89
  }
72
90
 
73
91
  if (props.legacyEnv) {
@@ -76,7 +94,6 @@ export default async function publish(props: Props): Promise<void> {
76
94
  const envName = props.env ?? "production";
77
95
 
78
96
  const destination = await tmp.dir({ unsafeCleanup: true });
79
-
80
97
  if (props.config.build?.command) {
81
98
  // TODO: add a deprecation message here?
82
99
  console.log("running:", props.config.build.command);
@@ -90,7 +107,7 @@ export default async function publish(props: Props): Promise<void> {
90
107
 
91
108
  const moduleCollector = makeModuleCollector();
92
109
  const result = await esbuild.build({
93
- ...(props.public
110
+ ...(props.experimentalPublic
94
111
  ? {
95
112
  stdin: {
96
113
  contents: (
@@ -98,14 +115,14 @@ export default async function publish(props: Props): Promise<void> {
98
115
  path.join(__dirname, "../static-asset-facade.js"),
99
116
  "utf8"
100
117
  )
101
- ).replace("__ENTRY_POINT__", path.join(process.cwd(), file)),
118
+ ).replace("__ENTRY_POINT__", file),
102
119
  sourcefile: "static-asset-facade.js",
103
120
  resolveDir: path.dirname(file),
104
121
  },
122
+ nodePaths: [path.join(__dirname, "../vendor")],
105
123
  }
106
124
  : { entryPoints: [file] }),
107
125
  bundle: true,
108
- nodePaths: props.public ? [path.join(__dirname, "../vendor")] : undefined,
109
126
  outdir: destination.path,
110
127
  external: ["__STATIC_CONTENT_MANIFEST"],
111
128
  format: "esm",
@@ -120,18 +137,28 @@ export default async function publish(props: Props): Promise<void> {
120
137
  ...(jsxFragment && { jsxFragment }),
121
138
  });
122
139
 
123
- const chunks = Object.entries(result.metafile.outputs).find(
124
- ([_path, { entryPoint }]) =>
125
- entryPoint ===
126
- (props.public
127
- ? path.join(path.dirname(file), "static-asset-facade.js")
128
- : file)
140
+ // result.metafile is defined because of the `metafile: true` option above.
141
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
142
+ const metafile = result.metafile!;
143
+ const entryPoints = Object.entries(metafile.outputs).filter(
144
+ ([_path, output]) => output.entryPoint !== undefined
129
145
  );
130
-
146
+ assert(
147
+ entryPoints.length > 0,
148
+ `Cannot find entry-point "${file}" in generated bundle.` +
149
+ listEntryPoints(entryPoints)
150
+ );
151
+ assert(
152
+ entryPoints.length < 2,
153
+ "More than one entry-point found for generated bundle." +
154
+ listEntryPoints(entryPoints)
155
+ );
156
+ const entryPointExports = entryPoints[0][1].exports;
157
+ const resolvedEntryPointPath = entryPoints[0][0];
131
158
  const { format } = props;
132
159
  const bundle = {
133
- type: chunks[1].exports.length > 0 ? "esm" : "commonjs",
134
- exports: chunks[1].exports,
160
+ type: entryPointExports.length > 0 ? "esm" : "commonjs",
161
+ exports: entryPointExports,
135
162
  };
136
163
 
137
164
  // TODO: instead of bundling the facade with the worker, we should just bundle the worker and expose it as a module.
@@ -148,13 +175,13 @@ export default async function publish(props: Props): Promise<void> {
148
175
  return;
149
176
  }
150
177
 
151
- const content = await readFile(chunks[0], { encoding: "utf-8" });
178
+ const content = await readFile(resolvedEntryPointPath, { encoding: "utf-8" });
152
179
  await destination.cleanup();
153
180
 
154
181
  // if config.migrations
155
182
  // get current migration tag
156
183
  let migrations;
157
- if ("migrations" in config) {
184
+ if (config.migrations !== undefined) {
158
185
  const scripts = await fetchResult<{ id: string; migration_tag: string }[]>(
159
186
  `/accounts/${accountId}/workers/scripts`
160
187
  );
@@ -190,17 +217,13 @@ export default async function publish(props: Props): Promise<void> {
190
217
  }
191
218
  }
192
219
 
193
- const assets =
194
- props.public || props.site || props.config.site?.bucket // TODO: allow both
195
- ? await syncAssets(
196
- accountId,
197
- scriptName,
198
- props.public || props.site || props.config.site?.bucket,
199
- false
200
- )
201
- : { manifest: undefined, namespace: undefined };
202
-
203
- const envRootObj = props.env ? config.env[props.env] || {} : config;
220
+ const assets = await syncAssets(
221
+ accountId,
222
+ scriptName,
223
+ props.assetPaths,
224
+ false
225
+ );
226
+
204
227
  const bindings: CfWorkerInit["bindings"] = {
205
228
  kv_namespaces: envRootObj.kv_namespaces?.concat(
206
229
  assets.namespace
@@ -215,7 +238,7 @@ export default async function publish(props: Props): Promise<void> {
215
238
  const worker: CfWorkerInit = {
216
239
  name: scriptName,
217
240
  main: {
218
- name: path.basename(chunks[0]),
241
+ name: path.basename(resolvedEntryPointPath),
219
242
  content: content,
220
243
  type: bundle.type === "esm" ? "esm" : "commonjs",
221
244
  },
@@ -248,12 +271,13 @@ export default async function publish(props: Props): Promise<void> {
248
271
 
249
272
  // Upload the script so it has time to propagate.
250
273
  const { available_on_subdomain } = await fetchResult(
251
- `${workerUrl}?available_on_subdomain=true`,
274
+ workerUrl,
252
275
  {
253
276
  method: "PUT",
254
277
  // @ts-expect-error: TODO: fix this type error!
255
278
  body: toFormData(worker),
256
- }
279
+ },
280
+ new URLSearchParams({ available_on_subdomains: "true" })
257
281
  );
258
282
 
259
283
  const uploadMs = Date.now() - start;
@@ -348,3 +372,11 @@ export default async function publish(props: Props): Promise<void> {
348
372
  console.log(" ", target);
349
373
  }
350
374
  }
375
+
376
+ function listEntryPoints(
377
+ outputs: [string, ValueOf<Metafile["outputs"]>][]
378
+ ): string {
379
+ return outputs.map(([_input, output]) => output.entryPoint).join("\n");
380
+ }
381
+
382
+ type ValueOf<T> = T[keyof T];
package/src/sites.tsx CHANGED
@@ -1,13 +1,32 @@
1
- import crypto from "node:crypto";
2
- import { createReadStream } from "node:fs";
3
1
  import * as path from "node:path";
4
- import { readdir, readFile } from "node:fs/promises";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import ignore from "ignore";
4
+ import { XXHash64 } from "xxhash-addon";
5
5
  import { fetchResult } from "./cfetch";
6
+ import type { Config } from "./config";
6
7
  import { listNamespaceKeys, listNamespaces, putBulkKeyValue } from "./kv";
7
8
 
9
+ /** Paths to always ignore. */
10
+ const ALWAYS_IGNORE = ["node_modules"];
11
+ const HIDDEN_FILES_TO_INCLUDE = [
12
+ ".well-known", // See https://datatracker.ietf.org/doc/html/rfc8615
13
+ ];
14
+
8
15
  async function* getFilesInFolder(dirPath: string): AsyncIterable<string> {
9
16
  const files = await readdir(dirPath, { withFileTypes: true });
10
17
  for (const file of files) {
18
+ // Skip files that we never want to process.
19
+ if (ALWAYS_IGNORE.some((p) => file.name === p)) {
20
+ continue;
21
+ }
22
+ // Skip hidden files (starting with .) except for some special ones
23
+ if (
24
+ file.name.startsWith(".") &&
25
+ !HIDDEN_FILES_TO_INCLUDE.some((p) => file.name === p)
26
+ ) {
27
+ continue;
28
+ }
29
+ // TODO: follow symlinks??
11
30
  if (file.isDirectory()) {
12
31
  yield* await getFilesInFolder(path.join(dirPath, file.name));
13
32
  } else {
@@ -16,27 +35,32 @@ async function* getFilesInFolder(dirPath: string): AsyncIterable<string> {
16
35
  }
17
36
  }
18
37
 
19
- async function hashFileContent(filePath: string): Promise<string> {
20
- return new Promise((resolve, reject) => {
21
- const hash = crypto.createHash("sha1");
22
- const rs = createReadStream(filePath);
23
- rs.on("error", reject);
24
- rs.on("data", (chunk) => hash.update(chunk));
25
- rs.on("end", () => resolve(hash.digest("hex")));
26
- });
38
+ /**
39
+ * Create a hash key for the given content using the xxhash algorithm.
40
+ *
41
+ * Note we only return the first 10 characters, since we will also include the file name in the asset manifest key
42
+ * the most important thing here is to detect changes of a single file to invalidate the cache and
43
+ * it's impossible to serve two different files with the same name
44
+ */
45
+ function hashFileContent(content: string): string {
46
+ const hasher = new XXHash64();
47
+ hasher.update(Buffer.from(content));
48
+ const hash = hasher.digest();
49
+ return hash.toString("hex").substring(0, 10);
27
50
  }
28
51
 
29
- async function hashFile(filePath: string): Promise<{
30
- filePath: string;
31
- hash: string;
32
- }> {
33
- const extName = path.extname(filePath);
52
+ /**
53
+ * Create a hashed asset key for the given asset.
54
+ *
55
+ * The key will change if the file path or content of the asset changes.
56
+ * The algorithm used here matches that of Wrangler 1.
57
+ */
58
+ function hashAsset(filePath: string, content: string): string {
59
+ const extName = path.extname(filePath) || "";
34
60
  const baseName = path.basename(filePath, extName);
35
- const hash = await hashFileContent(filePath);
36
- return {
37
- filePath: `${baseName}.${hash}${extName || ""}`,
38
- hash,
39
- };
61
+ const directory = path.dirname(filePath);
62
+ const hash = hashFileContent(content);
63
+ return urlSafe(path.join(directory, `${baseName}.${hash}${extName}`));
40
64
  }
41
65
 
42
66
  async function createKVNamespaceIfNotAlreadyExisting(
@@ -70,13 +94,32 @@ async function createKVNamespaceIfNotAlreadyExisting(
70
94
  };
71
95
  }
72
96
 
97
+ /**
98
+ * Upload the assets found within the `dirPath` directory to the sites assets KV namespace for
99
+ * the worker given by `scriptName`.
100
+ *
101
+ * @param accountId the account to upload to.
102
+ * @param scriptName the name of the worker whose assets we are uploading.
103
+ * @param siteAssets an objects describing what assets to upload, or undefined if there are no assets to upload.
104
+ * @param preview if true then upload to a "preview" KV namespace.
105
+ * @param _env (not implemented).
106
+ * @returns a promise for an object mapping the relative paths of the assets to the key of that
107
+ * asset in the KV namespace.
108
+ */
73
109
  export async function syncAssets(
74
110
  accountId: string,
75
111
  scriptName: string,
76
- dirPath: string,
112
+ siteAssets: AssetPaths | undefined,
77
113
  preview: boolean,
78
114
  _env?: string
79
- ) {
115
+ ): Promise<{
116
+ manifest: { [filePath: string]: string } | undefined;
117
+ namespace: string | undefined;
118
+ }> {
119
+ if (siteAssets === undefined) {
120
+ return { manifest: undefined, namespace: undefined };
121
+ }
122
+
80
123
  const title = `__${scriptName}_sites_assets${preview ? "_preview" : ""}`;
81
124
  const { id: namespace } = await createKVNamespaceIfNotAlreadyExisting(
82
125
  title,
@@ -84,31 +127,117 @@ export async function syncAssets(
84
127
  );
85
128
 
86
129
  // let's get all the keys in this namespace
87
- const keys = new Set(
88
- (await listNamespaceKeys(accountId, namespace)).map((x) => x.name)
89
- );
130
+ const result = await listNamespaceKeys(accountId, namespace);
131
+ const keys = new Set(result.map((x) => x.name));
90
132
 
91
133
  const manifest = {};
92
- const upload = [];
134
+ const upload: {
135
+ key: string;
136
+ value: string;
137
+ base64: boolean;
138
+ }[] = [];
139
+
140
+ const include = createPatternMatcher(siteAssets.includePatterns, false);
141
+ const exclude = createPatternMatcher(siteAssets.excludePatterns, true);
93
142
  // TODO: this can be more efficient by parallelising
94
- for await (const file of getFilesInFolder(dirPath)) {
95
- // TODO: "exclude:" config
96
- const { filePath } = await hashFile(file);
143
+ for await (const file of getFilesInFolder(siteAssets.baseDirectory)) {
144
+ if (!include(file)) {
145
+ continue;
146
+ }
147
+ if (exclude(file)) {
148
+ continue;
149
+ }
150
+
151
+ await validateAssetSize(file);
152
+ console.log(`reading ${file}...`);
153
+ const content = await readFile(file, "base64");
154
+
155
+ const assetKey = hashAsset(file, content);
156
+ validateAssetKey(assetKey);
157
+
97
158
  // now put each of the files into kv
98
- if (!keys.has(filePath)) {
99
- console.log(`uploading ${file}...`);
100
- const content = await readFile(file, "base64");
101
- if (content.length > 25 * 1024 * 1024) {
102
- throw new Error(`File ${file} is too big, it should be under 25 mb.`);
103
- }
159
+ if (!keys.has(assetKey)) {
160
+ console.log(`uploading as ${assetKey}...`);
104
161
  upload.push({
105
- key: filePath,
162
+ key: assetKey,
106
163
  value: content,
107
164
  base64: true,
108
165
  });
166
+ } else {
167
+ console.log(`skipping - already uploaded`);
109
168
  }
110
- manifest[path.relative(dirPath, file)] = filePath;
169
+ manifest[path.relative(siteAssets.baseDirectory, file)] = assetKey;
111
170
  }
112
171
  await putBulkKeyValue(accountId, namespace, JSON.stringify(upload));
113
172
  return { manifest, namespace };
114
173
  }
174
+
175
+ function createPatternMatcher(
176
+ patterns: string[],
177
+ exclude: boolean
178
+ ): (filePath: string) => boolean {
179
+ if (patterns.length === 0) {
180
+ return (_filePath) => !exclude;
181
+ } else {
182
+ const ignorer = ignore().add(patterns);
183
+ return (filePath) => ignorer.test(filePath).ignored;
184
+ }
185
+ }
186
+
187
+ async function validateAssetSize(filePath: string) {
188
+ const { size } = await stat(filePath);
189
+ if (size > 25 * 1024 * 1024) {
190
+ throw new Error(
191
+ `File ${filePath} is too big, it should be under 25 MiB. See https://developers.cloudflare.com/workers/platform/limits#kv-limits`
192
+ );
193
+ }
194
+ }
195
+
196
+ function validateAssetKey(assetKey: string) {
197
+ if (assetKey.length > 512) {
198
+ throw new Error(
199
+ `The asset path key "${assetKey}" exceeds the maximum key size limit of 512. See https://developers.cloudflare.com/workers/platform/limits#kv-limits",`
200
+ );
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Convert a filePath to be safe to use as a relative URL.
206
+ *
207
+ * Primarily this involves converting Windows backslashes to forward slashes.
208
+ */
209
+ function urlSafe(filePath: string): string {
210
+ return filePath.replace(/\\/g, "/");
211
+ }
212
+
213
+ /**
214
+ * Information about the assets that should be uploaded
215
+ */
216
+ export interface AssetPaths {
217
+ baseDirectory: string;
218
+ includePatterns: string[];
219
+ excludePatterns: string[];
220
+ }
221
+
222
+ /**
223
+ * Get an object that describes what assets to upload, if any.
224
+ *
225
+ * Uses the args (passed from the command line) if available,
226
+ * falling back to those defined in the config.
227
+ *
228
+ * // TODO: Support for environments
229
+ */
230
+ export function getAssetPaths(
231
+ config: Config,
232
+ baseDirectory = config.site?.bucket,
233
+ includePatterns = config.site?.include ?? [],
234
+ excludePatterns = config.site?.exclude ?? []
235
+ ): undefined | AssetPaths {
236
+ return baseDirectory
237
+ ? {
238
+ baseDirectory,
239
+ includePatterns,
240
+ excludePatterns,
241
+ }
242
+ : undefined;
243
+ }