From 12bb02b895c42e344c3529313e039c424c29adfa Mon Sep 17 00:00:00 2001 From: James Daly Date: Mon, 23 Sep 2024 22:55:02 -0500 Subject: [PATCH 1/3] feat: add tus-uploads example --- tus-uploads/.env.example | 1 + tus-uploads/.gitignore | 3 + tus-uploads/.nvmrc | 1 + tus-uploads/README.md | 56 +++++ tus-uploads/app/bucket.server.tsx | 24 +++ tus-uploads/app/root.tsx | 29 +++ tus-uploads/app/routes/_index.tsx | 108 ++++++++++ .../routes/api.tus-cloud-uploads.$fileId.tsx | 17 ++ .../app/routes/api.tus-cloud-uploads.tsx | 19 ++ .../routes/api.tus-native-uploads.$fileId.tsx | 17 ++ .../app/routes/api.tus-native-uploads.tsx | 12 ++ .../app/tusCloudBucketHandler.server.tsx | 195 ++++++++++++++++++ .../app/tusFileStoreHandler.server.tsx | 171 +++++++++++++++ tus-uploads/package.json | 43 ++++ tus-uploads/public/favicon.ico | Bin 0 -> 16958 bytes tus-uploads/sandbox.config.json | 7 + tus-uploads/tsconfig.json | 32 +++ tus-uploads/vite.config.ts | 16 ++ 18 files changed, 751 insertions(+) create mode 100644 tus-uploads/.env.example create mode 100644 tus-uploads/.gitignore create mode 100644 tus-uploads/.nvmrc create mode 100644 tus-uploads/README.md create mode 100644 tus-uploads/app/bucket.server.tsx create mode 100644 tus-uploads/app/root.tsx create mode 100644 tus-uploads/app/routes/_index.tsx create mode 100644 tus-uploads/app/routes/api.tus-cloud-uploads.$fileId.tsx create mode 100644 tus-uploads/app/routes/api.tus-cloud-uploads.tsx create mode 100644 tus-uploads/app/routes/api.tus-native-uploads.$fileId.tsx create mode 100644 tus-uploads/app/routes/api.tus-native-uploads.tsx create mode 100644 tus-uploads/app/tusCloudBucketHandler.server.tsx create mode 100644 tus-uploads/app/tusFileStoreHandler.server.tsx create mode 100644 tus-uploads/package.json create mode 100644 tus-uploads/public/favicon.ico create mode 100644 tus-uploads/sandbox.config.json create mode 100644 tus-uploads/tsconfig.json create mode 100644 tus-uploads/vite.config.ts diff --git a/tus-uploads/.env.example b/tus-uploads/.env.example new file mode 100644 index 00000000..8ef578c9 --- /dev/null +++ b/tus-uploads/.env.example @@ -0,0 +1 @@ +PRIVATE_KEY_GOOGLE \ No newline at end of file diff --git a/tus-uploads/.gitignore b/tus-uploads/.gitignore new file mode 100644 index 00000000..b57e31b8 --- /dev/null +++ b/tus-uploads/.gitignore @@ -0,0 +1,3 @@ +/uploads +.env +/app/config.ts \ No newline at end of file diff --git a/tus-uploads/.nvmrc b/tus-uploads/.nvmrc new file mode 100644 index 00000000..9aef5aab --- /dev/null +++ b/tus-uploads/.nvmrc @@ -0,0 +1 @@ +v20.17.0 \ No newline at end of file diff --git a/tus-uploads/README.md b/tus-uploads/README.md new file mode 100644 index 00000000..8249f2cb --- /dev/null +++ b/tus-uploads/README.md @@ -0,0 +1,56 @@ +# 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 +| └── utils +| └── supabaseClient.server.tsx // create supabase client on the server side +└── .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" - Get metadata regarding file + +## 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 diff --git a/tus-uploads/app/bucket.server.tsx b/tus-uploads/app/bucket.server.tsx new file mode 100644 index 00000000..db92ee5d --- /dev/null +++ b/tus-uploads/app/bucket.server.tsx @@ -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: "", + }, + }); +} diff --git a/tus-uploads/app/root.tsx b/tus-uploads/app/root.tsx new file mode 100644 index 00000000..e82f26fd --- /dev/null +++ b/tus-uploads/app/root.tsx @@ -0,0 +1,29 @@ +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} diff --git a/tus-uploads/app/routes/_index.tsx b/tus-uploads/app/routes/_index.tsx new file mode 100644 index 00000000..26d190a9 --- /dev/null +++ b/tus-uploads/app/routes/_index.tsx @@ -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 ( +
+

