alchemy 0.4.0 → 0.5.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 (53) hide show
  1. package/README.md +3 -463
  2. package/lib/ai/data.d.ts +5 -0
  3. package/lib/ai/data.js +3 -0
  4. package/lib/alchemy.d.ts +3 -3
  5. package/lib/alchemy.js +82 -85
  6. package/lib/cloudflare/assets.d.ts +74 -0
  7. package/lib/cloudflare/assets.js +75 -0
  8. package/lib/cloudflare/bindings.d.ts +6 -1
  9. package/lib/cloudflare/bindings.js +6 -0
  10. package/lib/cloudflare/bound.d.ts +2 -1
  11. package/lib/cloudflare/custom-domain.d.ts +1 -15
  12. package/lib/cloudflare/custom-domain.js +1 -15
  13. package/lib/cloudflare/index.d.ts +1 -1
  14. package/lib/cloudflare/index.js +1 -1
  15. package/lib/cloudflare/r2-rest-state-store.js +52 -22
  16. package/lib/cloudflare/worker.d.ts +18 -0
  17. package/lib/cloudflare/worker.js +153 -4
  18. package/lib/internal/docs/providers.js +2 -1
  19. package/lib/os/exec.d.ts +103 -0
  20. package/lib/os/exec.js +103 -0
  21. package/lib/os/index.d.ts +1 -0
  22. package/lib/os/index.js +1 -0
  23. package/lib/secret.d.ts +2 -2
  24. package/lib/secret.js +2 -2
  25. package/lib/web/vitepress/vitepress.js +1 -1
  26. package/package.json +2 -1
  27. package/src/ai/data.ts +10 -0
  28. package/src/alchemy.ts +97 -100
  29. package/src/cloudflare/assets.ts +158 -0
  30. package/src/cloudflare/bindings.ts +11 -2
  31. package/src/cloudflare/bound.ts +4 -1
  32. package/src/cloudflare/custom-domain.ts +1 -15
  33. package/src/cloudflare/index.ts +1 -1
  34. package/src/cloudflare/r2-rest-state-store.ts +78 -34
  35. package/src/cloudflare/worker.ts +261 -6
  36. package/src/internal/docs/providers.ts +2 -1
  37. package/src/os/exec.ts +190 -0
  38. package/src/os/index.ts +1 -0
  39. package/src/secret.ts +2 -2
  40. package/src/web/vitepress/vitepress.ts +1 -1
  41. package/lib/cloudflare/generate-asset-manifest.d.ts +0 -2
  42. package/lib/cloudflare/generate-asset-manifest.js +0 -68
  43. package/lib/cloudflare/static-site-router.d.ts +0 -18
  44. package/lib/cloudflare/static-site-router.js +0 -115
  45. package/lib/cloudflare/static-site.d.ts +0 -211
  46. package/lib/cloudflare/static-site.js +0 -213
  47. package/lib/cloudflare/upload-asset-manifest.d.ts +0 -11
  48. package/lib/cloudflare/upload-asset-manifest.js +0 -56
  49. package/src/cloudflare/generate-asset-manifest.ts +0 -93
  50. package/src/cloudflare/static-site-router.ts +0 -157
  51. package/src/cloudflare/static-site.ts +0 -416
  52. package/src/cloudflare/upload-asset-manifest.ts +0 -85
  53. package/src/web/vitepress/index.md +0 -75
@@ -1,11 +1,13 @@
1
+ import * as crypto from "crypto";
1
2
  import * as fs from "fs/promises";
2
3
  import { Bundle } from "../esbuild/bundle";
3
4
  import { Resource } from "../resource";
4
5
  import { isSecret } from "../secret";
6
+ import { getContentType } from "../util/content-type";
5
7
  import { withExponentialBackoff } from "../util/retry";
6
8
  import { slugify } from "../util/slugify";
7
9
  import { createCloudflareApi } from "./api";
8
- import { isDurableObjectNamespace, } from "./bindings";
10
+ import { isAssets, isDurableObjectNamespace, } from "./bindings";
9
11
  import { isKVNamespace } from "./kv-namespace";
