cloudflare/cloudflare-typescript

Public

mirrored from https://github.com/cloudflare/cloudflare-typescriptAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v5.0.0

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

examples/workers/script-with-assets-upload.ts

411lines · modecode

1#!/usr/bin/env -S npm run tsn -T
2
3/**
4 * Create a Worker that serves static assets
5 *
6 * This example demonstrates how to:
7 * - Upload static assets to Cloudflare Workers
8 * - Create and deploy a Worker that serves those assets
9 *
10 * Docs:
11 * - https://developers.cloudflare.com/workers/static-assets/direct-upload
12 *
13 * Prerequisites:
14 * 1. Generate an API token: https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
15 * 2. Find your account ID: https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/
16 * 3. Find your workers.dev subdomain: https://developers.cloudflare.com/workers/configuration/routing/workers-dev/
17 *
18 * Environment variables:
19 * - CLOUDFLARE_API_TOKEN (required)
20 * - CLOUDFLARE_ACCOUNT_ID (required)
21 * - ASSETS_DIRECTORY (required)
22 * - CLOUDFLARE_SUBDOMAIN (optional)
23 *
24 * Usage:
25 * Place your static files in the ASSETS_DIRECTORY, then run this script.
26 * Assets will be available at: my-script-with-assets.$subdomain.workers.dev/$filename
27 */
28
29import crypto from 'crypto';
30import fs from 'fs';
31import { readFile } from 'node:fs/promises';
32import { extname } from 'node:path';
33import path from 'path';
34import { exit } from 'node:process';
35
36import Cloudflare from 'cloudflare';
37
38interface Config {
39 apiToken: string;
40 accountId: string;
41 assetsDirectory: string;
42 subdomain: string | undefined;
43 workerName: string;
44}
45
46interface AssetManifest {
47 [path: string]: {
48 hash: string;
49 size: number;
50 };
51}
52
53interface UploadPayload {
54 [hash: string]: string; // base64 encoded content
55}
56
57const WORKER_NAME = 'my-worker-with-assets';
58const SCRIPT_FILENAME = `${WORKER_NAME}.mjs`;
59
60function loadConfig(): Config {
61 const apiToken = process.env['CLOUDFLARE_API_TOKEN'];
62 if (!apiToken) {
63 throw new Error('Missing required environment variable: CLOUDFLARE_API_TOKEN');
64 }
65
66 const accountId = process.env['CLOUDFLARE_ACCOUNT_ID'];
67 if (!accountId) {
68 throw new Error('Missing required environment variable: CLOUDFLARE_ACCOUNT_ID');
69 }
70
71 const assetsDirectory = process.env['ASSETS_DIRECTORY'];
72 if (!assetsDirectory) {
73 throw new Error('Missing required environment variable: ASSETS_DIRECTORY');
74 }
75
76 if (!fs.existsSync(assetsDirectory)) {
77 throw new Error(`Assets directory does not exist: ${assetsDirectory}`);
78 }
79
80 const subdomain = process.env['CLOUDFLARE_SUBDOMAIN'];
81
82 return {
83 apiToken,
84 accountId,
85 assetsDirectory,
86 subdomain: subdomain || undefined,
87 workerName: WORKER_NAME,
88 };
89}
90
91const config = loadConfig();
92const client = new Cloudflare({
93 apiToken: config.apiToken,
94});
95
96/**
97 * Recursively reads all files from a directory and creates a manifest
98 * mapping file paths to their hash and size.
99 */
100function createManifest(directory: string): AssetManifest {
101 const manifest: AssetManifest = {};
102
103 function processDirectory(currentDir: string, basePath = ''): void {
104 try {
105 const entries = fs.readdirSync(currentDir, { withFileTypes: true });
106
107 for (const entry of entries) {
108 const fullPath = path.join(currentDir, entry.name);
109 const relativePath = path.join(basePath, entry.name);
110
111 if (entry.isDirectory()) {
112 processDirectory(fullPath, relativePath);
113 } else if (entry.isFile()) {
114 try {
115 const fileContent = fs.readFileSync(fullPath);
116 const extension = extname(relativePath).substring(1);
117
118 // Generate a hash for the file
119 const hash = crypto
120 .createHash('sha256')
121 .update(fileContent.toString('base64') + extension)
122 .digest('hex')
123 .slice(0, 32);
124
125 // Normalize path separators to forward slashes
126 const manifestPath = `/${relativePath.replace(/\\/g, '/')}`;
127
128 manifest[manifestPath] = {
129 hash,
130 size: fileContent.length,
131 };
132
133 console.log(`Added to manifest: ${manifestPath} (${fileContent.length} bytes)`);
134 } catch (error) {
135 console.warn(`Failed to process file ${fullPath}:`, error);
136 }
137 }
138 }
139 } catch (error) {
140 throw new Error(`Failed to read directory ${currentDir}: ${error}`);
141 }
142 }
143
144 processDirectory(directory);
145
146 if (Object.keys(manifest).length === 0) {
147 throw new Error(`No files found in assets directory: ${directory}`);
148 }
149
150 console.log(`Created manifest with ${Object.keys(manifest).length} files`);
151 return manifest;
152}
153
154/**
155 * Generates the Worker script content that serves static assets
156 */
157function generateWorkerScript(exampleFile: string): string {
158 return `
159export default {
160 async fetch(request, env, ctx) {
161 const url = new URL(request.url);
162
163 // Serve a simple index page at the root
164 if (url.pathname === '/') {
165 return new Response(
166 \`<!DOCTYPE html>
167<html>
168<head>
169 <title>Static Assets Worker</title>
170 <style>
171 body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
172 h1 { color: #f38020; }
173 .asset-info { background: #f5f5f5; padding: 15px; border-radius: 5px; }
174 </style>
175</head>
176<body>
177 <h1>This Worker serves static assets!</h1>
178 <div class="asset-info">
179 <p><strong>To access your assets,</strong> add <code>/filename</code> to the URL.</p>
180 <p>Try visiting <a href="\${url.origin}/${exampleFile}">/${exampleFile}</a></p>
181 </div>
182</body>
183</html>\`,
184 {
185 status: 200,
186 headers: { 'Content-Type': 'text/html' }
187 }
188 );
189 }
190
191 // Serve static assets for all other paths
192 return env.ASSETS.fetch(request);
193 }
194};
195 `.trim();
196}
197
198/**
199 * Creates upload payloads from buckets and manifest
200 */
201async function createUploadPayloads(
202 buckets: string[][],
203 manifest: AssetManifest,
204 assetsDirectory: string
205): Promise<UploadPayload[]> {
206 const payloads: UploadPayload[] = [];
207
208 for (const bucket of buckets) {
209 const payload: UploadPayload = {};
210
211 for (const hash of bucket) {
212 // Find the file path for this hash
213 const manifestEntry = Object.entries(manifest).find(
214 ([_, data]) => data.hash === hash
215 );
216
217 if (!manifestEntry) {
218 throw new Error(`Could not find file for hash: ${hash}`);
219 }
220
221 const [relativePath] = manifestEntry;
222 const fullPath = path.join(assetsDirectory, relativePath);
223
224 try {
225 const fileContent = await readFile(fullPath);
226 payload[hash] = fileContent.toString('base64');
227 console.log(`Prepared for upload: ${relativePath}`);
228 } catch (error) {
229 throw new Error(`Failed to read file ${fullPath}: ${error}`);
230 }
231 }
232
233 payloads.push(payload);
234 }
235
236 return payloads;
237}
238
239/**
240 * Uploads asset payloads
241 */
242async function uploadAssets(
243 payloads: UploadPayload[],
244 uploadJwt: string,
245 accountId: string
246): Promise<string> {
247 let completionJwt: string | undefined;
248
249 console.log(`Uploading ${payloads.length} payload(s)...`);
250
251 for (let i = 0; i < payloads.length; i++) {
252 const payload = payloads[i]!;
253 console.log(`Uploading payload ${i + 1}/${payloads.length}...`);
254
255 try {
256 const response = await client.workers.assets.upload.create(
257 {
258 account_id: accountId,
259 base64: true,
260 body: payload,
261 },
262 {
263 headers: { Authorization: `Bearer ${uploadJwt}` },
264 }
265 );
266
267 if (response?.jwt) {
268 completionJwt = response.jwt;
269 }
270 } catch (error) {
271 throw new Error(`Failed to upload payload ${i + 1}: ${error}`);
272 }
273 }
274
275 if (!completionJwt) {
276 throw new Error('Upload completed but no completion JWT received');
277 }
278
279 console.log('✅ All assets uploaded successfully');
280 return completionJwt;
281}
282
283async function main(): Promise<void> {
284 try {
285 console.log('🚀 Starting Worker creation and deployment with static assets...');
286 console.log(`📁 Assets directory: ${config.assetsDirectory}`);
287
288 console.log('📝 Creating asset manifest...');
289 const manifest = createManifest(config.assetsDirectory);
290 const exampleFile = Object.keys(manifest)[0]?.replace(/^\//, '') || 'file.txt';
291
292 const scriptContent = generateWorkerScript(exampleFile);
293
294 let worker;
295 try {
296 worker = await client.workers.beta.workers.get(config.workerName, {
297 account_id: config.accountId,
298 });
299 console.log(`♻️ Worker ${config.workerName} already exists. Using it.`);
300 } catch (error) {
301 if (!(error instanceof Cloudflare.NotFoundError)) { throw error; }
302 console.log(`✏️ Creating Worker ${config.workerName}...`);
303 worker = await client.workers.beta.workers.create({
304 account_id: config.accountId,
305 name: config.workerName,
306 subdomain: {
307 enabled: config.subdomain !== undefined,
308 },
309 observability: {
310 enabled: true,
311 },
312 });
313 }
314
315 console.log(`⚙️ Worker id: ${worker.id}`);
316 console.log('🔄 Starting asset upload session...');
317
318 const uploadResponse = await client.workers.scripts.assets.upload.create(
319 config.workerName,
320 {
321 account_id: config.accountId,
322 manifest,
323 }
324 );
325
326 const { buckets, jwt: uploadJwt } = uploadResponse;
327
328 if (!uploadJwt || !buckets) {
329 throw new Error('Failed to start asset upload session');
330 }
331
332 let completionJwt: string;
333
334 if (buckets.length === 0) {
335 console.log('✅ No new assets to upload!');
336 // Use the initial upload JWT as completion JWT when no uploads are needed
337 completionJwt = uploadJwt;
338 } else {
339 const payloads = await createUploadPayloads(
340 buckets,
341 manifest,
342 config.assetsDirectory
343 );
344
345 completionJwt = await uploadAssets(
346 payloads,
347 uploadJwt,
348 config.accountId
349 );
350 }
351
352 console.log('✏️ Creating Worker version...');
353
354 // Create a new version with assets
355 const version = await client.workers.beta.workers.versions.create(worker.id, {
356 account_id: config.accountId,
357 main_module: SCRIPT_FILENAME,
358 compatibility_date: new Date().toISOString().split('T')[0]!,
359 bindings: [
360 {
361 type: 'assets',
362 name: 'ASSETS',
363 },
364 ],
365 assets: {
366 jwt: completionJwt,
367 },
368 modules: [
369 {
370 name: SCRIPT_FILENAME,
371 content_type: 'application/javascript+module',
372 content_base64: Buffer.from(scriptContent).toString('base64'),
373 },
374 ],
375 });
376
377 console.log('🚚 Creating Worker deployment...');
378
379 // Create a deployment and point all traffic to the version we created
380 await client.workers.scripts.deployments.create(config.workerName, {
381 account_id: config.accountId,
382 strategy: 'percentage',
383 versions: [
384 {
385 percentage: 100,
386 version_id: version.id,
387 },
388 ],
389 });
390
391 console.log('✅ Deployment successful!');
392
393 if (config.subdomain) {
394 console.log(`
395🌍 Your Worker is live!
396📍 Base URL: https://${config.workerName}.${config.subdomain}.workers.dev/
397📄 Try accessing: https://${config.workerName}.${config.subdomain}.workers.dev/${exampleFile}
398`);
399 } else {
400 console.log(`
401⚠️ Set up a route, custom domain, or workers.dev subdomain to access your Worker.
402Add CLOUDFLARE_SUBDOMAIN to your environment variables to set one up automatically.
403`);
404 }
405 } catch (error) {
406 console.error('❌ Deployment failed:', error);
407 exit(1);
408 }
409}
410
411main();
412