From 73aca76ff31fa90ac9507a35ce21d6ddde73d816 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Tue, 7 Jul 2026 16:59:28 +0200 Subject: [PATCH 01/10] Add opt-in Fastify HTTP server adapter (phase 6) Adds packages/oc-fastify-server-adapter implementing the HttpServerAdapter interface against Fastify: body parsing with qs urlencoded semantics, cookies, multipart uploads normalized to the OC upload file shape, request timing, logging, local error handling, strong ETag/304 support, and HEAD/OPTIONS parity with Express. Re-exports the neutral HTTP adapter types from the oc package root so the external adapter can compile against the public package surface. Includes adapter-level parity tests (ETag/304, OPTIONS, urlencoded qs, cookies, wildcard params, connect middleware, streaming, multipart) and registry-level opt-in smoke/parity tests exercising the adapter through Registry({ server: { adapter } }). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitignore | 1 + biome.json | 3 + package-lock.json | 758 +++++++++++++++++- .../oc-fastify-server-adapter/package.json | 59 ++ .../oc-fastify-server-adapter/src/index.ts | 630 +++++++++++++++ .../test/index.test.ts | 227 ++++++ .../test/registry-node.ts | 190 +++++ .../oc-fastify-server-adapter/tsconfig.json | 33 + packages/oc/src/index.ts | 9 + 9 files changed, 1892 insertions(+), 18 deletions(-) create mode 100644 packages/oc-fastify-server-adapter/package.json create mode 100644 packages/oc-fastify-server-adapter/src/index.ts create mode 100644 packages/oc-fastify-server-adapter/test/index.test.ts create mode 100644 packages/oc-fastify-server-adapter/test/registry-node.ts create mode 100644 packages/oc-fastify-server-adapter/tsconfig.json diff --git a/.gitignore b/.gitignore index f235f271b..79d783827 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ packages/oc-s3-storage-adapter/dist/ packages/oc-azure-storage-adapter/dist/ packages/oc-gs-storage-adapter/dist/ packages/oc-riak-storage-adapter/dist/ +packages/oc-fastify-server-adapter/dist/ packages/oc/test-components/ packages/oc-client-browser/playwright-report/ packages/oc-client-browser/test-results/ diff --git a/biome.json b/biome.json index d14f2f3c7..0d46cb382 100644 --- a/biome.json +++ b/biome.json @@ -22,6 +22,9 @@ "packages/oc-metadata-adapters-utils/**/*", "!packages/oc-metadata-adapters-utils/dist/**/*", "!packages/oc-metadata-adapters-utils/node_modules/**/*", + "packages/oc-fastify-server-adapter/**/*", + "!packages/oc-fastify-server-adapter/dist/**/*", + "!packages/oc-fastify-server-adapter/node_modules/**/*", "packages/oc-client-browser/**/*", "!packages/oc-client-browser/dist/**/*", "!packages/oc-client-browser/vendor/**/*", diff --git a/package-lock.json b/package-lock.json index 35526d9d7..1ca6b1f22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4413,6 +4413,260 @@ "node": ">=18" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/cookie/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/etag": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@fastify/etag/-/etag-6.1.0.tgz", + "integrity": "sha512-95fxJ1YHSh1p76+tXyFvluy/H1G1Z+f7fSlv8CQxAFCuGIr/DgtwAYAfZIG//WuKZ7UB4TBLJOX14ZaIPqEirQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/formbody": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz", + "integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-querystring": "^1.1.2", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/multipart": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.0.0.tgz", + "integrity": "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/proxy-addr/node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@google-cloud/paginator": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-3.0.7.tgz", @@ -5496,6 +5750,12 @@ "node": ">= 8" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -6412,9 +6672,9 @@ "license": "MIT" }, "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "dev": true, "license": "MIT" }, @@ -6751,6 +7011,12 @@ "node": ">=6.5" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accept-language-parser": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz", @@ -7088,6 +7354,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/author-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/author-regex/-/author-regex-1.0.0.tgz", @@ -7112,6 +7387,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -8717,6 +9012,15 @@ "node": ">=4" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -9380,6 +9684,12 @@ "node": ">=0.4.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9409,6 +9719,56 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/fast-json-stringify": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.0.tgz", + "integrity": "sha512-YV53BAbR3Qwq37wfD1oZ97YJ0nYj6CwfzKXQ38ock9XxI2EnLOdl5psKms6Evook6ACckytZJOaKY0ThmIi1uw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, "node_modules/fast-text-encoding": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz", @@ -9460,11 +9820,59 @@ "url": "https://paypal.me/naturalintelligence" } }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -9543,6 +9951,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/find-package-json": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/find-package-json/-/find-package-json-1.2.0.tgz", @@ -11645,6 +12067,25 @@ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -12109,6 +12550,56 @@ "node": ">=6" } }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -13050,6 +13541,10 @@ "node": ">=6" } }, + "node_modules/oc-fastify-server-adapter": { + "resolved": "packages/oc-fastify-server-adapter", + "link": true + }, "node_modules/oc-generic-template-compiler": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/oc-generic-template-compiler/-/oc-generic-template-compiler-2.2.0.tgz", @@ -13766,6 +14261,15 @@ "node": ">=12" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -14098,6 +14602,43 @@ "node": ">=6" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -14348,6 +14889,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/promise": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", @@ -14445,12 +15002,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -14497,6 +15055,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -14659,6 +15223,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -14918,6 +15491,15 @@ "node": ">= 0.8.0" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -14944,13 +15526,18 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/right-align": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", @@ -15206,6 +15793,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -15237,6 +15855,22 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", @@ -15497,6 +16131,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15542,14 +16182,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -15561,13 +16201,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -15683,6 +16323,15 @@ "node": ">=8" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -15731,6 +16380,15 @@ "signal-exit": "^4.0.1" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -16321,6 +16979,24 @@ "node": ">=8" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -16405,6 +17081,15 @@ "node": ">=8.0" } }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -18205,6 +18890,43 @@ "@esbuild/win32-x64": "0.28.1" } }, + "packages/oc-fastify-server-adapter": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/etag": "^6.1.0", + "@fastify/formbody": "^8.0.2", + "@fastify/multipart": "^10.0.0", + "fastify": "^5.10.0", + "oc": "*", + "qs": "^6.15.3" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.0", + "@types/jest": "^29.5.14", + "@types/node": "^25.9.3", + "@types/qs": "^6.15.1", + "jest": "^29.7.0", + "rimraf": "^6.1.3", + "ts-jest": "^29.2.5", + "typescript": "^6.0.3" + } + }, + "packages/oc-fastify-server-adapter/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/oc-gs-storage-adapter": { "version": "2.2.2", "license": "MIT", diff --git a/packages/oc-fastify-server-adapter/package.json b/packages/oc-fastify-server-adapter/package.json new file mode 100644 index 000000000..a105db21d --- /dev/null +++ b/packages/oc-fastify-server-adapter/package.json @@ -0,0 +1,59 @@ +{ + "name": "oc-fastify-server-adapter", + "version": "0.1.0", + "description": "Fastify HTTP server adapter for OC", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/opencomponents/oc.git", + "directory": "packages/oc-fastify-server-adapter" + }, + "bugs": { + "url": "https://github.com/opencomponents/oc/issues" + }, + "homepage": "https://github.com/opencomponents/oc/tree/master/packages/oc-fastify-server-adapter#readme", + "keywords": [ + "oc", + "opencomponents", + "fastify", + "server", + "adapter" + ], + "author": "Matteo Figus", + "license": "MIT", + "files": [ + "dist" + ], + "scripts": { + "prebuild": "rimraf dist", + "build": "npm run lint && tsc", + "lint": "npx @biomejs/biome check src test", + "test": "jest --runInBand && node dist/test/registry-node.js", + "test-silent": "jest --runInBand --silent && node dist/test/registry-node.js" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testPathIgnorePatterns": ["/dist/"] + }, + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/etag": "^6.1.0", + "@fastify/formbody": "^8.0.2", + "@fastify/multipart": "^10.0.0", + "fastify": "^5.10.0", + "oc": "*", + "qs": "^6.15.3" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.0", + "@types/jest": "^29.5.14", + "@types/node": "^25.9.3", + "@types/qs": "^6.15.1", + "jest": "^29.7.0", + "rimraf": "^6.1.3", + "ts-jest": "^29.2.5", + "typescript": "^6.0.3" + } +} diff --git a/packages/oc-fastify-server-adapter/src/index.ts b/packages/oc-fastify-server-adapter/src/index.ts new file mode 100644 index 000000000..f44918a9a --- /dev/null +++ b/packages/oc-fastify-server-adapter/src/index.ts @@ -0,0 +1,630 @@ +import { createWriteStream } from 'node:fs'; +import { mkdir, stat } from 'node:fs/promises'; +import type http from 'node:http'; +import path from 'node:path'; +import { parse as parseQueryString } from 'node:querystring'; +import { pipeline } from 'node:stream/promises'; +import cookie from '@fastify/cookie'; +import etag from '@fastify/etag'; +import formbody from '@fastify/formbody'; +import multipart from '@fastify/multipart'; +import fastify, { + type FastifyInstance, + type FastifyReply, + type FastifyRequest, + type HTTPMethods +} from 'fastify'; +import type { + ExpressMiddleware, + HttpServerAdapter, + Method, + OcHandler, + OcRequest, + OcResponse, + UploadedFile +} from 'oc'; +import { parse as parseQs } from 'qs'; + +export interface FastifyServerAdapterOptions { + port?: number | string; + trustProxy?: boolean | string | string[] | number; +} + +const ocRequestSym = Symbol('ocRequest'); +const ocResponseSym = Symbol('ocResponse'); +const timingStartSym = Symbol('timingStart'); +const multipartParsedSym = Symbol('multipartParsed'); +const defaultBodyLimit = 100 * 1024; + +type FastifyRequestWithState = FastifyRequest & { + [ocRequestSym]?: MutableOcRequest; + [timingStartSym]?: bigint; + [multipartParsedSym]?: boolean; +}; + +type FastifyReplyWithState = FastifyReply & { + [ocResponseSym]?: FastifyOcResponse; +}; + +type MutableOcRequest = OcRequest & { + files?: UploadedFile[] | Record; +}; + +export default function createFastifyAdapter( + options?: FastifyServerAdapterOptions | number | string +): HttpServerAdapter { + const adapterOptions = + typeof options === 'object' && options !== null + ? options + : { port: options }; + + return new FastifyHttpServerAdapter(adapterOptions); +} + +class FastifyHttpServerAdapter implements HttpServerAdapter { + name = 'fastify'; + + private app: FastifyInstance; + private bodyLimit = defaultBodyLimit; + private routes: Array<{ method: Method; path: string }> = []; + private optionsRoutesRegistered = false; + + constructor(options: FastifyServerAdapterOptions = {}) { + this.app = fastify({ + bodyLimit: this.bodyLimit, + exposeHeadRoutes: true, + logger: false, + routerOptions: { + querystringParser: (str) => + parseQueryString(str) as Record + }, + trustProxy: options.trustProxy + }); + + this.app.register(etag, { weak: false, replyWith304: true }); + } + + enableBodyParser(opts: { limit?: number | string }): void { + this.bodyLimit = parseBodyLimit(opts.limit); + this.app.register(formbody, { + bodyLimit: this.bodyLimit, + parser: (body) => parseQs(body) as Record + }); + } + + enableCookies(): void { + this.app.register(cookie); + } + + enableFileUploads(opts: { + tempDir: string; + filename: (originalName: string) => string; + }): void { + this.app.register(multipart, { + limits: { + fieldSize: 10, + fileSize: Number.MAX_SAFE_INTEGER + } + }); + + this.app.addHook('preHandler', async (request) => { + const req = request as FastifyRequestWithState; + if ( + req[multipartParsedSym] || + typeof req.isMultipart !== 'function' || + !req.isMultipart() + ) { + return; + } + + req[multipartParsedSym] = true; + await mkdir(opts.tempDir, { recursive: true }); + + const files: UploadedFile[] = []; + const body: Record = {}; + + for await (const part of req.parts({ + limits: { fieldSize: 10, fileSize: Number.MAX_SAFE_INTEGER } + })) { + if (part.type === 'file') { + const originalName = part.filename || 'file'; + const filename = opts.filename(originalName); + const destination = opts.tempDir; + const filePath = path.join(destination, filename); + + await pipeline(part.file, createWriteStream(filePath)); + const fileStats = await stat(filePath); + + files.push({ + destination, + encoding: part.encoding, + fieldname: part.fieldname, + filename, + mimetype: part.mimetype, + originalname: originalName, + path: filePath, + size: fileStats.size, + stream: part.file, + truncated: (part.file as { truncated?: boolean }).truncated + } as UploadedFile); + } else { + assignBodyField(body, part.fieldname, part.value); + } + } + + req.body = body; + this.toOcRequest(req).files = files; + }); + } + + enableRequestTiming( + onDone: (req: OcRequest, res: OcResponse, ms: number) => void + ): void { + this.app.addHook('onRequest', (request, _reply, done) => { + (request as FastifyRequestWithState)[timingStartSym] = + process.hrtime.bigint(); + done(); + }); + + this.app.addHook('onResponse', (request, reply, done) => { + const req = request as FastifyRequestWithState; + const start = req[timingStartSym] ?? process.hrtime.bigint(); + const ms = Number(process.hrtime.bigint() - start) / 1_000_000; + onDone(this.toOcRequest(req), this.toOcResponse(reply), ms); + done(); + }); + } + + enableLogging(opts: { + skip: (req: OcRequest, res: OcResponse) => boolean; + }): void { + this.app.addHook('onResponse', (request, reply, done) => { + const req = this.toOcRequest(request as FastifyRequestWithState); + const res = this.toOcResponse(reply); + if (!opts.skip(req, res)) { + console.log( + `${req.method} ${req.url} ${res.statusCode} - ${Math.round( + reply.elapsedTime + )} ms` + ); + } + done(); + }); + } + + enableErrorHandler(): void { + this.app.setErrorHandler((error, _request, reply) => { + const err = error as { + message?: string; + stack?: string; + statusCode?: number; + }; + const statusCode = + typeof err.statusCode === 'number' ? err.statusCode : 500; + reply + .code(statusCode) + .type('text/html; charset=utf-8') + .send(err.stack || err.message || String(error)); + }); + } + + use(handler: OcHandler): void { + this.app.addHook('preHandler', async (request, reply) => { + const ocReq = this.toOcRequest(request as FastifyRequestWithState); + const ocRes = this.toOcResponse(reply); + + await handler(ocReq, ocRes); + }); + } + + route(method: Method, routePath: string, id: string, handlers: OcHandler[]) { + const url = toFastifyRoutePath(routePath); + this.routes.push({ method, path: url }); + + this.app.route({ + bodyLimit: this.bodyLimit, + handler: (request, reply) => { + const ocReq = this.toOcRequest(request as FastifyRequestWithState, id); + const ocRes = this.toOcResponse(reply); + + void this.runRouteHandlers(handlers, ocReq, ocRes).catch((err) => { + if (!reply.sent) { + reply.send(err); + } + }); + + return reply; + }, + method: method.toUpperCase() as HTTPMethods, + url + }); + } + + fromConnect(handler: ExpressMiddleware): OcHandler { + return (req, res) => + new Promise((resolve, reject) => { + let settled = false; + const finish = (err?: unknown) => { + if (settled) { + return; + } + settled = true; + cleanup(); + if (err) { + reject(err); + } else { + resolve(); + } + }; + const cleanup = () => { + res.raw.off('finish', onFinish); + res.raw.off('close', onFinish); + res.raw.off('error', onError); + }; + const onFinish = () => finish(); + const onError = (err: Error) => finish(err); + + res.raw.once('finish', onFinish); + res.raw.once('close', onFinish); + res.raw.once('error', onError); + + try { + handler(req, res, (err?: unknown) => finish(err)); + if (isResponseSent(res)) { + finish(); + } + } catch (err) { + finish(err); + } + }); + } + + listen( + opts: { port: number | string; timeout: number; keepAliveTimeout?: number }, + cb: (err?: Error) => void + ): void { + this.registerOptionsRoutes(); + + this.app.server.timeout = opts.timeout; + if (opts.keepAliveTimeout) { + this.app.server.keepAliveTimeout = opts.keepAliveTimeout; + } + + this.app.listen({ port: normalisePort(opts.port) }, (err) => + cb(err ?? undefined) + ); + } + + onServerError(cb: (err: Error) => void): void { + this.app.server.on('error', cb); + } + + close(cb: (err?: Error) => void): void { + this.app.close().then(() => cb(), cb); + } + + isListening(): boolean { + return this.app.server.listening; + } + + native(): FastifyInstance { + return this.app; + } + + httpServer(): http.Server { + return this.app.server; + } + + private async runRouteHandlers( + handlers: OcHandler[], + req: OcRequest, + res: OcResponse + ): Promise { + for (const handler of handlers) { + await handler(req, res); + if (isResponseSent(res)) { + return; + } + } + } + + private toOcRequest( + request: FastifyRequestWithState, + routeId?: string + ): MutableOcRequest { + const cached = request[ocRequestSym]; + const params = normaliseParams(request.params as Record); + const cookies = normaliseCookies( + (request as FastifyRequest & { cookies?: Record }).cookies + ); + const originalUrl = request.originalUrl || request.url; + + if (cached) { + Object.assign(cached, { + body: request.body, + cookies, + headers: request.headers, + ip: request.ip, + method: request.method, + originalUrl, + params, + path: getPath(request.url), + protocol: request.protocol, + query: request.query as Record, + routeId: routeId ?? cached.routeId, + secure: request.protocol === 'https', + url: request.url + }); + return cached; + } + + const ocReq: MutableOcRequest = { + body: request.body, + cookies, + get: (header) => getHeader(request, header), + headers: request.headers, + ip: request.ip, + method: request.method, + originalUrl, + params, + path: getPath(request.url), + protocol: request.protocol, + query: request.query as Record, + raw: request.raw, + routeId: routeId ?? '', + secure: request.protocol === 'https', + url: request.url + }; + + request[ocRequestSym] = ocReq; + return ocReq; + } + + private toOcResponse(reply: FastifyReply): FastifyOcResponse { + const res = reply as FastifyReplyWithState; + if (!res[ocResponseSym]) { + res[ocResponseSym] = new FastifyOcResponse(reply); + } + + return res[ocResponseSym]; + } + + private registerOptionsRoutes(): void { + if (this.optionsRoutesRegistered) { + return; + } + + this.optionsRoutesRegistered = true; + const handler = (_request: FastifyRequest, reply: FastifyReply) => { + reply.header('Allow', this.getAllowHeader()).send(''); + }; + + this.app.route({ method: 'OPTIONS', url: '/', handler }); + this.app.route({ method: 'OPTIONS', url: '/*', handler }); + } + + private getAllowHeader(): string { + const methods = new Set(['OPTIONS']); + for (const route of this.routes) { + methods.add(route.method.toUpperCase()); + if (route.method === 'get') { + methods.add('HEAD'); + } + } + + return ['GET', 'HEAD', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS'] + .filter((method) => methods.has(method)) + .join(', '); + } +} + +class FastifyOcResponse implements OcResponse { + conf!: OcResponse['conf']; + errorCode?: string; + errorDetails?: string; + raw: http.ServerResponse; + + constructor(private reply: FastifyReply) { + this.raw = reply.raw; + } + + get statusCode(): number { + return this.reply.statusCode; + } + + set statusCode(code: number) { + this.reply.code(code); + } + + get sent(): boolean { + return this.reply.sent || this.raw.writableEnded; + } + + status(code: number): this { + this.reply.code(code); + return this; + } + + json(body: unknown): void { + this.reply.type('application/json; charset=utf-8').send(body); + } + + send(body: unknown): void { + if (typeof body === 'string' && !this.reply.hasHeader('content-type')) { + this.reply.type('text/html; charset=utf-8'); + } + + this.reply.send(body); + } + + set(field: string | Record, value?: string): this { + if (typeof field === 'string') { + this.reply.header(field, value); + } else { + this.reply.headers(field); + } + return this; + } + + header(name: string, value: string): this { + return this.set(name, value); + } + + setHeader(name: string, value: string): this { + return this.set(name, value); + } + + removeHeader(name: string): this { + this.reply.removeHeader(name); + return this; + } + + type(mime: string): this { + this.reply.type(mime); + return this; + } + + cookie( + name: string, + value: string, + opts?: Parameters[2] + ): this { + this.reply.cookie(name, String(value), opts as never); + return this; + } + + redirect(url: string): void { + this.reply.redirect(url); + } + + end(chunk?: unknown): void { + this.reply.send(chunk ?? ''); + } + + stream(readable: NodeJS.ReadableStream): void { + readable.on('error', (err) => { + if (this.raw.headersSent) { + this.raw.destroy(err instanceof Error ? err : undefined); + } else if (!this.sent) { + this.status(500).end(String(err)); + } + }); + this.reply.send(readable); + } +} + +function parseBodyLimit(limit?: number | string): number { + if (typeof limit === 'number') { + return limit; + } + + if (!limit) { + return defaultBodyLimit; + } + + const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(limit); + if (!match) { + throw new Error(`Invalid body parser limit: ${limit}`); + } + + const value = Number(match[1]); + const unit = (match[2] || 'b').toLowerCase(); + const multiplier = + unit === 'gb' + ? 1024 ** 3 + : unit === 'mb' + ? 1024 ** 2 + : unit === 'kb' + ? 1024 + : 1; + + return Math.floor(value * multiplier); +} + +function normalisePort(port: number | string): number { + if (typeof port === 'number') { + return port; + } + + const parsed = Number(port); + if (!Number.isInteger(parsed)) { + throw new Error(`Fastify adapter requires a numeric port, got "${port}"`); + } + + return parsed; +} + +function toFastifyRoutePath(routePath: string): string { + return routePath.replace(/\*[A-Za-z0-9_]+/g, '*'); +} + +function normaliseParams(raw: Record): Record { + const params: Record = {}; + for (const [key, value] of Object.entries(raw || {})) { + if (typeof value === 'string') { + params[key] = value; + } else if (Array.isArray(value)) { + params[key] = value.join('/'); + } else if (typeof value !== 'undefined') { + params[key] = String(value); + } + } + + if (typeof raw['*'] === 'string') { + params['splat'] = raw['*']; + delete params['*']; + } + + return params; +} + +function normaliseCookies( + cookies?: Record +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(cookies || {})) { + if (typeof value !== 'undefined') { + result[key] = value; + } + } + return result; +} + +function getHeader( + request: FastifyRequest, + header: string +): string | undefined { + const value = request.headers[header.toLowerCase()]; + if (Array.isArray(value)) { + return value.join(', '); + } + if (typeof value === 'number') { + return String(value); + } + return value; +} + +function getPath(url: string): string { + const queryStart = url.indexOf('?'); + return queryStart === -1 ? url : url.slice(0, queryStart); +} + +function assignBodyField( + body: Record, + field: string, + value: unknown +): void { + if (typeof body[field] === 'undefined') { + body[field] = value; + } else if (Array.isArray(body[field])) { + (body[field] as unknown[]).push(value); + } else { + body[field] = [body[field], value]; + } +} + +function isResponseSent(res: OcResponse): boolean { + return res instanceof FastifyOcResponse + ? res.sent + : res.raw.writableEnded || res.raw.headersSent; +} diff --git a/packages/oc-fastify-server-adapter/test/index.test.ts b/packages/oc-fastify-server-adapter/test/index.test.ts new file mode 100644 index 000000000..b7ce28f76 --- /dev/null +++ b/packages/oc-fastify-server-adapter/test/index.test.ts @@ -0,0 +1,227 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import type { FastifyInstance } from 'fastify'; +import type { HttpServerAdapter } from 'oc'; +import createFastifyAdapter from '../src'; + +const asFastify = (adapter: HttpServerAdapter): FastifyInstance => + adapter.native() as FastifyInstance; + +const closeAdapter = (adapter: HttpServerAdapter): Promise => + new Promise((resolve, reject) => { + adapter.close((err) => (err ? reject(err) : resolve())); + }); + +const listen = (adapter: HttpServerAdapter): Promise => + new Promise((resolve, reject) => { + adapter.listen({ port: 0, timeout: 120000 }, (err) => + err ? reject(err) : resolve() + ); + }); + +test('returns strong etags and honours conditional requests', async () => { + const adapter = createFastifyAdapter(); + const app = asFastify(adapter); + + adapter.route('get', '/component', 'component', [ + (_req, res) => { + res.status(200).json({ ok: true }); + } + ]); + + await app.ready(); + + const first = await app.inject('/component'); + const etag = first.headers.etag; + + expect(first.statusCode).toBe(200); + expect(first.json()).toEqual({ ok: true }); + expect(etag).toMatch(/^"/); + expect(etag).not.toMatch(/^W\//); + + const second = await app.inject({ + headers: { 'if-none-match': String(etag) }, + url: '/component' + }); + + expect(second.statusCode).toBe(304); + expect(second.body).toBe(''); + + await closeAdapter(adapter); +}); + +test('runs neutral middleware for OPTIONS and exposes an Express-like allow header', async () => { + const adapter = createFastifyAdapter(); + const app = asFastify(adapter); + + adapter.use((_req, res) => { + res.removeHeader('X-Powered-By'); + res.set('Access-Control-Allow-Origin', '*'); + res.set('Access-Control-Allow-Methods', 'GET, OPTIONS, PUT, POST'); + }); + adapter.route('get', '/component', 'component-get', [ + (_req, res) => { + res.send('ok'); + } + ]); + adapter.route('post', '/component', 'component-post', [ + (_req, res) => { + res.status(201).send('created'); + } + ]); + + await listen(adapter); + + const response = await app.inject({ method: 'OPTIONS', url: '/component' }); + + expect(response.statusCode).toBe(200); + expect(response.headers['access-control-allow-origin']).toBe('*'); + expect(response.headers.allow).toBe('GET, HEAD, POST, OPTIONS'); + + await closeAdapter(adapter); +}); + +test('uses qs semantics for urlencoded request bodies', async () => { + const adapter = createFastifyAdapter(); + const app = asFastify(adapter); + + adapter.enableBodyParser({ limit: '1mb' }); + adapter.route('post', '/submit', 'submit', [ + (req, res) => { + res.json(req.body); + } + ]); + + await app.ready(); + + const response = await app.inject({ + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + method: 'POST', + payload: 'user[name]=Ada&items[]=one&items[]=two', + url: '/submit' + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + items: ['one', 'two'], + user: { name: 'Ada' } + }); + + await closeAdapter(adapter); +}); + +test('normalises cookies, wildcard params, connect middleware, and streams', async () => { + const adapter = createFastifyAdapter(); + const app = asFastify(adapter); + + adapter.enableCookies(); + adapter.route('get', '/assets/*splat', 'asset', [ + adapter.fromConnect((req, _res, next) => { + req.user = 'publisher'; + next(); + }), + (req, res) => { + res.set('x-route-id', req.routeId); + res.set('x-cookie-session', req.cookies['session']); + res.set('x-user', req.user || ''); + res.type('text/plain'); + res.stream(Readable.from([req.params['splat']])); + } + ]); + + await app.ready(); + + const response = await app.inject({ + headers: { cookie: 'session=abc123' }, + url: '/assets/path/to/file.js' + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe('text/plain'); + expect(response.headers['x-route-id']).toBe('asset'); + expect(response.headers['x-cookie-session']).toBe('abc123'); + expect(response.headers['x-user']).toBe('publisher'); + expect(response.body).toBe('path/to/file.js'); + + await closeAdapter(adapter); +}); + +test('normalises multipart uploads into OC files and body fields', async () => { + const adapter = createFastifyAdapter(); + const app = asFastify(adapter); + const tempDir = await mkdtemp(path.join(tmpdir(), 'oc-fastify-adapter-')); + + adapter.enableFileUploads({ + filename: (originalName) => `stored-${originalName}`, + tempDir + }); + adapter.route('put', '/publish/:componentName/:componentVersion', 'publish', [ + async (req, res) => { + const files = Array.isArray(req.files) ? req.files : []; + const firstFile = files[0]; + + res.json({ + body: req.body, + file: firstFile + ? { + destination: firstFile.destination, + fieldname: firstFile.fieldname, + filename: firstFile.filename, + mimetype: firstFile.mimetype, + originalname: firstFile.originalname, + saved: await readFile(firstFile.path, 'utf8'), + size: firstFile.size + } + : undefined, + params: req.params + }); + } + ]); + + await app.ready(); + + const boundary = 'oc-fastify-boundary'; + const payload = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="description"', + '', + 'demo', + `--${boundary}`, + 'Content-Disposition: form-data; name="package"; filename="component.tar.gz"', + 'Content-Type: application/gzip', + '', + 'component archive', + `--${boundary}--`, + '' + ].join('\r\n'); + + const response = await app.inject({ + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + method: 'PUT', + payload, + url: '/publish/hello-world/1.0.0' + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + body: { description: 'demo' }, + file: { + destination: tempDir, + fieldname: 'package', + filename: 'stored-component.tar.gz', + mimetype: 'application/gzip', + originalname: 'component.tar.gz', + saved: 'component archive', + size: 'component archive'.length + }, + params: { + componentName: 'hello-world', + componentVersion: '1.0.0' + } + }); + + await closeAdapter(adapter); + await rm(tempDir, { force: true, recursive: true }); +}); diff --git a/packages/oc-fastify-server-adapter/test/registry-node.ts b/packages/oc-fastify-server-adapter/test/registry-node.ts new file mode 100644 index 000000000..ba83f523e --- /dev/null +++ b/packages/oc-fastify-server-adapter/test/registry-node.ts @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:net'; +import path from 'node:path'; +import createFastifyAdapter from '../src'; + +type RegistryFactory = typeof import('../../oc/dist/registry').default; +type StartedRegistry = ReturnType; + +type StartedData = { + app: unknown; + server: { address(): string | { port: number } | null }; +}; + +const Registry = require(path.resolve(__dirname, '../../../oc/dist/registry')) + .default as RegistryFactory; +const fixturePath = path.resolve( + __dirname, + '../../../oc/test/fixtures/components' +); + +const getPort = (): Promise => + new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, () => { + const address = server.address(); + server.close((err) => { + if (err) { + reject(err); + } else if (address && typeof address !== 'string') { + resolve(address.port); + } else { + reject(new Error('Could not allocate test port')); + } + }); + }); + }); + +const closeRegistry = (registry: StartedRegistry): Promise => + new Promise((resolve, reject) => { + registry.close((err) => (err instanceof Error ? reject(err) : resolve())); + }); + +const startRegistry = async ( + options: Record = {} +): Promise<{ + baseUrl: string; + data: StartedData; + registry: StartedRegistry; +}> => { + const port = await getPort(); + const registry = Registry({ + baseUrl: `http://localhost:${port}/`, + dependencies: ['lodash.isequal'], + env: { name: 'local' }, + local: true, + path: fixturePath, + port, + server: { + adapter: createFastifyAdapter, + options: { port } + }, + verbosity: 0, + ...options + } as never); + + const data = await new Promise((resolve, reject) => { + registry.start((err, startData) => { + if (err) { + reject(err); + } else { + resolve(startData as StartedData); + } + }); + }); + + return { baseUrl: `http://localhost:${port}`, data, registry }; +}; + +async function testRegistryComponentResponses(): Promise { + const { baseUrl, data, registry } = await startRegistry(); + + try { + const address = data.server.address(); + const response = await fetch(`${baseUrl}/hello-world/1.0.0`); + const body = (await response.json()) as Record; + + assert.equal(typeof data.app, 'object'); + assert.ok(data.app && typeof data.app === 'object' && 'server' in data.app); + assert.ok(address && typeof address !== 'string'); + assert.equal(response.status, 200); + assert.equal(body['name'], 'hello-world'); + assert.equal(body['version'], '1.0.0'); + } finally { + await closeRegistry(registry); + } +} + +async function testRegistryEtags(): Promise { + const { baseUrl, registry } = await startRegistry(); + + try { + const url = `${baseUrl}/oc-client/client.js`; + const first = await fetch(url); + const etag = first.headers.get('etag'); + const second = await fetch(url, { + headers: { 'if-none-match': etag || '' } + }); + + assert.equal(first.status, 200); + assert.match(etag || '', /^"/); + assert.doesNotMatch(etag || '', /^W\//); + assert.equal(second.status, 304); + assert.equal(await second.text(), ''); + } finally { + await closeRegistry(registry); + } +} + +async function testRegistryOptions(): Promise { + const { baseUrl, registry } = await startRegistry(); + + try { + const response = await fetch(`${baseUrl}/hello-world`, { + headers: { + 'access-control-request-method': 'GET', + origin: 'http://example.com' + }, + method: 'OPTIONS' + }); + const allow = response.headers.get('allow') || ''; + + assert.equal(response.status, 200); + assert.equal(response.headers.get('access-control-allow-origin'), '*'); + assert.equal( + response.headers.get('access-control-allow-methods'), + 'GET, OPTIONS, PUT, POST' + ); + assert.ok(allow.includes('GET')); + assert.ok(allow.includes('OPTIONS')); + } finally { + await closeRegistry(registry); + } +} + +async function testRegistryUserRouteUrlencodedBody(): Promise { + const { baseUrl, registry } = await startRegistry({ + prefix: '/components/', + routes: [ + { + handler: ( + req: { body: unknown }, + res: { json(body: unknown): void } + ) => { + res.json(req.body); + }, + method: 'POST', + route: '/echo' + } + ] + }); + + try { + const response = await fetch(`${baseUrl}/echo`, { + body: 'user[name]=Ada&items[]=one&items[]=two', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + method: 'POST' + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + items: ['one', 'two'], + user: { name: 'Ada' } + }); + } finally { + await closeRegistry(registry); + } +} + +async function main(): Promise { + await testRegistryComponentResponses(); + await testRegistryEtags(); + await testRegistryOptions(); + await testRegistryUserRouteUrlencodedBody(); +} + +void main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/packages/oc-fastify-server-adapter/tsconfig.json b/packages/oc-fastify-server-adapter/tsconfig.json new file mode 100644 index 000000000..50f92987b --- /dev/null +++ b/packages/oc-fastify-server-adapter/tsconfig.json @@ -0,0 +1,33 @@ +{ + "include": ["src", "test"], + "exclude": ["node_modules"], + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "bundler", + "types": ["node", "jest"], + "declaration": true, + "rootDir": ".", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "useUnknownInCatchVariables": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "skipLibCheck": true + } +} diff --git a/packages/oc/src/index.ts b/packages/oc/src/index.ts index c336bebf6..b8d0d94a0 100644 --- a/packages/oc/src/index.ts +++ b/packages/oc/src/index.ts @@ -2,4 +2,13 @@ export { default as Client } from 'oc-client'; export { default as cli } from './cli/programmatic-api'; export { default as Registry, RegistryOptions } from './registry'; +export type { + ExpressMiddleware, + HttpServerAdapter, + Method, + OcHandler, + OcRequest, + OcResponse, + UploadedFile +} from './registry/domain/http-server/types'; export type { Plugin, PluginContext } from './types'; From bb661071f5dfd57b9798ff94b8ab5b8351f3f175 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Tue, 7 Jul 2026 18:28:14 +0200 Subject: [PATCH 02/10] Address phase 6 review: fix packaging, types, and Fastify behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes blocking issues and should-fix items from the phase 6 review: - Package: fix broken entry points (tsconfig rootDir, files exclude dist/test) - Deps: move oc to peerDependency (>=0.50.56) and devDependency - Types: replace Express/Multer type aliases with neutral CookieOptions/UploadedFile - Cookies: translate Express maxAge (ms→s) and default Path=/; fail on signed - Listen: default host to 0.0.0.0, accept host option, catch invalid ports via callback - Body: add gzip/deflate/brotli inflation via preParsing, set receivedEncodedLength - Logging: guard skip against missing res.conf in adapter and core middleware - Multipart: throw LIMIT_FIELD_VALUE on field truncation (match multer) - OPTIONS: scope Allow header to matching routes when possible - Fallthrough: 404 only on empty handler arrays; document send requirement - Response: track hasSent immediately to avoid false fallthroughs; remove default end() content-type - Tests: add adapter tests for cookie semantics, compressed JSON, listen host, invalid port, scoped Allow, empty-handler 404 - Integration: add non-local publish/auth/static/batch smoke tests with temp file storage - Docs: add README documenting opt-in contract and Fastify differences Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- package-lock.json | 5 +- packages/oc-fastify-server-adapter/README.md | 48 ++++ .../oc-fastify-server-adapter/package.json | 13 +- .../oc-fastify-server-adapter/src/index.ts | 157 +++++++++-- .../test/index.test.ts | 125 +++++++++ .../test/registry-node.ts | 263 +++++++++++++++++- .../oc-fastify-server-adapter/tsconfig.json | 4 +- .../tsconfig.test.json | 9 + packages/oc/src/index.ts | 1 + .../oc/src/registry/domain/extract-package.ts | 7 +- .../src/registry/domain/http-server/types.ts | 29 +- .../domain/validators/uploaded-package.ts | 12 +- packages/oc/src/registry/middleware/index.ts | 2 +- 13 files changed, 637 insertions(+), 38 deletions(-) create mode 100644 packages/oc-fastify-server-adapter/README.md create mode 100644 packages/oc-fastify-server-adapter/tsconfig.test.json diff --git a/package-lock.json b/package-lock.json index 1ca6b1f22..0927dfd80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18899,7 +18899,6 @@ "@fastify/formbody": "^8.0.2", "@fastify/multipart": "^10.0.0", "fastify": "^5.10.0", - "oc": "*", "qs": "^6.15.3" }, "devDependencies": { @@ -18908,9 +18907,13 @@ "@types/node": "^25.9.3", "@types/qs": "^6.15.1", "jest": "^29.7.0", + "oc": "*", "rimraf": "^6.1.3", "ts-jest": "^29.2.5", "typescript": "^6.0.3" + }, + "peerDependencies": { + "oc": ">=0.50.56" } }, "packages/oc-fastify-server-adapter/node_modules/typescript": { diff --git a/packages/oc-fastify-server-adapter/README.md b/packages/oc-fastify-server-adapter/README.md new file mode 100644 index 000000000..a009081b3 --- /dev/null +++ b/packages/oc-fastify-server-adapter/README.md @@ -0,0 +1,48 @@ +# oc-fastify-server-adapter + +Fastify HTTP server adapter for the OC registry. The adapter is opt-in and implements OC's neutral `HttpServerAdapter` interface. + +## Installation + +```sh +npm install oc-fastify-server-adapter fastify +``` + +`oc` is a peer dependency. Use this adapter with an OC version that exports the HTTP server adapter types (`>=0.50.56`). + +## Usage + +```js +const oc = require('oc'); +const createFastifyAdapter = require('oc-fastify-server-adapter').default; + +const registry = oc.Registry({ + // regular OC registry options... + server: { + adapter: createFastifyAdapter, + options: { + host: '0.0.0.0', + port: 3030, + trustProxy: true + } + } +}); +``` + +`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. + +## Opt-in contract and differences from Express + +- `registry.start()` returns a Fastify instance as `app`, not an Express application. Code that reaches into `app` must use Fastify APIs. +- `server.httpServer()` and the `{ server }` value returned by `registry.start()` are the underlying Node `http.Server`. +- 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. +- `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. +- JSON and urlencoded body limits follow the OC/Express configuration. Gzip, deflate, and brotli encoded request bodies are inflated before Fastify parses them. +- 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. +- 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. +- Multipart uploads are normalized to OC's uploaded file shape. Oversized fields return a `LIMIT_FIELD_VALUE`-style 413 instead of silently truncating. +- Strong ETags and 304 conditional responses are enabled to match the Express registry. +- `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. +- 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. +- Non-empty OC route handler chains must eventually send a response. This matches existing OC callback-style handlers; empty handler arrays return 404 defensively. +- Logging uses Fastify response timing, so formatting is not byte-for-byte identical to Morgan's Express output. diff --git a/packages/oc-fastify-server-adapter/package.json b/packages/oc-fastify-server-adapter/package.json index a105db21d..ca2850ccc 100644 --- a/packages/oc-fastify-server-adapter/package.json +++ b/packages/oc-fastify-server-adapter/package.json @@ -23,14 +23,16 @@ "author": "Matteo Figus", "license": "MIT", "files": [ - "dist" + "dist", + "!dist/test/**" ], "scripts": { "prebuild": "rimraf dist", "build": "npm run lint && tsc", "lint": "npx @biomejs/biome check src test", - "test": "jest --runInBand && node dist/test/registry-node.js", - "test-silent": "jest --runInBand --silent && node dist/test/registry-node.js" + "test": "jest --runInBand && npm run test:integration", + "test-silent": "jest --runInBand --silent && npm run test:integration", + "test:integration": "tsc --project tsconfig.test.json && node dist/test/registry-node.js" }, "jest": { "preset": "ts-jest", @@ -43,15 +45,18 @@ "@fastify/formbody": "^8.0.2", "@fastify/multipart": "^10.0.0", "fastify": "^5.10.0", - "oc": "*", "qs": "^6.15.3" }, + "peerDependencies": { + "oc": ">=0.50.56" + }, "devDependencies": { "@biomejs/biome": "^2.5.0", "@types/jest": "^29.5.14", "@types/node": "^25.9.3", "@types/qs": "^6.15.1", "jest": "^29.7.0", + "oc": "*", "rimraf": "^6.1.3", "ts-jest": "^29.2.5", "typescript": "^6.0.3" diff --git a/packages/oc-fastify-server-adapter/src/index.ts b/packages/oc-fastify-server-adapter/src/index.ts index f44918a9a..cb736767e 100644 --- a/packages/oc-fastify-server-adapter/src/index.ts +++ b/packages/oc-fastify-server-adapter/src/index.ts @@ -4,6 +4,7 @@ import type http from 'node:http'; import path from 'node:path'; import { parse as parseQueryString } from 'node:querystring'; import { pipeline } from 'node:stream/promises'; +import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib'; import cookie from '@fastify/cookie'; import etag from '@fastify/etag'; import formbody from '@fastify/formbody'; @@ -15,6 +16,7 @@ import fastify, { type HTTPMethods } from 'fastify'; import type { + CookieOptions, ExpressMiddleware, HttpServerAdapter, Method, @@ -26,6 +28,7 @@ import type { import { parse as parseQs } from 'qs'; export interface FastifyServerAdapterOptions { + host?: string; port?: number | string; trustProxy?: boolean | string | string[] | number; } @@ -65,11 +68,14 @@ class FastifyHttpServerAdapter implements HttpServerAdapter { name = 'fastify'; private app: FastifyInstance; + private bodyInflationRegistered = false; private bodyLimit = defaultBodyLimit; + private host?: string; private routes: Array<{ method: Method; path: string }> = []; private optionsRoutesRegistered = false; constructor(options: FastifyServerAdapterOptions = {}) { + this.host = options.host; this.app = fastify({ bodyLimit: this.bodyLimit, exposeHeadRoutes: true, @@ -86,6 +92,7 @@ class FastifyHttpServerAdapter implements HttpServerAdapter { enableBodyParser(opts: { limit?: number | string }): void { this.bodyLimit = parseBodyLimit(opts.limit); + this.registerBodyInflation(); this.app.register(formbody, { bodyLimit: this.bodyLimit, parser: (body) => parseQs(body) as Record @@ -148,6 +155,9 @@ class FastifyHttpServerAdapter implements HttpServerAdapter { truncated: (part.file as { truncated?: boolean }).truncated } as UploadedFile); } else { + if (part.valueTruncated) { + throw fastifyMultipartFieldLimitError(part.fieldname); + } assignBodyField(body, part.fieldname, part.value); } } @@ -181,7 +191,13 @@ class FastifyHttpServerAdapter implements HttpServerAdapter { this.app.addHook('onResponse', (request, reply, done) => { const req = this.toOcRequest(request as FastifyRequestWithState); const res = this.toOcResponse(reply); - if (!opts.skip(req, res)) { + let skip = false; + try { + skip = opts.skip(req, res); + } catch { + skip = false; + } + if (!skip) { console.log( `${req.method} ${req.url} ${res.statusCode} - ${Math.round( reply.elapsedTime @@ -290,7 +306,15 @@ class FastifyHttpServerAdapter implements HttpServerAdapter { this.app.server.keepAliveTimeout = opts.keepAliveTimeout; } - this.app.listen({ port: normalisePort(opts.port) }, (err) => + let port: number; + try { + port = normalisePort(opts.port); + } catch (err) { + cb(err instanceof Error ? err : new Error(String(err))); + return; + } + + this.app.listen({ host: this.host ?? '0.0.0.0', port }, (err) => cb(err ?? undefined) ); } @@ -320,6 +344,11 @@ class FastifyHttpServerAdapter implements HttpServerAdapter { req: OcRequest, res: OcResponse ): Promise { + if (handlers.length === 0) { + res.status(404).end(); + return; + } + for (const handler of handlers) { await handler(req, res); if (isResponseSent(res)) { @@ -328,6 +357,37 @@ class FastifyHttpServerAdapter implements HttpServerAdapter { } } + private registerBodyInflation(): void { + if (this.bodyInflationRegistered) { + return; + } + + this.bodyInflationRegistered = true; + this.app.addHook('preParsing', (request, _reply, payload, done) => { + const encoding = String( + request.headers['content-encoding'] || '' + ).toLowerCase(); + const decoder = + encoding === 'gzip' + ? createGunzip() + : encoding === 'deflate' + ? createInflate() + : encoding === 'br' + ? createBrotliDecompress() + : undefined; + + if (!decoder) { + done(null, payload); + return; + } + + Object.defineProperty(decoder, 'receivedEncodedLength', { + get: () => decoder.bytesWritten + }); + done(null, payload.pipe(decoder)); + }); + } + private toOcRequest( request: FastifyRequestWithState, routeId?: string @@ -395,17 +455,21 @@ class FastifyHttpServerAdapter implements HttpServerAdapter { } this.optionsRoutesRegistered = true; - const handler = (_request: FastifyRequest, reply: FastifyReply) => { - reply.header('Allow', this.getAllowHeader()).send(''); + const handler = (request: FastifyRequest, reply: FastifyReply) => { + reply.header('Allow', this.getAllowHeader(getPath(request.url))).send(''); }; this.app.route({ method: 'OPTIONS', url: '/', handler }); this.app.route({ method: 'OPTIONS', url: '/*', handler }); } - private getAllowHeader(): string { + private getAllowHeader(pathname: string): string { + const matchingRoutes = this.routes.filter((route) => + routePathMatches(route.path, pathname) + ); + const routes = matchingRoutes.length > 0 ? matchingRoutes : this.routes; const methods = new Set(['OPTIONS']); - for (const route of this.routes) { + for (const route of routes) { methods.add(route.method.toUpperCase()); if (route.method === 'get') { methods.add('HEAD'); @@ -423,6 +487,7 @@ class FastifyOcResponse implements OcResponse { errorCode?: string; errorDetails?: string; raw: http.ServerResponse; + private hasSent = false; constructor(private reply: FastifyReply) { this.raw = reply.raw; @@ -437,7 +502,7 @@ class FastifyOcResponse implements OcResponse { } get sent(): boolean { - return this.reply.sent || this.raw.writableEnded; + return this.hasSent || this.reply.sent || this.raw.writableEnded; } status(code: number): this { @@ -446,6 +511,7 @@ class FastifyOcResponse implements OcResponse { } json(body: unknown): void { + this.hasSent = true; this.reply.type('application/json; charset=utf-8').send(body); } @@ -454,6 +520,7 @@ class FastifyOcResponse implements OcResponse { this.reply.type('text/html; charset=utf-8'); } + this.hasSent = true; this.reply.send(body); } @@ -484,21 +551,23 @@ class FastifyOcResponse implements OcResponse { return this; } - cookie( - name: string, - value: string, - opts?: Parameters[2] - ): this { - this.reply.cookie(name, String(value), opts as never); + cookie(name: string, value: string, opts?: CookieOptions): this { + this.reply.cookie( + name, + String(value), + toFastifyCookieOptions(opts) as never + ); return this; } redirect(url: string): void { + this.hasSent = true; this.reply.redirect(url); } end(chunk?: unknown): void { - this.reply.send(chunk ?? ''); + this.hasSent = true; + this.reply.send(chunk); } stream(readable: NodeJS.ReadableStream): void { @@ -509,10 +578,37 @@ class FastifyOcResponse implements OcResponse { this.status(500).end(String(err)); } }); + this.hasSent = true; this.reply.send(readable); } } +function toFastifyCookieOptions(opts?: CookieOptions): CookieOptions { + if (opts?.signed) { + throw new Error('Signed cookies require a Fastify cookie secret'); + } + + const next: CookieOptions = { path: '/', ...opts }; + if (typeof opts?.maxAge === 'number') { + next.maxAge = Math.floor(opts.maxAge / 1000); + if (!opts.expires) { + next.expires = new Date(Date.now() + opts.maxAge); + } + } + + return next; +} + +function fastifyMultipartFieldLimitError(fieldname: string): Error { + const err = new Error(`Field value too long: ${fieldname}`) as Error & { + code?: string; + statusCode?: number; + }; + err.code = 'LIMIT_FIELD_VALUE'; + err.statusCode = 413; + return err; +} + function parseBodyLimit(limit?: number | string): number { if (typeof limit === 'number') { return limit; @@ -558,6 +654,31 @@ function toFastifyRoutePath(routePath: string): string { return routePath.replace(/\*[A-Za-z0-9_]+/g, '*'); } +function routePathMatches(routePath: string, pathname: string): boolean { + if (routePath === pathname) { + return true; + } + + const pattern = routePath + .split('/') + .map((segment) => { + if (segment === '*') { + return '.*'; + } + if (segment.startsWith(':')) { + return '[^/]+'; + } + return escapeRegExp(segment); + }) + .join('/'); + + return new RegExp(`^${pattern}$`).test(pathname); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function normaliseParams(raw: Record): Record { const params: Record = {}; for (const [key, value] of Object.entries(raw || {})) { @@ -570,8 +691,12 @@ function normaliseParams(raw: Record): Record { } } - if (typeof raw['*'] === 'string') { - params['splat'] = raw['*']; + const splat = raw['*']; + if (typeof splat === 'string') { + params['splat'] = splat; + delete params['*']; + } else if (Array.isArray(splat)) { + params['splat'] = splat.join('/'); delete params['*']; } diff --git a/packages/oc-fastify-server-adapter/test/index.test.ts b/packages/oc-fastify-server-adapter/test/index.test.ts index b7ce28f76..3f3f36178 100644 --- a/packages/oc-fastify-server-adapter/test/index.test.ts +++ b/packages/oc-fastify-server-adapter/test/index.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { Readable } from 'node:stream'; +import { gzipSync } from 'node:zlib'; import type { FastifyInstance } from 'fastify'; import type { HttpServerAdapter } from 'oc'; import createFastifyAdapter from '../src'; @@ -148,6 +149,130 @@ test('normalises cookies, wildcard params, connect middleware, and streams', asy await closeAdapter(adapter); }); +test('translates Express cookie options to Fastify cookie options', async () => { + const adapter = createFastifyAdapter(); + const app = asFastify(adapter); + + adapter.enableCookies(); + adapter.route('get', '/cookies', 'cookies', [ + (_req, res) => { + res.cookie('session', 'abc', { maxAge: 60000, sameSite: 'lax' }); + res.send('ok'); + } + ]); + + await app.ready(); + + const response = await app.inject('/cookies'); + const setCookie = String(response.headers['set-cookie']); + + expect(response.statusCode).toBe(200); + expect(setCookie).toContain('session=abc'); + expect(setCookie).toContain('Max-Age=60'); + expect(setCookie).toContain('Path=/'); + expect(setCookie).toContain('SameSite=Lax'); + expect(setCookie).not.toContain('Max-Age=60000'); + + await closeAdapter(adapter); +}); + +test('inflates compressed JSON request bodies', async () => { + const adapter = createFastifyAdapter(); + const app = asFastify(adapter); + + adapter.enableBodyParser({ limit: '1mb' }); + adapter.route('post', '/json', 'json', [ + (req, res) => { + res.json(req.body); + } + ]); + + await app.ready(); + + const response = await app.inject({ + headers: { + 'content-encoding': 'gzip', + 'content-type': 'application/json' + }, + method: 'POST', + payload: gzipSync(JSON.stringify({ ok: true })), + url: '/json' + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true }); + + await closeAdapter(adapter); +}); + +test('defaults listen host to all interfaces and reports invalid ports via callback', async () => { + const adapter = createFastifyAdapter(); + + await listen(adapter); + + const address = adapter.httpServer().address(); + expect(address && typeof address !== 'string' ? address.address : '').toBe( + '0.0.0.0' + ); + + await closeAdapter(adapter); + + const invalidAdapter = createFastifyAdapter(); + await new Promise((resolve, reject) => { + try { + invalidAdapter.listen({ port: 'not-a-port', timeout: 120000 }, (err) => { + try { + expect(err).toBeInstanceOf(Error); + expect(err?.message).toContain('numeric port'); + resolve(); + } catch (assertionErr) { + reject(assertionErr); + } + }); + } catch (err) { + reject(err); + } + }); + + await closeAdapter(invalidAdapter); +}); + +test('returns 404 for empty handler arrays and scopes allow headers to matching routes', async () => { + const adapter = createFastifyAdapter(); + const app = asFastify(adapter); + + adapter.route('get', '/empty', 'empty', []); + adapter.route('get', '/get-only', 'get-only', [ + (_req, res) => { + res.send('ok'); + } + ]); + adapter.route('post', '/post-only', 'post-only', [ + (_req, res) => { + res.send('ok'); + } + ]); + + await listen(adapter); + + const empty = await app.inject('/empty'); + const getOnlyOptions = await app.inject({ + method: 'OPTIONS', + url: '/get-only' + }); + const postOnlyOptions = await app.inject({ + method: 'OPTIONS', + url: '/post-only' + }); + + expect(empty.statusCode).toBe(404); + expect(empty.headers['content-type']).toBeUndefined(); + expect(getOnlyOptions.headers.allow).toBe('GET, HEAD, OPTIONS'); + expect(postOnlyOptions.headers.allow).toBe('POST, OPTIONS'); + + await closeAdapter(adapter); +}); + test('normalises multipart uploads into OC files and body fields', async () => { const adapter = createFastifyAdapter(); const app = asFastify(adapter); diff --git a/packages/oc-fastify-server-adapter/test/registry-node.ts b/packages/oc-fastify-server-adapter/test/registry-node.ts index ba83f523e..ee4dce82c 100644 --- a/packages/oc-fastify-server-adapter/test/registry-node.ts +++ b/packages/oc-fastify-server-adapter/test/registry-node.ts @@ -1,7 +1,19 @@ import assert from 'node:assert/strict'; +import { + cp, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + writeFile +} from 'node:fs/promises'; import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; import path from 'node:path'; -import createFastifyAdapter from '../src'; +import { promisify } from 'node:util'; +import createFastifyAdapter from '..'; type RegistryFactory = typeof import('../../oc/dist/registry').default; type StartedRegistry = ReturnType; @@ -17,6 +29,120 @@ const fixturePath = path.resolve( __dirname, '../../../oc/test/fixtures/components' ); +const targz = require('targz') as { + compress( + opts: { src: string; dest: string }, + cb: (err?: Error | string) => void + ): void; +}; +const compress = promisify(targz.compress); + +type FileStorageOptions = { + componentsDir: string; + path: string; + root: string; +}; + +const storagePath = (root: string, filePath: string): string => + path.join(root, filePath); + +const fileNotFound = (filePath: string): Error & { code: string } => { + const err = new Error(`File "${filePath}" not found`) as Error & { + code: string; + }; + err.code = 'file_not_found'; + return err; +}; + +const readStorageFile = async ( + root: string, + filePath: string +): Promise => { + try { + return await readFile(storagePath(root, filePath)); + } catch (err: any) { + if (err?.code === 'ENOENT') { + throw fileNotFound(filePath); + } + throw err; + } +}; + +const createFileStorageAdapter = (options: FileStorageOptions) => ({ + adapterType: 'file', + maxConcurrentRequests: 10, + getFile: (filePath: string) => readStorageFile(options.root, filePath), + getJson: async (filePath: string) => + JSON.parse( + (await readStorageFile(options.root, filePath)).toString('utf8') + ), + getUrl: (filePath: string) => `${options.path}/${filePath}`, + listSubDirectories: async (directory: string) => { + const fullPath = storagePath(options.root, directory); + try { + const entries = await readdir(fullPath); + const directories = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(fullPath, entry); + return (await stat(entryPath)).isDirectory() ? entry : undefined; + }) + ); + return directories.filter((entry): entry is string => !!entry); + } catch { + return []; + } + }, + putDir: (source: string, destination: string) => + cp(source, storagePath(options.root, destination), { recursive: true }), + putFile: (source: string, destination: string) => + cp(source, storagePath(options.root, destination)), + putFileContent: async (content: string, destination: string) => { + const fullPath = storagePath(options.root, destination); + await mkdir(path.dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content); + } +}); + +const readJson = async (response: Response, label: string): Promise => { + const text = await response.text(); + assert.notEqual(text, '', `${label}: expected JSON response body`); + return JSON.parse(text) as T; +}; + +const removeTempDir = (directory: string): Promise => + rm(directory, { + force: true, + maxRetries: 5, + recursive: true, + retryDelay: 100 + }); + +const createPublishArchive = async (): Promise<{ + archivePath: string; + tempDir: string; +}> => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'oc-fastify-publish-')); + const sourceDir = path.join(tempDir, 'source'); + const packageDir = path.join(sourceDir, '_package'); + const archivePath = path.join(tempDir, 'published-fastify-1.0.0.tar.gz'); + + await mkdir(packageDir, { recursive: true }); + await writeFile( + path.join(packageDir, 'package.json'), + JSON.stringify({ + name: 'published-fastify', + version: '1.0.0', + oc: { files: { template: {} } } + }) + ); + await writeFile( + path.join(packageDir, 'template.js'), + 'module.exports = "ok";' + ); + await compress({ src: sourceDir, dest: archivePath }); + + return { archivePath, tempDir }; +}; const getPort = (): Promise => new Promise((resolve, reject) => { @@ -83,7 +209,10 @@ async function testRegistryComponentResponses(): Promise { try { const address = data.server.address(); const response = await fetch(`${baseUrl}/hello-world/1.0.0`); - const body = (await response.json()) as Record; + const body = await readJson>( + response, + 'component response' + ); assert.equal(typeof data.app, 'object'); assert.ok(data.app && typeof data.app === 'object' && 'server' in data.app); @@ -143,6 +272,43 @@ async function testRegistryOptions(): Promise { } } +async function testRegistryStaticStream(): Promise { + const { baseUrl, registry } = await startRegistry(); + + try { + const response = await fetch( + `${baseUrl}/handlebars3-component/1.0.0/static/template.js` + ); + const body = await response.text(); + + assert.equal(response.status, 200); + assert.match( + response.headers.get('content-type') || '', + /^application\/javascript/ + ); + assert.match(body, /Hello world!/); + } finally { + await closeRegistry(registry); + } +} + +async function testRegistryBatchPost(): Promise { + const { baseUrl, registry } = await startRegistry(); + + try { + const response = await fetch(`${baseUrl}/`, { + body: JSON.stringify({ components: [] }), + headers: { 'content-type': 'application/json' }, + method: 'POST' + }); + + assert.equal(response.status, 200); + assert.deepEqual(await readJson(response, 'batch response'), []); + } finally { + await closeRegistry(registry); + } +} + async function testRegistryUserRouteUrlencodedBody(): Promise { const { baseUrl, registry } = await startRegistry({ prefix: '/components/', @@ -168,7 +334,7 @@ async function testRegistryUserRouteUrlencodedBody(): Promise { }); assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { + assert.deepEqual(await readJson(response, 'urlencoded response'), { items: ['one', 'two'], user: { name: 'Ada' } }); @@ -177,11 +343,102 @@ async function testRegistryUserRouteUrlencodedBody(): Promise { } } +async function testRegistryNonLocalPublishWithAuth(): Promise { + const port = await getPort(); + const storageRoot = await mkdtemp(path.join(tmpdir(), 'oc-fastify-storage-')); + const { archivePath, tempDir } = await createPublishArchive(); + const registry = Registry({ + baseUrl: `http://localhost:${port}/`, + dependencies: [], + env: { name: 'test' }, + local: false, + path: fixturePath, + port, + publishAuth: { type: 'basic', username: 'alice', password: 'secret' }, + server: { + adapter: createFastifyAdapter, + options: { host: '127.0.0.1', port } + }, + storage: { + adapter: createFileStorageAdapter, + options: { + componentsDir: 'components', + path: `http://localhost:${port}/storage`, + root: storageRoot + } + }, + verbosity: 0 + } as never); + + const createForm = async () => { + const form = new FormData(); + form.set( + 'package', + new Blob([new Uint8Array(await readFile(archivePath))], { + type: 'application/gzip' + }), + 'published-fastify-1.0.0.tar.gz' + ); + return form; + }; + + try { + await new Promise((resolve, reject) => { + registry.start((err) => (err ? reject(err) : resolve())); + }); + + const unauthorized = await fetch( + `http://localhost:${port}/published-fastify/1.0.0`, + { + body: await createForm(), + method: 'PUT' + } + ); + assert.equal(unauthorized.status, 401); + + const authorized = await fetch( + `http://localhost:${port}/published-fastify/1.0.0`, + { + body: await createForm(), + headers: { + authorization: `Basic ${Buffer.from('alice:secret').toString('base64')}`, + 'user-agent': `oc-cli-0.50.56/v${process.versions.node}-test` + }, + method: 'PUT' + } + ); + const body = await readJson>( + authorized, + 'publish response' + ); + const publishedPackage = JSON.parse( + await readFile( + path.join( + storageRoot, + 'components/published-fastify/1.0.0/package.json' + ), + 'utf8' + ) + ) as { oc?: { publisher?: string } }; + + assert.equal(authorized.status, 200); + assert.deepEqual(body, { ok: true }); + assert.equal(publishedPackage.oc?.publisher, 'alice'); + } finally { + await closeRegistry(registry).catch(() => undefined); + await removeTempDir(storageRoot); + await removeTempDir(tempDir); + } +} + async function main(): Promise { await testRegistryComponentResponses(); await testRegistryEtags(); await testRegistryOptions(); + await testRegistryStaticStream(); + await testRegistryBatchPost(); await testRegistryUserRouteUrlencodedBody(); + await testRegistryNonLocalPublishWithAuth(); } void main().catch((err) => { diff --git a/packages/oc-fastify-server-adapter/tsconfig.json b/packages/oc-fastify-server-adapter/tsconfig.json index 50f92987b..2ebc04d72 100644 --- a/packages/oc-fastify-server-adapter/tsconfig.json +++ b/packages/oc-fastify-server-adapter/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["src", "test"], + "include": ["src"], "exclude": ["node_modules"], "compilerOptions": { "target": "ES2022", @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "types": ["node", "jest"], "declaration": true, - "rootDir": ".", + "rootDir": "./src", "outDir": "./dist", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, diff --git a/packages/oc-fastify-server-adapter/tsconfig.test.json b/packages/oc-fastify-server-adapter/tsconfig.test.json new file mode 100644 index 000000000..a2bd6818b --- /dev/null +++ b/packages/oc-fastify-server-adapter/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["test/registry-node.ts"], + "compilerOptions": { + "rootDir": "./test", + "outDir": "./dist/test", + "noEmit": false + } +} diff --git a/packages/oc/src/index.ts b/packages/oc/src/index.ts index b8d0d94a0..8674568c4 100644 --- a/packages/oc/src/index.ts +++ b/packages/oc/src/index.ts @@ -3,6 +3,7 @@ export { default as cli } from './cli/programmatic-api'; export { default as Registry, RegistryOptions } from './registry'; export type { + CookieOptions, ExpressMiddleware, HttpServerAdapter, Method, diff --git a/packages/oc/src/registry/domain/extract-package.ts b/packages/oc/src/registry/domain/extract-package.ts index 2577cabb3..2818e3885 100644 --- a/packages/oc/src/registry/domain/extract-package.ts +++ b/packages/oc/src/registry/domain/extract-package.ts @@ -4,12 +4,13 @@ import targz from 'targz'; import type { Component } from '../../types'; import getPackageJsonFromTempDir from './get-package-json-from-temp-dir'; +import type { UploadedFile } from './http-server/types'; export default async function extractPackage( files: - | Express.Multer.File[] + | UploadedFile[] | { - [fieldname: string]: Express.Multer.File[]; + [fieldname: string]: UploadedFile[]; }, tarExtractMode: number ): Promise<{ @@ -18,7 +19,7 @@ export default async function extractPackage( packagePath: string; packageUntarOutput: string; }> { - const packageFile = (files as Express.Multer.File[])[0]; + const packageFile = (files as UploadedFile[])[0]; const packagePath = path.resolve(packageFile.path); const packageUntarOutput = path.resolve( packageFile.path, diff --git a/packages/oc/src/registry/domain/http-server/types.ts b/packages/oc/src/registry/domain/http-server/types.ts index b0ebdf8d3..d1ced3739 100644 --- a/packages/oc/src/registry/domain/http-server/types.ts +++ b/packages/oc/src/registry/domain/http-server/types.ts @@ -1,13 +1,36 @@ import type http from 'node:http'; import type { IncomingHttpHeaders } from 'node:http'; -import type { CookieOptions as ExpressCookieOptions } from 'express'; import type { Config } from '../../../types'; -export type CookieOptions = ExpressCookieOptions; +export interface CookieOptions { + domain?: string; + encode?: (value: string) => string; + expires?: Date; + httpOnly?: boolean; + maxAge?: number; + partitioned?: boolean; + path?: string; + priority?: 'low' | 'medium' | 'high'; + sameSite?: boolean | 'lax' | 'strict' | 'none'; + secure?: boolean; + signed?: boolean; +} export type Method = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete'; -export type UploadedFile = Express.Multer.File; +export interface UploadedFile { + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; + size: number; + destination: string; + filename: string; + path: string; + buffer?: Buffer; + stream?: NodeJS.ReadableStream; + truncated?: boolean; +} export interface OcRequest { method: string; diff --git a/packages/oc/src/registry/domain/validators/uploaded-package.ts b/packages/oc/src/registry/domain/validators/uploaded-package.ts index 6ebca5ebc..032ee8e99 100644 --- a/packages/oc/src/registry/domain/validators/uploaded-package.ts +++ b/packages/oc/src/registry/domain/validators/uploaded-package.ts @@ -1,10 +1,12 @@ +import type { UploadedFile } from '../http-server/types'; + type ValidationResponse = | { isValid: true; files: - | Express.Multer.File[] + | UploadedFile[] | { - [fieldname: string]: Express.Multer.File[]; + [fieldname: string]: UploadedFile[]; }; } | { @@ -14,9 +16,9 @@ type ValidationResponse = export default function uploadedPackage( input?: - | Express.Multer.File[] + | UploadedFile[] | { - [fieldname: string]: Express.Multer.File[]; + [fieldname: string]: UploadedFile[]; } ): ValidationResponse { const returnError = (message?: string): ValidationResponse => ({ @@ -32,7 +34,7 @@ export default function uploadedPackage( return returnError('not_valid'); } - const file: Express.Multer.File = (input as Express.Multer.File[])[0]; + const file: UploadedFile = (input as UploadedFile[])[0]; const validTypes = ['application/gzip', 'application/octet-stream']; if ( diff --git a/packages/oc/src/registry/middleware/index.ts b/packages/oc/src/registry/middleware/index.ts index 2ef2bfe7f..d6dbe7cdc 100644 --- a/packages/oc/src/registry/middleware/index.ts +++ b/packages/oc/src/registry/middleware/index.ts @@ -57,7 +57,7 @@ export const bind = ( skip: (req, res) => { // Hide logging development console calls return req.url.startsWith( - `${res.conf.prefix}~actions/$$__oc__server___console__$$` + `${res.conf?.prefix ?? ''}~actions/$$__oc__server___console__$$` ); } }); From acd3d4aab53bc6d13e73de7007a5b10c0a9805a9 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Tue, 7 Jul 2026 18:35:36 +0200 Subject: [PATCH 03/10] Fix Fastify adapter build without oc dist Keep compile-time HTTP server adapter types self-contained so the package can build before oc emits dist/index.d.ts in workspace CI. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../oc-fastify-server-adapter/src/index.ts | 117 ++++++++++++++++-- 1 file changed, 107 insertions(+), 10 deletions(-) diff --git a/packages/oc-fastify-server-adapter/src/index.ts b/packages/oc-fastify-server-adapter/src/index.ts index cb736767e..3c9f63fab 100644 --- a/packages/oc-fastify-server-adapter/src/index.ts +++ b/packages/oc-fastify-server-adapter/src/index.ts @@ -15,18 +15,115 @@ import fastify, { type FastifyRequest, type HTTPMethods } from 'fastify'; -import type { - CookieOptions, - ExpressMiddleware, - HttpServerAdapter, - Method, - OcHandler, - OcRequest, - OcResponse, - UploadedFile -} from 'oc'; import { parse as parseQs } from 'qs'; +type CookieOptions = { + domain?: string; + encode?: (value: string) => string; + expires?: Date; + httpOnly?: boolean; + maxAge?: number; + partitioned?: boolean; + path?: string; + priority?: 'low' | 'medium' | 'high'; + sameSite?: boolean | 'lax' | 'strict' | 'none'; + secure?: boolean; + signed?: boolean; +}; + +type Method = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete'; + +type UploadedFile = { + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; + size: number; + destination: string; + filename: string; + path: string; + buffer?: Buffer; + stream?: NodeJS.ReadableStream; + truncated?: boolean; +}; + +type OcRequest = { + method: string; + url: string; + path: string; + originalUrl: string; + protocol: string; + secure: boolean; + ip: string; + headers: http.IncomingHttpHeaders; + params: Record; + query: Record; + body: unknown; + cookies: Record; + files?: UploadedFile[] | Record; + user?: string; + routeId: string; + get(header: string): string | undefined; + raw: http.IncomingMessage; +}; + +type OcResponse = { + conf: any; + errorCode?: string; + errorDetails?: string; + statusCode: number; + status(code: number): OcResponse; + json(body: unknown): void; + send(body: unknown): void; + set(field: string | Record, value?: string): OcResponse; + header(name: string, value: string): OcResponse; + setHeader(name: string, value: string): OcResponse; + removeHeader(name: string): OcResponse; + type(mime: string): OcResponse; + cookie(name: string, value: string, opts?: CookieOptions): OcResponse; + redirect(url: string): void; + end(chunk?: unknown): void; + stream(readable: NodeJS.ReadableStream): void; + raw: http.ServerResponse; +}; + +type OcHandler = (req: OcRequest, res: OcResponse) => void | Promise; + +type ExpressMiddleware = ( + req: any, + res: any, + next: (err?: unknown) => void +) => void; + +type HttpServerAdapter = { + name: string; + enableBodyParser(opts: { limit?: number | string }): void; + enableCookies(): void; + enableFileUploads(opts: { + tempDir: string; + filename: (originalName: string) => string; + }): void; + enableRequestTiming( + onDone: (req: OcRequest, res: OcResponse, ms: number) => void + ): void; + enableLogging(opts: { + skip: (req: OcRequest, res: OcResponse) => boolean; + }): void; + enableErrorHandler(): void; + use(handler: OcHandler): void; + route(method: Method, path: string, id: string, handlers: OcHandler[]): void; + fromConnect(handler: ExpressMiddleware): OcHandler; + listen( + opts: { port: number | string; timeout: number; keepAliveTimeout?: number }, + cb: (err?: Error) => void + ): void; + onServerError(cb: (err: Error) => void): void; + close(cb: (err?: Error) => void): void; + isListening(): boolean; + native(): unknown; + httpServer(): http.Server; +}; + export interface FastifyServerAdapterOptions { host?: string; port?: number | string; From 6a70d65e84916ef0f34558f121d17dc2634320df Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Tue, 7 Jul 2026 18:45:45 +0200 Subject: [PATCH 04/10] Remove redundant oc dev dependency from Fastify adapter The adapter now uses self-contained structural types and does not need oc for package builds. Keep oc only as a peer dependency to document the supported host package range. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- package-lock.json | 1 - packages/oc-fastify-server-adapter/package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0927dfd80..7e9379e12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18907,7 +18907,6 @@ "@types/node": "^25.9.3", "@types/qs": "^6.15.1", "jest": "^29.7.0", - "oc": "*", "rimraf": "^6.1.3", "ts-jest": "^29.2.5", "typescript": "^6.0.3" diff --git a/packages/oc-fastify-server-adapter/package.json b/packages/oc-fastify-server-adapter/package.json index ca2850ccc..2acf5673c 100644 --- a/packages/oc-fastify-server-adapter/package.json +++ b/packages/oc-fastify-server-adapter/package.json @@ -56,7 +56,6 @@ "@types/node": "^25.9.3", "@types/qs": "^6.15.1", "jest": "^29.7.0", - "oc": "*", "rimraf": "^6.1.3", "ts-jest": "^29.2.5", "typescript": "^6.0.3" From 2464ac242208fb5ffcc86d1a492c661e69563102 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Tue, 7 Jul 2026 18:47:37 +0200 Subject: [PATCH 05/10] Remove oc peer dependency from Fastify adapter The adapter has no compile-time or runtime dependency on oc and is consumed structurally by the host package. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- package-lock.json | 3 --- packages/oc-fastify-server-adapter/package.json | 3 --- 2 files changed, 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7e9379e12..e8e8acf98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18910,9 +18910,6 @@ "rimraf": "^6.1.3", "ts-jest": "^29.2.5", "typescript": "^6.0.3" - }, - "peerDependencies": { - "oc": ">=0.50.56" } }, "packages/oc-fastify-server-adapter/node_modules/typescript": { diff --git a/packages/oc-fastify-server-adapter/package.json b/packages/oc-fastify-server-adapter/package.json index 2acf5673c..311e48dfa 100644 --- a/packages/oc-fastify-server-adapter/package.json +++ b/packages/oc-fastify-server-adapter/package.json @@ -47,9 +47,6 @@ "fastify": "^5.10.0", "qs": "^6.15.3" }, - "peerDependencies": { - "oc": ">=0.50.56" - }, "devDependencies": { "@biomejs/biome": "^2.5.0", "@types/jest": "^29.5.14", From ddd97d1c08f90c4aaee3957876a45ed46584b265 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Tue, 7 Jul 2026 18:50:04 +0200 Subject: [PATCH 06/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/oc-fastify-server-adapter/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/oc-fastify-server-adapter/src/index.ts b/packages/oc-fastify-server-adapter/src/index.ts index 3c9f63fab..b174128ec 100644 --- a/packages/oc-fastify-server-adapter/src/index.ts +++ b/packages/oc-fastify-server-adapter/src/index.ts @@ -685,7 +685,7 @@ function toFastifyCookieOptions(opts?: CookieOptions): CookieOptions { throw new Error('Signed cookies require a Fastify cookie secret'); } - const next: CookieOptions = { path: '/', ...opts }; +const next: CookieOptions = { ...(opts ?? {}), path: opts?.path ?? '/' }; if (typeof opts?.maxAge === 'number') { next.maxAge = Math.floor(opts.maxAge / 1000); if (!opts.expires) { From 841370c038f8755d5574bd45f4682689ede74238 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Tue, 7 Jul 2026 20:06:15 +0200 Subject: [PATCH 07/10] apply lint --- packages/oc-fastify-server-adapter/package.json | 1 + packages/oc-fastify-server-adapter/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/oc-fastify-server-adapter/package.json b/packages/oc-fastify-server-adapter/package.json index 311e48dfa..052621513 100644 --- a/packages/oc-fastify-server-adapter/package.json +++ b/packages/oc-fastify-server-adapter/package.json @@ -30,6 +30,7 @@ "prebuild": "rimraf dist", "build": "npm run lint && tsc", "lint": "npx @biomejs/biome check src test", + "lint:apply": "npx @biomejs/biome check --write src test", "test": "jest --runInBand && npm run test:integration", "test-silent": "jest --runInBand --silent && npm run test:integration", "test:integration": "tsc --project tsconfig.test.json && node dist/test/registry-node.js" diff --git a/packages/oc-fastify-server-adapter/src/index.ts b/packages/oc-fastify-server-adapter/src/index.ts index b174128ec..3e72a4143 100644 --- a/packages/oc-fastify-server-adapter/src/index.ts +++ b/packages/oc-fastify-server-adapter/src/index.ts @@ -685,7 +685,7 @@ function toFastifyCookieOptions(opts?: CookieOptions): CookieOptions { throw new Error('Signed cookies require a Fastify cookie secret'); } -const next: CookieOptions = { ...(opts ?? {}), path: opts?.path ?? '/' }; + const next: CookieOptions = { ...(opts ?? {}), path: opts?.path ?? '/' }; if (typeof opts?.maxAge === 'number') { next.maxAge = Math.floor(opts.maxAge / 1000); if (!opts.expires) { From 92a705178241be56753bdabb189f667664e29df3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:27:34 +0000 Subject: [PATCH 08/10] Fix CI: export HttpServerAdapter from adapter and import from local source in tests --- packages/oc-fastify-server-adapter/src/index.ts | 2 +- packages/oc-fastify-server-adapter/test/index.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/oc-fastify-server-adapter/src/index.ts b/packages/oc-fastify-server-adapter/src/index.ts index 3e72a4143..556ff72e0 100644 --- a/packages/oc-fastify-server-adapter/src/index.ts +++ b/packages/oc-fastify-server-adapter/src/index.ts @@ -95,7 +95,7 @@ type ExpressMiddleware = ( next: (err?: unknown) => void ) => void; -type HttpServerAdapter = { +export type HttpServerAdapter = { name: string; enableBodyParser(opts: { limit?: number | string }): void; enableCookies(): void; diff --git a/packages/oc-fastify-server-adapter/test/index.test.ts b/packages/oc-fastify-server-adapter/test/index.test.ts index 3f3f36178..1f28a2d38 100644 --- a/packages/oc-fastify-server-adapter/test/index.test.ts +++ b/packages/oc-fastify-server-adapter/test/index.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { Readable } from 'node:stream'; import { gzipSync } from 'node:zlib'; import type { FastifyInstance } from 'fastify'; -import type { HttpServerAdapter } from 'oc'; +import type { HttpServerAdapter } from '../src'; import createFastifyAdapter from '../src'; const asFastify = (adapter: HttpServerAdapter): FastifyInstance => From b275afc55ea19fbb3e0912e7c0d547fc7ab02082 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Wed, 8 Jul 2026 08:36:02 +0200 Subject: [PATCH 09/10] fix build --- turbo.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/turbo.json b/turbo.json index 3e2ddbd2d..380d50e0c 100644 --- a/turbo.json +++ b/turbo.json @@ -24,6 +24,20 @@ ], "outputs": [] }, + "oc-fastify-server-adapter#test": { + "dependsOn": [ + "build", + "oc#build" + ], + "outputs": [] + }, + "oc-fastify-server-adapter#test-silent": { + "dependsOn": [ + "build", + "oc#build" + ], + "outputs": [] + }, "lint": { "outputs": [] }, From d778c1b274376366b5edd75e693a66bd3aba9844 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Wed, 8 Jul 2026 08:57:09 +0200 Subject: [PATCH 10/10] fix --- .github/workflows/node.js.yml | 3 +++ packages/oc/package.json | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 32953eb78..a74697616 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -20,6 +20,9 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: git show-ref --verify --quiet refs/heads/master || git branch --track master origin/master - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: diff --git a/packages/oc/package.json b/packages/oc/package.json index e41c81582..eae83af37 100644 --- a/packages/oc/package.json +++ b/packages/oc/package.json @@ -11,8 +11,8 @@ "preview": "bun run preview/uiPreview.ts", "prebuild": "rimraf dist", "build": "npm run lint && tsc && node tasks/build.js", - "test": "npm run build && node tasks/mochaTest.js", - "test-silent": "npm run build && node tasks/mochaTest.js --silent", + "test": "node tasks/mochaTest.js", + "test-silent": "node tasks/mochaTest.js --silent", "bench": "node tasks/benchmarks/server-benchmark.js --repetitions=5", "bench:quick": "node tasks/benchmarks/server-benchmark.js --repetitions=3", "bench:update-baseline": "node tasks/benchmarks/server-benchmark.js --repetitions=5 --update-baseline=true",