aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/bun-lambda/.gitignore5
-rw-r--r--packages/bun-lambda/README.md92
-rwxr-xr-xpackages/bun-lambda/bootstrap3
-rwxr-xr-xpackages/bun-lambda/bun.lockbbin0 -> 174029 bytes
-rw-r--r--packages/bun-lambda/example/lambda.ts33
-rw-r--r--packages/bun-lambda/package.json16
-rwxr-xr-xpackages/bun-lambda/runtime.ts821
-rw-r--r--packages/bun-lambda/scripts/build-layer.ts101
-rw-r--r--packages/bun-lambda/scripts/publish-layer.ts91
-rw-r--r--packages/bun-lambda/tsconfig.json12
10 files changed, 1174 insertions, 0 deletions
diff --git a/packages/bun-lambda/.gitignore b/packages/bun-lambda/.gitignore
new file mode 100644
index 000000000..040012130
--- /dev/null
+++ b/packages/bun-lambda/.gitignore
@@ -0,0 +1,5 @@
+.DS_Store
+.serverless/
+node_modules/
+bun-lambda-layer/
+*.zip
diff --git a/packages/bun-lambda/README.md b/packages/bun-lambda/README.md
new file mode 100644
index 000000000..015a6e26c
--- /dev/null
+++ b/packages/bun-lambda/README.md
@@ -0,0 +1,92 @@
+# bun-lambda
+
+A custom runtime layer that runs Bun on AWS Lambda.
+
+## Setup
+
+First, you will need to deploy the layer to your AWS account. Clone this repository and run the `publish-layer` script to get started.
+
+```sh
+git clone git@github.com:oven-sh/bun.git
+cd packages/bun-lambda
+bun install
+bun run publish-layer
+```
+
+### `bun run build-layer`
+
+Builds a Lambda layer for Bun and saves it to a `.zip` file.
+
+| Flag | Description | Default |
+| ----------- | -------------------------------------------------------------------- | ---------------------- |
+| `--arch` | The architecture, either: "x64" or "aarch64" | aarch64 |
+| `--release` | The release of Bun, either: "latest", "canary", or a release "x.y.z" | latest |
+| `--output` | The path to write the layer as a `.zip`. | ./bun-lambda-layer.zip |
+
+Example:
+
+```sh
+bun run build-layer -- \
+ --arch x64 \
+ --release canary \
+ --output /path/to/layer.zip
+```
+
+### `bun run publish-layer`
+
+Builds a Lambda layer for Bun then publishes it to your AWS account.
+
+| Flag | Description | Default |
+| ---------- | ----------------------------------------- | ------- |
+| `--layer` | The layer name. | bun |
+| `--region` | The region name, or "\*" for all regions. | |
+| `--public` | If the layer should be public. | false |
+
+Example:
+
+```sh
+bun run publish-layer -- \
+ --arch aarch64 \
+ --release latest \
+ --output /path/to/layer.zip \
+ --region us-east-1
+```
+
+## Usage
+
+Once you publish the layer to your AWS account, you can create a Lambda function that uses the layer.
+
+Here's an example function that can run on Lambda using the layer for Bun:
+
+### HTTP events
+
+When an event is triggered from [API Gateway](https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html), the layer transforms the event payload into a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request). This means you can test your Lambda function locally using `bun run`, without any code changes.
+
+```ts
+export default {
+ async fetch(request: Request): Promise<Response> {
+ console.log(request.headers.get("x-amzn-function-arn"));
+ // ...
+ return new Response("Hello from Lambda!", {
+ status: 200,
+ headers: {
+ "Content-Type": "text/plain",
+ },
+ });
+ },
+};
+```
+
+### Non-HTTP events
+
+For non-HTTP events — S3, SQS, EventBridge, etc. — the event payload is the body of the [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request).
+
+```ts
+export default {
+ async fetch(request: Request): Promise<Response> {
+ const event = await request.json();
+ // ...
+ return new Response();
+ },
+};
+```
diff --git a/packages/bun-lambda/bootstrap b/packages/bun-lambda/bootstrap
new file mode 100755
index 000000000..38387b2c9
--- /dev/null
+++ b/packages/bun-lambda/bootstrap
@@ -0,0 +1,3 @@
+#! /bin/sh
+export BUN_INSTALL_CACHE_DIR=/tmp/bun/cache
+exec /opt/bun --cwd $LAMBDA_TASK_ROOT /opt/runtime.ts
diff --git a/packages/bun-lambda/bun.lockb b/packages/bun-lambda/bun.lockb
new file mode 100755
index 000000000..d826da88d
--- /dev/null
+++ b/packages/bun-lambda/bun.lockb
Binary files differ
diff --git a/packages/bun-lambda/example/lambda.ts b/packages/bun-lambda/example/lambda.ts
new file mode 100644
index 000000000..ec96cf6c9
--- /dev/null
+++ b/packages/bun-lambda/example/lambda.ts
@@ -0,0 +1,33 @@
+import type { Server, ServerWebSocket } from "bun";
+
+export default {
+ async fetch(request: Request, server: Server): Promise<Response | undefined> {
+ console.log("Request", {
+ url: request.url,
+ method: request.method,
+ headers: request.headers.toJSON(),
+ body: request.body ? await request.text() : null,
+ });
+ if (server.upgrade(request)) {
+ console.log("WebSocket upgraded");
+ return;
+ }
+ return new Response("Hello from Bun on Lambda!", {
+ status: 200,
+ headers: {
+ "Content-Type": "text/plain;charset=utf-8",
+ },
+ });
+ },
+ websocket: {
+ async open(ws: ServerWebSocket): Promise<void> {
+ console.log("WebSocket opened");
+ },
+ async message(ws: ServerWebSocket, message: string): Promise<void> {
+ console.log("WebSocket message", message);
+ },
+ async close(ws: ServerWebSocket, code: number, reason?: string): Promise<void> {
+ console.log("WebSocket closed", { code, reason });
+ },
+ },
+};
diff --git a/packages/bun-lambda/package.json b/packages/bun-lambda/package.json
new file mode 100644
index 000000000..b558b15fa
--- /dev/null
+++ b/packages/bun-lambda/package.json
@@ -0,0 +1,16 @@
+{
+ "devDependencies": {
+ "bun-types": "^0.4.0",
+ "jszip": "^3.10.1",
+ "oclif": "^3.6.5",
+ "prettier": "^2.8.2"
+ },
+ "dependencies": {
+ "aws4fetch": "^1.0.17"
+ },
+ "scripts": {
+ "build-layer": "bun scripts/build-layer.ts",
+ "publish-layer": "bun scripts/publish-layer.ts",
+ "format": "prettier --write ."
+ }
+}
diff --git a/packages/bun-lambda/runtime.ts b/packages/bun-lambda/runtime.ts
new file mode 100755
index 000000000..c29462b9b
--- /dev/null
+++ b/packages/bun-lambda/runtime.ts
@@ -0,0 +1,821 @@
+import type { Server, ServerWebSocket } from "bun";
+import { AwsClient } from "aws4fetch";
+
+type Lambda = {
+ fetch: (request: Request, server: Server) => Promise<Response | undefined>;
+ error?: (error: unknown) => Promise<Response>;
+ websocket?: {
+ open?: (ws: ServerWebSocket) => Promise<void>;
+ message?: (ws: ServerWebSocket, message: string) => Promise<void>;
+ close?: (ws: ServerWebSocket, code: number, reason: string) => Promise<void>;
+ };
+};
+
+let requestId: string | undefined;
+let traceId: string | undefined;
+let functionArn: string | undefined;
+let aws: AwsClient | undefined;
+
+let logger = console.log;
+
+function log(level: string, ...args: any[]): void {
+ if (!args.length) {
+ return;
+ }
+ const message = Bun.inspect(...args).replace(/\n/g, "\r");
+ if (requestId === undefined) {
+ logger(level, message);
+ } else {
+ logger(level, `RequestId: ${requestId}`, message);
+ }
+}
+
+console.log = (...args: any[]) => log("INFO", ...args);
+console.info = (...args: any[]) => log("INFO", ...args);
+console.warn = (...args: any[]) => log("WARN", ...args);
+console.error = (...args: any[]) => log("ERROR", ...args);
+console.debug = (...args: any[]) => log("DEBUG", ...args);
+console.trace = (...args: any[]) => log("TRACE", ...args);
+
+let warnings: Set<string> | undefined;
+
+function warnOnce(message: string, ...args: any[]): void {
+ if (warnings === undefined) {
+ warnings = new Set();
+ }
+ if (warnings.has(message)) {
+ return;
+ }
+ warnings.add(message);
+ console.warn(message, ...args);
+}
+
+function reset(): void {
+ requestId = undefined;
+ traceId = undefined;
+ warnings = undefined;
+}
+
+function exit(...cause: any[]): never {
+ console.error(...cause);
+ process.exit(1);
+}
+
+function env(name: string, fallback?: string): string {
+ const value = process.env[name] ?? fallback ?? null;
+ if (value === null) {
+ exit(`Runtime failed to find the '${name}' environment variable`);
+ }
+ return value;
+}
+
+const runtimeUrl = new URL(`http://${env("AWS_LAMBDA_RUNTIME_API")}/2018-06-01/`);
+
+async function fetch(url: string, options?: RequestInit): Promise<Response> {
+ const { href } = new URL(url, runtimeUrl);
+ const response = await globalThis.fetch(href, {
+ ...options,
+ timeout: false,
+ });
+ if (!response.ok) {
+ exit(`Runtime failed to send request to Lambda [status: ${response.status}]`);
+ }
+ return response;
+}
+
+async function fetchAws(url: string, options?: RequestInit): Promise<Response> {
+ if (aws === undefined) {
+ aws = new AwsClient({
+ accessKeyId: env("AWS_ACCESS_KEY_ID"),
+ secretAccessKey: env("AWS_SECRET_ACCESS_KEY"),
+ sessionToken: env("AWS_SESSION_TOKEN"),
+ region: env("AWS_REGION"),
+ });
+ }
+ return aws.fetch(url, options);
+}
+
+type LambdaError = {
+ readonly errorType: string;
+ readonly errorMessage: string;
+ readonly stackTrace?: string[];
+};
+
+function formatError(error: unknown): LambdaError {
+ if (error instanceof Error) {
+ return {
+ errorType: error.name,
+ errorMessage: error.message,
+ stackTrace: error.stack?.split("\n").filter(line => !line.includes(" /opt/runtime.ts")),
+ };
+ }
+ return {
+ errorType: "Error",
+ errorMessage: Bun.inspect(error),
+ };
+}
+
+async function sendError(type: string, cause: unknown): Promise<void> {
+ console.error(cause);
+ await fetch(requestId === undefined ? "runtime/init/error" : `runtime/invocation/${requestId}/error`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/vnd.aws.lambda.error+json",
+ "Lambda-Runtime-Function-Error-Type": `Bun.${type}`,
+ },
+ body: JSON.stringify(formatError(cause)),
+ });
+}
+
+async function throwError(type: string, cause: unknown): Promise<never> {
+ await sendError(type, cause);
+ exit();
+}
+
+async function init(): Promise<Lambda> {
+ const handlerName = env("_HANDLER");
+ const index = handlerName.lastIndexOf(".");
+ const fileName = handlerName.substring(0, index);
+ const filePath = `${env("LAMBDA_TASK_ROOT")}/${fileName}`;
+ let file;
+ try {
+ file = await import(filePath);
+ } catch (cause) {
+ if (cause instanceof Error && cause.message.startsWith("Cannot find module")) {
+ return throwError("FileDoesNotExist", `Did not find a file named '${fileName}'`);
+ }
+ return throwError("InitError", cause);
+ }
+ const moduleName = handlerName.substring(index + 1) || "default";
+ let module = file[moduleName];
+ if (moduleName !== "default") {
+ module = {
+ fetch: module,
+ };
+ }
+ const { fetch, websocket } = module;
+ if (typeof fetch !== "function") {
+ return throwError(
+ fetch === undefined ? "MethodDoesNotExist" : "MethodIsNotAFunction",
+ moduleName === "default"
+ ? `${fileName} does not have a default export with a function named 'fetch'`
+ : `${fileName} does not export a function named '${moduleName}'`,
+ );
+ }
+ if (websocket === undefined) {
+ return module;
+ }
+ for (const name of ["open", "message", "close"]) {
+ const method = websocket[name];
+ if (method === undefined) {
+ continue;
+ }
+ if (typeof method !== "function") {
+ return throwError(
+ "MethodIsNotAFunction",
+ `${fileName} does not have a function named '${name}' on the default 'websocket' property`,
+ );
+ }
+ }
+ return module;
+}
+
+type LambdaRequest<E = any> = {
+ readonly requestId: string;
+ readonly traceId: string;
+ readonly functionArn: string;
+ readonly deadlineMs: number | null;
+ readonly event: E;
+};
+
+async function receiveRequest(): Promise<LambdaRequest> {
+ const response = await fetch("runtime/invocation/next");
+ requestId = response.headers.get("Lambda-Runtime-Aws-Request-Id") ?? undefined;
+ if (requestId === undefined) {
+ exit("Runtime received a request without a request ID");
+ }
+ traceId = response.headers.get("Lambda-Runtime-Trace-Id") ?? undefined;
+ if (traceId === undefined) {
+ exit("Runtime received a request without a trace ID");
+ }
+ process.env["_X_AMZN_TRACE_ID"] = traceId;
+ functionArn = response.headers.get("Lambda-Runtime-Invoked-Function-Arn") ?? undefined;
+ if (functionArn === undefined) {
+ exit("Runtime received a request without a function ARN");
+ }
+ const deadlineMs = parseInt(response.headers.get("Lambda-Runtime-Deadline-Ms") ?? "0") || null;
+ let event;
+ try {
+ event = await response.json();
+ } catch (cause) {
+ exit("Runtime received a request with invalid JSON", cause);
+ }
+ return {
+ requestId,
+ traceId,
+ functionArn,
+ deadlineMs,
+ event,
+ };
+}
+
+type LambdaResponse = {
+ readonly statusCode: number;
+ readonly headers?: Record<string, string>;
+ readonly isBase64Encoded?: boolean;
+ readonly body?: string;
+ readonly multiValueHeaders?: Record<string, string[]>;
+ readonly cookies?: string[];
+};
+
+async function formatResponse(response: Response): Promise<LambdaResponse> {
+ const statusCode = response.status;
+ const headers = response.headers.toJSON();
+ if (statusCode === 101) {
+ const protocol = headers["sec-websocket-protocol"];
+ if (protocol === undefined) {
+ return {
+ statusCode: 200,
+ };
+ }
+ return {
+ statusCode: 200,
+ headers: {
+ "Sec-WebSocket-Protocol": protocol,
+ },
+ };
+ }
+ const mime = headers["content-type"];
+ const isBase64Encoded = !mime || (!mime.startsWith("text/") && !mime.startsWith("application/json"));
+ const body = isBase64Encoded ? Buffer.from(await response.arrayBuffer()).toString("base64") : await response.text();
+ delete headers["set-cookie"];
+ const cookies = response.headers.getAll("Set-Cookie");
+ if (cookies.length === 0) {
+ return {
+ statusCode,
+ headers,
+ isBase64Encoded,
+ body,
+ };
+ }
+ return {
+ statusCode,
+ headers,
+ cookies,
+ multiValueHeaders: {
+ "Set-Cookie": cookies,
+ },
+ isBase64Encoded,
+ body,
+ };
+}
+
+async function sendResponse(response: unknown): Promise<void> {
+ if (requestId === undefined) {
+ exit("Runtime attempted to send a response without a request ID");
+ }
+ await fetch(`runtime/invocation/${requestId}/response`, {
+ method: "POST",
+ body: response === null ? null : JSON.stringify(response),
+ });
+}
+
+function formatBody(body?: string, isBase64Encoded?: boolean): string | null {
+ if (body === undefined) {
+ return null;
+ }
+ if (!isBase64Encoded) {
+ return body;
+ }
+ return Buffer.from(body).toString("base64");
+}
+
+type HttpEventV1 = {
+ readonly version: "1.0";
+ readonly requestContext: {
+ readonly requestId: string;
+ readonly domainName: string;
+ readonly httpMethod: string;
+ readonly path: string;
+ };
+ readonly headers: Record<string, string>;
+ readonly multiValueHeaders?: Record<string, string[]>;
+ readonly queryStringParameters?: Record<string, string>;
+ readonly multiValueQueryStringParameters?: Record<string, string[]>;
+ readonly isBase64Encoded: boolean;
+ readonly body?: string;
+};
+
+function isHttpEventV1(event: any): event is HttpEventV1 {
+ return event.version === "1.0" && typeof event.requestContext === "object";
+}
+
+function formatHttpEventV1(event: HttpEventV1): Request {
+ const request = event.requestContext;
+ const headers = new Headers();
+ for (const [name, value] of Object.entries(event.headers)) {
+ headers.append(name, value);
+ }
+ for (const [name, values] of Object.entries(event.multiValueHeaders ?? {})) {
+ for (const value of values) {
+ headers.append(name, value);
+ }
+ }
+ const hostname = headers.get("Host") ?? request.domainName;
+ const proto = headers.get("X-Forwarded-Proto") ?? "http";
+ const url = new URL(request.path, `${proto}://${hostname}/`);
+ for (const [name, value] of new URLSearchParams(event.queryStringParameters)) {
+ url.searchParams.append(name, value);
+ }
+ for (const [name, values] of Object.entries(event.multiValueQueryStringParameters ?? {})) {
+ for (const value of values ?? []) {
+ url.searchParams.append(name, value);
+ }
+ }
+ return new Request(url.toString(), {
+ method: request.httpMethod,
+ headers,
+ body: formatBody(event.body, event.isBase64Encoded),
+ });
+}
+
+type HttpEventV2 = {
+ readonly version: "2.0";
+ readonly requestContext: {
+ readonly requestId: string;
+ readonly domainName: string;
+ readonly http: {
+ readonly method: string;
+ readonly path: string;
+ };
+ };
+ readonly headers: Record<string, string>;
+ readonly queryStringParameters?: Record<string, string>;
+ readonly cookies?: string[];
+ readonly isBase64Encoded: boolean;
+ readonly body?: string;
+};
+
+function isHttpEventV2(event: any): event is HttpEventV2 {
+ return event.version === "2.0" && typeof event.requestContext === "object";
+}
+
+function formatHttpEventV2(event: HttpEventV2): Request {
+ const request = event.requestContext;
+ const headers = new Headers();
+ for (const [name, values] of Object.entries(event.headers)) {
+ for (const value of values.split(",")) {
+ headers.append(name, value);
+ }
+ }
+ for (const [name, values] of Object.entries(event.queryStringParameters ?? {})) {
+ for (const value of values.split(",")) {
+ headers.append(name, value);
+ }
+ }
+ for (const cookie of event.cookies ?? []) {
+ headers.append("Set-Cookie", cookie);
+ }
+ const hostname = headers.get("Host") ?? request.domainName;
+ const proto = headers.get("X-Forwarded-Proto") ?? "http";
+ const url = new URL(request.http.path, `${proto}://${hostname}/`);
+ return new Request(url.toString(), {
+ method: request.http.method,
+ headers,
+ body: formatBody(event.body, event.isBase64Encoded),
+ });
+}
+
+type WebSocketEvent = {
+ readonly headers: Record<string, string>;
+ readonly multiValueHeaders: Record<string, string[]>;
+ readonly isBase64Encoded: boolean;
+ readonly body?: string;
+ readonly requestContext: {
+ readonly apiId: string;
+ readonly requestId: string;
+ readonly connectionId: string;
+ readonly domainName: string;
+ readonly stage: string;
+ readonly identity: {
+ readonly sourceIp: string;
+ };
+ } & (
+ | {
+ readonly eventType: "CONNECT";
+ }
+ | {
+ readonly eventType: "MESSAGE";
+ }
+ | {
+ readonly eventType: "DISCONNECT";
+ readonly disconnectStatusCode: number;
+ readonly disconnectReason: string;
+ }
+ );
+};
+
+function isWebSocketEvent(event: any): event is WebSocketEvent {
+ return typeof event.requestContext === "object" && typeof event.requestContext.connectionId === "string";
+}
+
+function isWebSocketUpgrade(event: any): event is WebSocketEvent {
+ return isWebSocketEvent(event) && event.requestContext.eventType === "CONNECT";
+}
+
+function formatWebSocketUpgrade(event: WebSocketEvent): Request {
+ const request = event.requestContext;
+ const headers = new Headers();
+ headers.set("Upgrade", "websocket");
+ headers.set("x-amzn-connection-id", request.connectionId);
+ for (const [name, values] of Object.entries(event.multiValueHeaders as any)) {
+ for (const value of (values as any) ?? []) {
+ headers.append(name, value);
+ }
+ }
+ const hostname = headers.get("Host") ?? request.domainName;
+ const proto = headers.get("X-Forwarded-Proto") ?? "http";
+ const url = new URL(`${proto}://${hostname}/${request.stage}`);
+ return new Request(url.toString(), {
+ headers,
+ body: formatBody(event.body, event.isBase64Encoded),
+ });
+}
+
+function formatUnknownEvent(event: unknown): Request {
+ return new Request("https://lambda/", {
+ method: "POST",
+ body: JSON.stringify(event),
+ headers: {
+ "Content-Type": "application/json;charset=utf-8",
+ },
+ });
+}
+
+function formatRequest(input: LambdaRequest): Request | undefined {
+ const { event, requestId, traceId, functionArn, deadlineMs } = input;
+ let request: Request;
+ if (isHttpEventV2(event)) {
+ request = formatHttpEventV2(event);
+ } else if (isHttpEventV1(event)) {
+ request = formatHttpEventV1(event);
+ } else if (isWebSocketEvent(event)) {
+ if (!isWebSocketUpgrade(event)) {
+ return undefined;
+ }
+ request = formatWebSocketUpgrade(event);
+ } else {
+ request = formatUnknownEvent(input);
+ }
+ request.headers.set("x-amzn-requestid", requestId);
+ request.headers.set("x-amzn-trace-id", traceId);
+ request.headers.set("x-amzn-function-arn", functionArn);
+ if (deadlineMs !== null) {
+ request.headers.set("x-amzn-deadline-ms", `${deadlineMs}`);
+ }
+ // @ts-ignore: Attach the original event to the Request
+ request.aws = event;
+ return request;
+}
+
+class LambdaServer implements Server {
+ #lambda: Lambda;
+ #webSockets: Map<string, LambdaWebSocket>;
+ #upgrade: Response | null;
+ pendingRequests: number;
+ pendingWebSockets: number;
+ port: number;
+ hostname: string;
+ development: boolean;
+
+ constructor(lambda: Lambda) {
+ this.#lambda = lambda;
+ this.#webSockets = new Map();
+ this.#upgrade = null;
+ this.pendingRequests = 0;
+ this.pendingWebSockets = 0;
+ this.port = 80;
+ this.hostname = "lambda";
+ this.development = false;
+ }
+
+ async accept(request: LambdaRequest): Promise<unknown> {
+ const deadlineMs = request.deadlineMs === null ? Date.now() + 60_000 : request.deadlineMs;
+ const durationMs = Math.max(1, deadlineMs - Date.now());
+ let response: unknown;
+ try {
+ response = await Promise.race([
+ new Promise<undefined>(resolve => setTimeout(resolve, durationMs)),
+ this.#acceptRequest(request),
+ ]);
+ } catch (cause) {
+ await sendError("RequestError", cause);
+ return;
+ }
+ if (response === undefined) {
+ await sendError("TimeoutError", "Function timed out");
+ return;
+ }
+ return response;
+ }
+
+ async #acceptRequest(event: LambdaRequest): Promise<unknown> {
+ const request = formatRequest(event);
+ let response: Response | undefined;
+ if (request === undefined) {
+ await this.#acceptWebSocket(event.event);
+ } else {
+ response = await this.fetch(request);
+ if (response.status === 101) {
+ await this.#acceptWebSocket(event.event);
+ }
+ }
+ if (response === undefined) {
+ return {
+ statusCode: 200,
+ };
+ }
+ if (!request?.headers.has("Host")) {
+ return response.text();
+ }
+ return formatResponse(response);
+ }
+
+ async #acceptWebSocket(event: WebSocketEvent): Promise<void> {
+ const request = event.requestContext;
+ const { connectionId, eventType } = request;
+ const webSocket = this.#webSockets.get(connectionId);
+ if (webSocket === undefined || this.#lambda.websocket === undefined) {
+ return;
+ }
+ const { open, message, close } = this.#lambda.websocket;
+ switch (eventType) {
+ case "CONNECT": {
+ if (open) {
+ await open(webSocket);
+ }
+ break;
+ }
+ case "MESSAGE": {
+ if (message) {
+ const body = formatBody(event.body, event.isBase64Encoded);
+ if (body !== null) {
+ await message(webSocket, body);
+ }
+ }
+ break;
+ }
+ case "DISCONNECT": {
+ try {
+ if (close) {
+ const { disconnectStatusCode: code, disconnectReason: reason } = request;
+ await close(webSocket, code, reason);
+ }
+ } finally {
+ this.#webSockets.delete(connectionId);
+ this.pendingWebSockets--;
+ }
+ break;
+ }
+ }
+ }
+
+ stop(): void {
+ exit("Runtime exited because Server.stop() was called");
+ }
+
+ reload(options: any): void {
+ this.#lambda = {
+ fetch: options.fetch ?? this.#lambda.fetch,
+ error: options.error ?? this.#lambda.error,
+ websocket: options.websocket ?? this.#lambda.websocket,
+ };
+ this.port =
+ typeof options.port === "number"
+ ? options.port
+ : typeof options.port === "string"
+ ? parseInt(options.port)
+ : this.port;
+ this.hostname = options.hostname ?? this.hostname;
+ this.development = options.development ?? this.development;
+ }
+
+ async fetch(request: Request): Promise<Response> {
+ this.pendingRequests++;
+ try {
+ let response = await this.#lambda.fetch(request, this);
+ if (response instanceof Response) {
+ return response;
+ }
+ if (response === undefined && this.#upgrade !== null) {
+ return this.#upgrade;
+ }
+ throw new Error("fetch() did not return a Response");
+ } catch (cause) {
+ console.error(cause);
+ if (this.#lambda.error !== undefined) {
+ try {
+ return await this.#lambda.error(cause);
+ } catch (cause) {
+ console.error(cause);
+ }
+ }
+ return new Response(null, { status: 500 });
+ } finally {
+ this.pendingRequests--;
+ this.#upgrade = null;
+ }
+ }
+
+ upgrade<T = undefined>(
+ request: Request,
+ options?: {
+ headers?: HeadersInit;
+ data?: T;
+ },
+ ): boolean {
+ if (request.method === "GET" && request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
+ this.#upgrade = new Response(null, {
+ status: 101,
+ headers: options?.headers,
+ });
+ if ("aws" in request && isWebSocketUpgrade(request.aws)) {
+ const { connectionId } = request.aws.requestContext;
+ this.#webSockets.set(connectionId, new LambdaWebSocket(request.aws, options?.data));
+ this.pendingWebSockets++;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ publish(topic: string, data: string | ArrayBuffer | ArrayBufferView, compress?: boolean): number {
+ let count = 0;
+ for (const webSocket of this.#webSockets.values()) {
+ count += webSocket.publish(topic, data, compress) ? 1 : 0;
+ }
+ return count;
+ }
+}
+
+class LambdaWebSocket implements ServerWebSocket {
+ #connectionId: string;
+ #url: string;
+ #invokeArn: string;
+ #topics: Set<string> | null;
+ remoteAddress: string;
+ readyState: 0 | 2 | 1 | -1 | 3;
+ binaryType?: "arraybuffer" | "uint8array";
+ data: any;
+
+ constructor(event: WebSocketEvent, data?: any) {
+ const request = event.requestContext;
+ this.#connectionId = `${request.connectionId}`;
+ this.#url = `https://${request.domainName}/${request.stage}/@connections/${this.#connectionId}`;
+ const [region, accountId] = (functionArn ?? "").split(":").slice(3, 5);
+ this.#invokeArn = `arn:aws:execute-api:${region}:${accountId}:${request.apiId}/${request.stage}/*`;
+ this.#topics = null;
+ this.remoteAddress = request.identity.sourceIp;
+ this.readyState = 1; // WebSocket.OPEN
+ this.data = data;
+ }
+
+ send(data: string | ArrayBuffer | ArrayBufferView, compress?: boolean): number {
+ if (typeof data === "string") {
+ return this.sendText(data, compress);
+ }
+ if (data instanceof ArrayBuffer) {
+ return this.sendBinary(new Uint8Array(data), compress);
+ }
+ const buffer = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
+ return this.sendBinary(buffer, compress);
+ }
+
+ sendText(data: string, compress?: boolean): number {
+ fetchAws(this.#url, {
+ method: "POST",
+ body: data,
+ })
+ .then(({ status }) => {
+ if (status === 403) {
+ warnOnce(
+ "Failed to send WebSocket message due to insufficient IAM permissions",
+ `Assign the following IAM policy to ${functionArn} to fix this issue:`,
+ {
+ Version: "2012-10-17",
+ Statement: [
+ {
+ Effect: "Allow",
+ Action: ["execute-api:Invoke"],
+ Resource: [this.#invokeArn],
+ },
+ ],
+ },
+ );
+ } else {
+ warnOnce(`Failed to send WebSocket message due to a ${status} error`);
+ }
+ })
+ .catch(error => {
+ warnOnce("Failed to send WebSocket message", error);
+ });
+ return data.length;
+ }
+
+ sendBinary(data: Uint8Array, compress?: boolean): number {
+ warnOnce(
+ "Lambda does not support binary WebSocket messages",
+ "https://docs.aws.amazon.com/apigateway/latest/developerguide/websocket-api-develop-binary-media-types.html",
+ );
+ const base64 = Buffer.from(data).toString("base64");
+ return this.sendText(base64, compress);
+ }
+
+ publish(topic: string, data: string | ArrayBuffer | ArrayBufferView, compress?: boolean): number {
+ if (this.isSubscribed(topic)) {
+ return this.send(data, compress);
+ }
+ return -1;
+ }
+
+ publishText(topic: string, data: string, compress?: boolean): number {
+ if (this.isSubscribed(topic)) {
+ return this.sendText(data, compress);
+ }
+ return -1;
+ }
+
+ publishBinary(topic: string, data: Uint8Array, compress?: boolean): number {
+ if (this.isSubscribed(topic)) {
+ return this.sendBinary(data, compress);
+ }
+ return -1;
+ }
+
+ close(code?: number, reason?: string): void {
+ // TODO: code? reason?
+ fetchAws(this.#url, {
+ method: "DELETE",
+ })
+ .then(({ status }) => {
+ if (status === 403) {
+ warnOnce(
+ "Failed to close WebSocket due to insufficient IAM permissions",
+ `Assign the following IAM policy to ${functionArn} to fix this issue:`,
+ {
+ Version: "2012-10-17",
+ Statement: [
+ {
+ Effect: "Allow",
+ Action: ["execute-api:Invoke"],
+ Resource: [this.#invokeArn],
+ },
+ ],
+ },
+ );
+ } else {
+ warnOnce(`Failed to close WebSocket due to a ${status} error`);
+ }
+ })
+ .catch(error => {
+ warnOnce("Failed to close WebSocket", error);
+ });
+ this.readyState = 3; // WebSocket.CLOSED;
+ }
+
+ subscribe(topic: string): void {
+ if (this.#topics === null) {
+ this.#topics = new Set();
+ }
+ this.#topics.add(topic);
+ }
+
+ unsubscribe(topic: string): void {
+ if (this.#topics !== null) {
+ this.#topics.delete(topic);
+ }
+ }
+
+ isSubscribed(topic: string): boolean {
+ return this.#topics !== null && this.#topics.has(topic);
+ }
+
+ cork(callback: (ws: ServerWebSocket<undefined>) => any): void | Promise<void> {
+ // Lambda does not support sending multiple messages at a time.
+ return callback(this);
+ }
+}
+
+const lambda = await init();
+const server = new LambdaServer(lambda);
+while (true) {
+ try {
+ const request = await receiveRequest();
+ const response = await server.accept(request);
+ if (response !== undefined) {
+ await sendResponse(response);
+ }
+ } finally {
+ reset();
+ }
+}
diff --git a/packages/bun-lambda/scripts/build-layer.ts b/packages/bun-lambda/scripts/build-layer.ts
new file mode 100644
index 000000000..65eeac083
--- /dev/null
+++ b/packages/bun-lambda/scripts/build-layer.ts
@@ -0,0 +1,101 @@
+// HACK: https://github.com/oven-sh/bun/issues/2081
+process.stdout.getWindowSize = () => [80, 80];
+process.stderr.getWindowSize = () => [80, 80];
+
+import { createReadStream, createWriteStream } from "node:fs";
+import { join } from "node:path";
+import { Command, Flags } from "@oclif/core";
+import JSZip from "jszip";
+
+export class BuildCommand extends Command {
+ static summary = "Build a custom Lambda layer for Bun.";
+
+ static flags = {
+ arch: Flags.string({
+ description: "The architecture type to support.",
+ options: ["x64", "aarch64"],
+ default: "aarch64",
+ }),
+ release: Flags.string({
+ description: "The release of Bun to install.",
+ default: "latest",
+ }),
+ url: Flags.string({
+ description: "A custom URL to download Bun.",
+ exclusive: ["release"],
+ }),
+ output: Flags.file({
+ exists: false,
+ default: async () => "bun-lambda-layer.zip",
+ }),
+ layer: Flags.string({
+ description: "The name of the Lambda layer.",
+ multiple: true,
+ default: ["bun"],
+ }),
+ region: Flags.string({
+ description: "The region to publish the layer.",
+ multiple: true,
+ default: [],
+ }),
+ public: Flags.boolean({
+ description: "If the layer should be public.",
+ default: false,
+ }),
+ };
+
+ async run() {
+ const result = await this.parse(BuildCommand);
+ const { flags } = result;
+ this.debug("Options:", flags);
+ const { arch, release, url, output } = flags;
+ const { href } = new URL(url ?? `https://bun.sh/download/${release}/linux/${arch}?avx2=true`);
+ this.log("Downloading...", href);
+ const response = await fetch(href, {
+ headers: {
+ "User-Agent": "bun-lambda",
+ },
+ });
+ if (response.url !== href) {
+ this.debug("Redirected URL:", response.url);
+ }
+ this.debug("Response:", response.status, response.statusText);
+ if (!response.ok) {
+ const reason = await response.text();
+ this.error(reason, { exit: 1 });
+ }
+ this.log("Extracting...");
+ const buffer = await response.arrayBuffer();
+ let archive;
+ try {
+ archive = await JSZip.loadAsync(buffer);
+ } catch (cause) {
+ this.debug(cause);
+ this.error("Failed to unzip file:", { exit: 1 });
+ }
+ this.debug("Extracted archive:", Object.keys(archive.files));
+ const bun = archive.filter((_, { dir, name }) => !dir && name.endsWith("bun"))[0];
+ if (!bun) {
+ this.error("Failed to find executable in zip", { exit: 1 });
+ }
+ const cwd = bun.name.split("/")[0];
+ archive = archive.folder(cwd) ?? archive;
+ for (const filename of ["bootstrap", "runtime.ts"]) {
+ const path = join(__dirname, "..", filename);
+ archive.file(filename, createReadStream(path));
+ }
+ this.log("Saving...", output);
+ archive
+ .generateNodeStream({
+ streamFiles: true,
+ compression: "DEFLATE",
+ compressionOptions: {
+ level: 9,
+ },
+ })
+ .pipe(createWriteStream(output));
+ this.log("Saved");
+ }
+}
+
+await BuildCommand.run(process.argv.slice(2));
diff --git a/packages/bun-lambda/scripts/publish-layer.ts b/packages/bun-lambda/scripts/publish-layer.ts
new file mode 100644
index 000000000..b7129fc50
--- /dev/null
+++ b/packages/bun-lambda/scripts/publish-layer.ts
@@ -0,0 +1,91 @@
+import { spawnSync } from "node:child_process";
+import { BuildCommand } from "./build-layer";
+
+export class PublishCommand extends BuildCommand {
+ static summary = "Publish a custom Lambda layer for Bun.";
+
+ #aws(args: string[]): string {
+ this.debug("$", "aws", ...args);
+ const { status, stdout, stderr } = spawnSync("aws", args, {
+ stdio: "pipe",
+ });
+ const result = stdout.toString("utf-8").trim();
+ if (status === 0) {
+ return result;
+ }
+ const reason = stderr.toString("utf-8").trim() || result;
+ throw new Error(`aws ${args.join(" ")} exited with ${status}: ${reason}`);
+ }
+
+ async run() {
+ const { flags } = await this.parse(PublishCommand);
+ this.debug("Options:", flags);
+ try {
+ const version = this.#aws(["--version"]);
+ this.debug("AWS CLI:", version);
+ } catch (error) {
+ this.debug(error);
+ this.error(
+ "Install the `aws` CLI to continue: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
+ { exit: 1 },
+ );
+ }
+ const { layer, region, arch, output, public: isPublic } = flags;
+ if (region.includes("*")) {
+ // prettier-ignore
+ const result = this.#aws([
+ "ec2",
+ "describe-regions",
+ "--query", "Regions[].RegionName",
+ "--output", "json"
+ ]);
+ region.length = 0;
+ for (const name of JSON.parse(result)) {
+ region.push(name);
+ }
+ } else if (!region.length) {
+ // prettier-ignore
+ region.push(this.#aws([
+ "configure",
+ "get",
+ "region"
+ ]));
+ }
+ this.log("Publishing...");
+ for (const regionName of region) {
+ for (const layerName of layer) {
+ // prettier-ignore
+ const result = this.#aws([
+ "lambda",
+ "publish-layer-version",
+ "--layer-name", layerName,
+ "--region", regionName,
+ "--description", "Bun is an incredibly fast JavaScript runtime, bundler, transpiler, and package manager.",
+ "--license-info", "MIT",
+ "--compatible-architectures", arch === "x64" ? "x86_64" : "arm64",
+ "--compatible-runtimes", "provided.al2", "provided",
+ "--zip-file", `fileb://${output}`,
+ "--output", "json",
+ ]);
+ const { LayerVersionArn } = JSON.parse(result);
+ this.log("Published", LayerVersionArn);
+ if (isPublic) {
+ // prettier-ignore
+ this.#aws([
+ "lambda",
+ "add-layer-version-permission",
+ "--layer-name", layerName,
+ "--region", regionName,
+ "--version-number", LayerVersionArn.split(":").pop(),
+ "--statement-id", `${layerName}-public`,
+ "--action", "lambda:GetLayerVersion",
+ "--principal", "*",
+ ]);
+ }
+ }
+ }
+ this.log("Done");
+ }
+}
+
+await PublishCommand.run(process.argv.slice(2));
diff --git a/packages/bun-lambda/tsconfig.json b/packages/bun-lambda/tsconfig.json
new file mode 100644
index 000000000..8d1637c76
--- /dev/null
+++ b/packages/bun-lambda/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "lib": ["ESNext"],
+ "module": "ESNext",
+ "target": "ESNext",
+ "moduleResolution": "node",
+ "types": ["bun-types"],
+ "esModuleInterop": true,
+ "allowJs": true,
+ "strict": true
+ }
+}