diff --git a/.changeset/green-ducks-return.md b/.changeset/green-ducks-return.md new file mode 100644 index 00000000..bff3714c --- /dev/null +++ b/.changeset/green-ducks-return.md @@ -0,0 +1,5 @@ +--- +"@tus/server": minor +--- + +Use srvx for convert Node.js req/res to Request/Response. This also comes with a performance boost. When using `server.handle()` in a Node.js environment, you can now access the orignal req/res via `req.node.req`/`req.node.res`. diff --git a/package-lock.json b/package-lock.json index 119ff1cb..2bd14b5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3372,6 +3372,12 @@ "node": ">= 0.6" } }, + "node_modules/cookie-es": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", + "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", + "license": "MIT" + }, "node_modules/cookiejar": { "version": "2.1.4", "dev": true, @@ -5663,6 +5669,18 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/srvx": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.2.7.tgz", + "integrity": "sha512-iMic+FvEyd57WN2+ni9tq6jDD940NI2cfhw/LrzgCLelXdfcQUGxjp7TIeHQlmMwrz53Tz/l0CQecqvRA+9Yww==", + "license": "MIT", + "dependencies": { + "cookie-es": "^2.0.0" + }, + "engines": { + "node": ">=20.11.1" + } + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -6201,11 +6219,11 @@ }, "packages/azure-store": { "name": "@tus/azure-store", - "version": "1.0.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "@azure/storage-blob": "^12.24.0", - "@tus/utils": "^0.5.0", + "@tus/utils": "^0.6.0", "debug": "^4.3.4" }, "devDependencies": { @@ -6238,10 +6256,10 @@ }, "packages/file-store": { "name": "@tus/file-store", - "version": "1.5.1", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@tus/utils": "^0.5.0", + "@tus/utils": "^0.6.0", "debug": "^4.3.4" }, "devDependencies": { @@ -6260,15 +6278,15 @@ }, "packages/gcs-store": { "name": "@tus/gcs-store", - "version": "1.4.2", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@tus/utils": "^0.5.0", + "@tus/utils": "^0.6.0", "debug": "^4.3.4" }, "devDependencies": { "@google-cloud/storage": "^7.15.2", - "@tus/server": "^1.10.0", + "@tus/server": "^2.0.0", "@types/debug": "^4.1.12", "@types/mocha": "^10.0.6", "@types/node": "^22.13.7", @@ -6284,12 +6302,12 @@ }, "packages/s3-store": { "name": "@tus/s3-store", - "version": "1.9.1", + "version": "2.0.0", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.758.0", "@shopify/semaphore": "^3.1.0", - "@tus/utils": "^0.5.1", + "@tus/utils": "^0.6.0", "debug": "^4.3.4", "multistream": "^4.1.0" }, @@ -6307,13 +6325,14 @@ }, "packages/server": { "name": "@tus/server", - "version": "1.10.2", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@tus/utils": "^0.5.1", + "@tus/utils": "^0.6.0", "debug": "^4.3.4", "lodash.throttle": "^4.1.1", - "set-cookie-parser": "^2.7.1" + "set-cookie-parser": "^2.7.1", + "srvx": "^0.2.7" }, "devDependencies": { "@types/debug": "^4.1.12", @@ -6339,7 +6358,7 @@ }, "packages/utils": { "name": "@tus/utils", - "version": "0.5.1", + "version": "0.6.0", "license": "MIT", "devDependencies": { "@types/debug": "^4.1.12", @@ -6355,10 +6374,10 @@ }, "test": { "dependencies": { - "@tus/file-store": "^1.5.1", - "@tus/gcs-store": "^1.4.2", - "@tus/s3-store": "^1.9.1", - "@tus/server": "^1.10.2" + "@tus/file-store": "^2.0.0", + "@tus/gcs-store": "^2.0.0", + "@tus/s3-store": "^2.0.0", + "@tus/server": "^2.0.0" }, "devDependencies": { "@types/mocha": "^10.0.6", diff --git a/package.json b/package.json index 9b5d9cf9..e7894667 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "private": true, + "type": "module", "workspaces": [ "packages/*", "test" diff --git a/packages/server/README.md b/packages/server/README.md index 9a52e4fe..2f38b34a 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -202,6 +202,9 @@ observability. The main server request handler invoked on every request. Use this to integrate into your existing Node.js server. +This handler converts `http.IncomingMessage`/`http.ServerResponse` to `Request`/`Response`. +You can still access the Node.js versions via `req.node.req`/`req.node.res` in the hooks. + #### `server.handleWeb(req: Request)` The main server request handler invoked on every request. diff --git a/packages/server/package.json b/packages/server/package.json index 9ef6b264..98a3d1fb 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -24,14 +24,15 @@ "@tus/utils": "^0.6.0", "debug": "^4.3.4", "lodash.throttle": "^4.1.1", - "set-cookie-parser": "^2.7.1" + "set-cookie-parser": "^2.7.1", + "srvx": "^0.2.7" }, "devDependencies": { "@types/debug": "^4.1.12", "@types/lodash.throttle": "^4.1.9", "@types/mocha": "^10.0.6", - "@types/set-cookie-parser": "^2.4.10", "@types/node": "^22.13.7", + "@types/set-cookie-parser": "^2.4.10", "@types/sinon": "^17.0.3", "@types/supertest": "^2.0.16", "mocha": "^11.0.1", diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 2f2b8007..b422b357 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,11 +1,12 @@ import http from 'node:http' import {EventEmitter} from 'node:events' +import type {ServerRequest} from 'srvx/types' +import {toNodeHandler} from 'srvx/node' import debug from 'debug' import {EVENTS, ERRORS, EXPOSED_HEADERS, REQUEST_METHODS, TUS_RESUMABLE} from '@tus/utils' import type {DataStore, Upload, CancellationContext} from '@tus/utils' -import {BaseHandler} from './handlers/BaseHandler.js' import {GetHandler} from './handlers/GetHandler.js' import {HeadHandler} from './handlers/HeadHandler.js' import {OptionsHandler} from './handlers/OptionsHandler.js' @@ -15,7 +16,6 @@ import {DeleteHandler} from './handlers/DeleteHandler.js' import {validateHeader} from './validators/HeaderValidator.js' import type {ServerOptions, RouteHandler, WithOptional} from './types.js' import {MemoryLocker} from './lockers/index.js' -import {getRequest, setResponse} from './web.js' type Handlers = { GET: InstanceType @@ -27,10 +27,10 @@ type Handlers = { } interface TusEvents { - [EVENTS.POST_CREATE]: (req: Request, upload: Upload, url: string) => void - [EVENTS.POST_RECEIVE]: (req: Request, upload: Upload) => void - [EVENTS.POST_FINISH]: (req: Request, res: Response, upload: Upload) => void - [EVENTS.POST_TERMINATE]: (req: Request, res: Response, id: string) => void + [EVENTS.POST_CREATE]: (req: ServerRequest, upload: Upload, url: string) => void + [EVENTS.POST_RECEIVE]: (req: ServerRequest, upload: Upload) => void + [EVENTS.POST_FINISH]: (req: ServerRequest, res: Response, upload: Upload) => void + [EVENTS.POST_TERMINATE]: (req: ServerRequest, res: Response, id: string) => void } type on = EventEmitter['on'] @@ -119,19 +119,11 @@ export class Server extends EventEmitter { } get(path: string, handler: RouteHandler) { - this.handlers.GET.registerPath(this.options.path + path, handler) + this.handlers.GET.registerPath(path, handler) } async handle(req: http.IncomingMessage, res: http.ServerResponse) { - const {proto, host} = BaseHandler.extractHostAndProto( - // @ts-expect-error it's fine - new Headers(req.headers), - this.options.respectForwardedHeaders - ) - const base = `${proto}://${host}${this.options.path}` - const webReq = await getRequest({request: req, base}) - const webRes = await this.handler(webReq) - return setResponse(res, webRes) + return toNodeHandler(this.handler.bind(this))(req, res) } async handleWeb(req: Request) { @@ -142,6 +134,19 @@ export class Server extends EventEmitter { const context = this.createContext() const headers = new Headers() + // @ts-expect-error temporary until https://github.com/unjs/srvx/issues/44 is fixed + req.headers.get = (key: string) => { + for (const [k, v] of req.headers.entries()) { + if (k === key) { + if (v === '') { + return null + } + return v + } + } + return null + } + const onError = async (error: { status_code?: number body?: string @@ -164,7 +169,7 @@ export class Server extends EventEmitter { if (req.method === 'GET') { const handler = this.handlers.GET const res = await handler.send(req, context, headers).catch(onError) - context.abort + context.abort() return res } diff --git a/packages/server/src/test/Server.test.ts b/packages/server/src/test/Server.test.ts index c39bd067..42fddafa 100644 --- a/packages/server/src/test/Server.test.ts +++ b/packages/server/src/test/Server.test.ts @@ -264,75 +264,71 @@ describe('Server', () => { done() }) - it('should allow overriding the HTTP origin', async () => { + it('should allow overriding the HTTP origin', (done) => { const origin = 'vimeo.com' - const req = httpMocks.createRequest({ - headers: {origin}, - method: 'OPTIONS', - url: '/', - }) - // @ts-expect-error todo - const res = new http.ServerResponse({method: 'OPTIONS'}) - await server.handle(req, res) - assert.equal(res.hasHeader('Access-Control-Allow-Origin'), true) + request(listener) + .options('/') + .set('Origin', origin) + .expect(204) + .then((res) => { + assert.equal(res.headers['access-control-allow-origin'], origin) + done() + }) + .catch(done) }) - it('should allow overriding the HTTP origin only if match allowedOrigins', async () => { + it('should allow overriding the HTTP origin only if match allowedOrigins', (done) => { const origin = 'vimeo.com' server.options.allowedOrigins = ['vimeo.com'] - const req = httpMocks.createRequest({ - headers: {origin}, - method: 'OPTIONS', - url: '/', - }) - // @ts-expect-error todo - const res = new http.ServerResponse({method: 'OPTIONS'}) - await server.handle(req, res) - assert.equal(res.hasHeader('Access-Control-Allow-Origin'), true) - assert.equal(res.getHeader('Access-Control-Allow-Origin'), 'vimeo.com') + request(listener) + .options('/') + .set('Origin', origin) + .expect(204) + .then((res) => { + assert.equal(res.headers['access-control-allow-origin'], origin) + done() + }) + .catch(done) }) - it('should allow overriding the HTTP origin only if match allowedOrigins with multiple allowed domains', async () => { + it('should allow overriding the HTTP origin only if match allowedOrigins with multiple allowed domains', (done) => { const origin = 'vimeo.com' server.options.allowedOrigins = ['google.com', 'vimeo.com'] - const req = httpMocks.createRequest({ - headers: {origin}, - method: 'OPTIONS', - url: '/', - }) - // @ts-expect-error todo - const res = new http.ServerResponse({method: 'OPTIONS'}) - await server.handle(req, res) - assert.equal(res.hasHeader('Access-Control-Allow-Origin'), true) - assert.equal(res.getHeader('Access-Control-Allow-Origin'), 'vimeo.com') + request(listener) + .options('/') + .set('Origin', origin) + .expect(204) + .then((res) => { + assert.equal(res.headers['access-control-allow-origin'], origin) + done() + }) + .catch(done) }) - it(`should now allow overriding the HTTP origin if doesn't match allowedOrigins`, async () => { + it('should not allow overriding the HTTP origin if does not match allowedOrigins', (done) => { const origin = 'vimeo.com' server.options.allowedOrigins = ['google.com'] - const req = httpMocks.createRequest({ - headers: {origin}, - method: 'OPTIONS', - url: '/', - }) - // @ts-expect-error todo - const res = new http.ServerResponse({method: 'OPTIONS'}) - await server.handle(req, res) - assert.equal(res.hasHeader('Access-Control-Allow-Origin'), true) - assert.equal(res.getHeader('Access-Control-Allow-Origin'), 'google.com') + request(listener) + .options('/') + .set('Origin', origin) + .expect(204) + .then((res) => { + assert.equal(res.headers['access-control-allow-origin'], 'google.com') + done() + }) + .catch(done) }) - it('should return Access-Control-Allow-Origin if no origin header', async () => { + it('should return Access-Control-Allow-Origin if no origin header', (done) => { server.options.allowedOrigins = ['google.com'] - const req = httpMocks.createRequest({ - method: 'OPTIONS', - url: '/', - }) - // @ts-expect-error todo - const res = new http.ServerResponse({method: 'OPTIONS'}) - await server.handle(req, res) - assert.equal(res.hasHeader('Access-Control-Allow-Origin'), true) - assert.equal(res.getHeader('Access-Control-Allow-Origin'), 'google.com') + request(listener) + .options('/') + .expect(204) + .then((res) => { + assert.equal(res.headers['access-control-allow-origin'], 'google.com') + done() + }) + .catch(done) }) it('should not invoke handlers if onIncomingRequest throws', (done) => { @@ -427,6 +423,38 @@ describe('Server', () => { }) }) + it('should preserve custom request', (done) => { + const userData = {username: 'admin'} + const server = new Server({ + path: '/test/output', + datastore: new FileStore({directory}), + async onIncomingRequest(req) { + // @ts-expect-error fine + if (req?.node?.req?.user?.username === 'admin') { + done() + } else { + done(new Error('user data should be preserved in onIncomingRequest')) + } + }, + }) + + // Simulate Express middleware by adding user property to request + const req = httpMocks.createRequest({ + method: 'POST', + url: server.options.path, + headers: { + 'tus-resumable': TUS_RESUMABLE, + 'upload-length': '12345678', + }, + }) + + // Add custom property like Express middleware would + req.user = userData + + const res = httpMocks.createResponse({req}) + server.handle(req, res) + }) + it('should fire when a file is deleted', (done) => { server.on(EVENTS.POST_TERMINATE, (req, id) => { assert.ok(req) diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 438bd8ad..48545bc7 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,3 +1,4 @@ +import type {ServerRequest as Request} from 'srvx/types' import type {Locker, Upload} from '@tus/utils' /** diff --git a/packages/server/src/web.ts b/packages/server/src/web.ts deleted file mode 100644 index baa84f15..00000000 --- a/packages/server/src/web.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type http from 'node:http' -import {createReadStream} from 'node:fs' -import {Readable} from 'node:stream' -import * as set_cookie_parser from 'set-cookie-parser' - -function getRawBody(req: http.IncomingMessage) { - const h = req.headers - - if (!h['content-type']) { - return null - } - - const content_length = Number(h['content-length']) - - // check if no request body - if ( - (req.httpVersionMajor === 1 && - Number.isNaN(content_length) && - h['transfer-encoding'] == null) || - content_length === 0 - ) { - return null - } - - if (req.destroyed) { - const readable = new ReadableStream() - readable.cancel() - return readable - } - - let cancelled = false - - return new ReadableStream({ - start(controller) { - req.on('error', (error) => { - cancelled = true - controller.error(error) - }) - - req.on('end', () => { - if (cancelled) return - controller.close() - }) - - req.on('data', (chunk) => { - if (cancelled) return - - controller.enqueue(chunk) - - if (controller.desiredSize === null || controller.desiredSize <= 0) { - req.pause() - } - }) - }, - - pull() { - req.resume() - }, - - cancel(reason) { - cancelled = true - req.destroy(reason) - }, - }) -} - -export async function getRequest({ - request, - base, -}: {request: http.IncomingMessage; base: string}) { - let headers = request.headers - if (request.httpVersionMajor >= 2) { - // the Request constructor rejects headers with ':' in the name - headers = Object.assign({}, headers) - // https://www.rfc-editor.org/rfc/rfc9113.html#section-8.3.1-2.3.5 - if (headers[':authority']) { - headers.host = headers[':authority'] as string - } - delete headers[':authority'] - delete headers[':method'] - delete headers[':path'] - delete headers[':scheme'] - } - - return new Request(base + request.url, { - duplex: 'half', - method: request.method, - // @ts-expect-error it's fine - headers: Object.entries(headers), - body: - request.method === 'GET' || request.method === 'HEAD' - ? undefined - : getRawBody(request), - }) -} - -export async function setResponse(res: http.ServerResponse, response: Response) { - for (const [key, value] of response.headers) { - try { - res.setHeader( - key, - key === 'set-cookie' - ? set_cookie_parser.splitCookiesString(response.headers.get(key) as string) - : value - ) - } catch (error) { - for (const name of res.getHeaderNames()) { - res.removeHeader(name) - } - res.writeHead(500).end(String(error)) - return - } - } - - res.writeHead(response.status) - - if (!response.body) { - res.end() - return - } - - if (response.body.locked) { - res.end( - 'Fatal error: Response body is locked. ' + - "This can happen when the response was already read (for example through 'response.json()' or 'response.text()')." - ) - return - } - - const reader = response.body.getReader() - - if (res.destroyed) { - reader.cancel() - return - } - - const cancel = (error: Error | undefined) => { - res.off('close', cancel) - res.off('error', cancel) - - // If the reader has already been interrupted with an error earlier, - // then it will appear here, it is useless, but it needs to be catch. - reader.cancel(error).catch(() => {}) - if (error) res.destroy(error) - } - - res.on('close', cancel) - res.on('error', cancel) - - next() - async function next() { - try { - for (;;) { - const {done, value} = await reader.read() - - if (done) break - - if (!res.write(value)) { - res.once('drain', next) - return - } - } - res.end() - } catch (error) { - cancel(error instanceof Error ? error : new Error(String(error))) - } - } -} - -export function createReadableStream(file: string) { - return Readable.toWeb(createReadStream(file)) -}