Skip to content

Commit 0f90980

Browse files
netdownMurderlon
andauthored
@tus/server: allow onUploadFinish hook to override response data (#615)
Co-authored-by: Merlijn Vos <[email protected]>
1 parent 60698da commit 0f90980

File tree

7 files changed

+150
-24
lines changed

7 files changed

+150
-24
lines changed

.changeset/small-pandas-laugh.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tus/server': minor
3+
---
4+
5+
Allow onUploadFinish hook to override response data

packages/server/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,13 @@ This can be used to implement validation of upload metadata or add headers.
157157
#### `options.onUploadFinish`
158158

159159
`onUploadFinish` will be invoked after an upload is completed but before a response is
160-
returned to the client (`(req, res, upload) => Promise<res>`).
160+
returned to the client (`(req, res, upload) => Promise<{ res: http.ServerResponse, status_code?: number, headers?: Record<string, string | number>, body?: string }>`).
161161

162-
If the function returns the (modified) response, the upload will finish. You can `throw`
163-
an Object and the HTTP request will be aborted with the provided `body` and `status_code`
164-
(or their fallbacks).
162+
- You can optionally return `status_code`, `headers` and `body` to modify the response.
163+
Note that the tus specification does not allow sending response body nor status code
164+
other than 204, but most clients support it. Use at your own risk.
165+
- You can `throw` an Object and the HTTP request will be aborted with the provided `body`
166+
and `status_code` (or their fallbacks).
165167

166168
This can be used to implement post-processing validation.
167169

packages/server/src/handlers/PatchHandler.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,22 +107,42 @@ export class PatchHandler extends BaseHandler {
107107

108108
upload.offset = newOffset
109109
this.emit(EVENTS.POST_RECEIVE, req, res, upload)
110+
111+
//Recommended response defaults
112+
const responseData = {
113+
status: 204,
114+
headers: {
115+
'Upload-Offset': newOffset,
116+
} as Record<string, string | number>,
117+
body: '',
118+
}
119+
110120
if (newOffset === upload.size && this.options.onUploadFinish) {
111121
try {
112-
res = await this.options.onUploadFinish(req, res, upload)
122+
const resOrObject = await this.options.onUploadFinish(req, res, upload)
123+
// Backwards compatibility, remove in next major
124+
// Ugly check because we can't use `instanceof` because we mock the instance in tests
125+
if (
126+
typeof (resOrObject as http.ServerResponse).write === 'function' &&
127+
typeof (resOrObject as http.ServerResponse).writeHead === 'function'
128+
) {
129+
res = resOrObject as http.ServerResponse
130+
} else {
131+
// Ugly types because TS only understands instanceof
132+
type ExcludeServerResponse<T> = T extends http.ServerResponse ? never : T
133+
const obj = resOrObject as ExcludeServerResponse<typeof resOrObject>
134+
res = obj.res
135+
if (obj.status_code) responseData.status = obj.status_code
136+
if (obj.body) responseData.body = obj.body
137+
if (obj.headers)
138+
responseData.headers = Object.assign(obj.headers, responseData.headers)
139+
}
113140
} catch (error) {
114141
log(`onUploadFinish: ${error.body}`)
115142
throw error
116143
}
117144
}
118145

119-
const headers: {
120-
'Upload-Offset': number
121-
'Upload-Expires'?: string
122-
} = {
123-
'Upload-Offset': newOffset,
124-
}
125-
126146
if (
127147
this.store.hasExtension('expiration') &&
128148
this.store.getExpiration() > 0 &&
@@ -134,11 +154,16 @@ export class PatchHandler extends BaseHandler {
134154
const dateString = new Date(
135155
creation.getTime() + this.store.getExpiration()
136156
).toUTCString()
137-
headers['Upload-Expires'] = dateString
157+
responseData.headers['Upload-Expires'] = dateString
138158
}
139159

140160
// The Server MUST acknowledge successful PATCH requests with the 204
141-
const writtenRes = this.write(res, 204, headers)
161+
const writtenRes = this.write(
162+
res,
163+
responseData.status,
164+
responseData.headers,
165+
responseData.body
166+
)
142167

143168
if (newOffset === upload.size) {
144169
this.emit(EVENTS.POST_FINISH, req, writtenRes, upload)

packages/server/src/handlers/PostHandler.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,12 @@ export class PostHandler extends BaseHandler {
127127

128128
let isFinal: boolean
129129
let url: string
130-
let headers: {
131-
'Upload-Offset'?: string
132-
'Upload-Expires'?: string
130+
131+
//Recommended response defaults
132+
const responseData = {
133+
status: 201,
134+
headers: {} as Record<string, string | number>,
135+
body: '',
133136
}
134137

135138
try {
@@ -139,14 +142,13 @@ export class PostHandler extends BaseHandler {
139142
this.emit(EVENTS.POST_CREATE, req, res, upload, url)
140143

141144
isFinal = upload.size === 0 && !upload.sizeIsDeferred
142-
headers = {}
143145

144146
// The request MIGHT include a Content-Type header when using creation-with-upload extension
145147
if (validateHeader('content-type', req.headers['content-type'])) {
146148
const bodyMaxSize = await this.calculateMaxBodySize(req, upload, maxFileSize)
147149
const newOffset = await this.writeToStore(req, upload, bodyMaxSize, context)
148150

149-
headers['Upload-Offset'] = newOffset.toString()
151+
responseData.headers['Upload-Offset'] = newOffset.toString()
150152
isFinal = newOffset === Number.parseInt(upload_length as string, 10)
151153
upload.offset = newOffset
152154
}
@@ -159,7 +161,24 @@ export class PostHandler extends BaseHandler {
159161

160162
if (isFinal && this.options.onUploadFinish) {
161163
try {
162-
res = await this.options.onUploadFinish(req, res, upload)
164+
const resOrObject = await this.options.onUploadFinish(req, res, upload)
165+
// Backwards compatibility, remove in next major
166+
// Ugly check because we can't use `instanceof` because we mock the instance in tests
167+
if (
168+
typeof (resOrObject as http.ServerResponse).write === 'function' &&
169+
typeof (resOrObject as http.ServerResponse).writeHead === 'function'
170+
) {
171+
res = resOrObject as http.ServerResponse
172+
} else {
173+
// Ugly types because TS only understands instanceof
174+
type ExcludeServerResponse<T> = T extends http.ServerResponse ? never : T
175+
const obj = resOrObject as ExcludeServerResponse<typeof resOrObject>
176+
res = obj.res
177+
if (obj.status_code) responseData.status = obj.status_code
178+
if (obj.body) responseData.body = obj.body
179+
if (obj.headers)
180+
responseData.headers = Object.assign(obj.headers, responseData.headers)
181+
}
163182
} catch (error) {
164183
log(`onUploadFinish: ${error.body}`)
165184
throw error
@@ -178,13 +197,26 @@ export class PostHandler extends BaseHandler {
178197
if (created.offset !== Number.parseInt(upload_length as string, 10)) {
179198
const creation = new Date(upload.creation_date)
180199
// Value MUST be in RFC 7231 datetime format
181-
headers['Upload-Expires'] = new Date(
200+
responseData.headers['Upload-Expires'] = new Date(
182201
creation.getTime() + this.store.getExpiration()
183202
).toUTCString()
184203
}
185204
}
186205

187-
const writtenRes = this.write(res, 201, {Location: url, ...headers})
206+
//Only append Location header if its valid for the final http status (201 or 3xx)
207+
if (
208+
responseData.status === 201 ||
209+
(responseData.status >= 300 && responseData.status < 400)
210+
) {
211+
responseData.headers['Location'] = url
212+
}
213+
214+
const writtenRes = this.write(
215+
res,
216+
responseData.status,
217+
responseData.headers,
218+
responseData.body
219+
)
188220

189221
if (isFinal) {
190222
this.emit(EVENTS.POST_FINISH, req, writtenRes, upload)

packages/server/src/types.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ export type ServerOptions = {
107107

108108
/**
109109
* `onUploadFinish` will be invoked after an upload is completed but before a response is returned to the client.
110-
* If the function returns the (modified) response, the upload will finish.
110+
* You can optionally return `status_code`, `headers` and `body` to modify the response.
111+
* Note that the tus specification does not allow sending response body nor status code other than 204, but most clients support it.
111112
* If an error is thrown, the HTTP request will be aborted, and the provided `body` and `status_code`
112113
* (or their fallbacks) will be sent to the client. This can be used to implement post-processing validation.
113114
* @param req - The incoming HTTP request.
@@ -118,7 +119,16 @@ export type ServerOptions = {
118119
req: http.IncomingMessage,
119120
res: http.ServerResponse,
120121
upload: Upload
121-
) => Promise<http.ServerResponse>
122+
) => Promise<
123+
// TODO: change in the next major
124+
| http.ServerResponse
125+
| {
126+
res: http.ServerResponse
127+
status_code?: number
128+
headers?: Record<string, string | number>
129+
body?: string
130+
}
131+
>
122132

123133
/**
124134
* `onIncomingRequest` will be invoked when an incoming request is received.

packages/server/test/PostHandler.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,24 @@ describe('PostHandler', () => {
347347
assert.equal(upload.offset, 0)
348348
assert.equal(upload.size, 0)
349349
})
350+
351+
it('does not set Location header if onUploadFinish hook returned a not eligible status code', async function () {
352+
const store = sinon.createStubInstance(DataStore)
353+
const handler = new PostHandler(store, {
354+
path: '/test/output',
355+
locker: new MemoryLocker(),
356+
onUploadFinish: async (req, res) => ({res, status_code: 200}),
357+
})
358+
359+
req.headers = {
360+
'upload-length': '0',
361+
host: 'localhost:3000',
362+
}
363+
store.create.resolvesArg(0)
364+
365+
await handler.send(req, res, context)
366+
assert.equal('location' in res._getHeaders(), false)
367+
})
350368
})
351369
})
352370
})

packages/server/test/Server.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,40 @@ describe('Server', () => {
533533
.expect(500, 'no', done)
534534
})
535535

536+
it('should allow response to be changed in onUploadFinish', (done) => {
537+
const server = new Server({
538+
path: '/test/output',
539+
datastore: new FileStore({directory}),
540+
async onUploadFinish(_, res) {
541+
return {
542+
res,
543+
status_code: 200,
544+
body: '{ fileProcessResult: 12 }',
545+
headers: {'X-TestHeader': '1'},
546+
}
547+
},
548+
})
549+
550+
request(server.listen())
551+
.post(server.options.path)
552+
.set('Tus-Resumable', TUS_RESUMABLE)
553+
.set('Upload-Length', '4')
554+
.then((res) => {
555+
request(server.listen())
556+
.patch(removeProtocol(res.headers.location))
557+
.send('test')
558+
.set('Tus-Resumable', TUS_RESUMABLE)
559+
.set('Upload-Offset', '0')
560+
.set('Content-Type', 'application/offset+octet-stream')
561+
.expect(200, '{ fileProcessResult: 12 }')
562+
.then((r) => {
563+
assert.equal(r.headers['upload-offset'], '4')
564+
assert.equal(r.headers['x-testheader'], '1')
565+
done()
566+
})
567+
})
568+
})
569+
536570
it('should fire when an upload is finished with upload-defer-length', (done) => {
537571
const length = Buffer.byteLength('test', 'utf8').toString()
538572
server.on(EVENTS.POST_FINISH, (req, res, upload) => {

0 commit comments

Comments
 (0)