Skip to content

Commit 22c26d1

Browse files
committed
Add workers assets example
1 parent 980edbf commit 22c26d1

File tree

1 file changed

+215
-0
lines changed

1 file changed

+215
-0
lines changed
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/usr/bin/env -S npm run tsn -T
2+
3+
/*
4+
* This is an example of how to upload a Worker with Static Assets and serve them from a Worker
5+
* https://developers.cloudflare.com/workers/static-assets/direct-upload
6+
*
7+
* Generate an API token: https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
8+
* (Not Global API Key!)
9+
*
10+
* Find your account id: https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/
11+
*
12+
* Set these environment variables:
13+
* - CLOUDFLARE_API_TOKEN
14+
* - CLOUDFLARE_ACCOUNT_ID
15+
* - ASSETS_DIRECTORY
16+
*
17+
* In your assets directory, place files like html or images.
18+
*
19+
* After deployment, your assets will be available at:
20+
* <worker-route>/<filename> (e.g. example.com/cat.jpg)
21+
*/
22+
23+
import crypto from 'crypto';
24+
import fs from 'fs';
25+
import { readFile } from 'node:fs/promises';
26+
import { extname } from 'node:path';
27+
import path from 'path';
28+
import { exit } from 'node:process';
29+
30+
import Cloudflare from 'cloudflare';
31+
import { toFile } from 'cloudflare/index';
32+
import { UploadCreateParams } from 'cloudflare/resources/workers/scripts/assets';
33+
34+
const apiToken = process.env['CLOUDFLARE_API_TOKEN'] ?? '';
35+
if (!apiToken) {
36+
throw new Error('Please set envar CLOUDFLARE_ACCOUNT_ID');
37+
}
38+
39+
const accountID = process.env['CLOUDFLARE_ACCOUNT_ID'] ?? '';
40+
if (!accountID) {
41+
throw new Error('Please set envar CLOUDFLARE_API_TOKEN');
42+
}
43+
44+
const assetsDirectory = process.env['ASSETS_DIRECTORY'] ?? '';
45+
if (!assetsDirectory) {
46+
throw new Error('Please set envar ASSETS_DIRECTORY');
47+
}
48+
49+
const client = new Cloudflare({
50+
apiToken: apiToken,
51+
});
52+
53+
/**
54+
* Recursively reads all files from a directory and creates a manifest
55+
* mapping file paths to a hash and size.
56+
*
57+
* Output format:
58+
* {
59+
* "/index.html": { hash: "abc123...", size: 123 },
60+
* "/images/cat.jpg": { hash: "def456...", size: 4567 }
61+
* }
62+
*/
63+
function createManifest(directory: string): Record<string, UploadCreateParams.Manifest> {
64+
const manifest: Record<string, UploadCreateParams.Manifest> = {};
65+
(function processDirectory(directory: string, basePath = '') {
66+
fs.readdirSync(directory, { withFileTypes: true }).forEach((dirent) => {
67+
const fullPath = path.join(directory, dirent.name);
68+
const relativePath = path.join(basePath, dirent.name);
69+
70+
if (dirent.isDirectory()) {
71+
processDirectory(fullPath, relativePath);
72+
} else {
73+
const fileContent = fs.readFileSync(fullPath);
74+
const extension = extname(relativePath).substring(1);
75+
76+
// Generate SHA-256 hash and encode in Base64
77+
const hash = crypto
78+
.createHash('sha256')
79+
.update(fileContent.toString('base64') + extension)
80+
.digest('hex')
81+
.toString()
82+
.slice(0, 32);
83+
84+
// Use forward slashes for paths in manifest
85+
const manifestPath = `/${relativePath.replace(/\\/g, '/')}`;
86+
manifest[manifestPath] = {
87+
hash: hash,
88+
size: fileContent.length,
89+
};
90+
}
91+
});
92+
})(directory);
93+
return manifest;
94+
}
95+
96+
async function main() {
97+
/*
98+
* For simplicity, we'll just create the workers script content directly instead
99+
* of reading it from the Assets Directory which would be typical after running:
100+
* `wrangler deploy --dry-run -outdir build`.
101+
*/
102+
const scriptName = 'my-script-with-assets';
103+
const scriptFileName = `${scriptName}.mjs`;
104+
const scriptContent = `
105+
export default {
106+
async fetch(request, env, ctx) {
107+
return env.ASSETS.fetch(request);
108+
}
109+
};
110+
`;
111+
112+
const manifest = createManifest(assetsDirectory);
113+
114+
try {
115+
// Upload the manifest and get back which new or changed files need to be uploaded
116+
// The files that need to be uploaded are indicated by hash and batched into buckets
117+
const response = await client.workers.scripts.assets.upload.create(scriptName, {
118+
account_id: accountID,
119+
manifest: manifest,
120+
});
121+
const { buckets } = response;
122+
if (!response.jwt || !buckets) {
123+
throw new Error('There was a problem starting the Assets Upload Session');
124+
}
125+
126+
if (buckets.length === 0) {
127+
console.log('Nothing to upload!');
128+
exit(0);
129+
}
130+
131+
// The auth token to use for uploading via the assets upload API
132+
const uploadJwt: string = response.jwt;
133+
134+
/*
135+
* For the new or changed files that need to be uploaded...
136+
* - Look at the file hashes per bucket and foreach...
137+
* - Get the filepath back from the hash (reverse lookup in the manifest)
138+
* - Read the file contents from disk and encode them to base64
139+
* - Add that to the payload for upload (1 bucket = 1 payload = 1..N base64 encoded files)
140+
*/
141+
const newPayloads: Record<string, any>[] = [];
142+
for (const bucket of buckets) {
143+
const newPayload: Record<string, any> = {};
144+
for (const hash of bucket) {
145+
const relativeAssetPath = Object.entries(manifest).find((record) => record[1].hash == hash)?.[0];
146+
if (!relativeAssetPath) {
147+
return;
148+
}
149+
const assetFileContents = (await readFile(path.join(assetsDirectory, relativeAssetPath))).toString(
150+
'base64',
151+
);
152+
newPayload[hash] = assetFileContents;
153+
}
154+
newPayloads.push(newPayload);
155+
}
156+
157+
let completionJwt: string | undefined;
158+
159+
// Upload each bucket/payload (this could be parallelized)
160+
for (const payload of newPayloads) {
161+
const bucketUploadReponse = await client.workers.assets.upload.create(
162+
{
163+
account_id: accountID,
164+
base64: true,
165+
body: payload,
166+
},
167+
{
168+
// This API uses `Bearer: <assets_jwt>` instead of `Bearer: <api_token>`
169+
headers: { Authorization: `Bearer ${uploadJwt}` },
170+
},
171+
);
172+
173+
// For each bucket of uploads, we might or might not get a new jwt to use that indicates
174+
// it got all expected files.
175+
// See: https://developers.cloudflare.com/workers/static-assets/direct-upload
176+
if (bucketUploadReponse?.jwt) {
177+
completionJwt = bucketUploadReponse.jwt;
178+
}
179+
}
180+
181+
if (!completionJwt) {
182+
console.error('Did not get completion JWT');
183+
exit(1);
184+
}
185+
186+
// Finally, upload the Worker and define Assets in the config
187+
// https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/update/
188+
const script = await client.workers.scripts.update(scriptName, {
189+
account_id: accountID,
190+
// https://developers.cloudflare.com/workers/configuration/multipart-upload-metadata/
191+
metadata: {
192+
main_module: scriptFileName,
193+
assets: {
194+
config: {
195+
not_found_handling: 'single-page-application',
196+
},
197+
jwt: completionJwt,
198+
},
199+
},
200+
files: {
201+
// Add main_module file. Again, you'd probably actually read this from your
202+
// Worker build output directory.
203+
[scriptFileName]: await toFile(Buffer.from(scriptContent), scriptFileName, {
204+
type: 'application/javascript+module',
205+
}),
206+
},
207+
});
208+
console.log('Script and Assets uploaded successfully!');
209+
console.log(JSON.stringify(script, null, 2));
210+
} catch (error) {
211+
console.error(error);
212+
}
213+
}
214+
215+
main();

0 commit comments

Comments
 (0)