@just-be/deploy 0.10.0 → 0.11.0

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 (3) hide show
  1. package/index.ts +215 -11
  2. package/package.json +19 -19
  3. package/schema.json +15 -0
package/index.ts CHANGED
@@ -45,8 +45,9 @@
45
45
  import { $ } from "bun";
46
46
  import { readdir, stat, access } from "fs/promises";
47
47
  import { join, relative, resolve } from "path";
48
- import { intro, outro, spinner } from "@clack/prompts";
48
+ import { intro, outro, spinner, password as passwordPrompt } from "@clack/prompts";
49
49
  import { z } from "zod";
50
+ import { createHash } from "crypto";
50
51
  import packageJson from "./package.json" with { type: "json" };
51
52
  import {
52
53
  StaticConfigSchema,
@@ -80,14 +81,17 @@ function wrangler(...args: string[]) {
80
81
  const StaticRuleSchema = StaticConfigSchema.safeExtend({
81
82
  subdomain: subdomain(),
82
83
  dir: z.string().min(1),
84
+ password: z.string().optional(),
83
85
  });
84
86
 
85
87
  const RedirectRuleSchema = RedirectConfigSchema.safeExtend({
86
88
  subdomain: subdomain(),
89
+ password: z.string().optional(),
87
90
  });
88
91
 
89
92
  const RewriteRuleSchema = RewriteConfigSchema.safeExtend({
90
93
  subdomain: subdomain(),
94
+ password: z.string().optional(),
91
95
  });
92
96
 
93
97
  const RouteRuleSchema = z.discriminatedUnion("type", [
@@ -97,6 +101,7 @@ const RouteRuleSchema = z.discriminatedUnion("type", [
97
101
  ]);
98
102
 
99
103
  export const DeployConfigSchema = z.object({
104
+ $schema: z.string().optional(),
100
105
  rules: z.array(RouteRuleSchema).min(1, "At least one rule is required"),
101
106
  });
102
107
 
@@ -174,20 +179,140 @@ async function findFiles(dir: string): Promise<string[]> {
174
179
  return results;
175
180
  }
176
181
 
182
+ /**
183
+ * Get content type based on file extension
184
+ */
185
+ function getContentType(filePath: string): string | null {
186
+ const ext = filePath.toLowerCase().split(".").pop();
187
+ const contentTypes: Record<string, string> = {
188
+ // Text
189
+ html: "text/html",
190
+ css: "text/css",
191
+ js: "application/javascript",
192
+ json: "application/json",
193
+ xml: "application/xml",
194
+ txt: "text/plain",
195
+ // Images
196
+ png: "image/png",
197
+ jpg: "image/jpeg",
198
+ jpeg: "image/jpeg",
199
+ gif: "image/gif",
200
+ svg: "image/svg+xml",
201
+ webp: "image/webp",
202
+ // Audio
203
+ mp3: "audio/mpeg",
204
+ m4a: "audio/mp4",
205
+ wav: "audio/wav",
206
+ ogg: "audio/ogg",
207
+ aac: "audio/aac",
208
+ flac: "audio/flac",
209
+ // Video
210
+ mp4: "video/mp4",
211
+ webm: "video/webm",
212
+ // Fonts
213
+ woff: "font/woff",
214
+ woff2: "font/woff2",
215
+ ttf: "font/ttf",
216
+ otf: "font/otf",
217
+ };
218
+ return ext ? contentTypes[ext] || null : null;
219
+ }
220
+
221
+ /**
222
+ * Compute MD5 hash of a file
223
+ */
224
+ async function computeFileMD5(filePath: string): Promise<string> {
225
+ const file = Bun.file(filePath);
226
+ const buffer = await file.arrayBuffer();
227
+ const hash = createHash("md5");
228
+ hash.update(new Uint8Array(buffer));
229
+ return hash.digest("hex");
230
+ }
231
+
232
+ /**
233
+ * Check if a file needs to be uploaded by comparing local MD5 with remote ETag
234
+ * Returns true if file should be uploaded (missing or different content)
235
+ */
236
+ async function shouldUploadFile(
237
+ localPath: string,
238
+ r2Key: string,
239
+ subdomain: string,
240
+ ): Promise<boolean> {
241
+ try {
242
+ // Compute local file hash
243
+ const localHash = await computeFileMD5(localPath);
244
+
245
+ // Make HEAD request to check remote file (don't follow redirects)
246
+ const url = `https://${subdomain}.just-be.dev/${r2Key.replace(`${subdomain}/`, "")}`;
247
+ const controller = new AbortController();
248
+ const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
249
+
250
+ try {
251
+ const response = await fetch(url, {
252
+ method: "HEAD",
253
+ redirect: "manual",
254
+ signal: controller.signal,
255
+ headers: {
256
+ Connection: "close", // Ensure connection is closed
257
+ },
258
+ });
259
+
260
+ clearTimeout(timeoutId);
261
+
262
+ // If file doesn't exist (4xx/5xx), upload it
263
+ if (!response.ok) {
264
+ return true;
265
+ }
266
+
267
+ // Get ETag from response (remove quotes if present)
268
+ const etag = response.headers.get("etag")?.replace(/"/g, "");
269
+
270
+ // If no ETag or ETag doesn't match local hash, upload
271
+ if (!etag || etag !== localHash) {
272
+ return true;
273
+ }
274
+
275
+ // File exists with same content, skip upload
276
+ return false;
277
+ } catch (fetchError) {
278
+ clearTimeout(timeoutId);
279
+ // On fetch error (timeout, network error), default to uploading
280
+ if (DEBUG) {
281
+ console.error(`\nFetch error checking ${localPath}:`, fetchError);
282
+ }
283
+ return true;
284
+ }
285
+ } catch (error) {
286
+ // On error, default to uploading
287
+ if (DEBUG) {
288
+ console.error(`\nError checking if upload needed for ${localPath}:`, error);
289
+ }
290
+ return true;
291
+ }
292
+ }
293
+
177
294
  /**
178
295
  * Upload a file to R2
179
296
  */
180
297
  async function uploadToR2(localPath: string, r2Key: string): Promise<boolean> {
181
298
  try {
182
- await wrangler(
299
+ const args = [
183
300
  "r2",
184
301
  "object",
185
302
  "put",
186
303
  `${BUCKET_NAME}/${r2Key}`,
187
304
  "--file",
188
305
  localPath,
189
- "--remote"
190
- );
306
+ "--remote",
307
+ ];
308
+
309
+ // Add content-type if we can determine it
310
+ const contentType = getContentType(localPath);
311
+ if (contentType) {
312
+ args.push("--content-type", contentType);
313
+ }
314
+
315
+ await wrangler(...args);
191
316
  return true;
192
317
  } catch (error) {
193
318
  if (DEBUG) {
@@ -242,6 +367,52 @@ async function validateKVAccess(): Promise<boolean> {
242
367
  }
243
368
  }
244
369
 
370
+ /**
371
+ * List secrets configured on the wildcard worker
372
+ */
373
+ async function listWorkerSecrets(): Promise<string[]> {
374
+ try {
375
+ const output = await wrangler("secret", "list", "--name", "just-be-dev-wildcard").text();
376
+ const secrets = JSON.parse(output);
377
+ return Array.isArray(secrets) ? secrets.map((s: { name: string }) => s.name) : [];
378
+ } catch {
379
+ return [];
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Ensure a secret exists on the wildcard worker, prompting the user if it doesn't
385
+ */
386
+ async function ensureSecretExists(
387
+ secretName: string,
388
+ s: ReturnType<typeof spinner>,
389
+ ): Promise<void> {
390
+ s.start(`Checking if secret "${secretName}" exists`);
391
+ const existingSecrets = await listWorkerSecrets();
392
+
393
+ if (existingSecrets.includes(secretName)) {
394
+ s.stop(`Secret "${secretName}" already exists`);
395
+ return;
396
+ }
397
+
398
+ s.stop(`Secret "${secretName}" not found`);
399
+
400
+ const value = await passwordPrompt({
401
+ message: `Enter the password value for secret "${secretName}":`,
402
+ });
403
+
404
+ if (typeof value !== "string" || !value) {
405
+ console.error("Error: No password provided");
406
+ process.exit(1);
407
+ }
408
+
409
+ s.start(`Uploading secret "${secretName}"`);
410
+ await $`echo ${value}`.pipe(
411
+ wrangler("secret", "put", secretName, "--name", "just-be-dev-wildcard"),
412
+ );
413
+ s.stop(`Secret "${secretName}" uploaded`);
414
+ }
415
+
245
416
  /**
246
417
  * Get the current git branch name
247
418
  */
@@ -286,7 +457,7 @@ async function createKVEntry(subdomain: string, routeConfig: RouteConfig): Promi
286
457
  KV_NAMESPACE_ID,
287
458
  subdomain,
288
459
  configJson,
289
- "--remote"
460
+ "--remote",
290
461
  );
291
462
  }
292
463
 
@@ -330,12 +501,23 @@ async function deployStaticRule(rule: StaticRule, s: ReturnType<typeof spinner>)
330
501
 
331
502
  // Upload files to R2
332
503
  let uploadCount = 0;
504
+ let skippedCount = 0;
333
505
  const failedUploads: string[] = [];
334
506
 
335
507
  for (const filePath of filePaths) {
336
508
  const relativePath = relative(rule.dir, filePath);
337
509
  const r2Key = `${rule.subdomain}/${relativePath}`;
338
510
 
511
+ // Check if upload is needed
512
+ s.start(`Checking ${relativePath}`);
513
+ const needsUpload = await shouldUploadFile(filePath, r2Key, rule.subdomain);
514
+
515
+ if (!needsUpload) {
516
+ skippedCount++;
517
+ s.stop(`Skipped ${relativePath} (unchanged)`);
518
+ continue;
519
+ }
520
+
339
521
  s.start(`Uploading ${relativePath}`);
340
522
  const success = await uploadToR2(filePath, r2Key);
341
523
 
@@ -352,7 +534,12 @@ async function deployStaticRule(rule: StaticRule, s: ReturnType<typeof spinner>)
352
534
  throw new Error(`Failed to upload ${failedUploads.length} file(s)`);
353
535
  }
354
536
 
355
- console.log(`✓ Uploaded ${uploadCount} files to R2`);
537
+ console.log(`✓ Uploaded ${uploadCount} files, skipped ${skippedCount} unchanged files`);
538
+
539
+ // Ensure password secret exists if configured
540
+ if (rule.password) {
541
+ await ensureSecretExists(rule.password, s);
542
+ }
356
543
 
357
544
  // Create KV entry (path is derived from subdomain at runtime)
358
545
  const routeConfig: RouteConfig = {
@@ -361,6 +548,7 @@ async function deployStaticRule(rule: StaticRule, s: ReturnType<typeof spinner>)
361
548
  ...(rule.fallback && { fallback: rule.fallback }),
362
549
  ...(rule.redirects?.length && { redirects: rule.redirects }),
363
550
  ...(rule.rewrites?.length && { rewrites: rule.rewrites }),
551
+ ...(rule.password && { password: rule.password }),
364
552
  };
365
553
 
366
554
  s.start(`Creating KV routing entry`);
@@ -373,17 +561,23 @@ async function deployStaticRule(rule: StaticRule, s: ReturnType<typeof spinner>)
373
561
  */
374
562
  async function deployRedirectRule(
375
563
  rule: RedirectRule,
376
- s: ReturnType<typeof spinner>
564
+ s: ReturnType<typeof spinner>,
377
565
  ): Promise<void> {
378
566
  console.log(`\n🔀 Configuring redirect: ${rule.subdomain}.just-be.dev`);
379
567
  console.log(` Target URL: ${rule.url}`);
380
568
  console.log(` Permanent: ${rule.permanent ?? false}`);
381
569
 
570
+ // Ensure password secret exists if configured
571
+ if (rule.password) {
572
+ await ensureSecretExists(rule.password, s);
573
+ }
574
+
382
575
  const routeConfig: RouteConfig = {
383
576
  type: "redirect",
384
577
  url: rule.url,
385
578
  ...(rule.permanent !== undefined && { permanent: rule.permanent }),
386
579
  ...(rule.preservePath !== undefined && { preservePath: rule.preservePath }),
580
+ ...(rule.password && { password: rule.password }),
387
581
  };
388
582
 
389
583
  s.start(`Creating KV routing entry`);
@@ -399,10 +593,16 @@ async function deployRewriteRule(rule: RewriteRule, s: ReturnType<typeof spinner
399
593
  console.log(` Target URL: ${rule.url}`);
400
594
  console.log(` Allowed Methods: ${rule.allowedMethods?.join(", ") || "GET, HEAD, OPTIONS"}`);
401
595
 
596
+ // Ensure password secret exists if configured
597
+ if (rule.password) {
598
+ await ensureSecretExists(rule.password, s);
599
+ }
600
+
402
601
  const routeConfig: RouteConfig = {
403
602
  type: "rewrite",
404
603
  url: rule.url,
405
604
  ...(rule.allowedMethods && { allowedMethods: rule.allowedMethods }),
605
+ ...(rule.password && { password: rule.password }),
406
606
  };
407
607
 
408
608
  s.start(`Creating KV routing entry`);
@@ -562,8 +762,12 @@ if (import.meta.main) {
562
762
  process.exit(0);
563
763
  }
564
764
 
565
- deploy().catch((error) => {
566
- console.error("\n❌ Fatal error:", error);
567
- process.exit(1);
568
- });
765
+ deploy()
766
+ .then(() => {
767
+ process.exit(0);
768
+ })
769
+ .catch((error) => {
770
+ console.error("\n❌ Fatal error:", error);
771
+ process.exit(1);
772
+ });
569
773
  }
package/package.json CHANGED
@@ -1,41 +1,41 @@
1
1
  {
2
2
  "name": "@just-be/deploy",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Deploy static sites to Cloudflare R2 with subdomain routing",
5
- "type": "module",
6
- "bin": {
7
- "deploy": "index.ts"
8
- },
9
- "files": [
10
- "index.ts",
11
- "schema.json"
12
- ],
13
- "scripts": {
14
- "generate-schema": "bun run scripts/generate-schema.ts"
15
- },
16
5
  "keywords": [
17
6
  "cloudflare",
18
- "r2",
19
7
  "deploy",
8
+ "r2",
20
9
  "static-site"
21
10
  ],
22
- "author": "Justin Bennett",
23
11
  "license": "MIT",
12
+ "author": "Justin Bennett",
24
13
  "repository": {
25
14
  "type": "git",
26
15
  "url": "git+https://github.com/just-be-dev/just-be.dev.git",
27
16
  "directory": "packages/deploy"
28
17
  },
29
- "engines": {
30
- "bun": ">=1.0.0"
18
+ "bin": {
19
+ "deploy": "index.ts"
20
+ },
21
+ "files": [
22
+ "index.ts",
23
+ "schema.json"
24
+ ],
25
+ "type": "module",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "generate-schema": "bun run scripts/generate-schema.ts"
31
31
  },
32
32
  "dependencies": {
33
33
  "@clack/prompts": "^0.11.0",
34
- "@just-be/wildcard": "0.2.0",
34
+ "@just-be/wildcard": "0.6.0",
35
35
  "wrangler": "4.48.0",
36
36
  "zod": "^4.1.13"
37
37
  },
38
- "publishConfig": {
39
- "access": "public"
38
+ "engines": {
39
+ "bun": ">=1.0.0"
40
40
  }
41
41
  }
package/schema.json CHANGED
@@ -2,6 +2,9 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "type": "object",
4
4
  "properties": {
5
+ "$schema": {
6
+ "type": "string"
7
+ },
5
8
  "rules": {
6
9
  "minItems": 1,
7
10
  "type": "array",
@@ -75,6 +78,10 @@
75
78
  "dir": {
76
79
  "type": "string",
77
80
  "minLength": 1
81
+ },
82
+ "password": {
83
+ "type": "string",
84
+ "description": "Name of the Cloudflare Worker secret containing the password for HTTP Basic Auth protection"
78
85
  }
79
86
  },
80
87
  "required": ["type", "subdomain", "dir"],
@@ -99,6 +106,10 @@
99
106
  },
100
107
  "subdomain": {
101
108
  "type": "string"
109
+ },
110
+ "password": {
111
+ "type": "string",
112
+ "description": "Name of the Cloudflare Worker secret containing the password for HTTP Basic Auth protection"
102
113
  }
103
114
  },
104
115
  "required": ["type", "url", "subdomain"],
@@ -125,6 +136,10 @@
125
136
  },
126
137
  "subdomain": {
127
138
  "type": "string"
139
+ },
140
+ "password": {
141
+ "type": "string",
142
+ "description": "Name of the Cloudflare Worker secret containing the password for HTTP Basic Auth protection"
128
143
  }
129
144
  },
130
145
  "required": ["type", "url", "allowedMethods", "subdomain"],