Skip to content

Commit 61470ac

Browse files
authored
feat: upgrade tus to latest + S3 Locker (#740)
1 parent 2dd4efd commit 61470ac

File tree

11 files changed

+11752
-8302
lines changed

11 files changed

+11752
-8302
lines changed

jest.config.cjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ module.exports = {
33
testSequencer: './jest.sequencer.cjs',
44
transform: {
55
'^.+/node_modules/jose/.+\\.[jt]s$': 'babel-jest',
6+
'^.+/node_modules/@tus/.+\\.[jt]s$': 'babel-jest',
7+
'^.+/node_modules/srvx/.+\\.[jt]s$': 'babel-jest',
8+
'^.+/node_modules/cookie-es/.+\\.[jt]s$': 'babel-jest',
69
'^.+\\.mjs$': 'babel-jest',
710
'^.+\\.(t|j)sx?$': 'ts-jest',
811
},
9-
transformIgnorePatterns: ['node_modules/(?!(jose)/)'],
12+
transformIgnorePatterns: ['node_modules/(?!(jose|@tus|srvx|cookie-es)/)'],
1013
moduleNameMapper: {
1114
'^@storage/(.*)$': '<rootDir>/src/storage/$1',
1215
'^@internal/(.*)$': '<rootDir>/src/internal/$1',

package-lock.json

Lines changed: 10482 additions & 8202 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@
4747
"@opentelemetry/instrumentation-pg": "^0.44.0",
4848
"@shopify/semaphore": "^3.0.2",
4949
"@smithy/node-http-handler": "^2.3.1",
50-
"@tus/file-store": "1.4.0",
51-
"@tus/s3-store": "1.5.0",
52-
"@tus/server": "1.7.0",
50+
"@tus/file-store": "2.0.0",
51+
"@tus/s3-store": "2.0.0",
52+
"@tus/server": "2.2.1",
5353
"agentkeepalive": "^4.5.0",
5454
"ajv": "^8.12.0",
5555
"async-retry": "^1.3.3",

src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ type StorageConfigType = {
157157
tusPartSize: number
158158
tusUseFileVersionSeparator: boolean
159159
tusAllowS3Tags: boolean
160+
tusLockType: 'postgres' | 's3'
160161
defaultMetricsEnabled: boolean
161162
s3ProtocolEnabled: boolean
162163
s3ProtocolPrefix: string
@@ -312,6 +313,7 @@ export function getConfig(options?: { reload?: boolean }): StorageConfigType {
312313
tusUseFileVersionSeparator:
313314
getOptionalConfigFromEnv('TUS_USE_FILE_VERSION_SEPARATOR') === 'true',
314315
tusAllowS3Tags: getOptionalConfigFromEnv('TUS_ALLOW_S3_TAGS') !== 'false',
316+
tusLockType: getOptionalConfigFromEnv('TUS_LOCK_TYPE') || 'postgres',
315317

316318
// S3 Protocol
317319
s3ProtocolEnabled: getOptionalConfigFromEnv('S3_PROTOCOL_ENABLED') !== 'false',

src/http/routes/tus/index.ts

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,12 @@
11
import { FastifyBaseLogger, FastifyInstance } from 'fastify'
22
import fastifyPlugin from 'fastify-plugin'
33
import * as http from 'http'
4-
import { ServerOptions, DataStore } from '@tus/server'
4+
import { ServerOptions, DataStore, Server } from '@tus/server'
55
import { getFileSizeLimit } from '@storage/limits'
66
import { Storage } from '@storage/storage'
77
import { jwt, storage, db, dbSuperUser } from '../../plugins'
88
import { getConfig } from '../../../config'
9-
import {
10-
TusServer,
11-
FileStore,
12-
LockNotifier,
13-
PgLocker,
14-
UploadId,
15-
AlsMemoryKV,
16-
} from '@storage/protocols/tus'
9+
import { FileStore, LockNotifier, PgLocker, UploadId, AlsMemoryKV } from '@storage/protocols/tus'
1710
import {
1811
namingFunction,
1912
onCreate,
@@ -30,6 +23,10 @@ import { NodeHttpHandler } from '@smithy/node-http-handler'
3023
import { ROUTE_OPERATIONS } from '../operations'
3124
import * as https from 'node:https'
3225
import { createAgent } from '@internal/http'
26+
import type { ServerRequest as Request } from 'srvx'
27+
import { S3Locker } from '@storage/protocols/tus/s3-locker'
28+
import { S3Client } from '@aws-sdk/client-s3'
29+
import { ERRORS } from '@internal/errors'
3330

3431
const {
3532
storageS3MaxSockets,
@@ -43,6 +40,7 @@ const {
4340
tusPartSize,
4441
tusMaxConcurrentUploads,
4542
tusAllowS3Tags,
43+
tusLockType,
4644
uploadFileSizeLimit,
4745
storageBackendType,
4846
storageFilePath,
@@ -98,9 +96,42 @@ function createTusServer(
9896
path: tusPath,
9997
datastore: datastore,
10098
disableTerminationForFinishedUploads: true,
101-
locker: (rawReq: http.IncomingMessage) => {
102-
const req = rawReq as MultiPartRequest
103-
return new PgLocker(req.upload.storage.db, lockNotifier)
99+
locker: (rawReq: Request) => {
100+
const req = rawReq.node?.req as MultiPartRequest
101+
102+
if (!req) {
103+
throw ERRORS.InternalError(undefined, 'Request object is missing')
104+
}
105+
106+
switch (tusLockType) {
107+
case 'postgres':
108+
return new PgLocker(req.upload.storage.db, lockNotifier)
109+
110+
case 's3':
111+
return new S3Locker({
112+
bucket: storageS3Bucket,
113+
keyPrefix: `__tus_locks/${req.upload.tenantId}/`,
114+
logger: console,
115+
lockTtlMs: 15 * 1000, // 15 seconds
116+
maxRetries: 10,
117+
retryDelayMs: 250,
118+
renewalIntervalMs: 10 * 1000, // 10 seconds
119+
s3Client: new S3Client({
120+
requestHandler: new NodeHttpHandler({
121+
...agent,
122+
connectionTimeout: 5000,
123+
requestTimeout: storageS3ClientTimeout,
124+
}),
125+
region: storageS3Region,
126+
endpoint: storageS3Endpoint,
127+
forcePathStyle: storageS3ForcePathStyle,
128+
}),
129+
notifier: lockNotifier,
130+
})
131+
132+
default:
133+
throw ERRORS.InternalError(undefined, 'Unsupported TUS locker type')
134+
}
104135
},
105136
namingFunction: namingFunction,
106137
onUploadCreate: onCreate,
@@ -112,7 +143,7 @@ function createTusServer(
112143
respectForwardedHeaders: true,
113144
allowedHeaders: ['Authorization', 'X-Upsert', 'Upload-Expires', 'ApiKey', 'x-signature'],
114145
maxSize: async (rawReq, uploadId) => {
115-
const req = rawReq as MultiPartRequest
146+
const req = rawReq.node?.req as MultiPartRequest
116147

117148
if (!req.upload.tenantId) {
118149
return uploadFileSizeLimit
@@ -138,7 +169,7 @@ function createTusServer(
138169
return fileSizeLimit
139170
},
140171
}
141-
return new TusServer(serverOptions)
172+
return new Server(serverOptions)
142173
}
143174

144175
export default async function routes(fastify: FastifyInstance) {
@@ -204,7 +235,7 @@ export default async function routes(fastify: FastifyInstance) {
204235
}
205236

206237
const authenticatedRoutes = fastifyPlugin(
207-
async (fastify: FastifyInstance, options: { tusServer: TusServer; operation?: string }) => {
238+
async (fastify: FastifyInstance, options: { tusServer: Server; operation?: string }) => {
208239
fastify.register(async function authorizationContext(fastify) {
209240
fastify.addContentTypeParser('application/offset+octet-stream', (request, payload, done) =>
210241
done(null)
@@ -312,7 +343,7 @@ const authenticatedRoutes = fastifyPlugin(
312343
)
313344

314345
const publicRoutes = fastifyPlugin(
315-
async (fastify: FastifyInstance, options: { tusServer: TusServer; operation?: string }) => {
346+
async (fastify: FastifyInstance, options: { tusServer: Server; operation?: string }) => {
316347
fastify.register(async (fastify) => {
317348
fastify.addContentTypeParser('application/offset+octet-stream', (request, payload, done) =>
318349
done(null)

src/http/routes/tus/lifecycle.ts

Lines changed: 58 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ERRORS, isRenderableError } from '@internal/errors'
77
import { Storage } from '@storage/storage'
88
import { Uploader, validateMimeType } from '@storage/uploader'
99
import { UploadId } from '@storage/protocols/tus'
10+
import type { ServerRequest as Request } from 'srvx'
1011

1112
import { getConfig } from '../../../config'
1213

@@ -15,6 +16,19 @@ const reExtractFileID = /([^/]+)\/?$/
1516

1617
export const SIGNED_URL_SUFFIX = '/sign'
1718

19+
/**
20+
* Validates that the request object exists and returns it
21+
* @throws {Error} If the request object is missing
22+
*/
23+
function getNodeRequest(rawReq: Request): MultiPartRequest {
24+
const req = rawReq.node?.req as MultiPartRequest
25+
26+
if (!req) {
27+
throw ERRORS.InternalError(undefined, 'Request object is missing')
28+
}
29+
30+
return req
31+
}
1832
export type MultiPartRequest = http.IncomingMessage & {
1933
log: BaseLogger
2034
upload: {
@@ -30,14 +44,20 @@ export type MultiPartRequest = http.IncomingMessage & {
3044
/**
3145
* Runs on every TUS incoming request
3246
*/
33-
export async function onIncomingRequest(
34-
rawReq: http.IncomingMessage,
35-
res: http.ServerResponse,
36-
id: string
37-
) {
38-
const req = rawReq as MultiPartRequest
47+
export async function onIncomingRequest(rawReq: Request, id: string) {
48+
const req = getNodeRequest(rawReq)
49+
const res = rawReq.node?.res as http.ServerResponse
50+
51+
if (!res) {
52+
throw ERRORS.InternalError(undefined, 'Response object is missing')
53+
}
54+
55+
if (!res) {
56+
throw ERRORS.InternalError(undefined, 'Response object is missing')
57+
}
3958

4059
res.on('finish', () => {
60+
console.log('Tus request finished')
4161
req.upload.db.dispose().catch((e) => {
4262
req.log.error({ error: e }, 'Error disposing db connection')
4363
})
@@ -88,9 +108,15 @@ export async function onIncomingRequest(
88108
* Generate URL for TUS upload, it encodes the uploadID to base64url
89109
*/
90110
export function generateUrl(
91-
req: http.IncomingMessage,
111+
rawReq: Request,
92112
{ proto, host, path, id }: { proto: string; host: string; path: string; id: string }
93113
) {
114+
const req = getNodeRequest(rawReq)
115+
116+
if (!req.url) {
117+
throw ERRORS.InvalidParameter('url')
118+
}
119+
94120
if (!req.url) {
95121
throw ERRORS.InvalidParameter('url')
96122
}
@@ -127,8 +153,9 @@ export function generateUrl(
127153
/**
128154
* Extract the uploadId from the request and decodes it from base64url
129155
*/
130-
export function getFileIdFromRequest(rawRwq: http.IncomingMessage) {
131-
const req = rawRwq as MultiPartRequest
156+
export function getFileIdFromRequest(rawReq: Request) {
157+
const req = getNodeRequest(rawReq)
158+
132159
const match = reExtractFileID.exec(req.url as string)
133160

134161
if (!match || tusPath.includes(match[1])) {
@@ -145,11 +172,12 @@ export function getFileIdFromRequest(rawRwq: http.IncomingMessage) {
145172
*
146173
* /tenant-id/bucket-name/object-name/version
147174
*/
148-
export function namingFunction(
149-
rawReq: http.IncomingMessage,
150-
metadata?: Record<string, string | null>
151-
) {
152-
const req = rawReq as MultiPartRequest
175+
export function namingFunction(rawReq: Request, metadata?: Record<string, string | null>) {
176+
const req = getNodeRequest(rawReq)
177+
178+
if (!req.url) {
179+
throw new Error('no url set')
180+
}
153181

154182
if (!req.url) {
155183
throw new Error('no url set')
@@ -177,13 +205,13 @@ export function namingFunction(
177205
* Runs before the upload URL is created
178206
*/
179207
export async function onCreate(
180-
rawReq: http.IncomingMessage,
181-
res: http.ServerResponse,
208+
rawReq: Request,
182209
upload: Upload
183-
): Promise<{ res: http.ServerResponse; metadata?: Upload['metadata'] }> {
210+
): Promise<{ metadata?: Upload['metadata'] }> {
184211
const uploadID = UploadId.fromString(upload.id)
185212

186-
const req = rawReq as MultiPartRequest
213+
const req = getNodeRequest(rawReq)
214+
187215
const storage = req.upload.storage
188216

189217
const bucket = await storage
@@ -204,18 +232,15 @@ export async function onCreate(
204232
validateMimeType(metadata.contentType, bucket.allowed_mime_types)
205233
}
206234

207-
return { res, metadata }
235+
return { metadata }
208236
}
209237

210238
/**
211239
* Runs when the upload to the underline store is completed
212240
*/
213-
export async function onUploadFinish(
214-
rawReq: http.IncomingMessage,
215-
res: http.ServerResponse,
216-
upload: Upload
217-
) {
218-
const req = rawReq as MultiPartRequest
241+
export async function onUploadFinish(rawReq: Request, upload: Upload) {
242+
const req = getNodeRequest(rawReq)
243+
219244
const resourceId = UploadId.fromString(upload.id)
220245

221246
try {
@@ -255,9 +280,11 @@ export async function onUploadFinish(
255280
userMetadata: customMd,
256281
})
257282

258-
res.setHeader('Tus-Complete', '1')
259-
260-
return res
283+
return {
284+
headers: {
285+
'Tus-Complete': '1',
286+
},
287+
}
261288
} catch (e) {
262289
if (isRenderableError(e)) {
263290
;(e as any).status_code = parseInt(e.render().statusCode, 10)
@@ -272,11 +299,9 @@ type TusError = { status_code: number; body: string }
272299
/**
273300
* Runs when there is an error on the TUS upload
274301
*/
275-
export function onResponseError(
276-
req: http.IncomingMessage,
277-
_: http.ServerResponse,
278-
e: TusError | Error
279-
) {
302+
export function onResponseError(rawReq: Request, e: TusError | Error) {
303+
const req = getNodeRequest(rawReq)
304+
280305
if (e instanceof Error) {
281306
;(req as any).executionError = e
282307
} else {

src/storage/protocols/tus/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,3 @@ export * from './file-store'
22
export * from './postgres-locker'
33
export * from './upload-id'
44
export * from './als-memory-kv'
5-
export * from './server'

0 commit comments

Comments
 (0)