Uploading Files using Tus Protocol

+
+ + +
+
+ ); +} diff --git a/tus-uploads/app/routes/api.tus-cloud-uploads.$fileId.tsx b/tus-uploads/app/routes/api.tus-cloud-uploads.$fileId.tsx new file mode 100644 index 00000000..178993b0 --- /dev/null +++ b/tus-uploads/app/routes/api.tus-cloud-uploads.$fileId.tsx @@ -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); +} diff --git a/tus-uploads/app/routes/api.tus-cloud-uploads.tsx b/tus-uploads/app/routes/api.tus-cloud-uploads.tsx new file mode 100644 index 00000000..d3f6c34b --- /dev/null +++ b/tus-uploads/app/routes/api.tus-cloud-uploads.tsx @@ -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 }); +}; diff --git a/tus-uploads/app/routes/api.tus-native-uploads.$fileId.tsx b/tus-uploads/app/routes/api.tus-native-uploads.$fileId.tsx new file mode 100644 index 00000000..434a2bad --- /dev/null +++ b/tus-uploads/app/routes/api.tus-native-uploads.$fileId.tsx @@ -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); +} diff --git a/tus-uploads/app/routes/api.tus-native-uploads.tsx b/tus-uploads/app/routes/api.tus-native-uploads.tsx new file mode 100644 index 00000000..b7a02359 --- /dev/null +++ b/tus-uploads/app/routes/api.tus-native-uploads.tsx @@ -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); +}; diff --git a/tus-uploads/app/tusCloudBucketHandler.server.tsx b/tus-uploads/app/tusCloudBucketHandler.server.tsx new file mode 100644 index 00000000..c136835a --- /dev/null +++ b/tus-uploads/app/tusCloudBucketHandler.server.tsx @@ -0,0 +1,195 @@ +import { GCSStore } from "@tus/gcs-store"; +import { getCloudStorage } from "./bucket.server" +import { Readable } from "stream"; +const { randomUUID } = await import("node:crypto"); + +const storage = getCloudStorage(); + + +export async function handleTusRequest(request:any, method: string, id?:string, fileType?: string, nameOfBucket?: string) { + + // we only do this on a GET request to update metadata of content type + if (id && nameOfBucket && fileType) { + const bucket = storage.bucket(nameOfBucket); + const file = bucket.file(id); + const fileMetadata = await file.setMetadata({ + contentType: fileType, + }); + return fileMetadata; + } + + switch (method) { + case "POST": + return handlePostRequest(request); + case "PATCH": + return handlePatchRequest(request); + case "HEAD": + return handleHeadRequest(request); + default: + return new Response("Method Not Allowed", { status: 405 }); + } +} + +async function handlePostRequest(request: any) { + const uploadLength = request.headers.get("Upload-Length"); + const metadata = request.headers.get("Upload-Metadata"); + const parsedMetaData = parseMetadata(metadata); + + const { destination } = parsedMetaData; + const targetBucket = destination; + + const store = new GCSStore({ + bucket: storage.bucket(targetBucket), + }); + + let id; + + if (!uploadLength) { + return new Response("Upload-Length header is required", { status: 400 }); + } + console.log({ uploadLength }); + + const genereatedId = randomUUID(); + try { + const file = await store.create({ + metadata: parsedMetaData, + id: genereatedId, + offset: parseInt(uploadLength), + size: uploadLength, + creation_date: undefined, + storage: undefined, + sizeIsDeferred: false + }); + + id = file.id; + } catch (error) { + console.error("Error creating file:", error); + } + + const location = new URL(request.url); + location.pathname += `/${id}`; + location.search = `?bucket=${targetBucket}`; + + return new Response(null, { + status: 201, + headers: { + Location: location.toString(), + "Tus-Resumable": "1.0.0", + }, + }); +} + +async function handlePatchRequest(request: any) { + const url = new URL(request.url); + const fileId = url.pathname.split("/").pop(); + const bucketName = url.searchParams.get("bucket"); + const offset = parseInt(request.headers.get("Upload-Offset") || "0"); + + if (!bucketName) { + return new Response("bucket name not provided", { status: 404 }); + } + + const store = new GCSStore({ + bucket: storage.bucket(bucketName), + }); + + if (!store) { + return new Response("Upload not found", { status: 404 }); + } + + if (!fileId) { + return new Response("File ID is required", { status: 400 }); + } + try { + const body = await request.arrayBuffer(); + if (!body) { + return new Response("Request body is missing", { status: 400 }); + } + + const buffer = Buffer.from(body); + const readable = Readable.from(buffer); + const newOffset = await store.write(readable, fileId, offset); + + console.log({ newOffset }); + + return new Response(null, { + status: 204, + headers: { + "Upload-Offset": newOffset.toString(), + "Tus-Resumable": "1.0.0", + }, + }); + } catch (error) { + console.error("Error handling PATCH request:", error); + return new Response("Internal Server Error", { status: 500 }); + } +} + +async function handleHeadRequest(request:any) { + const url = new URL(request.url); + const fileId = url.pathname.split("/").pop(); + const bucketName = url.searchParams.get("bucket"); + + if (!fileId) { + return new Response("File ID is required", { status: 400 }); + } + + if (!bucketName) { + return new Response("bucket name not provided", { status: 404 }); + } + + const bucket = storage.bucket(bucketName); + const file = bucket.file(fileId); + + try { + const [metadata] = await file.getMetadata(); + console.log({ metadata }); + + const thirdTierMetaData = metadata?.metadata?.metadata; + + const stringifyMetaData = stringify(thirdTierMetaData); + + const metaDataOffset = metadata?.metadata?.["offset"]; + const uploadSizeMetaData = metadata?.metadata?.["size"]; + + const uploadOffset = parseInt(metaDataOffset as any, 10); + const uploadSize = parseInt(uploadSizeMetaData as any, 10); + + return new Response(null, { + status: 200, + headers: { + "Upload-Offset": uploadOffset.toString(), + "Upload-Length": uploadSize.toString(), + "Tus-Resumable": "1.0.0", + "Cache-Control": "no-store", + "Upload-Metadata": stringifyMetaData, + }, + }); + } catch (error) { + console.error("Error retrieving upload info:", error); + throw error; + } +} + +function parseMetadata(metadata: any) { + if (!metadata) return {}; + const data = metadata.split(",").reduce((acc: { [x: string]: string; }, pair: { split: (arg0: string) => [any, any]; }) => { + const [key, value] = pair.split(" "); + acc[key] = Buffer.from(value, "base64").toString("utf-8"); + return acc; + }, {}); + return data; +} + +function stringify(metadata: NonNullable): string { + return Object.entries(metadata) + .map(([key, value]) => { + if (value === null) { + return key + } + + const encodedValue = Buffer.from(value as any, 'utf8').toString('base64') + return `${key} ${encodedValue}` + }) + .join(',') +} diff --git a/tus-uploads/app/tusFileStoreHandler.server.tsx b/tus-uploads/app/tusFileStoreHandler.server.tsx new file mode 100644 index 00000000..a1803101 --- /dev/null +++ b/tus-uploads/app/tusFileStoreHandler.server.tsx @@ -0,0 +1,171 @@ +import { Readable } from "stream"; +const { randomUUID } = await import("node:crypto"); +import { FileStore } from "@tus/file-store"; + + +export async function handleTusRequest(request:any, method: string) { + + switch (method) { + case "POST": + return handlePostRequest(request); + case "PATCH": + return handlePatchRequest(request); + case "HEAD": + return handleHeadRequest(request); + default: + return new Response("Method Not Allowed", { status: 405 }); + } +} + +async function handlePostRequest(request: any) { + const uploadLength = request.headers.get("Upload-Length"); + const metadata = request.headers.get("Upload-Metadata"); + const parsedMetaData = parseMetadata(metadata); + + const { destination } = parsedMetaData; + const directory = destination; + + const dataStore = new FileStore({directory}); + + let id; + + if (!uploadLength) { + return new Response("Upload-Length header is required", { status: 400 }); + } + console.log({ uploadLength }); + + const genereatedId = randomUUID(); + try { + const file = await dataStore.create({ + metadata: parsedMetaData, + id: genereatedId, + offset: parseInt(uploadLength), + size: uploadLength, + creation_date: undefined, + storage: undefined, + sizeIsDeferred: false + }); + id = file.id; + } catch (error) { + console.error("Error creating file:", error); + } + + const location = new URL(request.url); + location.pathname += `/${id}`; + location.search = `?directory=${directory}`; + + return new Response(null, { + status: 201, + headers: { + Location: location.toString(), + "Tus-Resumable": "1.0.0", + }, + }); +} + +async function handlePatchRequest(request: any) { + const url = new URL(request.url); + const fileId = url.pathname.split("/").pop(); + const directory = url.searchParams.get("directory"); + const offset = parseInt(request.headers.get("Upload-Offset") || "0"); + + if (!directory) { + return new Response("bucket name not provided", { status: 404 }); + } + + const dataStore = new FileStore({directory}); + + if (!dataStore) { + return new Response("Upload not found", { status: 404 }); + } + + if (!fileId) { + return new Response("File ID is required", { status: 400 }); + } + try { + const body = await request.arrayBuffer(); + if (!body) { + return new Response("Request body is missing", { status: 400 }); + } + + const buffer = Buffer.from(body); + const readable = Readable.from(buffer); + const newOffset = await dataStore.write(readable, fileId, offset); + + console.log({ newOffset }); + + return new Response(null, { + status: 204, + headers: { + "Upload-Offset": newOffset.toString(), + "Tus-Resumable": "1.0.0", + }, + }); + } catch (error) { + console.error("Error handling PATCH request:", error); + return new Response("Internal Server Error", { status: 500 }); + } +} + +async function handleHeadRequest(request:any) { + const url = new URL(request.url); + const fileId = url.pathname.split("/").pop(); + const directory = url.searchParams.get("directory"); + + if (!fileId) { + return new Response("File ID is required", { status: 400 }); + } + + if (!directory) { + return new Response("bucket name not provided", { status: 404 }); + } + + const dataStore = new FileStore({directory}); + + try { + const file = await dataStore.getUpload(fileId); + const uploadOffset = file.offset + const uploadSize = file.size as number; + let stringifyMetaData = "" + if (file.metadata !== undefined) { + stringifyMetaData = stringify(file.metadata); + } + + return new Response(null, { + status: 200, + headers: { + "Upload-Offset": uploadOffset.toString(), + "Upload-Length": uploadSize.toString(), + "Tus-Resumable": "1.0.0", + "Cache-Control": "no-store", + "Upload-Metadata": stringifyMetaData, + }, + }); + } catch (error) { + console.error("Error retrieving upload info:", error); + throw error; + } +} + +function parseMetadata(metadata: any) { + if (!metadata) return {}; + const data = metadata.split(",").reduce((acc: { [x: string]: string; }, pair: { split: (arg0: string) => [any, any]; }) => { + const [key, value] = pair.split(" "); + acc[key] = Buffer.from(value, "base64").toString("utf-8"); + return acc; + }, {}); + return data; +} + +export function stringify(metadata: NonNullable): string { + return Object.entries(metadata) + .map(([key, value]) => { + if (value === null) { + return key + } + + const encodedValue = Buffer.from(value as any, 'utf8').toString('base64') + return `${key} ${encodedValue}` + }) + .join(',') +} diff --git a/tus-uploads/package.json b/tus-uploads/package.json new file mode 100644 index 00000000..d416296b --- /dev/null +++ b/tus-uploads/package.json @@ -0,0 +1,43 @@ +{ + "name": "template", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "remix vite:build", + "dev": "remix vite:dev", + "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", + "start": "remix-serve ./build/server/index.js", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/node": "^2.9.2", + "@remix-run/react": "^2.9.2", + "@remix-run/serve": "^2.9.2", + "@tus/file-store": "^1.5.0", + "@tus/gcs-store": "^1.4.0", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tus-js-client": "^4.2.3" + }, + "devDependencies": { + "@remix-run/dev": "^2.9.2", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.7.4", + "@typescript-eslint/parser": "^6.7.4", + "eslint": "^8.38.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "typescript": "^5.1.6", + "vite": "^5.1.0", + "vite-tsconfig-paths": "^4.2.1" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/tus-uploads/public/favicon.ico b/tus-uploads/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8830cf6821b354114848e6354889b8ecf6d2bc61 GIT binary patch literal 16958 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 EKbET!0{{R3 literal 0 HcmV?d00001 diff --git a/tus-uploads/sandbox.config.json b/tus-uploads/sandbox.config.json new file mode 100644 index 00000000..f92e0250 --- /dev/null +++ b/tus-uploads/sandbox.config.json @@ -0,0 +1,7 @@ +{ + "hardReloadOnChange": true, + "template": "remix", + "container": { + "port": 3000 + } +} diff --git a/tus-uploads/tsconfig.json b/tus-uploads/tsconfig.json new file mode 100644 index 00000000..9d87dd37 --- /dev/null +++ b/tus-uploads/tsconfig.json @@ -0,0 +1,32 @@ +{ + "include": [ + "**/*.ts", + "**/*.tsx", + "**/.server/**/*.ts", + "**/.server/**/*.tsx", + "**/.client/**/*.ts", + "**/.client/**/*.tsx" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["@remix-run/node", "vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Vite takes care of building everything, not tsc. + "noEmit": true + } +} diff --git a/tus-uploads/vite.config.ts b/tus-uploads/vite.config.ts new file mode 100644 index 00000000..54066fb7 --- /dev/null +++ b/tus-uploads/vite.config.ts @@ -0,0 +1,16 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + remix({ + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + }, + }), + tsconfigPaths(), + ], +}); From d2c5e1fb9fd816cd708feb4c3a881eb14f42154c Mon Sep 17 00:00:00 2001 From: James Daly Date: Tue, 24 Sep 2024 15:23:32 -0500 Subject: [PATCH 2/3] update readme --- tus-uploads/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tus-uploads/README.md b/tus-uploads/README.md index 8249f2cb..f2e0ffad 100644 --- a/tus-uploads/README.md +++ b/tus-uploads/README.md @@ -34,7 +34,7 @@ Servers like Cloud Run usually have a fixed limit ~32 mb of what data your uploa ## 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" - Get metadata regarding file +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 ## Production On an environment like cloud run you may need to set content security policy header @@ -54,3 +54,10 @@ see issue here - https://github.com/tus/tus-js-client/issues/186 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. + From 570d41ec4ddb149703cb03f81773cb90ab04080a Mon Sep 17 00:00:00 2001 From: James Daly Date: Tue, 24 Sep 2024 15:31:11 -0500 Subject: [PATCH 3/3] update readme more links --- tus-uploads/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tus-uploads/README.md b/tus-uploads/README.md index f2e0ffad..544004c7 100644 --- a/tus-uploads/README.md +++ b/tus-uploads/README.md @@ -15,8 +15,6 @@ The relevent files are: | │ └── 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 -| └── utils -| └── supabaseClient.server.tsx // create supabase client on the server side └── .env // hold cloud bucket credentials secret key ``` @@ -34,7 +32,13 @@ Servers like Cloud Run usually have a fixed limit ~32 mb of what data your uploa ## 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 +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