diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..87fa0f3 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,6 @@ +node_modules +/.cache +/build +.env +.react-router +.DS_Store \ No newline at end of file diff --git a/website/components.json b/website/components.json new file mode 100644 index 0000000..291aaa0 --- /dev/null +++ b/website/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "website/src/components", + "utils": "website/src/lib/utils", + "ui": "website/src/components/ui", + "lib": "website/src/lib", + "hooks": "website/src/lib/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..672f967 --- /dev/null +++ b/website/package.json @@ -0,0 +1,46 @@ +{ + "name": "website", + "private": true, + "type": "module", + "scripts": { + "build": "react-router build", + "dev": "react-router dev --port 8044", + "start": "node ./build/server/server.js", + "typecheck": "react-router typegen && tsc" + }, + "exports": { + "./*": "./*" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.2.4", + "@react-router/fs-routes": "^7.11.0", + "@react-router/node": "^7.11.0", + "@sentry/node": "^10.32.1", + "@tailwindcss/typography": "^0.5.19", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "isbot": "^5.1.32", + "lucide-react": "^0.562.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router": "^7.11.0", + "sentries": "^0.2.2", + "tailwind-merge": "^3.4.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@react-router/dev": "^7.11.0", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^25.0.3", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "@xmorse/deployment-utils": "^0.7.2", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.3.0", + "vite-plugin-environment": "^1.1.3", + "vite-tsconfig-paths": "^6.0.3", + "vitest": "^4.0.16" + } +} diff --git a/website/public/favicon.ico b/website/public/favicon.ico new file mode 100644 index 0000000..8830cf6 Binary files /dev/null and b/website/public/favicon.ico differ diff --git a/website/react-router.config.ts b/website/react-router.config.ts new file mode 100644 index 0000000..afe7ca6 --- /dev/null +++ b/website/react-router.config.ts @@ -0,0 +1,6 @@ +import type { Config } from "@react-router/dev/config"; + +export default { + appDirectory: "src", + prerender: ["/", "/defer-example"], +} satisfies Config; diff --git a/website/src/entry.client.tsx b/website/src/entry.client.tsx new file mode 100644 index 0000000..e13f9b4 --- /dev/null +++ b/website/src/entry.client.tsx @@ -0,0 +1,7 @@ +import { startTransition } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; + +startTransition(() => { + hydrateRoot(document, ); +}); diff --git a/website/src/entry.server.tsx b/website/src/entry.server.tsx new file mode 100644 index 0000000..31814ba --- /dev/null +++ b/website/src/entry.server.tsx @@ -0,0 +1,163 @@ +import { PassThrough } from "node:stream"; +import { createReadableStreamFromReadable } from "@react-router/node"; +import { isbot } from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; +import type { + ActionFunctionArgs, + AppLoadContext, + EntryContext, + LoaderFunctionArgs, +} from "react-router"; +import { isRouteErrorResponse, ServerRouter } from "react-router"; +import { notifyError } from "./lib/errors"; + +export const streamTimeout = 60 * 20 * 1_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + reactRouterContext: EntryContext, + // This is ignored so we can keep it in the template for visibility. Feel + // free to delete this parameter in your app if you're not using it! + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loadContext: AppLoadContext +) { + return isbot(request.headers.get("user-agent") || "") + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + reactRouterContext + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + reactRouterContext + ); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + reactRouterContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + let timeoutId: NodeJS.Timeout; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + clearTimeout(timeoutId); + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + clearTimeout(timeoutId); + 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); + } + }, + } + ); + + timeoutId = setTimeout(abort, streamTimeout); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + reactRouterContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + let timeoutId: NodeJS.Timeout; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + clearTimeout(timeoutId); + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + clearTimeout(timeoutId); + 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); + } + }, + } + ); + + timeoutId = setTimeout(abort, streamTimeout); + }); +} + +export function handleError( + error: any, + { request, params, context }: LoaderFunctionArgs | ActionFunctionArgs +) { + // https://github.com/remix-run/remix/discussions/8933 + if (request.signal.aborted || isRouteErrorResponse(error)) { + return; + } + if (error?.["status"] === 404) { + return; + } + if (error instanceof Error) { + notifyError(error, "unhandled remix error"); + } else { + console.error("error", error); + } +} diff --git a/website/src/lib/errors.ts b/website/src/lib/errors.ts new file mode 100644 index 0000000..0dd67a6 --- /dev/null +++ b/website/src/lib/errors.ts @@ -0,0 +1,34 @@ +import { captureException, flush, init } from "sentries"; + +init({ + dsn: "https://3e3f1075fec9ee2de1e0f79026b5f734@o4508014272446464.ingest.de.sentry.io/4508014292697168", + integrations: [], + tracesSampleRate: 0.01, + profilesSampleRate: 0.01, + beforeSend(event) { + if (process.env.NODE_ENV === "development") { + return null; + } + if (process.env.BYTECODE_RUN) { + return null; + } + if (event?.["name"] === "AbortError") { + return null; + } + + return event; + }, +}); + +export async function notifyError(error: any, msg?: string) { + console.error(msg, error); + captureException(error, { extra: { msg } }); + await flush(1000); +} + +export class AppError extends Error { + constructor(message: string) { + super(message); + this.name = "AppError"; + } +} diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts new file mode 100644 index 0000000..b13e418 --- /dev/null +++ b/website/src/lib/utils.ts @@ -0,0 +1,10 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export const sleep = (ms: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/website/src/root.tsx b/website/src/root.tsx new file mode 100644 index 0000000..edb45f8 --- /dev/null +++ b/website/src/root.tsx @@ -0,0 +1,63 @@ +import "website/src/styles/globals.css"; +import { Route } from "./+types/root"; +import { + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + if (isRouteErrorResponse(error)) { + return ( +
+

+ {error.status} {error.statusText} +

+

{error.data}

+
+ ); + } else if (error instanceof Error) { + return ( +
+

Error

+

{error.message}

+

The stack trace is:

+
+          {error.stack}
+        
+
+ ); + } else { + return ( +
+

Unknown Error

+
+ ); + } +} + +export default function App() { + return ; +} diff --git a/website/src/routes.ts b/website/src/routes.ts new file mode 100644 index 0000000..30b5e6f --- /dev/null +++ b/website/src/routes.ts @@ -0,0 +1,4 @@ +import type { RouteConfig } from "@react-router/dev/routes"; +import { flatRoutes } from "@react-router/fs-routes"; + +export default flatRoutes() satisfies RouteConfig; diff --git a/website/src/routes/_index.tsx b/website/src/routes/_index.tsx new file mode 100644 index 0000000..20a6535 --- /dev/null +++ b/website/src/routes/_index.tsx @@ -0,0 +1,9 @@ +import { redirect } from 'react-router'; + +export const loader = () => { + return redirect('https://github.com/remorses/playwriter'); +}; + +export default function Index() { + return null; +} diff --git a/website/src/routes/defer-example.tsx b/website/src/routes/defer-example.tsx new file mode 100644 index 0000000..e29848a --- /dev/null +++ b/website/src/routes/defer-example.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { sleep } from "../lib/utils"; +import { Route } from "./+types/defer-example"; + +async function getProjectLocation() { + return Promise.resolve().then(() => sleep(1000).then(() => "hi")); +} + +export async function loader({}: Route.LoaderArgs) { + return { + project: getProjectLocation(), + }; +} + +export default function ProjectRoute({ loaderData }: Route.ComponentProps) { + const location = React.use(loaderData.project); + + return ( +
+

Let's locate your project

+

Your project is at {location}.

+
+ ); +} diff --git a/website/src/styles/globals.css b/website/src/styles/globals.css new file mode 100644 index 0000000..7ba0a5f --- /dev/null +++ b/website/src/styles/globals.css @@ -0,0 +1,143 @@ +@import "tailwindcss"; +@custom-variant dark (&:is(.dark *)); + +@plugin "@tailwindcss/typography"; + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.637 0.237 25.331); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.871 0.006 286.286); + --ring: oklch(0.871 0.006 286.286); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.871 0.006 286.286); + --sidebar-primary: oklch(0.37 0.013 285.805); + --sidebar-primary-foreground: oklch(0.871 0.006 286.286); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.871 0.006 286.286); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.871 0.006 286.286); +} +.dark { + --background: oklch(0.21 0.006 285.885); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.637 0.237 25.331); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.274 0.006 286.033); + --input: oklch(0.274 0.006 286.033); + --ring: oklch(0.442 0.017 285.786); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: #000; + --sidebar-foreground: oklch(0.871 0.006 286.286); + --sidebar-primary: oklch(0.37 0.013 285.805); + --sidebar-primary-foreground: oklch(0.871 0.006 286.286); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.871 0.006 286.286); + --sidebar-border: oklch(0.274 0.006 286.033); + --sidebar-ring: oklch(0.442 0.017 285.786); +} + +@theme inline { + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-background: var(--background); + --color-foreground: var(--foreground); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +/* Custom scrollbar styles: shadcn-inspired */ +::-webkit-scrollbar { + width: 6px; + height: 6px; + background: transparent; + border-radius: var(--radius-md); +} + +::-webkit-scrollbar-thumb { + background: var(--color-muted); + border-radius: var(--radius-md); + border: none; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-muted-foreground); +} diff --git a/website/tsconfig.json b/website/tsconfig.json new file mode 100644 index 0000000..9ecab99 --- /dev/null +++ b/website/tsconfig.json @@ -0,0 +1,28 @@ +{ + "include": ["src"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["@react-router/node", "vite/client", "@types/node"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "allowArbitraryExtensions": true, + "allowImportingTsExtensions": true, + "noImplicitAny": false, + "useUnknownInCatchVariables": false, + "forceConsistentCasingInFileNames": true, + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + "paths": { + "website/*": ["./*"] + }, + "noEmit": true + } +} diff --git a/website/vite.config.ts b/website/vite.config.ts new file mode 100644 index 0000000..3f2ca80 --- /dev/null +++ b/website/vite.config.ts @@ -0,0 +1,47 @@ +/// +import { reactRouter } from "@react-router/dev/vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import EnvironmentPlugin from "vite-plugin-environment"; +import { defineConfig } from "vite"; +import { + viteExternalsPlugin, + enablePreserveModulesPlugin, +} from "@xmorse/deployment-utils/dist/vite-externals-plugin.js"; +import { reactRouterServerPlugin } from "@xmorse/deployment-utils/dist/react-router.js"; +import tsconfigPaths from "vite-tsconfig-paths"; + +const NODE_ENV = JSON.stringify(process.env.NODE_ENV || "production"); + +export default defineConfig({ + clearScreen: false, + define: { + "process.env.NODE_ENV": NODE_ENV, + }, + test: { + pool: "threads", + exclude: ["**/dist/**", "**/esm/**", "**/node_modules/**", "**/e2e/**"], + poolOptions: { + threads: { + isolate: false, + }, + }, + }, + plugins: [ + EnvironmentPlugin("all", { prefix: "PUBLIC" }), + EnvironmentPlugin("all", { prefix: "NEXT_PUBLIC" }), + process.env.VITEST ? react() : reactRouter(), + tsconfigPaths(), + // viteExternalsPlugin({ + // externals: ["@sentry/node"], + // }), + // enablePreserveModulesPlugin(), + reactRouterServerPlugin({ port: process.env.PORT || '8044' }), + tailwindcss(), + ], + build: { + commonjsOptions: { + transformMixedEsModules: true, + }, + }, +});