1/**
2 * Runtime bootstrap for Effect-native `GCP.CloudFunctions.Function`. The
3 * generated entry imports this module and the user's `main`, and exports
4 * {@link makeHandler}'s result as the Functions Framework target.
5 *
6 * The Node.js Functions Framework owns the HTTP server, so the program's
7 * `HttpServer` here is a capture: `serve` records the composed handler
8 * (event-source listeners, then the user's `fetch`) and each framework
9 * request is bridged into it as a web `Request`. The program — built once
10 * per instance, like the Cloud Run bootstrap — runs in the background for
11 * the instance's lifetime.
12 *
13 * Runtime credentials come from the metadata server: the function's
14 * runtime service account.
15 */
16import { NodeServices } from "@effect/platform-node";
17import * as ConfigProvider from "effect/ConfigProvider";
18import * as Context from "effect/Context";
19import * as Deferred from "effect/Deferred";
20import * as Effect from "effect/Effect";
21import * as Exit from "effect/Exit";
22import * as Layer from "effect/Layer";
23import * as FetchHttpClient from "effect/http/FetchHttpClient";
24import * as Logger from "effect/Logger";
25import * as Scope from "effect/Scope";
26import * as EffectHttp from "effect/http/HttpEffect";
27import * as HttpServerRequest from "effect/http/HttpServerRequest";
28import * as HttpServerResponse from "effect/http/HttpServerResponse";
29import { fromMetadataServer } from "../../GCP/MetadataCredentials.ts";
30import { HttpServer, safeHttpEffect } from "../../Http.ts";
31import { reifyBoundConfigProvider } from "../../Runtime.ts";
32import { entrypointLayer, resolveProgram, stackFromEnv } from "./Process.ts";
33
34/** Web-standard dispatcher over the program's composed HTTP handler. */
35type Dispatch = (request: Request) => Promise<Response>;
36
37/** The subset of the Functions Framework (Express) request/response used. */
38interface FrameworkRequest {
39 method: string;
40 originalUrl?: string;
41 url: string;
42 headers: Record<string, string | string[] | undefined>;
43 rawBody?: Uint8Array;
44}
45interface FrameworkResponse {
46 status(code: number): FrameworkResponse;
47 setHeader(name: string, value: string | string[]): void;
48 end(body?: Uint8Array): void;
49}
50
51const toWebRequest = (req: FrameworkRequest): Request => {
52 const headers = new Headers();
53 for (const [name, value] of Object.entries(req.headers)) {
54 if (value === undefined) continue;
55 for (const item of Array.isArray(value) ? value : [value]) {
56 headers.append(name, item);
57 }
58 }
59 const host = headers.get("x-forwarded-host") ?? headers.get("host");
60 const url = `https://${host ?? "function"}${req.originalUrl ?? req.url}`;
61 const hasBody = req.method !== "GET" && req.method !== "HEAD";
62 return new Request(url, {
63 method: req.method,
64 headers,
65 body: hasBody && req.rawBody ? new Uint8Array(req.rawBody) : undefined,
66 });
67};
68
69const writeResponse = async (res: FrameworkResponse, response: Response) => {
70 res.status(response.status);
71 const cookies = response.headers.getSetCookie();
72 if (cookies.length > 0) res.setHeader("set-cookie", cookies);
73 response.headers.forEach((value, name) => {
74 if (name !== "set-cookie") res.setHeader(name, value);
75 });
76 res.end(new Uint8Array(await response.arrayBuffer()));
77};
78
79/**
80 * `HttpServer` whose `serve` publishes a dispatcher instead of binding a
81 * port. Requests run under the program's context and their own scope.
82 */
83const captureServer = (dispatch: Deferred.Deferred<Dispatch>) =>
84 Layer.succeed(HttpServer, {
85 serve: (handler) =>
86 Effect.gen(function* () {
87 const context = yield* Effect.context<any>();
88 const safe = safeHttpEffect(handler as any);
89 const run: Dispatch = (webRequest) =>
90 Effect.runPromise(
91 Effect.gen(function* () {
92 const request = HttpServerRequest.fromWeb(webRequest);
93 const scope = yield* Scope.make();
94 const out = yield* Deferred.make<Response>();
95 yield* EffectHttp.toHandled(safe, (req, response) =>
96 Deferred.succeed(
97 out,
98 HttpServerResponse.toWeb(
99 EffectHttp.scopeTransferToStream(response),
100 { withoutBody: req.method === "HEAD", context },
101 ),
102 ),
103 ).pipe(
104 Effect.provideService(
105 HttpServerRequest.HttpServerRequest,
106 request,
107 ),
108 Effect.provideService(Scope.Scope, scope),
109 );
110 const response = yield* Deferred.await(out);
111 yield* Scope.close(scope, Exit.void);
112 return response;
113 }).pipe(Effect.provideContext(context as Context.Context<never>)),
114 );
115 yield* Deferred.succeed(dispatch, run);
116 }) as any,
117 });
118
119/**
120 * Build the Functions Framework target for `entrypoint`. The program starts
121 * when the framework loads the module and is reused by every request on
122 * the instance.
123 */
124export const makeHandler = (entrypoint: unknown) => {
125 const start = (): Promise<Dispatch> => {
126 const dispatch = Deferred.makeUnsafe<Dispatch>();
127 const platform = Layer.mergeAll(
128 NodeServices.layer,
129 FetchHttpClient.layer,
130 Logger.layer([Logger.consolePretty()]),
131 );
132 const program = resolveProgram("program", { telemetry: true }).pipe(
133 Effect.provide(
134 entrypointLayer(entrypoint).pipe(
135 Layer.provideMerge(stackFromEnv),
136 Layer.provideMerge(fromMetadataServer()),
137 Layer.provideMerge(captureServer(dispatch)),
138 Layer.provideMerge(platform),
139 Layer.provideMerge(
140 Layer.succeed(
141 ConfigProvider.ConfigProvider,
142 reifyBoundConfigProvider(ConfigProvider.fromEnv(), process.env),
143 ),
144 ),
145 ),
146 ),
147 Effect.scoped,
148 );
149 Effect.runFork(
150 program.pipe(
151 Effect.tapCause((cause) =>
152 Effect.logError("Cloud Function program failed", cause),
153 ),
154 // A program that ends (or fails) without serving never answers;
155 // fail pending and future requests instead of hanging them.
156 Effect.onExit(() =>
157 Deferred.die(
158 dispatch,
159 new Error("Cloud Function program exited without serving HTTP"),
160 ),
161 ),
162 ) as Effect.Effect<unknown>,
163 );
164 return Effect.runPromise(Deferred.await(dispatch));
165 };
166
167 const ready = start();
168 // Surface a failed boot through the requests, not an unhandled rejection.
169 ready.catch(() => undefined);
170
171 return async (req: FrameworkRequest, res: FrameworkResponse) => {
172 try {
173 const dispatch = await ready;
174 await writeResponse(res, await dispatch(toWebRequest(req)));
175 } catch (error) {
176 console.error("Cloud Function request failed", error);
177 res.status(500).end();
178 }
179 };
180};