diff --git a/file-and-s3-upload-v2/.env.sample b/file-and-s3-upload-v2/.env.sample
new file mode 100644
index 00000000..17b368d3
--- /dev/null
+++ b/file-and-s3-upload-v2/.env.sample
@@ -0,0 +1,4 @@
+STORAGE_ACCESS_KEY=
+STORAGE_SECRET=
+STORAGE_REGION=
+STORAGE_BUCKET=
\ No newline at end of file
diff --git a/file-and-s3-upload-v2/.eslintrc.js b/file-and-s3-upload-v2/.eslintrc.js
new file mode 100644
index 00000000..2061cd22
--- /dev/null
+++ b/file-and-s3-upload-v2/.eslintrc.js
@@ -0,0 +1,4 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
+};
diff --git a/file-and-s3-upload-v2/.gitignore b/file-and-s3-upload-v2/.gitignore
new file mode 100644
index 00000000..3f7bf98d
--- /dev/null
+++ b/file-and-s3-upload-v2/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+
+/.cache
+/build
+/public/build
+.env
diff --git a/file-and-s3-upload-v2/README.md b/file-and-s3-upload-v2/README.md
new file mode 100644
index 00000000..9fc13b1c
--- /dev/null
+++ b/file-and-s3-upload-v2/README.md
@@ -0,0 +1,37 @@
+# Upload images to S3 with AWS SDK v2
+
+> **Note:** This example is for the old AWS SDK v2. For the updated with the AWS SDK v3 example, see [file-and-s3-upload](https://github.com/remix-run/examples/tree/main/file-and-s3-upload).
+
+This is a simple example of using the remix built-in [uploadHandler](https://remix.run/utils/parse-multipart-form-data#uploadhandler) and Form with multipart data to upload a file with the built-in local uploader and upload an image file to S3 with a custom uploader and display it. You can test it locally by running the dev server and opening the path `/s3-upload` in your browser.
+
+The relevent files are:
+
+```
+├── app
+│ ├── routes
+│ │ ├── s3-upload.tsx // upload to S3
+│ └── utils
+│ └── s3.server.ts // init S3 client on server side
+|── .env // holds AWS S3 credentails
+```
+
+## Steps to set up an S3 bucket
+
+- Sign up for an [AWS account](https://portal.aws.amazon.com/billing/signup) - this will require a credit card
+- Create an S3 bucket in your desired region
+- Create an access key pair for an IAM user that has access to the bucket
+- Copy the .env.sample to .env and fill in the S3 bucket, the region as well as the access key and secret key from the IAM user
+
+Note: in order for the image to be displayed after being uploaded to your S3 bucket in this example, the bucket needs to have public access enabled, which is potentially dangerous.
+
+> :warning: Lambda imposes a [limit of 6MB](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html) on the invocation payload size. If you use this example with Remix running on Lambda, you can only update files with a size smaller than 6MB.
+
+Open this example on [CodeSandbox](https://codesandbox.com):
+
+[![Open in CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/remix-run/examples/tree/main/file-and-s3-upload)
+
+## Related Links
+
+- [Handle Multiple Part Forms (File Uploads)](https://remix.run/utils/parse-multipart-form-data-node)
+- [Upload Handler](https://remix.run/utils/parse-multipart-form-data#uploadhandler)
+- [Custom Uploader](https://remix.run/guides/file-uploads)
diff --git a/file-and-s3-upload-v2/app/entry.client.tsx b/file-and-s3-upload-v2/app/entry.client.tsx
new file mode 100644
index 00000000..852a7928
--- /dev/null
+++ b/file-and-s3-upload-v2/app/entry.client.tsx
@@ -0,0 +1,21 @@
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+const hydrate = () =>
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/file-and-s3-upload-v2/app/entry.server.tsx b/file-and-s3-upload-v2/app/entry.server.tsx
new file mode 100644
index 00000000..dc6248a2
--- /dev/null
+++ b/file-and-s3-upload-v2/app/entry.server.tsx
@@ -0,0 +1,110 @@
+import { PassThrough } from "stream";
+
+import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
+import { RemixServer } from "@remix-run/react";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5000;
+
+const handleRequest = (
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) =>
+ isbot(request.headers.get("user-agent"))
+ ? handleBotRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ )
+ : handleBrowserRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ );
+export default handleRequest;
+
+const handleBotRequest = (
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) =>
+ new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady: () => {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError: (error: unknown) => {
+ reject(error);
+ },
+ onError: (error: unknown) => {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+
+const handleBrowserRequest = (
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext
+) =>
+ new Promise((resolve, reject) => {
+ let didError = false;
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady: () => {
+ const body = new PassThrough();
+
+ responseHeaders.set("Content-Type", "text/html");
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ })
+ );
+
+ pipe(body);
+ },
+ onShellError: (error: unknown) => {
+ reject(error);
+ },
+ onError: (error: unknown) => {
+ didError = true;
+
+ console.error(error);
+ },
+ }
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
diff --git a/file-and-s3-upload-v2/app/root.tsx b/file-and-s3-upload-v2/app/root.tsx
new file mode 100644
index 00000000..927a0f74
--- /dev/null
+++ b/file-and-s3-upload-v2/app/root.tsx
@@ -0,0 +1,32 @@
+import type { MetaFunction } from "@remix-run/node";
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+} from "@remix-run/react";
+
+export const meta: MetaFunction = () => ({
+ charset: "utf-8",
+ title: "New Remix App",
+ viewport: "width=device-width,initial-scale=1",
+});
+
+export default function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/file-and-s3-upload-v2/app/routes/s3-upload.tsx b/file-and-s3-upload-v2/app/routes/s3-upload.tsx
new file mode 100644
index 00000000..b6fbce86
--- /dev/null
+++ b/file-and-s3-upload-v2/app/routes/s3-upload.tsx
@@ -0,0 +1,68 @@
+import type { ActionArgs, UploadHandler } from "@remix-run/node";
+import {
+ json,
+ unstable_composeUploadHandlers as composeUploadHandlers,
+ unstable_createMemoryUploadHandler as createMemoryUploadHandler,
+ unstable_parseMultipartFormData as parseMultipartFormData,
+} from "@remix-run/node";
+import { useFetcher } from "@remix-run/react";
+
+import { s3UploadHandler } from "~/utils/s3.server";
+
+type ActionData = {
+ errorMsg?: string;
+ imgSrc?: string;
+ imgDesc?: string;
+};
+
+export const action = async ({ request }: ActionArgs) => {
+ const uploadHandler: UploadHandler = composeUploadHandlers(
+ s3UploadHandler,
+ createMemoryUploadHandler()
+ );
+ const formData = await parseMultipartFormData(request, uploadHandler);
+ const imgSrc = formData.get("img");
+ const imgDesc = formData.get("desc");
+ console.log(imgDesc);
+ if (!imgSrc) {
+ return json({
+ errorMsg: "Something went wrong while uploading",
+ });
+ }
+ return json({
+ imgSrc,
+ imgDesc,
+ });
+};
+
+export default function Index() {
+ const fetcher = useFetcher();
+ return (
+ <>
+
+
+
+
+
+
+
+ {fetcher.type === "done" ? (
+ fetcher.data.errorMsg ? (
+ {fetcher.data.errorMsg}
+ ) : (
+ <>
+
+ File has been uploaded to S3 and is available under the following
+ URL (if the bucket has public access enabled):
+
+ {fetcher.data.imgSrc}
+
+ >
+ )
+ ) : null}
+ >
+ );
+}
diff --git a/file-and-s3-upload-v2/app/utils/s3.server.ts b/file-and-s3-upload-v2/app/utils/s3.server.ts
new file mode 100644
index 00000000..5c2a0dba
--- /dev/null
+++ b/file-and-s3-upload-v2/app/utils/s3.server.ts
@@ -0,0 +1,50 @@
+import { PassThrough } from "stream";
+
+import AWS from "aws-sdk";
+import type { UploadHandler } from "@remix-run/node";
+import { writeAsyncIterableToWritable } from "@remix-run/node";
+
+const { STORAGE_ACCESS_KEY, STORAGE_SECRET, STORAGE_REGION, STORAGE_BUCKET } =
+ process.env;
+
+if (
+ !(STORAGE_ACCESS_KEY && STORAGE_SECRET && STORAGE_REGION && STORAGE_BUCKET)
+) {
+ throw new Error(`Storage is missing required configuration.`);
+}
+
+const uploadStream = ({ Key }: Pick) => {
+ const s3 = new AWS.S3({
+ credentials: {
+ accessKeyId: STORAGE_ACCESS_KEY,
+ secretAccessKey: STORAGE_SECRET,
+ },
+ region: STORAGE_REGION,
+ });
+ const pass = new PassThrough();
+ return {
+ writeStream: pass,
+ promise: s3.upload({ Bucket: STORAGE_BUCKET, Key, Body: pass }).promise(),
+ };
+};
+
+export async function uploadStreamToS3(data: any, filename: string) {
+ const stream = uploadStream({
+ Key: filename,
+ });
+ await writeAsyncIterableToWritable(data, stream.writeStream);
+ const file = await stream.promise;
+ return file.Location;
+}
+
+export const s3UploadHandler: UploadHandler = async ({
+ name,
+ filename,
+ data,
+}) => {
+ if (name !== "img") {
+ return undefined;
+ }
+ const uploadedFileLocation = await uploadStreamToS3(data, filename!);
+ return uploadedFileLocation;
+};
diff --git a/file-and-s3-upload-v2/package.json b/file-and-s3-upload-v2/package.json
new file mode 100644
index 00000000..86b79f36
--- /dev/null
+++ b/file-and-s3-upload-v2/package.json
@@ -0,0 +1,30 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "build": "remix build",
+ "dev": "remix dev",
+ "start": "remix-serve build",
+ "typecheck": "tsc"
+ },
+ "dependencies": {
+ "@remix-run/node": "*",
+ "@remix-run/react": "*",
+ "@remix-run/serve": "*",
+ "aws-sdk": "^2.1152.0",
+ "isbot": "^3.6.5",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@remix-run/dev": "*",
+ "@remix-run/eslint-config": "*",
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.8",
+ "eslint": "^8.27.0",
+ "typescript": "^4.6.2"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+}
diff --git a/file-and-s3-upload-v2/public/favicon.ico b/file-and-s3-upload-v2/public/favicon.ico
new file mode 100644
index 00000000..8830cf68
Binary files /dev/null and b/file-and-s3-upload-v2/public/favicon.ico differ
diff --git a/file-and-s3-upload-v2/remix.config.js b/file-and-s3-upload-v2/remix.config.js
new file mode 100644
index 00000000..260b82c7
--- /dev/null
+++ b/file-and-s3-upload-v2/remix.config.js
@@ -0,0 +1,10 @@
+/**
+ * @type {import('@remix-run/dev').AppConfig}
+ */
+module.exports = {
+ ignoredRouteFiles: ["**/.*"],
+ // appDirectory: "app",
+ // assetsBuildDirectory: "public/build",
+ // serverBuildPath: "build/index.js",
+ // publicPath: "/build/",
+};
diff --git a/file-and-s3-upload-v2/remix.env.d.ts b/file-and-s3-upload-v2/remix.env.d.ts
new file mode 100644
index 00000000..dcf8c45e
--- /dev/null
+++ b/file-and-s3-upload-v2/remix.env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/file-and-s3-upload-v2/sandbox.config.json b/file-and-s3-upload-v2/sandbox.config.json
new file mode 100644
index 00000000..f92e0250
--- /dev/null
+++ b/file-and-s3-upload-v2/sandbox.config.json
@@ -0,0 +1,7 @@
+{
+ "hardReloadOnChange": true,
+ "template": "remix",
+ "container": {
+ "port": 3000
+ }
+}
diff --git a/file-and-s3-upload-v2/tsconfig.json b/file-and-s3-upload-v2/tsconfig.json
new file mode 100644
index 00000000..20f8a386
--- /dev/null
+++ b/file-and-s3-upload-v2/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "target": "ES2019",
+ "strict": true,
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+
+ // Remix takes care of building everything in `remix build`.
+ "noEmit": true
+ }
+}
diff --git a/file-and-s3-upload/.env.sample b/file-and-s3-upload/.env.sample
index 17b368d3..6b14abc5 100644
--- a/file-and-s3-upload/.env.sample
+++ b/file-and-s3-upload/.env.sample
@@ -1,4 +1,4 @@
-STORAGE_ACCESS_KEY=
-STORAGE_SECRET=
-STORAGE_REGION=
-STORAGE_BUCKET=
\ No newline at end of file
+AWS_ACCESS_KEY_ID=
+AWS_SECRET_ACCESS_KEY=
+S3_STORAGE_REGION=
+S3_STORAGE_BUCKET=
\ No newline at end of file
diff --git a/file-and-s3-upload/app/utils/s3.server.ts b/file-and-s3-upload/app/utils/s3.server.ts
index 5c2a0dba..40b10df4 100644
--- a/file-and-s3-upload/app/utils/s3.server.ts
+++ b/file-and-s3-upload/app/utils/s3.server.ts
@@ -1,30 +1,42 @@
import { PassThrough } from "stream";
-import AWS from "aws-sdk";
+import type { PutObjectCommandInput } from '@aws-sdk/client-s3';
+import { S3Client } from '@aws-sdk/client-s3';
+import { Upload } from '@aws-sdk/lib-storage';
import type { UploadHandler } from "@remix-run/node";
import { writeAsyncIterableToWritable } from "@remix-run/node";
-const { STORAGE_ACCESS_KEY, STORAGE_SECRET, STORAGE_REGION, STORAGE_BUCKET } =
- process.env;
+const {
+ AWS_ACCESS_KEY_ID,
+ AWS_SECRET_ACCESS_KEY,
+ S3_STORAGE_BUCKET,
+ S3_STORAGE_REGION,
+} = process.env;
if (
- !(STORAGE_ACCESS_KEY && STORAGE_SECRET && STORAGE_REGION && STORAGE_BUCKET)
+ !(
+ AWS_ACCESS_KEY_ID &&
+ AWS_SECRET_ACCESS_KEY &&
+ S3_STORAGE_BUCKET &&
+ S3_STORAGE_REGION
+ )
) {
throw new Error(`Storage is missing required configuration.`);
}
-const uploadStream = ({ Key }: Pick) => {
- const s3 = new AWS.S3({
- credentials: {
- accessKeyId: STORAGE_ACCESS_KEY,
- secretAccessKey: STORAGE_SECRET,
- },
- region: STORAGE_REGION,
- });
+const uploadStream = ({ Key }: Pick) => {
+ const client = new S3Client({ region: S3_STORAGE_REGION });
const pass = new PassThrough();
return {
writeStream: pass,
- promise: s3.upload({ Bucket: STORAGE_BUCKET, Key, Body: pass }).promise(),
+ promise: new Upload({
+ client,
+ params: {
+ Body: pass,
+ Bucket: S3_STORAGE_BUCKET,
+ Key,
+ },
+ }).done(),
};
};
@@ -34,7 +46,12 @@ export async function uploadStreamToS3(data: any, filename: string) {
});
await writeAsyncIterableToWritable(data, stream.writeStream);
const file = await stream.promise;
- return file.Location;
+
+ if ('Location' in file) {
+ return file.Location;
+ }
+
+ throw new Error('Upload to S3 aborted');
}
export const s3UploadHandler: UploadHandler = async ({
diff --git a/file-and-s3-upload/package.json b/file-and-s3-upload/package.json
index 86b79f36..6a268d39 100644
--- a/file-and-s3-upload/package.json
+++ b/file-and-s3-upload/package.json
@@ -1,17 +1,10 @@
{
- "private": true,
- "sideEffects": false,
- "scripts": {
- "build": "remix build",
- "dev": "remix dev",
- "start": "remix-serve build",
- "typecheck": "tsc"
- },
"dependencies": {
+ "@aws-sdk/client-s3": "^3.272.0",
+ "@aws-sdk/lib-storage": "^3.272.0",
"@remix-run/node": "*",
"@remix-run/react": "*",
"@remix-run/serve": "*",
- "aws-sdk": "^2.1152.0",
"isbot": "^3.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -26,5 +19,13 @@
},
"engines": {
"node": ">=14"
- }
-}
+ },
+ "private": true,
+ "scripts": {
+ "build": "remix build",
+ "dev": "remix dev",
+ "start": "remix-serve build",
+ "typecheck": "tsc"
+ },
+ "sideEffects": false
+}
\ No newline at end of file