1import type {
2 ALBEvent,
3 ALBResult,
4 APIGatewayProxyEvent,
5 APIGatewayProxyResult,
6 LambdaFunctionURLEvent,
7 LambdaFunctionURLResult,
8} from "aws-lambda";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11import type { Scope } from "effect/Scope";
12import * as HttpMiddleware from "effect/http/HttpMiddleware";
13import * as HttpServerRequest from "effect/http/HttpServerRequest";
14import * as HttpServerResponse from "effect/http/HttpServerResponse";
15import * as Http from "../../Http.ts";
16
17export const isFunctionURLEvent = (
18 event: any,
19): event is LambdaFunctionURLEvent => {
20 return event.requestContext?.http?.method !== undefined;
21};
22
23/**
24 * REST API (v1) AWS_PROXY events have a top-level `httpMethod` and a
25 * `requestContext.resourcePath` field. They lack the `requestContext.http.*`
26 * shape of Function URL / HTTP API (v2) events.
27 */
28export const isApiGatewayProxyEvent = (
29 event: any,
30): event is APIGatewayProxyEvent => {
31 return (
32 typeof event?.httpMethod === "string" &&
33 event?.requestContext?.resourcePath !== undefined
34 );
35};
36
37/**
38 * Application Load Balancer target events carry a `requestContext.elb` marker
39 * (the target group ARN) and a top-level `httpMethod` + `path`.
40 */
41export const isAlbEvent = (event: any): event is ALBEvent => {
42 return (
43 typeof event?.httpMethod === "string" &&
44 event?.requestContext?.elb !== undefined
45 );
46};
47
48export const makeFunctionHttpHandler = <Req>(handler: Http.HttpEffect<Req>) => {
49 // `HttpMiddleware.tracer` creates the `http.server` root span per request
50 // (continuing an incoming `traceparent`), matching the Worker bridge's
51 // fetch path. With the default no-op tracer this is free; with a telemetry
52 // exporter installed the span is exported when the invocation scope
53 // flushes.
54 const safeHandler = HttpMiddleware.tracer(Http.safeHttpEffect(handler));
55 return (
56 event: any,
57 ):
58 | Effect.Effect<
59 ALBResult | APIGatewayProxyResult | LambdaFunctionURLResult,
60 never,
61 Exclude<
62 Effect.Services<typeof handler>,
63 HttpServerRequest.HttpServerRequest | Scope
64 >
65 >
66 | undefined => {
67 if (isFunctionURLEvent(event)) {
68 const webRequest = functionUrlEventToWebRequest(event);
69 const request = HttpServerRequest.fromWeb(webRequest).modify({
70 url: webRequest.url,
71 remoteAddress: Option.some(event.requestContext.http.sourceIp),
72 });
73 return safeHandler.pipe(
74 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
75 Effect.flatMap(toLambdaFunctionURLResult),
76 ) as Effect.Effect<
77 LambdaFunctionURLResult,
78 never,
79 Exclude<
80 Effect.Services<typeof handler>,
81 HttpServerRequest.HttpServerRequest | Scope
82 >
83 >;
84 }
85 if (isAlbEvent(event)) {
86 const webRequest = albEventToWebRequest(event);
87 const request = HttpServerRequest.fromWeb(webRequest).modify({
88 url: webRequest.url,
89 });
90 return safeHandler.pipe(
91 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
92 // The ALB result shape is the API Gateway v1 result shape (statusCode,
93 // headers, multiValueHeaders, body, isBase64Encoded); ALB reads
94 // whichever of headers/multiValueHeaders matches the target group's
95 // multi-value-headers setting.
96 Effect.flatMap(toApiGatewayProxyResult),
97 ) as Effect.Effect<
98 ALBResult,
99 never,
100 Exclude<
101 Effect.Services<typeof handler>,
102 HttpServerRequest.HttpServerRequest | Scope
103 >
104 >;
105 }
106 if (isApiGatewayProxyEvent(event)) {
107 const webRequest = apiGatewayProxyEventToWebRequest(event);
108 const request = HttpServerRequest.fromWeb(webRequest).modify({
109 url: webRequest.url,
110 remoteAddress: Option.fromNullishOr(
111 event.requestContext?.identity?.sourceIp,
112 ),
113 });
114 return safeHandler.pipe(
115 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
116 Effect.flatMap(toApiGatewayProxyResult),
117 ) as Effect.Effect<
118 APIGatewayProxyResult,
119 never,
120 Exclude<
121 Effect.Services<typeof handler>,
122 HttpServerRequest.HttpServerRequest | Scope
123 >
124 >;
125 }
126 };
127};
128
129const functionUrlEventToWebRequest = (
130 event: LambdaFunctionURLEvent,
131): Request => {
132 // `requestContext.http.protocol` is the HTTP version ("HTTP/1.1"), never a
133 // URL scheme — without `x-forwarded-proto` (real Function URLs always set
134 // it; local emulators may not) fall back to https.
135 const protocol = event.headers["x-forwarded-proto"] ?? "https";
136 const host = event.headers.host ?? event.requestContext.domainName;
137 const url = `${protocol}://${host}${event.rawPath}${event.rawQueryString ? `?${event.rawQueryString}` : ""}`;
138 const method = event.requestContext.http.method;
139 const headers = new Headers();
140 for (const [key, value] of Object.entries(event.headers)) {
141 if (value !== undefined) {
142 headers.set(key, value);
143 }
144 }
145 if (event.cookies?.length) {
146 headers.set("cookie", event.cookies.join("; "));
147 }
148
149 let body: string | ArrayBuffer | undefined;
150 if (event.body !== undefined) {
151 body = event.isBase64Encoded
152 ? Uint8Array.from(atob(event.body), (c) => c.charCodeAt(0)).buffer
153 : event.body;
154 }
155
156 return new Request(url, {
157 method,
158 headers,
159 body: body && method !== "GET" && method !== "HEAD" ? body : undefined,
160 });
161};
162
163const albEventToWebRequest = (event: ALBEvent): Request => {
164 const headers = new Headers();
165 if (event.multiValueHeaders) {
166 for (const [key, values] of Object.entries(event.multiValueHeaders)) {
167 if (!values) continue;
168 for (const value of values) {
169 if (value !== undefined && value !== null) {
170 headers.append(key, value);
171 }
172 }
173 }
174 }
175 if (event.headers) {
176 for (const [key, value] of Object.entries(event.headers)) {
177 if (value !== undefined && value !== null && !headers.has(key)) {
178 headers.set(key, value);
179 }
180 }
181 }
182
183 const protocol =
184 headers.get("x-forwarded-proto") ??
185 headers.get("X-Forwarded-Proto") ??
186 "http";
187 const host = headers.get("host") ?? headers.get("Host") ?? "alb";
188
189 // Unlike API Gateway, ALB does NOT URL-decode the path or query-string
190 // parameters — they arrive still percent-encoded, so join them verbatim
191 // (re-encoding would double-encode).
192 const queryParts: string[] = [];
193 if (event.multiValueQueryStringParameters) {
194 for (const [k, vs] of Object.entries(
195 event.multiValueQueryStringParameters,
196 )) {
197 if (!vs) continue;
198 for (const v of vs) {
199 queryParts.push(`${k}=${v ?? ""}`);
200 }
201 }
202 } else if (event.queryStringParameters) {
203 for (const [k, v] of Object.entries(event.queryStringParameters)) {
204 if (v === undefined || v === null) continue;
205 queryParts.push(`${k}=${v}`);
206 }
207 }
208 const queryString = queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
209
210 const url = `${protocol}://${host}${event.path ?? "/"}${queryString}`;
211 const method = event.httpMethod;
212
213 let body: string | ArrayBuffer | undefined;
214 if (event.body !== null && event.body !== undefined) {
215 body = event.isBase64Encoded
216 ? Uint8Array.from(atob(event.body), (c) => c.charCodeAt(0)).buffer
217 : event.body;
218 }
219
220 return new Request(url, {
221 method,
222 headers,
223 body: body && method !== "GET" && method !== "HEAD" ? body : undefined,
224 });
225};
226
227const apiGatewayProxyEventToWebRequest = (
228 event: APIGatewayProxyEvent,
229): Request => {
230 const headers = new Headers();
231 if (event.multiValueHeaders) {
232 for (const [key, values] of Object.entries(event.multiValueHeaders)) {
233 if (!values) continue;
234 for (const value of values) {
235 if (value !== undefined && value !== null) {
236 headers.append(key, value);
237 }
238 }
239 }
240 }
241 if (event.headers) {
242 for (const [key, value] of Object.entries(event.headers)) {
243 if (value !== undefined && value !== null && !headers.has(key)) {
244 headers.set(key, value);
245 }
246 }
247 }
248
249 const protocol =
250 headers.get("x-forwarded-proto") ??
251 headers.get("X-Forwarded-Proto") ??
252 "https";
253 const host =
254 headers.get("host") ??
255 headers.get("Host") ??
256 event.requestContext.domainName ??
257 "lambda";
258 const stage = event.requestContext.stage;
259 // API Gateway prefixes paths with the stage when invoked via the default
260 // execute-api endpoint; `event.path` already contains that. Use it as-is.
261 const path = event.path ?? "/";
262
263 const queryParts: string[] = [];
264 if (event.multiValueQueryStringParameters) {
265 for (const [k, vs] of Object.entries(
266 event.multiValueQueryStringParameters,
267 )) {
268 if (!vs) continue;
269 for (const v of vs) {
270 queryParts.push(
271 `${encodeURIComponent(k)}=${encodeURIComponent(v ?? "")}`,
272 );
273 }
274 }
275 } else if (event.queryStringParameters) {
276 for (const [k, v] of Object.entries(event.queryStringParameters)) {
277 if (v === undefined || v === null) continue;
278 queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
279 }
280 }
281 const queryString = queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
282
283 const url = `${protocol}://${host}${path}${queryString}`;
284 const method = event.httpMethod;
285
286 let body: string | ArrayBuffer | undefined;
287 if (event.body !== null && event.body !== undefined) {
288 body = event.isBase64Encoded
289 ? Uint8Array.from(atob(event.body), (c) => c.charCodeAt(0)).buffer
290 : event.body;
291 }
292
293 // Reference `stage` so static analysis sees it as used; the variable is
294 // useful when constructing fully-qualified URLs in downstream code.
295 void stage;
296
297 return new Request(url, {
298 method,
299 headers,
300 body: body && method !== "GET" && method !== "HEAD" ? body : undefined,
301 });
302};
303
304const toLambdaFunctionURLResult = (
305 response: HttpServerResponse.HttpServerResponse,
306): Effect.Effect<LambdaFunctionURLResult> =>
307 Effect.gen(function* () {
308 const context = yield* Effect.context();
309 const webResponse = HttpServerResponse.toWeb(response, { context });
310 const headers = new Headers(webResponse.headers);
311 const cookies =
312 typeof headers.getSetCookie === "function" ? headers.getSetCookie() : [];
313
314 headers.delete("set-cookie");
315
316 if (!webResponse.body) {
317 return {
318 statusCode: webResponse.status,
319 headers: Object.fromEntries(headers.entries()),
320 cookies: cookies.length > 0 ? cookies : undefined,
321 };
322 }
323
324 const bytes = new Uint8Array(
325 yield* Effect.promise(() => webResponse.arrayBuffer()),
326 );
327 const isTextual = isTextualContentType(headers.get("content-type"));
328 const body =
329 bytes.length === 0
330 ? undefined
331 : isTextual
332 ? new TextDecoder().decode(bytes)
333 : Buffer.from(bytes).toString("base64");
334
335 return {
336 statusCode: webResponse.status,
337 headers: Object.fromEntries(headers.entries()),
338 body,
339 cookies: cookies.length > 0 ? cookies : undefined,
340 isBase64Encoded: body !== undefined && !isTextual ? true : undefined,
341 };
342 });
343
344const toApiGatewayProxyResult = (
345 response: HttpServerResponse.HttpServerResponse,
346): Effect.Effect<APIGatewayProxyResult> =>
347 Effect.gen(function* () {
348 const context = yield* Effect.context();
349 const webResponse = HttpServerResponse.toWeb(response, { context });
350 const headers = new Headers(webResponse.headers);
351
352 const multiValueHeaders: { [name: string]: string[] } = {};
353 const singleHeaders: { [name: string]: string } = {};
354 for (const [key, value] of headers.entries()) {
355 if (multiValueHeaders[key]) {
356 multiValueHeaders[key].push(value);
357 } else {
358 multiValueHeaders[key] = [value];
359 singleHeaders[key] = value;
360 }
361 }
362
363 if (!webResponse.body) {
364 return {
365 statusCode: webResponse.status,
366 headers: singleHeaders,
367 multiValueHeaders,
368 body: "",
369 };
370 }
371
372 const bytes = new Uint8Array(
373 yield* Effect.promise(() => webResponse.arrayBuffer()),
374 );
375 const isTextual = isTextualContentType(headers.get("content-type"));
376 const isBase64 = bytes.length > 0 && !isTextual;
377 const body =
378 bytes.length === 0
379 ? ""
380 : isTextual
381 ? new TextDecoder().decode(bytes)
382 : Buffer.from(bytes).toString("base64");
383
384 return {
385 statusCode: webResponse.status,
386 headers: singleHeaders,
387 multiValueHeaders,
388 body,
389 isBase64Encoded: isBase64,
390 };
391 });
392
393const isTextualContentType = (contentType: string | null): boolean => {
394 if (!contentType) {
395 return false;
396 }
397
398 const normalized = contentType.toLowerCase();
399 return (
400 normalized.startsWith("text/") ||
401 normalized.includes("json") ||
402 normalized.includes("xml") ||
403 normalized.includes("javascript") ||
404 normalized.includes("form-urlencoded")
405 );
406};