How to customize the URL reported back to the client in @tus/server

I have a small @tus/node-server with the @tus/s3-store to upload files to a Tigris bucket.

I’ve followed the documentation to specify a custom path on the storage based on the userId.

The files are properly being uploaded to the right location in my bucket.

I’m using Uppy (React) with the Tus uploader.

How can I build a URL for the uploaded file and read that in the upload-success event on the client? This event gets an uploadURL which is the URL of the specific Tus server request, not the actual file URL on the destination (the Tigris bucket).

import { Server } from '@tus/server'
import { S3Store } from '@tus/s3-store'
import { consola } from "consola";
import { nanoid } from "nanoid"
import invariant from 'tiny-invariant';

const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || 8080

invariant(process.env.S3_BUCKET, "S3_BUCKET is required")
invariant(process.env.S3_ACCESS_KEY_ID, "S3_ACCESS_KEY_ID is required")
invariant(process.env.S3_SECRET_ACCESS_KEY, "S3_SECRET_ACCESS_KEY is required")

const S3_ENDPOINT = process.env.S3_ENDPOINT || "https://fly.storage.tigris.dev"
const S3_BUCKET = process.env.S3_BUCKET

const server = new Server({
  path: '/files',
  datastore: new S3Store({
    s3ClientConfig: {
      endpoint: S3_ENDPOINT,
      region: "auto",
      bucket: S3_BUCKET,
      credentials: {
        accessKeyId: process.env.S3_ACCESS_KEY_ID,
        secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
      }
    }
  }),

  namingFunction(req, metadata) {
    invariant(metadata?.filename, "filename is required")
    invariant(metadata?.userId, "userId is required")

    const id = nanoid()
    const userId = metadata.userId;
    const filename = metadata.filename;

    const extension = filename.split('.').pop();

    return `${userId}/${id}.${extension}`
  },

  generateUrl(req, { proto, host, path, id }) {
    id = Buffer.from(id, 'utf-8').toString('base64url')

    return `${proto}://${host}${path}/${id}`
  },

  getFileIdFromRequest(req, lastPath) {
    // lastPath is everything after the last `/`
    // If your custom URL is different, this might be undefined
    // and you need to extract the ID yourself
    return Buffer.from(lastPath ?? "", 'base64url').toString('utf-8')
  },


  onUploadFinish: async (req, res, upload) => {
    consola.info(`Upload finished: ${upload.id}`)

    // Is this where I can build the file url? If so how?
    // I want to be able to build the file URL like:
    // const url = `${S3_ENDPOINT}/${S3_BUCKET}/${pathToFile}`
    // And get that in the `upload-success` (or any similar event) on Uppy.js

    return res
  }
})

consola.info(`Server is running on ${host}:${port}`)
server.listen({ host, port })

Hi, retrieving files is not part of the tus specification so you will have to write a get handler yourself.

I would be open to adding an example to the @tus/s3-store readme for getting files based on what you come up with.

I think I might have not explained myself properly. I’m not looking to make the tus-server to retrieve the file. I just want to return the direct URL.

When using Transloadit, Uppy receives the file URL after the assembly is finished.

Is my use case really that uncommon? I mean, you upload a file, then get a result, if it succeeded you get some information about the result. Among that information, it would make a lot of sense to have an URL to the file.
Otherwise, I have to build that logic on the client and it doesn’t belong there. The responsible of knowing how the uploaded file is handled, where is it stored and so it’s the server. Why can’t it just return a URL back to the client after the upload is completed…?

You can use onUploadFinish and return status 200 (otherwise body is not allowed) and the url in the body. Or don’t change the status code and use a header. Then you can get it somehow in onAfterResponse for @uppy/tus.

Actually you can just access it without onAfterResponse.

uppy.on('upload-success', (file, res) => {
  console.log(res.body.xhr)
})

Can you show a full example of it? This is what I’m doing on the server:

  onUploadFinish: async (req, res, upload) => {
    consola.info(`Upload finished: ${upload.id}`)

    return {
      res,
      status_code: 200,
      body: JSON.stringify({
        url: `https://${S3_ENDPOINT}/${S3_BUCKET}/${upload.id}`
      })
    }
  }

And on the client:

uppy.on("upload-success", (file, res) => {
  debugger;
  console.log("upload-success", res.body?.xhr);
})

But res doesn’t have the body set on the server.

Do you see it returned in the network tab? If so, can you access it in with onAfterResponse for @uppy/tus? If you can’t access it be do see it please create an uppy issue. If you don’t see it in the network tab create an issue for tus-node-server.

It’s not on the network tab either.

The response is 200 but with an empty body no matter what I return in the onUploadFinish method.

HTTP/1.1 200 OK
Tus-Resumable: 1.0.0
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Expose-Headers: Authorization, Content-Type, Location, Tus-Extension, Tus-Max-Size, Tus-Resumable, Tus-Version, Upload-Concat, Upload-Defer-Length, Upload-Length, Upload-Metadata, Upload-Offset, X-HTTP-Method-Override, X-Requested-With, X-Forwarded-Host, X-Forwarded-Proto, Forwarded
Cache-Control: no-store
Upload-Offset: 4483969
Upload-Length: 4483969
Upload-Metadata: relativePath bnVsbA==,name aXRzLW15LWxpZmUubXAz,type YXVkaW8vbXBlZw==,userId M2E0ZjlkNGUtZTg4Mi00MzI4LTg2NDUtYmQyOTNiZTAxNDIy,contentType YXVkaW8vbXBlZw==,filetype YXVkaW8vbXBlZw==,filename aXRzLW15LWxpZmUubXAz
Date: Thu, 06 Feb 2025 08:45:17 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Ok, so the thing is that the server seems to be skipping/ignoring already uploaded files, but it’s returning a 200 empty response back to the client.

How can I disable this cache or ignore thing? I want the server to process each file upload independently as a new file, even though the file uploaded might be the same one over and over again.

It’s not the server ignoring files, but the client. It recognizes that it uploaded the same file and has to ability to resume the previous upload which only consists of a single request to check if the server still has the file.

If you don’t want this behavior, configure the client to not resume completed uploads.

Ok, but how exactly does the “single request to check if the server still has the file” works? Is there some event I can listen to?

If the server still has the file, how can I modify the response back to the client to include a URL to the file just like I do in the onUploadFinish event? I’m simply trying to have a consistent response to the client in both cases: “new file uploaded” and “file was previously uploaded, returning existing URL”.

Tus-js-client sends a HEAD request (see Resumable upload protocol 1.0.x | tus.io) to check the upload’s status. On the server-side, there are no events so far to modify the server’s response.

That’s currently not possible with tus-node-server. @Merlijn and I have been brainstorming about how this situation can be improved using metadata. The server could attach metadata once the upload is finished, which the client can then retrieve when either the upload is finished or the client notices that the upload is already done.