diff --git a/accelerate/remix-starter/.env.example b/accelerate/remix-starter/.env.example new file mode 100644 index 000000000000..5c8813b77c04 --- /dev/null +++ b/accelerate/remix-starter/.env.example @@ -0,0 +1,3 @@ +DIRECT_DATABASE_URL="__YOUR_DATABASE_CONNECTION_STRING__" +DATABASE_URL="__YOUR_ACCELERATE_CONNECTION_STRING__" +NODE_ENV="development" \ No newline at end of file diff --git a/accelerate/remix-starter/.eslintignore b/accelerate/remix-starter/.eslintignore new file mode 100644 index 000000000000..67191efbb0b9 --- /dev/null +++ b/accelerate/remix-starter/.eslintignore @@ -0,0 +1,7 @@ +.cache +build +public/build +app/styles +dist/ +node_modules +api/ diff --git a/accelerate/remix-starter/.eslintrc b/accelerate/remix-starter/.eslintrc new file mode 100644 index 000000000000..c22f538460f1 --- /dev/null +++ b/accelerate/remix-starter/.eslintrc @@ -0,0 +1,68 @@ +{ + "parser": "@typescript-eslint/parser", + "globals": { + "module": true, + "require": true, + "process": true, + "exports": true + }, + "settings": { + "react": { + "version": "detect" + }, + "formComponents": ["Form"], + "linkComponents": [ + { "name": "Link", "linkAttribute": "to" }, + { "name": "NavLink", "linkAttribute": "to" } + ] + }, + "plugins": [ + "prettier", + "@typescript-eslint", + "jsx-a11y", + "import", + "react", + "react-hooks", + "lodash" + ], + "extends": [ + "eslint:recommended", + "prettier", + "plugin:prettier/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:jsx-a11y/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + "plugin:react/jsx-runtime", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "@remix-run/eslint-config/node" + ], + "rules": { + "lodash/import-scope": [2, "method"], + "prettier/prettier": "error", + "import/no-unresolved": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } + ], + "jsx-a11y/anchor-is-valid": [ + "error", + { + "components": ["Link", "NavLink"], + "specialLink": ["to"] + } + ], + "react/boolean-prop-naming": "error", + "react/react-in-jsx-scope": "off", + "react/button-has-type": "error", + "react/jsx-no-target-blank": [ + "error", + { + "warnOnSpreadAttributes": true, + "links": true, + "forms": true + } + ] + } +} diff --git a/accelerate/remix-starter/.gitignore b/accelerate/remix-starter/.gitignore new file mode 100644 index 000000000000..1e76147d5ced --- /dev/null +++ b/accelerate/remix-starter/.gitignore @@ -0,0 +1,12 @@ +node_modules +.cache +.output +build +public/build +api/* +.env +test-results/ +playwright-report/ +.eslintcache + +!.env.example \ No newline at end of file diff --git a/accelerate/remix-starter/.npmrc b/accelerate/remix-starter/.npmrc new file mode 100644 index 000000000000..dc48c97ba2c6 --- /dev/null +++ b/accelerate/remix-starter/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=false +shamefully-hoist=true diff --git a/accelerate/remix-starter/.prettierrc b/accelerate/remix-starter/.prettierrc new file mode 100644 index 000000000000..bf357fbbc081 --- /dev/null +++ b/accelerate/remix-starter/.prettierrc @@ -0,0 +1,3 @@ +{ + "trailingComma": "all" +} diff --git a/accelerate/remix-starter/.tool-versions b/accelerate/remix-starter/.tool-versions new file mode 100644 index 000000000000..02fda068233c --- /dev/null +++ b/accelerate/remix-starter/.tool-versions @@ -0,0 +1 @@ +nodejs 20.14.0 diff --git a/accelerate/remix-starter/Prisma-Studio-Image.png b/accelerate/remix-starter/Prisma-Studio-Image.png new file mode 100644 index 000000000000..9180399bd1c7 Binary files /dev/null and b/accelerate/remix-starter/Prisma-Studio-Image.png differ diff --git a/accelerate/remix-starter/README.md b/accelerate/remix-starter/README.md new file mode 100644 index 000000000000..c1c7af8c5ce5 --- /dev/null +++ b/accelerate/remix-starter/README.md @@ -0,0 +1,99 @@ +# Prisma Accelerate Example: Remix Starter + +This project showcases how to use Prisma ORM with Prisma Accelerate in a Remix application. It [demonstrates](./app/routes/_index.tsx#L10-13) every available [caching strategy in Accelerate](https://www.prisma.io/docs/data-platform/accelerate/concepts#cache-strategies). + +## Prerequisites + +To successfully run the project, you will need the following: + +- The **connection string** of a publicly accessible database. +- Your **Accelerate connection string** (containing your **Accelerate API key**) which you can get by enabling Accelerate in a project in your [Prisma Data Platform](https://pris.ly/pdp) account (learn more in the [docs](https://www.prisma.io/docs/platform/concepts/environments#api-keys)). + +## Getting started + +### 1. Clone the repository + +Clone the repository, navigate into it and install dependencies: + +``` +git clone git@github.com:prisma/prisma-examples.git --depth=1 +cd prisma-examples/accelerate/remix-starter +npm install +``` + +### 2. Configure environment variables + +Copy the `.env.example` env file in the root of the project directory: + +```bash +cp .env.example .env +``` + +Now, open the `.env` file and set the `DIRECT_DATABASE_URL` and `DATABASE_URL` environment variables with the values of your connection string and your Accelerate connection string respectively: + +```bash +# .env + +# Accelerate connection string (used for queries by Prisma Client) +DATABASE_URL="__YOUR_ACCELERATE_CONNECTION_STRING__" + +# Database connection string (used for migrations by Prisma Migrate) +DIRECT_DATABASE_URL="__YOUR_DATABASE_CONNECTION_STRING__" + +``` + +Note that `__YOUR_DATABASE_CONNECTION_STRING__` and `__YOUR_ACCELERATE_CONNECTION_STRING__` are placeholder values that you need to replace with the values of your database and Accelerate connection strings. Notice that the Accelerate connection string has the following structure: `prisma://accelerate.prisma-data.net/?api_key=__YOUR_ACCELERATE_API_KEY__`. + +### 3. Run a migration to create the `Quotes` table and seed the database + +The Prisma schema file contains a single `Quotes` model. You can map this model to the database and create the corresponding `Quotes` table using the following command: + +``` +npx prisma migrate dev --name init +``` + +You now have an empty `Quotes` table in your database. Next, run the [seed script](./prisma/seed.ts) to create some sample records in the table: + +``` +npx prisma db seed +``` + +### 4. Generate Prisma Client for Accelerate + +When using Accelerate, Prisma Client doesn't need a query engine. That's why you should generate it as follows: + +``` +npx prisma generate --no-engine +``` + +### 5. Start the app + +You can run the app with the following command: + +``` +npm run dev +``` + +The application will start on PORT 5173 and you should be able to see the performance and other stats (e.g. cache/hit) for the different Accelerate cache strategies at the bottom of the UI: + +![Demo](./Remix-accelerate.gif) + +This application queries the most recent Quote with all the different cache strategies available in Accelerate. + +Optionally, to add your own quote and see the caching strategies in action, you can add a new quote through Prisma Studio by running the following command: + +``` +npx prisma studio +``` + +Once the Prisma Studio is running, you can add a new quote by clicking on the `Quotes` table and then the `Add Record` button as shown in the screenshot below. + +![Prisma Studio](./Prisma-Studio-Image.png) + +After adding a new record, you can refresh the Remix application to see the new quote and the caching strategies in action. + +## Resources + +- [Accelerate Speed Test](https://accelerate-speed-test.vercel.app/) +- [Accelerate documentation](https://www.prisma.io/docs/accelerate) +- [Prisma Discord](https://pris.ly/discord) diff --git a/accelerate/remix-starter/Remix-accelerate.gif b/accelerate/remix-starter/Remix-accelerate.gif new file mode 100644 index 000000000000..e5095d57df55 Binary files /dev/null and b/accelerate/remix-starter/Remix-accelerate.gif differ diff --git a/accelerate/remix-starter/app/components/Quote/Quote.tsx b/accelerate/remix-starter/app/components/Quote/Quote.tsx new file mode 100644 index 000000000000..b71c1cee2b77 --- /dev/null +++ b/accelerate/remix-starter/app/components/Quote/Quote.tsx @@ -0,0 +1,77 @@ +/* eslint-disable react/prop-types */ +import { QuoteWrapper } from "./QuoteWrapper"; +import { QuoteCacheType, QuoteResult } from "./../../lib/types"; +import pkg from "openflights-cached"; +const { findIATA } = pkg; + +export const Quote: React.FC<{ + title: string; + type: QuoteCacheType; + result: QuoteResult; +}> = ({ title, type, result }) => { + const [{ id, quote, createdAt }, { cacheStatus, region, lastModified }] = [ + result.data, + result.info, + ]; + + return ( + +
+

+ ID {id} ⸺ {'"'} + {quote} + {'"'} +

+
+

+ Created At ⸺ + {new Date(createdAt).toLocaleString("en-US")} +

+ +
+ +
+
+

+ Cache Node Region ⸺ + + {findIATA(region)?.city ?? region} + +

+
+

+ Cached Modified at ⸺ + + {new Date(lastModified).toLocaleString("en-US")} + +

+
+

+ Cache status ⸺ + + {cacheStatus + .toUpperCase() + .concat( + cacheStatus === "swr" || cacheStatus === "ttl" + ? " CACHE HIT" + : "", + )} + +

+
+

+ Time taken: {result.time}ms +

+
+
+
+
+ ); +}; diff --git a/accelerate/remix-starter/app/components/Quote/QuoteWrapper.tsx b/accelerate/remix-starter/app/components/Quote/QuoteWrapper.tsx new file mode 100644 index 000000000000..97e731d18e03 --- /dev/null +++ b/accelerate/remix-starter/app/components/Quote/QuoteWrapper.tsx @@ -0,0 +1,24 @@ +/* eslint-disable react/prop-types */ +import { QuoteCacheType } from "../../lib/types"; +import { ReactNode } from "react"; + +export const QuoteWrapper: React.FC<{ + title: string; + type: QuoteCacheType; + children: ReactNode; +}> = ({ title, type, children }) => { + return ( +
+
+
+ {title} +
+
+

+ {type} +

+
+
{children}
+
+ ); +}; diff --git a/accelerate/remix-starter/app/components/Quote/Quotes.tsx b/accelerate/remix-starter/app/components/Quote/Quotes.tsx new file mode 100644 index 000000000000..83e8e3ef94ce --- /dev/null +++ b/accelerate/remix-starter/app/components/Quote/Quotes.tsx @@ -0,0 +1,21 @@ +// app/components/Quotes.tsx +import { Quote } from "./Quote"; +import { QuoteResult } from "~/lib/types"; + +interface QuotesProps { + ttl: QuoteResult; + swr: QuoteResult; + both: QuoteResult; + none: QuoteResult; +} + +export default function Quotes({ ttl, swr, both, none }: QuotesProps) { + return ( +
+ + + + +
+ ); +} diff --git a/accelerate/remix-starter/app/components/Quote/RefreshQuote.tsx b/accelerate/remix-starter/app/components/Quote/RefreshQuote.tsx new file mode 100644 index 000000000000..8a9ffd66e3d5 --- /dev/null +++ b/accelerate/remix-starter/app/components/Quote/RefreshQuote.tsx @@ -0,0 +1,17 @@ +import { Toaster } from "react-hot-toast"; + +export const RefreshQuote = () => { + return ( + <> + + + + + ); +}; diff --git a/accelerate/remix-starter/app/data/query.server.ts b/accelerate/remix-starter/app/data/query.server.ts new file mode 100644 index 000000000000..51b58f7914ae --- /dev/null +++ b/accelerate/remix-starter/app/data/query.server.ts @@ -0,0 +1,23 @@ +import prisma from "./utils/prisma.server"; +import { CacheStrategy } from "../lib/types"; + +export const getQuotes = async (strategy?: CacheStrategy) => { + const start = Date.now(); + + const result = await prisma.quotes + .findMany({ + // You can find the `cacheStrategy` options [here](https://www.prisma.io/docs/accelerate/caching#cache-strategies). The `cacheStrategy` can also be undefined, which would mean only connection pooling is being used. + cacheStrategy: strategy, + orderBy: { + id: "desc", + }, + take: 1, + }) + .withAccelerateInfo(); + + return { + data: result?.data?.[0], + info: result.info, + time: Date.now() - start, + }; +}; diff --git a/accelerate/remix-starter/app/data/quotes.server.ts b/accelerate/remix-starter/app/data/quotes.server.ts new file mode 100644 index 000000000000..2d6d019173a2 --- /dev/null +++ b/accelerate/remix-starter/app/data/quotes.server.ts @@ -0,0 +1,28 @@ +import { getQuotes } from "./query.server"; + +export async function getQuotesFromDB(cacheStrategy: string) { + const map = new Map(); + + // When TTL is selected + map.set("TTL", { + ttl: 30, + }); + + // When SWR is selected + map.set("SWR", { + swr: 30, + }); + + // When TTL + SWR is selected + map.set("BOTH", { + ttl: 30, + swr: 60, + }); + + // This ensures no caching is performed and only the Accelerate connection pool is used + map.set("NONE", undefined); + + const data = await getQuotes(map.get(cacheStrategy)); + + return data; +} diff --git a/accelerate/remix-starter/app/data/utils/formatZodErrors.server.ts b/accelerate/remix-starter/app/data/utils/formatZodErrors.server.ts new file mode 100644 index 000000000000..bb6c785a64d7 --- /dev/null +++ b/accelerate/remix-starter/app/data/utils/formatZodErrors.server.ts @@ -0,0 +1,11 @@ +import capitalize from "lodash/capitalize.js"; +import type { ZodError } from "zod"; + +export function formatZodErrors(zodError: ZodError) { + return Object.fromEntries( + zodError.issues.map((errorObj) => [ + errorObj.path, + capitalize(errorObj.message), + ]), + ); +} diff --git a/accelerate/remix-starter/app/data/utils/prisma.server.ts b/accelerate/remix-starter/app/data/utils/prisma.server.ts new file mode 100644 index 000000000000..293f661397bd --- /dev/null +++ b/accelerate/remix-starter/app/data/utils/prisma.server.ts @@ -0,0 +1,38 @@ +/* eslint-disable no-var */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { PrismaClient } from "@prisma/client"; +import { withAccelerate } from "@prisma/extension-accelerate"; +import { SERVER_ENV } from "~/env/envFlags.server"; + +function buildClient() { + const client = new PrismaClient().$extends(withAccelerate()); + + return client; +} + +/** + * The type of the PrismaClient with extensions + */ +export type PrismaClientType = ReturnType; + +let prisma: PrismaClientType; + +declare global { + var __prisma: PrismaClientType | undefined; +} + +// This is needed because in development we don't want to restart +// the server with every change, but we want to make sure we don't +// create a new connection to the DB with every change either. +if (SERVER_ENV.NODE_ENV === "production") { + prisma = buildClient(); + prisma.$connect(); +} else { + if (!global.__prisma) { + global.__prisma = buildClient(); + global.__prisma.$connect(); + } + prisma = global.__prisma; +} + +export default prisma; diff --git a/accelerate/remix-starter/app/data/utils/types.ts b/accelerate/remix-starter/app/data/utils/types.ts new file mode 100644 index 000000000000..66818d7a67de --- /dev/null +++ b/accelerate/remix-starter/app/data/utils/types.ts @@ -0,0 +1,5 @@ +export type GenericDataError = Record; + +export type DataResult = + | { data: DataType; errors: null } + | { data: null; errors: GenericDataError }; diff --git a/accelerate/remix-starter/app/entry.client.tsx b/accelerate/remix-starter/app/entry.client.tsx new file mode 100644 index 000000000000..442da4f8a80d --- /dev/null +++ b/accelerate/remix-starter/app/entry.client.tsx @@ -0,0 +1,12 @@ +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/accelerate/remix-starter/app/entry.server.tsx b/accelerate/remix-starter/app/entry.server.tsx new file mode 100644 index 000000000000..38476546d30e --- /dev/null +++ b/accelerate/remix-starter/app/entry.server.tsx @@ -0,0 +1,147 @@ +import { PassThrough } from "node:stream"; + +import type { + AppLoadContext, + EntryContext, + HandleErrorFunction, +} from "@remix-run/node"; +import { createReadableStreamFromReadable } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { isbot } from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; + +const ABORT_DELAY = 5_000; + +export const handleError: HandleErrorFunction = ( + error, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + { request, params, context }, +) => { + if (!request.signal.aborted) { + // Here you can log the error to your preferred error handling service + // handleError(error) + } +}; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loadContext: AppLoadContext, +) { + return isbot(request.headers.get("user-agent")) + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext, + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext, + ); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/accelerate/remix-starter/app/env/envFlags.server.ts b/accelerate/remix-starter/app/env/envFlags.server.ts new file mode 100644 index 000000000000..dac50a0581ca --- /dev/null +++ b/accelerate/remix-starter/app/env/envFlags.server.ts @@ -0,0 +1,28 @@ +import z from "zod"; +import { generateErrorMessage } from "zod-error"; + +export const serverEnvSchema = z.object({ + DIRECT_DATABASE_URL: z.string(), + DATABASE_URL: z.string(), + NODE_ENV: z.string(), +}); + +export type ServerEnv = z.infer; + +/** Zod will filter all the keys not specified on the schema */ +function buildEnv(): ServerEnv { + try { + return serverEnvSchema.parse(process.env); + } catch (error: unknown) { + console.error("Warning: invalid server env vars!"); + console.error( + generateErrorMessage((error as z.ZodError).issues, { + delimiter: { error: "\n" }, + }), + ); + + return {} as ServerEnv; + } +} + +export const SERVER_ENV = buildEnv(); diff --git a/accelerate/remix-starter/app/env/globalEnv.ts b/accelerate/remix-starter/app/env/globalEnv.ts new file mode 100644 index 000000000000..341ff02ea751 --- /dev/null +++ b/accelerate/remix-starter/app/env/globalEnv.ts @@ -0,0 +1,41 @@ +import z from "zod"; +import { generateErrorMessage } from "zod-error"; + +export const globalEnvSchema = z.object({ + PUBLIC_EXAMPLE: z.string().optional(), + EXAMPLE_GLOBAL_FEATURE_FLAG: z + .enum(["true", "false"]) + .optional() + .transform((v) => v === "true"), +}); + +export type GlobalEnv = z.infer; + +/** Zod will filter all the keys not specified on the schema */ +function buildEnv(): GlobalEnv { + try { + return globalEnvSchema.parse(process.env); + } catch (error: unknown) { + console.error("Warning: invalid client env vars!"); + console.error( + generateErrorMessage((error as z.ZodError).issues, { + delimiter: { error: "\n" }, + }), + ); + + return {} as GlobalEnv; + } +} + +/** + * If we are on a browser environment, we get the vars from the `window` object. + * We set this on the root.tsx file on around line 58. + * + * If we are on a server environment, we just read it from process.env. + * + * Remember that CLIENT_ENV vars can be accessed from any context. + */ +export const GLOBAL_ENV = + typeof window === "undefined" + ? buildEnv() + : (window as unknown as { ENV: GlobalEnv }).ENV; diff --git a/accelerate/remix-starter/app/env/useFeatureFlags.tsx b/accelerate/remix-starter/app/env/useFeatureFlags.tsx new file mode 100644 index 000000000000..67c9a46ca3f5 --- /dev/null +++ b/accelerate/remix-starter/app/env/useFeatureFlags.tsx @@ -0,0 +1,29 @@ +import { useContext, useMemo } from "react"; + +import { UserContext } from "../hooks/useUser"; +import { UserFeatureFlags } from "./userFeatureFlags.server"; +import { GLOBAL_ENV, GlobalEnv } from "./globalEnv"; + +export default function useFeatureFlags() { + const userContext = useContext(UserContext); + + return useMemo( + () => ({ + // Check the CLIENT_ENV object for feature flags + hasGlobalFeatureFlag: (flag: keyof GlobalEnv): boolean => + !!GLOBAL_ENV[flag], + // Get a value from CLIENT_ENV object + getGlobalFeatureFlag: ( + flag: T, + ): GlobalEnv[T] => GLOBAL_ENV[flag], + // Check the current user feature flags. If there's no user, this returns false, always + hasUserFeatureFlag: (flag: keyof UserFeatureFlags): boolean => + !!userContext?.featureFlags?.[flag], + // Get the current user feature flag value. If there's no user, this returns undefined, always + getUserFeatureFlag: ( + flag: T, + ): UserFeatureFlags[T] | undefined => userContext?.featureFlags?.[flag], + }), + [userContext?.featureFlags], + ); +} diff --git a/accelerate/remix-starter/app/env/userFeatureFlags.server.ts b/accelerate/remix-starter/app/env/userFeatureFlags.server.ts new file mode 100644 index 000000000000..bfa1f2d4b5bc --- /dev/null +++ b/accelerate/remix-starter/app/env/userFeatureFlags.server.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const UserFeatureFlagsSchema = z + .object({ + EXAMPLE_FEATURE_FLAG: z.boolean().optional(), + EXAMPLE_FEATURE_FLAG_STRING: z.string().optional(), + }) + .default({}); + +export type UserFeatureFlags = z.infer; diff --git a/accelerate/remix-starter/app/hooks/useRootLoaderData.tsx b/accelerate/remix-starter/app/hooks/useRootLoaderData.tsx new file mode 100644 index 000000000000..8afaec415576 --- /dev/null +++ b/accelerate/remix-starter/app/hooks/useRootLoaderData.tsx @@ -0,0 +1,9 @@ +import { SerializeFrom } from "@remix-run/node"; +import { useRouteLoaderData } from "@remix-run/react"; +import { loader } from "~/root"; + +export type RootLoaderType = SerializeFrom>>; + +export function useRootLoaderData() { + return useRouteLoaderData("root")!; +} diff --git a/accelerate/remix-starter/app/lib/types/index.tsx b/accelerate/remix-starter/app/lib/types/index.tsx new file mode 100644 index 000000000000..1fda962ee16b --- /dev/null +++ b/accelerate/remix-starter/app/lib/types/index.tsx @@ -0,0 +1,33 @@ +export type CacheStrategy = + | { + ttl: number; + swr: number; + } + | { + ttl: number; + } + | { + swr: number; + }; + +export type AccelerateInfo = { + cacheStatus: "ttl" | "swr" | "miss" | "none"; + lastModified: string; + region: string; + requestId: string; + signature: string; +}; + +export type Quote = { + id: number; + quote: string; + createdAt: string; +}; + +export type QuoteResult = { + data: Quote; + info: AccelerateInfo; + time: number; +}; + +export type QuoteCacheType = "SWR" | "TTL" | "No caching" | "TTL + SWR"; diff --git a/accelerate/remix-starter/app/root.css b/accelerate/remix-starter/app/root.css new file mode 100644 index 000000000000..8abdb15c9458 --- /dev/null +++ b/accelerate/remix-starter/app/root.css @@ -0,0 +1,76 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/accelerate/remix-starter/app/root.tsx b/accelerate/remix-starter/app/root.tsx new file mode 100644 index 000000000000..d5ffc151b210 --- /dev/null +++ b/accelerate/remix-starter/app/root.tsx @@ -0,0 +1,127 @@ +import "./root.css"; + +import { unstable_defineLoader } from "@remix-run/node"; +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; +import acceptLanguage from "accept-language-parser"; +import React, { useEffect } from "react"; +import { GLOBAL_ENV } from "./env/globalEnv"; +import { useRootLoaderData } from "./hooks/useRootLoaderData"; +import { cn } from "./utils"; +import { getCurrentTheme } from "./web/theme.server"; + +// Load the locale from the Accept-Language header to later +// inject it on the app's context +function localeFromRequest(request: Request): string { + const languages = acceptLanguage.parse( + request.headers.get("Accept-Language") as string, + ); + + // If somehow the header is empty, return a default locale + if (languages?.length < 1) return "en-us"; + + // If there is no region for this locale, just return the country code + if (!languages[0].region) return languages[0].code; + + return `${languages[0].code}-${languages[0].region.toLowerCase()}`; +} + +export const loader = unstable_defineLoader(async ({ request }) => { + return { + locale: localeFromRequest(request), + ENV: GLOBAL_ENV, + rootTime: new Date().toISOString(), + currentTheme: await getCurrentTheme(request), + }; +}); + +function applySystemTheme() { + const theme = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + const cl = document.documentElement.classList; + + cl.add(theme); +} + +const applySystemThemeString = ` + const theme = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + const cl = document.documentElement.classList; + + cl.add(theme); +`; + +export default function App() { + const { ENV, currentTheme } = useRootLoaderData(); + + useEffect(() => { + if (currentTheme === "system") applySystemTheme(); + }, [currentTheme]); + + return ( + +