1import * as cloudfunctions from "@distilled.cloud/gcp/cloudfunctions_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as HttpClient from "effect/http/HttpClient";
5import * as HttpClientRequest from "effect/http/HttpClientRequest";
6import * as Bundle from "../../Bundle/Bundle.ts";
7import { findCwdForBundle, resolveMainPath } from "../../Bundle/TempRoot.ts";
8import { sha256Object } from "../../Util/sha256.ts";
9import { 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
22export const FUNCTION_ENTRY_POINT = "handler";
23export const DEFAULT_NODE_RUNTIME = "nodejs22";
24
25export 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 */
35export const makeFunctionBootstrap =
36 (handler: string) =>
37 (importPath: string): string =>
38 `
39import { makeHandler } from "alchemy/Runtime/Bootstrap/CloudFunction";
40
41globalThis.__ALCHEMY_RUNTIME__ = true;
42const { ${handler}: entrypoint } = await import(${JSON.stringify(importPath)});
43
44export const ${FUNCTION_ENTRY_POINT} = makeHandler(entrypoint);
45`;
46
47const 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
58export 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});