Skip to content

Commit 88a9549

Browse files
mitjapMurderlon
andauthored
@tus/file-store: support async config stores & add Redis store (#407)
Co-authored-by: Murderlon <[email protected]>
1 parent 50580a2 commit 88a9549

File tree

13 files changed

+1047
-894
lines changed

13 files changed

+1047
-894
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
import {Upload} from '@tus/server'
4+
import PQueue from 'p-queue'
5+
6+
import {Configstore} from './Types'
7+
8+
/**
9+
* FileConfigstore writes the `Upload` JSON metadata to disk next the uploaded file itself.
10+
* It uses a queue which only processes one operation at a time to prevent unsafe concurrent access.
11+
*/
12+
export class FileConfigstore implements Configstore {
13+
directory: string
14+
queue: PQueue
15+
16+
constructor(path: string) {
17+
this.directory = path
18+
this.queue = new PQueue({concurrency: 1})
19+
}
20+
21+
async get(key: string): Promise<Upload | undefined> {
22+
try {
23+
const buffer = await this.queue.add(() => fs.readFile(this.resolve(key), 'utf8'))
24+
return JSON.parse(buffer as string)
25+
} catch {
26+
return undefined
27+
}
28+
}
29+
30+
async set(key: string, value: Upload): Promise<void> {
31+
await this.queue.add(() => fs.writeFile(this.resolve(key), JSON.stringify(value)))
32+
}
33+
34+
async delete(key: string): Promise<void> {
35+
await this.queue.add(() => fs.rm(this.resolve(key)))
36+
}
37+
38+
async list(): Promise<Array<string>> {
39+
return this.queue.add(async () => {
40+
const files = await fs.readdir(this.directory, {withFileTypes: true})
41+
const promises = files
42+
.filter((file) => file.isFile() && file.name.endsWith('.json'))
43+
.map((file) => fs.readFile(path.resolve(file.path, file.name), 'utf8'))
44+
45+
return Promise.all(promises)
46+
}) as Promise<string[]>
47+
}
48+
49+
private resolve(key: string): string {
50+
return path.resolve(this.directory, `${key}.json`)
51+
}
52+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {Upload} from '@tus/server'
2+
import {Configstore} from './Types'
3+
4+
/**
5+
* Memory based configstore.
6+
* Used mostly for unit tests.
7+
*
8+
* @author Mitja Puzigaća <[email protected]>
9+
*/
10+
export class MemoryConfigstore implements Configstore {
11+
data: Map<string, string> = new Map()
12+
13+
async get(key: string): Promise<Upload | undefined> {
14+
return this.deserializeValue(this.data.get(key))
15+
}
16+
17+
async set(key: string, value: Upload): Promise<void> {
18+
this.data.set(key, this.serializeValue(value))
19+
}
20+
21+
async delete(key: string): Promise<void> {
22+
this.data.delete(key)
23+
}
24+
25+
async list(): Promise<Array<string>> {
26+
return [...this.data.keys()]
27+
}
28+
29+
private serializeValue(value: Upload): string {
30+
return JSON.stringify(value)
31+
}
32+
33+
private deserializeValue(buffer: string | undefined): Upload | undefined {
34+
return buffer ? new Upload(JSON.parse(buffer)) : undefined
35+
}
36+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {RedisClientType} from '@redis/client'
2+
3+
import {Upload} from '@tus/server'
4+
import {Configstore} from './Types'
5+
6+
/**
7+
* Redis based configstore.
8+
*
9+
* @author Mitja Puzigaća <[email protected]>
10+
*/
11+
export class RedisConfigstore implements Configstore {
12+
constructor(private redis: RedisClientType, private prefix: string = '') {
13+
this.redis = redis
14+
this.prefix = prefix
15+
}
16+
17+
async get(key: string): Promise<Upload | undefined> {
18+
return this.deserializeValue(await this.redis.get(this.prefix + key))
19+
}
20+
21+
async set(key: string, value: Upload): Promise<void> {
22+
await this.redis.set(this.prefix + key, this.serializeValue(value))
23+
}
24+
25+
async delete(key: string): Promise<void> {
26+
await this.redis.del(this.prefix + key)
27+
}
28+
29+
async list(): Promise<Array<string>> {
30+
return this.redis.keys(this.prefix + '*')
31+
}
32+
33+
private serializeValue(value: Upload): string {
34+
return JSON.stringify(value)
35+
}
36+
37+
private deserializeValue(buffer: string | null): Upload | undefined {
38+
return buffer ? JSON.parse(buffer) : undefined
39+
}
40+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {Upload} from '@tus/server'
2+
3+
export interface Configstore {
4+
get(key: string): Promise<Upload | undefined>
5+
set(key: string, value: Upload): Promise<void>
6+
delete(key: string): Promise<void>
7+
8+
list?(): Promise<Array<string>>
9+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export {FileConfigstore} from './FileConfigstore'
2+
export {MemoryConfigstore} from './MemoryConfigstore'
3+
export {RedisConfigstore} from './RedisConfigstore'
4+
export {Configstore} from './Types'

packages/file-store/index.ts

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,15 @@ import stream from 'node:stream'
55
import http from 'node:http'
66

77
import debug from 'debug'
8-
import Configstore from 'configstore'
98

9+
import {Configstore, FileConfigstore} from './configstores'
1010
import {DataStore, Upload, ERRORS} from '@tus/server'
11-
import pkg from './package.json'
1211

13-
type Store = {
14-
get(key: string): Upload | undefined
15-
set(key: string, value: Upload): void
16-
delete(key: string): void
17-
all: Record<string, Upload>
18-
}
12+
export * from './configstores'
1913

2014
type Options = {
2115
directory: string
22-
configstore?: Store
16+
configstore?: Configstore
2317
expirationPeriodInMilliseconds?: number
2418
}
2519

@@ -30,13 +24,13 @@ const log = debug('tus-node-server:stores:filestore')
3024

3125
export class FileStore extends DataStore {
3226
directory: string
33-
configstore: Store
27+
configstore: Configstore
3428
expirationPeriodInMilliseconds: number
3529

3630
constructor({directory, configstore, expirationPeriodInMilliseconds}: Options) {
3731
super()
3832
this.directory = directory
39-
this.configstore = configstore ?? new Configstore(`${pkg.name}-${pkg.version}`)
33+
this.configstore = configstore ?? new FileConfigstore(directory)
4034
this.expirationPeriodInMilliseconds = expirationPeriodInMilliseconds ?? 0
4135
this.extensions = [
4236
'creation',
@@ -65,13 +59,13 @@ export class FileStore extends DataStore {
6559
*/
6660
create(file: Upload): Promise<Upload> {
6761
return new Promise((resolve, reject) => {
68-
fs.open(path.join(this.directory, file.id), 'w', (err, fd) => {
62+
fs.open(path.join(this.directory, file.id), 'w', async (err, fd) => {
6963
if (err) {
7064
log('[FileStore] create: Error', err)
7165
return reject(err)
7266
}
7367

74-
this.configstore.set(file.id, file)
68+
await this.configstore.set(file.id, file)
7569

7670
return fs.close(fd, (exception) => {
7771
if (exception) {
@@ -143,7 +137,7 @@ export class FileStore extends DataStore {
143137
}
144138

145139
async getUpload(id: string): Promise<Upload> {
146-
const file = this.configstore.get(id)
140+
const file = await this.configstore.get(id)
147141

148142
if (!file) {
149143
throw ERRORS.FILE_NOT_FOUND
@@ -188,25 +182,29 @@ export class FileStore extends DataStore {
188182
}
189183

190184
async declareUploadLength(id: string, upload_length: number) {
191-
const file = this.configstore.get(id)
185+
const file = await this.configstore.get(id)
192186

193187
if (!file) {
194188
throw ERRORS.FILE_NOT_FOUND
195189
}
196190

197191
file.size = upload_length
198192

199-
this.configstore.set(id, file)
193+
await this.configstore.set(id, file)
200194
}
201195

202196
async deleteExpired(): Promise<number> {
203197
const now = new Date()
204198
const toDelete: Promise<void>[] = []
205199

206-
const uploadInfos = this.configstore.all
207-
for (const file_id of Object.keys(uploadInfos)) {
200+
if (!this.configstore.list) {
201+
throw ERRORS.UNSUPPORTED_EXPIRATION_EXTENSION
202+
}
203+
204+
const uploadKeys = await this.configstore.list()
205+
for (const file_id of uploadKeys) {
208206
try {
209-
const info = uploadInfos[file_id]
207+
const info = await this.configstore.get(file_id)
210208
if (
211209
info &&
212210
'creation_date' in info &&
@@ -227,7 +225,8 @@ export class FileStore extends DataStore {
227225
}
228226
}
229227

230-
return Promise.all(toDelete).then(() => toDelete.length)
228+
await Promise.all(toDelete)
229+
return toDelete.length
231230
}
232231

233232
getExpiration(): number {

packages/file-store/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@
2121
"test": "mocha test.ts --exit --extension ts --require ts-node/register"
2222
},
2323
"dependencies": {
24-
"configstore": "^5.0.1",
25-
"debug": "^4.3.3"
24+
"debug": "^4.3.3",
25+
"p-queue": "^6.6.2"
2626
},
2727
"devDependencies": {
2828
"@tus/server": "workspace:^",
29-
"@types/configstore": "^6.0.0",
3029
"@types/debug": "^4.1.7",
3130
"@types/mocha": "^10.0.1",
3231
"@types/node": "latest",
@@ -39,6 +38,9 @@
3938
"peerDependencies": {
4039
"@tus/server": "workspace:^"
4140
},
41+
"optionalDependencies": {
42+
"@redis/client": "^1.5.5"
43+
},
4244
"engines": {
4345
"node": ">=16"
4446
}

packages/file-store/test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import path from 'node:path'
77
import sinon from 'sinon'
88

99
import {FileStore} from './'
10-
import {Upload, MemoryConfigstore} from '@tus/server'
10+
import {Upload} from '@tus/server'
1111

1212
import * as shared from '../../test/stores.test'
13+
import {MemoryConfigstore} from './configstores/MemoryConfigstore'
1314

1415
const fixturesPath = path.resolve('../', '../', 'test', 'fixtures')
1516
const storePath = path.resolve('../', '../', 'test', 'output')

packages/server/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export {Server} from './server'
22
export * from './types'
3-
export * from './models/'
3+
export * from './models'
44
export * from './constants'

packages/server/src/models/MemoryConfigstore.ts

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)