10
12
  /**
11
13
  * A Cloudflare Worker is a serverless function that can be deployed to the Cloudflare network.
@@ -68,6 +70,20 @@ import { isKVNamespace } from "./kv-namespace";
68
70
  * }
69
71
  * });
70
72
  *
73
+ * @example
74
+ * // Create a worker with static assets:
75
+ * const staticAssets = await Assets("static", {
76
+ * path: "./src/assets"
77
+ * });
78
+ *
79
+ * const frontendWorker = await Worker("frontend", {
80
+ * name: "frontend-worker",
81
+ * entrypoint: "./src/worker.ts",
82
+ * bindings: {
83
+ * ASSETS: staticAssets
84
+ * }
85
+ * });
86
+ *
71
87
  * @see https://developers.cloudflare.com/workers/
72
88
  */
73
89
  export const Worker = Resource("cloudflare::Worker", {
@@ -86,12 +102,33 @@ export const Worker = Resource("cloudflare::Worker", {
86
102
  return this.destroy();
87
103
  }
88
104
  else if (this.phase === "create") {
89
- await assertWorkerDoesNotExist(this, api, workerName);
105
+ if (!props.adopt) {
106
+ await assertWorkerDoesNotExist(this, api, workerName);
107
+ }
90
108
  }
91
109
  const oldBindings = await this.get("bindings");
92
- const scriptMetadata = await prepareWorkerMetadata(this, oldBindings, props);
93
110
  // Get the script content - either from props.script, or by bundling
94
111
  const scriptContent = props.script ?? (await bundleWorkerScript(props));
112
+ // Find any assets bindings
113
+ const assetsBindings = [];
114
+ if (props.bindings) {
115
+ for (const [bindingName, binding] of Object.entries(props.bindings)) {
116
+ if (isAssets(binding)) {
117
+ assetsBindings.push({ name: bindingName, assets: binding });
118
+ }
119
+ }
120
+ }
121
+ // Upload any assets and get completion tokens
122
+ let assetUploadResult;
123
+ if (assetsBindings.length > 0) {
124
+ // We'll use the first asset binding for now
125
+ // In the future, we might want to support multiple asset bindings
126
+ const assetBinding = assetsBindings[0];
127
+ // Upload the assets and get the completion token
128
+ assetUploadResult = await uploadAssets(api, workerName, assetBinding.assets);
129
+ }
130
+ // Prepare metadata with bindings
131
+ const scriptMetadata = await prepareWorkerMetadata(this, oldBindings, props, assetUploadResult);
95
132
  // Upload the worker script
96
133
  await putWorker(api, workerName, scriptContent, scriptMetadata);
97
134
  // TODO: it is less than ideal that this can fail, resulting in state problem
@@ -206,7 +243,7 @@ class NotFoundError extends Error {
206
243
  this.name = "NotFoundError";
207
244
  }
208
245
  }
209
- async function prepareWorkerMetadata(ctx, oldBindings, props) {
246
+ async function prepareWorkerMetadata(ctx, oldBindings, props, assetUploadResult) {
210
247
  // Prepare metadata with bindings
211
248
  const meta = {
212
249
  bindings: [],
@@ -223,6 +260,15 @@ async function prepareWorkerMetadata(ctx, oldBindings, props) {
223
260
  new_sqlite_classes: props.migrations?.new_sqlite_classes ?? [],
224
261
  },
225
262
  };
263
+ // If we have asset upload results, add them to the metadata
264
+ if (assetUploadResult) {
265
+ meta.assets = {
266
+ jwt: assetUploadResult.completionToken,
267
+ };
268
+ if (assetUploadResult.assetConfig) {
269
+ meta.assets.config = assetUploadResult.assetConfig;
270
+ }
271
+ }
226
272
  const bindings = (props.bindings ?? {});
227
273
  // Convert bindings to the format expected by the API
228
274
  for (const [bindingName, binding] of Object.entries(bindings)) {
@@ -284,6 +330,12 @@ async function prepareWorkerMetadata(ctx, oldBindings, props) {
284
330
  bucket_name: binding.name,
285
331
  });
286
332
  }
333
+ else if (isAssets(binding)) {
334
+ meta.bindings.push({
335
+ type: "assets",
336
+ name: bindingName,
337
+ });
338
+ }
287
339
  else if (isSecret(binding)) {
288
340
  meta.bindings.push({
289
341
  type: "secret_text",
@@ -468,3 +520,100 @@ async function getWorkerBindings(api, workerName, environment = "production") {
468
520
  const data = await response.json();
469
521
  return data.result;
470
522
  }
523
+ /**
524
+ * Uploads assets to Cloudflare and returns a completion token
525
+ *
526
+ * @param api CloudflareApi instance
527
+ * @param workerName Name of the worker
528
+ * @param assets Assets resource containing files to upload
529
+ * @returns Completion token for the assets upload
530
+ */
531
+ async function uploadAssets(api, workerName, assets) {
532
+ // Generate the file manifest
533
+ const fileMetadata = {};
534
+ // Process each file in the assets
535
+ for (const file of assets.files) {
536
+ const { hash, size } = await calculateFileMetadata(file.filePath);
537
+ // Use the relative path as the key, ensuring it starts with a slash
538
+ const key = file.path.startsWith("/") ? file.path : `/${file.path}`;
539
+ fileMetadata[key] = { hash, size };
540
+ }
541
+ // Start the upload session
542
+ const uploadSessionUrl = `/accounts/${api.accountId}/workers/scripts/${workerName}/assets-upload-session`;
543
+ const uploadSessionResponse = await api.post(uploadSessionUrl, JSON.stringify({ manifest: fileMetadata }), {
544
+ headers: { "Content-Type": "application/json" },
545
+ });
546
+ if (!uploadSessionResponse.ok) {
547
+ throw new Error(`Failed to start assets upload session: ${uploadSessionResponse.status} ${uploadSessionResponse.statusText}`);
548
+ }
549
+ const sessionData = (await uploadSessionResponse.json());
550
+ // If there are no buckets, assets are already uploaded or empty
551
+ if (!sessionData.result.buckets || sessionData.result.buckets.length === 0) {
552
+ return { completionToken: sessionData.result.jwt };
553
+ }
554
+ // Upload the files in batches as specified by the API
555
+ let completionToken = sessionData.result.jwt;
556
+ const buckets = sessionData.result.buckets;
557
+ // Process each bucket of files
558
+ for (const bucket of buckets) {
559
+ const formData = new FormData();
560
+ // Add each file in the bucket to the form
561
+ for (const fileHash of bucket) {
562
+ // Find the file with this hash
563
+ const file = assets.files.find((f) => {
564
+ const filePath = f.path.startsWith("/") ? f.path : `/${f.path}`;
565
+ return fileMetadata[filePath]?.hash === fileHash;
566
+ });
567
+ if (!file) {
568
+ throw new Error(`Could not find file with hash ${fileHash}`);
569
+ }
570
+ // Read the file content
571
+ const fileContent = await fs.readFile(file.filePath);
572
+ // Convert to base64 as required by the API when using base64=true
573
+ const base64Content = fileContent.toString("base64");
574
+ // Add the file to the form with the hash as the key and set the correct content type
575
+ const blob = new Blob([base64Content], {
576
+ type: getContentType(file.filePath),
577
+ });
578
+ formData.append(fileHash, blob, fileHash);
579
+ }
580
+ // Upload this batch of files
581
+ const uploadResponse = await api.post(`/accounts/${api.accountId}/workers/assets/upload?base64=true`, formData, {
582
+ headers: {
583
+ Authorization: `Bearer ${completionToken}`,
584
+ "Content-Type": "multipart/form-data",
585
+ },
586
+ });
587
+ if (!uploadResponse.ok) {
588
+ throw new Error(`Failed to upload asset files: ${uploadResponse.status} ${uploadResponse.statusText}`);
589
+ }
590
+ const uploadData = (await uploadResponse.json());
591
+ // Update the completion token for the next batch
592
+ if (uploadData.result.jwt) {
593
+ completionToken = uploadData.result.jwt;
594
+ }
595
+ }
596
+ // Return the final completion token
597
+ return {
598
+ completionToken,
599
+ assetConfig: {
600
+ html_handling: "auto-trailing-slash",
601
+ },
602
+ };
603
+ }
604
+ /**
605
+ * Calculate the SHA-256 hash and size of a file
606
+ *
607
+ * @param filePath Path to the file
608
+ * @returns Hash (first 32 chars of SHA-256) and size of the file
609
+ */
610
+ async function calculateFileMetadata(filePath) {
611
+ const hash = crypto.createHash("sha256");
612
+ const fileContent = await fs.readFile(filePath);
613
+ hash.update(fileContent);
614
+ const fileHash = hash.digest("hex").substring(0, 32); // First 32 chars of hash
615
+ return {
616
+ hash: fileHash,
617
+ size: fileContent.length,
618
+ };
619
+ }
@@ -55,6 +55,7 @@ async function generateProviderDocs({ provider, outDir, parallel, }) {
55
55
  reasoningEffort: "high",
56
56
  },
57
57
  },
58
+ freeze: true,
58
59
  temperature: 0.1,
59
60
  schema: type({
60
61
  groups: type({
@@ -81,7 +82,7 @@ async function generateProviderDocs({ provider, outDir, parallel, }) {
81
82
  A file is considered a "Utility" if it contains utility functions that are not resources or clients.
82
83
  A file is considered a "Types" if it contains just type definitions and maybe helpers around working with those types.
83
84
 
84
- The title should be simply the name of the resource's const in code (with spaces added in between each word), e.g. "Bucket" or "Function", except with spaces, e.g. "Static Site" for "const StaticSite". Maintain all other casing.
85
+ The title should be simply the name of the resource's const in code (with spaces added in between each word), e.g. "Bucket" or "Function", except with spaces, e.g. "StaticSite" for "const StaticSite". Maintain all other casing.
85
86
 
86
87
  // "Resource Name"
87
88
  const ResourceName = Resource(...)
@@ -0,0 +1,103 @@
1
+ import type { Context } from "../context";
2
+ import { Resource } from "../resource";
3
+ /**
4
+ * Properties for executing a shell command
5
+ */
6
+ export interface ExecProps {
7
+ /**
8
+ * The command to execute (including any arguments)
9
+ */
10
+ command: string;
11
+ /**
12
+ * Whether to memoize the command (only re-run if the command changes)
13
+ *
14
+ * @default false
15
+ */
16
+ memoize?: boolean;
17
+ /**
18
+ * Working directory for the command
19
+ */
20
+ cwd?: string;
21
+ /**
22
+ * Environment variables to set
23
+ */
24
+ env?: Record<string, string>;
25
+ /**
26
+ * Maximum buffer size for stdout and stderr (in bytes)
27
+ * @default 1024 * 1024 (1MB)
28
+ */
29
+ maxBuffer?: number;
30
+ /**
31
+ * Whether to throw an error if the command exits with a non-zero status
32
+ */
33
+ throwOnError?: boolean;
34
+ }
35
+ /**
36
+ * Output returned after command execution
37
+ */
38
+ export interface Exec extends Resource<"os::Exec">, ExecProps {
39
+ /**
40
+ * Unique identifier for this execution
41
+ */
42
+ id: string;
43
+ /**
44
+ * Exit code of the command
45
+ */
46
+ exitCode: number;
47
+ /**
48
+ * Standard output from the command
49
+ */
50
+ stdout: string;
51
+ /**
52
+ * Standard error from the command
53
+ */
54
+ stderr: string;
55
+ /**
56
+ * Time at which the command was executed
57
+ */
58
+ executedAt: number;
59
+ /**
60
+ * Whether the command has completed execution
61
+ */
62
+ completed: boolean;
63
+ }
64
+ /**
65
+ * Execute a shell command
66
+ *
67
+ * @example
68
+ * // Run a simple command
69
+ * const result = await Exec("list-files", {
70
+ * command: "ls -la"
71
+ * });
72
+ *
73
+ * console.log(result.stdout);
74
+ *
75
+ * @example
76
+ * // Run a command in a specific directory with custom environment
77
+ * const build = await Exec("build-project", {
78
+ * command: "npm run build",
79
+ * cwd: "./my-project",
80
+ * env: { NODE_ENV: "production" }
81
+ * });
82
+ *
83
+ * @example
84
+ * // Run a command with a larger buffer for output
85
+ * const logs = await Exec("get-logs", {
86
+ * command: "cat large-log-file.log",
87
+ * maxBuffer: 10 * 1024 * 1024 // 10MB
88
+ * });
89
+ *
90
+ * @example
91
+ * // Run a memoized command that only re-executes when the command changes
92
+ * const memoizedCmd = await Exec("status-check", {
93
+ * command: "git status",
94
+ * memoize: true
95
+ * });
96
+ *
97
+ * // This won't actually run the command again if nothing has changed
98
+ * await Exec("status-check", {
99
+ * command: "git status",
100
+ * memoize: true
101
+ * });
102
+ */
103
+ export declare const Exec: (((this: any, id: string, props?: {}) => never) & (new (_: never) => never)) | ((this: Context<Exec>, id: string, props: ExecProps) => Promise<Exec>);
package/lib/os/exec.js ADDED
@@ -0,0 +1,103 @@
1
+ import { exec } from "child_process";
2
+ import { promisify } from "util";
3
+ import { Resource } from "../resource";
4
+ const execAsync = promisify(exec);
5
+ /**
6
+ * Execute a shell command
7
+ *
8
+ * @example
9
+ * // Run a simple command
10
+ * const result = await Exec("list-files", {
11
+ * command: "ls -la"
12
+ * });
13
+ *
14
+ * console.log(result.stdout);
15
+ *
16
+ * @example
17
+ * // Run a command in a specific directory with custom environment
18
+ * const build = await Exec("build-project", {
19
+ * command: "npm run build",
20
+ * cwd: "./my-project",
21
+ * env: { NODE_ENV: "production" }
22
+ * });
23
+ *
24
+ * @example
25
+ * // Run a command with a larger buffer for output
26
+ * const logs = await Exec("get-logs", {
27
+ * command: "cat large-log-file.log",
28
+ * maxBuffer: 10 * 1024 * 1024 // 10MB
29
+ * });
30
+ *
31
+ * @example
32
+ * // Run a memoized command that only re-executes when the command changes
33
+ * const memoizedCmd = await Exec("status-check", {
34
+ * command: "git status",
35
+ * memoize: true
36
+ * });
37
+ *
38
+ * // This won't actually run the command again if nothing has changed
39
+ * await Exec("status-check", {
40
+ * command: "git status",
41
+ * memoize: true
42
+ * });
43
+ */
44
+ export const Exec = Resource("os::Exec", {
45
+ alwaysUpdate: true,
46
+ }, async function (id, props) {
47
+ if (this.phase === "delete") {
48
+ // Nothing to actually delete for an exec command
49
+ return this.destroy();
50
+ }
51
+ else if (this.phase === "update" &&
52
+ props.memoize &&
53
+ this.output?.command === props.command) {
54
+ // If memoize is enabled and the command hasn't changed, return the existing output
55
+ return this.output;
56
+ }
57
+ else {
58
+ // Default values
59
+ let stdout = "";
60
+ let stderr = "";
61
+ let exitCode = 0;
62
+ try {
63
+ console.log(props.command);
64
+ // Execute the command
65
+ const result = await execAsync(props.command, {
66
+ cwd: props.cwd || process.cwd(),
67
+ env: { ...process.env, ...props.env },
68
+ maxBuffer: props.maxBuffer || 1024 * 1024, // Default 1MB
69
+ });
70
+ stdout = result.stdout;
71
+ console.log(stdout);
72
+ stderr = result.stderr;
73
+ exitCode = 0; // Success
74
+ }
75
+ catch (error) {
76
+ console.log("error", error);
77
+ if (props.throwOnError) {
78
+ throw error;
79
+ }
80
+ // If not throwing, capture the error information
81
+ exitCode = error.code || 1;
82
+ stdout = error.stdout || "";
83
+ stderr = error.stderr || String(error);
84
+ console.log(stdout);
85
+ console.error(stderr);
86
+ }
87
+ // Return the execution result
88
+ return this({
89
+ id,
90
+ command: props.command,
91
+ cwd: props.cwd,
92
+ env: props.env,
93
+ maxBuffer: props.maxBuffer,
94
+ throwOnError: props.throwOnError,
95
+ memoize: props.memoize,
96
+ exitCode,
97
+ stdout,
98
+ stderr,
99
+ executedAt: Date.now(),
100
+ completed: true,
101
+ });
102
+ }
103
+ });
@@ -0,0 +1 @@
1
+ export * from "./exec";
@@ -0,0 +1 @@
1
+ export * from "./exec";
package/lib/secret.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * 1. Globally when initializing the alchemy application:
7
7
  * ```ts
8
- * const app = alchemy("my-app", {
8
+ * const app = await alchemy("my-app", {
9
9
  * password: process.env.SECRET_PASSPHRASE
10
10
  * });
11
11
  * ```
@@ -49,7 +49,7 @@ export declare function isSecret(binding: any): binding is Secret;
49
49
  *
50
50
  * @example
51
51
  * // Global password for all secrets
52
- * const app = alchemy("my-app", {
52
+ * const app = await alchemy("my-app", {
53
53
  * password: process.env.SECRET_PASSPHRASE
54
54
  * });
55
55
  *
package/lib/secret.js CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * 1. Globally when initializing the alchemy application:
7
7
  * ```ts
8
- * const app = alchemy("my-app", {
8
+ * const app = await alchemy("my-app", {
9
9
  * password: process.env.SECRET_PASSPHRASE
10
10
  * });
11
11
  * ```
@@ -54,7 +54,7 @@ export function isSecret(binding) {
54
54
  *
55
55
  * @example
56
56
  * // Global password for all secrets
57
- * const app = alchemy("my-app", {
57
+ * const app = await alchemy("my-app", {
58
58
  * password: process.env.SECRET_PASSPHRASE
59
59
  * });
60
60
  *
@@ -76,7 +76,7 @@ export const VitepressProject = Resource("project::VitepressProject", {
76
76
  // import { Folder } from "alchemy/fs";
77
77
  // import { Document } from "alchemy/docs";
78
78
  // import path from "path";
79
- // await using _ = alchemy("alchemy.run", {
79
+ // const app = await alchemy("alchemy.run", {
80
80
  // stage: "prod",
81
81
  // phase: process.argv.includes("--destroy") ? "destroy" : "up",
82
82
  // password: process.env.SECRET_PASSPHRASE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "module": "./lib/index.js",
6
6
  "scripts": {
@@ -18,6 +18,7 @@
18
18
  "./esbuild": "./lib/esbuild/index.js",
19
19
  "./fs": "./lib/fs/index.js",
20
20
  "./github": "./lib/github/index.js",
21
+ "./os": "./lib/os/index.js",
21
22
  "./shadcn": "./lib/shadcn/index.js",
22
23
  "./stripe": "./lib/stripe/index.js",
23
24
  "./test/bun": "./lib/test/bun.js",
package/src/ai/data.ts CHANGED
@@ -63,6 +63,12 @@ export interface DataProps<T extends Type<any, any>> {
63
63
  * Model configuration
64
64
  */
65
65
  model?: ModelConfig;
66
+
67
+ /**
68
+ * Whether to freeze the generated object
69
+ * @default false
70
+ */
71
+ freeze?: boolean;
66
72
  }
67
73
 
68
74
  /**
@@ -171,6 +177,10 @@ export const Data = Resource("ai::Object", async function <
171
177
  throw new Error("Either prompt or messages must be provided");
172
178
  }
173
179
 
180
+ if (this.phase === "update" && props.freeze) {
181
+ return this(this.output);
182
+ }
183
+
174
184
  // Create messages array if only prompt is provided
175
185
  const messages = props.messages || [{ role: "user", content: props.prompt! }];
176
186