diff --git a/.changeset/beige-walls-invent.md b/.changeset/beige-walls-invent.md new file mode 100644 index 0000000..6688c97 --- /dev/null +++ b/.changeset/beige-walls-invent.md @@ -0,0 +1,5 @@ +--- +"amplify-adapter-react-router": minor +--- + +feat: add amplify-adapter-react-router pacakge diff --git a/amplify.yml b/amplify.yml new file mode 100644 index 0000000..10b07d2 --- /dev/null +++ b/amplify.yml @@ -0,0 +1,26 @@ +version: 1 +applications: + - backend: + phases: + build: + commands: + - pwd + - "ls -al" + - echo "node-linker=hoisted" > ../../.npmrc + - corepack enable + - pnpm install --frozen-lockfile + - pnpm exec ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID + frontend: + phases: + build: + commands: + - pnpm --workspace-root build + artifacts: + baseDirectory: .amplify-hosting + files: + - "**/*" + cache: + paths: + - node_modules/**/* + buildPath: examples/todo-app + appRoot: examples/todo-app diff --git a/examples/todo-app/.dockerignore b/examples/todo-app/.dockerignore new file mode 100644 index 0000000..9b8d514 --- /dev/null +++ b/examples/todo-app/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/examples/todo-app/.gitignore b/examples/todo-app/.gitignore new file mode 100644 index 0000000..5f48967 --- /dev/null +++ b/examples/todo-app/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +/node_modules/ + +# React Router +/.react-router/ +/build/ + +# Amplify +/.amplify/ +/.amplify-hosting/ +amplify_outputs.json + diff --git a/examples/todo-app/README.md b/examples/todo-app/README.md new file mode 100644 index 0000000..8632b61 --- /dev/null +++ b/examples/todo-app/README.md @@ -0,0 +1,3 @@ +# Todo app using React Router SSR and AWS Amplify + +A Todo application using React Router and AWS Amplify. diff --git a/examples/todo-app/amplify/auth/resource.ts b/examples/todo-app/amplify/auth/resource.ts new file mode 100644 index 0000000..ec9de03 --- /dev/null +++ b/examples/todo-app/amplify/auth/resource.ts @@ -0,0 +1,7 @@ +import { defineAuth } from '@aws-amplify/backend'; + +export const auth = defineAuth({ + loginWith: { + email: true + } +}); diff --git a/examples/todo-app/amplify/backend.ts b/examples/todo-app/amplify/backend.ts new file mode 100644 index 0000000..8a95ef6 --- /dev/null +++ b/examples/todo-app/amplify/backend.ts @@ -0,0 +1,9 @@ +import { defineBackend } from '@aws-amplify/backend'; +import { auth } from './auth/resource'; +import { data } from './data/resource'; + +defineBackend({ + auth, + data +}); + diff --git a/examples/todo-app/amplify/data/resource.ts b/examples/todo-app/amplify/data/resource.ts new file mode 100644 index 0000000..3bb3a25 --- /dev/null +++ b/examples/todo-app/amplify/data/resource.ts @@ -0,0 +1,18 @@ +import { a, defineData, type ClientSchema } from '@aws-amplify/backend'; + +const schema = a.schema({ + Todo: a.model({ + content: a.string(), + isDone: a.boolean() + }) + .authorization(allow => [allow.publicApiKey()]) +}); + +export type Schema = ClientSchema; +export const data = defineData({ + schema, + authorizationModes: { + defaultAuthorizationMode: 'apiKey', + } +}); + diff --git a/examples/todo-app/app/app.css b/examples/todo-app/app/app.css new file mode 100644 index 0000000..99345d8 --- /dev/null +++ b/examples/todo-app/app/app.css @@ -0,0 +1,15 @@ +@import "tailwindcss"; + +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} + +html, +body { + @apply bg-white dark:bg-gray-950; + + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} diff --git a/examples/todo-app/app/components/TodoList.tsx b/examples/todo-app/app/components/TodoList.tsx new file mode 100644 index 0000000..50d212b --- /dev/null +++ b/examples/todo-app/app/components/TodoList.tsx @@ -0,0 +1,19 @@ +import { TodoListItem, type TodoListItemProps } from "./TodoListItem"; + +export type TodoListProps = { + items: TodoListItemProps["item"][]; +}; + +export function TodoList({ items }: TodoListProps) { + if (items.length === 0) { + return

No items

