diff --git a/CHANGES.md b/CHANGES.md index 089e42898..25f3462b3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,6 +18,51 @@ To be released. [#1274]: https://github.com/fedify-dev/fedify/issues/1274 [#1276]: https://github.com/fedify-dev/fedify/pull/1276 +### @fedify/elysia + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +[#1277]: https://github.com/fedify-dev/fedify/issues/1277 +[#1280]: https://github.com/fedify-dev/fedify/pull/1280 + +### @fedify/express + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/hono + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. Matched application routes and downstream wildcard + middleware retain their 404 responses, even when they delegate to Hono's + default not-found handler. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/koa + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + +### @fedify/nestjs + + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] + Version 2.0.32 -------------- diff --git a/changes.d/elysia/not-acceptable-fallback.md b/changes.d/elysia/not-acceptable-fallback.md new file mode 100644 index 000000000..90dd8e67c --- /dev/null +++ b/changes.d/elysia/not-acceptable-fallback.md @@ -0,0 +1,10 @@ +--- +links: + '#1277': https://github.com/fedify-dev/fedify/issues/1277 + '#1280': https://github.com/fedify-dev/fedify/pull/1280 +--- + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] diff --git a/changes.d/express/not-acceptable-fallback.md b/changes.d/express/not-acceptable-fallback.md new file mode 100644 index 000000000..90dd8e67c --- /dev/null +++ b/changes.d/express/not-acceptable-fallback.md @@ -0,0 +1,10 @@ +--- +links: + '#1277': https://github.com/fedify-dev/fedify/issues/1277 + '#1280': https://github.com/fedify-dev/fedify/pull/1280 +--- + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] diff --git a/changes.d/hono/not-acceptable-fallback.md b/changes.d/hono/not-acceptable-fallback.md new file mode 100644 index 000000000..168f58bb1 --- /dev/null +++ b/changes.d/hono/not-acceptable-fallback.md @@ -0,0 +1,12 @@ +--- +links: + '#1277': https://github.com/fedify-dev/fedify/issues/1277 + '#1280': https://github.com/fedify-dev/fedify/pull/1280 +--- + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. Matched application routes and downstream wildcard + middleware retain their 404 responses, even when they delegate to Hono's + default not-found handler. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] diff --git a/changes.d/koa/not-acceptable-fallback.md b/changes.d/koa/not-acceptable-fallback.md new file mode 100644 index 000000000..90dd8e67c --- /dev/null +++ b/changes.d/koa/not-acceptable-fallback.md @@ -0,0 +1,10 @@ +--- +links: + '#1277': https://github.com/fedify-dev/fedify/issues/1277 + '#1280': https://github.com/fedify-dev/fedify/pull/1280 +--- + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] diff --git a/changes.d/nestjs/not-acceptable-fallback.md b/changes.d/nestjs/not-acceptable-fallback.md new file mode 100644 index 000000000..90dd8e67c --- /dev/null +++ b/changes.d/nestjs/not-acceptable-fallback.md @@ -0,0 +1,10 @@ +--- +links: + '#1277': https://github.com/fedify-dev/fedify/issues/1277 + '#1280': https://github.com/fedify-dev/fedify/pull/1280 +--- + - Fixed federation endpoints returning `404 Not Found` instead of + `406 Not Acceptable` when the application cannot handle a request with an + unsupported `Accept` header. Application responses, including explicit + 404s, are preserved. The fallback includes `Vary: Accept`. + [[#1277], [#1280]] diff --git a/deno.json b/deno.json index 6f9d94247..273143e54 100644 --- a/deno.json +++ b/deno.json @@ -36,6 +36,8 @@ "@logtape/file": "jsr:@logtape/file@^2.0.0", "@logtape/logtape": "jsr:@logtape/logtape@^2.0.0", "@nestjs/common": "npm:@nestjs/common@^11.0.1", + "@nestjs/core": "npm:@nestjs/core@11.1.4", + "@nestjs/platform-express": "npm:@nestjs/platform-express@11.1.4", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", "@opentelemetry/context-async-hooks": "npm:@opentelemetry/context-async-hooks@^2.5.0", "@opentelemetry/core": "npm:@opentelemetry/core@^2.5.0", @@ -62,6 +64,7 @@ "json-preserve-indent": "npm:json-preserve-indent@^1.1.3", "postgres": "npm:postgres@^3.4.7", "preact": "npm:preact@10.19.6", + "reflect-metadata": "npm:reflect-metadata@^0.2.2", "tsdown": "npm:tsdown@^0.21.6" }, "unstable": [ @@ -114,16 +117,20 @@ ] }, "pnpm:build-vocab-runtime": { - "command": "pnpm -C packages/vocab-runtime build" + "command": "pnpm -C packages/vocab-runtime build", + "dependencies": ["install"] }, "pnpm:build-vocab-tools": { - "command": "pnpm -C packages/vocab-tools build" + "command": "pnpm -C packages/vocab-tools build", + "dependencies": ["install"] }, "pnpm:build-fixture": { - "command": "pnpm -C packages/fixture build" + "command": "pnpm -C packages/fixture build", + "dependencies": ["install"] }, "pnpm:build-webfinger": { - "command": "pnpm -C packages/webfinger build" + "command": "pnpm -C packages/webfinger build", + "dependencies": ["install"] }, "pnpm:build-vocab": { "command": "pnpm -C packages/vocab build", @@ -170,7 +177,9 @@ "./packages" ], "exclude": [ - "./packages/init/src/templates/**" + "./packages/init/src/templates/**", + "packages/elysia/src/**/*.test.ts", + "packages/nestjs/src/**/*.test.ts" ] } } diff --git a/deno.lock b/deno.lock index 9b282795c..cf32d7168 100644 --- a/deno.lock +++ b/deno.lock @@ -89,6 +89,8 @@ "npm:@mjackson/node-fetch-server@0.7": "0.7.0", "npm:@multiformats/base-x@^4.0.1": "4.0.1", "npm:@nestjs/common@^11.0.1": "11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2", + "npm:@nestjs/core@11.1.4": "11.1.4_@nestjs+common@11.1.17__reflect-metadata@0.2.2__rxjs@7.8.2_@nestjs+platform-express@11.1.4_reflect-metadata@0.2.2_rxjs@7.8.2", + "npm:@nestjs/platform-express@11.1.4": "11.1.4_@nestjs+common@11.1.17__reflect-metadata@0.2.2__rxjs@7.8.2_@nestjs+core@11.1.4_reflect-metadata@0.2.2_rxjs@7.8.2", "npm:@opentelemetry/api@^1.9.0": "1.9.1", "npm:@opentelemetry/context-async-hooks@^2.5.0": "2.6.1_@opentelemetry+api@1.9.1", "npm:@opentelemetry/core@^2.5.0": "2.6.1_@opentelemetry+api@1.9.1", @@ -154,6 +156,7 @@ "npm:preact@^10.27.2": "10.29.7_preact-render-to-string@6.6.6", "npm:preact@^10.28.2": "10.29.7_preact-render-to-string@6.6.6", "npm:preact@^10.29.1": "10.29.7_preact-render-to-string@6.6.6", + "npm:reflect-metadata@~0.2.2": "0.2.2", "npm:rollup@^4.50.0": "4.60.0", "npm:semver@^7.7.2": "7.7.4", "npm:shiki@^1.6.4": "1.29.2", @@ -2163,9 +2166,47 @@ "uid" ] }, + "@nestjs/core@11.1.4_@nestjs+common@11.1.17__reflect-metadata@0.2.2__rxjs@7.8.2_@nestjs+platform-express@11.1.4_reflect-metadata@0.2.2_rxjs@7.8.2": { + "integrity": "sha512-pTLjlfLCSgEvCm8iOONLtHN9uw4XyMEI4yec6+jdw6/7QuOHoqEPAVvdUlNwW1OfFjrAdZmZlpf6Uw8pWBfQlQ==", + "dependencies": [ + "@nestjs/common", + "@nestjs/platform-express", + "@nuxt/opencollective", + "fast-safe-stringify", + "iterare", + "path-to-regexp@8.2.0", + "reflect-metadata", + "rxjs", + "tslib", + "uid" + ], + "optionalPeers": [ + "@nestjs/platform-express" + ], + "scripts": true + }, + "@nestjs/platform-express@11.1.4_@nestjs+common@11.1.17__reflect-metadata@0.2.2__rxjs@7.8.2_@nestjs+core@11.1.4_reflect-metadata@0.2.2_rxjs@7.8.2": { + "integrity": "sha512-SDpfSDJogCZ3fOeP518+GdjKnpJc51LN0dylapq3xBfxJeiEOqmqiZM1tWMOsoijPlUVsz0acpmaVEaOiMXpXw==", + "dependencies": [ + "@nestjs/common", + "@nestjs/core", + "cors", + "express@5.1.0", + "multer", + "path-to-regexp@8.2.0", + "tslib" + ] + }, "@noble/hashes@1.4.0": { "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==" }, + "@nuxt/opencollective@0.4.1": { + "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", + "dependencies": [ + "consola" + ], + "bin": true + }, "@opentelemetry/api@1.9.1": { "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==" }, @@ -2833,8 +2874,15 @@ "accepts@1.3.8": { "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "dependencies": [ - "mime-types", - "negotiator" + "mime-types@2.1.35", + "negotiator@0.6.3" + ] + }, + "accepts@2.0.0": { + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": [ + "mime-types@3.0.2", + "negotiator@1.1.0" ] }, "acorn-jsx@5.3.2_acorn@8.16.0": { @@ -2918,6 +2966,9 @@ "any-promise@1.3.0": { "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" }, + "append-field@1.0.0": { + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, "argparse@2.0.1": { "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, @@ -2994,19 +3045,33 @@ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "dependencies": [ "bytes", - "content-type", + "content-type@1.0.5", "debug@2.6.9", "depd@2.0.0", "destroy", "http-errors@2.0.1", "iconv-lite@0.4.24", "on-finished", - "qs", - "raw-body", - "type-is", + "qs@6.14.2", + "raw-body@2.5.3", + "type-is@1.6.18", "unpipe" ] }, + "body-parser@2.3.0": { + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dependencies": [ + "bytes", + "content-type@2.1.0", + "debug@4.4.3", + "http-errors@2.0.1", + "iconv-lite@0.7.2", + "on-finished", + "qs@6.16.0", + "raw-body@3.0.2", + "type-is@2.1.0" + ] + }, "brace-expansion@1.1.13": { "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dependencies": [ @@ -3031,6 +3096,9 @@ ], "bin": true }, + "buffer-from@1.1.2": { + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, "buffer-more-ints@1.0.0": { "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==" }, @@ -3048,6 +3116,12 @@ "ieee754" ] }, + "busboy@1.6.0": { + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": [ + "streamsearch" + ] + }, "byte-encodings@1.0.11": { "integrity": "sha512-+/xR2+ySc2yKGtud3DGkGSH1DNwHfRVK0KTnMhoeH36/KwG+tHQ4d9B3jxJFq7dW27YcfudkywaYJRPA2dmxzg==" }, @@ -3066,7 +3140,7 @@ "cache-content-type@1.0.1": { "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", "dependencies": [ - "mime-types", + "mime-types@2.1.35", "ylru" ] }, @@ -3211,15 +3285,33 @@ "concat-map@0.0.1": { "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "concat-stream@2.0.0": { + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dependencies": [ + "buffer-from", + "inherits", + "readable-stream@3.6.2", + "typedarray" + ] + }, + "consola@3.4.2": { + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==" + }, "content-disposition@0.5.4": { "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "dependencies": [ "safe-buffer" ] }, + "content-disposition@1.1.0": { + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==" + }, "content-type@1.0.5": { "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" }, + "content-type@2.1.0": { + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==" + }, "convert-source-map@2.0.0": { "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, @@ -3229,6 +3321,9 @@ "cookie-signature@1.0.7": { "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" }, + "cookie-signature@1.2.2": { + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" + }, "cookie@0.6.0": { "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" }, @@ -3245,6 +3340,13 @@ "keygrip" ] }, + "cors@2.8.5": { + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": [ + "object-assign", + "vary" + ] + }, "cross-spawn@7.0.6": { "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": [ @@ -3664,39 +3766,71 @@ "express@4.22.1": { "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "dependencies": [ - "accepts", + "accepts@1.3.8", "array-flatten", - "body-parser", - "content-disposition", - "content-type", + "body-parser@1.20.4", + "content-disposition@0.5.4", + "content-type@1.0.5", "cookie@0.7.2", - "cookie-signature", + "cookie-signature@1.0.7", "debug@2.6.9", "depd@2.0.0", "encodeurl@2.0.0", "escape-html", "etag", - "finalhandler", - "fresh", + "finalhandler@1.3.2", + "fresh@0.5.2", "http-errors@2.0.1", - "merge-descriptors", + "merge-descriptors@1.0.3", "methods", "on-finished", "parseurl", "path-to-regexp@0.1.13", "proxy-addr", - "qs", + "qs@6.14.2", "range-parser", "safe-buffer", - "send", - "serve-static", + "send@0.19.2", + "serve-static@1.16.3", "setprototypeof", "statuses@2.0.2", - "type-is", + "type-is@1.6.18", "utils-merge", "vary" ] }, + "express@5.1.0": { + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dependencies": [ + "accepts@2.0.0", + "body-parser@2.3.0", + "content-disposition@1.1.0", + "content-type@1.0.5", + "cookie@0.7.2", + "cookie-signature@1.2.2", + "debug@4.4.3", + "encodeurl@2.0.0", + "escape-html", + "etag", + "finalhandler@2.1.1", + "fresh@2.0.0", + "http-errors@2.0.1", + "merge-descriptors@2.0.0", + "mime-types@3.0.2", + "on-finished", + "once", + "parseurl", + "proxy-addr", + "qs@6.16.0", + "range-parser", + "router", + "send@1.2.1", + "serve-static@2.2.1", + "statuses@2.0.2", + "type-is@2.1.0", + "vary" + ] + }, "exsolve@1.0.8": { "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==" }, @@ -3735,6 +3869,9 @@ "fast-decode-uri-component" ] }, + "fast-safe-stringify@2.1.1": { + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "fast-uri@3.1.0": { "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==" }, @@ -3829,6 +3966,17 @@ "unpipe" ] }, + "finalhandler@2.1.1": { + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dependencies": [ + "debug@4.4.3", + "encodeurl@2.0.0", + "escape-html", + "on-finished", + "parseurl", + "statuses@2.0.2" + ] + }, "find-my-way@9.5.0": { "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", "dependencies": [ @@ -3860,6 +4008,9 @@ "fresh@0.5.2": { "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" }, + "fresh@2.0.0": { + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, "fsevents@2.3.3": { "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "os": ["darwin"], @@ -4154,6 +4305,9 @@ "is-interactive@2.0.0": { "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==" }, + "is-promise@4.0.0": { + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, "is-reference@3.0.3": { "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "dependencies": [ @@ -4314,10 +4468,10 @@ "koa@2.16.4": { "integrity": "sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==", "dependencies": [ - "accepts", + "accepts@1.3.8", "cache-content-type", - "content-disposition", - "content-type", + "content-disposition@0.5.4", + "content-type@1.0.5", "cookies", "debug@4.4.3", "delegates", @@ -4325,7 +4479,7 @@ "destroy", "encodeurl@1.0.2", "escape-html", - "fresh", + "fresh@0.5.2", "http-assert", "http-errors@1.8.1", "is-generator-function", @@ -4335,7 +4489,7 @@ "only", "parseurl", "statuses@1.5.0", - "type-is", + "type-is@1.6.18", "vary" ] }, @@ -4429,9 +4583,15 @@ "media-typer@0.3.0": { "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" }, + "media-typer@1.1.1": { + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==" + }, "merge-descriptors@1.0.3": { "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" }, + "merge-descriptors@2.0.0": { + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" + }, "methods@1.1.2": { "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, @@ -4462,10 +4622,19 @@ "mime-db@1.52.0": { "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" }, + "mime-db@1.54.0": { + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, "mime-types@2.1.35": { "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dependencies": [ - "mime-db" + "mime-db@1.52.0" + ] + }, + "mime-types@3.0.2": { + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": [ + "mime-db@1.54.0" ] }, "mime@1.6.0": { @@ -4509,6 +4678,16 @@ "brace-expansion@1.1.13" ] }, + "minimist@1.2.8": { + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + }, + "mkdirp@0.5.6": { + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": [ + "minimist" + ], + "bin": true + }, "mrmime@2.0.1": { "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==" }, @@ -4518,6 +4697,18 @@ "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "multer@2.0.1": { + "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", + "dependencies": [ + "append-field", + "busboy", + "concat-stream", + "mkdirp", + "object-assign", + "type-is@1.6.18", + "xtend" + ] + }, "mute-stream@1.0.0": { "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==" }, @@ -4542,6 +4733,12 @@ "negotiator@0.6.3": { "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, + "negotiator@1.1.0": { + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dependencies": [ + "content-type@2.1.0" + ] + }, "node-fetch@2.7.0": { "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": [ @@ -4578,6 +4775,12 @@ "ee-first" ] }, + "once@1.4.0": { + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": [ + "wrappy" + ] + }, "onetime@7.0.0": { "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dependencies": [ @@ -4681,6 +4884,9 @@ "path-to-regexp@6.3.0": { "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" }, + "path-to-regexp@8.2.0": { + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==" + }, "pathe@2.0.3": { "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" }, @@ -4834,6 +5040,13 @@ "side-channel" ] }, + "qs@6.16.0": { + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dependencies": [ + "es-define-property", + "side-channel" + ] + }, "quansync@1.0.0": { "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==" }, @@ -4858,12 +5071,29 @@ "unpipe" ] }, + "raw-body@3.0.2": { + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": [ + "bytes", + "http-errors@2.0.1", + "iconv-lite@0.7.2", + "unpipe" + ] + }, "rdf-canonize@5.0.0": { "integrity": "sha512-g8OUrgMXAR9ys/ZuJVfBr05sPPoMA7nHIVs8VEvg9QwM5W4GR2qSFEEHjsyHF1eWlBaf8Ev40WNjQFQ+nJTO3w==", "dependencies": [ "setimmediate" ] }, + "readable-stream@3.6.2": { + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": [ + "inherits", + "string_decoder", + "util-deprecate" + ] + }, "readable-stream@4.7.0": { "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "dependencies": [ @@ -4877,7 +5107,7 @@ "readable-web-to-node-stream@3.0.4": { "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", "dependencies": [ - "readable-stream" + "readable-stream@4.7.0" ] }, "real-require@0.2.0": { @@ -5028,6 +5258,16 @@ ], "bin": true }, + "router@2.2.0": { + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": [ + "debug@4.4.3", + "depd@2.0.0", + "is-promise", + "parseurl", + "path-to-regexp@8.2.0" + ] + }, "run-async@4.0.6": { "integrity": "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==" }, @@ -5084,7 +5324,7 @@ "encodeurl@2.0.0", "escape-html", "etag", - "fresh", + "fresh@0.5.2", "http-errors@2.0.1", "mime@1.6.0", "ms@2.1.3", @@ -5093,13 +5333,38 @@ "statuses@2.0.2" ] }, + "send@1.2.1": { + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dependencies": [ + "debug@4.4.3", + "encodeurl@2.0.0", + "escape-html", + "etag", + "fresh@2.0.0", + "http-errors@2.0.1", + "mime-types@3.0.2", + "ms@2.1.3", + "on-finished", + "range-parser", + "statuses@2.0.2" + ] + }, "serve-static@1.16.3": { "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "dependencies": [ "encodeurl@2.0.0", "escape-html", "parseurl", - "send" + "send@0.19.2" + ] + }, + "serve-static@2.2.1": { + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dependencies": [ + "encodeurl@2.0.0", + "escape-html", + "parseurl", + "send@1.2.1" ] }, "set-cookie-parser@2.7.2": { @@ -5166,8 +5431,8 @@ "@types/hast" ] }, - "side-channel-list@1.0.0": { - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "side-channel-list@1.0.1": { + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dependencies": [ "es-errors", "object-inspect" @@ -5192,8 +5457,8 @@ "side-channel-map" ] }, - "side-channel@1.1.0": { - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "side-channel@1.1.1": { + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dependencies": [ "es-errors", "object-inspect", @@ -5268,6 +5533,9 @@ "stoppable@1.1.0": { "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==" }, + "streamsearch@1.1.0": { + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" + }, "string-width@4.2.3": { "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dependencies": [ @@ -5518,10 +5786,21 @@ "type-is@1.6.18": { "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "dependencies": [ - "media-typer", - "mime-types" + "media-typer@0.3.0", + "mime-types@2.1.35" + ] + }, + "type-is@2.1.0": { + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dependencies": [ + "content-type@2.1.0", + "media-typer@1.1.1", + "mime-types@3.0.2" ] }, + "typedarray@0.0.6": { + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "typescript@5.9.3": { "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "bin": true @@ -5647,6 +5926,9 @@ "pako" ] }, + "util-deprecate@1.0.2": { + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "utils-merge@1.0.1": { "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, @@ -5835,6 +6117,9 @@ "strip-ansi@6.0.1" ] }, + "wrappy@1.0.2": { + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, "ws@8.18.0": { "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==" }, @@ -5851,6 +6136,9 @@ "xmlbuilder@11.0.1": { "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" }, + "xtend@4.0.2": { + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, "y18n@5.0.8": { "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" }, @@ -5938,6 +6226,8 @@ "npm:@fxts/core@^1.21.1", "npm:@js-temporal/polyfill@~0.5.1", "npm:@nestjs/common@^11.0.1", + "npm:@nestjs/core@11.1.4", + "npm:@nestjs/platform-express@11.1.4", "npm:@opentelemetry/api@^1.9.0", "npm:@opentelemetry/context-async-hooks@^2.5.0", "npm:@opentelemetry/core@^2.5.0", @@ -5953,6 +6243,7 @@ "npm:json-preserve-indent@^1.1.3", "npm:postgres@^3.4.7", "npm:preact@10.19.6", + "npm:reflect-metadata@~0.2.2", "npm:tsdown@~0.21.6" ], "members": { diff --git a/docs/manual/integration.md b/docs/manual/integration.md index a793d9b10..8b953fe2e 100644 --- a/docs/manual/integration.md +++ b/docs/manual/integration.md @@ -278,6 +278,13 @@ const app = new Hono(); app.use(federation(fedi, (ctx) => "context data")); // [!code highlight] ~~~~ +When Fedify rejects the request's `Accept` header, a matched application route +owns its response, including the default 404 that results when the route calls +`next()`. Downstream wildcard middleware also owns its response, since it can +return the same 404 text as Hono's default not-found handler. The 406 fallback +applies only when no downstream handler matches. Place middleware that should +wrap the 406 response before the Fedify middleware. + [Hono]: https://hono.dev/ diff --git a/packages/elysia/package.json b/packages/elysia/package.json index c3fd33cf6..bb3c26407 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -49,7 +49,9 @@ "build:self": "tsdown", "build": "pnpm --filter @fedify/elysia... run build:self", "prepack": "pnpm build", - "prepublish": "pnpm build" + "prepublish": "pnpm build", + "test": "node --experimental-transform-types --test", + "test:bun": "bun test" }, "peerDependencies": { "elysia": "^1.3.6", @@ -58,6 +60,7 @@ "devDependencies": { "bun-types": "^1.2.19", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "@fedify/fixture": "workspace:*" } } diff --git a/packages/elysia/src/fallback.test.ts b/packages/elysia/src/fallback.test.ts new file mode 100644 index 000000000..ee6f2001b --- /dev/null +++ b/packages/elysia/src/fallback.test.ts @@ -0,0 +1,262 @@ +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { test } from "@fedify/fixture"; +import { strict as assert } from "node:assert"; +import { Elysia, NotFoundError } from "elysia"; +import { fedify } from "./index.ts"; + +for (const aot of [true, false]) { + for ( + const mode of [ + "unhandled", + "async-pass", + "html", + "async-html", + "not-found-html", + "not-found-json", + "not-found-error", + "early-route", + "early-error-hook", + "late-error-hook", + "late-404-hook", + "headers", + "error", + ] + ) { + test(`Elysia preserves application responses or falls back to 406 (aot=${aot}): ${mode}`, async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = new Elysia({ aot }); + let errorHookCalls = 0; + const appErrorHook = () => { + errorHookCalls++; + return new Response("

Custom missing

", { + status: mode === "late-404-hook" ? 404 : 200, + headers: { "Content-Type": "text/html" }, + }); + }; + if (mode === "early-route") { + app.get("/users/:identifier", () => { + throw new NotFoundError("No such user"); + }); + } + if (mode === "early-error-hook") app.onError(appErrorHook); + if (mode === "headers") { + app.onRequest(({ set }) => { + set.headers["Content-Type"] = "text/html"; + set.headers["Content-Length"] = "999"; + set.headers["Content-Encoding"] = "gzip"; + set.headers["ETag"] = '"old"'; + set.headers["Vary"] = "Origin"; + set.headers["X-Custom"] = "kept"; + set.headers["Set-Cookie"] = "session=kept; Path=/"; + }); + } + app.use(fedify(fed, () => undefined)); + if (mode.startsWith("late-")) app.onError(appErrorHook); + if (mode === "async-pass") { + app.onRequest(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + } + if (mode.endsWith("html")) { + app.get("/users/:identifier", async () => { + if (mode === "async-html") { + await new Promise((r) => setTimeout(r, 10)); + } + return new Response( + mode === "not-found-html" ? "

No such user

" : "

Alice

", + { + status: mode === "not-found-html" ? 404 : 200, + headers: { "Content-Type": "text/html" }, + }, + ); + }); + } else if (mode === "not-found-json") { + app.get( + "/users/:identifier", + () => + new Response(JSON.stringify({ error: "No such user" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ); + } else if (mode === "not-found-error") { + app.get("/users/:identifier", () => { + throw new NotFoundError("No such user"); + }); + } else if (mode === "error") { + app.get("/users/:identifier", () => { + throw new Error("application failure"); + }); + } + // Returning a string from the fallback would crash Elysia 1.3.8 here. + app.mapResponse(() => undefined); + const response = await app.handle( + new Request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }), + ); + const expected = mode.startsWith("not-found") || mode === "early-route" || + mode === "late-404-hook" + ? 404 + : mode.endsWith("html") || mode.endsWith("error-hook") + ? 200 + : mode === "error" + ? 500 + : 406; + assert.equal(response.status, expected); + const body = await response.text(); + if (mode.endsWith("error-hook") || mode === "late-404-hook") { + assert.equal(body, "

Custom missing

"); + assert.equal(errorHookCalls, 1); + } else if (mode === "not-found-json") { + assert.deepEqual(JSON.parse(body), { error: "No such user" }); + } else if (expected === 404) { + assert.equal( + body, + mode.endsWith("html") ? "

No such user

" : "No such user", + ); + } else if (expected === 200) assert.equal(body, "

Alice

"); + else if (expected === 406) { + assert.equal(body, "Not acceptable"); + assert.equal( + response.headers.get("Content-Type"), + "text/plain; charset=utf-8", + ); + assert.ok( + response.headers.get("Vary")?.split(",").some((field) => + field.trim().toLowerCase() === "accept" + ), + ); + if (mode === "headers") { + assert.ok(response.headers.get("Vary")?.includes("Origin")); + for (const name of ["Content-Length", "Content-Encoding", "ETag"]) { + assert.equal(response.headers.get(name), null); + } + assert.equal(response.headers.get("X-Custom"), "kept"); + assert.equal( + response.headers.get("Set-Cookie"), + "session=kept; Path=/", + ); + } + } + if ( + !["early-error-hook", "late-error-hook", "late-404-hook"].includes(mode) + ) { + const missing = await app.handle( + new Request("http://localhost/missing"), + ); + const control = await new Elysia({ aot }).handle( + new Request("http://localhost/missing"), + ); + assert.equal(missing.status, control.status); + assert.equal(await missing.text(), await control.text()); + assert.ok( + !missing.headers.get("Vary")?.split(",").some((field) => + field.trim().toLowerCase() === "accept" + ), + ); + } + }); + } +} + +for (const aot of [true, false]) { + for (const depth of [1, 2]) { + test(`Elysia preserves parent error hooks (aot=${aot}, depth=${depth})`, async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + let app = new Elysia({ aot }).use(fedify(fed, () => undefined)); + for (let level = 0; level < depth; level++) { + app = new Elysia({ aot }).use(app); + } + let calls = 0; + app.onError(() => { + calls++; + return new Response("

Parent missing

", { status: 404 }); + }); + const response = await app.handle( + new Request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }), + ); + assert.equal(response.status, 404); + assert.equal(await response.text(), "

Parent missing

"); + assert.equal(calls, 1); + }); + } + test(`Elysia preserves explicit request-hook NotFoundErrors (aot=${aot})`, async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = new Elysia({ aot }).use(fedify(fed, () => undefined)) + .onRequest(() => { + throw new NotFoundError("User has been removed"); + }); + const response = await app.handle( + new Request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }), + ); + assert.equal(response.status, 404); + assert.equal(await response.text(), "User has been removed"); + }); +} + +for (const aot of [true, false]) { + test(`Elysia preserves each parent's error hooks when a plugin is reused (aot=${aot})`, async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const shared = new Elysia({ aot }).use(fedify(fed, () => undefined)); + const apps = ["first", "second"].map((body) => + new Elysia({ aot }).use(shared).onError(() => + new Response(body, { status: 404 }) + ) + ); + for (const [index, app] of apps.entries()) { + const response = await app.handle( + new Request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }), + ); + assert.equal(response.status, 404); + assert.equal(await response.text(), index === 0 ? "first" : "second"); + } + }); +} + +for (const aot of [true, false]) { + test(`Elysia does not replay earlier error hooks when the fallback is wrapped (aot=${aot})`, async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + let earlierCalls = 0; + const app = new Elysia({ aot }).onError(() => { + earlierCalls++; + }).use(fedify(fed, () => undefined)); + const hook = app.event.error!.find((hook) => + hook.fn.name === "notAcceptableFallback" + )!; + const fallback = hook.fn; + // Simulate a plugin replacing the registration with a wrapped copy. + hook.fn = async (context) => { + // Keep context fields visible to Elysia's AOT inference. + const { request, code, set, error, store, route } = context; + return await fallback({ + ...context, + request, + code, + set, + error, + store, + route, + }); + }; + const response = await app.handle( + new Request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }), + ); + assert.equal(response.status, 406); + assert.equal(await response.text(), "Not acceptable"); + assert.equal(earlierCalls, 1); + }); +} diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 9381d0d13..6f97fe2db 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -9,10 +9,13 @@ export const fedify = ( federation: Federation, contextDataFactory: ContextDataFactory, ) => { - return (app: Elysia) => - app + const notAcceptableRequests = new WeakSet(); + return (app: Elysia) => { + observeApplicationParents(app); + return app .decorate("federation", federation) .onRequest(async ({ request, set, federation }) => { + notAcceptableRequests.delete(request); let notFound = false; let notAcceptable = false; @@ -29,6 +32,7 @@ export const fedify = ( onNotAcceptable: () => { // Let Elysia handle when federation doesn't accept the request notAcceptable = true; + notAcceptableRequests.add(request); return new Response("Not acceptable", { status: 406, headers: { @@ -57,5 +61,128 @@ export const fedify = ( // Continue to next handler if federation didn't handle the request }) + .onTransform(({ request }) => { + // A matched route owns its response, including a deliberate 404. + notAcceptableRequests.delete(request); + }) + .onError(async function notAcceptableFallback(context) { + const { request, code, set } = context; + if ( + code !== "NOT_FOUND" || !notAcceptableRequests.has(request) || + context.route || context.error.message !== "NOT_FOUND" + ) return; + notAcceptableRequests.delete(request); + // Elysia has no next() for error hooks. Let later application hooks + // handle the request once before supplying our terminal fallback. + // The context's store identifies the application handling this + // request, even when the installation plugin has several parents. + const activeApp = findApplication(app, context.store); + const hooks = activeApp.event.error ?? []; + const index = hooks.findIndex((hook) => + hook.fn === notAcceptableFallback + ); + for (const hook of index < 0 ? [] : hooks.slice(index + 1)) { + const response = await hook.fn(context); + if ( + activeApp.config.aot === false + ? response != null + : response !== undefined + ) { + return response; + } + } + const vary: string[] = []; + for (const header of Object.keys(set.headers)) { + const name = header.toLowerCase(); + if (name === "vary") { + vary.push(...String(set.headers[header]).split(",")); + } + if ( + [ + "content-type", + "vary", + "content-encoding", + "content-language", + "content-range", + "content-length", + "etag", + "last-modified", + "transfer-encoding", + ].includes(name) + ) delete set.headers[header]; + } + const fields = vary.map((field) => field.trim()).filter(Boolean); + if ( + !fields.some((field) => + field === "*" || field.toLowerCase() === "accept" + ) + ) { + fields.push("Accept"); + } + set.status = 406; + // Return a Response: Elysia 1.3.8's AOT error mapper can fail for a + // string result when application mapResponse hooks are installed. + return new Response("Not acceptable", { + status: 406, + headers: { + "Content-Type": "text/plain; charset=utf-8", + Vary: fields.join(", "), + }, + }); + }) .as("global"); + }; }; + +// Elysia links plugin instances to their parent through a private getParent +// assignment. Observe these links without changing its getter behavior. Weak +// references keep a reusable plugin from retaining discarded applications. +const applicationParents = new WeakMap>>(); + +function observeApplicationParents(app: Elysia): void { + if (applicationParents.has(app)) return; + const parents = new Set>(); + applicationParents.set(app, parents); + let getParent = (app as unknown as { + getParent?: () => Elysia | null; + }).getParent; + const rememberParent = (): void => { + const parent = getParent?.call(app); + if (parent == null || parent === app) return; + for (const reference of parents) { + const value = reference.deref(); + if (value === parent) return; + if (value == null) parents.delete(reference); + } + parents.add(new WeakRef(parent)); + observeApplicationParents(parent); + }; + rememberParent(); + Object.defineProperty(app, "getParent", { + configurable: true, + enumerable: true, + get: () => getParent, + set: (value: typeof getParent) => { + getParent = value; + rememberParent(); + }, + }); +} + +function findApplication(app: Elysia, store: object): Elysia { + const pending = [app]; + const visited = new Set(); + while (pending.length > 0) { + const candidate = pending.pop()!; + if (visited.has(candidate)) continue; + visited.add(candidate); + if (candidate.singleton.store === store) return candidate; + const parents = applicationParents.get(candidate); + for (const reference of parents ?? []) { + const parent = reference.deref(); + if (parent == null) parents?.delete(reference); + else pending.push(parent); + } + } + return app; +} diff --git a/packages/express/package.json b/packages/express/package.json index 4cd1e0646..51bfa4b32 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -55,7 +55,8 @@ "@types/express": "catalog:", "@types/node": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "@fedify/fixture": "workspace:*" }, "scripts": { "build:self": "tsdown", diff --git a/packages/express/src/fallback.test.ts b/packages/express/src/fallback.test.ts new file mode 100644 index 000000000..d87a743cb --- /dev/null +++ b/packages/express/src/fallback.test.ts @@ -0,0 +1,275 @@ +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { test } from "@fedify/fixture"; +import { strict as assert } from "node:assert"; +import { Buffer } from "node:buffer"; +import express from "express"; +import { integrateFederation } from "./index.ts"; +import type { AddressInfo } from "node:net"; + +async function withServer( + app: express.Express, + callback: (origin: string) => Promise, +): Promise { + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const { port } = server.address() as AddressInfo; + try { + await callback(`http://127.0.0.1:${port}`); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +for ( + const mode of [ + "unhandled", + "pass", + "async-pass", + "html", + "async-html", + "not-found-html", + "not-found-json", + "not-found-end", + "error", + ] +) { + test( + "express preserves the application's response or falls back to 406: " + + mode, + async () => { + let dispatches = 0; + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => { + dispatches++; + return null; + }); + const app = express(); + app.use( + ( + _req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + res.vary("Origin"); + next(); + }, + ); + app.use(integrateFederation(fed, () => undefined)); + if (mode !== "unhandled") { + app.get( + "/users/:identifier", + async ( + _req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + if (mode.startsWith("async")) { + await new Promise((r) => setTimeout(r, 10)); + } + if (mode.startsWith("not-found")) { + res.status(404); + if (mode.endsWith("json")) res.json({ error: "No such user" }); + else if (mode.endsWith("end")) { + res.type("text").end("No such user"); + } else res.type("html").send("

No such user

"); + } else if (mode.endsWith("html")) { + res.type("html").send("

Alice

"); + } else if (mode === "error") next(new Error("application failure")); + else next(); + }, + ); + } + app.use( + ( + error: Error, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + res.status(500).send(error.message); + }, + ); + + await withServer(app, async (origin) => { + for (const method of ["GET", "HEAD"]) { + const response = await fetch(`${origin}/users/alice`, { + method, + headers: { Accept: "image/png" }, + signal: AbortSignal.timeout(5000), + }); + const body = await response.text(); + const expected = mode.startsWith("not-found") + ? 404 + : mode.endsWith("html") + ? 200 + : mode === "error" + ? 500 + : 406; + assert.equal(response.status, expected); + if (expected === 406) { + assert.equal(response.statusText, "Not Acceptable"); + assert.equal( + response.headers.get("Content-Type")?.split(";")[0], + "text/plain", + ); + assert.ok( + response.headers.get("Vary")?.toLowerCase().includes("accept"), + ); + assert.ok( + response.headers.get("Vary")?.toLowerCase().includes("origin"), + ); + assert.equal(response.headers.get("ETag"), null); + assert.equal(body, method === "HEAD" ? "" : "Not acceptable"); + } else if (expected === 404) { + if (method === "HEAD") assert.equal(body, ""); + else if (mode.endsWith("json")) { + assert.deepEqual(JSON.parse(body), { error: "No such user" }); + } else {assert.equal( + body, + mode.endsWith("html") ? "

No such user

" : "No such user", + );} + } else if (expected === 200) { + assert.equal(body, method === "HEAD" ? "" : "

Alice

"); + assert.equal( + response.headers.get("Content-Type")?.split(";")[0], + "text/html", + ); + } + } + if (mode === "unhandled" || mode.endsWith("pass")) { + const missing = await fetch(`${origin}/missing`, { + headers: { Accept: "image/png" }, + }); + assert.equal(missing.status, 404); + assert.ok( + !missing.headers.get("Vary")?.split(",").some((field) => + field.trim().toLowerCase() === "accept" + ), + ); + await missing.text(); + } + }); + assert.equal(dispatches, 0); + }, + ); +} + +test("express falls back for paths escaped in its default 404 page", async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = express().use(integrateFederation(fed, () => undefined)); + await withServer(app, async (origin) => { + for (const identifier of ["a{b}", "a%7Bb%7D", "a%20b"]) { + for (const method of ["GET", "HEAD"]) { + const response = await fetch(`${origin}/users/${identifier}`, { + method, + headers: { Accept: "image/png" }, + signal: AbortSignal.timeout(5000), + }); + const body = await response.text(); + assert.equal(response.status, 406, `${method} ${identifier}`); + assert.equal(body, method === "HEAD" ? "" : "Not acceptable"); + } + } + }); +}); + +test("express preserves a custom HTML 404 resembling its default page", async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = express().use(integrateFederation(fed, () => undefined)); + app.get( + "/users/:identifier", + (req: express.Request, res: express.Response) => { + const body = + `\n
Cannot ${req.method} ${req.path}
` + + "

No such user; try the directory.

\n"; + res.status(404).set({ + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": "default-src 'none'", + "X-Content-Type-Options": "nosniff", + "Content-Length": Buffer.byteLength(body), + }).end(body); + }, + ); + await withServer(app, async (origin) => { + for (const method of ["GET", "HEAD"]) { + const response = await fetch(`${origin}/users/alice`, { + method, + headers: { Accept: "image/png" }, + }); + assert.equal(response.status, 404); + const body = await response.text(); + if (method === "HEAD") assert.equal(body, ""); + else assert.ok(body.includes("No such user; try the directory.")); + } + }); +}); + +for (const mode of ["unhandled", "application-404", "html"]) { + test( + "express applies fallback before downstream response wrappers: " + mode, + async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = express().use(integrateFederation(fed, () => undefined)); + let ends = 0; + app.use( + ( + _req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + const end = res.end; + res.end = function (this: express.Response, ...args: unknown[]) { + ends++; + this.writeHead(this.statusCode); + return end.apply(this, args as Parameters); + } as typeof res.end; + next(); + }, + ); + if (mode !== "unhandled") { + app.get( + "/users/:identifier", + (_req: express.Request, res: express.Response) => { + res.status(mode === "application-404" ? 404 : 200).send( + "Application response", + ); + }, + ); + } + await withServer(app, async (origin) => { + for (const method of ["GET", "HEAD"]) { + const response = await fetch(`${origin}/users/alice`, { + method, + headers: { Accept: "image/png", "Accept-Encoding": "identity" }, + signal: AbortSignal.timeout(5000), + }); + assert.equal( + response.status, + mode === "unhandled" ? 406 : mode === "application-404" ? 404 : 200, + ); + assert.equal( + await response.text(), + method === "HEAD" + ? "" + : mode === "unhandled" + ? "Not acceptable" + : "Application response", + ); + if (mode === "unhandled") { + assert.ok( + response.headers.get("Vary")?.split(",").some((field) => + field.trim().toLowerCase() === "accept" + ), + ); + assert.equal(response.headers.get("Content-Length"), "14"); + } + } + }); + assert.equal(ends, 2); + }, + ); +} diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index 3add45ef6..fd0008c66 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -46,9 +46,10 @@ export function integrateFederation( // When the `federation` object finds a request not acceptable // type-wise (i.e., a user-agent doesn't want JSON-LD), it will call // the `next` function provided by the Express framework to continue - // if any route is matched, and otherwise, it will return a 406 Not + // if the application can handle it, and otherwise return a 406 Not // Acceptable response: notAcceptable = true; + installNotAcceptableFallback(req, res); body?.restore(); next(); return new Response("Not acceptable", { @@ -60,7 +61,7 @@ export function integrateFederation( }); }, }); - if (notFound || (notAcceptable && req.route != null)) return; + if (notFound || notAcceptable) return; await setEResponse(res, response); // Prevent the Express framework from sending the response again: res.end(); @@ -245,3 +246,133 @@ function setEResponse(res: EResponse, response: Response): Promise { }); }); } + +/** + * Waits for downstream handling to finish before replacing an unsent 404. + * A matched route can still fall through, and next() does not wait for an + * asynchronous handler. Already committed responses belong to the application. + */ +function installNotAcceptableFallback(req: ERequest, res: EResponse): void { + const end = res.end; + const send = res.send; + let pending = true; + let applicationResponse = false; + const wrappedSend: typeof res.send = function ( + this: EResponse, + body?: unknown, + ) { + applicationResponse = true; + return send.call(this, body); + }; + res.send = wrappedSend; + const wrapEnd = (end: typeof res.end): typeof res.end => + function ( + this: EResponse, + ...args: unknown[] + ) { + if (pending) { + // Stop observing assignments before entering downstream wrappers. Their + // saved end functions still delegate through our now-inactive wrappers. + Object.defineProperty(this, "end", { + configurable: true, + enumerable: true, + writable: true, + value: end, + }); + if (this.send === wrappedSend) this.send = send; + } + if ( + !pending || applicationResponse || this.headersSent || + this.statusCode !== 404 || !isDefaultNotFound(req, this, args[0]) + ) { + pending = false; + return end.apply(this, args as Parameters); + } + pending = false; + const callback = args.find((arg) => typeof arg === "function") as + | (() => void) + | undefined; + const body = "Not acceptable"; + this.statusCode = 406; + this.statusMessage = "Not Acceptable"; + for ( + const header of [ + "Content-Encoding", + "Content-Language", + "Content-Range", + "ETag", + "Last-Modified", + "Transfer-Encoding", + ] + ) this.removeHeader(header); + this.setHeader("Content-Type", "text/plain; charset=utf-8"); + this.setHeader("Content-Length", Buffer.byteLength(body)); + this.vary("Accept"); + return end.call( + this, + req.method === "HEAD" ? undefined : body, + "utf8", + callback, + ); + }; + // Middleware such as compression wraps end and commits headers before + // invoking the saved function. Wrap each assignment so classification and + // replacement happen before the outermost downstream wrapper commits. + let wrappedEnd = wrapEnd(end); + Object.defineProperty(res, "end", { + configurable: true, + enumerable: true, + get: () => wrappedEnd, + set: (value: typeof res.end) => { + wrappedEnd = wrapEnd(value); + }, + }); +} + +/** + * Express offers no public router-exhaustion callback to middleware. Recognize + * finalhandler's default page, failing safe when its format changes. An app + * response identical to that default page cannot be distinguished from it. + */ +function isDefaultNotFound( + req: ERequest, + res: EResponse, + chunk: unknown, +): boolean { + if ( + res.getHeader("Content-Security-Policy") !== "default-src 'none'" || + res.getHeader("X-Content-Type-Options") !== "nosniff" || + res.getHeader("Content-Type") !== "text/html; charset=utf-8" + ) return false; + // Match the whole default page, preserving custom HTML with similar + // markup. HEAD omits the page, so only its expected length is available. + const path = (req.originalUrl ?? req.url).split("?")[0]; + // Match finalhandler's encodeurl escaping, retaining valid percent escapes. + const encodedPath = path + .replace( + /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g, + "$1\uFFFD$2", + ) + .replace( + /(?:[^\x21\x23-\x3B\x3D\x3F-\x5F\x61-\x7A\x7C\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g, + encodeURI, + ); + const message = `Cannot ${req.method} ${encodedPath}`.replace( + /[&<>"']/g, + (character) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character]!, + ); + const page = '\n\n\n' + + '\nError\n\n\n' + + `
${message}
\n\n\n`; + return req.method === "HEAD" + ? chunk == null && + Number(res.getHeader("Content-Length")) === Buffer.byteLength(page) + : chunk === page; +} diff --git a/packages/fedify/src/utils/docloader.test.ts b/packages/fedify/src/utils/docloader.test.ts index 2ba98cbc0..97af91ccb 100644 --- a/packages/fedify/src/utils/docloader.test.ts +++ b/packages/fedify/src/utils/docloader.test.ts @@ -123,70 +123,75 @@ test("getAuthenticatedDocumentLoader() validates redirects", async (t) => { fetchMock.hardReset(); }); -test("getAuthenticatedDocumentLoader() cancellation", { - sanitizeResources: false, - sanitizeOps: false, -}, async (t) => { +test("getAuthenticatedDocumentLoader() cancellation", async (t) => { fetchMock.spyGlobal(); + try { + await t.step("document loader cancellation", async () => { + let responseTimer: ReturnType | undefined; + try { + fetchMock.get( + "https://example.com/slow-object", + () => + new Promise((resolve) => { + responseTimer = setTimeout(() => { + resolve({ + status: 200, + headers: { "Content-Type": "application/activity+json" }, + body: { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Note", + content: "Slow response", + }, + }); + }, 1000); + }), + ); - await t.step("document loader cancellation", async () => { - fetchMock.get( - "https://example.com/slow-object", - () => - new Promise((resolve) => { - setTimeout(() => { - resolve({ - status: 200, - headers: { "Content-Type": "application/activity+json" }, - body: { - "@context": "https://www.w3.org/ns/activitystreams", - type: "Note", - content: "Slow response", - }, - }); - }, 1000); - }), - ); - - const loader = getAuthenticatedDocumentLoader({ - keyId: new URL("https://example.com/key2"), - privateKey: rsaPrivateKey2, - }); - - const controller = new AbortController(); - const promise = loader("https://example.com/slow-object", { - signal: controller.signal, - }); + const loader = getAuthenticatedDocumentLoader({ + keyId: new URL("https://example.com/key2"), + privateKey: rsaPrivateKey2, + }); - controller.abort(); + const controller = new AbortController(); + const promise = loader("https://example.com/slow-object", { + signal: controller.signal, + }); - await assertRejects( - () => promise, - Error, - ); + controller.abort(); - await assertRejects( - () => loader("https://example.com/object", { signal: controller.signal }), - Error, - ); - }); + await assertRejects( + () => promise, + Error, + ); - await t.step("immediate cancellation", async () => { - const loader = getAuthenticatedDocumentLoader({ - keyId: new URL("https://example.com/key2"), - privateKey: rsaPrivateKey2, + await assertRejects( + () => + loader("https://example.com/object", { signal: controller.signal }), + Error, + ); + } finally { + clearTimeout(responseTimer); + } }); - const controller = new AbortController(); - controller.abort(); + await t.step("immediate cancellation", async () => { + const loader = getAuthenticatedDocumentLoader({ + keyId: new URL("https://example.com/key2"), + privateKey: rsaPrivateKey2, + }); - await assertRejects( - () => loader("https://example.com/object", { signal: controller.signal }), - Error, - ); - }); + const controller = new AbortController(); + controller.abort(); - fetchMock.hardReset(); + await assertRejects( + () => + loader("https://example.com/object", { signal: controller.signal }), + Error, + ); + }); + } finally { + fetchMock.hardReset(); + } }); test("getAuthenticatedDocumentLoader() bounds JSON after redirects", async () => { diff --git a/packages/hono/package.json b/packages/hono/package.json index 764b4659c..d4277a02a 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -54,13 +54,15 @@ }, "devDependencies": { "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "@fedify/fixture": "workspace:*" }, "scripts": { "build:self": "tsdown", "build": "pnpm --filter @fedify/hono... run build:self", "prepack": "pnpm build", "prepublish": "pnpm build", - "test": "node --experimental-transform-types --test" + "test": "node --experimental-transform-types --test", + "test:bun": "bun test" } } diff --git a/packages/hono/src/fallback.test.ts b/packages/hono/src/fallback.test.ts new file mode 100644 index 000000000..fe4e0a70d --- /dev/null +++ b/packages/hono/src/fallback.test.ts @@ -0,0 +1,314 @@ +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { test } from "@fedify/fixture"; +import { strict as assert } from "node:assert"; +import { Hono } from "hono"; +import { federation } from "./mod.ts"; + +for ( + const mode of [ + "unhandled", + "pass", + "html", + "async-html", + "not-found-html", + "not-found-json", + "custom-not-found", + "error", + ] +) { + test( + "Hono preserves the application's response or falls back to 406: " + mode, + async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = new Hono(); + // Exercise res setter cloning and stale metadata from upstream middleware. + app.use("*", async (ctx, next) => { + ctx.header("Vary", "Origin"); + if (mode === "unhandled") { + ctx.header("ETag", '"old"'); + ctx.header("Content-Length", "999"); + } + await next(); + }); + app.use("*", federation(fed, () => undefined)); + if (mode === "pass") { + app.use("*", async (_ctx, next) => await next()); + } + if (mode.endsWith("html")) { + app.get("/users/:identifier", async (ctx) => { + if (mode === "async-html") { + await new Promise((r) => setTimeout(r, 10)); + } + return ctx.html( + mode === "not-found-html" ? "

No such user

" : "

Alice

", + mode === "not-found-html" ? 404 : 200, + ); + }); + } else if (mode === "not-found-json") { + app.get( + "/users/:identifier", + (ctx) => ctx.json({ error: "No such user" }, 404), + ); + } else if (mode === "custom-not-found") { + app.notFound((ctx) => ctx.html("

Custom missing

", 404)); + } else if (mode === "error") { + app.get("/users/:identifier", () => { + throw new Error("application failure"); + }); + app.onError((_error, ctx) => ctx.text("application failure", 500)); + } + const response = await app.request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }); + const expected = + mode.startsWith("not-found") || mode === "custom-not-found" || + mode === "pass" + ? 404 + : mode.endsWith("html") + ? 200 + : mode === "error" + ? 500 + : 406; + assert.equal(response.status, expected); + const body = await response.text(); + if (mode === "not-found-json") { + assert.deepEqual(JSON.parse(body), { error: "No such user" }); + } else {assert.equal( + body, + expected === 200 + ? "

Alice

" + : mode === "custom-not-found" + ? "

Custom missing

" + : mode === "pass" + ? "404 Not Found" + : expected === 404 + ? "

No such user

" + : expected === 500 + ? "application failure" + : "Not acceptable", + );} + if (expected === 406) { + assert.equal( + response.headers.get("Content-Type")?.split(";")[0], + "text/plain", + ); + assert.ok( + response.headers.get("Vary")?.split(",").some((field) => + field.trim().toLowerCase() === "accept" + ), + ); + assert.ok( + response.headers.get("Vary")?.toLowerCase().includes("origin"), + ); + assert.equal(response.headers.get("Content-Length"), null); + assert.equal(response.headers.get("ETag"), null); + } + const missing = await app.request("http://localhost/missing"); + assert.equal(missing.status, 404); + assert.equal( + await missing.text(), + mode === "custom-not-found" ? "

Custom missing

" : "404 Not Found", + ); + assert.equal(missing.headers.get("Vary"), "Origin"); + }, + ); +} + +test("Hono preserves an application 404 stream without reading it", async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + let pulls = 0; + const stream = new ReadableStream({ + pull() { + pulls++; + }, + }, { highWaterMark: 0 }); + const app = new Hono(); + app.use("*", federation(fed, () => undefined)); + app.get( + "/users/:identifier", + (ctx) => + ctx.body(stream, 404, { "Content-Type": "text/plain; charset=UTF-8" }), + ); + const response = await app.request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }); + assert.equal(response.status, 404); + assert.equal(pulls, 0); + await response.body!.cancel(); +}); + +test("Hono preserves a redirect with immutable headers", async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = new Hono(); + app.use("*", federation(fed, () => undefined)); + app.get( + "/users/:identifier", + () => Response.redirect("http://localhost/profile", 302), + ); + const response = await app.request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }); + assert.equal(response.status, 302); + assert.equal(response.headers.get("Location"), "http://localhost/profile"); +}); + +test("Hono preserves an application replacement of the default 404", async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = new Hono(); + app.use("*", federation(fed, () => undefined)); + app.use("*", async (ctx, next) => { + await next(); + if (ctx.res.status === 404) { + ctx.res = ctx.html("

Custom missing

", 404); + } + }); + const response = await app.request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }); + assert.equal(response.status, 404); + assert.equal(await response.text(), "

Custom missing

"); +}); + +for (const mode of ["route", "wildcard-route", "all-route", "pass"]) { + test( + "Hono preserves 404 responses owned by matched routes: " + mode, + async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = new Hono(); + app.use("*", federation(fed, () => undefined)); + if (mode === "wildcard-route") { + app.get("*", (ctx) => ctx.text("404 Not Found", 404)); + } else if (mode === "all-route") { + app.all("/users/:identifier", (ctx) => ctx.text("404 Not Found", 404)); + } else { + app.get("/users/:identifier", async (ctx, next) => { + if (mode === "pass") await next(); + else return ctx.text("404 Not Found", 404); + }); + } + const response = await app.request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }); + assert.equal(response.status, 404); + assert.equal( + await response.text(), + "404 Not Found", + ); + }, + ); +} + +test("Hono preserves responses when observation cannot be installed", async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = new Hono(); + app.use("*", async (ctx, next) => { + const descriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(ctx), + "res", + )!; + Object.defineProperty(ctx, "res", { ...descriptor, configurable: false }); + await next(); + }); + app.use("*", federation(fed, () => undefined)); + const response = await app.request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }); + assert.equal(response.status, 404); + assert.equal(await response.text(), "404 Not Found"); + assert.equal(response.headers.get("Vary"), null); +}); + +for (const mode of ["same-body", "wrapped-stream", "replacement"]) { + test( + "Hono preserves middleware ownership after response replacement: " + mode, + async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = new Hono(); + app.use("*", federation(fed, () => undefined)); + app.use("*", async (ctx, next) => { + await next(); + if (mode === "replacement") { + ctx.res = ctx.html("

Application missing

", 404); + } else { + const response = ctx.res; + const body = mode === "same-body" + ? response.body + : response.body!.pipeThrough(new TransformStream()); + ctx.res = new Response(body, response); + } + ctx.header("Vary", "Origin"); + }); + const response = await app.request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }); + assert.equal(response.status, 404); + assert.equal( + await response.text(), + mode === "replacement" ? "

Application missing

" : "404 Not Found", + ); + assert.equal(response.headers.get("Vary"), "Origin"); + }, + ); +} + +for (const mode of ["use", "async-use", "two-argument-use", "all"]) { + test( + "Hono preserves default-text 404s returned by wildcard handlers: " + mode, + async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = new Hono(); + app.use("*", federation(fed, () => undefined)); + if (mode === "all") { + app.all("*", (ctx) => ctx.text("404 Not Found", 404)); + } else if (mode === "async-use") { + app.use("*", async (ctx) => { + await Promise.resolve(); + return ctx.text("404 Not Found", 404); + }); + } else if (mode === "two-argument-use") { + app.use( + "*", + (ctx, _next) => Promise.resolve(ctx.text("404 Not Found", 404)), + ); + } else { + app.use("*", (ctx) => Promise.resolve(ctx.text("404 Not Found", 404))); + } + const response = await app.request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }); + assert.equal(response.status, 404); + assert.equal(await response.text(), "404 Not Found"); + assert.equal(response.headers.get("Vary"), null); + }, + ); +} + +test("Hono falls back to 406 with only the federation middleware", async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = new Hono(); + // A single matched handler bypasses Hono's compose() middleware pipeline. + app.use(federation(fed, () => undefined)); + const response = await app.request("http://localhost/users/alice", { + headers: { Accept: "image/png" }, + }); + assert.equal(response.status, 406); + assert.equal(await response.text(), "Not acceptable"); + assert.equal(response.headers.get("Vary"), "Accept"); + assert.equal( + response.headers.get("Content-Type"), + "text/plain; charset=utf-8", + ); + + const missing = await app.request("http://localhost/missing"); + assert.equal(missing.status, 404); + assert.equal(await missing.text(), "404 Not Found"); +}); diff --git a/packages/hono/src/mod.ts b/packages/hono/src/mod.ts index dda53c804..faab515bd 100644 --- a/packages/hono/src/mod.ts +++ b/packages/hono/src/mod.ts @@ -91,15 +91,102 @@ function integrateFetchOptions( // This kind of trick enables the Fedify and Hono to share the same routes // and they do content negotiation depending on `Accept` header: async onNotAcceptable(_req: Request): Promise { - await next(); - if (ctx.res.status !== 404) return ctx.res; - return new Response("Not acceptable", { - status: 406, - headers: { - "Content-Type": "text/plain", - Vary: "Accept", - }, - }); + const { matchedRoutes, routeIndex } = ctx.req as HonoRequest & { + matchedRoutes?: { method: string; path: string }[]; + routeIndex?: number; + }; + // Public route metadata cannot distinguish an intentional default-text + // 404 from terminal handling after a matched route calls next(). Keep + // ownership with application routes and downstream wildcard middleware. + // Even generic ALL middleware can return the same text as Hono's private + // not-found handler. A fallback is safe only without downstream handlers. + if ( + matchedRoutes == null || routeIndex == null || + matchedRoutes.length > routeIndex + 1 || + matchedRoutes.some((route) => + route.method !== "ALL" || !route.path.endsWith("*") + ) + ) { + await next(); + return ctx.res; + } + // Hono calls its private not-found handler directly. Observe the + // default text response and res assignments without reading any body or + // mutating application headers. Body identity is not stable on Bun. + const context = ctx as unknown as { + text?: (...args: unknown[]) => Response; + }; + const text = context.text; + const ownRes = Object.getOwnPropertyDescriptor(ctx, "res"); + let owner: object | null = ctx; + let res = ownRes; + while (res == null && (owner = Object.getPrototypeOf(owner)) != null) { + res = Object.getOwnPropertyDescriptor(owner, "res"); + } + let defaultResponse: Response | undefined; + let defaultNotFound = false; + const wrappedText = (...args: unknown[]): Response => { + const response = text!.apply(ctx, args); + if (args[0] === "404 Not Found" && args[1] === 404) { + defaultResponse = response; + } + return response; + }; + const setRes = (response: Response): void => { + res!.set!.call(ctx, response); + // A later assignment from application middleware owns its response, + // even when Hono copies headers from the previous default 404. This + // includes body wrappers: a transformed stream cannot safely be + // distinguished from an application-authored replacement. + defaultNotFound = response === defaultResponse; + }; + const observing = res?.get != null && res.set != null && + ownRes?.configurable !== false && text != null; + if (observing) { + context.text = wrappedText; + Object.defineProperty(ctx, "res", { + configurable: true, + enumerable: res!.enumerable, + get: () => res!.get!.call(ctx), + set: setRes, + }); + } + try { + await next(); + } finally { + if (context.text === wrappedText) context.text = text; + if (Object.getOwnPropertyDescriptor(ctx, "res")?.set === setRes) { + if (ownRes == null) delete (ctx as Partial).res; + else Object.defineProperty(ctx, "res", ownRes); + } + } + if (ctx.res.status !== 404 || !defaultNotFound) return ctx.res; + const vary = ctx.res.headers.get("Vary"); + // Hono ignores middleware return values once downstream finalized ctx. + // Its response setter also copies old headers onto the new response. + ctx.res = new Response("Not acceptable", { status: 406 }); + for ( + const header of [ + "Content-Encoding", + "Content-Language", + "Content-Range", + "ETag", + "Last-Modified", + "Transfer-Encoding", + "Content-Length", + ] + ) ctx.res.headers.delete(header); + ctx.res.headers.set("Content-Type", "text/plain; charset=utf-8"); + const fields = vary?.split(",").map((field) => field.trim()) ?? []; + if ( + !fields.some((field) => + field === "*" || field.toLowerCase() === "accept" + ) + ) { + fields.push("Accept"); + } + ctx.res.headers.set("Vary", fields.join(", ")); + return ctx.res; }, }; } diff --git a/packages/koa/package.json b/packages/koa/package.json index 41594a013..2eed051aa 100644 --- a/packages/koa/package.json +++ b/packages/koa/package.json @@ -56,7 +56,8 @@ "@types/koa": "catalog:", "@types/node": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "@fedify/fixture": "workspace:*" }, "scripts": { "build:self": "tsdown", diff --git a/packages/koa/src/fallback.test.ts b/packages/koa/src/fallback.test.ts new file mode 100644 index 000000000..975dbad77 --- /dev/null +++ b/packages/koa/src/fallback.test.ts @@ -0,0 +1,165 @@ +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { test } from "@fedify/fixture"; +import { strict as assert } from "node:assert"; +import Koa from "koa"; +import { createMiddleware } from "./index.ts"; +import type { AddressInfo } from "node:net"; + +async function withServer( + app: Koa, + callback: (origin: string) => Promise, +): Promise { + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const { port } = server.address() as AddressInfo; + try { + await callback(`http://127.0.0.1:${port}`); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +for ( + const mode of [ + "unhandled", + "pass", + "async-pass", + "html", + "async-html", + "not-found-html", + "not-found-json", + "not-found-end", + "error", + ] +) { + test( + "koa preserves the application's response or falls back to 406: " + mode, + async () => { + let dispatches = 0; + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => { + dispatches++; + return null; + }); + const app = new Koa(); + app.silent = true; + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + ctx.vary("Origin"); + await next(); + }); + app.use(createMiddleware(fed, () => undefined)); + if (mode !== "unhandled") { + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + if (mode.startsWith("async")) { + await new Promise((r) => setTimeout(r, 10)); + } + if (mode.startsWith("not-found")) { + ctx.status = 404; + ctx.type = mode.endsWith("html") ? "html" : "text"; + ctx.body = mode.endsWith("json") + ? { error: "No such user" } + : mode.endsWith("html") + ? "

No such user

" + : "No such user"; + } else if (mode.endsWith("html")) { + ctx.type = "html"; + ctx.body = "

Alice

"; + } else if (mode === "error") ctx.throw(500, "application failure"); + else await next(); + }); + } + + await withServer(app, async (origin) => { + for (const method of ["GET", "HEAD"]) { + const response = await fetch(`${origin}/users/alice`, { + method, + headers: { Accept: "image/png" }, + signal: AbortSignal.timeout(5000), + }); + const body = await response.text(); + const expected = mode.startsWith("not-found") + ? 404 + : mode.endsWith("html") + ? 200 + : mode === "error" + ? 500 + : 406; + assert.equal(response.status, expected); + if (expected === 406) { + assert.equal(response.statusText, "Not Acceptable"); + assert.equal( + response.headers.get("Content-Type")?.split(";")[0], + "text/plain", + ); + assert.ok( + response.headers.get("Vary")?.toLowerCase().includes("accept"), + ); + assert.ok( + response.headers.get("Vary")?.toLowerCase().includes("origin"), + ); + assert.equal(response.headers.get("ETag"), null); + assert.equal(body, method === "HEAD" ? "" : "Not acceptable"); + } else if (expected === 404) { + if (method === "HEAD") assert.equal(body, ""); + else if (mode.endsWith("json")) { + assert.deepEqual(JSON.parse(body), { error: "No such user" }); + } else {assert.equal( + body, + mode.endsWith("html") ? "

No such user

" : "No such user", + );} + } else if (expected === 200) { + assert.equal(body, method === "HEAD" ? "" : "

Alice

"); + assert.equal( + response.headers.get("Content-Type")?.split(";")[0], + "text/html", + ); + } + } + if (mode === "unhandled" || mode.endsWith("pass")) { + const missing = await fetch(`${origin}/missing`, { + headers: { Accept: "image/png" }, + }); + assert.equal(missing.status, 404); + assert.ok( + !missing.headers.get("Vary")?.split(",").some((field) => + field.trim().toLowerCase() === "accept" + ), + ); + await missing.text(); + } + }); + assert.equal(dispatches, 0); + }, + ); +} + +for (const mode of ["empty", "message", "status"]) { + test(`koa preserves an explicit ${mode} 404`, async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + const app = new Koa().use(createMiddleware(fed, () => undefined)); + app.use((ctx: Koa.Context) => { + if (mode === "empty") ctx.body = null; + ctx.status = 404; + if (mode === "message") ctx.message = "User removed"; + }); + await withServer(app, async (origin) => { + for (const method of ["GET", "HEAD"]) { + const response = await fetch(`${origin}/users/alice`, { + method, + headers: { Accept: "image/png" }, + }); + assert.equal(response.status, 404); + assert.equal( + await response.text(), + mode === "empty" || method === "HEAD" + ? "" + : mode === "status" + ? "Not Found" + : "User removed", + ); + } + }); + }); +} diff --git a/packages/koa/src/index.ts b/packages/koa/src/index.ts index 0298bcc48..026f03fc4 100644 --- a/packages/koa/src/index.ts +++ b/packages/koa/src/index.ts @@ -74,6 +74,7 @@ export function createMiddleware( notAcceptable = true; body?.restore(); await next(); + setNotAcceptableResponse(ctx); return new Response("Not acceptable", { status: 406, headers: { @@ -257,3 +258,33 @@ function setResponse(ctx: any, response: Response): void { }, }); } + +// deno-lint-ignore no-explicit-any +function setNotAcceptableResponse(ctx: any): void { + // Downstream middleware has finished. Preserve application responses, + // including explicit status-only 404s and raw Node responses. Koa sets + // _explicitStatus when an application uses the status setter; its initial + // default 404 is assigned directly to the raw response instead. + if ( + ctx.status === 404 && ctx.body === undefined && + ctx.message === "Not Found" && + !ctx.response._explicitStatus && !ctx.headerSent && ctx.writable && + ctx.respond !== false + ) { + for ( + const header of [ + "Content-Encoding", + "Content-Language", + "Content-Range", + "ETag", + "Last-Modified", + "Transfer-Encoding", + "Content-Length", + ] + ) ctx.remove(header); + ctx.status = 406; + ctx.body = "Not acceptable"; + ctx.set("Content-Type", "text/plain; charset=utf-8"); + ctx.vary("Accept"); + } +} diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 019a8a78a..2718d3cee 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -55,12 +55,18 @@ "@types/express": "catalog:", "@types/node": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "@fedify/fixture": "workspace:*", + "@nestjs/core": "11.1.4", + "@nestjs/platform-express": "11.1.4", + "reflect-metadata": "^0.2.2" }, "scripts": { "build:self": "tsdown", "build": "pnpm --filter @fedify/nestjs... run build:self", "prepack": "pnpm build", - "prepublish": "pnpm build" + "prepublish": "pnpm build", + "test": "pnpm build:self && node --experimental-transform-types --test", + "test:bun": "pnpm build:self && bun test" } } diff --git a/packages/nestjs/src/fallback.test.ts b/packages/nestjs/src/fallback.test.ts new file mode 100644 index 000000000..0e633090c --- /dev/null +++ b/packages/nestjs/src/fallback.test.ts @@ -0,0 +1,179 @@ +import { createFederation, MemoryKvStore } from "@fedify/fedify"; +import { test } from "@fedify/fixture"; +import { strict as assert } from "node:assert"; +import "reflect-metadata"; +import { + Controller, + Get, + Header, + HttpCode, + type MiddlewareConsumer, + Module, + type NestModule, + Next, + NotFoundException, + RequestMethod, +} from "@nestjs/common"; +import { NestFactory } from "@nestjs/core"; +import type { AddressInfo } from "node:net"; +import type { Response as ExpressResponse } from "express"; +// Node cannot load legacy decorators directly; exercise the built middleware. +import { integrateFederation } from "../dist/index.mjs"; + +for ( + const scenario of [ + "unhandled", + "async-pass", + "html", + "async-html", + "not-found-html", + "not-found-json", + "not-found-error", + "controller-pass", + "wrapped-unhandled", + "wrapped-not-found-json", + "wrapped-html", + ] +) { + const mode = scenario.replace(/^wrapped-/, ""); + test( + "NestJS preserves the application's response or falls back to 406: " + + scenario, + async () => { + const fed = createFederation({ kv: new MemoryKvStore() }); + fed.setActorDispatcher("/users/{identifier}", () => null); + class ActorController { + async actor(next?: () => void) { + if (mode === "controller-pass") return next!(); + if (mode === "not-found-error") { + throw new NotFoundException("No such user"); + } + if (mode === "not-found-json") return { error: "No such user" }; + + if (mode === "async-html") { + await new Promise((r) => setTimeout(r, 10)); + } + return mode === "not-found-html" + ? "

No such user

" + : "

Alice

"; + } + } + Controller("users")(ActorController); + Get(":identifier")( + ActorController.prototype, + "actor", + Object.getOwnPropertyDescriptor(ActorController.prototype, "actor")!, + ); + const descriptor = Object.getOwnPropertyDescriptor( + ActorController.prototype, + "actor", + )!; + if (mode.startsWith("not-found")) { + HttpCode(404)(ActorController.prototype, "actor", descriptor); + } + if (mode === "not-found-html") { + Header("Content-Type", "text/html")( + ActorController.prototype, + "actor", + descriptor, + ); + } + if (mode === "controller-pass") { + Next()(ActorController.prototype, "actor", 0); + } + class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(integrateFederation(fed, () => undefined)).forRoutes( + { path: "users/:identifier", method: RequestMethod.GET }, + { path: "users/:identifier", method: RequestMethod.HEAD }, + ); + if (scenario.startsWith("wrapped-")) { + consumer.apply( + (_req: unknown, res: ExpressResponse, next: () => void) => { + const end = res.end; + res.end = function (this: ExpressResponse, ...args: unknown[]) { + this.writeHead(this.statusCode); + return end.apply(this, args as Parameters); + } as typeof res.end; + next(); + }, + ).forRoutes( + { path: "users/:identifier", method: RequestMethod.GET }, + { path: "users/:identifier", method: RequestMethod.HEAD }, + ); + } + if (mode === "async-pass") { + consumer.apply( + async (_req: unknown, _res: unknown, next: () => void) => { + await new Promise((r) => setTimeout(r, 10)); + next(); + }, + ).forRoutes({ + path: "users/:identifier", + method: RequestMethod.GET, + }); + } + } + } + Module({ + controllers: mode !== "unhandled" && mode !== "async-pass" + ? [ActorController] + : [], + })( + AppModule, + ); + const app = await NestFactory.create(AppModule, { logger: false }); + try { + await app.listen(0, "127.0.0.1"); + const { port } = app.getHttpServer().address() as AddressInfo; + app.getHttpAdapter().getInstance().set("json spaces", 2); + for (const method of ["GET", "HEAD"]) { + const response = await fetch(`http://127.0.0.1:${port}/users/alice`, { + method, + headers: { Accept: "image/png" }, + signal: AbortSignal.timeout(5000), + }); + const expected = mode.startsWith("not-found") + ? 404 + : mode.endsWith("html") + ? 200 + : 406; + assert.equal(response.status, expected); + const body = await response.text(); + if (method === "HEAD") assert.equal(body, ""); + else if (mode === "not-found-json") { + assert.deepEqual(JSON.parse(body), { error: "No such user" }); + } else if (mode === "not-found-error") { + assert.equal(JSON.parse(body).message, "No such user"); + } else {assert.equal( + body, + expected === 406 + ? "Not acceptable" + : mode === "not-found-html" + ? "

No such user

" + : "

Alice

", + );} + if (expected === 406) { + assert.equal( + response.headers.get("Content-Type")?.split(";")[0], + "text/plain", + ); + assert.ok( + response.headers.get("Vary")?.split(",").some((field) => + field.trim().toLowerCase() === "accept" + ), + ); + assert.equal(response.headers.get("ETag"), null); + } + } + const missing = await fetch(`http://127.0.0.1:${port}/missing`); + assert.equal(missing.status, 404); + assert.equal((await missing.json()).statusCode, 404); + assert.equal(missing.headers.get("Vary"), null); + } finally { + app.getHttpServer().closeAllConnections(); + await app.close(); + } + }, + ); +} diff --git a/packages/nestjs/src/fedify.middleware.ts b/packages/nestjs/src/fedify.middleware.ts index 606f232b3..05dcb24b3 100644 --- a/packages/nestjs/src/fedify.middleware.ts +++ b/packages/nestjs/src/fedify.middleware.ts @@ -43,9 +43,10 @@ export function integrateFederation( // When the `federation` object finds a request not acceptable // type-wise (i.e., a user-agent doesn't want JSON-LD), it will call // the `next` function provided by the Express framework to continue - // if any route is matched, and otherwise, it will return a 406 Not + // if the application can handle it, and otherwise return a 406 Not // Acceptable response: notAcceptable = true; + installNotAcceptableFallback(req, res); next(); return new Response("Not acceptable", { status: 406, @@ -57,7 +58,7 @@ export function integrateFederation( }, }); - if (notFound || (notAcceptable && req.route != null)) return; + if (notFound || notAcceptable) return; await setEResponse(res, response); next(); @@ -108,3 +109,101 @@ function setEResponse(res: EResponse, response: Response): Promise { }); }); } + +/** + * Waits for downstream handling to finish before replacing an unsent 404. + * A matched route can still fall through, and next() does not wait for an + * asynchronous handler. Already committed responses belong to the application. + */ +function installNotAcceptableFallback(req: ERequest, res: EResponse): void { + const end = res.end; + const json = res.json; + let pending = true; + let defaultNotFound = false; + const wrappedJson: typeof res.json = function ( + this: EResponse, + body?: unknown, + ) { + // Nest's terminal NotFoundException is sent through the exception filter, + // not Express's finalhandler. Observe the object before send() serializes + // it (or omits it for HEAD). Custom controller/filter responses stay intact. + defaultNotFound = this.statusCode === 404 && isDefaultNotFound(req, body); + return json.call(this, body); + }; + res.json = wrappedJson; + const wrapEnd = (end: typeof res.end): typeof res.end => + function ( + this: EResponse, + ...args: unknown[] + ) { + if (pending) { + Object.defineProperty(this, "end", { + configurable: true, + enumerable: true, + writable: true, + value: end, + }); + if (this.json === wrappedJson) this.json = json; + } + if ( + !pending || !defaultNotFound || this.headersSent || + this.statusCode !== 404 + ) { + pending = false; + return end.apply(this, args as Parameters); + } + pending = false; + const callback = args.find((arg) => typeof arg === "function") as + | (() => void) + | undefined; + const body = "Not acceptable"; + this.statusCode = 406; + this.statusMessage = "Not Acceptable"; + for ( + const header of [ + "Content-Encoding", + "Content-Language", + "Content-Range", + "ETag", + "Last-Modified", + "Transfer-Encoding", + ] + ) this.removeHeader(header); + this.setHeader("Content-Type", "text/plain; charset=utf-8"); + this.setHeader("Content-Length", Buffer.byteLength(body)); + this.vary("Accept"); + return end.call( + this, + req.method === "HEAD" ? undefined : body, + "utf8", + callback, + ); + }; + // Run before downstream end wrappers can commit headers, while retaining + // their saved-function chain after the response has been classified. + let wrappedEnd = wrapEnd(end); + Object.defineProperty(res, "end", { + configurable: true, + enumerable: true, + get: () => wrappedEnd, + set: (value: typeof res.end) => { + wrappedEnd = wrapEnd(value); + }, + }); +} + +/** A custom response copying Nest's exact default 404 is indistinguishable. */ +function isDefaultNotFound(req: ERequest, body: unknown): boolean { + if ( + body == null || typeof body !== "object" || Object.keys(body).length !== 3 + ) { + return false; + } + const error = body as { + statusCode?: unknown; + error?: unknown; + message?: unknown; + }; + return error.statusCode === 404 && error.error === "Not Found" && + error.message === `Cannot ${req.method} ${req.originalUrl}`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24be96f09..4e29e7d35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -960,6 +960,9 @@ importers: specifier: ^1.3.6 version: 1.3.8(exact-mirror@0.1.3(@sinclair/typebox@0.34.38))(file-type@21.0.0)(typescript@6.0.3) devDependencies: + '@fedify/fixture': + specifier: workspace:* + version: link:../fixture bun-types: specifier: ^1.2.19 version: 1.2.19(@types/react@19.1.8) @@ -979,6 +982,9 @@ importers: specifier: 'catalog:' version: 4.21.2 devDependencies: + '@fedify/fixture': + specifier: workspace:* + version: link:../fixture '@types/express': specifier: 'catalog:' version: 4.17.23 @@ -1158,6 +1164,9 @@ importers: specifier: 'catalog:' version: 4.8.3 devDependencies: + '@fedify/fixture': + specifier: workspace:* + version: link:../fixture tsdown: specifier: 'catalog:' version: 0.21.6(typescript@6.0.3) @@ -1211,6 +1220,9 @@ importers: specifier: ^2.0.0 || ^3.0.0 version: 3.0.1 devDependencies: + '@fedify/fixture': + specifier: workspace:* + version: link:../fixture '@types/koa': specifier: 'catalog:' version: 2.15.0 @@ -1267,12 +1279,24 @@ importers: specifier: ^4.0.0 || ^5.0.0 version: 4.21.2 devDependencies: + '@fedify/fixture': + specifier: workspace:* + version: link:../fixture + '@nestjs/core': + specifier: 11.1.4 + version: 11.1.4(@nestjs/common@11.1.4(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: 11.1.4 + version: 11.1.4(@nestjs/common@11.1.4(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.4) '@types/express': specifier: 'catalog:' version: 4.17.23 '@types/node': specifier: 'catalog:' version: 22.19.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 tsdown: specifier: 'catalog:' version: 0.21.6(typescript@6.0.3) @@ -3366,6 +3390,30 @@ packages: class-validator: optional: true + '@nestjs/core@11.1.4': + resolution: {integrity: sha512-pTLjlfLCSgEvCm8iOONLtHN9uw4XyMEI4yec6+jdw6/7QuOHoqEPAVvdUlNwW1OfFjrAdZmZlpf6Uw8pWBfQlQ==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/platform-express@11.1.4': + resolution: {integrity: sha512-SDpfSDJogCZ3fOeP518+GdjKnpJc51LN0dylapq3xBfxJeiEOqmqiZM1tWMOsoijPlUVsz0acpmaVEaOiMXpXw==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@next/env@14.2.30': resolution: {integrity: sha512-KBiBKrDY6kxTQWGzKjQB7QirL3PiiOkV7KW98leHFjtVRKtft76Ra5qSA/SL75xT44dp6hOcqiiJ6iievLOYug==} @@ -3554,6 +3602,11 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@nuxt/opencollective@0.4.1': + resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} + engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} + hasBin: true + '@opentelemetry/api-logs@0.211.0': resolution: {integrity: sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==} engines: {node: '>=8.0.0'} @@ -5344,6 +5397,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -5431,6 +5488,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -5572,6 +5632,10 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -5591,6 +5655,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-more-ints@1.0.0: resolution: {integrity: sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==} @@ -5806,20 +5873,36 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -5829,6 +5912,10 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -5853,6 +5940,10 @@ packages: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -6569,6 +6660,10 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} @@ -6609,6 +6704,9 @@ packages: fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -6668,6 +6766,10 @@ packages: resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way@9.3.0: resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} engines: {node: '>=20'} @@ -6722,6 +6824,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -6919,6 +7025,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + icojs@0.19.5: resolution: {integrity: sha512-4RnOvFpl7HNgUYLmmLKzhCk/DCNS81sXSC8ENp+mUonMHn3m9dsE+oQLnmoznjwr2bV9n1IapfSpdxopDzYBXA==} engines: {node: '>=18.17.1'} @@ -6935,6 +7045,10 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -7106,6 +7220,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -7564,6 +7681,10 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -7665,6 +7786,10 @@ packages: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -7721,6 +7846,10 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -7749,6 +7878,10 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multer@2.0.1: + resolution: {integrity: sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==} + engines: {node: '>= 10.16.0'} + mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true @@ -7781,6 +7914,10 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + next@14.2.30: resolution: {integrity: sha512-+COdu6HQrHHFQ1S/8BBsCag61jZacmvbuL2avHvQFbWa2Ox7bE+d8FyNgxRLjXQ5wtPyQwEmk85js/AuaG2Sbg==} engines: {node: '>=18.17.0'} @@ -8358,6 +8495,10 @@ packages: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + quansync@0.2.10: resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} @@ -8384,6 +8525,10 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rdf-canonize@5.0.0: resolution: {integrity: sha512-g8OUrgMXAR9ys/ZuJVfBr05sPPoMA7nHIVs8VEvg9QwM5W4GR2qSFEEHjsyHF1eWlBaf8Ev40WNjQFQ+nJTO3w==} engines: {node: '>=18'} @@ -8421,6 +8566,10 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readable-stream@4.7.0: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -8584,6 +8733,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-async@4.0.6: resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} engines: {node: '>=0.12.0'} @@ -8661,10 +8814,18 @@ packages: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serve-static@1.16.2: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-cookie-parser@2.7.1: resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} @@ -8719,6 +8880,10 @@ packages: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + side-channel-map@1.0.1: resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} engines: {node: '>= 0.4'} @@ -8731,6 +8896,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -8810,6 +8979,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -9207,6 +9380,10 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -9223,6 +9400,9 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript-eslint@8.41.0: resolution: {integrity: sha512-n66rzs5OBXW3SFSnZHr2T685q1i4ODm2nulFJhMZBotaTavsS8TrI3d7bDlRSs9yWo7HmyWrN9qDu14Qv7Y0Dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -9908,7 +10088,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -11363,6 +11543,32 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/core@11.1.4(@nestjs/common@11.1.4(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.4(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxt/opencollective': 0.4.1 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.2.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.1.4(@nestjs/common@11.1.4(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.4) + + '@nestjs/platform-express@11.1.4(@nestjs/common@11.1.4(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.4)': + dependencies: + '@nestjs/common': 11.1.4(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.4(@nestjs/common@11.1.4(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.5 + express: 5.1.0 + multer: 2.0.1 + path-to-regexp: 8.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@next/env@14.2.30': {} '@next/env@15.3.1': {} @@ -11472,6 +11678,10 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@nuxt/opencollective@0.4.1': + dependencies: + consola: 3.4.2 + '@opentelemetry/api-logs@0.211.0': dependencies: '@opentelemetry/api': 1.9.0 @@ -13046,7 +13256,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.41.0(typescript@5.9.2) '@typescript-eslint/types': 8.41.0 - debug: 4.4.1 + debug: 4.4.3 typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -13055,7 +13265,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.41.0(typescript@6.0.3) '@typescript-eslint/types': 8.41.0 - debug: 4.4.1 + debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -13064,7 +13274,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@6.0.3) '@typescript-eslint/types': 8.50.0 - debug: 4.4.1 + debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -13127,7 +13337,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.3) '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@6.0.3) - debug: 4.4.1 + debug: 4.4.3 eslint: 8.57.1 ts-api-utils: 1.4.3(typescript@6.0.3) optionalDependencies: @@ -13140,7 +13350,7 @@ snapshots: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@6.0.3) '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@6.0.3) - debug: 4.4.1 + debug: 4.4.3 eslint: 9.32.0(jiti@2.5.1) ts-api-utils: 2.1.0(typescript@6.0.3) typescript: 6.0.3 @@ -13152,7 +13362,7 @@ snapshots: '@typescript-eslint/types': 8.41.0 '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) '@typescript-eslint/utils': 8.41.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2) - debug: 4.4.1 + debug: 4.4.3 eslint: 9.32.0(jiti@2.5.1) ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 @@ -13185,7 +13395,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.1 + debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -13202,7 +13412,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@6.0.3) '@typescript-eslint/types': 8.38.0 '@typescript-eslint/visitor-keys': 8.38.0 - debug: 4.4.1 + debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -13603,6 +13813,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.1 + negotiator: 1.1.0 + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -13685,6 +13900,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + append-field@1.0.0: {} + arg@5.0.2: {} argparse@1.0.10: @@ -13846,6 +14063,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -13871,6 +14102,8 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-from@1.1.2: {} + buffer-more-ints@1.0.0: {} buffer@5.7.1: @@ -14074,22 +14307,37 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + confbox@0.1.8: {} confbox@0.2.2: {} + consola@3.4.2: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 + content-disposition@1.1.0: {} + content-type@1.0.5: {} + content-type@2.1.0: {} + convert-source-map@2.0.0: {} cookie-es@1.2.2: {} cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} + cookie@0.6.0: {} cookie@0.7.1: {} @@ -14107,6 +14355,11 @@ snapshots: dependencies: is-what: 4.1.16 + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -15247,6 +15500,38 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.1.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.16.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.7: {} extend-shallow@2.0.1: @@ -15296,6 +15581,8 @@ snapshots: dependencies: fast-decode-uri-component: 1.0.1 + fast-safe-stringify@2.1.1: {} + fast-uri@3.1.0: {} fastify-plugin@5.1.0: {} @@ -15389,6 +15676,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + find-my-way@9.3.0: dependencies: fast-deep-equal: 3.1.3 @@ -15440,6 +15738,8 @@ snapshots: fresh@0.5.2: {} + fresh@2.0.0: {} + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -15671,6 +15971,14 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + icojs@0.19.5(@jimp/custom@0.22.12): dependencies: '@jimp/bmp': 0.22.12(@jimp/custom@0.22.12) @@ -15694,6 +16002,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -15869,6 +16181,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + is-reference@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -16432,6 +16746,8 @@ snapshots: merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} mermaid@11.7.0: @@ -16622,6 +16938,10 @@ snapshots: dependencies: mime-db: 1.54.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@3.0.0: {} @@ -16692,6 +17012,10 @@ snapshots: mitt@3.0.1: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mkdirp@3.0.1: {} mlly@1.7.4: @@ -16713,6 +17037,16 @@ snapshots: muggle-string@0.4.1: {} + multer@2.0.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + mustache@4.2.0: {} mute-stream@1.0.0: {} @@ -16733,6 +17067,10 @@ snapshots: negotiator@0.6.3: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + next@14.2.30(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-7771d3a7-20240827(react@19.0.0-rc-7771d3a7-20240827))(react@19.0.0-rc-7771d3a7-20240827): dependencies: '@next/env': 14.2.30 @@ -17250,6 +17588,11 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quansync@0.2.10: {} quansync@1.0.0: {} @@ -17271,6 +17614,13 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + rdf-canonize@5.0.0: dependencies: setimmediate: 1.0.5 @@ -17302,6 +17652,12 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readable-stream@4.7.0: dependencies: abort-controller: 3.0.0 @@ -17531,6 +17887,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.2.0 + transitivePeerDependencies: + - supports-color + run-async@4.0.6: {} run-parallel@1.2.0: @@ -17615,6 +17981,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serve-static@1.16.2: dependencies: encodeurl: 2.0.0 @@ -17624,6 +18006,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-cookie-parser@2.7.1: {} set-function-length@1.2.2: @@ -17775,6 +18166,11 @@ snapshots: es-errors: 1.3.0 object-inspect: 1.13.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-map@1.0.1: dependencies: call-bound: 1.0.4 @@ -17798,6 +18194,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -17853,6 +18257,8 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: {} + std-env@3.10.0: {} stdin-discarder@0.2.2: {} @@ -18289,6 +18695,12 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.1 + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.0 + mime-types: 3.0.1 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -18322,6 +18734,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typedarray@0.0.6: {} + typescript-eslint@8.41.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2): dependencies: '@typescript-eslint/eslint-plugin': 8.41.0(@typescript-eslint/parser@8.41.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2)