Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add tus-uploads example #540

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tus-uploads/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PRIVATE_KEY_GOOGLE
3 changes: 3 additions & 0 deletions tus-uploads/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/uploads
.env
/app/config.ts
1 change: 1 addition & 0 deletions tus-uploads/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v20.17.0
67 changes: 67 additions & 0 deletions tus-uploads/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# TUS Integration Resumable File Uploads

This is an example to use the Tus Protocol to upload files to either a Google Cloud Bucket or your internal file storge

The relevent files are:

```
├── app
| ├── bucket.server.tsx // if using google cloud bucket - store your credentials here
| ├── tusCloudBucketHandler.server.tsx // this file handles different request methods used by Tus Protocol for cloud bucket integration
| ├── tusFileStoreHanlder.server.tsx // this file handles different request methods used by Tus Protocol for uploading files to an internal file storage/directory within your
| ├── routes
| │ └── _index.tsx // front end end file with basic form and tus-js client that uploads file to a designated route
| │ └── api.tus-cloud-uploads.tsx // initial route Tus uses to POST and create file for cloud bucket integration
| │ └── api.tus-cloud-uploads.$fileId.tsx // Afte file is created Tus makes patch requests to the file that was created using the POST request to update the file in "chunks" for cloud bucket integration
| │ └── api.tus-native-uploads.tsx // initial route Tus uses to POST and create file on local files system
| │ └── api.tus-native-uploads.$fileId.tsx // Afte file is created Tus makes patch requests to the file that was created using the POST request to update the file in "chunks" for local file system integration
└── .env // hold cloud bucket credentials secret key
```

## Setup

1. Copy `.env.example` to create a new file `.env`:

```sh
cp .env.example .env
```

## Example

Servers like Cloud Run usually have a fixed limit ~32 mb of what data your upload to the server at one time, The Tus Protocol solves these limits by uploading files in chunks, when large files are uploaded there can be network issues but when files are uploaded in chuunks in tus prootcol tus keeps track of when a file stopped uploading and can resume the upload.

## Related Links

Tus Protocol generally utilizes a front end and a back end, while integrating Tus-Js-Client npm package was relatively easy in a remix application - integrating Tus Server required either an implemented Node/Expres server that didn't quite fit into the remix architecture of using web fetch Api, rather it uses the native req, res objects in Express, instead of using the TusServer npm package which is tighly couple to Express/Node, the tusHanlerServer files basically implement the tus Server request methods while not being confined to using Express. The TusHandler handles the same request methods required by the tus protocol "POST" - creation of file, "PATCH" - updates to File - "HEAD" - when needed to retrieve metadata for file.
npm package for tus-js-client - https://github.com/tus/tus-js-client
npm package for gcs-store tus-node-server - https://github.com/tus/tus-node-server/tree/main/packages/gcs-store
npm pacakge for file-store tus-node-server - https://github.com/tus/tus-node-server/tree/main/packages/file-store
code inspiration for request handlers - https://github.com/tus/tus-node-server/tree/main/packages/server



## Production
On an environment like cloud run you may need to set content security policy header
```
if (process.env.NODE_ENV === "production") {
app.use((req, res, next) => {
res.setHeader("Content-Security-Policy", "upgrade-insecure-requests");
next();
});
}
```
see issue here - https://github.com/tus/tus-js-client/issues/186


## To Run exmaple
`npm i`
in `_index.tsx` when tusClientUploader is invoked you have th option to call either `/api/tus-cloud-uploads` endpoint or the `/api/tus-native-uploads` endpoint when calling the cloud-uploads endpoint you must provide a bucketName `${bucketName}` the other endpoint requires a directory path like `./uploads/tus`
`npm run dev`
use ux to upload file and watch the magic happen

## Process
The typical flow for tus-js-client involves:
- An initial POST request to create the upload resource.
- One or more PATCH requests to upload the file data.
- HEAD requests as needed to check the status of the existing upload/resource.

24 changes: 24 additions & 0 deletions tus-uploads/app/bucket.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {Storage} from "@google-cloud/storage"


