Skip to content

Commit 7873d93

Browse files
fenosMurderlon
andauthored
@tus/server: add support for lockers (#514)
Co-authored-by: Merlijn Vos <[email protected]>
1 parent f1a4ac3 commit 7873d93

32 files changed

+987
-256
lines changed

.yarnrc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ plugins:
77
spec: "@yarnpkg/plugin-typescript"
88
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
99
spec: "@yarnpkg/plugin-interactive-tools"
10+
11+
yarnPath: .yarn/releases/yarn-3.2.3.cjs

packages/file-store/configstores/FileConfigstore.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import fs from 'node:fs/promises'
22
import path from 'node:path'
33
import {Upload} from '@tus/server'
4-
import PQueue from 'p-queue'
54

65
import {Configstore} from './Types'
76

@@ -11,40 +10,36 @@ import {Configstore} from './Types'
1110
*/
1211
export class FileConfigstore implements Configstore {
1312
directory: string
14-
queue: PQueue
1513

1614
constructor(path: string) {
1715
this.directory = path
18-
this.queue = new PQueue({concurrency: 1})
1916
}
2017

2118
async get(key: string): Promise<Upload | undefined> {
2219
try {
23-
const buffer = await this.queue.add(() => fs.readFile(this.resolve(key), 'utf8'))
20+
const buffer = await fs.readFile(this.resolve(key), 'utf8')
2421
return JSON.parse(buffer as string)
2522
} catch {
2623
return undefined
2724
}
2825
}
2926

3027
async set(key: string, value: Upload): Promise<void> {
31-
await this.queue.add(() => fs.writeFile(this.resolve(key), JSON.stringify(value)))
28+
await fs.writeFile(this.resolve(key), JSON.stringify(value))
3229
}
3330

3431
async delete(key: string): Promise<void> {
35-
await this.queue.add(() => fs.rm(this.resolve(key)))
32+
await fs.rm(this.resolve(key))
3633
}
3734

3835
async list(): Promise<Array<string>> {
39-
return this.queue.add(async () => {
40-
const files = await fs.readdir(this.directory)
41-
const sorted = files.sort((a, b) => a.localeCompare(b))
42-
const name = (file: string) => path.basename(file, '.json')
43-
// To only return tus file IDs we check if the file has a corresponding JSON info file
44-
return sorted.filter(
45-
(file, idx) => idx < sorted.length - 1 && name(file) === name(sorted[idx + 1])
46-
)
47-
})
36+
const files = await fs.readdir(this.directory)
37+
const sorted = files.sort((a, b) => a.localeCompare(b))
38+
const name = (file: string) => path.basename(file, '.json')
39+
// To only return tus file IDs we check if the file has a corresponding JSON info file
40+
return sorted.filter(
41+
(file, idx) => idx < sorted.length - 1 && name(file) === name(sorted[idx + 1])
42+
)
4843
}
4944

5045
private resolve(key: string): string {

packages/file-store/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@
2121
"test": "mocha test.ts --exit --extension ts --require ts-node/register"
2222
},
2323
"dependencies": {
24-
"debug": "^4.3.4",
25-
"p-queue": "^6.6.2"
24+
"debug": "^4.3.4"
2625
},
2726
"devDependencies": {
2827
"@tus/server": "workspace:^",

packages/server/src/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ export const ERRORS = {
3333
status_code: 403,
3434
body: 'Upload-Offset header required\n',
3535
},
36+
ABORTED: {
37+
status_code: 400,
38+
body: 'Request aborted due to lock acquired',
39+
},
40+
ERR_LOCK_TIMEOUT: {
41+
status_code: 500,
42+
body: 'failed to acquire lock before timeout',
43+
},
3644
INVALID_CONTENT_TYPE: {
3745
status_code: 403,
3846
body: 'Content-Type header required\n',

packages/server/src/handlers/BaseHandler.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import EventEmitter from 'node:events'
22

3-
import type {ServerOptions} from '../types'
4-
import type {DataStore} from '../models'
3+
import type {ServerOptions, WithRequired} from '../types'
4+
import type {DataStore, CancellationContext} from '../models'
55
import type http from 'node:http'
6+
import stream from 'node:stream'
7+
import {ERRORS} from '../constants'
68

79
const reExtractFileID = /([^/]+)\/?$/
810
const reForwardedHost = /host="?([^";]+)/
911
const reForwardedProto = /proto=(https?)/
1012

1113
export class BaseHandler extends EventEmitter {
12-
options: ServerOptions
14+
options: WithRequired<ServerOptions, 'locker'>
1315
store: DataStore
1416

15-
constructor(store: DataStore, options: ServerOptions) {
17+
constructor(store: DataStore, options: WithRequired<ServerOptions, 'locker'>) {
1618
super()
1719
if (!store) {
1820
throw new Error('Store must be defined')
@@ -27,6 +29,7 @@ export class BaseHandler extends EventEmitter {
2729
// @ts-expect-error not explicitly typed but possible
2830
headers['Content-Length'] = Buffer.byteLength(body, 'utf8')
2931
}
32+
3033
res.writeHead(status, headers)
3134
res.write(body)
3235
return res.end()
@@ -101,4 +104,61 @@ export class BaseHandler extends EventEmitter {
101104

102105
return {host: host as string, proto}
103106
}
107+
108+
protected async getLocker(req: http.IncomingMessage) {
109+
if (typeof this.options.locker === 'function') {
110+
return this.options.locker(req)
111+
}
112+
return this.options.locker
113+
}
114+
115+
protected async acquireLock(
116+
req: http.IncomingMessage,
117+
id: string,
118+
context: CancellationContext
119+
) {
120+
const locker = await this.getLocker(req)
121+
122+
const lock = locker.newLock(id)
123+
124+
await lock.lock(() => {
125+
context.cancel()
126+
})
127+
128+
return lock
129+
}
130+
131+
protected writeToStore(
132+
req: http.IncomingMessage,
133+
id: string,
134+
offset: number,
135+
context: CancellationContext
136+
) {
137+
return new Promise<number>(async (resolve, reject) => {
138+
if (context.signal.aborted) {
139+
reject(ERRORS.ABORTED)
140+
return
141+
}
142+
143+
const proxy = new stream.PassThrough()
144+
stream.addAbortSignal(context.signal, proxy)
145+
146+
proxy.on('error', (err) => {
147+
req.unpipe(proxy)
148+
if (err.name === 'AbortError') {
149+
reject(ERRORS.ABORTED)
150+
} else {
151+
reject(err)
152+
}
153+
})
154+
155+
req.on('error', (err) => {
156+
if (!proxy.closed) {
157+
proxy.destroy(err)
158+
}
159+
})
160+
161+
this.store.write(req.pipe(proxy), id, offset).then(resolve).catch(reject)
162+
})
163+
}
104164
}

packages/server/src/handlers/DeleteHandler.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import {BaseHandler} from './BaseHandler'
22
import {ERRORS, EVENTS} from '../constants'
3+
import {CancellationContext} from '../models'
34

45
import type http from 'node:http'
56

67
export class DeleteHandler extends BaseHandler {
7-
async send(req: http.IncomingMessage, res: http.ServerResponse) {
8+
async send(
9+
req: http.IncomingMessage,
10+
res: http.ServerResponse,
11+
context: CancellationContext
12+
) {
813
const id = this.getFileIdFromRequest(req)
914
if (!id) {
1015
throw ERRORS.FILE_NOT_FOUND
@@ -14,7 +19,12 @@ export class DeleteHandler extends BaseHandler {
1419
await this.options.onIncomingRequest(req, res, id)
1520
}
1621

17-
await this.store.remove(id)
22+
const lock = await this.acquireLock(req, id, context)
23+
try {
24+
await this.store.remove(id)
25+
} finally {
26+
await lock.unlock()
27+
}
1828
const writtenRes = this.write(res, 204, {})
1929
this.emit(EVENTS.POST_TERMINATE, req, writtenRes, id)
2030
return writtenRes

packages/server/src/handlers/GetHandler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export class GetHandler extends BaseHandler {
4040
}
4141

4242
const stats = await this.store.getUpload(id)
43+
4344
if (!stats || stats.offset !== stats.size) {
4445
throw ERRORS.FILE_NOT_FOUND
4546
}

packages/server/src/handlers/HeadHandler.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import {BaseHandler} from './BaseHandler'
22

33
import {ERRORS} from '../constants'
4-
import {Metadata} from '../models'
4+
import {Metadata, Upload, CancellationContext} from '../models'
55

66
import type http from 'node:http'
77

88
export class HeadHandler extends BaseHandler {
9-
async send(req: http.IncomingMessage, res: http.ServerResponse) {
9+
async send(
10+
req: http.IncomingMessage,
11+
res: http.ServerResponse,
12+
context: CancellationContext
13+
) {
1014
const id = this.getFileIdFromRequest(req)
1115
if (!id) {
1216
throw ERRORS.FILE_NOT_FOUND
@@ -16,7 +20,14 @@ export class HeadHandler extends BaseHandler {
1620
await this.options.onIncomingRequest(req, res, id)
1721
}
1822

19-
const file = await this.store.getUpload(id)
23+
const lock = await this.acquireLock(req, id, context)
24+
25+
let file: Upload
26+
try {
27+
file = await this.store.getUpload(id)
28+
} finally {
29+
await lock.unlock()
30+
}
2031

2132
// If a Client does attempt to resume an upload which has since
2233
// been removed by the Server, the Server SHOULD respond with the

0 commit comments

Comments
 (0)