; + } + + return ( + + ); +} diff --git a/examples/todo-app/app/components/TodoListItem.tsx b/examples/todo-app/app/components/TodoListItem.tsx new file mode 100644 index 0000000..b0ef71c --- /dev/null +++ b/examples/todo-app/app/components/TodoListItem.tsx @@ -0,0 +1,39 @@ +import { Link, useFetcher } from "react-router"; +import type { Schema } from "../../amplify/data/resource"; + +export type Todo = Schema["Todo"]["type"]; + +export type TodoListItemProps = { + item: Todo; +}; + +export function TodoListItem({ item }: TodoListItemProps) { + const fetcher = useFetcher(); + const busy = fetcher.state !== "idle"; + + return ( +
  • +
    + + + {item.content} + + + {item.isDone && ☑️} +
    + + + +
  • + ); +} diff --git a/examples/todo-app/app/lib/amplify-ssr-client.ts b/examples/todo-app/app/lib/amplify-ssr-client.ts new file mode 100644 index 0000000..aa5c553 --- /dev/null +++ b/examples/todo-app/app/lib/amplify-ssr-client.ts @@ -0,0 +1,9 @@ +import type { Schema } from "amplify/data/resource"; +import { parseAmplifyConfig } from "aws-amplify/utils"; +import { generateClient } from "aws-amplify/api/server"; +import config from "../../amplify_outputs.json"; + +const amplifyConfig = parseAmplifyConfig(config); +export const client = generateClient({ + config: amplifyConfig, +}); diff --git a/examples/todo-app/app/lib/amplifyServerUtils.ts b/examples/todo-app/app/lib/amplifyServerUtils.ts new file mode 100644 index 0000000..f96b8b8 --- /dev/null +++ b/examples/todo-app/app/lib/amplifyServerUtils.ts @@ -0,0 +1,6 @@ +import { createServerRunner } from "amplify-adapter-react-router"; +import outputs from "../../amplify_outputs.json"; + +export const { runWithAmplifyServerContext } = createServerRunner({ + config: outputs, +}); diff --git a/examples/todo-app/app/root.tsx b/examples/todo-app/app/root.tsx new file mode 100644 index 0000000..e164eec --- /dev/null +++ b/examples/todo-app/app/root.tsx @@ -0,0 +1,80 @@ +import { + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; + +import type { Route } from "./+types/root"; +import "./app.css"; + +import config from "../amplify_outputs.json"; +import { Amplify } from "aws-amplify"; + +Amplify.configure(config, { ssr: true }); + +export const links: Route.LinksFunction = () => [ + { rel: "preconnect", href: "https://fonts.googleapis.com" }, + { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossOrigin: "anonymous", + }, + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", + }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = "Oops!"; + let details = "An unexpected error occurred."; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? "404" : "Error"; + details = + error.status === 404 + ? "The requested page could not be found." + : error.statusText || details; + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message; + stack = error.stack; + } + + return ( +
    +

    {message}

    +

    {details}

    + {stack && ( +
    +          {stack}
    +        
    + )} +
    + ); +} diff --git a/examples/todo-app/app/routes.ts b/examples/todo-app/app/routes.ts new file mode 100644 index 0000000..0eb7119 --- /dev/null +++ b/examples/todo-app/app/routes.ts @@ -0,0 +1,8 @@ +import { type RouteConfig, index, route } from "@react-router/dev/routes"; + +export default [ + index("./routes/index.tsx"), + route("new", "./routes/new.tsx"), + route(":todoId", "./routes/$todoId/index.tsx"), + route(":todoId/edit", "./routes/$todoId/edit.tsx"), +] satisfies RouteConfig; diff --git a/examples/todo-app/app/routes/$todoId/edit.tsx b/examples/todo-app/app/routes/$todoId/edit.tsx new file mode 100644 index 0000000..6456907 --- /dev/null +++ b/examples/todo-app/app/routes/$todoId/edit.tsx @@ -0,0 +1,95 @@ +import { data, Form, redirect } from "react-router"; +import type { Route } from "./+types/edit"; +import { runWithAmplifyServerContext } from "~/lib/amplifyServerUtils"; +import { client } from "~/lib/amplify-ssr-client"; + +export function meta() { + return [ + { title: "React Router Todo App" }, + { name: "description", content: "Add New Todo" }, + ]; +} + +export async function loader({ request, params }: Route.LoaderArgs) { + const { todoId } = params; + const responseHeaders = new Headers(); + const { data: todo, errors } = await runWithAmplifyServerContext({ + serverContext: { request, responseHeaders }, + operation: (contextSpec) => client.models.Todo.get(contextSpec, { id: todoId }), + }); + return data( + { + todo, + error: errors?.map((e) => e.message).join(", "), + }, + { + headers: responseHeaders, + }, + ); +} + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + const id = formData.get("id") as string;; + const content = formData.get("content"); + const isDone = formData.get("isDone") === "true"; + if (typeof content !== "string") { + throw new Error("Invalid content"); + } + const responseHeaders = new Headers(); + await runWithAmplifyServerContext({ + serverContext: { request, responseHeaders }, + operation: (contextSpec) => + client.models.Todo.update(contextSpec, { + id, + content, + isDone, + }), + }); + return redirect("/", { + headers: responseHeaders, + }); +} + +export default function EditTodo({ loaderData }: Route.ComponentProps) { + const { todo, error } = loaderData; + if (error) { + return
    Error: {error}
    ; + } + if (!todo) { + return
    Todo not found
    ; + } + return ( +
    +

    Edit Todo

    +
    + +