Skip to content

Commit

Permalink
allow creating custom context + refactored Locks interfaces
Browse files Browse the repository at this point in the history
  • Loading branch information
fenos committed Dec 6, 2023
1 parent 941e557 commit 472ef1e
Show file tree
Hide file tree
Showing 22 changed files with 455 additions and 214 deletions.
83 changes: 31 additions & 52 deletions packages/server/src/handlers/BaseHandler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import EventEmitter from 'node:events'

import type {ServerOptions} from '../types'
import type {DataStore} from '../models'
import type {DataStore, Context} from '../models'
import type http from 'node:http'
import stream from 'node:stream'
import {ERRORS} from '../constants'
Expand All @@ -10,37 +10,14 @@ const reExtractFileID = /([^/]+)\/?$/
const reForwardedHost = /host="?([^";]+)/
const reForwardedProto = /proto=(https?)/

/**
* The CancellationContext interface provides mechanisms to manage the termination of a request.
* It is designed to handle two types of request terminations: immediate abortion and graceful cancellation.
*
* Properties:
* - signal: An instance of AbortSignal. It allows external entities to listen for cancellation requests,
* making it possible to react accordingly.
*
* Methods:
* - abort(): This function should be called to immediately terminate the request. It is intended for scenarios
* where the request cannot continue and needs to be stopped as soon as possible, such as due to upload errors
* or invalid conditions. Implementers should ensure that invoking this method leads to the swift cessation of all
* request-related operations to save resources.
*
* - cancel(): This function is used for more controlled termination of the request. It signals that the request should
* be concluded, but allows for a short period of time to finalize operations gracefully. This could involve
* completing current transactions or cleaning up resources. The exact behavior and the time allowed for cancellation
* completion are determined by the implementation, but the goal is to try to end the request without abrupt interruption,
* ensuring orderly shutdown of ongoing processes.
*/
export interface CancellationContext {
signal: AbortSignal
abort: () => void
cancel: () => void
}

