@dotpress/adapter-fastify 0.0.0-stage → 2.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Renaud Guyon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,86 @@
1
- # Temporary Holding Version
1
+ # @dotpress/adapter-fastify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Fastify](https://fastify.dev) adapter for [Dotpress](https://dotpress.dev), on Node.js and AWS Lambda. Supports Fastify 5.
4
+
5
+ > 🚧 Dotpress V2 is under active development and not published yet.
6
+
7
+ ```sh
8
+ pnpm add @dotpress/core @dotpress/adapter-fastify fastify zod
9
+ ```
10
+
11
+ ```ts
12
+ import { createApp, defineRoute } from '@dotpress/core'
13
+ import { fastifyAdapter } from '@dotpress/adapter-fastify'
14
+ import helmet from '@fastify/helmet'
15
+
16
+ defineRoute({ path: '/hello', handler: () => ({ message: 'Hello' }) })
17
+
18
+ const app = await createApp({
19
+ adapter: fastifyAdapter({
20
+ // Fastify plugins and hooks applied to the Dotpress routes
21
+ beforeRoutes: async (app) => {
22
+ await app.register(helmet)
23
+ },
24
+ }),
25
+ })
26
+
27
+ await app.listen(3000)
28
+ ```
29
+
30
+ ## Options
31
+
32
+ | Option | Description |
33
+ | --- | --- |
34
+ | `app` | Existing Fastify instance. Defaults to `Fastify()`. |
35
+ | `beforeRoutes(app)` | Registers hooks, plugins and native routes. Hooks registered here apply to the Dotpress routes. |
36
+ | `afterRoutes(app)` | Registers hooks, plugins and native routes in an encapsulated context: they do not apply to the Dotpress routes, except plugins wrapped with `fastify-plugin`, which apply to the parent context. |
37
+ | `bodyLimit` | Maximum request body size in bytes. Defaults to 1 MiB. |
38
+
39
+ ## Behavior
40
+
41
+ - **Routes.** Each Dotpress route is a native Fastify route: the route path (`request.routeOptions.url`, `/users/:id`) shows in Fastify logs, metrics and traces, and a native route with the same method and path fails at startup. Routing follows the Dotpress rules whatever the router options: trailing slashes are ignored, `HEAD` requests use the `GET` routes, and unsupported methods get `405` with an `Allow` header. Between native and Dotpress routes, the most specific path wins (Fastify routing), whatever the registration order.
42
+ - **Bodies.** Dotpress reads the bodies of its routes, with the same rules on every adapter: the Fastify body parsers only apply to native routes, and `@fastify/multipart` is not needed for uploads. `preParsing` hooks apply. Like Fastify, the adapter does not read the bodies of `GET` and `HEAD` requests, and rejects invalid `Content-Type` headers (`415`).
43
+ - **Errors.** Errors with a 4xx `statusCode` raised by Fastify or its plugins (`@fastify/rate-limit`, `@fastify/sensible`…) use the Dotpress error format and keep their status and message. Other errors are unexpected errors (`500`). The adapter takes over the not-found and error handlers of the instance: they apply to native routes too.
44
+ - **`ctx.req.ip`** is `request.ip`: proxy headers are only used with the `trustProxy` option of Fastify.
45
+ - **Streamed responses** (`stream()`, `sse()`) are written directly to the Node.js response, so that their headers are sent immediately: `onSend` hooks (`@fastify/compress`…) do not apply to them. The headers set by earlier hooks are kept.
46
+ - `ctx.raw` is the Fastify request.
47
+ - The instance is ready once `createApp()` resolves: register plugins and routes in `beforeRoutes` or `afterRoutes`.
48
+ - `listen(port, host?)` listens on every interface without `host`, like Node.js. `close()` closes the Fastify instance (`onClose` hooks of the plugins), waits for pending requests, and closes the responses being streamed. `adapter.server()` returns the Node.js HTTP server while listening.
49
+
50
+ To test without opening a port, use `inject()` of Fastify:
51
+
52
+ ```ts
53
+ const adapter = fastifyAdapter()
54
+ await createApp({ adapter })
55
+ const res = await adapter.native().inject('/hello')
56
+ ```
57
+
58
+ ## On AWS Lambda
59
+
60
+ ```sh
61
+ pnpm add @dotpress/core @dotpress/adapter-fastify fastify @fastify/aws-lambda zod
62
+ ```
63
+
64
+ ```ts
65
+ import { createApp } from '@dotpress/core'
66
+ import { fastifyAdapter } from '@dotpress/adapter-fastify'
67
+ import { createLambdaHandler } from '@dotpress/adapter-fastify/lambda'
68
+
69
+ export const handler = createLambdaHandler(() =>
70
+ createApp({ adapter: fastifyAdapter(), plugins: [/* ... */] })
71
+ )
72
+ ```
73
+
74
+ `createLambdaHandler()` (based on [`@fastify/aws-lambda`](https://github.com/fastify/aws-lambda-fastify) 6.1 or later, which sends the events to `inject()`) handles the events of API Gateway REST APIs (payload v1), HTTP APIs and Function URLs (payload v2), and Application Load Balancers, with or without multi-value headers. Enable the multi-value headers of the target group to receive repeated query keys and send several cookies through a load balancer.
75
+
76
+ - **Lazy creation.** The factory is called at the first invocation (cold start), and the app is reused by the next invocations of the execution environment. Invocations received meanwhile wait for the same app. The handler export stays synchronous: no top-level `await`.
77
+ - **Recoverable failures.** When the factory fails, the error is logged (`logger` option, console by default), the invocation gets the standard `500` error, and the next invocation calls the factory again.
78
+ - **Buffered responses.** Responses are sent once complete: `stream()` works for finite streams, but Server-Sent Events (`sse()`) are not supported, and a stream that fails gets an empty `500` response rather than a truncated one. Lambda response streaming is not supported yet.
79
+ - **Binary responses** are sent in base64: compressed responses, and every content type except text, JSON and XML by default, or as decided by the `isContentTypeBinary(contentType)` option.
80
+ - **Query strings** are read as sent when the event has them (payload v2, load balancers): plus signs and commas keep their meaning, like on the other adapters.
81
+ - **`ctx.req.ip`** is the source IP of API Gateway, or the last `X-Forwarded-For` entry behind a load balancer (appended by the load balancer); `127.0.0.1` when the event has no address. With the `trustProxy` option, Fastify reads `X-Forwarded-For` instead.
82
+ - The Lambda event and context are not given to the routes: `request.awsLambda` of `@fastify/aws-lambda` needs a decoration before the instance is ready.
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,59 @@
1
+ import { Server } from 'node:http';
2
+ import { HttpAdapter } from '@dotpress/core';
3
+ import { FastifyInstance } from 'fastify';
4
+
5
+ interface FastifyAdapterOptions<TApp extends FastifyInstance> {
6
+ /** Fastify instance to mount the routes on. Defaults to `Fastify()`. */
7
+ app?: TApp;
8
+ /**
9
+ * Registers hooks and plugins applied to the Dotpress routes
10
+ * (`@fastify/helmet`…), and native routes.
11
+ */
12
+ beforeRoutes?: (app: TApp) => void | Promise<void>;
13
+ /**
14
+ * Registers hooks, plugins and native routes in an encapsulated context:
15
+ * they do not apply to the Dotpress routes (except plugins wrapped with
16
+ * `fastify-plugin`, which apply to the parent context).
17
+ */
18
+ afterRoutes?: (app: FastifyInstance) => void | Promise<void>;
19
+ /**
20
+ * Maximum request body size, in bytes. Defaults to 1 MiB. Routes can
21
+ * override it with their own `bodyLimit`.
22
+ */
23
+ bodyLimit?: number;
24
+ }
25
+ interface FastifyAdapter<TApp extends FastifyInstance = FastifyInstance> extends HttpAdapter<TApp> {
26
+ /**
27
+ * Starts the HTTP server (`fastify.listen()`). Without `host`, listens on
28
+ * every interface, like Node.js.
29
+ */
30
+ listen(port: number, host?: string): Promise<void>;
31
+ /**
32
+ * Closes the Fastify instance (`onClose` hooks of the plugins): waits for
33
+ * pending requests, and closes the responses being streamed (their
34
+ * `req.signal` is aborted).
35
+ */
36
+ close(): Promise<void>;
37
+ /** HTTP server started by `listen()`, if any. */
38
+ server(): Server | undefined;
39
+ }
40
+ /**
41
+ * Creates the Fastify adapter (Fastify 5).
42
+ *
43
+ * @example
44
+ * const app = await createApp({
45
+ * adapter: fastifyAdapter({
46
+ * beforeRoutes: async (app) => {
47
+ * await app.register(helmet)
48
+ * },
49
+ * }),
50
+ * })
51
+ * await app.listen(3000)
52
+ */
53
+ declare function fastifyAdapter(options?: FastifyAdapterOptions<FastifyInstance>): FastifyAdapter;
54
+ declare function fastifyAdapter<TApp extends FastifyInstance>(options: FastifyAdapterOptions<TApp> & {
55
+ app: TApp;
56
+ }): FastifyAdapter<TApp>;
57
+
58
+ export { fastifyAdapter };
59
+ export type { FastifyAdapter, FastifyAdapterOptions };
package/dist/index.js ADDED
@@ -0,0 +1,303 @@
1
+ // src/index.ts
2
+ import {
3
+ DEFAULT_BODY_LIMIT,
4
+ HttpError,
5
+ parseQueryString,
6
+ readRequestBody
7
+ } from "@dotpress/core";
8
+ import Fastify from "fastify";
9
+ var trackConnections = (server) => {
10
+ const sockets = /* @__PURE__ */ new Set();
11
+ let closing = false;
12
+ server.on("connection", (socket) => {
13
+ if (closing) {
14
+ socket.destroy();
15
+ return;
16
+ }
17
+ sockets.add(socket);
18
+ socket.once("close", () => sockets.delete(socket));
19
+ });
20
+ server.on(
21
+ "request",
22
+ (_request, response) => {
23
+ response.once("finish", () => {
24
+ if (closing) server.closeIdleConnections();
25
+ });
26
+ }
27
+ );
28
+ return () => {
29
+ closing = true;
30
+ for (const socket of sockets) {
31
+ if (socket.bytesRead === 0) socket.destroy();
32
+ }
33
+ };
34
+ };
35
+ var CLIENT_GONE = /* @__PURE__ */ Symbol("client gone");
36
+ var encoder = new TextEncoder();
37
+ var toWebStream = (payload) => {
38
+ let cleanup = () => {
39
+ };
40
+ return new ReadableStream({
41
+ start(controller) {
42
+ const onData = (chunk) => {
43
+ controller.enqueue(
44
+ typeof chunk === "string" ? encoder.encode(chunk) : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)
45
+ );
46
+ };
47
+ const onEnd = () => {
48
+ cleanup();
49
+ controller.close();
50
+ };
51
+ const onError = () => {
52
+ cleanup();
53
+ controller.error(CLIENT_GONE);
54
+ };
55
+ cleanup = () => {
56
+ payload.off("data", onData);
57
+ payload.off("end", onEnd);
58
+ payload.off("error", onError);
59
+ };
60
+ payload.on("data", onData);
61
+ payload.on("end", onEnd);
62
+ payload.on("error", onError);
63
+ },
64
+ cancel() {
65
+ cleanup();
66
+ }
67
+ });
68
+ };
69
+ var readBody = (request, limit) => {
70
+ const payload = request.body;
71
+ const headers = new Headers();
72
+ for (const name of ["content-type", "content-encoding", "content-length"]) {
73
+ const value = request.headers[name];
74
+ if (typeof value === "string") headers.set(name, value);
75
+ }
76
+ return readRequestBody(
77
+ new Request("http://localhost/", {
78
+ method: "POST",
79
+ headers,
80
+ body: payload ? toWebStream(payload) : null,
81
+ duplex: "half"
82
+ }),
83
+ limit
84
+ );
85
+ };
86
+ var ERROR_CODES = {
87
+ 400: "BAD_REQUEST",
88
+ 401: "UNAUTHORIZED",
89
+ 403: "FORBIDDEN",
90
+ 404: "NOT_FOUND",
91
+ 405: "METHOD_NOT_ALLOWED",
92
+ 408: "REQUEST_TIMEOUT",
93
+ 409: "CONFLICT",
94
+ 413: "PAYLOAD_TOO_LARGE",
95
+ 415: "UNSUPPORTED_MEDIA_TYPE",
96
+ 422: "UNPROCESSABLE_ENTITY",
97
+ 429: "TOO_MANY_REQUESTS"
98
+ };
99
+ var fromNativeError = (error) => {
100
+ if (!(error instanceof Error)) return error;
101
+ const { statusCode } = error;
102
+ if (typeof statusCode !== "number" || !Number.isInteger(statusCode)) {
103
+ return error;
104
+ }
105
+ if (statusCode < 400 || statusCode > 499) return error;
106
+ return new HttpError(
107
+ statusCode,
108
+ ERROR_CODES[statusCode] ?? "CLIENT_ERROR",
109
+ error.message || "Bad Request",
110
+ void 0,
111
+ { cause: error }
112
+ );
113
+ };
114
+ var waitForDrain = (res) => new Promise((resolve) => {
115
+ const done = () => {
116
+ res.off("drain", done);
117
+ res.off("close", done);
118
+ resolve();
119
+ };
120
+ res.on("drain", done);
121
+ res.on("close", done);
122
+ });
123
+ function fastifyAdapter(options = {}) {
124
+ const bodyLimit = options.bodyLimit ?? DEFAULT_BODY_LIMIT;
125
+ if (!Number.isInteger(bodyLimit) || bodyLimit < 0) {
126
+ throw new RangeError(
127
+ "Invalid bodyLimit: expected a non-negative integer (bytes)."
128
+ );
129
+ }
130
+ const application = options.app ?? Fastify();
131
+ let mounted = false;
132
+ let listening = false;
133
+ let closeConnections = () => {
134
+ };
135
+ const streams = /* @__PURE__ */ new Set();
136
+ const normalize = (request, signal) => {
137
+ const url = request.url;
138
+ const index = url.indexOf("?");
139
+ return {
140
+ method: request.method,
141
+ path: index === -1 ? url : url.slice(0, index),
142
+ url: `${request.protocol}://${request.host}${url}`,
143
+ params: {},
144
+ query: parseQueryString(index === -1 ? "" : url.slice(index)),
145
+ headers: request.headers,
146
+ body: void 0,
147
+ signal,
148
+ ip: request.ip,
149
+ raw: request
150
+ };
151
+ };
152
+ const send = (reply, response, controller) => {
153
+ const { body } = response;
154
+ if (body instanceof ReadableStream) {
155
+ return sendStream(reply, response, body, controller);
156
+ }
157
+ reply.code(response.status);
158
+ for (const [name, value] of Object.entries(response.headers)) {
159
+ reply.header(name, value);
160
+ }
161
+ if (body === null) return reply.send();
162
+ if (typeof body === "string") return reply.send(body);
163
+ return reply.send(
164
+ Buffer.from(body.buffer, body.byteOffset, body.byteLength)
165
+ );
166
+ };
167
+ const sendStream = async (reply, response, body, controller) => {
168
+ reply.hijack();
169
+ const res = reply.raw;
170
+ res.statusCode = response.status;
171
+ for (const [name, value] of Object.entries(reply.getHeaders())) {
172
+ if (value !== void 0) res.setHeader(name, value);
173
+ }
174
+ for (const [name, value] of Object.entries(response.headers)) {
175
+ res.setHeader(name, value);
176
+ }
177
+ if (reply.request.method === "HEAD") {
178
+ await body.cancel().catch(() => {
179
+ });
180
+ res.end();
181
+ return;
182
+ }
183
+ const reader = body.getReader();
184
+ const cancel = () => {
185
+ reader.cancel().catch(() => {
186
+ });
187
+ };
188
+ const stop = () => {
189
+ controller?.abort();
190
+ cancel();
191
+ };
192
+ res.on("close", cancel);
193
+ streams.add(stop);
194
+ res.flushHeaders();
195
+ try {
196
+ for (; ; ) {
197
+ const { done, value } = await reader.read();
198
+ if (done) break;
199
+ if (!res.write(value)) await waitForDrain(res);
200
+ }
201
+ res.end();
202
+ } catch {
203
+ res.destroy();
204
+ } finally {
205
+ res.off("close", cancel);
206
+ streams.delete(stop);
207
+ }
208
+ };
209
+ const handleRoute = async (route, request, reply, runtime) => {
210
+ const controller = new AbortController();
211
+ reply.raw.on("close", () => {
212
+ if (!reply.raw.writableFinished) controller.abort();
213
+ });
214
+ const normalized = normalize(request, controller.signal);
215
+ try {
216
+ const { body, files, rawBody } = await readBody(
217
+ request,
218
+ route.definition.bodyLimit ?? bodyLimit
219
+ );
220
+ normalized.body = body;
221
+ normalized.files = files;
222
+ normalized.rawBody = rawBody;
223
+ } catch (error) {
224
+ if (error === CLIENT_GONE) return reply.hijack();
225
+ return send(reply, await runtime.handleError(error, normalized));
226
+ }
227
+ normalized.params = { ...request.params };
228
+ return send(reply, await route.execute(normalized), controller);
229
+ };
230
+ return {
231
+ name: "fastify",
232
+ mount: async (routes, runtime) => {
233
+ if (mounted) throw new Error("The Fastify adapter is already mounted.");
234
+ mounted = true;
235
+ const app = application;
236
+ app.setNotFoundHandler(
237
+ async (request, reply) => send(reply, await runtime.handleNotFound(normalize(request)))
238
+ );
239
+ app.setErrorHandler(
240
+ async (error, request, reply) => send(
241
+ reply,
242
+ await runtime.handleError(fromNativeError(error), normalize(request))
243
+ )
244
+ );
245
+ await options.beforeRoutes?.(application);
246
+ await app.register(async (child) => {
247
+ child.removeAllContentTypeParsers();
248
+ child.addContentTypeParser("*", (_request, payload, done) => {
249
+ done(null, payload);
250
+ });
251
+ const ordered = [...routes].sort(
252
+ (a, b) => Number(b.method === "head") - Number(a.method === "head")
253
+ );
254
+ for (const route of ordered) {
255
+ const method = route.method.toUpperCase();
256
+ const register = (url) => {
257
+ child.route({
258
+ method,
259
+ url,
260
+ exposeHeadRoute: true,
261
+ handler: (request, reply) => handleRoute(route, request, reply, runtime)
262
+ });
263
+ };
264
+ register(route.path);
265
+ if (route.path !== "/" && child.findRoute({ method, url: `${route.path}/` }) === null) {
266
+ register(`${route.path}/`);
267
+ }
268
+ }
269
+ });
270
+ const { afterRoutes } = options;
271
+ if (afterRoutes) {
272
+ await app.register(async (scope) => {
273
+ await afterRoutes(scope);
274
+ });
275
+ }
276
+ await app.ready();
277
+ },
278
+ listen: async (port, host) => {
279
+ const app = application;
280
+ try {
281
+ await app.listen({ port, host: host ?? "::" });
282
+ } catch (error) {
283
+ const code = error.code;
284
+ if (host !== void 0 || code !== "EAFNOSUPPORT") throw error;
285
+ await app.listen({ port, host: "0.0.0.0" });
286
+ }
287
+ listening = true;
288
+ closeConnections = trackConnections(app.server);
289
+ },
290
+ close: async () => {
291
+ for (const stop of [...streams]) stop();
292
+ listening = false;
293
+ closeConnections();
294
+ await application.close();
295
+ },
296
+ native: () => application,
297
+ server: () => listening ? application.server : void 0
298
+ };
299
+ }
300
+ export {
301
+ fastifyAdapter
302
+ };
303
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { IncomingMessage, Server, ServerResponse } from 'node:http'\nimport type { Socket } from 'node:net'\nimport type { Readable } from 'node:stream'\nimport {\n DEFAULT_BODY_LIMIT,\n HttpError,\n parseQueryString,\n readRequestBody,\n type CompiledRoute,\n type DotpressRuntime,\n type HttpAdapter,\n type NormalizedRequest,\n type NormalizedResponse,\n type RequestBody,\n} from '@dotpress/core'\nimport Fastify, {\n type FastifyInstance,\n type FastifyReply,\n type FastifyRequest,\n} from 'fastify'\n\nexport interface FastifyAdapterOptions<TApp extends FastifyInstance> {\n /** Fastify instance to mount the routes on. Defaults to `Fastify()`. */\n app?: TApp\n /**\n * Registers hooks and plugins applied to the Dotpress routes\n * (`@fastify/helmet`…), and native routes.\n */\n beforeRoutes?: (app: TApp) => void | Promise<void>\n /**\n * Registers hooks, plugins and native routes in an encapsulated context:\n * they do not apply to the Dotpress routes (except plugins wrapped with\n * `fastify-plugin`, which apply to the parent context).\n */\n afterRoutes?: (app: FastifyInstance) => void | Promise<void>\n /**\n * Maximum request body size, in bytes. Defaults to 1 MiB. Routes can\n * override it with their own `bodyLimit`.\n */\n bodyLimit?: number\n}\n\nexport interface FastifyAdapter<\n TApp extends FastifyInstance = FastifyInstance,\n> extends HttpAdapter<TApp> {\n /**\n * Starts the HTTP server (`fastify.listen()`). Without `host`, listens on\n * every interface, like Node.js.\n */\n listen(port: number, host?: string): Promise<void>\n /**\n * Closes the Fastify instance (`onClose` hooks of the plugins): waits for\n * pending requests, and closes the responses being streamed (their\n * `req.signal` is aborted).\n */\n close(): Promise<void>\n /** HTTP server started by `listen()`, if any. */\n server(): Server | undefined\n}\n\n/**\n * Lets `close()` complete as soon as the pending requests are served, and\n * returns the function to call on close. Before Node.js 26, `server.close()`\n * waits for the connections that have sent nothing yet (a browser\n * preconnect…) until `headersTimeout`; and, whatever the version, the\n * connections of the pending requests stay open once served, until\n * `keepAliveTimeout`. Fastify keeps listening during its `preClose` hooks:\n * the connections accepted from then on are destroyed right away.\n */\nconst trackConnections = (server: Server) => {\n const sockets = new Set<Socket>()\n let closing = false\n server.on('connection', (socket: Socket) => {\n if (closing) {\n socket.destroy()\n return\n }\n sockets.add(socket)\n socket.once('close', () => sockets.delete(socket))\n })\n server.on(\n 'request',\n (_request: IncomingMessage, response: ServerResponse) => {\n response.once('finish', () => {\n if (closing) server.closeIdleConnections()\n })\n }\n )\n return () => {\n closing = true\n for (const socket of sockets) {\n if (socket.bytesRead === 0) socket.destroy()\n }\n }\n}\n\n/** Rejection reason when the client goes away while the body is read. */\nconst CLIENT_GONE = Symbol('client gone')\n\nconst encoder = new TextEncoder()\n\n/**\n * Web stream of a request body. Cancelling it discards the rest of the body\n * instead of destroying the socket, so that the error response can still\n * be sent.\n */\nconst toWebStream = (payload: Readable): ReadableStream<Uint8Array> => {\n let cleanup = () => {}\n return new ReadableStream<Uint8Array>({\n start(controller) {\n const onData = (chunk: Buffer | string) => {\n // Streams of `preParsing` hooks may produce strings.\n controller.enqueue(\n typeof chunk === 'string'\n ? encoder.encode(chunk)\n : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)\n )\n }\n const onEnd = () => {\n cleanup()\n controller.close()\n }\n // Also emitted when the client goes away (`ECONNRESET`).\n const onError = () => {\n cleanup()\n controller.error(CLIENT_GONE)\n }\n cleanup = () => {\n payload.off('data', onData)\n payload.off('end', onEnd)\n payload.off('error', onError)\n }\n payload.on('data', onData)\n payload.on('end', onEnd)\n payload.on('error', onError)\n },\n cancel() {\n // Still flowing without listener: the rest is discarded.\n cleanup()\n },\n })\n}\n\n/**\n * Reads the body with the rules of the core (`readRequestBody()`), from the\n * payload given by Fastify (after its `preParsing` hooks). Fastify does not\n * read the body of `GET` and `HEAD` requests.\n */\nconst readBody = (\n request: FastifyRequest,\n limit: number\n): Promise<RequestBody> => {\n const payload = request.body as Readable | undefined\n const headers = new Headers()\n for (const name of ['content-type', 'content-encoding', 'content-length']) {\n const value = request.headers[name]\n if (typeof value === 'string') headers.set(name, value)\n }\n return readRequestBody(\n new Request('http://localhost/', {\n method: 'POST',\n headers,\n body: payload ? toWebStream(payload) : null,\n duplex: 'half',\n } as RequestInit),\n limit\n )\n}\n\n/** Code of client errors. */\nconst ERROR_CODES: Record<number, string> = {\n 400: 'BAD_REQUEST',\n 401: 'UNAUTHORIZED',\n 403: 'FORBIDDEN',\n 404: 'NOT_FOUND',\n 405: 'METHOD_NOT_ALLOWED',\n 408: 'REQUEST_TIMEOUT',\n 409: 'CONFLICT',\n 413: 'PAYLOAD_TOO_LARGE',\n 415: 'UNSUPPORTED_MEDIA_TYPE',\n 422: 'UNPROCESSABLE_ENTITY',\n 429: 'TOO_MANY_REQUESTS',\n}\n\n/**\n * Converts the client errors (`statusCode` 4xx) raised by Fastify and its\n * plugins into Dotpress errors, with the same status and message. Anything\n * else is an unexpected error.\n */\nconst fromNativeError = (error: unknown): unknown => {\n if (!(error instanceof Error)) return error\n\n const { statusCode } = error as { statusCode?: unknown }\n if (typeof statusCode !== 'number' || !Number.isInteger(statusCode)) {\n return error\n }\n if (statusCode < 400 || statusCode > 499) return error\n\n return new HttpError(\n statusCode,\n ERROR_CODES[statusCode] ?? 'CLIENT_ERROR',\n error.message || 'Bad Request',\n undefined,\n { cause: error }\n )\n}\n\nconst waitForDrain = (res: ServerResponse) =>\n new Promise<void>((resolve) => {\n const done = () => {\n res.off('drain', done)\n res.off('close', done)\n resolve()\n }\n res.on('drain', done)\n res.on('close', done)\n })\n\n/**\n * Creates the Fastify adapter (Fastify 5).\n *\n * @example\n * const app = await createApp({\n * adapter: fastifyAdapter({\n * beforeRoutes: async (app) => {\n * await app.register(helmet)\n * },\n * }),\n * })\n * await app.listen(3000)\n */\nexport function fastifyAdapter(\n options?: FastifyAdapterOptions<FastifyInstance>\n): FastifyAdapter\nexport function fastifyAdapter<TApp extends FastifyInstance>(\n options: FastifyAdapterOptions<TApp> & { app: TApp }\n): FastifyAdapter<TApp>\nexport function fastifyAdapter<TApp extends FastifyInstance>(\n options: FastifyAdapterOptions<TApp> = {}\n): FastifyAdapter<TApp> {\n const bodyLimit = options.bodyLimit ?? DEFAULT_BODY_LIMIT\n\n if (!Number.isInteger(bodyLimit) || bodyLimit < 0) {\n throw new RangeError(\n 'Invalid bodyLimit: expected a non-negative integer (bytes).'\n )\n }\n\n const application = options.app ?? (Fastify() as unknown as TApp)\n let mounted = false\n let listening = false\n let closeConnections = () => {}\n /** Closes the responses being streamed, so that `close()` can complete. */\n const streams = new Set<() => void>()\n\n const normalize = (\n request: FastifyRequest,\n signal?: AbortSignal\n ): NormalizedRequest => {\n const url = request.url\n const index = url.indexOf('?')\n return {\n method: request.method,\n path: index === -1 ? url : url.slice(0, index),\n url: `${request.protocol}://${request.host}${url}`,\n params: {},\n query: parseQueryString(index === -1 ? '' : url.slice(index)),\n headers: request.headers,\n body: undefined,\n signal,\n ip: request.ip,\n raw: request,\n }\n }\n\n const send = (\n reply: FastifyReply,\n response: NormalizedResponse,\n controller?: AbortController\n ): FastifyReply | Promise<void> => {\n const { body } = response\n if (body instanceof ReadableStream) {\n return sendStream(reply, response, body, controller)\n }\n\n reply.code(response.status)\n for (const [name, value] of Object.entries(response.headers)) {\n reply.header(name, value)\n }\n if (body === null) return reply.send()\n if (typeof body === 'string') return reply.send(body)\n return reply.send(\n Buffer.from(body.buffer, body.byteOffset, body.byteLength)\n )\n }\n\n /**\n * Streams the body with the response of Node.js: Fastify only sends the\n * headers with the first chunk, too late for Server-Sent Events and for\n * streams that fail right away. `onSend` hooks do not apply.\n */\n const sendStream = async (\n reply: FastifyReply,\n response: NormalizedResponse,\n body: ReadableStream<Uint8Array>,\n controller: AbortController | undefined\n ): Promise<void> => {\n reply.hijack()\n const res = reply.raw\n\n res.statusCode = response.status\n // Headers set by hooks (`@fastify/helmet`…), then by Dotpress.\n for (const [name, value] of Object.entries(reply.getHeaders())) {\n if (value !== undefined) res.setHeader(name, value)\n }\n for (const [name, value] of Object.entries(response.headers)) {\n res.setHeader(name, value)\n }\n\n if (reply.request.method === 'HEAD') {\n await body.cancel().catch(() => {})\n res.end()\n return\n }\n\n const reader = body.getReader()\n const cancel = () => {\n reader.cancel().catch(() => {})\n }\n const stop = () => {\n controller?.abort()\n cancel()\n }\n\n res.on('close', cancel)\n streams.add(stop)\n res.flushHeaders()\n\n try {\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n if (!res.write(value)) await waitForDrain(res)\n }\n\n // The reader is cancelled when the client goes away.\n res.end()\n } catch {\n // Ending the response would hide the failure: abort the connection.\n res.destroy()\n } finally {\n res.off('close', cancel)\n streams.delete(stop)\n }\n }\n\n const handleRoute = async (\n route: CompiledRoute,\n request: FastifyRequest,\n reply: FastifyReply,\n runtime: DotpressRuntime\n ) => {\n const controller = new AbortController()\n reply.raw.on('close', () => {\n if (!reply.raw.writableFinished) controller.abort()\n })\n\n const normalized = normalize(request, controller.signal)\n\n try {\n const { body, files, rawBody } = await readBody(\n request,\n route.definition.bodyLimit ?? bodyLimit\n )\n normalized.body = body\n normalized.files = files\n normalized.rawBody = rawBody\n } catch (error) {\n if (error === CLIENT_GONE) return reply.hijack()\n return send(reply, await runtime.handleError(error, normalized))\n }\n\n normalized.params = { ...(request.params as Record<string, string>) }\n return send(reply, await route.execute(normalized), controller)\n }\n\n return {\n name: 'fastify',\n\n mount: async (\n routes: readonly CompiledRoute[],\n runtime: DotpressRuntime\n ) => {\n if (mounted) throw new Error('The Fastify adapter is already mounted.')\n mounted = true\n\n const app = application as unknown as FastifyInstance\n\n // Set first: encapsulated plugins inherit them when they are created.\n app.setNotFoundHandler(async (request, reply) =>\n send(reply, await runtime.handleNotFound(normalize(request)))\n )\n app.setErrorHandler(async (error, request, reply) =>\n send(\n reply,\n await runtime.handleError(fromNativeError(error), normalize(request))\n )\n )\n\n await options.beforeRoutes?.(application)\n\n await app.register(async (child) => {\n // Dotpress reads the bodies: the payload is passed as is.\n child.removeAllContentTypeParsers()\n child.addContentTypeParser('*', (_request, payload, done) => {\n done(null, payload)\n })\n\n // Explicit HEAD routes first: GET routes add one otherwise.\n const ordered = [...routes].sort(\n (a, b) => Number(b.method === 'head') - Number(a.method === 'head')\n )\n for (const route of ordered) {\n const method = route.method.toUpperCase() as 'GET'\n const register = (url: string) => {\n child.route({\n method,\n url,\n exposeHeadRoute: true,\n handler: (request, reply) =>\n handleRoute(route, request, reply, runtime),\n })\n }\n\n register(route.path)\n // Trailing slashes are ignored, whatever the router options\n // (`findRoute()` follows `ignoreTrailingSlash`).\n if (\n route.path !== '/' &&\n child.findRoute({ method, url: `${route.path}/` }) === null\n ) {\n register(`${route.path}/`)\n }\n }\n })\n\n // Root hooks apply to every route, whatever the order: only an\n // encapsulated context keeps them away from the Dotpress routes.\n const { afterRoutes } = options\n if (afterRoutes) {\n await app.register(async (scope) => {\n await afterRoutes(scope)\n })\n }\n await app.ready()\n },\n\n listen: async (port: number, host?: string) => {\n const app = application as unknown as FastifyInstance\n try {\n await app.listen({ port, host: host ?? '::' })\n } catch (error) {\n // No IPv6: every IPv4 interface, like Node.js.\n const code = (error as { code?: unknown }).code\n if (host !== undefined || code !== 'EAFNOSUPPORT') throw error\n await app.listen({ port, host: '0.0.0.0' })\n }\n listening = true\n closeConnections = trackConnections(app.server as Server)\n },\n\n close: async () => {\n // Streams never end by themselves: close them.\n for (const stop of [...streams]) stop()\n listening = false\n closeConnections()\n await (application as unknown as FastifyInstance).close()\n },\n\n native: () => application,\n server: () =>\n listening\n ? ((application as unknown as FastifyInstance).server as Server)\n : undefined,\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,51 @@
1
+ import { App, Logger } from '@dotpress/core';
2
+ import { FastifyInstance } from 'fastify';
3
+
4
+ /**
5
+ * Event of API Gateway REST APIs (payload v1), HTTP APIs and Function URLs
6
+ * (payload v2), or Application Load Balancers.
7
+ */
8
+ type LambdaEvent = object;
9
+ /** Result of the handler, in the format of the event. */
10
+ interface LambdaResult {
11
+ statusCode: number;
12
+ headers?: Record<string, string>;
13
+ multiValueHeaders?: Record<string, string[]>;
14
+ cookies?: string[];
15
+ body: string;
16
+ isBase64Encoded?: boolean;
17
+ }
18
+ interface LambdaHandlerOptions {
19
+ /** Logs the failures to create the app. Defaults to a console logger. */
20
+ logger?: Logger;
21
+ /**
22
+ * Whether a response content type is binary (sent in base64). Defaults to
23
+ * everything except text, JSON and XML.
24
+ */
25
+ isContentTypeBinary?: (contentType: string) => boolean;
26
+ }
27
+ /** AWS Lambda handler. */
28
+ type LambdaHandler = (event: LambdaEvent, context?: object) => Promise<LambdaResult>;
29
+ /** App of the factory, created with `fastifyAdapter()`. */
30
+ type LambdaApp = Pick<App<FastifyInstance>, 'native'>;
31
+ /**
32
+ * Creates the AWS Lambda handler of an app (`@fastify/aws-lambda`).
33
+ *
34
+ * The app is created by the factory at the first invocation (cold start),
35
+ * then reused by the next invocations of the execution environment.
36
+ * Invocations received meanwhile wait for the same app. When the factory
37
+ * fails, the error is logged, the invocation gets a `500` error, and the
38
+ * next invocation calls the factory again.
39
+ *
40
+ * Responses are sent once complete: streamed responses (`stream()`) are
41
+ * buffered, and Server-Sent Events are not supported.
42
+ *
43
+ * @example
44
+ * export const handler = createLambdaHandler(() =>
45
+ * createApp({ adapter: fastifyAdapter(), plugins: [authPlugin(auth)] })
46
+ * )
47
+ */
48
+ declare const createLambdaHandler: (factory: () => LambdaApp | Promise<LambdaApp>, options?: LambdaHandlerOptions) => LambdaHandler;
49
+
50
+ export { createLambdaHandler };
51
+ export type { LambdaApp, LambdaEvent, LambdaHandler, LambdaHandlerOptions, LambdaResult };
package/dist/lambda.js ADDED
@@ -0,0 +1,109 @@
1
+ // src/lambda.ts
2
+ import {
3
+ createConsoleLogger,
4
+ internalError,
5
+ toErrorBody
6
+ } from "@dotpress/core";
7
+ import awsLambdaFastify from "@fastify/aws-lambda";
8
+ import Fastify from "fastify";
9
+ var TEXT_TYPES = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded)$|[^;]*\+(json|xml)$)/;
10
+ var isTextType = (contentType) => TEXT_TYPES.test(contentType.split(";", 1)[0].trim().toLowerCase());
11
+ var albQuery = (event) => {
12
+ const parameters = event.multiValueQueryStringParameters ?? event.queryStringParameters ?? {};
13
+ const pairs = [];
14
+ for (const [key, values] of Object.entries(parameters)) {
15
+ for (const value of [values ?? []].flat()) pairs.push(`${key}=${value}`);
16
+ }
17
+ return pairs.join("&");
18
+ };
19
+ var withQuery = (path = "/", query = "") => query ? `${path}?${query}` : path;
20
+ var prepare = (event) => {
21
+ const context = event.requestContext;
22
+ const raw = {
23
+ ...event,
24
+ queryStringParameters: null,
25
+ multiValueQueryStringParameters: null
26
+ };
27
+ if (event.version === "2.0") {
28
+ return { ...raw, rawPath: withQuery(event.rawPath, event.rawQueryString) };
29
+ }
30
+ if (context?.elb === void 0) return event;
31
+ const forwarded = event.multiValueHeaders?.["x-forwarded-for"]?.at(-1) ?? event.headers?.["x-forwarded-for"];
32
+ return {
33
+ ...raw,
34
+ path: withQuery(event.path, albQuery(event)),
35
+ // Used as the remote address of the request (when defined).
36
+ requestContext: {
37
+ ...context,
38
+ identity: { sourceIp: forwarded?.split(",").at(-1)?.trim() }
39
+ }
40
+ };
41
+ };
42
+ var failureApp = () => {
43
+ const app = Fastify();
44
+ app.all(
45
+ "*",
46
+ async (_request, reply) => reply.code(500).header("content-type", "application/json; charset=utf-8").send(JSON.stringify(toErrorBody(internalError())))
47
+ );
48
+ return app;
49
+ };
50
+ var createLambdaHandler = (factory, options = {}) => {
51
+ const logger = options.logger ?? createConsoleLogger();
52
+ const isBinary = options.isContentTypeBinary ?? ((type) => !isTextType(type));
53
+ const toHandler = (app) => {
54
+ const create = (albMultiValueHeaders) => awsLambdaFastify(app, {
55
+ // The instance is ready: it cannot be decorated anymore.
56
+ decorateRequest: false,
57
+ albMultiValueHeaders,
58
+ enforceBase64: ({ headers }) => {
59
+ const type = headers["content-type"];
60
+ const encoding = headers["content-encoding"];
61
+ return typeof type === "string" && isBinary(type) || // Compressed (`@fastify/compress`).
62
+ typeof encoding === "string" && encoding !== "identity";
63
+ }
64
+ });
65
+ const single = create(false);
66
+ const multi = create(true);
67
+ return async (event, context) => {
68
+ const prepared = prepare(event);
69
+ if (prepared.requestContext?.elb === void 0 || !prepared.multiValueHeaders) {
70
+ return single(prepared, context);
71
+ }
72
+ const result = await multi(prepared, context);
73
+ delete result.headers;
74
+ return result;
75
+ };
76
+ };
77
+ let ready;
78
+ let failure;
79
+ const init = async () => {
80
+ const app = await factory();
81
+ const native = app?.native?.();
82
+ if (typeof native?.inject !== "function") {
83
+ throw new TypeError(
84
+ "createLambdaHandler() expects an app created with fastifyAdapter() of @dotpress/adapter-fastify."
85
+ );
86
+ }
87
+ return toHandler(native);
88
+ };
89
+ return async (event, context) => {
90
+ const current = ready ??= init();
91
+ let handler;
92
+ try {
93
+ handler = await current;
94
+ } catch (error) {
95
+ if (ready === current) ready = void 0;
96
+ logger.error(
97
+ error instanceof Error ? error : { error },
98
+ "Failed to create the app"
99
+ );
100
+ failure ??= toHandler(failureApp());
101
+ return failure(event, context);
102
+ }
103
+ return handler(event, context);
104
+ };
105
+ };
106
+ export {
107
+ createLambdaHandler
108
+ };
109
+ //# sourceMappingURL=lambda.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lambda.ts"],"sourcesContent":["import {\n createConsoleLogger,\n internalError,\n toErrorBody,\n type App,\n type Logger,\n} from '@dotpress/core'\nimport awsLambdaFastify from '@fastify/aws-lambda'\nimport Fastify, { type FastifyInstance } from 'fastify'\n\n/**\n * Event of API Gateway REST APIs (payload v1), HTTP APIs and Function URLs\n * (payload v2), or Application Load Balancers.\n */\nexport type LambdaEvent = object\n\n/** Result of the handler, in the format of the event. */\nexport interface LambdaResult {\n statusCode: number\n headers?: Record<string, string>\n multiValueHeaders?: Record<string, string[]>\n cookies?: string[]\n body: string\n isBase64Encoded?: boolean\n}\n\nexport interface LambdaHandlerOptions {\n /** Logs the failures to create the app. Defaults to a console logger. */\n logger?: Logger\n /**\n * Whether a response content type is binary (sent in base64). Defaults to\n * everything except text, JSON and XML.\n */\n isContentTypeBinary?: (contentType: string) => boolean\n}\n\n/** AWS Lambda handler. */\nexport type LambdaHandler = (\n event: LambdaEvent,\n context?: object\n) => Promise<LambdaResult>\n\n/** App of the factory, created with `fastifyAdapter()`. */\nexport type LambdaApp = Pick<App<FastifyInstance>, 'native'>\n\nconst TEXT_TYPES =\n /^(text\\/|application\\/(json|xml|javascript|x-www-form-urlencoded)$|[^;]*\\+(json|xml)$)/\n\nconst isTextType = (contentType: string) =>\n TEXT_TYPES.test(contentType.split(';', 1)[0]!.trim().toLowerCase())\n\ninterface EventShape {\n version?: string\n path?: string\n rawPath?: string\n rawQueryString?: string\n headers?: Record<string, string | undefined> | null\n multiValueHeaders?: Record<string, string[] | undefined> | null\n queryStringParameters?: Record<string, string | undefined> | null\n multiValueQueryStringParameters?: Record<string, string[] | undefined> | null\n requestContext?: { elb?: unknown; identity?: object }\n}\n\n/** Query string of a load balancer event (not decoded by the load balancer). */\nconst albQuery = (event: EventShape) => {\n const parameters =\n event.multiValueQueryStringParameters ?? event.queryStringParameters ?? {}\n const pairs: string[] = []\n for (const [key, values] of Object.entries(parameters)) {\n for (const value of [values ?? []].flat()) pairs.push(`${key}=${value}`)\n }\n return pairs.join('&')\n}\n\n/** Path with the query string. */\nconst withQuery = (path = '/', query = '') =>\n query ? `${path}?${query}` : path\n\n/**\n * Prepares the event for `@fastify/aws-lambda`.\n *\n * - The query string is passed as sent, with the path, when the event has\n * it: the parameters decoded by API Gateway or by the library lose the\n * plus signs (spaces) and the commas of the values (payload v2).\n * - Behind a load balancer, the client address is the last entry of\n * `X-Forwarded-For`, appended by the load balancer.\n */\nconst prepare = (event: EventShape): EventShape => {\n const context = event.requestContext\n const raw = {\n ...event,\n queryStringParameters: null,\n multiValueQueryStringParameters: null,\n }\n\n if (event.version === '2.0') {\n return { ...raw, rawPath: withQuery(event.rawPath, event.rawQueryString) }\n }\n // API Gateway REST APIs only give the decoded parameters.\n if (context?.elb === undefined) return event\n\n const forwarded =\n event.multiValueHeaders?.['x-forwarded-for']?.at(-1) ??\n event.headers?.['x-forwarded-for']\n return {\n ...raw,\n path: withQuery(event.path, albQuery(event)),\n // Used as the remote address of the request (when defined).\n requestContext: {\n ...context,\n identity: { sourceIp: forwarded?.split(',').at(-1)?.trim() },\n },\n }\n}\n\n/** Answers `500 INTERNAL_ERROR` when the app cannot be created. */\nconst failureApp = () => {\n const app = Fastify()\n app.all('*', async (_request, reply) =>\n reply\n .code(500)\n .header('content-type', 'application/json; charset=utf-8')\n .send(JSON.stringify(toErrorBody(internalError())))\n )\n return app\n}\n\n/**\n * Creates the AWS Lambda handler of an app (`@fastify/aws-lambda`).\n *\n * The app is created by the factory at the first invocation (cold start),\n * then reused by the next invocations of the execution environment.\n * Invocations received meanwhile wait for the same app. When the factory\n * fails, the error is logged, the invocation gets a `500` error, and the\n * next invocation calls the factory again.\n *\n * Responses are sent once complete: streamed responses (`stream()`) are\n * buffered, and Server-Sent Events are not supported.\n *\n * @example\n * export const handler = createLambdaHandler(() =>\n * createApp({ adapter: fastifyAdapter(), plugins: [authPlugin(auth)] })\n * )\n */\nexport const createLambdaHandler = (\n factory: () => LambdaApp | Promise<LambdaApp>,\n options: LambdaHandlerOptions = {}\n): LambdaHandler => {\n const logger = options.logger ?? createConsoleLogger()\n const isBinary = options.isContentTypeBinary ?? ((type) => !isTextType(type))\n\n const toHandler = (app: FastifyInstance): LambdaHandler => {\n const create = (albMultiValueHeaders: boolean) =>\n awsLambdaFastify(app, {\n // The instance is ready: it cannot be decorated anymore.\n decorateRequest: false,\n albMultiValueHeaders,\n enforceBase64: ({ headers }) => {\n const type = headers['content-type']\n const encoding = headers['content-encoding']\n return (\n (typeof type === 'string' && isBinary(type)) ||\n // Compressed (`@fastify/compress`).\n (typeof encoding === 'string' && encoding !== 'identity')\n )\n },\n }) as unknown as LambdaHandler\n const single = create(false)\n const multi = create(true)\n\n return async (event, context) => {\n const prepared = prepare(event as EventShape)\n if (\n prepared.requestContext?.elb === undefined ||\n !prepared.multiValueHeaders\n ) {\n return single(prepared, context)\n }\n // Load balancers with multi-value headers only read these ones.\n const result = await multi(prepared, context)\n delete result.headers\n return result\n }\n }\n\n let ready: Promise<LambdaHandler> | undefined\n let failure: LambdaHandler | undefined\n\n const init = async (): Promise<LambdaHandler> => {\n const app = await factory()\n const native: unknown = app?.native?.()\n if (typeof (native as Partial<FastifyInstance>)?.inject !== 'function') {\n throw new TypeError(\n 'createLambdaHandler() expects an app created with fastifyAdapter() of @dotpress/adapter-fastify.'\n )\n }\n return toHandler(native as FastifyInstance)\n }\n\n return async (event, context) => {\n const current = (ready ??= init())\n let handler: LambdaHandler\n try {\n handler = await current\n } catch (error) {\n // Retried at the next invocation.\n if (ready === current) ready = undefined\n logger.error(\n error instanceof Error ? error : { error },\n 'Failed to create the app'\n )\n failure ??= toHandler(failureApp())\n return failure(event, context)\n }\n return handler(event, context)\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@dotpress/adapter-fastify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "2.0.0-alpha.0",
4
+ "description": "Fastify 5 adapter for Dotpress, on Node.js and AWS Lambda",
5
+ "author": "Renaud Guyon",
6
+ "license": "MIT",
7
+ "homepage": "https://dotpress.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/dotpressjs/dotpress.git",
11
+ "directory": "packages/adapter-fastify"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/dotpressjs/dotpress/issues"
15
+ },
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "main": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./lambda": {
29
+ "types": "./dist/lambda.d.ts",
30
+ "default": "./dist/lambda.js"
31
+ }
32
+ },
33
+ "engines": {
34
+ "node": ">=22.12"
35
+ },
36
+ "peerDependencies": {
37
+ "@dotpress/core": "^2.0.0-alpha.0",
38
+ "@fastify/aws-lambda": "^6.1.0",
39
+ "fastify": "^5.0.0"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "@fastify/aws-lambda": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "devDependencies": {
47
+ "@dotpress/core": "2.0.0-alpha.0",
48
+ "@dotpress/testing": "2.0.0-alpha.0",
49
+ "@fastify/aws-lambda": "^6.4.2",
50
+ "fastify": "^5.12.8",
51
+ "zod": "^4.6.7"
52
+ },
53
+ "keywords": [
54
+ "dotpress",
55
+ "fastify",
56
+ "adapter",
57
+ "aws-lambda",
58
+ "typescript",
59
+ "zod"
60
+ ],
61
+ "scripts": {
62
+ "build": "tsup",
63
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
64
+ "lint": "eslint src test",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest watch"
67
+ }
6
68
  }