Skip to content

Commit 5a69d42

Browse files
committed
Merge branch 'main' into beta
2 parents d0c40f1 + a0ece97 commit 5a69d42

5 files changed

Lines changed: 294 additions & 28 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect-aws/lambda": minor
3+
---
4+
5+
Add `LambdaHandler.streamFromHttpApi` for streaming `HttpApi` responses through Lambda Function URLs with response streaming enabled.

packages/lambda/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@
3030

3131
- [#198](https://github.com/floydspace/effect-aws/pull/198) [`57b06d0`](https://github.com/floydspace/effect-aws/commit/57b06d0474b35ae754e8e1b0a317e15669191779) Thanks [@floydspace](https://github.com/floydspace)! - Migrate to effect v4
3232

33+
## 1.7.0
34+
35+
### Minor Changes
36+
37+
- [#218](https://github.com/floydspace/effect-aws/pull/218) [`1e12d67`](https://github.com/floydspace/effect-aws/commit/1e12d6795b51c3a85a526d80dd13343a7806dae9) Thanks [@yotamishak](https://github.com/yotamishak)! - Add `LambdaHandler.streamFromHttpApi` for streaming `HttpApi` responses through Lambda Function URLs with response streaming enabled.
38+
3339
## 1.6.0
3440

3541
### Minor Changes

packages/lambda/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,54 @@ export const handler: Handler = LambdaHandler.stream({
113113
layer: S3.defaultLayer
114114
})
115115
```
116+
117+
HttpApi streaming with Lambda Function URLs:
118+
119+
```ts
120+
import { LambdaHandler } from "@effect-aws/lambda"
121+
import {
122+
HttpApi,
123+
HttpApiBuilder,
124+
HttpApiEndpoint,
125+
HttpApiGroup,
126+
HttpApiSchema,
127+
HttpServer,
128+
HttpServerResponse
129+
} from "@effect/platform"
130+
import { Effect, Layer, Stream } from "effect"
131+
132+
const encoder = new TextEncoder()
133+
134+
const streamEndpoint = HttpApiEndpoint.get("stream")`/stream`.addSuccess(
135+
HttpApiSchema.Text()
136+
)
137+
const pingEndpoint = HttpApiEndpoint.get("ping")`/ping`.addSuccess(
138+
HttpApiSchema.Text()
139+
)
140+
141+
const group = HttpApiGroup.make("api").add(streamEndpoint).add(pingEndpoint)
142+
const api = HttpApi.make("Api").add(group)
143+
144+
const routes = HttpApiBuilder.group(api, "api", (handlers) =>
145+
handlers
146+
.handle("ping", () => Effect.succeed("pong\n"))
147+
.handleRaw("stream", () =>
148+
Effect.succeed(
149+
HttpServerResponse.stream(
150+
Stream.fromIterable(["chunk 1\n", "chunk 2\n", "done\n"]).pipe(
151+
Stream.map((chunk) => encoder.encode(chunk))
152+
),
153+
{ contentType: "text/plain; charset=utf-8" }
154+
)
155+
)
156+
)
157+
)
158+
159+
const apiLive = HttpApiBuilder.api(api).pipe(Layer.provide(routes))
160+
161+
export const handler = LambdaHandler.streamFromHttpApi(
162+
Layer.mergeAll(apiLive, HttpServer.layerContext)
163+
)
164+
```
165+
166+
Use `handle` for normal schema responses and `handleRaw` for streaming `HttpServerResponse` values. The Lambda must be invoked through a Lambda Function URL with response streaming enabled, for example `InvokeMode: RESPONSE_STREAM`. API Gateway HTTP API integrations do not support Lambda response streaming.

packages/lambda/src/LambdaHandler.ts

Lines changed: 127 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import type * as Cause from "effect/Cause";
55
import * as Context from "effect/Context";
66
import * as Effect from "effect/Effect";
77
import * as Layer from "effect/Layer";
8+
import type * as PlatformError from "effect/PlatformError";
89
import * as Predicate from "effect/Predicate";
910
import type { HttpMiddleware } from "effect/unstable/http";
1011
import { HttpRouter } from "effect/unstable/http";
12+
import { Readable } from "node:stream";
1113
import { getEventSource } from "./internal/index.js";
1214
import * as internal from "./internal/lambdaHandler.js";
13-
import { pipeTo } from "./internal/stream.js";
15+
import { pipeline, pipeTo } from "./internal/stream.js";
1416
import type { EventSource, ResponseValues } from "./internal/types.js";
1517
import { encodeBase64, isContentEncodingBinary, isContentTypeBinary } from "./internal/utils.js";
1618
import * as LambdaRuntime from "./LambdaRuntime.js";
@@ -207,6 +209,47 @@ type WebHandler = (
207209
) => Promise<globalThis.Response>;
208210
const WebHandler = Context.Service<WebHandler>("@effect-aws/lambda/WebHandler");
209211

212+
type EffectStreamifyHandler<T, R, E = never> = (
213+
event: T,
214+
responseStream: awslambda.HttpResponseStream,
215+
context: LambdaContext,
216+
) => Effect.Effect<void, E, R>;
217+
218+
const requestFromEvent = (event: LambdaHandler.Event): Request => {
219+
const eventSource = getEventSource(event) as EventSource<LambdaHandler.Event, LambdaHandler.Result>;
220+
const requestValues = eventSource.getRequest(event);
221+
222+
return new Request(
223+
`https://${requestValues.remoteAddress}${requestValues.path}`,
224+
{
225+
method: requestValues.method,
226+
headers: requestValues.headers,
227+
body: requestValues.body,
228+
},
229+
);
230+
};
231+
232+
const responseHeaders = (res: Response) => {
233+
const headers: Record<string, string> = {};
234+
const cookies = res.headers.has("set-cookie")
235+
? res.headers.getSetCookie
236+
? res.headers.getSetCookie()
237+
: Array.from((res.headers as any).entries())
238+
.filter(([k]: any) => k === "set-cookie")
239+
.map(([, v]: any) => v)
240+
: [];
241+
242+
if (Array.isArray(cookies) && cookies.length > 0) {
243+
res.headers.delete("set-cookie");
244+
}
245+
246+
res.headers.forEach((value, key) => {
247+
headers[key] = value;
248+
});
249+
250+
return { headers, cookies };
251+
};
252+
210253
/**
211254
* Construct an `WebHandler` from an `HttpRouter` instance.
212255
*
@@ -238,16 +281,7 @@ export const httpApiHandler: EffectHandler<
238281
> = (event, context) =>
239282
Effect.gen(function*() {
240283
const eventSource = getEventSource(event) as EventSource<LambdaHandler.Event, LambdaHandler.Result>;
241-
const requestValues = eventSource.getRequest(event);
242-
243-
const req = new Request(
244-
`https://${requestValues.remoteAddress}${requestValues.path}`,
245-
{
246-
method: requestValues.method,
247-
headers: requestValues.headers,
248-
body: requestValues.body,
249-
},
250-
);
284+
const req = requestFromEvent(event);
251285

252286
const handler = yield* Effect.service(WebHandler);
253287

@@ -270,34 +304,59 @@ export const httpApiHandler: EffectHandler<
270304
? encodeBase64(yield* Effect.promise(() => res.arrayBuffer()))
271305
: yield* Effect.promise(() => res.text());
272306

273-
const headers: ResponseValues<LambdaHandler.Event>["headers"] = {};
307+
const { cookies, headers } = responseHeaders(res);
308+
const responseHeadersMap: ResponseValues<LambdaHandler.Event>["headers"] = headers;
274309

275-
if (res.headers.has("set-cookie")) {
276-
const cookies = res.headers.getSetCookie
277-
? res.headers.getSetCookie()
278-
: Array.from((res.headers as any).entries())
279-
.filter(([k]: any) => k === "set-cookie")
280-
.map(([, v]: any) => v);
281-
282-
if (Array.isArray(cookies)) {
283-
headers["set-cookie"] = cookies;
284-
res.headers.delete("set-cookie");
285-
}
310+
if (cookies.length > 0) {
311+
responseHeadersMap["set-cookie"] = cookies;
286312
}
287313

288-
res.headers.forEach((value, key) => {
289-
headers[key] = value;
290-
});
291-
292314
return eventSource.getResponse({
293315
event,
294316
statusCode: res.status,
295317
body,
296-
headers,
318+
headers: responseHeadersMap,
297319
isBase64Encoded,
298320
});
299321
});
300322

323+
/**
324+
* Construct a streaming `StreamifyHandler` from an `HttpApi` instance for Lambda Function URLs.
325+
*
326+
* @since 1.7.0
327+
* @category constructors
328+
*/
329+
export const httpApiStreamHandler: EffectStreamifyHandler<
330+
LambdaHandler.Event,
331+
WebHandler,
332+
Cause.UnknownError | PlatformError.PlatformError
333+
> = (event, responseStream, context) =>
334+
Effect.gen(function*() {
335+
const req = requestFromEvent(event);
336+
337+
const handler = yield* Effect.service(WebHandler);
338+
339+
const ctx = Context.empty().pipe(
340+
Context.add(internal.lambdaEventTag, event),
341+
Context.add(internal.lambdaContextTag, context),
342+
);
343+
344+
const res: globalThis.Response = yield* Effect.tryPromise(() => handler(req, ctx));
345+
346+
const { cookies, headers } = responseHeaders(res);
347+
const httpResponseStream = global.awslambda.HttpResponseStream.from(responseStream, {
348+
statusCode: res.status,
349+
headers,
350+
cookies,
351+
});
352+
353+
if (res.body) {
354+
yield* pipeline(Readable.fromWeb(res.body as any), httpResponseStream);
355+
} else {
356+
httpResponseStream.end();
357+
}
358+
});
359+
301360
/**
302361
* Construct a lambda handler from an `HttpApi` instance.
303362
*
@@ -327,3 +386,43 @@ export const fromHttpApi = <LA, LE>(
327386
const WebHandlerLive = Layer.effect(WebHandler, makeWebHandler(layer, options));
328387
return make({ handler: httpApiHandler, layer: WebHandlerLive, memoMap: options?.memoMap });
329388
};
389+
390+
/**
391+
* Construct a streaming lambda handler from an `HttpApi` instance for Lambda Function URLs.
392+
*
393+
* Lambda response streaming is supported by Lambda Function URLs with response streaming enabled,
394+
* not by API Gateway HTTP API integrations.
395+
*
396+
* @example
397+
* ```ts
398+
* import { LambdaHandler } from "@effect-aws/lambda"
399+
* import { HttpApi, HttpApiBuilder, HttpServer } from "@effect/platform"
400+
* import { Layer } from "effect"
401+
*
402+
* class MyApi extends HttpApi.make("api") {}
403+
*
404+
* const MyApiLive = HttpApiBuilder.api(MyApi)
405+
*
406+
* export const handler = LambdaHandler.streamFromHttpApi(
407+
* Layer.mergeAll(
408+
* MyApiLive,
409+
* HttpServer.layerContext
410+
* )
411+
* )
412+
* ```
413+
*
414+
* @since 1.7.0
415+
* @category constructors
416+
*/
417+
export const streamFromHttpApi = <LA, LE>(
418+
layer: Layer.Layer<LA, LE, HttpRouter.HttpRouter>,
419+
options?: HttpApiOptions,
420+
): Handler<LambdaHandler.Event, void> => {
421+
const WebHandlerLive = Layer.effect(WebHandler, makeWebHandler(layer, options));
422+
const runtime = LambdaRuntime.fromLayer(WebHandlerLive, { memoMap: options?.memoMap });
423+
424+
return global.awslambda?.streamifyResponse(async (event, responseStream, context) => {
425+
context.callbackWaitsForEmptyEventLoop = false;
426+
return httpApiStreamHandler(event, responseStream, context).pipe(runtime.runPromise);
427+
});
428+
};