export class BaseHandler extends EventEmitter {
options: ServerOptions
export class BaseHandler<
CustomContext extends Record<string, unknown> = Context,
BaseContext extends CustomContext & Context = CustomContext & Context
> extends EventEmitter {
options: ServerOptions<CustomContext>
store: DataStore

constructor(store: DataStore, options: ServerOptions) {
constructor(store: DataStore, options: ServerOptions<CustomContext>) {
super()
if (!store) {
throw new Error('Store must be defined')
Expand All @@ -61,7 +38,7 @@ export class BaseHandler extends EventEmitter {
return res.end()
}

generateUrl(req: http.IncomingMessage, id: string) {
generateUrl(req: http.IncomingMessage, id: string, context: BaseContext) {
// @ts-expect-error req.baseUrl does exist
const baseUrl = req.baseUrl ?? ''
const path = this.options.path === '/' ? '' : this.options.path
Expand All @@ -70,14 +47,18 @@ export class BaseHandler extends EventEmitter {
// 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,
})
return this.options.generateUrl(
req,
{
proto,
host,
// @ts-expect-error we can pass undefined
baseUrl: req.baseUrl,
path: path,
id,
},
context
)
}

// Default implementation
Expand All @@ -90,9 +71,9 @@ export class BaseHandler extends EventEmitter {
return `${proto}://${host}${baseUrl}${path}/${id}`
}

getFileIdFromRequest(req: http.IncomingMessage) {
getFileIdFromRequest(req: http.IncomingMessage, context: BaseContext) {
if (this.options.getFileIdFromRequest) {
return this.options.getFileIdFromRequest(req)
return this.options.getFileIdFromRequest(req, context)
}
const match = reExtractFileID.exec(req.url as string)

Expand Down Expand Up @@ -131,36 +112,34 @@ export class BaseHandler extends EventEmitter {
return {host: host as string, proto}
}

protected getLocker(req: http.IncomingMessage) {
protected getLocker(context: BaseContext) {
if (typeof this.options.locker === 'function') {
return this.options.locker(req)
return this.options.locker(context)
}
return this.options.locker
}

protected async acquireLock(
req: http.IncomingMessage,
id: string,
context: CancellationContext
) {
const locker = this.getLocker(req)
protected async acquireLock(id: string, context: BaseContext) {
const locker = await this.getLocker(context)

if (!locker) {
return
}

await locker.lock(id, () => {
const lock = locker.newLock(id, context)

await lock.lock(() => {
context.cancel()
})

return () => locker.unlock(id)
return lock
}

protected writeToStore(
req: http.IncomingMessage,
id: string,
offset: number,
context: CancellationContext
context: BaseContext
) {
return new Promise<number>(async (resolve, reject) => {
if (context.signal.aborted) {
Expand Down
22 changes: 11 additions & 11 deletions packages/server/src/handlers/DeleteHandler.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import {BaseHandler, CancellationContext} from './BaseHandler'
import {BaseHandler} from './BaseHandler'
import {ERRORS, EVENTS} from '../constants'
import {Context} from '../models'

import type http from 'node:http'

export class DeleteHandler extends BaseHandler {
async send(
req: http.IncomingMessage,
res: http.ServerResponse,
context: CancellationContext
) {
const id = this.getFileIdFromRequest(req)
export class DeleteHandler<
CustomContext extends Record<string, unknown>,
BaseContext extends CustomContext & Context = CustomContext & Context
> extends BaseHandler<CustomContext, BaseContext> {
async send(req: http.IncomingMessage, res: http.ServerResponse, context: BaseContext) {
const id = this.getFileIdFromRequest(req, context)
if (!id) {
throw ERRORS.FILE_NOT_FOUND
}

if (this.options.onIncomingRequest) {
await this.options.onIncomingRequest(req, res, id)
await this.options.onIncomingRequest(req, res, id, context)
}

const unlock = await this.acquireLock(req, id, context)
const lock = await this.acquireLock(id, context)
try {
await this.store.remove(id)
} finally {
await unlock?.()
await lock?.unlock()
}
const writtenRes = this.write(res, 204, {})
this.emit(EVENTS.POST_TERMINATE, req, writtenRes, id)
Expand Down
13 changes: 9 additions & 4 deletions packages/server/src/handlers/GetHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import {ERRORS} from '../constants'

import type http from 'node:http'
import type {RouteHandler} from '../types'
import {Context} from '../models'

export class GetHandler extends BaseHandler {
export class GetHandler<
CustomContext extends Record<string, unknown>,
BaseContext extends CustomContext & Context = CustomContext & Context
> extends BaseHandler<CustomContext, BaseContext> {
paths: Map<string, RouteHandler> = new Map()

registerPath(path: string, handler: RouteHandler): void {
Expand All @@ -18,7 +22,8 @@ export class GetHandler extends BaseHandler {
*/
async send(
req: http.IncomingMessage,
res: http.ServerResponse
res: http.ServerResponse,
context: BaseContext
// TODO: always return void or a stream?
): Promise<stream.Writable | void> {
if (this.paths.has(req.url as string)) {
Expand All @@ -30,13 +35,13 @@ export class GetHandler extends BaseHandler {
throw ERRORS.FILE_NOT_FOUND
}

const id = this.getFileIdFromRequest(req)
const id = this.getFileIdFromRequest(req, context)
if (!id) {
throw ERRORS.FILE_NOT_FOUND
}

if (this.options.onIncomingRequest) {
await this.options.onIncomingRequest(req, res, id)
await this.options.onIncomingRequest(req, res, id, context)
}

const stats = await this.store.getUpload(id)
Expand Down
23 changes: 11 additions & 12 deletions packages/server/src/handlers/HeadHandler.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
import {BaseHandler, CancellationContext} from './BaseHandler'
import {BaseHandler} from './BaseHandler'

import {ERRORS} from '../constants'
import {Metadata, Upload} from '../models'
import {Context, Metadata, Upload} from '../models'

import type http from 'node:http'

export class HeadHandler extends BaseHandler {
async send(
req: http.IncomingMessage,
res: http.ServerResponse,
context: CancellationContext
) {
const id = this.getFileIdFromRequest(req)
export class HeadHandler<
CustomContext extends Record<string, unknown>,
BaseContext extends CustomContext & Context = CustomContext & Context
> extends BaseHandler<CustomContext, BaseContext> {
async send(req: http.IncomingMessage, res: http.ServerResponse, context: BaseContext) {
const id = this.getFileIdFromRequest(req, context)
if (!id) {
throw ERRORS.FILE_NOT_FOUND
}

if (this.options.onIncomingRequest) {
await this.options.onIncomingRequest(req, res, id)
await this.options.onIncomingRequest(req, res, id, context)
}

const unlock = await this.acquireLock(req, id, context)
const lock = await this.acquireLock(id, context)

let file: Upload
try {
file = await this.store.getUpload(id)
} finally {
await unlock?.()
await lock?.unlock()
}

// If a Client does attempt to resume an upload which has since
Expand Down
6 changes: 5 additions & 1 deletion packages/server/src/handlers/OptionsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import {BaseHandler} from './BaseHandler'
import {ALLOWED_METHODS, MAX_AGE, HEADERS} from '../constants'

import type http from 'node:http'
import {Context} from '../models'

// A successful response indicated by the 204 No Content status MUST contain
// the Tus-Version header. It MAY include the Tus-Extension and Tus-Max-Size headers.
export class OptionsHandler extends BaseHandler {
export class OptionsHandler<
CustomContext extends Record<string, unknown>,
BaseContext extends CustomContext & Context = CustomContext & Context
> extends BaseHandler<CustomContext, BaseContext> {
async send(_: http.IncomingMessage, res: http.ServerResponse) {
const allowedHeaders = [...HEADERS, ...(this.options.allowedHeaders ?? [])]

Expand Down
23 changes: 11 additions & 12 deletions packages/server/src/handlers/PatchHandler.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import debug from 'debug'

import {BaseHandler, CancellationContext} from './BaseHandler'
import {BaseHandler} from './BaseHandler'
import {ERRORS, EVENTS} from '../constants'

import type http from 'node:http'
import {Upload} from '../models'
import {Context, Upload} from '../models'

const log = debug('tus-node-server:handlers:patch')

export class PatchHandler extends BaseHandler {
export class PatchHandler<
CustomContext extends Record<string, unknown>,
BaseContext extends CustomContext & Context = CustomContext & Context
> extends BaseHandler<CustomContext, BaseContext> {
/**
* Write data to the DataStore and return the new offset.
*/
async send(
req: http.IncomingMessage,
res: http.ServerResponse,
context: CancellationContext
) {
async send(req: http.IncomingMessage, res: http.ServerResponse, context: BaseContext) {
try {
const id = this.getFileIdFromRequest(req)
const id = this.getFileIdFromRequest(req, context)
if (!id) {
throw ERRORS.FILE_NOT_FOUND
}
Expand All @@ -36,7 +35,7 @@ export class PatchHandler extends BaseHandler {
throw ERRORS.INVALID_CONTENT_TYPE
}

const unlock = await this.acquireLock(req, id, context)
const lock = await this.acquireLock(id, context)

let upload: Upload
let newOffset: number
Expand Down Expand Up @@ -92,14 +91,14 @@ export class PatchHandler extends BaseHandler {

newOffset = await this.writeToStore(req, id, offset, context)
} finally {
await unlock?.()
await lock?.unlock()
}

upload.offset = newOffset
this.emit(EVENTS.POST_RECEIVE, req, res, upload)
if (newOffset === upload.size && this.options.onUploadFinish) {
try {
res = await this.options.onUploadFinish(req, res, upload)
res = await this.options.onUploadFinish(req, res, upload, context)
} catch (error) {
log(`onUploadFinish: ${error.body}`)
throw error
Expand Down
Loading

0 comments on commit 472ef1e

Please sign in to comment.