| 1 | import * as cloudfunctions from "@distilled.cloud/gcp/cloudfunctions_v2"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as HttpClient from "effect/http/HttpClient"; |
| 5 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 6 | import * as Bundle from "../../Bundle/Bundle.ts"; |
| 7 | import { findCwdForBundle, resolveMainPath } from "../../Bundle/TempRoot.ts"; |
| 8 | import { sha256Object } from "../../Util/sha256.ts"; |
| 9 | import { zipFiles } from "../../Util/zip.ts"; |
| 10 | |
| 11 | /** |
| 12 | * Bundles an Effect-native `GCP.CloudFunctions.Function` `main` into a |
| 13 | * Node.js source archive and uploads it through `generateUploadUrl`. |
| 14 | * |
| 15 | * The archive holds the bundled `index.mjs` (the generated entry around |
| 16 | * `main`) and a `package.json` declaring the Functions Framework, which |
| 17 | * the Node.js buildpack runs with `--target=handler`. |
| 18 | * |
| 19 | * NOT exported from `index.ts`. |
| 20 | */ |
| 21 | |
| 22 | export const FUNCTION_ENTRY_POINT = "handler"; |
| 23 | export const DEFAULT_NODE_RUNTIME = "nodejs22"; |
| 24 | |
| 25 | export class FunctionSourceUploadFailed extends Data.TaggedError( |
| 26 | "GCP.CloudFunctions.FunctionSourceUploadFailed", |
| 27 | )<{ status: number; message: string }> {} |
| 28 | |
| 29 | /** |
| 30 | * Generated entry for an Effect-native Cloud Function: imports only |
| 31 | * `alchemy/Runtime/Bootstrap/CloudFunction` plus the user's `main`, and |
| 32 | * exports the Functions Framework target. The runtime flag is raised |
| 33 | * before the user's module evaluates. |
| 34 | */ |
| 35 | export const makeFunctionBootstrap = |
| 36 | (handler: string) => |
| 37 | (importPath: string): string => |
| 38 | ` |
| 39 | import { makeHandler } from "alchemy/Runtime/Bootstrap/CloudFunction"; |
| 40 | |
| 41 | globalThis.__ALCHEMY_RUNTIME__ = true; |
| 42 | const { ${handler}: entrypoint } = await import(${JSON.stringify(importPath)}); |
| 43 | |
| 44 | export const ${FUNCTION_ENTRY_POINT} = makeHandler(entrypoint); |
| 45 | `; |
| 46 | |
| 47 | const packageJson = `${JSON.stringify( |
| 48 | { |
| 49 | private: true, |
| 50 | type: "module", |
| 51 | main: "index.mjs", |
| 52 | dependencies: { "@google-cloud/functions-framework": "^3.4.0" }, |
| 53 | }, |
| 54 | null, |
| 55 | 2, |
| 56 | )}\n`; |
| 57 | |
| 58 | export const makeFunctionSource = Effect.gen(function* () { |
| 59 | const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin; |
| 60 | |
| 61 | /** Bundle `main` and hash the archive's content (no upload). */ |
| 62 | const bundle = Effect.fn(function* (options: { |
| 63 | main: string; |
| 64 | handler?: string; |
| 65 | build?: Bundle.BundleConfig; |
| 66 | isExternal?: boolean; |
| 67 | }) { |
| 68 | const realMain = yield* resolveMainPath(options.main); |
| 69 | const cwd = yield* findCwdForBundle(realMain); |
| 70 | const bootstrap = makeFunctionBootstrap(options.handler ?? "default"); |
| 71 | const output = yield* Bundle.build( |
| 72 | { |
| 73 | ...options.build?.input, |
| 74 | input: realMain, |
| 75 | cwd, |
| 76 | platform: "node", |
| 77 | resolve: { |
| 78 | conditionNames: ["node", "import", "module", "default"], |
| 79 | ...options.build?.input?.resolve, |
| 80 | }, |
| 81 | plugins: [ |
| 82 | options.build?.input?.plugins, |
| 83 | options.isExternal ? undefined : virtualEntryPlugin(bootstrap), |
| 84 | ], |
| 85 | }, |
| 86 | { |
| 87 | ...options.build?.output, |
| 88 | format: "esm", |
| 89 | sourcemap: options.build?.output?.sourcemap ?? false, |
| 90 | minify: options.build?.output?.minify ?? false, |
| 91 | entryFileNames: "index.mjs", |
| 92 | }, |
| 93 | options.build, |
| 94 | ); |
| 95 | const files = [ |
| 96 | ...output.files.map((file) => ({ |
| 97 | path: file.path, |
| 98 | content: |
| 99 | typeof file.content === "string" |
| 100 | ? new TextEncoder().encode(file.content) |
| 101 | : file.content, |
| 102 | })), |
| 103 | { path: "package.json", content: packageJson }, |
| 104 | ]; |
| 105 | const codeHash = (yield* sha256Object({ |
| 106 | bundle: output.hash, |
| 107 | packageJson, |
| 108 | })).slice(0, 16); |
| 109 | return { files, codeHash }; |
| 110 | }); |
| 111 | |
| 112 | /** Zip and upload the bundle; returns the `storageSource` to build from. */ |
| 113 | const upload = Effect.fn(function* (options: { |
| 114 | parent: string; |
| 115 | files: ReadonlyArray<{ path: string; content: string | Uint8Array }>; |
| 116 | kmsKeyName?: string; |
| 117 | }) { |
| 118 | const archive = yield* zipFiles(options.files); |
| 119 | const target = |
| 120 | yield* cloudfunctions.generateUploadUrlProjectsLocationsFunctions({ |
| 121 | parent: options.parent, |
| 122 | body: { |
| 123 | environment: "GEN_2", |
| 124 | ...(options.kmsKeyName ? { kmsKeyName: options.kmsKeyName } : {}), |
| 125 | }, |
| 126 | }); |
| 127 | if (target.uploadUrl === undefined || target.storageSource === undefined) { |
| 128 | return yield* new FunctionSourceUploadFailed({ |
| 129 | status: 0, |
| 130 | message: "generateUploadUrl returned no uploadUrl/storageSource", |
| 131 | }); |
| 132 | } |
| 133 | const http = yield* HttpClient.HttpClient; |
| 134 | const response = yield* http |
| 135 | .execute( |
| 136 | HttpClientRequest.put(target.uploadUrl).pipe( |
| 137 | HttpClientRequest.bodyUint8Array( |
| 138 | new Uint8Array(archive), |
| 139 | "application/zip", |
| 140 | ), |
| 141 | ), |
| 142 | ) |
| 143 | .pipe( |
| 144 | Effect.mapError( |
| 145 | (cause) => |
| 146 | new FunctionSourceUploadFailed({ |
| 147 | status: 0, |
| 148 | message: String(cause), |
| 149 | }), |
| 150 | ), |
| 151 | ); |
| 152 | if (response.status < 200 || response.status >= 300) { |
| 153 | const text = yield* response.text.pipe(Effect.orElseSucceed(() => "")); |
| 154 | return yield* new FunctionSourceUploadFailed({ |
| 155 | status: response.status, |
| 156 | message: text.slice(0, 500), |
| 157 | }); |
| 158 | } |
| 159 | return target.storageSource; |
| 160 | }); |
| 161 | |
| 162 | return { bundle, upload }; |
| 163 | }); |