export function getCloudStorage() {
const projectId = "";

let private_key = ''
const private_key_string = process.env.PRIVATE_KEY_GOOGLE || "";
if (private_key_string.length) {
private_key = private_key_string.split(String.raw`\n`).join("\n");
}
return new Storage({
projectId,
credentials: {
type: "",
project_id: "",
private_key_id: "",
private_key: `-----BEGIN PRIVATE KEY-----\n${private_key}\n-----END PRIVATE KEY-----\n`,
client_email: "",
client_id: "",
universe_domain: "",
},
});
}
29 changes: 29 additions & 0 deletions tus-uploads/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function App() {
return <Outlet />;
}
108 changes: 108 additions & 0 deletions tus-uploads/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Form } from "@remix-run/react";
import { useState } from "react";
import * as tus from "tus-js-client";
export default function Index() {

const [file, setFile] = useState(null as File | null);

//EDIT THIS
const bucketName = "you_bucket_name"

const startuploadProcess = async (e:any) => {
e.preventDefault();
if (file) {
// This will upload to a cloud storage buket
await tusClientUploader(file, "/api/tus-cloud-uploads", bucketName);
// this will upload to a directory on your file system

// await tusClientUploader(file, "/api/tus-native-uploads", "./uploads/tus");
}

}

async function tusClientUploader(file:File, endpoint: string, destination: string, title?: string) {
let tusUpload:any;
const metadata = {
name: title || file.name,
filename: file.name,
filetype: file.type,
contentType: file.type,
destination,
};
return new Promise((resolve, reject) => {
const options = {
endpoint: endpoint,
chunkSize: Infinity,
metadata: metadata,
onError(tusError:Error) {
console.log({ tusError });
reject({
success: false,
error: tusError,
message: `error uploading file ${metadata.name}`,
});
},
onSuccess() {
console.log("onsuccess");
const url = new URL(tusUpload.url);
const id = url.pathname.split("/").pop();
const bucketName = url.searchParams.get("bucket");
if (bucketName) {
const encodedFormat = encodeURIComponent(tusUpload.options.metadata.contentType);
fetch(`/api/tus-cloud-uploads?id=${id}&mediaType=${encodedFormat}&bucketName=${bucketName}`)
.then((response) => {
return response.json();
})
.then((json) => {
console.log({ json });
resolve({
success: true,
url: url,
id,
fileUpdated: true,
});
})
.catch((error) => {
console.error({ error });
resolve({
success: true,
url: url,
id,
fileUpdated: false,
encodedFormat,
bucketName,
});
});
}


},
onProgress(bytesUploaded:number) {
const progress = (bytesUploaded / file.size) * 100;
console.log(progress + "%");
},
};

tusUpload = new tus.Upload(file, options);
console.log({ tusUpload });
tusUpload.start();
});
}

return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<h1>Uploading Files using Tus Protocol</h1>
<Form onSubmit={startuploadProcess}>
<label className="btn-small btn-gray btn-trash flex justify-center items-center cursor-pointer btn-edit-image">
<input
type="file" onChange={(e) => {
const sellectFile = e.target.files ? e.target.files[0] : null;
setFile(sellectFile)
}}
/>
</label>
<button type="submit">Upload File</button>
</Form>
</div>
);
}
17 changes: 17 additions & 0 deletions tus-uploads/app/routes/api.tus-cloud-uploads.$fileId.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { json } from "@remix-run/node";
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/server-runtime";
import { handleTusRequest } from "../tusCloudBucketHandler.server";

export async function action({ request }: ActionFunctionArgs) {
const {method} = request;
if (method !== "PATCH") {
return json({ message: "Method not allowed" }, 405);
}

return handleTusRequest(request, method);
}

export const loader = async ({ request }: LoaderFunctionArgs) => {
const method = request.method;
return handleTusRequest(request, method);
}
19 changes: 19 additions & 0 deletions tus-uploads/app/routes/api.tus-cloud-uploads.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/server-runtime";
import { handleTusRequest } from "../tusCloudBucketHandler.server";
import { json } from "@remix-run/node";

export async function action({ request }: ActionFunctionArgs) {
const {method} = request;
return handleTusRequest(request, method);
}

export const loader = async ({ request }: LoaderFunctionArgs) => {
const method = request.method;
const urlToSearch = new URL(request.url);
const id = urlToSearch.searchParams.get("id") || "";
const mediaType = urlToSearch.searchParams.get("mediaType") || "";
const bucketName = urlToSearch.searchParams.get("bucketName") || "";
const data = await handleTusRequest(request, method, id, mediaType, bucketName);

return json({ data });
};
17 changes: 17 additions & 0 deletions tus-uploads/app/routes/api.tus-native-uploads.$fileId.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { json } from "@remix-run/node";
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/server-runtime";
import { handleTusRequest } from "../tusFileStoreHandler.server";

export async function action({ request }: ActionFunctionArgs) {
const {method} = request;
if (method !== "PATCH") {
return json({ message: "Method not allowed" }, 405);
}

return handleTusRequest(request, method);
}

export const loader = async ({ request }: LoaderFunctionArgs) => {
const {method} = request;
return handleTusRequest(request, method);
}
12 changes: 12 additions & 0 deletions tus-uploads/app/routes/api.tus-native-uploads.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/server-runtime";
import { handleTusRequest } from "../tusFileStoreHandler.server"

export async function action({ request }: ActionFunctionArgs) {
const {method} = request;
return handleTusRequest(request, method);
}

export const loader = async ({ request }: LoaderFunctionArgs) => {
const {method} = request;
return handleTusRequest(request, method);
};
Loading