├── .gitignore ├── example ├── src │ ├── user │ │ ├── shared.ts │ │ ├── client.ts │ │ ├── server.ts │ │ ├── schema.ts │ │ ├── middleware.ts │ │ └── actions.ts │ ├── secrets │ │ ├── server.ts │ │ └── middleware.ts │ ├── lib │ │ └── utils.ts │ ├── routes │ │ ├── chat.detail │ │ │ ├── schema.ts │ │ │ ├── shared.tsx │ │ │ ├── route.tsx │ │ │ ├── actions.tsx │ │ │ └── client.tsx │ │ ├── chats │ │ │ ├── actions.ts │ │ │ └── route.tsx │ │ ├── shell │ │ │ ├── client.tsx │ │ │ ├── route.tsx │ │ │ ├── header.tsx │ │ │ └── favicons.tsx │ │ ├── login │ │ │ ├── route.tsx │ │ │ └── client.tsx │ │ ├── signup │ │ │ ├── route.tsx │ │ │ └── client.tsx │ │ └── chat │ │ │ └── route.tsx │ ├── entry.server.tsx │ ├── app.ts │ ├── cache │ │ └── chat.ts │ ├── components │ │ ├── client-redirect.tsx │ │ └── ui │ │ │ ├── label.tsx │ │ │ ├── input.tsx │ │ │ ├── button.tsx │ │ │ └── card.tsx │ ├── routes.ts │ ├── db │ │ ├── server.ts │ │ └── schema.ts │ ├── entry.browser.tsx │ ├── forms │ │ └── client.ts │ ├── entry.prerender.tsx │ └── global.css ├── .gitignore ├── public │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── ms-icon-70x70.png │ ├── favicon-256x256.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── android-icon-192x192.png │ ├── browserconfig.xml │ └── manifest.json ├── .env.example ├── postcss.config.cjs ├── migrations │ ├── meta │ │ ├── _journal.json │ │ └── 0000_snapshot.json │ └── 0000_omniscient_marrow.sql ├── components.json ├── drizzle.config.ts ├── tsconfig.json ├── vite.config.ts ├── package.json ├── tailwind.config.js └── server.js ├── pnpm-workspace.yaml ├── framework ├── src │ ├── browser.ts │ ├── prerender.ts │ ├── runtime.client.ts │ ├── shared.tsx │ ├── router │ │ ├── client.tsx │ │ ├── prerender.tsx │ │ ├── trie.ts │ │ ├── browser.tsx │ │ └── server.tsx │ ├── client.tsx │ └── server.ts ├── tsconfig.json └── package.json ├── vite ├── browser.d.ts ├── tsconfig.json ├── browser.js ├── package.json └── src │ └── plugin.ts ├── .vscode └── settings.json ├── config ├── tsconfig.base.json └── tsconfig.node.json ├── biome.json ├── patches ├── react@0.0.0-experimental-96c584661-20240412.patch └── tiny-markdown-parser@1.0.1.patch └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .log 3 | *.log 4 | dist/ 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /example/src/user/shared.ts: -------------------------------------------------------------------------------- 1 | export const USER_ID_KEY = "USER_ID" as const; 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "example" 3 | - "framework" 4 | - "vite" 5 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .database 3 | .env 4 | .stats/ 5 | dist/ 6 | node_modules/ 7 | -------------------------------------------------------------------------------- /framework/src/browser.ts: -------------------------------------------------------------------------------- 1 | export { BrowserRouter, getInitialPayload } from "./router/browser.js"; 2 | -------------------------------------------------------------------------------- /framework/src/prerender.ts: -------------------------------------------------------------------------------- 1 | export { createHandler, renderServerResponse } from "./router/prerender.js"; 2 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/favicon.ico -------------------------------------------------------------------------------- /example/.env.example: -------------------------------------------------------------------------------- 1 | COOKIE_SECRET=your_cookie_secret 2 | DB_PATH=./.database 3 | OLLAMA_HOST=http://localhost:11434 -------------------------------------------------------------------------------- /vite/browser.d.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore lint/style/useExportType: make this a module you can naked import 2 | export {}; 3 | -------------------------------------------------------------------------------- /example/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require("tailwindcss"), require("autoprefixer")], 3 | }; 4 | -------------------------------------------------------------------------------- /example/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/favicon-16x16.png -------------------------------------------------------------------------------- /example/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/favicon-32x32.png -------------------------------------------------------------------------------- /example/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/favicon-96x96.png -------------------------------------------------------------------------------- /example/public/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/ms-icon-70x70.png -------------------------------------------------------------------------------- /example/public/favicon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/favicon-256x256.png -------------------------------------------------------------------------------- /example/public/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/ms-icon-144x144.png -------------------------------------------------------------------------------- /example/public/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/ms-icon-150x150.png -------------------------------------------------------------------------------- /example/public/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/ms-icon-310x310.png -------------------------------------------------------------------------------- /example/public/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/apple-icon-114x114.png -------------------------------------------------------------------------------- /example/public/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/apple-icon-120x120.png -------------------------------------------------------------------------------- /example/public/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/apple-icon-144x144.png -------------------------------------------------------------------------------- /example/public/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/apple-icon-152x152.png -------------------------------------------------------------------------------- /example/public/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/apple-icon-180x180.png -------------------------------------------------------------------------------- /example/public/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/apple-icon-57x57.png -------------------------------------------------------------------------------- /example/public/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/apple-icon-60x60.png -------------------------------------------------------------------------------- /example/public/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/apple-icon-72x72.png -------------------------------------------------------------------------------- /example/public/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/apple-icon-76x76.png -------------------------------------------------------------------------------- /example/public/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/vite-env-api/HEAD/example/public/android-icon-192x192.png -------------------------------------------------------------------------------- /example/src/secrets/server.ts: -------------------------------------------------------------------------------- 1 | export const Secrets = { 2 | COOKIE_SECRET: "COOKIE_SECRET", 3 | DB_PATH: "DB_PATH", 4 | OLLAMA_HOST: "OLLAMA_HOST", 5 | } as const; 6 | -------------------------------------------------------------------------------- /example/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /example/src/user/client.ts: -------------------------------------------------------------------------------- 1 | import { useServerContext } from "framework/client"; 2 | 3 | import { USER_ID_KEY } from "./shared"; 4 | 5 | export function useUserId() { 6 | return useServerContext(USER_ID_KEY); 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "[yaml]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "[postcss]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /config/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "strict": true, 5 | "noUnusedLocals": true, 6 | "noUnusedParameters": true, 7 | "resolvePackageJsonImports": true, 8 | "customConditions": ["source"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /example/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1714026392930, 9 | "tag": "0000_omniscient_marrow", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /example/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /example/src/routes/chat.detail/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const sendMessageSchema = z.object({ 4 | chatId: z.string().optional(), 5 | message: z 6 | .string({ 7 | required_error: "Message is required", 8 | }) 9 | .trim() 10 | .min(1, "Message is required"), 11 | }); 12 | -------------------------------------------------------------------------------- /example/src/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { createHandler, runRoutes } from "framework"; 2 | 3 | import { routes } from "./routes"; 4 | 5 | export default createHandler(async ({ request }) => { 6 | try { 7 | return await runRoutes(routes, request); 8 | } catch (reason) { 9 | console.error(reason); 10 | return new Response("Internal Server Error", { status: 500 }); 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /config/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.base.json", 4 | "compilerOptions": { 5 | "lib": ["es2023"], 6 | "target": "es2022", 7 | "module": "Node16", 8 | "moduleResolution": "Node16", 9 | "esModuleInterop": true, 10 | "skipLibCheck": true 11 | }, 12 | "ts-node": { 13 | "transpileOnly": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "../config/tsconfig.node.json", 4 | "compilerOptions": { 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "jsx": "react-jsx", 11 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 12 | "types": ["node", "vite/client"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /example/src/routes/chats/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { chat } from "@/db/schema"; 4 | import { getDB } from "@/db/server"; 5 | import { actionRequiresUserId } from "@/user/server"; 6 | import { eq } from "drizzle-orm"; 7 | 8 | export async function clearChats() { 9 | const userId = actionRequiresUserId(); 10 | const db = getDB(); 11 | 12 | await db.delete(chat).where(eq(chat.userId, userId)); 13 | } 14 | -------------------------------------------------------------------------------- /example/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/global.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /example/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | 3 | import type { Config } from "drizzle-kit"; 4 | 5 | const dbDir = process.env.DB_PATH 6 | ? path.resolve(process.env.DB_PATH) 7 | : path.join(process.cwd(), ".database"); 8 | 9 | export default { 10 | schema: "./src/db/schema.ts", 11 | out: "./migrations", 12 | driver: "better-sqlite", 13 | dbCredentials: { 14 | url: path.join(dbDir, "database.db"), 15 | }, 16 | } satisfies Config; 17 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.1/schema.json", 3 | "files": { 4 | "ignore": [ 5 | "**/.stats/**/*", 6 | "**/dist/**/*", 7 | "**/migrations/**/*", 8 | "**/node_modules/**/*", 9 | "pnpm-lock.yaml", 10 | "framework/src/router/trie.ts" 11 | ] 12 | }, 13 | "organizeImports": { 14 | "enabled": true 15 | }, 16 | "linter": { 17 | "enabled": true, 18 | "rules": { 19 | "recommended": true 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/src/app.ts: -------------------------------------------------------------------------------- 1 | export const Routes = { 2 | chatDetail: { 3 | pathname: (chatId: string) => `/chat/${chatId}`, 4 | }, 5 | chatList: { 6 | pathname: () => "/chats", 7 | }, 8 | login: { 9 | pathname: () => "/", 10 | }, 11 | newChat: { 12 | pathname: () => "/chat", 13 | }, 14 | signup: { 15 | pathname: () => "/signup", 16 | }, 17 | }; 18 | 19 | export const RevalidationTargets = { 20 | chatDetail: ["chat.detail"], 21 | chatList: ["chat", "chats"], 22 | }; 23 | -------------------------------------------------------------------------------- /framework/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "../config/tsconfig.node.json", 4 | "compilerOptions": { 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "jsx": "react-jsx", 11 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 12 | "types": [ 13 | "node", 14 | "react/canary", 15 | "react/experimental", 16 | "react-dom/canary", 17 | "react-dom/experimental" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /example/src/routes/shell/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useNavigation } from "framework/client"; 4 | 5 | export function PendingIndicator() { 6 | const navigation = useNavigation(); 7 | 8 | if (!navigation.pending) { 9 | return null; 10 | } 11 | 12 | return ( 13 |
14 |
15 |
16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /example/src/cache/chat.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { getDB } from "@/db/server"; 4 | import { getUserId } from "@/user/server"; 5 | 6 | export const getChatsForUser = React.cache(async () => { 7 | const userId = getUserId(); 8 | const db = getDB(); 9 | 10 | return db.query.chat.findMany({ 11 | where: (chat, { eq }) => eq(chat.userId, userId), 12 | orderBy: ({ createdAt }, { desc }) => desc(createdAt), 13 | columns: { 14 | createdAt: true, 15 | id: true, 16 | name: true, 17 | }, 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /framework/src/runtime.client.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error - no types 2 | import ReactServer from "react-server-dom-diy/client"; 3 | 4 | export function createServerReference(_: unknown, mod: string, name: string) { 5 | const id = `${mod}#${name}`; 6 | const reference = ReactServer.createServerReference( 7 | id, 8 | (id: string, args: unknown[]) => { 9 | return __callServer(id, args); 10 | }, 11 | ); 12 | 13 | Object.defineProperties(reference, { 14 | $$typeof: { value: Symbol.for("react.server.reference") }, 15 | $$id: { value: id }, 16 | }); 17 | 18 | return reference; 19 | } 20 | -------------------------------------------------------------------------------- /patches/react@0.0.0-experimental-96c584661-20240412.patch: -------------------------------------------------------------------------------- 1 | diff --git a/package.json b/package.json 2 | index 6e6a40723c9729773c9a376d9baa0d475d6771bc..7c6d7e2ea4e39579f0b68d2f8267f7b7321227f9 100644 3 | --- a/package.json 4 | +++ b/package.json 5 | @@ -30,7 +30,10 @@ 6 | "react-server": "./jsx-runtime.react-server.js", 7 | "default": "./jsx-runtime.js" 8 | }, 9 | - "./jsx-dev-runtime": "./jsx-dev-runtime.js" 10 | + "./jsx-dev-runtime": { 11 | + "react-server": "./jsx-runtime.react-server.js", 12 | + "default": "./jsx-dev-runtime.js" 13 | + } 14 | }, 15 | "repository": { 16 | "type": "git", 17 | -------------------------------------------------------------------------------- /patches/tiny-markdown-parser@1.0.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/package.json b/package.json 2 | index db5bd9b1ac8a1ba8ba85598847404dcd57b8e87b..cdbce66f6ccb432dfe92a243996568682d989702 100644 3 | --- a/package.json 4 | +++ b/package.json 5 | @@ -4,7 +4,10 @@ 6 | "description": "Tiny ~1.1kB markdown parser with TypeScript typings", 7 | "main": "./dist/index.umd.js", 8 | "module": "./dist/index.module.mjs", 9 | - "exports": "./dist/index.modern.mjs", 10 | + "exports": { 11 | + "types": "./tiny-markdown-parser.d.ts", 12 | + "default": "./dist/index.modern.mjs" 13 | + }, 14 | "types": "./tiny-markdown-parser.d.ts", 15 | "files": [ 16 | "dist", 17 | -------------------------------------------------------------------------------- /example/src/components/client-redirect.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | 5 | import { redirect } from "framework/client"; 6 | 7 | export function ClientRedirect({ 8 | preventScrollReset, 9 | to, 10 | }: { preventScrollReset?: boolean; to: string }) { 11 | const ref = React.useRef(false); 12 | const [redirectTo, setRedirectTo] = React.useState(to); 13 | if (to !== redirectTo) { 14 | setRedirectTo(to); 15 | } 16 | 17 | React.useEffect(() => { 18 | if (!ref.current) { 19 | ref.current = true; 20 | redirect(redirectTo, undefined, preventScrollReset); 21 | } 22 | }, [preventScrollReset, redirectTo]); 23 | 24 | return null; 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build": "pnpm --recursive build", 4 | "build:watch": "pnpm --parallel build:watch", 5 | "fix": "biome check --apply .", 6 | "lint": "biome check ." 7 | }, 8 | "pnpm": { 9 | "overrides": { 10 | "react": "0.0.0-experimental-96c584661-20240412", 11 | "react-dom": "0.0.0-experimental-96c584661-20240412", 12 | "vite": "6.0.0-alpha.1" 13 | }, 14 | "patchedDependencies": { 15 | "react@0.0.0-experimental-96c584661-20240412": "patches/react@0.0.0-experimental-96c584661-20240412.patch", 16 | "tiny-markdown-parser@1.0.1": "patches/tiny-markdown-parser@1.0.1.patch" 17 | } 18 | }, 19 | "devDependencies": { 20 | "@biomejs/biome": "1.7.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/src/routes/login/route.tsx: -------------------------------------------------------------------------------- 1 | import * as framework from "framework"; 2 | 3 | import { login } from "@/user/actions"; 4 | 5 | import { LoginForm } from "./client"; 6 | 7 | export default function LoginRoute() { 8 | const loginAction = framework.getActionResult(login); 9 | 10 | return ( 11 | <> 12 | Log in 13 | 14 | 15 |
19 |
20 |
21 | 22 |
23 |
24 |
25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /example/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /example/src/routes/shell/route.tsx: -------------------------------------------------------------------------------- 1 | import { PendingIndicator } from "./client"; 2 | import { Favicons } from "./favicons"; 3 | import { Header } from "./header"; 4 | 5 | export default async function ShellRoute({ 6 | children, 7 | }: { 8 | children?: React.ReactNode; 9 | }) { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | {children} 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /example/src/routes/signup/route.tsx: -------------------------------------------------------------------------------- 1 | import * as framework from "framework"; 2 | 3 | import { signup } from "@/user/actions"; 4 | 5 | import { SignupForm } from "./client"; 6 | 7 | export default function LoginRoute() { 8 | const signupAction = framework.getActionResult(signup); 9 | 10 | return ( 11 | <> 12 | Sign up 13 | 14 | 15 |
19 |
20 |
21 | 22 |
23 |
24 |
25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /example/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /example/src/user/server.ts: -------------------------------------------------------------------------------- 1 | import * as framework from "framework"; 2 | 3 | import { USER_ID_KEY } from "./shared"; 4 | 5 | declare global { 6 | interface ServerContext { 7 | [USER_ID_KEY]?: string; 8 | } 9 | interface ServerClientContext { 10 | [USER_ID_KEY]?: string; 11 | } 12 | } 13 | 14 | export function actionRequiresUserId() { 15 | const userId = framework.get(USER_ID_KEY); 16 | 17 | if (!userId) { 18 | const url = framework.getURL(); 19 | return framework.actionRedirects( 20 | `/?${new URLSearchParams({ redirectTo: url.pathname }).toString()}`, 21 | ); 22 | } 23 | 24 | return userId; 25 | } 26 | 27 | export function getUserId(required: false): string | undefined; 28 | export function getUserId(required?: true): string; 29 | export function getUserId(required = true) { 30 | const userId = framework.get(USER_ID_KEY); 31 | if (required && !userId) { 32 | throw new Error("User ID is required"); 33 | } 34 | return userId; 35 | } 36 | -------------------------------------------------------------------------------- /vite/browser.js: -------------------------------------------------------------------------------- 1 | // @ts-expect-error - no types 2 | import ReactServerDOM from "react-server-dom-diy/client"; 3 | 4 | import { navigate } from "framework/client"; 5 | 6 | if (import.meta.hot) { 7 | import.meta.hot.on("react-server:update", async () => { 8 | const controller = new AbortController(); 9 | __startNavigation( 10 | window.location.href, 11 | controller, 12 | async (completeNavigation) => { 13 | const responsePromise = fetch(window.location.href, { 14 | headers: { 15 | Accept: "text/x-component", 16 | "RSC-Refresh": "1", 17 | }, 18 | signal: controller.signal, 19 | }); 20 | 21 | let payload = await ReactServerDOM.createFromFetch(responsePromise, { 22 | ...__diy_client_manifest__, 23 | __callServer, 24 | }); 25 | if (payload.redirect) { 26 | payload = await navigate(payload.redirect, controller.signal); 27 | } 28 | 29 | completeNavigation(payload); 30 | }, 31 | ); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@framework/vite", 3 | "version": "0.0.0", 4 | "description": "", 5 | "type": "module", 6 | "keywords": [], 7 | "author": "", 8 | "license": "ISC", 9 | "types": "./dist/plugin.d.ts", 10 | "main": "./dist/plugin.js", 11 | "exports": { 12 | ".": { 13 | "types": "./dist/plugin.d.ts", 14 | "default": "./dist/plugin.js" 15 | }, 16 | "./browser": { 17 | "types": "./browser.d.ts", 18 | "default": "./browser.js" 19 | } 20 | }, 21 | "scripts": { 22 | "build": "tsc", 23 | "build:watch": "tsc --watch" 24 | }, 25 | "peerDependencies": { 26 | "@vitejs/plugin-react": "4.2.1", 27 | "framework": "workspace:*", 28 | "react-server-dom-diy": "0.0.0-experimental-15a3a5622-202404158", 29 | "vite": "6.0.0-alpha.1" 30 | }, 31 | "dependencies": { 32 | "@hattip/adapter-node": "0.0.45", 33 | "unplugin-rsc": "0.0.9" 34 | }, 35 | "devDependencies": { 36 | "@types/node": "20.12.4", 37 | "typescript": "5.4.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /example/src/secrets/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { MiddlewareContext, MiddlewareFunction } from "framework"; 2 | 3 | import { Secrets } from "./server"; 4 | 5 | declare global { 6 | interface ServerContext { 7 | [Secrets.COOKIE_SECRET]?: string; 8 | [Secrets.DB_PATH]?: string; 9 | [Secrets.OLLAMA_HOST]?: string; 10 | } 11 | } 12 | 13 | export const configureSecretsMiddleware: MiddlewareFunction = (c, next) => { 14 | configureSecret(c, Secrets.COOKIE_SECRET); 15 | configureSecret(c, Secrets.DB_PATH, import.meta.env.PROD); 16 | configureSecret(c, Secrets.OLLAMA_HOST, import.meta.env.PROD); 17 | 18 | return next(); 19 | }; 20 | 21 | function configureSecret( 22 | { get, set }: MiddlewareContext, 23 | key: keyof ServerContext, 24 | required = true, 25 | ) { 26 | const existingSecret = get(key); 27 | if (existingSecret) return; 28 | 29 | const secret = process.env[key]; 30 | if (!secret && required) { 31 | throw new Error(`Missing required secret: ${key}`); 32 | } 33 | 34 | set(key, secret); 35 | } 36 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "../config/tsconfig.node.json", 4 | "include": [ 5 | "drizzle.config.ts", 6 | "postcss.config.cjs", 7 | "server.js", 8 | "tailwind.config.js", 9 | "vite.config.ts", 10 | "src/**/*.ts", 11 | "src/**/*.tsx" 12 | ], 13 | "exclude": ["node_modules/**/*", "dist/**/*"], 14 | "compilerOptions": { 15 | "noEmit": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Bundler", 18 | "jsx": "react-jsx", 19 | "customConditions": ["source"], 20 | "rootDir": "..", 21 | "paths": { 22 | "@/*": ["./src/*"], 23 | "framework": ["../framework/src/server.ts"], 24 | "framework/browser": ["../framework/src/browser.ts"], 25 | "framework/client": ["../framework/src/client.tsx"], 26 | "framework/prerender": ["../framework/src/prerender.ts"], 27 | "framework/shared": ["../framework/src/shared.tsx"] 28 | }, 29 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 30 | "types": [ 31 | "node", 32 | "react/canary", 33 | "react/experimental", 34 | "react-dom/canary", 35 | "react-dom/experimental", 36 | "types-react", 37 | "vite/client" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "/apple-icon-57x57.png", 6 | "sizes": "57x57", 7 | "type": "image/png", 8 | "density": "1.0" 9 | }, 10 | { 11 | "src": "/apple-icon-76x76.png", 12 | "sizes": "76x76", 13 | "type": "image/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "/apple-icon-120x120.png", 18 | "sizes": "120x120", 19 | "type": "image/png", 20 | "density": "1.0" 21 | }, 22 | { 23 | "src": "/apple-icon-152x152.png", 24 | "sizes": "152x152", 25 | "type": "image/png", 26 | "density": "1.0" 27 | }, 28 | { 29 | "src": "/apple-icon-180x180.png", 30 | "sizes": "180x180", 31 | "type": "image/png", 32 | "density": "1.0" 33 | }, 34 | { 35 | "src": "/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image/png", 38 | "density": "1.0" 39 | }, 40 | { 41 | "src": "/favicon-32x32.png", 42 | "sizes": "32x32", 43 | "type": "image/png", 44 | "density": "1.0" 45 | }, 46 | { 47 | "src": "/favicon-96x96.png", 48 | "sizes": "96x96", 49 | "type": "image/png", 50 | "density": "1.0" 51 | }, 52 | { 53 | "src": "/favicon-16x16.png", 54 | "sizes": "16x16", 55 | "type": "image/png", 56 | "density": "1.0" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /example/migrations/0000_omniscient_marrow.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `chat` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `name` text NOT NULL, 4 | `created_at` text NOT NULL, 5 | `user_id` text NOT NULL, 6 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade 7 | ); 8 | --> statement-breakpoint 9 | CREATE TABLE `chat_message` ( 10 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 11 | `order` integer NOT NULL, 12 | `message` text NOT NULL, 13 | `created_at` text NOT NULL, 14 | `chat_id` text NOT NULL, 15 | `user_id` text, 16 | FOREIGN KEY (`chat_id`) REFERENCES `chat`(`id`) ON UPDATE no action ON DELETE cascade, 17 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 18 | ); 19 | --> statement-breakpoint 20 | CREATE TABLE `password` ( 21 | `id` text PRIMARY KEY NOT NULL, 22 | `user_id` text NOT NULL, 23 | `password` text NOT NULL, 24 | `created_at` text NOT NULL, 25 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade 26 | ); 27 | --> statement-breakpoint 28 | CREATE TABLE `user` ( 29 | `id` text PRIMARY KEY NOT NULL, 30 | `email` text NOT NULL, 31 | `full_name` text NOT NULL, 32 | `display_name` text NOT NULL 33 | ); 34 | --> statement-breakpoint 35 | CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`); -------------------------------------------------------------------------------- /example/src/user/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const loginFormSchema = z.object({ 4 | username: z 5 | .string({ 6 | required_error: "Email is required", 7 | }) 8 | .trim() 9 | .email("Invalid email address"), 10 | password: z 11 | .string({ required_error: "Password is required" }) 12 | .min(1, "Password is required"), 13 | }); 14 | 15 | const unrefinedSignupFormSchema = z.object({ 16 | username: z 17 | .string({ 18 | required_error: "Email is required", 19 | }) 20 | .trim() 21 | .email("Invalid email address"), 22 | password: z 23 | .string({ required_error: "Password is required" }) 24 | .min(1, "Password is required"), 25 | verifyPassword: z 26 | .string({ required_error: "Must verify password" }) 27 | .min(1, "Must verify password"), 28 | displayName: z 29 | .string({ 30 | required_error: "Display name is required", 31 | }) 32 | .min(1, "Display name is required"), 33 | fullName: z 34 | .string({ 35 | required_error: "Full name is required", 36 | }) 37 | .min(1, "Full name is required"), 38 | }); 39 | 40 | export const signupFormSchema = unrefinedSignupFormSchema.refine( 41 | (data) => data.password === data.verifyPassword, 42 | { 43 | message: "Passwords must match", 44 | path: ["verifyPassword"], 45 | }, 46 | ) as unknown as typeof unrefinedSignupFormSchema; 47 | -------------------------------------------------------------------------------- /example/src/routes/shell/header.tsx: -------------------------------------------------------------------------------- 1 | import * as framework from "framework"; 2 | 3 | import { Routes } from "@/app"; 4 | import { Button } from "@/components/ui/button"; 5 | import { logout } from "@/user/actions"; 6 | import { getUserId } from "@/user/server"; 7 | 8 | export function Header() { 9 | const loggedIn = !!getUserId(false); 10 | const url = framework.getURL(); 11 | const redirectTo = url.pathname; 12 | 13 | return ( 14 |
15 |
19 |

RSC

20 | 31 | {loggedIn && ( 32 |
33 | 34 | 36 | 37 |
38 | )} 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /example/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { createRoutes } from "framework"; 2 | 3 | import { configureDBMiddleware } from "./db/server"; 4 | import { configureSecretsMiddleware } from "./secrets/middleware"; 5 | import { 6 | parseUserIdMiddleware, 7 | redirectIfLoggedInMiddleware, 8 | requireUserIdMiddleware, 9 | } from "./user/middleware"; 10 | 11 | export const routes = createRoutes([ 12 | { 13 | id: "shell", 14 | middleware: [ 15 | configureSecretsMiddleware, 16 | configureDBMiddleware, 17 | parseUserIdMiddleware, 18 | ], 19 | import: () => import("./routes/shell/route"), 20 | children: [ 21 | { 22 | id: "login", 23 | index: true, 24 | middleware: [redirectIfLoggedInMiddleware("/chat")], 25 | import: () => import("./routes/login/route"), 26 | }, 27 | { 28 | id: "signup", 29 | path: "signup", 30 | index: true, 31 | import: () => import("./routes/signup/route"), 32 | }, 33 | { 34 | id: "chat", 35 | path: "chat", 36 | import: () => import("./routes/chat/route"), 37 | children: [ 38 | { 39 | id: "chat.detail", 40 | path: ":chatId?", 41 | middleware: [requireUserIdMiddleware], 42 | import: () => import("./routes/chat.detail/route"), 43 | }, 44 | ], 45 | }, 46 | { 47 | id: "chats", 48 | path: "chats", 49 | middleware: [requireUserIdMiddleware], 50 | import: () => import("./routes/chats/route"), 51 | }, 52 | ], 53 | }, 54 | ]); 55 | -------------------------------------------------------------------------------- /example/src/user/middleware.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "cookie"; 2 | import { unsign } from "cookie-signature"; 3 | 4 | import type { MiddlewareFunction } from "framework"; 5 | 6 | import { Secrets } from "@/secrets/server"; 7 | 8 | import { USER_ID_KEY } from "./shared"; 9 | 10 | export const parseUserIdMiddleware: MiddlewareFunction = ( 11 | { get, headers, set, setClient }, 12 | next, 13 | ) => { 14 | const secret = get(Secrets.COOKIE_SECRET, true); 15 | const existingUserId = get(USER_ID_KEY); 16 | 17 | if (existingUserId) return next(); 18 | 19 | const cookie = headers.get("Cookie"); 20 | const userId = 21 | cookie && 22 | parse(cookie, { 23 | decode(value) { 24 | const unsigned = unsign(value, secret); 25 | if (typeof unsigned === "boolean") return ""; 26 | return unsigned; 27 | }, 28 | }).userId; 29 | 30 | set(USER_ID_KEY, userId ?? undefined); 31 | setClient(USER_ID_KEY, userId ?? undefined); 32 | 33 | return next(); 34 | }; 35 | 36 | export const redirectIfLoggedInMiddleware = 37 | (to: string): MiddlewareFunction => 38 | ({ get, redirect }, next) => { 39 | const userId = get(USER_ID_KEY); 40 | 41 | if (userId) { 42 | return redirect(to); 43 | } 44 | 45 | return next(); 46 | }; 47 | 48 | export const requireUserIdMiddleware: MiddlewareFunction = ( 49 | { get, redirect }, 50 | next, 51 | ) => { 52 | const userId = get(USER_ID_KEY); 53 | 54 | if (!userId) { 55 | return redirect("/"); 56 | } 57 | 58 | return next(); 59 | }; 60 | -------------------------------------------------------------------------------- /example/src/db/server.ts: -------------------------------------------------------------------------------- 1 | import * as fsp from "node:fs/promises"; 2 | import * as path from "node:path"; 3 | 4 | import Database from "better-sqlite3"; 5 | import { drizzle } from "drizzle-orm/better-sqlite3"; 6 | import { migrate } from "drizzle-orm/better-sqlite3/migrator"; 7 | 8 | import * as framework from "framework"; 9 | import type { MiddlewareFunction } from "framework"; 10 | 11 | import { Secrets } from "@/secrets/server"; 12 | import type { DB } from "./schema"; 13 | import schema from "./schema"; 14 | 15 | export const DB_KEY = "DB" as const; 16 | 17 | declare global { 18 | interface ServerContext { 19 | [DB_KEY]?: DB; 20 | } 21 | } 22 | 23 | let initialized = false; 24 | export const configureDBMiddleware: MiddlewareFunction = async (c, next) => { 25 | let db = c.get(DB_KEY); 26 | 27 | if (!db) { 28 | const dbPath = c.get(Secrets.DB_PATH, import.meta.env.PROD as false); 29 | const dbDir = dbPath ? path.resolve(dbPath) : path.resolve("./.database"); 30 | 31 | if (!initialized) { 32 | await fsp.mkdir(dbDir, { recursive: true }); 33 | } 34 | 35 | const sqlite = new Database(path.resolve(dbDir, "database.db")); 36 | db = drizzle(sqlite, { schema }); 37 | 38 | if (!initialized) { 39 | migrate(drizzle(sqlite), { 40 | migrationsFolder: "./migrations", 41 | }); 42 | initialized = true; 43 | } 44 | 45 | c.set(DB_KEY, db); 46 | } 47 | 48 | return next(); 49 | }; 50 | 51 | export function getDB() { 52 | return framework.get(DB_KEY, true); 53 | } 54 | -------------------------------------------------------------------------------- /example/src/entry.browser.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom/client"; 3 | 4 | import "@framework/vite/browser"; 5 | import { BrowserRouter, getInitialPayload } from "framework/browser"; 6 | 7 | import "./global.css"; 8 | 9 | declare global { 10 | interface Window { 11 | __payloadPromise: ReturnType | undefined; 12 | __reactRoot: ReactDOM.Root | undefined; 13 | } 14 | } 15 | 16 | hydrate().catch((reason) => console.error(reason)); 17 | 18 | async function hydrate() { 19 | if (!window.__payloadPromise) { 20 | window.__payloadPromise = getInitialPayload(); 21 | } 22 | const payload = await window.__payloadPromise; 23 | React.startTransition(() => { 24 | const element = ( 25 | 26 | 27 | 28 | ); 29 | 30 | if (window.__reactRoot) { 31 | window.__reactRoot.render(element); 32 | } else { 33 | window.__reactRoot = ReactDOM.hydrateRoot(document, element, { 34 | formState: payload.formState, 35 | onRecoverableError(error, errorInfo) { 36 | console.error("RECOVERABLE ERROR", error, errorInfo); 37 | }, 38 | onCaughtError(error, errorInfo) { 39 | console.error("CAUGHT ERROR", error, errorInfo); 40 | }, 41 | onUncaughtError(error, errorInfo) { 42 | console.error("UNCAUGHT ERROR", error, errorInfo); 43 | }, 44 | }); 45 | } 46 | }); 47 | } 48 | 49 | if (import.meta.hot) { 50 | import.meta.hot.accept(); 51 | } 52 | -------------------------------------------------------------------------------- /example/src/routes/chat.detail/shared.tsx: -------------------------------------------------------------------------------- 1 | import * as markdown from "tiny-markdown-parser"; 2 | 3 | export function UserMessage({ children }: { children: string | string[] }) { 4 | return ( 5 |
6 | {children} 7 |
8 | ); 9 | } 10 | 11 | export function AIMessage({ children }: { children: string | string[] }) { 12 | return ( 13 |
14 | {children} 15 |
16 | ); 17 | } 18 | 19 | export function PendingAIMessage() { 20 | return ( 21 |
22 | 23 | Waiting for response... 24 | 25 |
26 | ); 27 | } 28 | 29 | export function ErrorMessage({ children }: { children: string | string[] }) { 30 | return ( 31 |
32 | {children} 33 |
34 | ); 35 | } 36 | 37 | export function MarkdownRenderer({ 38 | children, 39 | }: { 40 | children: string | string[]; 41 | }) { 42 | const content = Array.isArray(children) ? children.join("") : children; 43 | 44 | const parsed = markdown.parse(content); 45 | return ( 46 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /example/src/routes/shell/favicons.tsx: -------------------------------------------------------------------------------- 1 | export function Favicons() { 2 | return ( 3 | <> 4 | 5 | 6 | 7 | 8 | 13 | 18 | 23 | 28 | 33 | 39 | 45 | 51 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /framework/src/shared.tsx: -------------------------------------------------------------------------------- 1 | const g = ( 2 | typeof window !== "undefined" 3 | ? window 4 | : typeof globalThis !== "undefined" 5 | ? globalThis 6 | : typeof global !== "undefined" 7 | ? global 8 | : {} 9 | ) as typeof globalThis; 10 | 11 | export function getOrCreateGlobal( 12 | key: K, 13 | create: () => (typeof globalThis)[K], 14 | ): (typeof globalThis)[K] { 15 | if (!g[key]) { 16 | g[key] = create(); 17 | } 18 | return g[key]; 19 | } 20 | 21 | export function setGlobal( 22 | key: K, 23 | value: (typeof globalThis)[K], 24 | ) { 25 | g[key] = value; 26 | } 27 | 28 | export type FormOptionsProps = { 29 | preventScrollReset?: boolean; 30 | revalidate?: boolean | string[]; 31 | }; 32 | 33 | export function FormOptions({ 34 | preventScrollReset, 35 | revalidate, 36 | }: FormOptionsProps) { 37 | const options = []; 38 | 39 | if (preventScrollReset) { 40 | options.push( 41 | , 47 | ); 48 | } 49 | 50 | if (typeof revalidate === "boolean") { 51 | if (!revalidate) { 52 | options.push( 53 | , 59 | ); 60 | } 61 | } else if (Array.isArray(revalidate)) { 62 | const value = JSON.stringify(revalidate); 63 | options.push( 64 | , 70 | ); 71 | } 72 | 73 | return <>{options}; 74 | } 75 | -------------------------------------------------------------------------------- /example/src/routes/chat/route.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { RevalidationTargets } from "@/app"; 4 | import { Button } from "@/components/ui/button"; 5 | 6 | import { clearChats } from "../chats/actions"; 7 | import { ChatList } from "../chats/route"; 8 | 9 | export default function ChatLayoutRoute({ 10 | children, 11 | }: { 12 | children?: React.ReactNode; 13 | }) { 14 | return ( 15 |
16 | 31 |
32 | {children} 33 |
34 |
35 | ); 36 | // return ( 37 | //
41 | //
42 | // 49 | //
{children}
50 | //
51 | //
52 | // ); 53 | } 54 | -------------------------------------------------------------------------------- /framework/src/router/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | 5 | const RouteProviderContext = React.createContext< 6 | | { 7 | clientContext?: Record; 8 | rendered: Record; 9 | } 10 | | undefined 11 | >(undefined); 12 | 13 | export function RouteProvider({ 14 | children, 15 | clientContext, 16 | rendered, 17 | }: { 18 | children: React.ReactNode; 19 | clientContext?: Record; 20 | rendered: Record; 21 | }) { 22 | return ( 23 | 24 | {children} 25 | 26 | ); 27 | } 28 | 29 | export function RenderRoute({ id }: { id: string }) { 30 | const context = React.useContext(RouteProviderContext); 31 | if (!context) { 32 | throw new Error("Route must be used inside a RouteProvider"); 33 | } 34 | 35 | return context.rendered[id]; 36 | } 37 | 38 | type UseServerContextFunction = (( 39 | key: Key, 40 | truthy: true, 41 | ) => NonNullable) & 42 | (( 43 | key: Key, 44 | truthy?: false, 45 | ) => undefined | ServerClientContext[Key]) & 46 | (( 47 | key: Key, 48 | truthy?: boolean, 49 | ) => undefined | ServerClientContext[Key]); 50 | 51 | export const useServerContext: UseServerContextFunction = < 52 | Key extends keyof ServerClientContext, 53 | >( 54 | key: Key, 55 | truthy = false, 56 | ) => { 57 | const context = React.useContext(RouteProviderContext); 58 | if (!context) { 59 | throw new Error("useServerContext must be used inside a RouteProvider"); 60 | } 61 | 62 | const value = context.clientContext?.[key]; 63 | if (truthy && !value) { 64 | throw new Error(`Server context value ${key} is missing`); 65 | } 66 | 67 | return value as NonNullable; 68 | }; 69 | -------------------------------------------------------------------------------- /example/src/forms/client.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import type { FormOptions } from "@conform-to/dom"; 4 | import { useForm as useConformForm } from "@conform-to/react"; 5 | import { getZodConstraint, parseWithZod } from "@conform-to/zod"; 6 | import type { ZodTypeAny, infer as zodInfer, input as zodInput } from "zod"; 7 | 8 | import { useHydrated } from "framework/client"; 9 | 10 | type OrUnknown = T extends Record 11 | ? T 12 | : Record; 13 | 14 | export function useForm({ 15 | id, 16 | schema, 17 | ...rest 18 | }: { 19 | schema: Schema; 20 | } & Omit< 21 | FormOptions< 22 | OrUnknown>, 23 | string[], 24 | OrUnknown> 25 | >, 26 | "constraint" | "formId" 27 | > & { 28 | /** 29 | * The form id. If not provided, a random id will be generated. 30 | */ 31 | id?: string; 32 | /** 33 | * Enable constraint validation before the dom is hydated. 34 | * 35 | * Default to `true`. 36 | */ 37 | defaultNoValidate?: boolean; 38 | }) { 39 | const defaultId = React.useId(); 40 | const hydrated = useHydrated(); 41 | const [form, fields] = useConformForm< 42 | OrUnknown>, 43 | OrUnknown>, 44 | string[] 45 | >({ 46 | ...rest, 47 | id: id ?? defaultId, 48 | constraint: getZodConstraint(schema), 49 | // biome-ignore lint/suspicious/noExplicitAny: 50 | onValidate: (context): any => { 51 | return parseWithZod(context.formData, { 52 | schema, 53 | }); 54 | }, 55 | }); 56 | 57 | return React.useMemo(() => { 58 | const proxyForm = new Proxy( 59 | { noValidate: false }, 60 | { 61 | get(_, key: keyof typeof form) { 62 | if (key === "noValidate" && !hydrated) { 63 | return false; 64 | } 65 | return form[key]; 66 | }, 67 | }, 68 | ) as typeof form; 69 | 70 | return [proxyForm, fields] as const; 71 | }, [form, fields, hydrated]); 72 | } 73 | -------------------------------------------------------------------------------- /example/src/routes/chat.detail/route.tsx: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import * as React from "react"; 3 | import { URLPattern } from "urlpattern-polyfill"; 4 | 5 | import * as framework from "framework"; 6 | 7 | import { Routes } from "@/app"; 8 | import { chat } from "@/db/schema"; 9 | import { getDB } from "@/db/server"; 10 | import { getUserId } from "@/user/server"; 11 | 12 | import { sendMessage } from "./actions"; 13 | import { SendMessageForm } from "./client"; 14 | import { AIMessage, UserMessage } from "./shared"; 15 | 16 | export default async function ChatRoute() { 17 | const url = framework.getURL(); 18 | const sendMessageAction = framework.getActionResult(sendMessage); 19 | const userId = getUserId(); 20 | const db = getDB(); 21 | 22 | // await new Promise((resolve) => setTimeout(resolve, 1000)); 23 | 24 | const matched = new URLPattern({ pathname: "/chat/:chatId?" }).exec(url); 25 | const chatId = matched?.pathname.groups.chatId; 26 | 27 | const initialChat = chatId 28 | ? await db.query.chat.findFirst({ 29 | where: and(eq(chat.userId, userId), eq(chat.id, chatId)), 30 | with: { 31 | messages: { 32 | orderBy: ({ order }, { desc }) => desc(order), 33 | columns: { 34 | id: true, 35 | message: true, 36 | userId: true, 37 | }, 38 | }, 39 | }, 40 | }) 41 | : null; 42 | 43 | return ( 44 |
45 |
46 |
47 | 53 | {initialChat?.messages.map((message) => ( 54 | 55 | {message.userId ? ( 56 | {message.message} 57 | ) : ( 58 | {message.message} 59 | )} 60 | 61 | ))} 62 | 63 |
64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /example/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /framework/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "framework", 3 | "version": "0.0.0", 4 | "description": "", 5 | "type": "module", 6 | "keywords": [], 7 | "author": "", 8 | "license": "ISC", 9 | "files": [ 10 | "src/**/*.ts", 11 | "src/**/*.tsx", 12 | "dist/**/*.js", 13 | "dist/**/*.d.ts", 14 | "dist/**/*.*.map", 15 | "!**/*.test.*" 16 | ], 17 | "exports": { 18 | ".": { 19 | "types": "./dist/server.d.ts", 20 | "source": "./src/server.ts", 21 | "default": "./dist/server.js" 22 | }, 23 | "./browser": { 24 | "types": "./dist/browser.d.ts", 25 | "source": "./src/browser.ts", 26 | "default": "./dist/browser.js" 27 | }, 28 | "./client": { 29 | "types": "./dist/client.d.ts", 30 | "source": "./src/client.tsx", 31 | "default": "./dist/client.js" 32 | }, 33 | "./prerender": { 34 | "types": "./dist/prerender.d.ts", 35 | "source": "./src/prerender.ts", 36 | "default": "./dist/prerender.js" 37 | }, 38 | "./runtime.client": { 39 | "types": "./dist/runtime.client.d.ts", 40 | "source": "./src/runtime.client.ts", 41 | "default": "./dist/runtime.client.js" 42 | }, 43 | "./shared": { 44 | "types": "./dist/shared.d.ts", 45 | "source": "./src/shared.tsx", 46 | "default": "./dist/shared.js" 47 | } 48 | }, 49 | "scripts": { 50 | "build": "tsc", 51 | "build:watch": "tsc --watch", 52 | "test": "node --no-warnings --enable-source-maps --conditions source --loader ts-node/esm --test ./src/*.test.*", 53 | "test:watch": "node --no-warnings --enable-source-maps --conditions source --loader ts-node/esm --watch --test ./src/*.test.*" 54 | }, 55 | "peerDependencies": { 56 | "react": "0.0.0-experimental-96c584661-20240412", 57 | "react-dom": "0.0.0-experimental-96c584661-20240412", 58 | "react-server-dom-diy": "0.0.0-experimental-15a3a5622-202404158" 59 | }, 60 | "dependencies": { 61 | "@hattip/core": "0.0.45", 62 | "rsc-html-stream": "0.0.3" 63 | }, 64 | "devDependencies": { 65 | "@types/node": "20.12.4", 66 | "@types/react": "18.2.77", 67 | "@types/react-dom": "18.2.25", 68 | "ts-node": "10.9.2", 69 | "typescript": "5.4.4" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /example/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { visualizer } from "rollup-plugin-visualizer"; 3 | import { createNodeDevEnvironment, defineConfig } from "vite"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | 6 | import { 7 | createReactServerOptions, 8 | reactServerBuilder, 9 | reactServerDevServer, 10 | reactServerPlugin, 11 | } from "@framework/vite"; 12 | 13 | import packageJSON from "./package.json" assert { type: "json" }; 14 | 15 | const options = createReactServerOptions(); 16 | 17 | export default defineConfig({ 18 | builder: reactServerBuilder(options), 19 | environments: { 20 | client: { 21 | build: { 22 | outDir: "dist/browser", 23 | rollupOptions: { 24 | input: { 25 | index: "/src/entry.browser.tsx", 26 | }, 27 | plugins: [ 28 | visualizer({ 29 | template: "flamegraph", 30 | filename: ".stats/client.html", 31 | }), 32 | ], 33 | }, 34 | }, 35 | }, 36 | ssr: { 37 | build: { 38 | outDir: "dist/prerender", 39 | rollupOptions: { 40 | input: { 41 | index: "/src/entry.prerender.tsx", 42 | }, 43 | plugins: [ 44 | visualizer({ 45 | template: "flamegraph", 46 | filename: ".stats/ssr.html", 47 | }), 48 | ], 49 | }, 50 | }, 51 | resolve: { 52 | noExternal: packageJSON.bundlePrerender, 53 | }, 54 | }, 55 | server: { 56 | build: { 57 | outDir: "dist/server", 58 | rollupOptions: { 59 | input: { 60 | index: "/src/entry.server.tsx", 61 | }, 62 | plugins: [ 63 | visualizer({ 64 | template: "flamegraph", 65 | filename: ".stats/server.html", 66 | }), 67 | ], 68 | }, 69 | }, 70 | dev: { 71 | optimizeDeps: { 72 | exclude: ["@conform-to/zod"], 73 | }, 74 | }, 75 | resolve: { 76 | external: packageJSON.doNotBundleServer, 77 | }, 78 | }, 79 | }, 80 | plugins: [ 81 | tsconfigPaths(), 82 | react(), 83 | reactServerPlugin(options), 84 | reactServerDevServer({ 85 | ...options, 86 | createPrerenderEnvironment: createNodeDevEnvironment, 87 | createServerEnvironment: createNodeDevEnvironment, 88 | }), 89 | ], 90 | }); 91 | -------------------------------------------------------------------------------- /framework/src/client.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import type { Navigation } from "./router/browser.js"; 4 | import { NavigationContext, navigate, scrollToTop } from "./router/browser.js"; 5 | export { useServerContext } from "./router/client.js"; 6 | 7 | export type { Navigation }; 8 | export { navigate }; 9 | 10 | declare global { 11 | var __navigationContext: React.Context; 12 | } 13 | 14 | export function useNavigation() { 15 | return React.useContext(NavigationContext); 16 | } 17 | 18 | const emptySubscribe = () => () => {}; 19 | 20 | export function useHydrated() { 21 | return React.useSyncExternalStore( 22 | emptySubscribe, 23 | () => true, 24 | () => false, 25 | ); 26 | } 27 | 28 | export function useEnhancedActionState< 29 | Action extends (formData: FormData) => unknown, 30 | >( 31 | action: Action, 32 | clientAction: ( 33 | state: Awaited> | undefined, 34 | formData: FormData, 35 | ) => ReturnType | Promise>, 36 | initialState?: Awaited> | undefined, 37 | permalink?: string, 38 | ): [ 39 | state: Awaited> | undefined, 40 | dispatch: Action | ((formData: FormData) => void), 41 | isPending: boolean, 42 | ] { 43 | const [state, dispatch, pending] = React.useActionState< 44 | ReturnType | undefined, 45 | FormData 46 | >(clientAction, initialState, permalink); 47 | const hydrated = useHydrated(); 48 | if (hydrated) { 49 | return [state, dispatch, pending] as const; 50 | } 51 | return [state, action, pending] as const; 52 | } 53 | 54 | export function redirect( 55 | to: string, 56 | revalidate?: string, 57 | preventScrollReset = false, 58 | ) { 59 | const controller = new AbortController(); 60 | return __startNavigation( 61 | to, 62 | controller, 63 | async (completeNavigation, aborted) => { 64 | const payload = await navigate(to, controller.signal, revalidate); 65 | if (window.location.href !== payload.url.href && !aborted()) { 66 | window.history.pushState(null, "", payload.url.href); 67 | } 68 | if (!aborted() && !preventScrollReset) { 69 | scrollToTop(); 70 | } 71 | completeNavigation(payload); 72 | }, 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /example/src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { relations, sql } from "drizzle-orm"; 2 | import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; 3 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 4 | import { v4 as uuid } from "uuid"; 5 | 6 | const stringId = (name: string) => 7 | text(name) 8 | .primaryKey() 9 | .notNull() 10 | .$defaultFn(() => uuid()); 11 | 12 | const createdAt = () => 13 | text("created_at") 14 | .notNull() 15 | .$default(() => sql`CURRENT_TIMESTAMP`); 16 | 17 | export const password = sqliteTable("password", { 18 | id: stringId("id"), 19 | userId: text("user_id") 20 | .notNull() 21 | .references(() => user.id, { onDelete: "cascade" }), 22 | password: text("password").notNull(), 23 | createdAt: createdAt(), 24 | }); 25 | 26 | export const user = sqliteTable("user", { 27 | id: stringId("id"), 28 | email: text("email").unique().notNull(), 29 | fullName: text("full_name").notNull(), 30 | displayName: text("display_name").notNull(), 31 | }); 32 | 33 | export const chat = sqliteTable("chat", { 34 | id: stringId("id"), 35 | name: text("name").notNull(), 36 | createdAt: createdAt(), 37 | userId: text("user_id") 38 | .notNull() 39 | .references(() => user.id, { onDelete: "cascade" }), 40 | }); 41 | 42 | const chatRelations = relations(chat, ({ many }) => ({ 43 | messages: many(chatMessage), 44 | })); 45 | 46 | export const chatMessage = sqliteTable("chat_message", { 47 | id: integer("id").primaryKey({ autoIncrement: true }), 48 | order: integer("order").notNull(), 49 | message: text("message").notNull(), 50 | createdAt: createdAt(), 51 | chatId: text("chat_id") 52 | .notNull() 53 | .references(() => chat.id, { onDelete: "cascade" }), 54 | userId: text("user_id").references(() => user.id), 55 | }); 56 | 57 | const chatMessageRelations = relations(chatMessage, ({ one }) => ({ 58 | chat: one(chat, { 59 | fields: [chatMessage.chatId], 60 | references: [chat.id], 61 | }), 62 | sender: one(user, { 63 | fields: [chatMessage.userId], 64 | references: [user.id], 65 | }), 66 | })); 67 | 68 | const schema = { 69 | chat, 70 | chatMessage, 71 | chatMessageRelations, 72 | chatRelations, 73 | password, 74 | user, 75 | }; 76 | 77 | export default schema; 78 | 79 | export type DB = BetterSQLite3Database; 80 | -------------------------------------------------------------------------------- /example/src/routes/chats/route.tsx: -------------------------------------------------------------------------------- 1 | import { Routes } from "@/app"; 2 | import { getChatsForUser } from "@/cache/chat"; 3 | import { Button } from "@/components/ui/button"; 4 | 5 | import { clearChats } from "./actions"; 6 | 7 | export default function ChatListRoute() { 8 | return ( 9 |
10 |
11 |

Chat History

12 |
13 | 16 |
17 |
18 | 19 | 20 |
21 | ); 22 | } 23 | 24 | export async function ChatList({ revalidate }: { revalidate?: string }) { 25 | const chats = await getChatsForUser(); 26 | 27 | // short date / time format 28 | const dateFormatter = new Intl.DateTimeFormat("en-US", { 29 | year: "2-digit", 30 | month: "numeric", 31 | day: "numeric", 32 | hour: "numeric", 33 | minute: "numeric", 34 | second: "numeric", 35 | timeZoneName: "short", 36 | timeZone: "America/Los_Angeles", 37 | }); 38 | const formatDate = (str: string) => { 39 | const [date, time] = str.split(" "); 40 | const [year, month, day] = date.split("-"); 41 | const [hour, minute, second] = time.split(":"); 42 | return dateFormatter.format( 43 | new Date( 44 | Date.UTC( 45 | Number.parseInt(year, 10), 46 | Number.parseInt(month, 10) - 1, 47 | Number.parseInt(day, 10), 48 | Number.parseInt(hour, 10), 49 | Number.parseInt(minute, 10), 50 | Number.parseInt(second, 10), 51 | ), 52 | ), 53 | ); 54 | }; 55 | 56 | return ( 57 | <> 58 | 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /example/src/entry.prerender.tsx: -------------------------------------------------------------------------------- 1 | import * as stream from "node:stream"; 2 | 3 | import * as ReactDOM from "react-dom/server"; 4 | 5 | import { createHandler, renderServerResponse } from "framework/prerender"; 6 | 7 | export default createHandler( 8 | async ( 9 | { request }, 10 | { 11 | bootstrapModules, 12 | bootstrapScripts, 13 | bootstrapScriptContent, 14 | callServer, 15 | cssFiles, 16 | }, 17 | ) => { 18 | try { 19 | const serverResponse = await callServer(request); 20 | 21 | return await renderServerResponse(serverResponse, { 22 | requestHeaders: request.headers, 23 | renderToReadableStream: async (element, headers) => { 24 | const bytes = crypto.getRandomValues(new Uint8Array(16)); 25 | const nonce = btoa(String.fromCharCode(...bytes)); 26 | headers.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`); 27 | 28 | const readable = await new Promise( 29 | (resolve, reject) => { 30 | let sent = false; 31 | const html = ReactDOM.renderToPipeableStream( 32 | <> 33 | {cssFiles?.map((href) => ( 34 | 35 | ))} 36 | {element} 37 | , 38 | { 39 | bootstrapModules, 40 | bootstrapScripts, 41 | bootstrapScriptContent, 42 | nonce, 43 | onError(error, errorInfo) { 44 | if (!sent) { 45 | sent = true; 46 | reject(error); 47 | } else { 48 | console.error(error); 49 | if (errorInfo) { 50 | console.error(errorInfo); 51 | } 52 | } 53 | }, 54 | onShellError(error) { 55 | sent = true; 56 | reject(error); 57 | }, 58 | onShellReady() { 59 | sent = true; 60 | resolve(pipeable); 61 | }, 62 | }, 63 | ); 64 | const pipeable = html.pipe(new stream.PassThrough()); 65 | setTimeout(() => { 66 | html.abort( 67 | new Error("HTML render took longer than 30 seconds."), 68 | ); 69 | }, 30_000); 70 | }, 71 | ); 72 | 73 | return stream.Readable.toWeb(readable) as ReadableStream; 74 | }, 75 | }); 76 | } catch (reason) { 77 | console.error(reason); 78 | return new Response("Internal Server Error", { status: 500 }); 79 | } 80 | }, 81 | ); 82 | -------------------------------------------------------------------------------- /framework/src/router/prerender.tsx: -------------------------------------------------------------------------------- 1 | import * as stream from "node:stream"; 2 | import type * as streamWeb from "node:stream/web"; 3 | 4 | // @ts-expect-error - no types 5 | import ReactServerDOM from "react-server-dom-diy/client"; 6 | import { injectRSCPayload } from "rsc-html-stream/server"; 7 | 8 | import type { AdapterRequestContext } from "@hattip/core"; 9 | import { RenderRoute, RouteProvider } from "./client.js"; 10 | import type { ServerPayload } from "./server.js"; 11 | 12 | export type PrerenderHandlerArgs = { 13 | bootstrapModules?: string[]; 14 | bootstrapScripts?: string[]; 15 | bootstrapScriptContent?: string; 16 | callServer: (request: Request) => Promise; 17 | cssFiles?: string[]; 18 | }; 19 | 20 | export function createHandler( 21 | handler: ( 22 | context: AdapterRequestContext, 23 | args: PrerenderHandlerArgs, 24 | ) => Response | Promise, 25 | ) { 26 | return handler; 27 | } 28 | 29 | export async function renderServerResponse( 30 | response: Response, 31 | { 32 | requestHeaders: headers, 33 | renderToReadableStream, 34 | }: { 35 | requestHeaders: Headers; 36 | renderToReadableStream: ( 37 | node: React.ReactNode, 38 | headers: Headers, 39 | ) => Promise; 40 | }, 41 | ) { 42 | if (!response.body) throw new Error("No body"); 43 | 44 | if (headers.get("RSC-Refresh") === "1" || headers.get("Rsc-Action")) { 45 | return response; 46 | } 47 | 48 | const [bodyA, bodyB] = response.body.tee(); 49 | const payload = (await ReactServerDOM.createFromNodeStream( 50 | stream.Readable.fromWeb(bodyA as streamWeb.ReadableStream), 51 | __diy_client_manifest__, 52 | )) as ServerPayload; 53 | 54 | if (payload.redirect) { 55 | const responseHeaders = new Headers(response.headers); 56 | responseHeaders.set("Location", payload.redirect); 57 | 58 | return new Response(payload.redirect, { 59 | status: 302, 60 | headers: responseHeaders, 61 | }); 62 | } 63 | 64 | if (!payload.tree) { 65 | throw new Error("No elements rendered on the server"); 66 | } 67 | 68 | const responseHeaders = new Headers({ 69 | "Content-Type": "text/html", 70 | Vary: "Accept", 71 | }); 72 | const html = ( 73 | await renderToReadableStream( 74 | 78 | 79 | , 80 | headers, 81 | ) 82 | ).pipeThrough(injectRSCPayload(bodyB)); 83 | 84 | return new Response(html, { 85 | headers: responseHeaders, 86 | }); 87 | } 88 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "name": "example", 5 | "version": "0.0.0", 6 | "scripts": { 7 | "build": "vite build --all && tsc", 8 | "dev": "vite", 9 | "generate:migration": "pnpm drizzle-kit generate:sqlite", 10 | "start": "cross-env NODE_ENV=production node --no-warnings server.js" 11 | }, 12 | "bundlePrerender": [], 13 | "doNotBundleServer": [ 14 | "@conform-to/dom", 15 | "@conform-to/react", 16 | "@conform-to/zod", 17 | "@hattip/adapter-node", 18 | "@hattip/core", 19 | "@langchain/core", 20 | "@langchain/groq", 21 | "bcrypt", 22 | "better-sqlite3", 23 | "clsx", 24 | "compression", 25 | "cookie", 26 | "cookie-signature", 27 | "drizzle-orm", 28 | "express", 29 | "eventsource-parser", 30 | "jsondiffpatch", 31 | "rsc-html-stream", 32 | "secure-json-parse", 33 | "tiny-markdown-parser", 34 | "urlpattern-polyfill", 35 | "uuid", 36 | "zod" 37 | ], 38 | "dependencies": { 39 | "@conform-to/dom": "1.1.0", 40 | "@conform-to/react": "1.1.0", 41 | "@conform-to/zod": "1.1.0", 42 | "@framework/vite": "workspace:*", 43 | "@hattip/adapter-node": "0.0.45", 44 | "@hattip/core": "0.0.45", 45 | "@radix-ui/react-icons": "^1.3.0", 46 | "@radix-ui/react-label": "^2.0.2", 47 | "@radix-ui/react-slot": "^1.0.2", 48 | "ai": "3.0.29", 49 | "bcrypt": "5.1.1", 50 | "better-sqlite3": "9.5.0", 51 | "class-variance-authority": "^0.7.0", 52 | "clsx": "2.1.0", 53 | "cookie": "0.6.0", 54 | "cookie-signature": "1.2.1", 55 | "drizzle-orm": "0.30.9", 56 | "eventsource-parser": "1.1.2", 57 | "express": "4.19.2", 58 | "framework": "workspace:*", 59 | "jsondiffpatch": "0.6.0", 60 | "ollama": "0.5.0", 61 | "react": "0.0.0-experimental-96c584661-20240412", 62 | "react-dom": "0.0.0-experimental-96c584661-20240412", 63 | "react-server-dom-diy": "0.0.0-experimental-15a3a5622-202404158", 64 | "secure-json-parse": "2.7.0", 65 | "tailwind-merge": "^2.3.0", 66 | "tailwindcss-animate": "^1.0.7", 67 | "tiny-markdown-parser": "1.0.1", 68 | "urlpattern-polyfill": "10.0.0", 69 | "uuid": "9.0.1", 70 | "zod": "3.23.4", 71 | "zod-to-json-schema": "3.23.0" 72 | }, 73 | "devDependencies": { 74 | "@tailwindcss/typography": "0.5.12", 75 | "@tailwindcss/vite": "4.0.0-alpha.14", 76 | "@types/bcrypt": "5.0.2", 77 | "@types/better-sqlite3": "7.6.10", 78 | "@types/compression": "1.7.5", 79 | "@types/cookie": "0.6.0", 80 | "@types/cookie-signature": "1.1.2", 81 | "@types/express": "4.17.21", 82 | "@types/node": "20.12.7", 83 | "@types/react": "18.2.77", 84 | "@types/react-dom": "18.2.25", 85 | "@types/uuid": "9.0.8", 86 | "@vitejs/plugin-react": "4.2.1", 87 | "autoprefixer": "10.4.19", 88 | "cross-env": "7.0.3", 89 | "daisyui": "4.10.2", 90 | "drizzle-kit": "0.20.17", 91 | "postcss": "8.4.38", 92 | "rollup-plugin-visualizer": "5.12.0", 93 | "tailwindcss": "3.4.3", 94 | "types-react": "19.0.0-alpha.3", 95 | "typescript": "5.4.5", 96 | "vite": "6.0.0-alpha.1", 97 | "vite-tsconfig-paths": "4.3.2" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /example/src/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* 6 | Theme variables 7 | */ 8 | @layer base { 9 | :root { 10 | --background: 0 0% 100%; 11 | --foreground: 222.2 84% 4.9%; 12 | --background-dark: 222.2 84% 4.9%; 13 | --foreground-dark: 210 40% 98%; 14 | 15 | --card: 0 0% 100%; 16 | --card-foreground: 222.2 84% 4.9%; 17 | --card-dark: 222.2 84% 4.9%; 18 | --card-foreground-dark: 210 40% 98%; 19 | 20 | --popover: 0 0% 100%; 21 | --popover-foreground: 222.2 84% 4.9%; 22 | --popover-dark: 222.2 84% 4.9%; 23 | --popover-foreground-dark: 210 40% 98%; 24 | 25 | --primary: 222.2 47.4% 11.2%; 26 | --primary-foreground: 210 40% 98%; 27 | --primary-dark: 210 40% 98%; 28 | --primary-foreground-dark: 222.2 47.4% 11.2%; 29 | 30 | --secondary: 210 40% 96.1%; 31 | --secondary-foreground: 222.2 47.4% 11.2%; 32 | --secondary-dark: 217.2 32.6% 17.5%; 33 | --secondary-foreground-dark: 210 40% 98%; 34 | 35 | --muted: 210 40% 96.1%; 36 | --muted-foreground: 215.4 16.3% 46.9%; 37 | --muted-dark: 217.2 32.6% 17.5%; 38 | --muted-foreground-dark: 215 20.2% 65.1%; 39 | 40 | --accent: 210 40% 96.1%; 41 | --accent-foreground: 222.2 47.4% 11.2%; 42 | --accent-dark: 217.2 32.6% 17.5%; 43 | --accent-foreground-dark: 210 40% 98%; 44 | 45 | --destructive: 0 84.2% 60.2%; 46 | --destructive-foreground: 210 40% 98%; 47 | --destructive-dark: 0 62.8% 30.6%; 48 | --destructive-foreground-dark: 210 40% 98%; 49 | 50 | --border: 214.3 31.8% 91.4%; 51 | --input: 214.3 31.8% 91.4%; 52 | --ring: 222.2 84% 4.9%; 53 | --border-dark: 217.2 32.6% 17.5%; 54 | --input-dark: 217.2 32.6% 17.5%; 55 | --ring-dark: 212.7 26.8% 83.9%; 56 | 57 | --radius: 0.5rem; 58 | } 59 | } 60 | 61 | /* 62 | Theme switching based on this tweet from Devon Govett 63 | https://twitter.com/devongovett/status/1757131288144663027 64 | */ 65 | @layer base { 66 | :root { 67 | --theme-light: initial; 68 | --theme-dark: ; 69 | color-scheme: light dark; 70 | } 71 | 72 | @media (prefers-color-scheme: dark) { 73 | :root { 74 | --theme-light: ; 75 | --theme-dark: initial; 76 | } 77 | } 78 | 79 | [data-theme="light"] { 80 | --theme-light: initial; 81 | --theme-dark: ; 82 | color-scheme: light; 83 | } 84 | 85 | [data-theme="dark"] { 86 | --theme-light: ; 87 | --theme-dark: initial; 88 | color-scheme: dark; 89 | } 90 | } 91 | 92 | @layer base { 93 | * { 94 | @apply border-border; 95 | } 96 | html, 97 | body { 98 | @apply bg-background text-foreground; 99 | } 100 | } 101 | 102 | button { 103 | animation: none !important; 104 | transition-property: none !important; 105 | } 106 | 107 | button:hover, 108 | button:focus, 109 | button:active { 110 | animation: button-pop var(--animation-btn, 0.25s) ease-out !important; 111 | transition-property: color, background-color, border-color, opacity, 112 | box-shadow, transform !important; 113 | } 114 | -------------------------------------------------------------------------------- /example/src/routes/login/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useNavigation } from "framework/client"; 4 | 5 | import { Routes } from "@/app"; 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | Card, 9 | CardContent, 10 | CardDescription, 11 | CardHeader, 12 | CardTitle, 13 | } from "@/components/ui/card"; 14 | import { Input } from "@/components/ui/input"; 15 | import { Label } from "@/components/ui/label"; 16 | import { useForm } from "@/forms/client"; 17 | import type { login } from "@/user/actions"; 18 | import { loginFormSchema } from "@/user/schema"; 19 | 20 | type LoginFormProps = { 21 | action: typeof login; 22 | initialState?: Awaited>; 23 | }; 24 | 25 | export function LoginForm({ action, initialState }: LoginFormProps) { 26 | const { pending } = useNavigation(); 27 | const [form, fields] = useForm({ 28 | id: "login-form", 29 | lastResult: initialState, 30 | schema: loginFormSchema, 31 | shouldValidate: "onBlur", 32 | }); 33 | 34 | const { password, username: email } = fields; 35 | 36 | return ( 37 |
{ 43 | if (pending) { 44 | event.preventDefault(); 45 | return; 46 | } 47 | form.onSubmit(event); 48 | }} 49 | > 50 | 51 | 52 | Log in 53 | 54 | Enter your email below to log in to your account. 55 | 56 | 57 | 58 |
59 | 60 | 70 | {email.errors && ( 71 | 74 | )} 75 |
76 |
77 | 78 | 88 | {password.errors && ( 89 | 92 | )} 93 |
94 | 95 |
96 | Don't have an account?{" "} 97 | 98 | Sign up 99 | 100 |
101 |
102 |
103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /framework/src/server.ts: -------------------------------------------------------------------------------- 1 | import type { AsyncLocalStorage } from "node:async_hooks"; 2 | 3 | import type { RouterContext } from "./router/server.js"; 4 | import { REDIRECT_SYMBOL, asyncLocalStorage } from "./router/server.js"; 5 | 6 | export type { 7 | MiddlewareContext, 8 | MiddlewareFunction, 9 | RouteConfig, 10 | RouteModule, 11 | RouterContext, 12 | ServerPayload, 13 | } from "./router/server.js"; 14 | export { 15 | createHandler, 16 | createRoutes, 17 | runRoutes, 18 | runWithContext, 19 | } from "./router/server.js"; 20 | 21 | declare global { 22 | // biome-ignore lint/suspicious/noEmptyInterface: used for declaration merging 23 | interface ServerContext {} 24 | // biome-ignore lint/suspicious/noEmptyInterface: used for declaration merging 25 | interface ServerClientContext {} 26 | var __asyncLocalStorage: AsyncLocalStorage; 27 | } 28 | 29 | export function get( 30 | key: Key, 31 | truthy: true, 32 | ): NonNullable; 33 | export function get( 34 | key: Key, 35 | truthy?: false, 36 | ): undefined | ServerContext[Key]; 37 | export function get( 38 | key: Key, 39 | ): undefined | ServerContext[Key] { 40 | // biome-ignore lint/style/noArguments: 41 | const truthy = arguments.length === 2 ? arguments[1] : false; 42 | const context = asyncLocalStorage.getStore(); 43 | if (!context) throw new Error("No context"); 44 | if (truthy) return context.get(key, truthy); 45 | return context.get(key, truthy); 46 | } 47 | 48 | export function set( 49 | key: Key, 50 | value: ServerContext[Key], 51 | ) { 52 | const context = asyncLocalStorage.getStore(); 53 | if (!context) throw new Error("No context"); 54 | context.set(key, value); 55 | } 56 | 57 | export function setClient( 58 | key: Key, 59 | value: ServerClientContext[Key], 60 | ) { 61 | const context = asyncLocalStorage.getStore(); 62 | if (!context) throw new Error("No context"); 63 | context.setClient(key, value); 64 | } 65 | 66 | export function getActionResult( 67 | action: T, 68 | // biome-ignore lint/suspicious/noExplicitAny: needed for type inference 69 | ): T extends (...args: any) => infer R 70 | ? { ran: boolean; result: Awaited } 71 | : { ran: boolean; result: unknown } { 72 | const context = asyncLocalStorage.getStore(); 73 | if (!context) throw new Error("No context"); 74 | const actionRef = action as { $$id?: string }; 75 | const id = actionRef.$$id; 76 | if (!id) throw new Error("Invalid action reference"); 77 | if (context.action && context.action.actionId === id) { 78 | // biome-ignore lint/suspicious/noExplicitAny: needed for type inference 79 | return { ran: true, result: context.action.returnValue } as any; 80 | } 81 | // biome-ignore lint/suspicious/noExplicitAny: needed for type inference 82 | return { ran: false, result: undefined } as any; 83 | } 84 | 85 | export function getSetHeaders(): Headers { 86 | const context = asyncLocalStorage.getStore(); 87 | if (!context) throw new Error("No context"); 88 | return context.setHeaders; 89 | } 90 | 91 | export function actionRedirects(to: string): never { 92 | const context = asyncLocalStorage.getStore(); 93 | if (!context) throw new Error("No context"); 94 | 95 | context.redirect = to; 96 | throw REDIRECT_SYMBOL; 97 | } 98 | 99 | export function getURL() { 100 | const context = asyncLocalStorage.getStore(); 101 | if (!context) throw new Error("No context"); 102 | return new URL(context.request.url); 103 | } 104 | -------------------------------------------------------------------------------- /example/tailwind.config.js: -------------------------------------------------------------------------------- 1 | function lightDarkVar(baseName) { 2 | return `var(--theme-light, hsl(var(--${baseName}))) var(--theme-dark, hsl(var(--${baseName}-dark)))`; 3 | } 4 | 5 | /** @type {import('tailwindcss').Config} */ 6 | module.exports = { 7 | darkMode: "media", 8 | content: [ 9 | "./pages/**/*.{ts,tsx}", 10 | "./components/**/*.{ts,tsx}", 11 | "./app/**/*.{ts,tsx}", 12 | "./src/**/*.{ts,tsx}", 13 | ], 14 | prefix: "", 15 | theme: { 16 | container: { 17 | center: true, 18 | padding: "2rem", 19 | screens: { 20 | "2xl": "1400px", 21 | }, 22 | }, 23 | extend: { 24 | colors: { 25 | border: lightDarkVar("border"), 26 | input: lightDarkVar("input"), 27 | ring: lightDarkVar("ring"), 28 | background: lightDarkVar("background"), 29 | foreground: lightDarkVar("foreground"), 30 | primary: { 31 | DEFAULT: lightDarkVar("primary"), 32 | foreground: lightDarkVar("primary-foreground"), 33 | }, 34 | secondary: { 35 | DEFAULT: lightDarkVar("secondary"), 36 | foreground: lightDarkVar("secondary-foreground"), 37 | }, 38 | destructive: { 39 | DEFAULT: lightDarkVar("destructive"), 40 | foreground: lightDarkVar("destructive-foreground"), 41 | }, 42 | muted: { 43 | DEFAULT: lightDarkVar("muted"), 44 | foreground: lightDarkVar("muted-foreground"), 45 | }, 46 | accent: { 47 | DEFAULT: lightDarkVar("accent"), 48 | foreground: lightDarkVar("accent-foreground"), 49 | }, 50 | popover: { 51 | DEFAULT: lightDarkVar("popover"), 52 | foreground: lightDarkVar("popover-foreground"), 53 | }, 54 | card: { 55 | DEFAULT: lightDarkVar("card"), 56 | foreground: lightDarkVar("card-foreground"), 57 | }, 58 | }, 59 | borderRadius: { 60 | lg: "var(--radius)", 61 | md: "calc(var(--radius) - 2px)", 62 | sm: "calc(var(--radius) - 4px)", 63 | }, 64 | keyframes: { 65 | "accordion-down": { 66 | from: { height: "0" }, 67 | to: { height: "var(--radix-accordion-content-height)" }, 68 | }, 69 | "accordion-up": { 70 | from: { height: "var(--radix-accordion-content-height)" }, 71 | to: { height: "0" }, 72 | }, 73 | progress: { 74 | "0%": { transform: " translateX(0) scaleX(0)" }, 75 | "40%": { transform: "translateX(0) scaleX(0.4)" }, 76 | "100%": { transform: "translateX(100%) scaleX(0.5)" }, 77 | }, 78 | }, 79 | animation: { 80 | "accordion-down": "accordion-down 0.2s ease-out", 81 | "accordion-up": "accordion-up 0.2s ease-out", 82 | progress: "progress 1s infinite linear", 83 | }, 84 | transformOrigin: { 85 | "left-right": "0% 50%", 86 | }, 87 | typography: () => ({ 88 | DEFAULT: { 89 | css: { 90 | color: lightDarkVar("foreground"), 91 | '[class~="lead"]': { 92 | color: lightDarkVar("foreground"), 93 | }, 94 | a: { 95 | color: lightDarkVar("primary"), 96 | }, 97 | strong: { 98 | color: lightDarkVar("foreground"), 99 | }, 100 | "a strong": { 101 | color: lightDarkVar("primary"), 102 | }, 103 | "blockquote strong": { 104 | color: lightDarkVar("foreground"), 105 | }, 106 | "thead th strong": { 107 | color: lightDarkVar("foreground"), 108 | }, 109 | "ol > li::marker": { 110 | color: lightDarkVar("foreground"), 111 | }, 112 | "ul > li::marker": { 113 | color: lightDarkVar("foreground"), 114 | }, 115 | dt: { 116 | color: lightDarkVar("foreground"), 117 | }, 118 | blockquote: { 119 | color: lightDarkVar("foreground"), 120 | }, 121 | h1: { 122 | color: lightDarkVar("foreground"), 123 | }, 124 | "h1 strong": { 125 | color: lightDarkVar("foreground"), 126 | }, 127 | h2: { 128 | color: lightDarkVar("foreground"), 129 | }, 130 | "h2 strong": { 131 | color: lightDarkVar("foreground"), 132 | }, 133 | h3: { 134 | color: lightDarkVar("foreground"), 135 | }, 136 | "h3 strong": { 137 | color: lightDarkVar("foreground"), 138 | }, 139 | h4: { 140 | color: lightDarkVar("foreground"), 141 | }, 142 | "h4 strong": { 143 | color: lightDarkVar("foreground"), 144 | }, 145 | kbd: { 146 | color: lightDarkVar("foreground"), 147 | }, 148 | code: { 149 | color: lightDarkVar("foreground"), 150 | }, 151 | "a code": { 152 | color: lightDarkVar("primary"), 153 | }, 154 | "h1 code": { 155 | color: lightDarkVar("foreground"), 156 | }, 157 | "h2 code": { 158 | color: lightDarkVar("foreground"), 159 | }, 160 | "h3 code": { 161 | color: lightDarkVar("foreground"), 162 | }, 163 | "h4 code": { 164 | color: lightDarkVar("foreground"), 165 | }, 166 | "blockquote code": { 167 | color: lightDarkVar("foreground"), 168 | }, 169 | "thead th code": { 170 | color: lightDarkVar("foreground"), 171 | }, 172 | pre: { 173 | color: lightDarkVar("muted-foreground"), 174 | background: lightDarkVar("muted-background"), 175 | }, 176 | "pre code": { 177 | color: lightDarkVar("foreground"), 178 | }, 179 | "thead th": { 180 | color: lightDarkVar("foreground"), 181 | }, 182 | figcaption: { 183 | color: lightDarkVar("foreground"), 184 | }, 185 | }, 186 | }, 187 | }), 188 | }, 189 | }, 190 | plugins: [require("@tailwindcss/typography"), require("tailwindcss-animate")], 191 | }; 192 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | import { createMiddleware } from "@hattip/adapter-node"; 2 | import express from "express"; 3 | 4 | import browserViteManifest from "./dist/browser/.vite/manifest.json" with { 5 | type: "json", 6 | }; 7 | import clientReferences from "./dist/prerender/_client-references.js"; 8 | import prerenderHandler from "./dist/prerender/index.js"; 9 | import serverReferences from "./dist/server/_server-references.js"; 10 | import serverHandler from "./dist/server/index.js"; 11 | 12 | const serverModulePromiseCache = new Map(); 13 | global.__diy_server_manifest__ = { 14 | resolveClientReferenceMetadata(clientReference) { 15 | const id = clientReference.$$id; 16 | const idx = id.lastIndexOf("#"); 17 | const exportName = id.slice(idx + 1); 18 | const fullURL = id.slice(0, idx); 19 | return [fullURL, exportName]; 20 | }, 21 | resolveServerReference(_id) { 22 | const idx = _id.lastIndexOf("#"); 23 | const exportName = _id.slice(idx + 1); 24 | const id = _id.slice(0, idx); 25 | return { 26 | preloadModule() { 27 | if (serverModulePromiseCache.has(id)) { 28 | return serverModulePromiseCache.get(id); 29 | } 30 | const promise = /** 31 | @type {Promise & { 32 | status: "pending" | "fulfilled" | "rejected"; 33 | value?: unknown; 34 | reason?: unknown; 35 | }} 36 | */ ( 37 | serverReferences[/** @type {keyof typeof serverReferences} */ (id)]() 38 | .then((mod) => { 39 | promise.status = "fulfilled"; 40 | promise.value = mod; 41 | }) 42 | .catch((res) => { 43 | promise.status = "rejected"; 44 | promise.reason = res; 45 | throw res; 46 | }) 47 | ); 48 | promise.status = "pending"; 49 | serverModulePromiseCache.set(id, promise); 50 | return promise; 51 | }, 52 | requireModule() { 53 | const cached = serverModulePromiseCache.get(id); 54 | if (!cached) throw new Error(`Module ${id} not found`); 55 | if (cached.reason) throw cached.reason; 56 | return cached.value[exportName]; 57 | }, 58 | }; 59 | }, 60 | }; 61 | 62 | const clientModulePromiseCache = new Map(); 63 | global.__diy_client_manifest__ = { 64 | resolveClientReference([id, exportName]) { 65 | return { 66 | preloadModule() { 67 | if (clientModulePromiseCache.has(id)) { 68 | return clientModulePromiseCache.get(id); 69 | } 70 | const promise = /** 71 | @type {Promise & { 72 | status: "pending" | "fulfilled" | "rejected"; 73 | value?: unknown; 74 | reason?: unknown; 75 | }} 76 | */ ( 77 | clientReferences[/** @type {keyof typeof clientReferences} */ (id)]() 78 | .then((mod) => { 79 | promise.status = "fulfilled"; 80 | promise.value = mod; 81 | }) 82 | .catch((res) => { 83 | promise.status = "rejected"; 84 | promise.reason = res; 85 | throw res; 86 | }) 87 | ); 88 | promise.status = "pending"; 89 | clientModulePromiseCache.set(id, promise); 90 | return promise; 91 | }, 92 | requireModule() { 93 | const cached = clientModulePromiseCache.get(id); 94 | if (!cached) throw new Error(`Module ${id} not found`); 95 | if (cached.reason) throw cached.reason; 96 | return cached.value[exportName]; 97 | }, 98 | }; 99 | }, 100 | }; 101 | 102 | start(); 103 | 104 | async function start() { 105 | const app = express(); 106 | 107 | app.use(express.static("dist/browser")); 108 | 109 | app.use( 110 | createMiddleware((c) => { 111 | return prerenderHandler(c, { 112 | callServer: 113 | /** 114 | * @param {Request} request 115 | */ 116 | (request) => serverHandler({ ...c, request }), 117 | bootstrapModules: [ 118 | `/${browserViteManifest["src/entry.browser.tsx"].file}`, 119 | ], 120 | bootstrapScripts: [], 121 | bootstrapScriptContent: ` 122 | window.__diy_client_manifest__ = { 123 | _cache: new Map(), 124 | resolveClientReference([id, exportName]) { 125 | return { 126 | preloadModule() { 127 | if (window.__diy_client_manifest__._cache.has(id)) { 128 | return window.__diy_client_manifest__._cache.get(id); 129 | } 130 | const promise = import("/"+${JSON.stringify( 131 | browserViteManifest["virtual:client-references"].file, 132 | )}) 133 | .then(({default:mods}) => mods[id]()) 134 | .then((mod) => { 135 | promise.status = "fulfilled"; 136 | promise.value = mod; 137 | }) 138 | .catch((res) => { 139 | promise.status = "rejected"; 140 | promise.reason = res; 141 | throw res; 142 | }); 143 | promise.status = "pending"; 144 | window.__diy_client_manifest__._cache.set(id, promise); 145 | return promise; 146 | }, 147 | requireModule() { 148 | const cached = window.__diy_client_manifest__._cache.get(id); 149 | if (!cached) throw new Error(\`Module \${id} not found\`); 150 | if (cached.reason) throw cached.reason; 151 | return cached.value[exportName]; 152 | }, 153 | }; 154 | }, 155 | }; 156 | `, 157 | cssFiles: browserViteManifest["src/entry.browser.tsx"].css.map( 158 | (f) => `/${f}`, 159 | ), 160 | }); 161 | }), 162 | ); 163 | 164 | const port = process.env.PORT || 3000; 165 | app.listen(port, () => { 166 | console.log(`Server started on http://localhost:${port}`); 167 | }); 168 | } 169 | -------------------------------------------------------------------------------- /example/src/routes/signup/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useNavigation } from "framework/client"; 4 | 5 | import { Routes } from "@/app"; 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | Card, 9 | CardContent, 10 | CardDescription, 11 | CardHeader, 12 | CardTitle, 13 | } from "@/components/ui/card"; 14 | import { Input } from "@/components/ui/input"; 15 | import { Label } from "@/components/ui/label"; 16 | import { useForm } from "@/forms/client"; 17 | import type { signup } from "@/user/actions"; 18 | import { signupFormSchema } from "@/user/schema"; 19 | 20 | type SignupFormProps = { 21 | action: typeof signup; 22 | initialState?: Awaited>; 23 | }; 24 | 25 | export function SignupForm({ action, initialState }: SignupFormProps) { 26 | const { pending } = useNavigation(); 27 | 28 | const [form, fields] = useForm({ 29 | id: "signup-form", 30 | lastResult: initialState, 31 | schema: signupFormSchema, 32 | shouldValidate: "onBlur", 33 | }); 34 | 35 | const { 36 | username: email, 37 | password, 38 | verifyPassword, 39 | displayName, 40 | fullName, 41 | } = fields; 42 | 43 | return ( 44 |
{ 50 | if (pending) { 51 | event.preventDefault(); 52 | return; 53 | } 54 | form.onSubmit(event); 55 | }} 56 | > 57 | 58 | 59 | Sign up 60 | 61 | Enter your info below to sign up for an account. 62 | 63 | 64 | 65 |
66 | 67 | 77 | {email.errors && ( 78 | 81 | )} 82 |
83 |
84 | 85 | 95 | {password.errors && ( 96 | 99 | )} 100 |
101 |
102 | 103 | 113 | {verifyPassword.errors && ( 114 | 120 | )} 121 |
122 |
123 | 124 | 135 | {displayName.errors && ( 136 | 142 | )} 143 |
144 |
145 | 146 | 157 | {fullName.errors && ( 158 | 161 | )} 162 |
163 | 164 |
165 | Already have an account?{" "} 166 | 167 | Log in 168 | 169 |
170 |
171 |
172 |
173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /example/src/user/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import type { SubmissionResult } from "@conform-to/dom"; 4 | import { parseWithZod } from "@conform-to/zod"; 5 | import { compare, hash } from "bcrypt"; 6 | import { serialize } from "cookie"; 7 | import { sign } from "cookie-signature"; 8 | 9 | import * as framework from "framework"; 10 | 11 | import { password, user } from "@/db/schema"; 12 | import { getDB } from "@/db/server"; 13 | import { Secrets } from "@/secrets/server"; 14 | import { desc, eq } from "drizzle-orm"; 15 | import { loginFormSchema, signupFormSchema } from "./schema"; 16 | import { USER_ID_KEY } from "./shared"; 17 | 18 | export function logout(formData: FormData) { 19 | const url = framework.getURL(); 20 | const headers = framework.getSetHeaders(); 21 | const redirectToInput = String(formData.get("redirectTo")); 22 | const redirectTo = 23 | redirectToInput.startsWith("/") && !redirectToInput.startsWith("//") 24 | ? redirectToInput 25 | : "/"; 26 | 27 | framework.set(USER_ID_KEY, undefined); 28 | headers.append( 29 | "Set-Cookie", 30 | serialize("userId", "", { 31 | httpOnly: true, 32 | path: "/", 33 | sameSite: "lax", 34 | secure: url.protocol === "https:", 35 | maxAge: 0, 36 | }), 37 | ); 38 | 39 | return framework.actionRedirects(redirectTo); 40 | } 41 | 42 | const GENERIC_ERROR = "Invalid email or password"; 43 | 44 | export async function login( 45 | formData: FormData, 46 | ): Promise { 47 | const url = framework.getURL(); 48 | const headers = framework.getSetHeaders(); 49 | const secret = framework.get(Secrets.COOKIE_SECRET, true); 50 | const db = getDB(); 51 | 52 | const parsed = await parseWithZod(formData, { 53 | schema: loginFormSchema, 54 | }); 55 | 56 | switch (parsed.status) { 57 | case "error": { 58 | return parsed.reply({ hideFields: ["password"] }); 59 | } 60 | case "success": { 61 | // username is actually email, but for accessibility reasons we're 62 | // using username as the field name in the form. 63 | const { username: email, password: inputPassword } = parsed.value; 64 | 65 | const dbUser = await db.query.user.findFirst({ 66 | where: eq(user.email, email), 67 | columns: { 68 | id: true, 69 | }, 70 | }); 71 | const dbPassword = 72 | dbUser && 73 | (await db.query.password.findFirst({ 74 | where: eq(password.userId, dbUser.id), 75 | orderBy: desc(password.createdAt), 76 | columns: { 77 | password: true, 78 | }, 79 | })); 80 | const passwordMatch = 81 | !!dbPassword && (await compare(inputPassword, dbPassword.password)); 82 | 83 | if (!passwordMatch) { 84 | return parsed.reply({ 85 | fieldErrors: { password: [GENERIC_ERROR] }, 86 | hideFields: ["password"], 87 | resetForm: false, 88 | }); 89 | } 90 | 91 | const userId = dbUser.id; 92 | 93 | framework.set(USER_ID_KEY, userId); 94 | const cookie = serialize("userId", userId, { 95 | httpOnly: true, 96 | path: "/", 97 | sameSite: "lax", 98 | secure: url.protocol === "https:", 99 | encode(value) { 100 | return sign(value, secret); 101 | }, 102 | }); 103 | headers.append("Set-Cookie", cookie); 104 | 105 | return framework.actionRedirects("/chat"); 106 | } 107 | } 108 | } 109 | 110 | export async function signup( 111 | formData: FormData, 112 | ): Promise { 113 | const url = framework.getURL(); 114 | const headers = framework.getSetHeaders(); 115 | const secret = framework.get(Secrets.COOKIE_SECRET, true); 116 | const db = getDB(); 117 | 118 | const parsed = await parseWithZod(formData, { 119 | schema: signupFormSchema, 120 | }); 121 | 122 | switch (parsed.status) { 123 | case "error": { 124 | return parsed.reply({ hideFields: ["password", "verifyPassword"] }); 125 | } 126 | case "success": { 127 | // username is actually email, but for accessibility reasons we're 128 | // using username as the field name in the form. 129 | const { 130 | displayName, 131 | username: email, 132 | fullName, 133 | password: inputPassword, 134 | } = parsed.value; 135 | 136 | let dbUser = await db.query.user.findFirst({ 137 | where: eq(user.email, email), 138 | columns: { 139 | id: true, 140 | }, 141 | }); 142 | 143 | if (dbUser) { 144 | return parsed.reply({ 145 | fieldErrors: { password: [GENERIC_ERROR] }, 146 | hideFields: ["password", "verifyPassword"], 147 | resetForm: false, 148 | }); 149 | } 150 | 151 | const hashedPassword = await hash(inputPassword, 11); 152 | 153 | dbUser = await db.transaction(async (tx) => { 154 | const insertedUsers = await tx 155 | .insert(user) 156 | .values({ 157 | displayName, 158 | email, 159 | fullName, 160 | }) 161 | .returning({ id: user.id }); 162 | const createdUser = insertedUsers[0]; 163 | 164 | if (!createdUser) { 165 | tx.rollback(); 166 | return; 167 | } 168 | 169 | const insertedPasswords = await tx 170 | .insert(password) 171 | .values({ 172 | userId: createdUser.id, 173 | password: hashedPassword, 174 | }) 175 | .returning({ id: password.id }); 176 | const createdPassword = insertedPasswords[0]; 177 | 178 | if (!createdPassword) { 179 | tx.rollback(); 180 | return; 181 | } 182 | 183 | return createdUser; 184 | }); 185 | 186 | if (!dbUser) { 187 | return parsed.reply({ 188 | fieldErrors: { password: [GENERIC_ERROR] }, 189 | hideFields: ["password", "verifyPassword"], 190 | resetForm: false, 191 | }); 192 | } 193 | 194 | const userId = dbUser.id; 195 | 196 | framework.set(USER_ID_KEY, userId); 197 | const cookie = serialize("userId", userId, { 198 | httpOnly: true, 199 | path: "/", 200 | sameSite: "lax", 201 | secure: url.protocol === "https:", 202 | encode(value) { 203 | return sign(value, secret); 204 | }, 205 | }); 206 | headers.append("Set-Cookie", cookie); 207 | 208 | return framework.actionRedirects("/chat"); 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /example/src/routes/chat.detail/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { parseWithZod } from "@conform-to/zod"; 4 | import { createStreamableUI } from "ai/rsc"; 5 | import { and, eq } from "drizzle-orm"; 6 | import { Ollama } from "ollama"; 7 | 8 | import * as framework from "framework"; 9 | 10 | import { ClientRedirect } from "@/components/client-redirect"; 11 | import { chat, chatMessage } from "@/db/schema"; 12 | import { getDB } from "@/db/server"; 13 | import { Secrets } from "@/secrets/server"; 14 | import { actionRequiresUserId } from "@/user/server"; 15 | 16 | import { FocusSendMessageForm } from "./client"; 17 | import { sendMessageSchema } from "./schema"; 18 | import { 19 | AIMessage, 20 | ErrorMessage, 21 | PendingAIMessage, 22 | UserMessage, 23 | } from "./shared"; 24 | 25 | export async function sendMessage(formData: FormData, stream = false) { 26 | const ollamaHost = framework.get(Secrets.OLLAMA_HOST); 27 | const db = getDB(); 28 | 29 | const [userId, parsed] = await Promise.all([ 30 | actionRequiresUserId(), 31 | parseWithZod(formData, { 32 | schema: sendMessageSchema, 33 | }), 34 | ]); 35 | 36 | switch (parsed.status) { 37 | case "error": { 38 | return { lastResult: parsed.reply(), stream }; 39 | } 40 | case "success": { 41 | const { chatId, message } = parsed.value; 42 | 43 | const existingDBChat = chatId 44 | ? await db.query.chat.findFirst({ 45 | where: and(eq(chat.id, chatId), eq(chat.userId, userId)), 46 | columns: { id: true, name: true }, 47 | with: { 48 | messages: { 49 | orderBy: ({ id }, { asc }) => asc(id), 50 | columns: { 51 | id: true, 52 | message: true, 53 | userId: true, 54 | }, 55 | }, 56 | }, 57 | }) 58 | : undefined; 59 | 60 | if (chatId && !existingDBChat) { 61 | return { 62 | lastResult: parsed.reply({ 63 | fieldErrors: { 64 | message: ["Chat not found."], 65 | }, 66 | resetForm: false, 67 | }), 68 | }; 69 | } 70 | 71 | const existingMessages = existingDBChat?.messages ?? []; 72 | 73 | const aiMessage = createStreamableUI(); 74 | const userMessage = createStreamableUI( 75 | {message}, 76 | ); 77 | 78 | const ollama = new Ollama({ host: ollamaHost }); 79 | 80 | const chatNamePromise = (async () => { 81 | if (existingDBChat) return existingDBChat.name; 82 | 83 | const response = await ollama.chat({ 84 | model: "llama3", 85 | stream: false, 86 | messages: [ 87 | { 88 | role: "system", 89 | content: "You are a short title generator.", 90 | }, 91 | { 92 | role: "user", 93 | content: `It should be under 30 characters. Respond with ONLY the title. Determine a short title for a chat thread with the first message of:\n\`\`\`\n${message}\n\`\`\`.`, 94 | }, 95 | ], 96 | }); 97 | return response.message.content; 98 | })(); 99 | 100 | const redirectToPromise = (async () => { 101 | try { 102 | const response = await ollama.chat({ 103 | model: "llama3", 104 | stream: true, 105 | messages: [ 106 | ...existingMessages.map((message) => ({ 107 | role: message.userId ? "user" : "assistant", 108 | content: message.message, 109 | })), 110 | { 111 | role: "user", 112 | content: message, 113 | }, 114 | ], 115 | }); 116 | 117 | let aiResponse = ""; 118 | let lastSentLength = 0; 119 | for await (const chunk of response) { 120 | if ( 121 | typeof chunk.message.content === "string" && 122 | chunk.message.content 123 | ) { 124 | aiResponse += chunk.message.content; 125 | const trimmed = aiResponse.trim(); 126 | // send in chunks of 10 characters 127 | if (trimmed && trimmed.length - lastSentLength >= 10) { 128 | lastSentLength = trimmed.length; 129 | aiMessage.update({trimmed}...); 130 | } 131 | } 132 | } 133 | 134 | const dbChat = await db.transaction(async (tx) => { 135 | let dbChat: undefined | { id: string } = existingDBChat; 136 | if (!existingDBChat) { 137 | dbChat = ( 138 | await db 139 | .insert(chat) 140 | .values({ 141 | name: await chatNamePromise, 142 | userId, 143 | }) 144 | .returning({ id: chat.id }) 145 | )[0]; 146 | } 147 | 148 | if (!dbChat) { 149 | tx.rollback(); 150 | return null; 151 | } 152 | 153 | const userMessage = await tx.insert(chatMessage).values({ 154 | chatId: dbChat.id, 155 | message, 156 | order: existingMessages.length + 1, 157 | userId, 158 | }); 159 | if (!userMessage.changes) { 160 | tx.rollback(); 161 | return null; 162 | } 163 | const aiMessage = await tx.insert(chatMessage).values({ 164 | chatId: dbChat.id, 165 | message: aiResponse, 166 | order: existingMessages.length, 167 | }); 168 | if (!aiMessage.changes) { 169 | tx.rollback(); 170 | return null; 171 | } 172 | return dbChat; 173 | }); 174 | 175 | if (!dbChat) { 176 | throw new Error("Failed to save messages."); 177 | } 178 | 179 | const redirectTo = !existingDBChat ? `/chat/${dbChat.id}` : undefined; 180 | 181 | userMessage.done(); 182 | aiMessage.done( 183 | <> 184 | {aiResponse.trim()} 185 | {redirectTo ? ( 186 | 187 | ) : ( 188 | 189 | )} 190 | , 191 | ); 192 | 193 | return redirectTo; 194 | } catch (error) { 195 | console.error(error); 196 | userMessage.done(); 197 | aiMessage.done( 198 | 199 | Failed to send message. Please try again. 200 | , 201 | ); 202 | } 203 | })(); 204 | 205 | if (!stream && !existingDBChat) { 206 | const redirectTo = await redirectToPromise; 207 | if (redirectTo) { 208 | return framework.actionRedirects(redirectTo); 209 | } 210 | } 211 | 212 | return { 213 | lastResult: parsed.reply({ resetForm: true }), 214 | newMessages: [userMessage.value, aiMessage.value], 215 | stream, 216 | }; 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /example/src/routes/chat.detail/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { FieldMetadata } from "@conform-to/react"; 4 | import { parseWithZod } from "@conform-to/zod"; 5 | import * as React from "react"; 6 | import * as ReactDOM from "react-dom"; 7 | 8 | import { useEnhancedActionState } from "framework/client"; 9 | import { FormOptions } from "framework/shared"; 10 | 11 | import { Routes } from "@/app"; 12 | import { Button } from "@/components/ui/button"; 13 | import { Input } from "@/components/ui/input"; 14 | import { useForm } from "@/forms/client"; 15 | 16 | import type { sendMessage } from "./actions"; 17 | import { sendMessageSchema } from "./schema"; 18 | import { PendingAIMessage, UserMessage } from "./shared"; 19 | 20 | type SendMessageFormProps = { 21 | action: typeof sendMessage; 22 | chatId?: string; 23 | children?: React.ReactNode; 24 | initialState?: Awaited>; 25 | }; 26 | 27 | export function SendMessageForm({ 28 | action, 29 | chatId: currentChatId, 30 | children, 31 | initialState, 32 | }: SendMessageFormProps) { 33 | const [clientMessages, setClientMessages] = React.useState( 34 | [], 35 | ); 36 | const [pendingMessage, setPendingMessage] = 37 | React.useOptimistic(null); 38 | const formRef = React.useRef(null); 39 | const messageInputRef = React.useRef(null); 40 | 41 | const [formState, dispatch, isPending] = useEnhancedActionState( 42 | action, 43 | async (formState, formData) => { 44 | const parsed = parseWithZod(formData, { schema: sendMessageSchema }); 45 | if (parsed.status === "success") { 46 | setPendingMessage({parsed.value.message}); 47 | } 48 | 49 | formRef.current?.reset(); 50 | messageInputRef.current?.focus(); 51 | 52 | const result = await action(formData, true); 53 | 54 | if (result?.newMessages) { 55 | if (formState && !formState.stream && formState.newMessages) { 56 | setClientMessages((messages) => [ 57 | ...result.newMessages, 58 | ...formState.newMessages, 59 | ...messages, 60 | ]); 61 | } else { 62 | setClientMessages((messages) => [...result.newMessages, ...messages]); 63 | } 64 | } 65 | 66 | return result; 67 | }, 68 | initialState, 69 | ); 70 | 71 | const allClientMessages = React.useMemo(() => { 72 | if (pendingMessage) { 73 | return [ 74 | pendingMessage, 75 | , 76 | ...clientMessages, 77 | ]; 78 | } 79 | if (formState && !formState.stream && formState.newMessages) { 80 | return [...formState.newMessages, ...clientMessages]; 81 | } 82 | return clientMessages; 83 | }, [formState, pendingMessage, clientMessages]); 84 | 85 | const [form, fields] = useForm({ 86 | schema: sendMessageSchema, 87 | id: "send-message-form", 88 | lastResult: formState?.lastResult, 89 | shouldValidate: "onSubmit", 90 | }); 91 | 92 | const { chatId, message } = fields; 93 | 94 | return ( 95 |
96 |
{ 103 | if (isPending) { 104 | event.preventDefault(); 105 | event.stopPropagation(); 106 | return; 107 | } 108 | form.onSubmit(event); 109 | }} 110 | > 111 | 112 | 113 | 116 | 117 | 123 | 124 | 125 | 126 | 127 | 128 | {allClientMessages.map((message, key) => ( 129 | // biome-ignore lint/suspicious/noArrayIndexKey: 130 | {message} 131 | ))} 132 | {children} 133 |
134 | ); 135 | } 136 | 137 | function SendMessageLabel({ 138 | children, 139 | field, 140 | }: { 141 | children: React.ReactNode; 142 | field: FieldMetadata; 143 | }) { 144 | return ( 145 |
146 | 151 | {field.errors && ( 152 | 159 | )} 160 |
161 | ); 162 | } 163 | 164 | function SendMessageInput({ field }: { field: FieldMetadata }) { 165 | const form = ReactDOM.useFormStatus(); 166 | 167 | return ( 168 | 178 | ); 179 | } 180 | 181 | function SendMessageButton() { 182 | const form = ReactDOM.useFormStatus(); 183 | 184 | if (form.pending) { 185 | return ( 186 |
187 | 188 | Sending message 189 | 190 |
191 | ); 192 | } 193 | 194 | return ( 195 | 216 | ); 217 | } 218 | 219 | export function FocusSendMessageForm() { 220 | React.useLayoutEffect(() => { 221 | const form = document.getElementById("send-message-form"); 222 | if (form) { 223 | setTimeout(() => { 224 | form.querySelector("input[type=text]")?.focus(); 225 | }, 1); 226 | } 227 | }, []); 228 | 229 | return null; 230 | } 231 | -------------------------------------------------------------------------------- /example/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "id": "68d0e87e-08f9-458f-837f-7e6c3efc5e9c", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "chat": { 8 | "name": "chat", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "text", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "created_at": { 25 | "name": "created_at", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "user_id": { 32 | "name": "user_id", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | } 38 | }, 39 | "indexes": {}, 40 | "foreignKeys": { 41 | "chat_user_id_user_id_fk": { 42 | "name": "chat_user_id_user_id_fk", 43 | "tableFrom": "chat", 44 | "tableTo": "user", 45 | "columnsFrom": [ 46 | "user_id" 47 | ], 48 | "columnsTo": [ 49 | "id" 50 | ], 51 | "onDelete": "cascade", 52 | "onUpdate": "no action" 53 | } 54 | }, 55 | "compositePrimaryKeys": {}, 56 | "uniqueConstraints": {} 57 | }, 58 | "chat_message": { 59 | "name": "chat_message", 60 | "columns": { 61 | "id": { 62 | "name": "id", 63 | "type": "integer", 64 | "primaryKey": true, 65 | "notNull": true, 66 | "autoincrement": true 67 | }, 68 | "order": { 69 | "name": "order", 70 | "type": "integer", 71 | "primaryKey": false, 72 | "notNull": true, 73 | "autoincrement": false 74 | }, 75 | "message": { 76 | "name": "message", 77 | "type": "text", 78 | "primaryKey": false, 79 | "notNull": true, 80 | "autoincrement": false 81 | }, 82 | "created_at": { 83 | "name": "created_at", 84 | "type": "text", 85 | "primaryKey": false, 86 | "notNull": true, 87 | "autoincrement": false 88 | }, 89 | "chat_id": { 90 | "name": "chat_id", 91 | "type": "text", 92 | "primaryKey": false, 93 | "notNull": true, 94 | "autoincrement": false 95 | }, 96 | "user_id": { 97 | "name": "user_id", 98 | "type": "text", 99 | "primaryKey": false, 100 | "notNull": false, 101 | "autoincrement": false 102 | } 103 | }, 104 | "indexes": {}, 105 | "foreignKeys": { 106 | "chat_message_chat_id_chat_id_fk": { 107 | "name": "chat_message_chat_id_chat_id_fk", 108 | "tableFrom": "chat_message", 109 | "tableTo": "chat", 110 | "columnsFrom": [ 111 | "chat_id" 112 | ], 113 | "columnsTo": [ 114 | "id" 115 | ], 116 | "onDelete": "cascade", 117 | "onUpdate": "no action" 118 | }, 119 | "chat_message_user_id_user_id_fk": { 120 | "name": "chat_message_user_id_user_id_fk", 121 | "tableFrom": "chat_message", 122 | "tableTo": "user", 123 | "columnsFrom": [ 124 | "user_id" 125 | ], 126 | "columnsTo": [ 127 | "id" 128 | ], 129 | "onDelete": "no action", 130 | "onUpdate": "no action" 131 | } 132 | }, 133 | "compositePrimaryKeys": {}, 134 | "uniqueConstraints": {} 135 | }, 136 | "password": { 137 | "name": "password", 138 | "columns": { 139 | "id": { 140 | "name": "id", 141 | "type": "text", 142 | "primaryKey": true, 143 | "notNull": true, 144 | "autoincrement": false 145 | }, 146 | "user_id": { 147 | "name": "user_id", 148 | "type": "text", 149 | "primaryKey": false, 150 | "notNull": true, 151 | "autoincrement": false 152 | }, 153 | "password": { 154 | "name": "password", 155 | "type": "text", 156 | "primaryKey": false, 157 | "notNull": true, 158 | "autoincrement": false 159 | }, 160 | "created_at": { 161 | "name": "created_at", 162 | "type": "text", 163 | "primaryKey": false, 164 | "notNull": true, 165 | "autoincrement": false 166 | } 167 | }, 168 | "indexes": {}, 169 | "foreignKeys": { 170 | "password_user_id_user_id_fk": { 171 | "name": "password_user_id_user_id_fk", 172 | "tableFrom": "password", 173 | "tableTo": "user", 174 | "columnsFrom": [ 175 | "user_id" 176 | ], 177 | "columnsTo": [ 178 | "id" 179 | ], 180 | "onDelete": "cascade", 181 | "onUpdate": "no action" 182 | } 183 | }, 184 | "compositePrimaryKeys": {}, 185 | "uniqueConstraints": {} 186 | }, 187 | "user": { 188 | "name": "user", 189 | "columns": { 190 | "id": { 191 | "name": "id", 192 | "type": "text", 193 | "primaryKey": true, 194 | "notNull": true, 195 | "autoincrement": false 196 | }, 197 | "email": { 198 | "name": "email", 199 | "type": "text", 200 | "primaryKey": false, 201 | "notNull": true, 202 | "autoincrement": false 203 | }, 204 | "full_name": { 205 | "name": "full_name", 206 | "type": "text", 207 | "primaryKey": false, 208 | "notNull": true, 209 | "autoincrement": false 210 | }, 211 | "display_name": { 212 | "name": "display_name", 213 | "type": "text", 214 | "primaryKey": false, 215 | "notNull": true, 216 | "autoincrement": false 217 | } 218 | }, 219 | "indexes": { 220 | "user_email_unique": { 221 | "name": "user_email_unique", 222 | "columns": [ 223 | "email" 224 | ], 225 | "isUnique": true 226 | } 227 | }, 228 | "foreignKeys": {}, 229 | "compositePrimaryKeys": {}, 230 | "uniqueConstraints": {} 231 | } 232 | }, 233 | "enums": {}, 234 | "_meta": { 235 | "schemas": {}, 236 | "tables": {}, 237 | "columns": {} 238 | } 239 | } -------------------------------------------------------------------------------- /framework/src/router/trie.ts: -------------------------------------------------------------------------------- 1 | export const INDEX_SYMBOL = Symbol("index"), 2 | DYNAMIC_SYMBOL = Symbol("dynamic"), 3 | OPTIONAL_SYMBOL = Symbol("optional"), 4 | CATCH_ALL_SYMBOL = Symbol("catch-all"), 5 | ROUTE_SYMBOL = Symbol("route"), 6 | ROOT_SYMBOL = Symbol("root"); 7 | 8 | // #region MATCHING 9 | 10 | export function matchTrie( 11 | root: Node, 12 | pathname: string, 13 | options: { 14 | onVisit?: (node: Node) => void; 15 | } = {}, 16 | ) { 17 | const matched = matchRecursive( 18 | root, 19 | getSegments(sanitizePath(pathname)), 20 | 0, 21 | [], 22 | options.onVisit, 23 | ); 24 | 25 | return matched.length ? rankMatched(matched) : null; 26 | } 27 | 28 | function matchRecursive( 29 | root: Node | undefined, 30 | segments: string[], 31 | segmentIndex: number, 32 | matches: Omit[][], 33 | onVisit?: (node: Node) => void, 34 | ): Omit[][] { 35 | if (!root) return matches; 36 | 37 | if (onVisit) onVisit(root); 38 | 39 | const segmentsLength = segments.length; 40 | if (segmentIndex >= segmentsLength) { 41 | switch (root.key) { 42 | case INDEX_SYMBOL: 43 | matchRecursive( 44 | root.children[0], 45 | segments, 46 | segmentIndex, 47 | matches, 48 | onVisit, 49 | ); 50 | break; 51 | case DYNAMIC_SYMBOL: 52 | case CATCH_ALL_SYMBOL: 53 | break; 54 | 55 | case ROUTE_SYMBOL: { 56 | if (root.route) { 57 | matches.push(getMatchesFromNode(root)!); 58 | } 59 | } 60 | case ROOT_SYMBOL: 61 | case OPTIONAL_SYMBOL: 62 | for (const child of root.children) { 63 | matchRecursive(child, segments, segmentIndex, matches, onVisit); 64 | } 65 | break; 66 | } 67 | } else { 68 | if (typeof root.key === "string") { 69 | if (root.key === segments[segmentIndex]) { 70 | for (const child of root.children) { 71 | matchRecursive(child, segments, segmentIndex + 1, matches, onVisit); 72 | } 73 | } 74 | } else { 75 | switch (root.key) { 76 | case INDEX_SYMBOL: 77 | break; 78 | case CATCH_ALL_SYMBOL: 79 | matchRecursive( 80 | root.children[0], 81 | segments, 82 | segmentsLength, 83 | matches, 84 | onVisit, 85 | ); 86 | break; 87 | case DYNAMIC_SYMBOL: 88 | case OPTIONAL_SYMBOL: 89 | segmentIndex++; 90 | case ROOT_SYMBOL: 91 | case ROUTE_SYMBOL: 92 | for (const child of root.children) { 93 | matchRecursive(child, segments, segmentIndex, matches, onVisit); 94 | } 95 | break; 96 | } 97 | } 98 | } 99 | 100 | return matches; 101 | } 102 | 103 | function getMatchesFromNode(node: Node) { 104 | if (!node.route) return null; 105 | let matches: Omit[] = [], 106 | currentNode: Node | null = node; 107 | while (currentNode) { 108 | if (currentNode.route) { 109 | matches.push(currentNode.route); 110 | } 111 | currentNode = currentNode.parent; 112 | } 113 | 114 | return matches.reverse(); 115 | } 116 | 117 | function rankMatched( 118 | matched: Omit[][], 119 | ) { 120 | let bestScore = Number.MIN_SAFE_INTEGER, 121 | bestMatch; 122 | 123 | for (const matches of matched) { 124 | let score = 0; 125 | for (const match of matches) { 126 | score += computeScore(match); 127 | } 128 | 129 | if (score > bestScore) { 130 | bestScore = score; 131 | bestMatch = matches; 132 | } 133 | } 134 | 135 | return bestMatch; 136 | } 137 | 138 | const staticSegmentValue = 10, 139 | dynamicSegmentValue = 4, 140 | optionalSegmentValue = 3, 141 | indexRouteValue = 2, 142 | emptySegmentValue = 1, 143 | splatPenalty = -1, 144 | isSplat = (s: string) => s === "*"; 145 | function computeScore(match: Omit): number { 146 | let segments = getSegments(match.path || ""), 147 | initialScore = segments.length * segments.length; 148 | if (segments.some(isSplat)) { 149 | initialScore += splatPenalty; 150 | } 151 | 152 | if (match.index) { 153 | initialScore += indexRouteValue; 154 | } 155 | 156 | return segments 157 | .filter((s) => !isSplat(s)) 158 | .reduce( 159 | (score, segment, i) => 160 | score + 161 | (segment.startsWith(":") 162 | ? segment.endsWith("?") 163 | ? optionalSegmentValue * (i + 1) 164 | : dynamicSegmentValue * (i + 1) 165 | : segment === "" 166 | ? emptySegmentValue * (i + 1) 167 | : staticSegmentValue * (i + 1)), 168 | initialScore, 169 | ); 170 | } 171 | 172 | // #endregion MATCHING 173 | 174 | // #region CREATION 175 | export function createTrie(routes: Route[]) { 176 | const root: Node = { 177 | key: ROOT_SYMBOL, 178 | parent: null, 179 | children: [], 180 | route: null, 181 | }; 182 | 183 | for (const route of routes) { 184 | insertRouteConfig(root, route); 185 | } 186 | 187 | return root; 188 | } 189 | 190 | function insertRouteConfig( 191 | root: Node, 192 | route: Route, 193 | ) { 194 | const path = sanitizePath(route.path), 195 | node = insertPath(root, path, route); 196 | 197 | if (!route.index && route.children) { 198 | for (const childRoute of route.children) { 199 | insertRouteConfig(node, childRoute as Route); 200 | } 201 | } 202 | 203 | return node; 204 | } 205 | 206 | function sanitizePath(path?: string) { 207 | return path ? path.replace(/^\//, "").replace(/\/$/, "") : ""; 208 | } 209 | 210 | function getSegments(path: string) { 211 | return path.split("/").filter(Boolean); 212 | } 213 | 214 | function insertPath( 215 | root: Node, 216 | path: string, 217 | route: Route, 218 | ) { 219 | let segments = getSegments(path), 220 | segmentsLength = segments.length, 221 | currentNode = root; 222 | 223 | for (let i = 0; i < segmentsLength; i++) { 224 | const segment = segments[i]; 225 | if (!segment) continue; 226 | 227 | if (segment.startsWith("*")) { 228 | const existingNode = currentNode.children.find( 229 | (child) => child.key === CATCH_ALL_SYMBOL, 230 | ); 231 | if (existingNode) { 232 | throw new Error( 233 | "Only one catch all route is allowed per branch of the tree", 234 | ); 235 | } 236 | const catchAllNode = createNode(CATCH_ALL_SYMBOL, currentNode); 237 | currentNode.children.push(catchAllNode); 238 | currentNode = catchAllNode; 239 | break; 240 | } 241 | if (segment.startsWith(":")) { 242 | if (segment.endsWith("?")) { 243 | const existingNode = currentNode.children.find( 244 | (child) => child.key === OPTIONAL_SYMBOL, 245 | ); 246 | if (existingNode) { 247 | currentNode = existingNode; 248 | } else { 249 | const optionalNode = createNode(OPTIONAL_SYMBOL, currentNode); 250 | currentNode.children.push(optionalNode); 251 | currentNode = optionalNode; 252 | } 253 | } else { 254 | const existingNode = currentNode.children.find( 255 | (child) => child.key === DYNAMIC_SYMBOL, 256 | ); 257 | if (existingNode) { 258 | currentNode = existingNode; 259 | } else { 260 | const dynamicNode = createNode(DYNAMIC_SYMBOL, currentNode); 261 | currentNode.children.push(dynamicNode); 262 | currentNode = dynamicNode; 263 | } 264 | } 265 | continue; 266 | } 267 | 268 | const existingNode = currentNode.children.find( 269 | (child) => child.key === segment, 270 | ); 271 | if (existingNode) { 272 | currentNode = existingNode; 273 | } else { 274 | const segmentNode = createNode(segment, currentNode); 275 | currentNode.children.push(segmentNode); 276 | currentNode = segmentNode; 277 | } 278 | } 279 | 280 | if (route.index) { 281 | const indexNode = createNode(INDEX_SYMBOL, currentNode); 282 | currentNode.children.push(indexNode); 283 | currentNode = indexNode; 284 | } 285 | 286 | const routeNode = createNode(ROUTE_SYMBOL, currentNode, route); 287 | currentNode.children.push(routeNode); 288 | currentNode = routeNode; 289 | 290 | return currentNode; 291 | } 292 | 293 | function createNode( 294 | key: string | symbol, 295 | parent: Node, 296 | route: Route | null = null, 297 | ) { 298 | if (route) { 299 | const { children: _, ...rest } = route as NonIndexRouteConfig; 300 | route = rest as any; 301 | } 302 | const node: Node = { 303 | key, 304 | route, 305 | parent, 306 | children: [], 307 | }; 308 | return node; 309 | } 310 | 311 | // #endregion CREATION 312 | 313 | // #region TYPES 314 | 315 | export interface Node { 316 | key: string | symbol; 317 | parent: Node | null; 318 | children: Node[]; 319 | route: Omit | null; 320 | } 321 | 322 | interface BaseRouteConfig { 323 | id: string; 324 | path?: string; 325 | } 326 | 327 | export interface IndexRouteConfig extends BaseRouteConfig { 328 | index: true; 329 | } 330 | 331 | export interface NonIndexRouteConfig extends BaseRouteConfig { 332 | index?: false; 333 | children?: RouteConfig[]; 334 | } 335 | 336 | export type RouteConfig = IndexRouteConfig | NonIndexRouteConfig; 337 | 338 | // #endregion TYPES 339 | -------------------------------------------------------------------------------- /framework/src/router/browser.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | // @ts-expect-error - no types 3 | import ReactServerDOM from "react-server-dom-diy/client"; 4 | 5 | import { setGlobal } from "../shared.js"; 6 | import { RenderRoute, RouteProvider } from "./client.js"; 7 | import type { ServerPayload } from "./server.js"; 8 | 9 | type StartNavigation = ( 10 | location: string, 11 | controller: AbortController, 12 | callback: ( 13 | completeNavigation: (payload: ServerPayload) => void, 14 | aborted: () => boolean, 15 | ) => Promise, 16 | ) => Promise; 17 | 18 | declare global { 19 | var __startNavigation: StartNavigation; 20 | var __callServer: typeof callServer; 21 | } 22 | 23 | export type Navigation = 24 | | { 25 | pending: true; 26 | href: string; 27 | } 28 | | { pending: false }; 29 | 30 | export const NavigationContext = React.createContext({ 31 | pending: false, 32 | }); 33 | 34 | let startNavigation: StartNavigation; 35 | 36 | export async function getInitialPayload() { 37 | const { rscStream } = await import("rsc-html-stream/client"); 38 | return ReactServerDOM.createFromReadableStream(rscStream, { 39 | ...__diy_client_manifest__, 40 | callServer, 41 | }) as Promise; 42 | } 43 | 44 | async function callServer(id: string, args: unknown[]) { 45 | let revalidateHeader: string | null = null; 46 | let preventScrollReset = false; 47 | if (typeof args[0] === "object" && args[0] instanceof FormData) { 48 | preventScrollReset = args[0].get("RSC-PreventScrollReset") === "yes"; 49 | args[0].delete("RSC-PreventScrollReset"); 50 | 51 | const revalidate = args[0].get("RSC-Revalidate"); 52 | if (revalidate && typeof revalidate === "string") { 53 | if (revalidate !== "no") { 54 | JSON.parse(revalidate); 55 | } 56 | revalidateHeader = revalidate; 57 | } 58 | } 59 | 60 | const href = window.location.href; 61 | const headers = new Headers({ 62 | Accept: "text/x-component", 63 | "rsc-action": id, 64 | }); 65 | if (revalidateHeader) { 66 | headers.set("RSC-Revalidate", revalidateHeader); 67 | } 68 | const responsePromise = fetch(window.location.href, { 69 | method: "POST", 70 | headers, 71 | body: await ReactServerDOM.encodeReply(args), 72 | }); 73 | 74 | const payloadPromise = Promise.resolve( 75 | ReactServerDOM.createFromFetch(responsePromise, { 76 | ...__diy_client_manifest__, 77 | callServer, 78 | }), 79 | ); 80 | 81 | const controller = new AbortController(); 82 | __startNavigation(href, controller, async (completeNavigation, aborted) => { 83 | let payload = await payloadPromise; 84 | if (payload.redirect) { 85 | payload = await navigate( 86 | payload.redirect, 87 | controller.signal, 88 | revalidateHeader, 89 | preventScrollReset, 90 | ); 91 | } 92 | if (window.location.href !== payload.url.href && !aborted()) { 93 | window.history.pushState(null, "", payload.url.href); 94 | if (!aborted() && !preventScrollReset) { 95 | scrollToTop(); 96 | } 97 | } 98 | completeNavigation(payload); 99 | }); 100 | 101 | const payload = await payloadPromise; 102 | return payload.returnValue; 103 | } 104 | 105 | if (typeof document !== "undefined") { 106 | setGlobal("__callServer", callServer); 107 | } 108 | 109 | export async function navigate( 110 | to: string, 111 | signal: AbortSignal, 112 | revalidate?: string | null, 113 | preventScrollReset = false, 114 | ): Promise { 115 | const url = new URL(to, window.location.href); 116 | const responsePromise = fetch(url, { 117 | headers: { 118 | Accept: "text/x-component", 119 | "RSC-Refresh": "1", 120 | "RSC-Revalidate": revalidate ?? "true", 121 | }, 122 | signal, 123 | }); 124 | 125 | const payload = (await ReactServerDOM.createFromFetch(responsePromise, { 126 | ...__diy_client_manifest__, 127 | callServer, 128 | })) as ServerPayload; 129 | 130 | if (payload.redirect) { 131 | return navigate(payload.redirect, signal, revalidate, preventScrollReset); 132 | } 133 | 134 | return payload; 135 | } 136 | 137 | export function BrowserRouter({ 138 | initialPayload, 139 | }: { 140 | initialPayload: ServerPayload; 141 | }) { 142 | const navigationStateRef = React.useRef<{ 143 | id: number; 144 | previousNavigationControllers: { 145 | id: number; 146 | controller: AbortController; 147 | }[]; 148 | }>({ 149 | id: 0, 150 | previousNavigationControllers: [], 151 | }); 152 | const [isPending, startTransition] = React.useTransition(); 153 | const [pendingState, setPendingState] = React.useState(null); 157 | const [_state, setState] = React.useState<{ 158 | id: number; 159 | payload: ServerPayload; 160 | }>({ 161 | id: 0, 162 | payload: initialPayload, 163 | }); 164 | const state = React.useDeferredValue(_state); 165 | 166 | startNavigation = React.useCallback( 167 | async (location, controller, callback) => { 168 | navigationStateRef.current.id++; 169 | const id = navigationStateRef.current.id; 170 | navigationStateRef.current.previousNavigationControllers.push({ 171 | id, 172 | controller, 173 | }); 174 | 175 | startTransition(async () => { 176 | setPendingState({ 177 | id, 178 | location, 179 | }); 180 | 181 | await callback( 182 | (newPayload) => { 183 | navigationStateRef.current.previousNavigationControllers = 184 | navigationStateRef.current.previousNavigationControllers.filter( 185 | (previous) => { 186 | if (previous.id >= id) { 187 | return true; 188 | } 189 | previous.controller.abort(new Error("Navigation aborted")); 190 | return false; 191 | }, 192 | ); 193 | if (id < navigationStateRef.current.id) { 194 | const error = new Error("Navigation aborted"); 195 | controller.abort(error); 196 | return; 197 | } 198 | startTransition(() => { 199 | setState((existingState) => { 200 | const existingTree = existingState.payload.tree ?? { 201 | matched: [], 202 | rendered: {}, 203 | }; 204 | const matched = 205 | newPayload.tree?.matched ?? existingTree.matched; 206 | return { 207 | id, 208 | payload: { 209 | ...newPayload, 210 | clientContext: { 211 | ...existingState.payload.clientContext, 212 | ...newPayload.clientContext, 213 | }, 214 | tree: { 215 | matched, 216 | rendered: Object.fromEntries( 217 | matched.map((id) => [ 218 | id, 219 | newPayload.tree?.rendered[id] ?? 220 | existingTree.rendered[id], 221 | ]), 222 | ), 223 | }, 224 | } satisfies ServerPayload, 225 | }; 226 | }); 227 | }); 228 | }, 229 | () => id < navigationStateRef.current.id, 230 | ); 231 | }); 232 | }, 233 | [], 234 | ); 235 | setGlobal("__startNavigation", startNavigation); 236 | 237 | const navigation: Navigation = 238 | pendingState && (pendingState.id > state.id || isPending) 239 | ? { 240 | href: pendingState.location, 241 | pending: true, 242 | } 243 | : { 244 | pending: false, 245 | }; 246 | 247 | React.useEffect(() => { 248 | const handlePopState = (event: PopStateEvent) => { 249 | event.preventDefault(); 250 | 251 | const to = window.location.href; 252 | const controller = new AbortController(); 253 | __startNavigation(to, controller, async (completeNavigation, aborted) => { 254 | const payload = await navigate(to, controller.signal); 255 | if (window.location.href !== payload.url.href && !aborted()) { 256 | window.history.replaceState(null, "", payload.url.href); 257 | } 258 | if (!aborted()) { 259 | scrollToTop(); 260 | } 261 | completeNavigation(payload); 262 | }); 263 | }; 264 | const handleLinkClick = (event: MouseEvent) => { 265 | if ( 266 | event.defaultPrevented || 267 | event.button !== 0 || 268 | event.metaKey || 269 | event.ctrlKey || 270 | event.shiftKey || 271 | event.altKey 272 | ) { 273 | return; 274 | } 275 | 276 | let target = event.target as HTMLElement | null; 277 | while (target && target.nodeName !== "A") { 278 | target = target.parentElement; 279 | } 280 | 281 | if (!target) return; 282 | 283 | const anchor = target as HTMLAnchorElement; 284 | if (anchor.target || anchor.hasAttribute("download")) return; 285 | 286 | const href = anchor.href; 287 | // if it's not a location on the same domain 288 | if (!href || href.indexOf(window.location.origin) !== 0) return; 289 | 290 | const revalidate = anchor.getAttribute("data-revalidate"); 291 | const preventScrollReset = 292 | anchor.getAttribute("data-prevent-scroll-reset") === "yes"; 293 | 294 | event.preventDefault(); 295 | const controller = new AbortController(); 296 | __startNavigation( 297 | href, 298 | controller, 299 | async (completeNavigation, aborted) => { 300 | const payload = await navigate( 301 | href, 302 | controller.signal, 303 | revalidate, 304 | preventScrollReset, 305 | ); 306 | if (window.location.href !== payload.url.href && !aborted()) { 307 | window.history.pushState(null, "", payload.url.href); 308 | } 309 | if (!aborted() && !preventScrollReset) { 310 | scrollToTop(); 311 | } 312 | completeNavigation(payload); 313 | }, 314 | ); 315 | }; 316 | 317 | window.addEventListener("popstate", handlePopState); 318 | window.addEventListener("click", handleLinkClick); 319 | return () => { 320 | window.removeEventListener("popstate", handlePopState); 321 | window.removeEventListener("click", handleLinkClick); 322 | }; 323 | }, []); 324 | 325 | if (!state.payload.tree) { 326 | throw new Error("No elements rendered on the server"); 327 | } 328 | 329 | return ( 330 | 331 | 335 | 336 | 337 | 338 | ); 339 | } 340 | 341 | export function scrollToTop() { 342 | window.scrollTo({ top: 0, behavior: "smooth" }); 343 | const scrollTargets = document.querySelectorAll("[data-scroll-to-top]"); 344 | for (const target of scrollTargets) { 345 | target.scrollTo({ top: 0, behavior: "smooth" }); 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /framework/src/router/server.tsx: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from "node:async_hooks"; 2 | import * as stream from "node:stream"; 3 | 4 | import type { HattipHandler } from "@hattip/core"; 5 | import type * as React from "react"; 6 | import type { ReactFormState } from "react-dom/client"; 7 | // @ts-expect-error - no types 8 | import ReactServerDOM from "react-server-dom-diy/server"; 9 | 10 | import { getOrCreateGlobal } from "../shared.js"; 11 | import { RenderRoute } from "./client.js"; 12 | import type { IndexRouteConfig, Node, NonIndexRouteConfig } from "./trie.js"; 13 | import { createTrie, matchTrie } from "./trie.js"; 14 | 15 | declare global { 16 | var __diy_server_manifest__: { 17 | resolveClientReferenceMetadata(clientReference: { 18 | $$id: string; 19 | }): [string, string]; 20 | resolveServerReference(id: string): { 21 | preloadModule(): Promise; 22 | requireModule(): unknown; 23 | }; 24 | }; 25 | var __diy_client_manifest__: { 26 | _cache?: Map; 27 | resolveClientReference([id, exportName]: [string, string]): { 28 | preloadModule(): Promise; 29 | requireModule(): unknown; 30 | }; 31 | }; 32 | } 33 | 34 | export type MiddlewareContext = { 35 | get( 36 | key: Key, 37 | truthy: true, 38 | ): NonNullable; 39 | get( 40 | key: Key, 41 | truthy?: false, 42 | ): undefined | ServerContext[Key]; 43 | get( 44 | key: Key, 45 | ): undefined | ServerContext[Key]; 46 | headers: Headers; 47 | redirect(to: string): never; 48 | set( 49 | key: Key, 50 | value: ServerContext[Key], 51 | ): void; 52 | setClient( 53 | key: Key, 54 | value: ServerClientContext[Key], 55 | ): void; 56 | }; 57 | 58 | export type MiddlewareFunction = ( 59 | c: MiddlewareContext, 60 | next: () => Promise, 61 | ) => void | Promise; 62 | 63 | export type RouteModule = { 64 | default?: (props: { children?: React.ReactNode }) => React.ReactNode; 65 | middleware?: MiddlewareFunction[]; 66 | }; 67 | 68 | export type RouteConfig = (IndexRouteConfig | NonIndexRouteConfig) & 69 | RouteModule & { 70 | children?: RouteConfig[]; 71 | import?: () => Promise; 72 | }; 73 | 74 | export type RouterContext = { 75 | action?: { 76 | actionId: string; 77 | returnValue: unknown; 78 | }; 79 | get( 80 | key: Key, 81 | truthy: true, 82 | ): NonNullable; 83 | get( 84 | key: Key, 85 | truthy?: false, 86 | ): undefined | ServerContext[Key]; 87 | get( 88 | key: Key, 89 | ): undefined | ServerContext[Key]; 90 | redirect?: string; 91 | request: Request; 92 | set( 93 | key: Key, 94 | value: ServerContext[Key], 95 | ): void; 96 | setClient( 97 | key: Key, 98 | value: ServerClientContext[Key], 99 | ): void; 100 | setHeaders: Headers; 101 | }; 102 | 103 | export type ServerPayload = { 104 | clientContext?: Record; 105 | formState?: ReactFormState; 106 | redirect?: string; 107 | returnValue?: unknown; 108 | tree?: { 109 | matched: string[]; 110 | rendered: Record; 111 | }; 112 | url: { 113 | href: string; 114 | }; 115 | }; 116 | 117 | export const REDIRECT_SYMBOL = Symbol("context.redirect"); 118 | 119 | getOrCreateGlobal("__asyncLocalStorage", () => new AsyncLocalStorage()); 120 | 121 | export const asyncLocalStorage = global.__asyncLocalStorage; 122 | 123 | export function runWithContext(context: RouterContext, fn: () => R) { 124 | return asyncLocalStorage.run(context, fn); 125 | } 126 | 127 | export function createHandler(handler: HattipHandler) { 128 | return handler; 129 | } 130 | 131 | export function createRoutes(routes: RouteConfig[]) { 132 | return createTrie(routes); 133 | } 134 | 135 | export async function runRoutes( 136 | routes: Node, 137 | request: Request, 138 | ): Promise { 139 | const contextValues: Partial< 140 | Record 141 | > = {}; 142 | const clientContextValues: Partial< 143 | Record 144 | > = {}; 145 | const context: RouterContext = { 146 | get(key, truthy = false) { 147 | const value = contextValues[key]; 148 | if (truthy && !value) { 149 | throw new Error(`Expected context key "${key}" to be truthy`); 150 | } 151 | return value as NonNullable; 152 | }, 153 | request, 154 | set(key, value) { 155 | contextValues[key] = value; 156 | }, 157 | setClient(key, value) { 158 | clientContextValues[key] = value; 159 | }, 160 | setHeaders: new Headers(), 161 | }; 162 | const url = new URL(request.url); 163 | const matched = matchTrie(routes, url.pathname); 164 | const matches: Array & RouteModule> = 165 | matched?.length 166 | ? await Promise.all( 167 | matched.map(async (match) => { 168 | const imported = await match.import?.(); 169 | const importedMiddleware = imported?.middleware || []; 170 | return { 171 | ...match, 172 | ...imported, 173 | middleware: match.middleware 174 | ? [...match.middleware, ...importedMiddleware] 175 | : importedMiddleware, 176 | }; 177 | }), 178 | ) 179 | : [ 180 | { 181 | id: "not-found", 182 | path: "*", 183 | default: () => ( 184 | 185 | 186 | 187 | Not Found 188 | 189 | 190 |

Not Found

191 | 192 | 193 | ), 194 | }, 195 | ]; 196 | 197 | const middlewareContext: MiddlewareContext = { 198 | get(key, truthy = false) { 199 | const value = contextValues[key]; 200 | if (truthy && !value) { 201 | throw new Error(`Expected context key "${key}" to be truthy`); 202 | } 203 | return value as NonNullable; 204 | }, 205 | headers: request.headers, 206 | redirect(to) { 207 | context.redirect = to; 208 | throw REDIRECT_SYMBOL; 209 | }, 210 | set(key, value) { 211 | contextValues[key] = value; 212 | }, 213 | setClient(key, value) { 214 | clientContextValues[key] = value; 215 | }, 216 | }; 217 | let runMiddleware = () => Promise.resolve(); 218 | // run middleware top down with a "next" function for each to call. 219 | for (let i = matches.length - 1; i >= 0; i--) { 220 | const match = matches[i]; 221 | if (match.middleware) { 222 | for (let j = match.middleware.length - 1; j >= 0; j--) { 223 | const middleware = match.middleware[j]; 224 | const next = runMiddleware; 225 | runMiddleware = async () => 226 | await runWithContext(context, () => 227 | middleware(middlewareContext, next), 228 | ); 229 | } 230 | } 231 | } 232 | 233 | try { 234 | await runWithContext(context, runMiddleware); 235 | } catch (reason) { 236 | if (reason !== REDIRECT_SYMBOL) { 237 | throw reason; 238 | } 239 | } 240 | 241 | const toRender: ServerPayload = { 242 | url: { href: request.url }, 243 | }; 244 | if (request.method === "POST") { 245 | const actionId = request.headers.get("RSC-Action"); 246 | if (actionId) { 247 | const serverReference = 248 | __diy_server_manifest__.resolveServerReference(actionId); 249 | const [serverAction, args] = await Promise.all([ 250 | serverReference 251 | .preloadModule() 252 | .then(() => serverReference.requireModule()), 253 | ReactServerDOM.decodeReply( 254 | await request.formData(), 255 | __diy_server_manifest__, 256 | ), 257 | ]); 258 | 259 | try { 260 | const returnValue = await runWithContext(context, async () => { 261 | return await serverAction(...args); 262 | }); 263 | context.action = { 264 | actionId, 265 | returnValue, 266 | }; 267 | 268 | toRender.returnValue = returnValue; 269 | } catch (reason) { 270 | if (reason !== REDIRECT_SYMBOL) { 271 | throw reason; 272 | } 273 | } 274 | } else { 275 | const formData = await request.formData(); 276 | const action = await ReactServerDOM.decodeAction( 277 | formData, 278 | __diy_server_manifest__, 279 | ); 280 | try { 281 | const returnValue = await runWithContext(context, action); 282 | context.action = { 283 | actionId: action.$$id, 284 | returnValue, 285 | }; 286 | const formState = ReactServerDOM.decodeFormState( 287 | returnValue, 288 | formData, 289 | __diy_server_manifest__, 290 | ); 291 | 292 | toRender.formState = formState; 293 | } catch (reason) { 294 | if (reason !== REDIRECT_SYMBOL) { 295 | throw reason; 296 | } 297 | } 298 | } 299 | } 300 | 301 | let revalidate: boolean | string[] = true; 302 | try { 303 | const revalidateHeader = request.headers.get("RSC-Revalidate"); 304 | revalidate = 305 | revalidateHeader === "no" 306 | ? false 307 | : revalidateHeader 308 | ? JSON.parse(revalidateHeader) 309 | : true; 310 | if (typeof revalidate !== "boolean" && !Array.isArray(revalidate)) { 311 | revalidate = true; 312 | } 313 | } catch (reason) { 314 | console.error( 315 | "Invalid RSC-Revalidate input value, falling back to a full revalidation", 316 | ); 317 | } 318 | 319 | if (context.redirect) { 320 | toRender.redirect = context.redirect; 321 | } else if (revalidate) { 322 | const toRevalidate = Array.isArray(revalidate) ? new Set(revalidate) : null; 323 | const matched: string[] = []; 324 | const rendered: Record = {}; 325 | 326 | let lastId: string | null = null; 327 | for (let i = matches.length - 1; i >= 0; i--) { 328 | const match = matches[i]; 329 | const Route = match.default; 330 | if (Route) { 331 | matched.unshift(match.id); 332 | if (!toRevalidate || toRevalidate.has(match.id)) { 333 | const children = lastId ? : null; 334 | rendered[match.id] = {children}; 335 | } 336 | lastId = match.id; 337 | } 338 | } 339 | 340 | toRender.tree = { matched, rendered }; 341 | } 342 | 343 | if (!context.redirect) { 344 | toRender.clientContext = clientContextValues; 345 | } 346 | 347 | const pipeable = runWithContext(context, () => 348 | ReactServerDOM.renderToPipeableStream(toRender, __diy_server_manifest__), 349 | ); 350 | 351 | const headers = new Headers(context.setHeaders); 352 | headers.set("Content-Type", "text/x-component"); 353 | headers.set("Vary", "Accept"); 354 | 355 | return new Response( 356 | stream.Readable.toWeb( 357 | pipeable.pipe(new stream.PassThrough()), 358 | ) as ReadableStream, 359 | { 360 | status: context.redirect ? 202 : 200, 361 | headers, 362 | }, 363 | ); 364 | } 365 | -------------------------------------------------------------------------------- /vite/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | 3 | import react from "@vitejs/plugin-react"; 4 | import { rscClientPlugin, rscServerPlugin } from "unplugin-rsc"; 5 | import type { DevEnvironment, Plugin, UserConfig, ViteDevServer } from "vite"; 6 | import { createServerModuleRunner, loadEnv } from "vite"; 7 | 8 | function prodHash(str: string, _: "use client" | "use server") { 9 | return `/${path.relative(process.cwd(), str)}`; 10 | } 11 | 12 | function devHash(str: string, _: "use client" | "use server") { 13 | const resolved = path.resolve(str); 14 | let unixPath = resolved.replace(/\\/g, "/"); 15 | if (!unixPath.startsWith("/")) { 16 | unixPath = `/${unixPath}`; 17 | } 18 | if (resolved.startsWith(process.cwd())) { 19 | return `/${path.relative(process.cwd(), unixPath)}`; 20 | } 21 | return `/@fs${unixPath}`; 22 | } 23 | 24 | declare global { 25 | var __clientModules: Set; 26 | var __serverModules: Set; 27 | } 28 | 29 | global.__clientModules = global.__clientModules || new Set(); 30 | global.__serverModules = global.__serverModules || new Set(); 31 | 32 | export function createReactServerOptions() { 33 | return { 34 | clientModules: global.__clientModules, 35 | serverModules: global.__clientModules, 36 | }; 37 | } 38 | 39 | export function reactServerBuilder({ 40 | serverModules, 41 | }: { 42 | serverModules: Set; 43 | }): UserConfig["builder"] { 44 | return { 45 | async buildEnvironments(builder, build) { 46 | async function doBuildRecursive() { 47 | const ogServerModulesCount = serverModules.size; 48 | await build(builder.environments.server); 49 | let serverNeedsRebuild = serverModules.size > ogServerModulesCount; 50 | 51 | await Promise.all([ 52 | build(builder.environments.ssr), 53 | build(builder.environments.client), 54 | ]); 55 | if (serverModules.size > ogServerModulesCount) { 56 | serverNeedsRebuild = true; 57 | } 58 | 59 | if (serverNeedsRebuild) { 60 | await doBuildRecursive(); 61 | } 62 | } 63 | await doBuildRecursive(); 64 | }, 65 | }; 66 | } 67 | 68 | export function reactServerPlugin({ 69 | clientModules, 70 | serverModules, 71 | }: { 72 | clientModules: Set; 73 | serverModules: Set; 74 | }): Plugin { 75 | let browserEntry: string; 76 | let devBase: string; 77 | 78 | return { 79 | name: "react-server", 80 | configEnvironment(name, env) { 81 | let ssr = false; 82 | let manifest = false; 83 | const input: Record = {}; 84 | let dev: (typeof env)["dev"] = undefined; 85 | let resolve: (typeof env)["resolve"] = undefined; 86 | 87 | switch (name) { 88 | case "client": 89 | ssr = false; 90 | input["_client-references"] = "virtual:client-references"; 91 | manifest = true; 92 | browserEntry = ( 93 | env.build?.rollupOptions?.input as Record 94 | )?.index; 95 | break; 96 | case "ssr": 97 | ssr = true; 98 | input["_client-references"] = "virtual:client-references"; 99 | dev = { 100 | optimizeDeps: { 101 | include: ["framework/client"], 102 | }, 103 | }; 104 | resolve = { 105 | noExternal: ["react-server-dom-diy/client"], 106 | }; 107 | break; 108 | case "server": 109 | ssr = true; 110 | input["_server-references"] = "virtual:server-references"; 111 | dev = { 112 | optimizeDeps: { 113 | include: [ 114 | "react", 115 | "react/jsx-runtime", 116 | "react/jsx-dev-runtime", 117 | "react-server-dom-diy/server", 118 | ], 119 | extensions: [".tsx", ".ts", "..."], 120 | }, 121 | }; 122 | resolve = { 123 | externalConditions: ["react-server", "..."], 124 | conditions: ["react-server", "..."], 125 | noExternal: true, 126 | }; 127 | break; 128 | } 129 | 130 | return { 131 | build: { 132 | ssr, 133 | manifest, 134 | emitAssets: !ssr, 135 | copyPublicDir: !ssr, 136 | rollupOptions: { 137 | preserveEntrySignatures: "exports-only", 138 | input, 139 | }, 140 | }, 141 | dev, 142 | resolve, 143 | }; 144 | }, 145 | configResolved(config) { 146 | devBase = config.base; 147 | }, 148 | transform(...args) { 149 | const hash = this.environment?.mode === "dev" ? devHash : prodHash; 150 | const clientPlugin: Plugin = rscClientPlugin.vite({ 151 | include: ["**/*"], 152 | transformModuleId: hash, 153 | useServerRuntime: { 154 | function: "createServerReference", 155 | module: "framework/runtime.client", 156 | }, 157 | onModuleFound(id, type) { 158 | switch (type) { 159 | case "use server": 160 | serverModules.add(id); 161 | break; 162 | } 163 | }, 164 | }) as Plugin; 165 | const prerenderPlugin: Plugin = rscClientPlugin.vite({ 166 | include: ["**/*"], 167 | transformModuleId: hash, 168 | useServerRuntime: { 169 | function: "createServerReference", 170 | module: "framework/runtime.client", 171 | }, 172 | onModuleFound(id, type) { 173 | switch (type) { 174 | case "use server": 175 | serverModules.add(id); 176 | break; 177 | } 178 | }, 179 | }) as Plugin; 180 | const serverPlugin = rscServerPlugin.vite({ 181 | include: ["**/*"], 182 | transformModuleId: hash, 183 | useClientRuntime: { 184 | function: "registerClientReference", 185 | module: "react-server-dom-diy/server", 186 | }, 187 | useServerRuntime: { 188 | function: "registerServerReference", 189 | module: "react-server-dom-diy/server", 190 | }, 191 | onModuleFound(id, type) { 192 | switch (type) { 193 | case "use client": 194 | clientModules.add(id); 195 | break; 196 | case "use server": 197 | serverModules.add(id); 198 | break; 199 | } 200 | }, 201 | }) as Plugin; 202 | 203 | if (this.environment?.name === "server") { 204 | // biome-ignore lint/complexity/noBannedTypes: bla bla bla 205 | return (serverPlugin.transform as Function).apply(this, args); 206 | } 207 | 208 | if (this.environment?.name === "ssr") { 209 | // biome-ignore lint/complexity/noBannedTypes: bla bla bla 210 | return (prerenderPlugin.transform as Function).apply(this, args); 211 | } 212 | 213 | // biome-ignore lint/complexity/noBannedTypes: bla bla bla 214 | return (clientPlugin.transform as Function).apply(this, args); 215 | }, 216 | resolveId(source) { 217 | if ( 218 | source === "virtual:client-references" || 219 | source === "virtual:server-references" || 220 | source === "virtual:browser-entry" || 221 | source === "virtual:react-preamble" 222 | ) { 223 | return `\0${source}`; 224 | } 225 | }, 226 | load(id) { 227 | const hash = this.environment?.mode === "dev" ? devHash : prodHash; 228 | if (id === "\0virtual:client-references") { 229 | let result = "export default {"; 230 | for (const clientModule of clientModules) { 231 | result += `${JSON.stringify( 232 | hash(clientModule, "use client"), 233 | )}: () => import(${JSON.stringify(clientModule)}),`; 234 | } 235 | return `${result}\};`; 236 | } 237 | 238 | if (id === "\0virtual:server-references") { 239 | let result = "export default {"; 240 | for (const serverModule of serverModules) { 241 | result += `${JSON.stringify( 242 | hash(serverModule, "use server"), 243 | )}: () => import(${JSON.stringify(serverModule)}),`; 244 | } 245 | return `${result}\};`; 246 | } 247 | 248 | if (id === "\0virtual:browser-entry") { 249 | return ` 250 | import "virtual:react-preamble"; 251 | import ${JSON.stringify(browserEntry)}; 252 | `; 253 | } 254 | 255 | if (id === "\0virtual:react-preamble") { 256 | return react.preambleCode.replace("__BASE__", devBase); 257 | } 258 | }, 259 | }; 260 | } 261 | 262 | export function reactServerDevServer({ 263 | createPrerenderEnvironment, 264 | createServerEnvironment, 265 | clientModules, 266 | }: { 267 | createPrerenderEnvironment?: ( 268 | server: ViteDevServer, 269 | name: string, 270 | ) => DevEnvironment | Promise; 271 | createServerEnvironment: ( 272 | server: ViteDevServer, 273 | name: string, 274 | ) => DevEnvironment | Promise; 275 | clientModules: Set; 276 | }): Plugin { 277 | const runners = {} as Record< 278 | "ssr" | "server", 279 | ReturnType 280 | >; 281 | 282 | type CachedPromise = Promise & { 283 | status: "pending" | "fulfilled" | "rejected"; 284 | value?: unknown; 285 | reason?: unknown; 286 | }; 287 | const serverModulePromiseCache = new Map>(); 288 | const clientModulePromiseCache = new Map>(); 289 | 290 | return { 291 | name: "hattip-rsc-dev-server", 292 | configEnvironment(name) { 293 | switch (name) { 294 | case "ssr": 295 | return { 296 | dev: { 297 | createEnvironment: createPrerenderEnvironment, 298 | }, 299 | }; 300 | case "server": 301 | return { 302 | dev: { 303 | createEnvironment: createServerEnvironment, 304 | }, 305 | }; 306 | } 307 | }, 308 | config(_, env) { 309 | process.env = { ...process.env, ...loadEnv(env.mode, process.cwd(), "") }; 310 | }, 311 | async configureServer(server) { 312 | runners.ssr = createServerModuleRunner(server.environments.ssr); 313 | runners.server = createServerModuleRunner(server.environments.server); 314 | 315 | const prerenderInput = server.environments.ssr.options.build.rollupOptions 316 | .input as Record; 317 | const prerenderEntry = prerenderInput.index; 318 | if (!prerenderEntry) { 319 | throw new Error( 320 | "No entry file found for ssr environment, please specify one in vite.config.ts under environments.ssr.build.rollupOptions.input.index", 321 | ); 322 | } 323 | 324 | const serverInput = server.environments.server.options.build.rollupOptions 325 | .input as Record; 326 | const serverEntry = serverInput.index; 327 | if (!serverEntry) { 328 | throw new Error( 329 | "No entry file found for server environment, please specify one in vite.config.ts under environments.server.build.rollupOptions.input.index", 330 | ); 331 | } 332 | 333 | const { createMiddleware } = await import("@hattip/adapter-node"); 334 | 335 | // @ts-expect-error - no types 336 | global.__diy_server_manifest__ = { 337 | resolveClientReferenceMetadata(clientReference: { $$id: string }) { 338 | const id = clientReference.$$id; 339 | const idx = id.lastIndexOf("#"); 340 | const exportName = id.slice(idx + 1); 341 | const fullURL = id.slice(0, idx); 342 | return [fullURL, exportName]; 343 | }, 344 | resolveServerReference(_id: string) { 345 | const idx = _id.lastIndexOf("#"); 346 | const exportName = _id.slice(idx + 1); 347 | const id = _id.slice(0, idx); 348 | return { 349 | preloadModule() { 350 | if (serverModulePromiseCache.has(id)) { 351 | return serverModulePromiseCache.get(id) as CachedPromise; 352 | } 353 | const promise = runners.server 354 | .import(id) 355 | .then((mod) => { 356 | promise.status = "fulfilled"; 357 | promise.value = mod; 358 | }) 359 | .catch((res) => { 360 | promise.status = "rejected"; 361 | promise.reason = res; 362 | throw res; 363 | }) as CachedPromise; 364 | promise.status = "pending"; 365 | serverModulePromiseCache.set(id, promise); 366 | return promise; 367 | }, 368 | requireModule() { 369 | const cached = serverModulePromiseCache.get(id); 370 | if (!cached) throw new Error(`Module ${id} not found`); 371 | if (cached.reason) throw cached.reason; 372 | return (cached.value as Record)[exportName]; 373 | }, 374 | }; 375 | }, 376 | }; 377 | 378 | // @ts-expect-error - no types 379 | global.__diy_client_manifest__ = { 380 | resolveClientReference([id, exportName]: [string, string]) { 381 | return { 382 | preloadModule() { 383 | if (clientModulePromiseCache.has(id)) { 384 | return clientModulePromiseCache.get(id) as CachedPromise; 385 | } 386 | const promise = runners.ssr 387 | .import(id) 388 | .then((mod) => { 389 | promise.status = "fulfilled"; 390 | promise.value = mod; 391 | }) 392 | .catch((res) => { 393 | promise.status = "rejected"; 394 | promise.reason = res; 395 | throw res; 396 | }) as CachedPromise; 397 | promise.status = "pending"; 398 | clientModulePromiseCache.set(id, promise); 399 | return promise; 400 | }, 401 | requireModule() { 402 | const cached = clientModulePromiseCache.get(id); 403 | if (!cached) throw new Error(`Module ${id} not found`); 404 | if (cached.reason) throw cached.reason; 405 | return (cached.value as Record)[exportName]; 406 | }, 407 | }; 408 | }, 409 | }; 410 | 411 | return () => { 412 | server.middlewares.use(async (req, res, next) => { 413 | try { 414 | const { ssr: prerender, server } = runners; 415 | 416 | const [prerenderMod, serverMod] = await Promise.all([ 417 | prerender.import(prerenderEntry), 418 | server.import(serverEntry), 419 | ]); 420 | 421 | const middleware = createMiddleware( 422 | (c) => { 423 | const callServer = (request: Request) => { 424 | return serverMod.default({ ...c, request }); 425 | }; 426 | 427 | return prerenderMod.default(c, { 428 | bootstrapModules: [ 429 | "/@vite/client", 430 | "/@id/virtual:browser-entry", 431 | ], 432 | bootstrapScriptContent: ` 433 | window.__diy_client_manifest__ = { 434 | _cache: new Map(), 435 | resolveClientReference([id, exportName]) { 436 | return { 437 | preloadModule() { 438 | if (window.__diy_client_manifest__._cache.has(id)) { 439 | return window.__diy_client_manifest__._cache.get(id); 440 | } 441 | const promise = import(id) 442 | .then((mod) => { 443 | promise.status = "fulfilled"; 444 | promise.value = mod; 445 | }) 446 | .catch((res) => { 447 | promise.status = "rejected"; 448 | promise.reason = res; 449 | throw res; 450 | }); 451 | promise.status = "pending"; 452 | window.__diy_client_manifest__._cache.set(id, promise); 453 | return promise; 454 | }, 455 | requireModule() { 456 | const cached = window.__diy_client_manifest__._cache.get(id); 457 | if (!cached) throw new Error(\`Module \${id} not found\`); 458 | if (cached.reason) throw cached.reason; 459 | return cached.value[exportName]; 460 | }, 461 | }; 462 | }, 463 | }; 464 | `, 465 | callServer, 466 | }); 467 | }, 468 | { 469 | alwaysCallNext: false, 470 | }, 471 | ); 472 | 473 | if (req.originalUrl !== req.url) { 474 | req.url = req.originalUrl; 475 | } 476 | await middleware(req, res, next); 477 | } catch (reason) { 478 | next(reason); 479 | } 480 | }); 481 | }; 482 | }, 483 | hotUpdate(ctx) { 484 | const ids: string[] = []; 485 | const cwd = process.cwd(); 486 | for (const mod of ctx.modules) { 487 | if (mod.id) { 488 | ids.push(mod.id); 489 | const toDelete = `/${path.relative(cwd, mod.id)}`; 490 | clientModulePromiseCache.delete(toDelete); 491 | serverModulePromiseCache.delete(toDelete); 492 | } 493 | } 494 | 495 | if (ids.length > 0) { 496 | switch (ctx.environment.name) { 497 | case "server": 498 | for (const id of ids) { 499 | if (ctx.environment.moduleGraph.getModuleById(id)) { 500 | runners.server.moduleCache.invalidateDepTree([id]); 501 | } 502 | } 503 | break; 504 | case "ssr": 505 | for (const id of ids) { 506 | if (ctx.environment.moduleGraph.getModuleById(id)) { 507 | runners.ssr.moduleCache.invalidateDepTree([id]); 508 | } 509 | } 510 | break; 511 | } 512 | } 513 | 514 | if ( 515 | ctx.environment.name === "client" && 516 | ids.some( 517 | (id) => 518 | !!ctx.server.environments.server.moduleGraph.getModuleById(id), 519 | ) 520 | ) { 521 | ctx.environment.hot.send("react-server:update", { 522 | ids, 523 | }); 524 | return ctx.modules.filter( 525 | (mod) => !!mod.id && clientModules.has(mod.id), 526 | ); 527 | } 528 | }, 529 | }; 530 | } 531 | --------------------------------------------------------------------------------