├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
157 | {field.errors}
158 |
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 |
--------------------------------------------------------------------------------