oc-fastify-server-adapter 0.1.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/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # oc-fastify-server-adapter
2
+
3
+ Fastify HTTP server adapter for the OC registry. The adapter is opt-in and implements OC's neutral `HttpServerAdapter` interface.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install oc-fastify-server-adapter fastify
9
+ ```
10
+
11
+ `oc` is a peer dependency. Use this adapter with an OC version that exports the HTTP server adapter types (`>=0.50.56`).
12
+
13
+ ## Usage
14
+
15
+ ```js
16
+ const oc = require('oc');
17
+ const createFastifyAdapter = require('oc-fastify-server-adapter').default;
18
+
19
+ const registry = oc.Registry({
20
+ // regular OC registry options...
21
+ server: {
22
+ adapter: createFastifyAdapter,
23
+ options: {
24
+ host: '0.0.0.0',
25
+ port: 3030,
26
+ trustProxy: true
27
+ }
28
+ }
29
+ });
30
+ ```
31
+
32
+ `options.host` defaults to `0.0.0.0`, matching Node/Express `server.listen(port)` behavior. Set it to `127.0.0.1` or another interface to bind more narrowly.
33
+
34
+ ## Opt-in contract and differences from Express
35
+
36
+ - `registry.start()` returns a Fastify instance as `app`, not an Express application. Code that reaches into `app` must use Fastify APIs.
37
+ - `server.httpServer()` and the `{ server }` value returned by `registry.start()` are the underlying Node `http.Server`.
38
+ - User routes are registered through Fastify's router. OC route patterns such as `:param` and `*splat` are normalized, but advanced Express-only route syntax is not supported.
39
+ - `fromConnect()` supports common Connect middleware, including middleware that ends the response without calling `next()`. Middleware that depends on Express-specific request/response APIs may still need changes.
40
+ - JSON and urlencoded body limits follow the OC/Express configuration. Gzip, deflate, and brotli encoded request bodies are inflated before Fastify parses them.
41
+ - Urlencoded bodies use `qs` parsing to match Express `extended: true` semantics. Query strings use Node's `querystring` parser to match Express 5's simple parser.
42
+ - Cookies use OC/Express-style options. `maxAge` is accepted in milliseconds and translated to Fastify's seconds-based serializer, with `Path=/` as the default. Signed cookies are not supported unless the adapter grows a Fastify cookie secret option.
43
+ - Multipart uploads are normalized to OC's uploaded file shape. Oversized fields return a `LIMIT_FIELD_VALUE`-style 413 instead of silently truncating.
44
+ - Strong ETags and 304 conditional responses are enabled to match the Express registry.
45
+ - `OPTIONS` requests are handled by the adapter so OC CORS middleware can answer preflight requests. The `Allow` header is scoped to matching routes when possible; unmatched `OPTIONS` paths still receive the global adapter method set for preflight compatibility.
46
+ - Handler errors are rendered by OC's configured error handler. The Fastify adapter returns `text/html` error bodies when OC enables the error handler, but default Fastify 404/error body shapes can differ from Express for requests outside OC routes.
47
+ - Non-empty OC route handler chains must eventually send a response. This matches existing OC callback-style handlers; empty handler arrays return 404 defensively.
48
+ - Logging uses Fastify response timing, so formatting is not byte-for-byte identical to Morgan's Express output.
@@ -0,0 +1,104 @@
1
+ import type http from 'node:http';
2
+ type CookieOptions = {
3
+ domain?: string;
4
+ encode?: (value: string) => string;
5
+ expires?: Date;
6
+ httpOnly?: boolean;
7
+ maxAge?: number;
8
+ partitioned?: boolean;
9
+ path?: string;
10
+ priority?: 'low' | 'medium' | 'high';
11
+ sameSite?: boolean | 'lax' | 'strict' | 'none';
12
+ secure?: boolean;
13
+ signed?: boolean;
14
+ };
15
+ type Method = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete';
16
+ type UploadedFile = {
17
+ fieldname: string;
18
+ originalname: string;
19
+ encoding: string;
20
+ mimetype: string;
21
+ size: number;
22
+ destination: string;
23
+ filename: string;
24
+ path: string;
25
+ buffer?: Buffer;
26
+ stream?: NodeJS.ReadableStream;
27
+ truncated?: boolean;
28
+ };
29
+ type OcRequest = {
30
+ method: string;
31
+ url: string;
32
+ path: string;
33
+ originalUrl: string;
34
+ protocol: string;
35
+ secure: boolean;
36
+ ip: string;
37
+ headers: http.IncomingHttpHeaders;
38
+ params: Record<string, string>;
39
+ query: Record<string, unknown>;
40
+ body: unknown;
41
+ cookies: Record<string, string>;
42
+ files?: UploadedFile[] | Record<string, UploadedFile[]>;
43
+ user?: string;
44
+ routeId: string;
45
+ get(header: string): string | undefined;
46
+ raw: http.IncomingMessage;
47
+ };
48
+ type OcResponse = {
49
+ conf: any;
50
+ errorCode?: string;
51
+ errorDetails?: string;
52
+ statusCode: number;
53
+ status(code: number): OcResponse;
54
+ json(body: unknown): void;
55
+ send(body: unknown): void;
56
+ set(field: string | Record<string, string>, value?: string): OcResponse;
57
+ header(name: string, value: string): OcResponse;
58
+ setHeader(name: string, value: string): OcResponse;
59
+ removeHeader(name: string): OcResponse;
60
+ type(mime: string): OcResponse;
61
+ cookie(name: string, value: string, opts?: CookieOptions): OcResponse;
62
+ redirect(url: string): void;
63
+ end(chunk?: unknown): void;
64
+ stream(readable: NodeJS.ReadableStream): void;
65
+ raw: http.ServerResponse;
66
+ };
67
+ type OcHandler = (req: OcRequest, res: OcResponse) => void | Promise<void>;
68
+ type ExpressMiddleware = (req: any, res: any, next: (err?: unknown) => void) => void;
69
+ export type HttpServerAdapter = {
70
+ name: string;
71
+ enableBodyParser(opts: {
72
+ limit?: number | string;
73
+ }): void;
74
+ enableCookies(): void;
75
+ enableFileUploads(opts: {
76
+ tempDir: string;
77
+ filename: (originalName: string) => string;
78
+ }): void;
79
+ enableRequestTiming(onDone: (req: OcRequest, res: OcResponse, ms: number) => void): void;
80
+ enableLogging(opts: {
81
+ skip: (req: OcRequest, res: OcResponse) => boolean;
82
+ }): void;
83
+ enableErrorHandler(): void;
84
+ use(handler: OcHandler): void;
85
+ route(method: Method, path: string, id: string, handlers: OcHandler[]): void;
86
+ fromConnect(handler: ExpressMiddleware): OcHandler;
87
+ listen(opts: {
88
+ port: number | string;
89
+ timeout: number;
90
+ keepAliveTimeout?: number;
91
+ }, cb: (err?: Error) => void): void;
92
+ onServerError(cb: (err: Error) => void): void;
93
+ close(cb: (err?: Error) => void): void;
94
+ isListening(): boolean;
95
+ native(): unknown;
96
+ httpServer(): http.Server;
97
+ };
98
+ export interface FastifyServerAdapterOptions {
99
+ host?: string;
100
+ port?: number | string;
101
+ trustProxy?: boolean | string | string[] | number;
102
+ }
103
+ export default function createFastifyAdapter(options?: FastifyServerAdapterOptions | number | string): HttpServerAdapter;
104
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,581 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.default = createFastifyAdapter;
7
+ const node_fs_1 = require("node:fs");
8
+ const promises_1 = require("node:fs/promises");
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const node_querystring_1 = require("node:querystring");
11
+ const promises_2 = require("node:stream/promises");
12
+ const node_zlib_1 = require("node:zlib");
13
+ const cookie_1 = __importDefault(require("@fastify/cookie"));
14
+ const etag_1 = __importDefault(require("@fastify/etag"));
15
+ const formbody_1 = __importDefault(require("@fastify/formbody"));
16
+ const multipart_1 = __importDefault(require("@fastify/multipart"));
17
+ const fastify_1 = __importDefault(require("fastify"));
18
+ const qs_1 = require("qs");
19
+ const ocRequestSym = Symbol('ocRequest');
20
+ const ocResponseSym = Symbol('ocResponse');
21
+ const timingStartSym = Symbol('timingStart');
22
+ const multipartParsedSym = Symbol('multipartParsed');
23
+ const defaultBodyLimit = 100 * 1024;
24
+ function createFastifyAdapter(options) {
25
+ const adapterOptions = typeof options === 'object' && options !== null
26
+ ? options
27
+ : { port: options };
28
+ return new FastifyHttpServerAdapter(adapterOptions);
29
+ }
30
+ class FastifyHttpServerAdapter {
31
+ name = 'fastify';
32
+ app;
33
+ bodyInflationRegistered = false;
34
+ bodyLimit = defaultBodyLimit;
35
+ host;
36
+ routes = [];
37
+ optionsRoutesRegistered = false;
38
+ constructor(options = {}) {
39
+ this.host = options.host;
40
+ this.app = (0, fastify_1.default)({
41
+ bodyLimit: this.bodyLimit,
42
+ exposeHeadRoutes: true,
43
+ logger: false,
44
+ routerOptions: {
45
+ querystringParser: (str) => (0, node_querystring_1.parse)(str)
46
+ },
47
+ trustProxy: options.trustProxy
48
+ });
49
+ this.app.register(etag_1.default, { weak: false, replyWith304: true });
50
+ }
51
+ enableBodyParser(opts) {
52
+ this.bodyLimit = parseBodyLimit(opts.limit);
53
+ this.registerBodyInflation();
54
+ this.app.register(formbody_1.default, {
55
+ bodyLimit: this.bodyLimit,
56
+ parser: (body) => (0, qs_1.parse)(body)
57
+ });
58
+ }
59
+ enableCookies() {
60
+ this.app.register(cookie_1.default);
61
+ }
62
+ enableFileUploads(opts) {
63
+ this.app.register(multipart_1.default, {
64
+ limits: {
65
+ fieldSize: 10,
66
+ fileSize: Number.MAX_SAFE_INTEGER
67
+ }
68
+ });
69
+ this.app.addHook('preHandler', async (request) => {
70
+ const req = request;
71
+ if (req[multipartParsedSym] ||
72
+ typeof req.isMultipart !== 'function' ||
73
+ !req.isMultipart()) {
74
+ return;
75
+ }
76
+ req[multipartParsedSym] = true;
77
+ await (0, promises_1.mkdir)(opts.tempDir, { recursive: true });
78
+ const files = [];
79
+ const body = {};
80
+ for await (const part of req.parts({
81
+ limits: { fieldSize: 10, fileSize: Number.MAX_SAFE_INTEGER }
82
+ })) {
83
+ if (part.type === 'file') {
84
+ const originalName = part.filename || 'file';
85
+ const filename = opts.filename(originalName);
86
+ const destination = opts.tempDir;
87
+ const filePath = node_path_1.default.join(destination, filename);
88
+ await (0, promises_2.pipeline)(part.file, (0, node_fs_1.createWriteStream)(filePath));
89
+ const fileStats = await (0, promises_1.stat)(filePath);
90
+ files.push({
91
+ destination,
92
+ encoding: part.encoding,
93
+ fieldname: part.fieldname,
94
+ filename,
95
+ mimetype: part.mimetype,
96
+ originalname: originalName,
97
+ path: filePath,
98
+ size: fileStats.size,
99
+ stream: part.file,
100
+ truncated: part.file.truncated
101
+ });
102
+ }
103
+ else {
104
+ if (part.valueTruncated) {
105
+ throw fastifyMultipartFieldLimitError(part.fieldname);
106
+ }
107
+ assignBodyField(body, part.fieldname, part.value);
108
+ }
109
+ }
110
+ req.body = body;
111
+ this.toOcRequest(req).files = files;
112
+ });
113
+ }
114
+ enableRequestTiming(onDone) {
115
+ this.app.addHook('onRequest', (request, _reply, done) => {
116
+ request[timingStartSym] =
117
+ process.hrtime.bigint();
118
+ done();
119
+ });
120
+ this.app.addHook('onResponse', (request, reply, done) => {
121
+ const req = request;
122
+ const start = req[timingStartSym] ?? process.hrtime.bigint();
123
+ const ms = Number(process.hrtime.bigint() - start) / 1_000_000;
124
+ onDone(this.toOcRequest(req), this.toOcResponse(reply), ms);
125
+ done();
126
+ });
127
+ }
128
+ enableLogging(opts) {
129
+ this.app.addHook('onResponse', (request, reply, done) => {
130
+ const req = this.toOcRequest(request);
131
+ const res = this.toOcResponse(reply);
132
+ let skip = false;
133
+ try {
134
+ skip = opts.skip(req, res);
135
+ }
136
+ catch {
137
+ skip = false;
138
+ }
139
+ if (!skip) {
140
+ console.log(`${req.method} ${req.url} ${res.statusCode} - ${Math.round(reply.elapsedTime)} ms`);
141
+ }
142
+ done();
143
+ });
144
+ }
145
+ enableErrorHandler() {
146
+ this.app.setErrorHandler((error, _request, reply) => {
147
+ const err = error;
148
+ const statusCode = typeof err.statusCode === 'number' ? err.statusCode : 500;
149
+ reply
150
+ .code(statusCode)
151
+ .type('text/html; charset=utf-8')
152
+ .send(err.stack || err.message || String(error));
153
+ });
154
+ }
155
+ use(handler) {
156
+ this.app.addHook('preHandler', async (request, reply) => {
157
+ const ocReq = this.toOcRequest(request);
158
+ const ocRes = this.toOcResponse(reply);
159
+ await handler(ocReq, ocRes);
160
+ });
161
+ }
162
+ route(method, routePath, id, handlers) {
163
+ const url = toFastifyRoutePath(routePath);
164
+ this.routes.push({ method, path: url });
165
+ this.app.route({
166
+ bodyLimit: this.bodyLimit,
167
+ handler: (request, reply) => {
168
+ const ocReq = this.toOcRequest(request, id);
169
+ const ocRes = this.toOcResponse(reply);
170
+ void this.runRouteHandlers(handlers, ocReq, ocRes).catch((err) => {
171
+ if (!reply.sent) {
172
+ reply.send(err);
173
+ }
174
+ });
175
+ return reply;
176
+ },
177
+ method: method.toUpperCase(),
178
+ url
179
+ });
180
+ }
181
+ fromConnect(handler) {
182
+ return (req, res) => new Promise((resolve, reject) => {
183
+ let settled = false;
184
+ const finish = (err) => {
185
+ if (settled) {
186
+ return;
187
+ }
188
+ settled = true;
189
+ cleanup();
190
+ if (err) {
191
+ reject(err);
192
+ }
193
+ else {
194
+ resolve();
195
+ }
196
+ };
197
+ const cleanup = () => {
198
+ res.raw.off('finish', onFinish);
199
+ res.raw.off('close', onFinish);
200
+ res.raw.off('error', onError);
201
+ };
202
+ const onFinish = () => finish();
203
+ const onError = (err) => finish(err);
204
+ res.raw.once('finish', onFinish);
205
+ res.raw.once('close', onFinish);
206
+ res.raw.once('error', onError);
207
+ try {
208
+ handler(req, res, (err) => finish(err));
209
+ if (isResponseSent(res)) {
210
+ finish();
211
+ }
212
+ }
213
+ catch (err) {
214
+ finish(err);
215
+ }
216
+ });
217
+ }
218
+ listen(opts, cb) {
219
+ this.registerOptionsRoutes();
220
+ this.app.server.timeout = opts.timeout;
221
+ if (opts.keepAliveTimeout) {
222
+ this.app.server.keepAliveTimeout = opts.keepAliveTimeout;
223
+ }
224
+ let port;
225
+ try {
226
+ port = normalisePort(opts.port);
227
+ }
228
+ catch (err) {
229
+ cb(err instanceof Error ? err : new Error(String(err)));
230
+ return;
231
+ }
232
+ this.app.listen({ host: this.host ?? '0.0.0.0', port }, (err) => cb(err ?? undefined));
233
+ }
234
+ onServerError(cb) {
235
+ this.app.server.on('error', cb);
236
+ }
237
+ close(cb) {
238
+ this.app.close().then(() => cb(), cb);
239
+ }
240
+ isListening() {
241
+ return this.app.server.listening;
242
+ }
243
+ native() {
244
+ return this.app;
245
+ }
246
+ httpServer() {
247
+ return this.app.server;
248
+ }
249
+ async runRouteHandlers(handlers, req, res) {
250
+ if (handlers.length === 0) {
251
+ res.status(404).end();
252
+ return;
253
+ }
254
+ for (const handler of handlers) {
255
+ await handler(req, res);
256
+ if (isResponseSent(res)) {
257
+ return;
258
+ }
259
+ }
260
+ }
261
+ registerBodyInflation() {
262
+ if (this.bodyInflationRegistered) {
263
+ return;
264
+ }
265
+ this.bodyInflationRegistered = true;
266
+ this.app.addHook('preParsing', (request, _reply, payload, done) => {
267
+ const encoding = String(request.headers['content-encoding'] || '').toLowerCase();
268
+ const decoder = encoding === 'gzip'
269
+ ? (0, node_zlib_1.createGunzip)()
270
+ : encoding === 'deflate'
271
+ ? (0, node_zlib_1.createInflate)()
272
+ : encoding === 'br'
273
+ ? (0, node_zlib_1.createBrotliDecompress)()
274
+ : undefined;
275
+ if (!decoder) {
276
+ done(null, payload);
277
+ return;
278
+ }
279
+ Object.defineProperty(decoder, 'receivedEncodedLength', {
280
+ get: () => decoder.bytesWritten
281
+ });
282
+ done(null, payload.pipe(decoder));
283
+ });
284
+ }
285
+ toOcRequest(request, routeId) {
286
+ const cached = request[ocRequestSym];
287
+ const params = normaliseParams(request.params);
288
+ const cookies = normaliseCookies(request.cookies);
289
+ const originalUrl = request.originalUrl || request.url;
290
+ if (cached) {
291
+ Object.assign(cached, {
292
+ body: request.body,
293
+ cookies,
294
+ headers: request.headers,
295
+ ip: request.ip,
296
+ method: request.method,
297
+ originalUrl,
298
+ params,
299
+ path: getPath(request.url),
300
+ protocol: request.protocol,
301
+ query: request.query,
302
+ routeId: routeId ?? cached.routeId,
303
+ secure: request.protocol === 'https',
304
+ url: request.url
305
+ });
306
+ return cached;
307
+ }
308
+ const ocReq = {
309
+ body: request.body,
310
+ cookies,
311
+ get: (header) => getHeader(request, header),
312
+ headers: request.headers,
313
+ ip: request.ip,
314
+ method: request.method,
315
+ originalUrl,
316
+ params,
317
+ path: getPath(request.url),
318
+ protocol: request.protocol,
319
+ query: request.query,
320
+ raw: request.raw,
321
+ routeId: routeId ?? '',
322
+ secure: request.protocol === 'https',
323
+ url: request.url
324
+ };
325
+ request[ocRequestSym] = ocReq;
326
+ return ocReq;
327
+ }
328
+ toOcResponse(reply) {
329
+ const res = reply;
330
+ if (!res[ocResponseSym]) {
331
+ res[ocResponseSym] = new FastifyOcResponse(reply);
332
+ }
333
+ return res[ocResponseSym];
334
+ }
335
+ registerOptionsRoutes() {
336
+ if (this.optionsRoutesRegistered) {
337
+ return;
338
+ }
339
+ this.optionsRoutesRegistered = true;
340
+ const handler = (request, reply) => {
341
+ reply.header('Allow', this.getAllowHeader(getPath(request.url))).send('');
342
+ };
343
+ this.app.route({ method: 'OPTIONS', url: '/', handler });
344
+ this.app.route({ method: 'OPTIONS', url: '/*', handler });
345
+ }
346
+ getAllowHeader(pathname) {
347
+ const matchingRoutes = this.routes.filter((route) => routePathMatches(route.path, pathname));
348
+ const routes = matchingRoutes.length > 0 ? matchingRoutes : this.routes;
349
+ const methods = new Set(['OPTIONS']);
350
+ for (const route of routes) {
351
+ methods.add(route.method.toUpperCase());
352
+ if (route.method === 'get') {
353
+ methods.add('HEAD');
354
+ }
355
+ }
356
+ return ['GET', 'HEAD', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS']
357
+ .filter((method) => methods.has(method))
358
+ .join(', ');
359
+ }
360
+ }
361
+ class FastifyOcResponse {
362
+ reply;
363
+ conf;
364
+ errorCode;
365
+ errorDetails;
366
+ raw;
367
+ hasSent = false;
368
+ constructor(reply) {
369
+ this.reply = reply;
370
+ this.raw = reply.raw;
371
+ }
372
+ get statusCode() {
373
+ return this.reply.statusCode;
374
+ }
375
+ set statusCode(code) {
376
+ this.reply.code(code);
377
+ }
378
+ get sent() {
379
+ return this.hasSent || this.reply.sent || this.raw.writableEnded;
380
+ }
381
+ status(code) {
382
+ this.reply.code(code);
383
+ return this;
384
+ }
385
+ json(body) {
386
+ this.hasSent = true;
387
+ this.reply.type('application/json; charset=utf-8').send(body);
388
+ }
389
+ send(body) {
390
+ if (typeof body === 'string' && !this.reply.hasHeader('content-type')) {
391
+ this.reply.type('text/html; charset=utf-8');
392
+ }
393
+ this.hasSent = true;
394
+ this.reply.send(body);
395
+ }
396
+ set(field, value) {
397
+ if (typeof field === 'string') {
398
+ this.reply.header(field, value);
399
+ }
400
+ else {
401
+ this.reply.headers(field);
402
+ }
403
+ return this;
404
+ }
405
+ header(name, value) {
406
+ return this.set(name, value);
407
+ }
408
+ setHeader(name, value) {
409
+ return this.set(name, value);
410
+ }
411
+ removeHeader(name) {
412
+ this.reply.removeHeader(name);
413
+ return this;
414
+ }
415
+ type(mime) {
416
+ this.reply.type(mime);
417
+ return this;
418
+ }
419
+ cookie(name, value, opts) {
420
+ this.reply.cookie(name, String(value), toFastifyCookieOptions(opts));
421
+ return this;
422
+ }
423
+ redirect(url) {
424
+ this.hasSent = true;
425
+ this.reply.redirect(url);
426
+ }
427
+ end(chunk) {
428
+ this.hasSent = true;
429
+ this.reply.send(chunk);
430
+ }
431
+ stream(readable) {
432
+ readable.on('error', (err) => {
433
+ if (this.raw.headersSent) {
434
+ this.raw.destroy(err instanceof Error ? err : undefined);
435
+ }
436
+ else if (!this.sent) {
437
+ this.status(500).end(String(err));
438
+ }
439
+ });
440
+ this.hasSent = true;
441
+ this.reply.send(readable);
442
+ }
443
+ }
444
+ function toFastifyCookieOptions(opts) {
445
+ if (opts?.signed) {
446
+ throw new Error('Signed cookies require a Fastify cookie secret');
447
+ }
448
+ const next = { ...(opts ?? {}), path: opts?.path ?? '/' };
449
+ if (typeof opts?.maxAge === 'number') {
450
+ next.maxAge = Math.floor(opts.maxAge / 1000);
451
+ if (!opts.expires) {
452
+ next.expires = new Date(Date.now() + opts.maxAge);
453
+ }
454
+ }
455
+ return next;
456
+ }
457
+ function fastifyMultipartFieldLimitError(fieldname) {
458
+ const err = new Error(`Field value too long: ${fieldname}`);
459
+ err.code = 'LIMIT_FIELD_VALUE';
460
+ err.statusCode = 413;
461
+ return err;
462
+ }
463
+ function parseBodyLimit(limit) {
464
+ if (typeof limit === 'number') {
465
+ return limit;
466
+ }
467
+ if (!limit) {
468
+ return defaultBodyLimit;
469
+ }
470
+ const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(limit);
471
+ if (!match) {
472
+ throw new Error(`Invalid body parser limit: ${limit}`);
473
+ }
474
+ const value = Number(match[1]);
475
+ const unit = (match[2] || 'b').toLowerCase();
476
+ const multiplier = unit === 'gb'
477
+ ? 1024 ** 3
478
+ : unit === 'mb'
479
+ ? 1024 ** 2
480
+ : unit === 'kb'
481
+ ? 1024
482
+ : 1;
483
+ return Math.floor(value * multiplier);
484
+ }
485
+ function normalisePort(port) {
486
+ if (typeof port === 'number') {
487
+ return port;
488
+ }
489
+ const parsed = Number(port);
490
+ if (!Number.isInteger(parsed)) {
491
+ throw new Error(`Fastify adapter requires a numeric port, got "${port}"`);
492
+ }
493
+ return parsed;
494
+ }
495
+ function toFastifyRoutePath(routePath) {
496
+ return routePath.replace(/\*[A-Za-z0-9_]+/g, '*');
497
+ }
498
+ function routePathMatches(routePath, pathname) {
499
+ if (routePath === pathname) {
500
+ return true;
501
+ }
502
+ const pattern = routePath
503
+ .split('/')
504
+ .map((segment) => {
505
+ if (segment === '*') {
506
+ return '.*';
507
+ }
508
+ if (segment.startsWith(':')) {
509
+ return '[^/]+';
510
+ }
511
+ return escapeRegExp(segment);
512
+ })
513
+ .join('/');
514
+ return new RegExp(`^${pattern}$`).test(pathname);
515
+ }
516
+ function escapeRegExp(value) {
517
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
518
+ }
519
+ function normaliseParams(raw) {
520
+ const params = {};
521
+ for (const [key, value] of Object.entries(raw || {})) {
522
+ if (typeof value === 'string') {
523
+ params[key] = value;
524
+ }
525
+ else if (Array.isArray(value)) {
526
+ params[key] = value.join('/');
527
+ }
528
+ else if (typeof value !== 'undefined') {
529
+ params[key] = String(value);
530
+ }
531
+ }
532
+ const splat = raw['*'];
533
+ if (typeof splat === 'string') {
534
+ params['splat'] = splat;
535
+ delete params['*'];
536
+ }
537
+ else if (Array.isArray(splat)) {
538
+ params['splat'] = splat.join('/');
539
+ delete params['*'];
540
+ }
541
+ return params;
542
+ }
543
+ function normaliseCookies(cookies) {
544
+ const result = {};
545
+ for (const [key, value] of Object.entries(cookies || {})) {
546
+ if (typeof value !== 'undefined') {
547
+ result[key] = value;
548
+ }
549
+ }
550
+ return result;
551
+ }
552
+ function getHeader(request, header) {
553
+ const value = request.headers[header.toLowerCase()];
554
+ if (Array.isArray(value)) {
555
+ return value.join(', ');
556
+ }
557
+ if (typeof value === 'number') {
558
+ return String(value);
559
+ }
560
+ return value;
561
+ }
562
+ function getPath(url) {
563
+ const queryStart = url.indexOf('?');
564
+ return queryStart === -1 ? url : url.slice(0, queryStart);
565
+ }
566
+ function assignBodyField(body, field, value) {
567
+ if (typeof body[field] === 'undefined') {
568
+ body[field] = value;
569
+ }
570
+ else if (Array.isArray(body[field])) {
571
+ body[field].push(value);
572
+ }
573
+ else {
574
+ body[field] = [body[field], value];
575
+ }
576
+ }
577
+ function isResponseSent(res) {
578
+ return res instanceof FastifyOcResponse
579
+ ? res.sent
580
+ : res.raw.writableEnded || res.raw.headersSent;
581
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "oc-fastify-server-adapter",
3
+ "version": "0.1.0",
4
+ "description": "Fastify HTTP server adapter for OC",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/opencomponents/oc.git",
10
+ "directory": "packages/oc-fastify-server-adapter"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/opencomponents/oc/issues"
14
+ },
15
+ "homepage": "https://github.com/opencomponents/oc/tree/master/packages/oc-fastify-server-adapter#readme",
16
+ "keywords": [
17
+ "oc",
18
+ "opencomponents",
19
+ "fastify",
20
+ "server",
21
+ "adapter"
22
+ ],
23
+ "author": "Matteo Figus",
24
+ "license": "MIT",
25
+ "files": [
26
+ "dist",
27
+ "!dist/test/**"
28
+ ],
29
+ "scripts": {
30
+ "prebuild": "rimraf dist",
31
+ "build": "npm run lint && tsc",
32
+ "lint": "npx @biomejs/biome check src test",
33
+ "lint:apply": "npx @biomejs/biome check --write src test",
34
+ "test": "jest --runInBand && npm run test:integration",
35
+ "test-silent": "jest --runInBand --silent && npm run test:integration",
36
+ "test:integration": "tsc --project tsconfig.test.json && node dist/test/registry-node.js"
37
+ },
38
+ "jest": {
39
+ "preset": "ts-jest",
40
+ "testEnvironment": "node",
41
+ "testPathIgnorePatterns": ["/dist/"]
42
+ },
43
+ "dependencies": {
44
+ "@fastify/cookie": "^11.0.2",
45
+ "@fastify/etag": "^6.1.0",
46
+ "@fastify/formbody": "^8.0.2",
47
+ "@fastify/multipart": "^10.0.0",
48
+ "fastify": "^5.10.0",
49
+ "qs": "^6.15.3"
50
+ },
51
+ "devDependencies": {
52
+ "@biomejs/biome": "^2.5.0",
53
+ "@types/jest": "^29.5.14",
54
+ "@types/node": "^25.9.3",
55
+ "@types/qs": "^6.15.1",
56
+ "jest": "^29.7.0",
57
+ "rimraf": "^6.1.3",
58
+ "ts-jest": "^29.2.5",
59
+ "typescript": "^6.0.3"
60
+ }
61
+ }