packages/lambda/test/LambdaHandler.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as Layer from "effect/Layer";
1414
import * as Schema from "effect/Schema";
1515
import { HttpEffect, HttpServer, HttpServerResponse } from "effect/unstable/http";
1616
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi";
17+
import { Writable } from "node:stream";
1718
import { describe, expect, it, vi } from "vitest";
1819
import { albEvent } from "./fixtures/alb-event.js";
1920
import { apiGatewayV1Event } from "./fixtures/api-gateway-v1-event.js";
@@ -339,4 +340,108 @@ describe("LambdaHandler", () => {
339340
);
340341
});
341342
});
343+
344+
describe("LambdaHandler.streamFromHttpApi", () => {
345+
it("should stream Lambda Function URL responses", async () => {
346+
const originalAwslambda = global.awslambda;
347+
const chunks: Array<Buffer> = [];
348+
let responseMetadata: {
349+
readonly statusCode: number;
350+
readonly headers: Record<string, string>;
351+
readonly cookies: Array<string>;
352+
} | undefined;
353+
type TestStreamifyHandler = (
354+
event: unknown,
355+
responseStream: awslambda.HttpResponseStream,
356+
context: LambdaContext,
357+
) => Promise<void>;
358+
359+
const streamifyResponse = vi.fn(
360+
(streamHandler: TestStreamifyHandler) => async (event: unknown, context: LambdaContext) => {
361+
const responseStream = new Writable({
362+
write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
363+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
364+
callback();
365+
},
366+
});
367+
368+
await streamHandler(event, responseStream as awslambda.HttpResponseStream, context);
369+
},
370+
);
371+
372+
const from = vi.fn((
373+
responseStream: awslambda.HttpResponseStream,
374+
metadata: NonNullable<typeof responseMetadata>,
375+
) => {
376+
responseMetadata = metadata;
377+
return responseStream;
378+
});
379+
380+
(global as any).awslambda = {
381+
streamifyResponse,
382+
HttpResponseStream: { from },
383+
};
384+
385+
try {
386+
const context = {
387+
functionVersion: "$LATEST",
388+
functionName: "demo-effect-app-dev-api",
389+
invokedFunctionArn: "arn:aws:lambda:eu-fake-1:000000000000:function:demo-effect-app-dev-api",
390+
awsRequestId: "8ad41330-f092-4037-bc7c-63ffb7d6d4e7",
391+
} as LambdaContext;
392+
393+
const hello = HttpApiEndpoint.post("hello", `/my/path`, {
394+
success: Schema.String.pipe(HttpApiSchema.asText()),
395+
});
396+
397+
const quotesGroup = HttpApiGroup.make("hello").add(hello);
398+
399+
const MyApi = HttpApi.make("MyApi").add(quotesGroup);
400+
401+
const HelloLive = HttpApiBuilder.group(
402+
MyApi,
403+
"hello",
404+
(handlers) =>
405+
handlers.handle(
406+
"hello",
407+
() =>
408+
HttpEffect.appendPreResponseHandler((_req, response) =>
409+
Effect.orDie(
410+
HttpServerResponse.setCookie(response, "cookie key", "cookie value"),
411+
)
412+
).pipe(
413+
Effect.flatMap(() => Effect.succeed("Hello, World!")),
414+
),
415+
),
416+
);
417+
418+
const MyApiLive = HttpApiBuilder.layer(MyApi).pipe(
419+
Layer.provide(HelloLive),
420+
Layer.provide(HttpServer.layerServices),
421+
);
422+
423+
const handler = LambdaHandler.streamFromHttpApi(MyApiLive);
424+
425+
// Lambda Function URL events currently use the same payload format as APIGatewayProxyEventV2.
426+
const result = await handler(apiGatewayV2Event, context);
427+
428+
expect(result).toBeUndefined();
429+
expect(streamifyResponse).toHaveBeenCalledTimes(1);
430+
expect(from).toHaveBeenCalledTimes(1);
431+
expect(responseMetadata).toStrictEqual({
432+
statusCode: 200,
433+
headers: {
434+
"content-length": "13",
435+
"content-type": "text/plain",
436+
},
437+
cookies: [
438+
"cookie key=cookie%20value",
439+
],
440+
});
441+
expect(Buffer.concat(chunks).toString()).toBe("Hello, World!");
442+
} finally {
443+
(global as any).awslambda = originalAwslambda;
444+
}
445+
});
446+
});
342447
});

0 commit comments

Comments
 (0)