@tus/server
Advanced tools
Comparing version 1.0.1 to 1.1.0
@@ -12,2 +12,10 @@ export declare const REQUEST_METHODS: readonly ["POST", "HEAD", "PATCH", "OPTIONS", "DELETE"]; | ||
}; | ||
readonly ABORTED: { | ||
readonly status_code: 400; | ||
readonly body: "Request aborted due to lock acquired"; | ||
}; | ||
readonly ERR_LOCK_TIMEOUT: { | ||
readonly status_code: 500; | ||
readonly body: "failed to acquire lock before timeout"; | ||
}; | ||
readonly INVALID_CONTENT_TYPE: { | ||
@@ -14,0 +22,0 @@ readonly status_code: 403; |
@@ -35,2 +35,10 @@ "use strict"; | ||
}, | ||
ABORTED: { | ||
status_code: 400, | ||
body: 'Request aborted due to lock acquired', | ||
}, | ||
ERR_LOCK_TIMEOUT: { | ||
status_code: 500, | ||
body: 'failed to acquire lock before timeout', | ||
}, | ||
INVALID_CONTENT_TYPE: { | ||
@@ -37,0 +45,0 @@ status_code: 403, |
@@ -5,3 +5,3 @@ /// <reference types="node" /> | ||
import type { ServerOptions } from '../types'; | ||
import type { DataStore } from '../models'; | ||
import type { DataStore, CancellationContext } from '../models'; | ||
import type http from 'node:http'; | ||
@@ -14,3 +14,10 @@ export declare class BaseHandler extends EventEmitter { | ||
generateUrl(req: http.IncomingMessage, id: string): string; | ||
getFileIdFromRequest(req: http.IncomingMessage): string | false; | ||
getFileIdFromRequest(req: http.IncomingMessage): string | void; | ||
protected extractHostAndProto(req: http.IncomingMessage): { | ||
host: string; | ||
proto: string; | ||
}; | ||
protected getLocker(req: http.IncomingMessage): Promise<import("../models").Locker>; | ||
protected acquireLock(req: http.IncomingMessage, id: string, context: CancellationContext): Promise<import("../models").Lock>; | ||
protected writeToStore(req: http.IncomingMessage, id: string, offset: number, context: CancellationContext): Promise<number>; | ||
} |
@@ -8,2 +8,4 @@ "use strict"; | ||
const node_events_1 = __importDefault(require("node:events")); | ||
const node_stream_1 = __importDefault(require("node:stream")); | ||
const constants_1 = require("../constants"); | ||
const reExtractFileID = /([^/]+)\/?$/; | ||
@@ -31,13 +33,39 @@ const reForwardedHost = /host="?([^";]+)/; | ||
generateUrl(req, id) { | ||
id = encodeURIComponent(id); | ||
const forwarded = req.headers.forwarded; | ||
// @ts-expect-error req.baseUrl does exist | ||
const baseUrl = req.baseUrl ?? ''; | ||
const path = this.options.path === '/' ? '' : this.options.path; | ||
// @ts-expect-error baseUrl type doesn't exist? | ||
const baseUrl = req.baseUrl ?? ''; | ||
let proto; | ||
let host; | ||
if (this.options.generateUrl) { | ||
// user-defined generateUrl function | ||
const { proto, host } = this.extractHostAndProto(req); | ||
return this.options.generateUrl(req, { | ||
proto, | ||
host, | ||
// @ts-expect-error we can pass undefined | ||
baseUrl: req.baseUrl, | ||
path: path, | ||
id, | ||
}); | ||
} | ||
// Default implementation | ||
if (this.options.relativeLocation) { | ||
return `${baseUrl}${path}/${id}`; | ||
} | ||
const { proto, host } = this.extractHostAndProto(req); | ||
return `${proto}://${host}${baseUrl}${path}/${id}`; | ||
} | ||
getFileIdFromRequest(req) { | ||
if (this.options.getFileIdFromRequest) { | ||
return this.options.getFileIdFromRequest(req); | ||
} | ||
const match = reExtractFileID.exec(req.url); | ||
if (!match || this.options.path.includes(match[1])) { | ||
return; | ||
} | ||
return decodeURIComponent(match[1]); | ||
} | ||
extractHostAndProto(req) { | ||
let proto; | ||
let host; | ||
if (this.options.respectForwardedHeaders) { | ||
const forwarded = req.headers.forwarded; | ||
if (forwarded) { | ||
@@ -57,12 +85,44 @@ host ?? (host = reForwardedHost.exec(forwarded)?.[1]); | ||
proto ?? (proto = 'http'); | ||
return `${proto}://${host}${baseUrl}${path}/${id}`; | ||
return { host: host, proto }; | ||
} | ||
getFileIdFromRequest(req) { | ||
const match = reExtractFileID.exec(req.url); | ||
if (!match || this.options.path.includes(match[1])) { | ||
return false; | ||
async getLocker(req) { | ||
if (typeof this.options.locker === 'function') { | ||
return this.options.locker(req); | ||
} | ||
return decodeURIComponent(match[1]); | ||
return this.options.locker; | ||
} | ||
async acquireLock(req, id, context) { | ||
const locker = await this.getLocker(req); | ||
const lock = locker.newLock(id); | ||
await lock.lock(() => { | ||
context.cancel(); | ||
}); | ||
return lock; | ||
} | ||
writeToStore(req, id, offset, context) { | ||
return new Promise(async (resolve, reject) => { | ||
if (context.signal.aborted) { | ||
reject(constants_1.ERRORS.ABORTED); | ||
return; | ||
} | ||
const proxy = new node_stream_1.default.PassThrough(); | ||
node_stream_1.default.addAbortSignal(context.signal, proxy); | ||
proxy.on('error', (err) => { | ||
req.unpipe(proxy); | ||
if (err.name === 'AbortError') { | ||
reject(constants_1.ERRORS.ABORTED); | ||
} | ||
else { | ||
reject(err); | ||
} | ||
}); | ||
req.on('error', (err) => { | ||
if (!proxy.closed) { | ||
proxy.destroy(err); | ||
} | ||
}); | ||
this.store.write(req.pipe(proxy), id, offset).then(resolve).catch(reject); | ||
}); | ||
} | ||
} | ||
exports.BaseHandler = BaseHandler; |
/// <reference types="node" /> | ||
import { BaseHandler } from './BaseHandler'; | ||
import { CancellationContext } from '../models'; | ||
import type http from 'node:http'; | ||
export declare class DeleteHandler extends BaseHandler { | ||
send(req: http.IncomingMessage, res: http.ServerResponse): Promise<http.ServerResponse<http.IncomingMessage>>; | ||
send(req: http.IncomingMessage, res: http.ServerResponse, context: CancellationContext): Promise<http.ServerResponse<http.IncomingMessage>>; | ||
} |
@@ -7,8 +7,17 @@ "use strict"; | ||
class DeleteHandler extends BaseHandler_1.BaseHandler { | ||
async send(req, res) { | ||
async send(req, res, context) { | ||
const id = this.getFileIdFromRequest(req); | ||
if (id === false) { | ||
if (!id) { | ||
throw constants_1.ERRORS.FILE_NOT_FOUND; | ||
} | ||
await this.store.remove(id); | ||
if (this.options.onIncomingRequest) { | ||
await this.options.onIncomingRequest(req, res, id); | ||
} | ||
const lock = await this.acquireLock(req, id, context); | ||
try { | ||
await this.store.remove(id); | ||
} | ||
finally { | ||
await lock.unlock(); | ||
} | ||
const writtenRes = this.write(res, 204, {}); | ||
@@ -15,0 +24,0 @@ this.emit(constants_1.EVENTS.POST_TERMINATE, req, writtenRes, id); |
@@ -32,5 +32,8 @@ "use strict"; | ||
const id = this.getFileIdFromRequest(req); | ||
if (id === false) { | ||
if (!id) { | ||
throw constants_1.ERRORS.FILE_NOT_FOUND; | ||
} | ||
if (this.options.onIncomingRequest) { | ||
await this.options.onIncomingRequest(req, res, id); | ||
} | ||
const stats = await this.store.getUpload(id); | ||
@@ -37,0 +40,0 @@ if (!stats || stats.offset !== stats.size) { |
/// <reference types="node" /> | ||
import { BaseHandler } from './BaseHandler'; | ||
import { CancellationContext } from '../models'; | ||
import type http from 'node:http'; | ||
export declare class HeadHandler extends BaseHandler { | ||
send(req: http.IncomingMessage, res: http.ServerResponse): Promise<http.ServerResponse<http.IncomingMessage>>; | ||
send(req: http.IncomingMessage, res: http.ServerResponse, context: CancellationContext): Promise<http.ServerResponse<http.IncomingMessage>>; | ||
} |
@@ -8,8 +8,18 @@ "use strict"; | ||
class HeadHandler extends BaseHandler_1.BaseHandler { | ||
async send(req, res) { | ||
async send(req, res, context) { | ||
const id = this.getFileIdFromRequest(req); | ||
if (id === false) { | ||
if (!id) { | ||
throw constants_1.ERRORS.FILE_NOT_FOUND; | ||
} | ||
const file = await this.store.getUpload(id); | ||
if (this.options.onIncomingRequest) { | ||
await this.options.onIncomingRequest(req, res, id); | ||
} | ||
const lock = await this.acquireLock(req, id, context); | ||
let file; | ||
try { | ||
file = await this.store.getUpload(id); | ||
} | ||
finally { | ||
await lock.unlock(); | ||
} | ||
// If a Client does attempt to resume an upload which has since | ||
@@ -16,0 +26,0 @@ // been removed by the Server, the Server SHOULD respond with the |
@@ -10,4 +10,5 @@ "use strict"; | ||
async send(_, res) { | ||
const allowedHeaders = [...constants_1.HEADERS, ...(this.options.allowedHeaders ?? [])]; | ||
res.setHeader('Access-Control-Allow-Methods', constants_1.ALLOWED_METHODS); | ||
res.setHeader('Access-Control-Allow-Headers', constants_1.ALLOWED_HEADERS); | ||
res.setHeader('Access-Control-Allow-Headers', allowedHeaders.join(', ')); | ||
res.setHeader('Access-Control-Max-Age', constants_1.MAX_AGE); | ||
@@ -14,0 +15,0 @@ if (this.store.extensions.length > 0) { |
/// <reference types="node" /> | ||
import { BaseHandler } from './BaseHandler'; | ||
import type http from 'node:http'; | ||
import { CancellationContext } from '../models'; | ||
export declare class PatchHandler extends BaseHandler { | ||
@@ -8,3 +9,3 @@ /** | ||
*/ | ||
send(req: http.IncomingMessage, res: http.ServerResponse): Promise<http.ServerResponse<http.IncomingMessage>>; | ||
send(req: http.IncomingMessage, res: http.ServerResponse, context: CancellationContext): Promise<http.ServerResponse<http.IncomingMessage>>; | ||
} |
@@ -15,85 +15,101 @@ "use strict"; | ||
*/ | ||
async send(req, res) { | ||
const id = this.getFileIdFromRequest(req); | ||
if (id === false) { | ||
throw constants_1.ERRORS.FILE_NOT_FOUND; | ||
} | ||
// The request MUST include a Upload-Offset header | ||
if (req.headers['upload-offset'] === undefined) { | ||
throw constants_1.ERRORS.MISSING_OFFSET; | ||
} | ||
const offset = Number.parseInt(req.headers['upload-offset'], 10); | ||
// The request MUST include a Content-Type header | ||
const content_type = req.headers['content-type']; | ||
if (content_type === undefined) { | ||
throw constants_1.ERRORS.INVALID_CONTENT_TYPE; | ||
} | ||
const upload = await this.store.getUpload(id); | ||
// If a Client does attempt to resume an upload which has since | ||
// been removed by the Server, the Server SHOULD respond with the | ||
// with the 404 Not Found or 410 Gone status. The latter one SHOULD | ||
// be used if the Server is keeping track of expired uploads. | ||
const now = Date.now(); | ||
const creation = upload.creation_date ? new Date(upload.creation_date).getTime() : now; | ||
const expiration = creation + this.store.getExpiration(); | ||
if (this.store.hasExtension('expiration') && | ||
this.store.getExpiration() > 0 && | ||
now > expiration) { | ||
throw constants_1.ERRORS.FILE_NO_LONGER_EXISTS; | ||
} | ||
if (upload.offset !== offset) { | ||
// If the offsets do not match, the Server MUST respond with the 409 Conflict status without modifying the upload resource. | ||
log(`[PatchHandler] send: Incorrect offset - ${offset} sent but file is ${upload.offset}`); | ||
throw constants_1.ERRORS.INVALID_OFFSET; | ||
} | ||
// The request MUST validate upload-length related headers | ||
const upload_length = req.headers['upload-length']; | ||
if (upload_length !== undefined) { | ||
const size = Number.parseInt(upload_length, 10); | ||
// Throw error if extension is not supported | ||
if (!this.store.hasExtension('creation-defer-length')) { | ||
throw constants_1.ERRORS.UNSUPPORTED_CREATION_DEFER_LENGTH_EXTENSION; | ||
async send(req, res, context) { | ||
try { | ||
const id = this.getFileIdFromRequest(req); | ||
if (!id) { | ||
throw constants_1.ERRORS.FILE_NOT_FOUND; | ||
} | ||
// Throw error if upload-length is already set. | ||
if (upload.size !== undefined) { | ||
throw constants_1.ERRORS.INVALID_LENGTH; | ||
// The request MUST include a Upload-Offset header | ||
if (req.headers['upload-offset'] === undefined) { | ||
throw constants_1.ERRORS.MISSING_OFFSET; | ||
} | ||
if (size < upload.offset) { | ||
throw constants_1.ERRORS.INVALID_LENGTH; | ||
const offset = Number.parseInt(req.headers['upload-offset'], 10); | ||
// The request MUST include a Content-Type header | ||
const content_type = req.headers['content-type']; | ||
if (content_type === undefined) { | ||
throw constants_1.ERRORS.INVALID_CONTENT_TYPE; | ||
} | ||
await this.store.declareUploadLength(id, size); | ||
upload.size = size; | ||
} | ||
const newOffset = await this.store.write(req, id, offset); | ||
upload.offset = newOffset; | ||
this.emit(constants_1.EVENTS.POST_RECEIVE, req, res, upload); | ||
if (newOffset === upload.size && this.options.onUploadFinish) { | ||
const lock = await this.acquireLock(req, id, context); | ||
let upload; | ||
let newOffset; | ||
try { | ||
res = await this.options.onUploadFinish(req, res, upload); | ||
upload = await this.store.getUpload(id); | ||
// If a Client does attempt to resume an upload which has since | ||
// been removed by the Server, the Server SHOULD respond with the | ||
// with the 404 Not Found or 410 Gone status. The latter one SHOULD | ||
// be used if the Server is keeping track of expired uploads. | ||
const now = Date.now(); | ||
const creation = upload.creation_date | ||
? new Date(upload.creation_date).getTime() | ||
: now; | ||
const expiration = creation + this.store.getExpiration(); | ||
if (this.store.hasExtension('expiration') && | ||
this.store.getExpiration() > 0 && | ||
now > expiration) { | ||
throw constants_1.ERRORS.FILE_NO_LONGER_EXISTS; | ||
} | ||
if (upload.offset !== offset) { | ||
// If the offsets do not match, the Server MUST respond with the 409 Conflict status without modifying the upload resource. | ||
log(`[PatchHandler] send: Incorrect offset - ${offset} sent but file is ${upload.offset}`); | ||
throw constants_1.ERRORS.INVALID_OFFSET; | ||
} | ||
// The request MUST validate upload-length related headers | ||
const upload_length = req.headers['upload-length']; | ||
if (upload_length !== undefined) { | ||
const size = Number.parseInt(upload_length, 10); | ||
// Throw error if extension is not supported | ||
if (!this.store.hasExtension('creation-defer-length')) { | ||
throw constants_1.ERRORS.UNSUPPORTED_CREATION_DEFER_LENGTH_EXTENSION; | ||
} | ||
// Throw error if upload-length is already set. | ||
if (upload.size !== undefined) { | ||
throw constants_1.ERRORS.INVALID_LENGTH; | ||
} | ||
if (size < upload.offset) { | ||
throw constants_1.ERRORS.INVALID_LENGTH; | ||
} | ||
await this.store.declareUploadLength(id, size); | ||
upload.size = size; | ||
} | ||
newOffset = await this.writeToStore(req, id, offset, context); | ||
} | ||
catch (error) { | ||
log(`onUploadFinish: ${error.body}`); | ||
throw error; | ||
finally { | ||
await lock.unlock(); | ||
} | ||
upload.offset = newOffset; | ||
this.emit(constants_1.EVENTS.POST_RECEIVE, req, res, upload); | ||
if (newOffset === upload.size && this.options.onUploadFinish) { | ||
try { | ||
res = await this.options.onUploadFinish(req, res, upload); | ||
} | ||
catch (error) { | ||
log(`onUploadFinish: ${error.body}`); | ||
throw error; | ||
} | ||
} | ||
const headers = { | ||
'Upload-Offset': newOffset, | ||
}; | ||
if (this.store.hasExtension('expiration') && | ||
this.store.getExpiration() > 0 && | ||
upload.creation_date && | ||
(upload.size === undefined || newOffset < upload.size)) { | ||
const creation = new Date(upload.creation_date); | ||
// Value MUST be in RFC 7231 datetime format | ||
const dateString = new Date(creation.getTime() + this.store.getExpiration()).toUTCString(); | ||
headers['Upload-Expires'] = dateString; | ||
} | ||
// The Server MUST acknowledge successful PATCH requests with the 204 | ||
const writtenRes = this.write(res, 204, headers); | ||
if (newOffset === upload.size) { | ||
this.emit(constants_1.EVENTS.POST_FINISH, req, writtenRes, upload); | ||
} | ||
return writtenRes; | ||
} | ||
const headers = { | ||
'Upload-Offset': newOffset, | ||
}; | ||
if (this.store.hasExtension('expiration') && | ||
this.store.getExpiration() > 0 && | ||
upload.creation_date && | ||
(upload.size === undefined || newOffset < upload.size)) { | ||
const creation = new Date(upload.creation_date); | ||
// Value MUST be in RFC 7231 datetime format | ||
const dateString = new Date(creation.getTime() + this.store.getExpiration()).toUTCString(); | ||
headers['Upload-Expires'] = dateString; | ||
catch (e) { | ||
context.abort(); | ||
throw e; | ||
} | ||
// The Server MUST acknowledge successful PATCH requests with the 204 | ||
const writtenRes = this.write(res, 204, headers); | ||
if (newOffset === upload.size) { | ||
this.emit(constants_1.EVENTS.POST_FINISH, req, writtenRes, upload); | ||
} | ||
return writtenRes; | ||
} | ||
} | ||
exports.PatchHandler = PatchHandler; |
/// <reference types="node" /> | ||
import { BaseHandler } from './BaseHandler'; | ||
import type http from 'node:http'; | ||
import type { ServerOptions } from '../types'; | ||
import type { DataStore } from '../models'; | ||
import type { ServerOptions, WithRequired } from '../types'; | ||
import { DataStore, CancellationContext } from '../models'; | ||
export declare class PostHandler extends BaseHandler { | ||
options: Required<Pick<ServerOptions, 'namingFunction'>> & Omit<ServerOptions, 'namingFunction'>; | ||
options: WithRequired<ServerOptions, 'namingFunction'>; | ||
constructor(store: DataStore, options: ServerOptions); | ||
@@ -12,3 +12,3 @@ /** | ||
*/ | ||
send(req: http.IncomingMessage, res: http.ServerResponse): Promise<http.ServerResponse<http.IncomingMessage>>; | ||
send(req: http.IncomingMessage, res: http.ServerResponse, context: CancellationContext): Promise<http.ServerResponse<http.IncomingMessage>>; | ||
} |
@@ -26,3 +26,3 @@ "use strict"; | ||
*/ | ||
async send(req, res) { | ||
async send(req, res, context) { | ||
if ('upload-concat' in req.headers && !this.store.hasExtension('concatentation')) { | ||
@@ -58,2 +58,5 @@ throw constants_1.ERRORS.UNSUPPORTED_CONCATENATION_EXTENSION; | ||
} | ||
if (this.options.onIncomingRequest) { | ||
await this.options.onIncomingRequest(req, res, id); | ||
} | ||
const upload = new models_1.Upload({ | ||
@@ -74,15 +77,27 @@ id, | ||
} | ||
await this.store.create(upload); | ||
const url = this.generateUrl(req, upload.id); | ||
this.emit(constants_1.EVENTS.POST_CREATE, req, res, upload, url); | ||
let newOffset; | ||
let isFinal = upload.size === 0 && !upload.sizeIsDeferred; | ||
const headers = {}; | ||
// The request MIGHT include a Content-Type header when using creation-with-upload extension | ||
if ((0, HeaderValidator_1.validateHeader)('content-type', req.headers['content-type'])) { | ||
newOffset = await this.store.write(req, upload.id, 0); | ||
headers['Upload-Offset'] = newOffset.toString(); | ||
isFinal = newOffset === Number.parseInt(upload_length, 10); | ||
upload.offset = newOffset; | ||
const lock = await this.acquireLock(req, id, context); | ||
let isFinal; | ||
let url; | ||
let headers; | ||
try { | ||
await this.store.create(upload); | ||
url = this.generateUrl(req, upload.id); | ||
this.emit(constants_1.EVENTS.POST_CREATE, req, res, upload, url); | ||
isFinal = upload.size === 0 && !upload.sizeIsDeferred; | ||
headers = {}; | ||
// The request MIGHT include a Content-Type header when using creation-with-upload extension | ||
if ((0, HeaderValidator_1.validateHeader)('content-type', req.headers['content-type'])) { | ||
const newOffset = await this.writeToStore(req, id, 0, context); | ||
headers['Upload-Offset'] = newOffset.toString(); | ||
isFinal = newOffset === Number.parseInt(upload_length, 10); | ||
upload.offset = newOffset; | ||
} | ||
} | ||
catch (e) { | ||
context.abort(); | ||
throw e; | ||
} | ||
finally { | ||
await lock.unlock(); | ||
} | ||
if (isFinal && this.options.onUploadFinish) { | ||
@@ -89,0 +104,0 @@ try { |
export { Server } from './server'; | ||
export * from './types'; | ||
export * from './models'; | ||
export * from './lockers'; | ||
export * from './constants'; |
@@ -22,2 +22,3 @@ "use strict"; | ||
__exportStar(require("./models"), exports); | ||
__exportStar(require("./lockers"), exports); | ||
__exportStar(require("./constants"), exports); |
@@ -6,1 +6,3 @@ export { DataStore } from './DataStore'; | ||
export { Upload } from './Upload'; | ||
export { Locker, Lock, RequestRelease } from './Locker'; | ||
export { CancellationContext } from './Context'; |
/// <reference types="node" /> | ||
/// <reference types="node" /> | ||
/// <reference types="node" /> | ||
/// <reference types="node" /> | ||
import http from 'node:http'; | ||
@@ -14,4 +15,4 @@ import { EventEmitter } from 'node:events'; | ||
import type stream from 'node:stream'; | ||
import type { ServerOptions, RouteHandler } from './types'; | ||
import type { DataStore, Upload } from './models'; | ||
import type { ServerOptions, RouteHandler, WithOptional } from './types'; | ||
import type { DataStore, Upload, CancellationContext } from './models'; | ||
type Handlers = { | ||
@@ -43,3 +44,3 @@ GET: InstanceType<typeof GetHandler>; | ||
options: ServerOptions; | ||
constructor(options: ServerOptions & { | ||
constructor(options: WithOptional<ServerOptions, 'locker'> & { | ||
datastore: DataStore; | ||
@@ -52,6 +53,11 @@ }); | ||
handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<http.ServerResponse | stream.Writable | void>; | ||
write(res: http.ServerResponse, status: number, body?: string, headers?: {}): http.ServerResponse<http.IncomingMessage>; | ||
write(context: CancellationContext, req: http.IncomingMessage, res: http.ServerResponse, status: number, body?: string, headers?: {}): http.ServerResponse<http.IncomingMessage>; | ||
listen(...args: any[]): http.Server; | ||
cleanUpExpiredUploads(): Promise<number>; | ||
protected createContext(req: http.IncomingMessage): { | ||
signal: AbortSignal; | ||
abort: () => void; | ||
cancel: () => void; | ||
}; | ||
} | ||
export {}; |
@@ -18,2 +18,3 @@ "use strict"; | ||
const constants_1 = require("./constants"); | ||
const lockers_1 = require("./lockers"); | ||
const log = (0, debug_1.default)('tus-node-server'); | ||
@@ -33,2 +34,5 @@ // eslint-disable-next-line no-redeclare | ||
} | ||
if (!options.locker) { | ||
options.locker = new lockers_1.MemoryLocker(); | ||
} | ||
const { datastore, ...rest } = options; | ||
@@ -48,3 +52,3 @@ this.options = rest; | ||
// Any handlers assigned to this object with the method as the key | ||
// will be used to repond to those requests. They get set/re-set | ||
// will be used to respond to those requests. They get set/re-set | ||
// when a datastore is assigned to the server. | ||
@@ -78,2 +82,3 @@ // Remove any event listeners from each handler as they are removed | ||
) { | ||
const context = this.createContext(req); | ||
log(`[TusServer] handle: ${req.method} ${req.url}`); | ||
@@ -86,13 +91,14 @@ // Allow overriding the HTTP method. The reason for this is | ||
} | ||
const onError = (error) => { | ||
const status_code = error.status_code || constants_1.ERRORS.UNKNOWN_ERROR.status_code; | ||
const body = error.body || `${constants_1.ERRORS.UNKNOWN_ERROR.body}${error.message || ''}\n`; | ||
return this.write(res, status_code, body); | ||
const onError = async (error) => { | ||
let status_code = error.status_code || constants_1.ERRORS.UNKNOWN_ERROR.status_code; | ||
let body = error.body || `${constants_1.ERRORS.UNKNOWN_ERROR.body}${error.message || ''}\n`; | ||
if (this.options.onResponseError) { | ||
const errorMapping = await this.options.onResponseError(req, res, error); | ||
if (errorMapping) { | ||
status_code = errorMapping.status_code; | ||
body = errorMapping.body; | ||
} | ||
} | ||
return this.write(context, req, res, status_code, body); | ||
}; | ||
try { | ||
await this.options.onIncomingRequest?.(req, res); | ||
} | ||
catch (err) { | ||
return onError(err); | ||
} | ||
if (req.method === 'GET') { | ||
@@ -107,3 +113,3 @@ const handler = this.handlers.GET; | ||
if (req.method !== 'OPTIONS' && req.headers['tus-resumable'] === undefined) { | ||
return this.write(res, 412, 'Tus-Resumable Required\n'); | ||
return this.write(context, req, res, 412, 'Tus-Resumable Required\n'); | ||
} | ||
@@ -130,3 +136,3 @@ // Validate all required headers to adhere to the tus protocol | ||
if (invalid_headers.length > 0) { | ||
return this.write(res, 400, `Invalid ${invalid_headers.join(' ')}\n`); | ||
return this.write(context, req, res, 400, `Invalid ${invalid_headers.join(' ')}\n`); | ||
} | ||
@@ -141,7 +147,8 @@ // Enable CORS | ||
if (handler) { | ||
return handler.send(req, res).catch(onError); | ||
return handler.send(req, res, context).catch(onError); | ||
} | ||
return this.write(res, 404, 'Not found\n'); | ||
return this.write(context, req, res, 404, 'Not found\n'); | ||
} | ||
write(res, status, body = '', headers = {}) { | ||
write(context, req, res, status, body = '', headers = {}) { | ||
const isAborted = context.signal.aborted; | ||
if (status !== 204) { | ||
@@ -151,2 +158,19 @@ // @ts-expect-error not explicitly typed but possible | ||
} | ||
if (isAborted) { | ||
// This condition handles situations where the request has been flagged as aborted. | ||
// In such cases, the server informs the client that the connection will be closed. | ||
// This is communicated by setting the 'Connection' header to 'close' in the response. | ||
// This step is essential to prevent the server from continuing to process a request | ||
// that is no longer needed, thereby saving resources. | ||
// @ts-expect-error not explicitly typed but possible | ||
headers['Connection'] = 'close'; | ||
// An event listener is added to the response ('res') for the 'finish' event. | ||
// The 'finish' event is triggered when the response has been sent to the client. | ||
// Once the response is complete, the request ('req') object is destroyed. | ||
// Destroying the request object is a crucial step to release any resources | ||
// tied to this request, as it has already been aborted. | ||
res.on('finish', () => { | ||
req.destroy(); | ||
}); | ||
} | ||
res.writeHead(status, headers); | ||
@@ -166,3 +190,36 @@ res.write(body); | ||
} | ||
createContext(req) { | ||
// Initialize two AbortControllers: | ||
// 1. `requestAbortController` for instant request termination, particularly useful for stopping clients to upload when errors occur. | ||
// 2. `abortWithDelayController` to introduce a delay before aborting, allowing the server time to complete ongoing operations. | ||
// This is particularly useful when a future request may need to acquire a lock currently held by this request. | ||
const requestAbortController = new AbortController(); | ||
const abortWithDelayController = new AbortController(); | ||
const onDelayedAbort = (err) => { | ||
abortWithDelayController.signal.removeEventListener('abort', onDelayedAbort); | ||
setTimeout(() => { | ||
requestAbortController.abort(err); | ||
}, 3000); | ||
}; | ||
abortWithDelayController.signal.addEventListener('abort', onDelayedAbort); | ||
req.on('close', () => { | ||
abortWithDelayController.signal.removeEventListener('abort', onDelayedAbort); | ||
}); | ||
return { | ||
signal: requestAbortController.signal, | ||
abort: () => { | ||
// abort the request immediately | ||
if (!requestAbortController.signal.aborted) { | ||
requestAbortController.abort(constants_1.ERRORS.ABORTED); | ||
} | ||
}, | ||
cancel: () => { | ||
// Initiates the delayed abort sequence unless it's already in progress. | ||
if (!abortWithDelayController.signal.aborted) { | ||
abortWithDelayController.abort(constants_1.ERRORS.ABORTED); | ||
} | ||
}, | ||
}; | ||
} | ||
} | ||
exports.Server = Server; |
/// <reference types="node" /> | ||
import type http from 'node:http'; | ||
import type { Upload } from './models'; | ||
import type { Locker, Upload } from './models'; | ||
/** | ||
* Represents the configuration options for a server. | ||
*/ | ||
export type ServerOptions = { | ||
/** | ||
* The route to accept requests. | ||
*/ | ||
path: string; | ||
/** | ||
* Return a relative URL as the `Location` header. | ||
*/ | ||
relativeLocation?: boolean; | ||
/** | ||
* Allow `Forwarded`, `X-Forwarded-Proto`, and `X-Forwarded-Host` headers | ||
* to override the `Location` header returned by the server. | ||
*/ | ||
respectForwardedHeaders?: boolean; | ||
/** | ||
* Additional headers sent in `Access-Control-Allow-Headers`. | ||
*/ | ||
allowedHeaders?: string[]; | ||
/** | ||
* Control how the upload URL is generated. | ||
* @param req - The incoming HTTP request. | ||
* @param options - Options for generating the URL. | ||
*/ | ||
generateUrl?: (req: http.IncomingMessage, options: { | ||
proto: string; | ||
host: string; | ||
baseUrl: string; | ||
path: string; | ||
id: string; | ||
}) => string; | ||
/** | ||
* Control how the Upload-ID is extracted from the request. | ||
* @param req - The incoming HTTP request. | ||
*/ | ||
getFileIdFromRequest?: (req: http.IncomingMessage) => string | void; | ||
/** | ||
* Control how you want to name files. | ||
* It is important to make these unique to prevent data loss. | ||
* Only use it if you really need to. | ||
* Default uses `crypto.randomBytes(16).toString('hex')`. | ||
* @param req - The incoming HTTP request. | ||
*/ | ||
namingFunction?: (req: http.IncomingMessage) => string; | ||
/** | ||
* The Lock interface defines methods for implementing a locking mechanism. | ||
* It is primarily used to ensure exclusive access to resources, such as uploads and their metadata. | ||
*/ | ||
locker: Locker | Promise<Locker> | ((req: http.IncomingMessage) => Locker | Promise<Locker>); | ||
/** | ||
* `onUploadCreate` will be invoked before a new upload is created. | ||
* If the function returns the (modified) response, the upload will be created. | ||
* If an error is thrown, the HTTP request will be aborted, and the provided `body` and `status_code` | ||
* (or their fallbacks) will be sent to the client. This can be used to implement validation of upload | ||
* metadata or add headers. | ||
* @param req - The incoming HTTP request. | ||
* @param res - The HTTP response. | ||
* @param upload - The Upload object. | ||
*/ | ||
onUploadCreate?: (req: http.IncomingMessage, res: http.ServerResponse, upload: Upload) => Promise<http.ServerResponse>; | ||
/** | ||
* `onUploadFinish` will be invoked after an upload is completed but before a response is returned to the client. | ||
* If the function returns the (modified) response, the upload will finish. | ||
* If an error is thrown, the HTTP request will be aborted, and the provided `body` and `status_code` | ||
* (or their fallbacks) will be sent to the client. This can be used to implement post-processing validation. | ||
* @param req - The incoming HTTP request. | ||
* @param res - The HTTP response. | ||
* @param upload - The Upload object. | ||
*/ | ||
onUploadFinish?: (req: http.IncomingMessage, res: http.ServerResponse, upload: Upload) => Promise<http.ServerResponse>; | ||
onIncomingRequest?: (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void>; | ||
/** | ||
* `onIncomingRequest` will be invoked when an incoming request is received. | ||
* @param req - The incoming HTTP request. | ||
* @param res - The HTTP response. | ||
* @param uploadId - The ID of the upload. | ||
*/ | ||
onIncomingRequest?: (req: http.IncomingMessage, res: http.ServerResponse, uploadId: string) => Promise<void>; | ||
/** | ||
* `onResponseError` will be invoked when an error response is about to be sent by the server. | ||
* Use this function to map custom errors to tus errors or for custom observability. | ||
* @param req - The incoming HTTP request. | ||
* @param res - The HTTP response. | ||
* @param err - The error object or response. | ||
*/ | ||
onResponseError?: (req: http.IncomingMessage, res: http.ServerResponse, err: Error | { | ||
status_code: number; | ||
body: string; | ||
}) => Promise<{ | ||
status_code: number; | ||
body: string; | ||
} | void> | { | ||
status_code: number; | ||
body: string; | ||
} | void; | ||
}; | ||
export type RouteHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void; | ||
export type WithOptional<T, K extends keyof T> = Omit<T, K> & { | ||
[P in K]+?: T[P]; | ||
}; | ||
export type WithRequired<T, K extends keyof T> = T & { | ||
[P in K]-?: T[P]; | ||
}; |
{ | ||
"$schema": "https://json.schemastore.org/package.json", | ||
"name": "@tus/server", | ||
"version": "1.0.1", | ||
"version": "1.1.0", | ||
"description": "Tus resumable upload protocol in Node.js", | ||
@@ -6,0 +6,0 @@ "main": "dist/index.js", |
@@ -71,2 +71,12 @@ # `@tus/server` | ||
#### `options.allowedHeaders` | ||
Additional headers sent in `Access-Control-Allow-Headers` (`string[]`). | ||
#### `options.generateUrl` | ||
Control how the upload url is generated (`(req, { proto, host, baseUrl, path, id }) => string)`) | ||
#### `options.getFileIdFromRequest` | ||
Control how the Upload-ID is extracted from the request (`(req) => string | void`) | ||
#### `options.namingFunction` | ||
@@ -104,2 +114,7 @@ | ||
#### `options.onResponseError` | ||
`onResponseError` will be invoked when an error response is about to be sent by the server. | ||
you use this function to map custom errors to tus errors or for custom observability. (`(req, res, err) => Promise<{status_code: number; body: string} | void> | {status_code: number; body: string} | void`) | ||
#### `server.handle(req, res)` | ||
@@ -198,2 +213,3 @@ | ||
const server = new Server({ | ||
path: '/uploads', | ||
datastore: new FileStore({directory: '/files'}), | ||
@@ -200,0 +216,0 @@ }) |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
94939
49
1945
402