├── .gitignore
├── package.json
├── pnpm-lock.yaml
├── postcss.config.cjs
├── prerender.ts
├── public
└── favicon.ico
├── src
├── app
│ ├── app.client.tsx
│ ├── app.css
│ ├── app.tsx
│ ├── components
│ │ └── ui
│ │ │ ├── button.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── global-loader.tsx
│ │ │ ├── input.tsx
│ │ │ ├── select.tsx
│ │ │ ├── sidebar.tsx
│ │ │ ├── textarea.tsx
│ │ │ └── validated-form.tsx
│ ├── global-actions.ts
│ ├── lib
│ │ └── utils.ts
│ ├── login
│ │ ├── login.client.tsx
│ │ ├── login.shared.ts
│ │ └── login.tsx
│ ├── signup
│ │ ├── signup.client.tsx
│ │ ├── signup.shared.ts
│ │ └── signup.tsx
│ └── todo
│ │ ├── todo.client.tsx
│ │ ├── todo.shared.ts
│ │ └── todo.tsx
├── browser
│ └── entry.browser.tsx
├── framework
│ ├── browser.tsx
│ ├── client.ts
│ ├── cookie-session.ts
│ ├── cookies.ts
│ ├── crypto.ts
│ ├── references.browser.ts
│ ├── references.server.ts
│ ├── references.ssr.ts
│ ├── server.ts
│ ├── sessions.ts
│ ├── ssr.tsx
│ └── warnings.ts
├── server
│ ├── .dev.vars
│ ├── entry.server.tsx
│ ├── todo-list.ts
│ ├── user.ts
│ └── wrangler.toml
└── ssr
│ ├── entry.ssr.tsx
│ └── wrangler.toml
├── tailwind.config.ts
├── tsconfig.client.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .wrangler
3 | build
4 | dist
5 | logs
6 | node_modules
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@playground/react-server",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "build": "vite build --app",
7 | "check:types": "tsc --build",
8 | "deploy": "wrangler deploy -c dist/server/wrangler.json && pnpm wrangler deploy -c dist/ssr/wrangler.json ",
9 | "dev": "vite dev",
10 | "prerender": "node --experimental-strip-types ./prerender.ts",
11 | "preview": "vite preview"
12 | },
13 | "dependencies": {
14 | "@jacob-ebey/react-server-dom-vite": "19.0.0-experimental.14",
15 | "@radix-ui/react-slot": "^1.1.1",
16 | "bcrypt-edge": "^0.1.0",
17 | "class-variance-authority": "^0.7.1",
18 | "clsx": "^2.1.1",
19 | "cookie": "^1.0.2",
20 | "focus-trap": "^7.6.2",
21 | "lucide-react": "^0.469.0",
22 | "pouchdb-browser": "^9.0.0",
23 | "react": "^19.0.0",
24 | "react-aria-components": "^1.5.0",
25 | "react-dom": "^19.0.0",
26 | "tailwind-merge": "^2.6.0",
27 | "valibot": "1.0.0-beta.9"
28 | },
29 | "devDependencies": {
30 | "@cloudflare/workers-types": "^4.20241230.0",
31 | "@flarelabs-net/vite-plugin-cloudflare": "https://pkg.pr.new/flarelabs-net/vite-plugin-cloudflare/@flarelabs-net/vite-plugin-cloudflare@123",
32 | "@jacob-ebey/vite-react-server-dom": "0.0.12",
33 | "@types/cookie": "^1.0.0",
34 | "@types/dom-navigation": "^1.0.4",
35 | "@types/node": "^22.10.4",
36 | "@types/react": "^19.0.0",
37 | "@types/react-dom": "^19.0.0",
38 | "autoprefixer": "^10.4.20",
39 | "execa": "^9.5.2",
40 | "postcss": "^8.4.49",
41 | "rsc-html-stream": "0.0.4",
42 | "tailwindcss": "^3.4.17",
43 | "typescript": "^5.7.2",
44 | "unenv": "npm:unenv-nightly@2.0.0-20241204-140205-a5d5190",
45 | "unplugin-rsc": "0.0.11",
46 | "vite": "^6.0.7",
47 | "vite-plugin-pwa": "^0.21.1",
48 | "vite-tsconfig-paths": "^5.1.4",
49 | "wrangler": "^3.99.0"
50 | },
51 | "pnpm": {
52 | "overrides": {
53 | "wrangler": "^3.99.0",
54 | "vite": "^6.0.7"
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [require('tailwindcss'), require('autoprefixer')],
3 | };
4 |
--------------------------------------------------------------------------------
/prerender.ts:
--------------------------------------------------------------------------------
1 | // Dependant on https://github.com/flarelabs-net/vite-plugin-cloudflare/pull/125
2 |
3 | import * as fsp from "node:fs/promises";
4 | import * as path from "node:path";
5 |
6 | import { $ } from "execa";
7 |
8 | const PATHS_TO_PRERENDER = ["/", "/signup"];
9 |
10 | const port = 4173;
11 | const host = "localhost";
12 | const proc = $`pnpm preview --host ${host} --port ${port}`;
13 |
14 | async function waitForPort() {
15 | const timeout = 10000;
16 |
17 | const start = Date.now();
18 | while (Date.now() - start < timeout) {
19 | try {
20 | await fetch(`http://${host}:${port}`);
21 | return;
22 | } catch (error) {
23 | await new Promise((resolve) => setTimeout(resolve, 100));
24 | }
25 | }
26 | }
27 |
28 | try {
29 | await waitForPort();
30 |
31 | for (const pathname of PATHS_TO_PRERENDER) {
32 | console.log(`prerendering ${pathname}`);
33 | const response = await fetch(
34 | new URL(pathname, `http://${host}:${port}`).href,
35 | {
36 | headers: {
37 | PRERENDER: "1",
38 | },
39 | }
40 | );
41 |
42 | const rscPayload = decodeURI(response.headers.get("X-RSC-Payload") || "");
43 |
44 | if (response.status !== 200 || !rscPayload) {
45 | throw new Error(`Failed to prerender rsc payload for ${pathname}`);
46 | }
47 |
48 | const html = await response.text();
49 | if (!html.includes("")) {
50 | throw new Error(`Failed to prerender html for ${pathname}`);
51 | }
52 |
53 | const segments = pathname.split("/").filter(Boolean);
54 | if (segments.length === 0) {
55 | segments.push("index");
56 | }
57 | const lastSegment = segments.pop();
58 | const rscPath = path.join(
59 | "dist",
60 | "client",
61 | "_prerender",
62 | ...segments,
63 | lastSegment + ".data"
64 | );
65 | const htmlPath = path.join(
66 | "dist",
67 | "client",
68 | "_prerender",
69 | ...segments,
70 | lastSegment + ".html"
71 | );
72 |
73 | await fsp.mkdir(path.dirname(rscPath), { recursive: true });
74 |
75 | await Promise.all([
76 | fsp.writeFile(rscPath, rscPayload),
77 | fsp.writeFile(htmlPath, html),
78 | ]);
79 | }
80 | } finally {
81 | proc.kill();
82 | }
83 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jacob-ebey/cf-react-server-template/19ed0b9bc6638eec4789c3a75827ed9e06487cfa/public/favicon.ico
--------------------------------------------------------------------------------
/src/app/app.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useNavigating } from "framework/client";
4 |
5 | import { GlobalLoader } from "~/components/ui/global-loader";
6 |
7 | export function GlobalPendingIndicator() {
8 | const navigating = useNavigating();
9 |
10 | return ;
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/app.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --sidebar-width-mobile: 80%;
8 | --sidebar-width-desktop: 20rem;
9 |
10 | --background: white;
11 | --foreground: black;
12 | --foreground-muted: #666;
13 | --border: black;
14 | --primary: blue;
15 | --primary-foreground: white;
16 | --destructive: red;
17 | --destructive-foreground: white;
18 |
19 | @media (prefers-color-scheme: dark) {
20 | --background: black;
21 | --foreground: white;
22 | --foreground-muted: #999;
23 | --border: white;
24 | --primary: blue;
25 | --primary-foreground: white;
26 | --destructive: red;
27 | --destructive-foreground: white;
28 | }
29 | }
30 |
31 | *:focus-visible {
32 | @apply outline-primary;
33 | }
34 |
35 | html,
36 | body {
37 | @apply bg-background text-foreground subpixel-antialiased;
38 |
39 | @media (prefers-color-scheme: dark) {
40 | color-scheme: dark;
41 | }
42 | }
43 | }
44 |
45 | @layer components {
46 | .typography {
47 | @apply text-sm lg:text-base;
48 | }
49 |
50 | .typography hr,
51 | .hr {
52 | @apply border border-border my-6;
53 | }
54 |
55 | .typography h1,
56 | .h1 {
57 | @apply text-2xl md:text-3xl font-bold [&:not(:first-child)]:mt-10 mb-6;
58 | }
59 |
60 | .typography h2,
61 | .h2 {
62 | @apply text-xl md:text-2xl font-bold [&:not(:first-child)]:mt-10 mb-6;
63 | }
64 |
65 | .typography h3,
66 | .h3 {
67 | @apply text-lg md:text-xl font-bold [&:not(:first-child)]:mt-10 mb-6;
68 | }
69 |
70 | .typography h4,
71 | .h4 {
72 | @apply text-base md:text-lg font-bold [&:not(:first-child)]:mt-10 mb-6;
73 | }
74 |
75 | .typography h5,
76 | .h5 {
77 | @apply text-sm lg:text-base font-bold [&:not(:first-child)]:mt-10 mb-6;
78 | }
79 |
80 | .typography h6,
81 | .h6 {
82 | @apply text-xs lg:text-sm font-bold [&:not(:first-child)]:mt-10 mb-6;
83 | }
84 |
85 | .typography p,
86 | .p {
87 | @apply mb-6 [&:not(:first-child)]:mt-6;
88 | }
89 |
90 | .typography a,
91 | .a {
92 | @apply underline decoration-2;
93 | }
94 |
95 | .typography abbr,
96 | .typography del,
97 | .typography ins {
98 | @apply decoration-2;
99 | }
100 |
101 | .typography ul {
102 | @apply list-disc pl-4 mb-6 [&:not(:first-child)]:mt-6 space-y-2;
103 | }
104 |
105 | .typography ol {
106 | @apply list-decimal pl-7 mb-6 [&:not(:first-child)]:mt-6 space-y-2;
107 | }
108 |
109 | .typography dl {
110 | @apply space-y-2 space-y-2;
111 | }
112 |
113 | .typography dt {
114 | @apply font-bold;
115 | }
116 |
117 | .typography dd {
118 | @apply ml-4;
119 | }
120 |
121 | .typography ul ul,
122 | .typography ol ul,
123 | .typography ol ol,
124 | .typography ul ol {
125 | @apply my-0;
126 | }
127 |
128 | .typography blockquote {
129 | @apply border-l-4 border-border pl-4 italic mb-6 [&:not(:first-child)]:mt-6;
130 | }
131 |
132 | .typography pre {
133 | @apply mb-6 [&:not(:first-child)]:mt-6;
134 | }
135 |
136 | .typography pre:has(code) {
137 | @apply overflow-x-auto p-4 border-2 border-border;
138 | }
139 |
140 | .typography code {
141 | @apply font-mono text-base;
142 | }
143 |
144 | .typography table,
145 | .table {
146 | @apply w-full border-x-2 border-t-2 border-border mb-6 [&:not(:first-child)]:mt-6;
147 | }
148 |
149 | .typography thead,
150 | .table thead {
151 | @apply font-bold text-left;
152 | }
153 |
154 | .typography tr,
155 | .table tr {
156 | @apply border-b-2 border-border;
157 | }
158 |
159 | .typography th,
160 | .table th {
161 | @apply font-bold border-r-2 border-border p-2 align-top;
162 | }
163 |
164 | .typography td,
165 | .table td {
166 | @apply border-r-2 border-border p-2 align-top;
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/src/app/app.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | destoryCookieSession,
3 | getCookieSession,
4 | getURL,
5 | } from "framework/server";
6 |
7 | import { GlobalPendingIndicator } from "./app.client";
8 | import stylesHref from "./app.css?url";
9 |
10 | import Login from "./login/login";
11 | import Signup from "./signup/signup";
12 | import Todo from "./todo/todo";
13 |
14 | export function App() {
15 | const url = getURL();
16 |
17 | return (
18 |
19 |
20 |
21 | React Server
22 |
23 |
24 |
25 |
26 | {(() => {
27 | const pathStart = url.pathname.split("/", 2).join("/");
28 | switch (pathStart) {
29 | case "/todo":
30 | return ;
31 | case "/signup":
32 | return ;
33 | default:
34 | return ;
35 | }
36 | })()}
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Slot } from '@radix-ui/react-slot';
4 | import { cn } from '~/lib/utils';
5 | import { cva } from 'class-variance-authority';
6 | import { Button as BaseButton } from 'react-aria-components';
7 | import type { VariantProps } from 'class-variance-authority';
8 | import type { ButtonProps as BaseButtonProps } from 'react-aria-components';
9 |
10 | export const buttonVariants = cva(
11 | cn(
12 | 'inline-flex items-center justify-center gap-2 whitepsace-nowrap [&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0',
13 | 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50',
14 | ),
15 | {
16 | variants: {
17 | size: {
18 | sm: 'px-2 py-1 text-base',
19 | md: 'px-2 py-1 text-lg',
20 | lg: 'px-3 py-2 text-xl',
21 | icon: 'h-9 w-9 aspect-square',
22 | },
23 | variant: {
24 | default:
25 | 'font-bold bg-background text-foreground border-2 border-border',
26 | primary:
27 | 'font-bold bg-primary text-primary-foreground border-2 border-border',
28 | destructive:
29 | 'font-bold bg-background text-destructive border-2 border-destructive focus-visible:ring-destructive',
30 | },
31 | },
32 | defaultVariants: {
33 | size: 'md',
34 | variant: 'default',
35 | },
36 | },
37 | );
38 |
39 | export type ButtonProps = React.ComponentProps<'button'> &
40 | BaseButtonProps &
41 | VariantProps & {
42 | asChild?: boolean;
43 | };
44 |
45 | export function Button({
46 | asChild,
47 | className,
48 | disabled,
49 | onKeyDown,
50 | size,
51 | variant,
52 | ...props
53 | }: ButtonProps) {
54 | const Comp: any = asChild ? Slot : BaseButton;
55 | return (
56 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/app/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "~/lib/utils";
2 |
3 | export type CheckboxProps = React.ComponentProps<"input">;
4 |
5 | export function Checkbox({ className, ...props }: CheckboxProps) {
6 | return (
7 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/components/ui/global-loader.tsx:
--------------------------------------------------------------------------------
1 | export type GlobalLoaderProps = {
2 | loading: boolean;
3 | };
4 |
5 | export function GlobalLoader({ loading }: GlobalLoaderProps) {
6 | if (!loading) {
7 | return null;
8 | }
9 |
10 | return (
11 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '~/lib/utils';
2 |
3 | export type InputProps = React.ComponentProps<'input'>;
4 |
5 | export function Input({ className, ...props }: InputProps) {
6 | return (
7 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "~/lib/utils";
2 |
3 | export type SelectProps = React.ComponentProps<"select">;
4 |
5 | export function Select({ className, ...props }: SelectProps) {
6 | return (
7 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/components/ui/sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useRef } from "react";
2 | import { createFocusTrap } from "focus-trap";
3 |
4 | import { cn } from "~/lib/utils";
5 |
6 | export type SidebarContainerProps = React.ComponentProps<"div">;
7 |
8 | export function SidebarContainer({
9 | className,
10 | ...props
11 | }: SidebarContainerProps) {
12 | return (
13 |
17 | );
18 | }
19 |
20 | export type SidebarProps = React.ComponentProps<"div"> & {
21 | collapsed?: boolean;
22 | onClose?: () => void;
23 | open?: boolean;
24 | side?: "left" | "right";
25 | };
26 |
27 | export function Sidebar({
28 | className,
29 | collapsed,
30 | onClose,
31 | open,
32 | side = "left",
33 | ...props
34 | }: SidebarProps) {
35 | const elementRef = useFocusTrap(onClose);
36 |
37 | return (
38 |
70 | );
71 | }
72 |
73 | export type SidebarMainProps = React.ComponentProps<"div">;
74 |
75 | export function SidebarMain({ className, ...props }: SidebarMainProps) {
76 | return (
77 |
85 | );
86 | }
87 |
88 | const BREAKPOINT = 768;
89 |
90 | function useFocusTrap(onClose?: () => void) {
91 | const elementRef = useRef<
92 | HTMLDivElement & {
93 | focusTrap?: ReturnType;
94 | }
95 | >(null);
96 |
97 | useLayoutEffect(() => {
98 | const element = elementRef.current;
99 | if (!element) return;
100 |
101 | if (!element.focusTrap) {
102 | element.focusTrap = createFocusTrap(element, {
103 | allowOutsideClick: true,
104 | escapeDeactivates: true,
105 | returnFocusOnDeactivate: true,
106 | onDeactivate() {
107 | onClose?.();
108 | },
109 | });
110 | }
111 |
112 | const elementRect = element.getBoundingClientRect();
113 | const parentRect = element.parentElement?.getBoundingClientRect();
114 | if (!parentRect) {
115 | throw new Error("Parent element not found");
116 | }
117 | if (
118 | elementRect.left < parentRect.left ||
119 | elementRect.right > parentRect.right ||
120 | element.clientWidth === 0
121 | ) {
122 | element.setAttribute("data-hidden", "");
123 | if (element.focusTrap?.active) {
124 | element.focusTrap.deactivate();
125 | }
126 | } else {
127 | element.removeAttribute("data-hidden");
128 | if (window.innerWidth < BREAKPOINT) {
129 | if (element.focusTrap && !element.focusTrap.active) {
130 | element.focusTrap.activate();
131 | }
132 | }
133 | }
134 | }, [onClose]);
135 |
136 | return elementRef;
137 | }
138 |
139 | function handleTransitionStart(event: React.TransitionEvent) {
140 | event.currentTarget.removeAttribute("data-hidden");
141 | }
142 |
143 | function handleTransitionEnd(
144 | event: React.TransitionEvent<
145 | HTMLDivElement & { focusTrap?: ReturnType }
146 | >
147 | ) {
148 | const element = event.currentTarget;
149 | const elementRect = element.getBoundingClientRect();
150 | const parentRect = element.parentElement?.getBoundingClientRect();
151 | if (!parentRect) {
152 | throw new Error("Parent element not found");
153 | }
154 |
155 | if (
156 | elementRect.left < parentRect.left ||
157 | elementRect.right > parentRect.right ||
158 | element.clientWidth === 0
159 | ) {
160 | element.setAttribute("data-hidden", "");
161 | if (element.focusTrap?.activate) {
162 | element.focusTrap.deactivate();
163 | }
164 | } else {
165 | element.removeAttribute("data-hidden");
166 | if (window.innerWidth < BREAKPOINT) {
167 | if (element.focusTrap && !element.focusTrap.active) {
168 | element.focusTrap.activate();
169 | }
170 | }
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/src/app/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "~/lib/utils";
2 |
3 | export type TextareaProps = React.ComponentProps<"textarea">;
4 |
5 | export function Textarea({ className, ...props }: TextareaProps) {
6 | return (
7 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/components/ui/validated-form.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import {
3 | createContext,
4 | use,
5 | useCallback,
6 | useEffect,
7 | useId,
8 | useMemo,
9 | useState,
10 | } from "react";
11 | import * as v from "valibot";
12 |
13 | import { Checkbox } from "~/components/ui/checkbox";
14 | import { Input } from "~/components/ui/input";
15 | import { Select } from "~/components/ui/select";
16 | import { Textarea } from "~/components/ui/textarea";
17 |
18 | type FormState = {
19 | // biome-ignore lint/suspicious/noExplicitAny: bla bla bla
20 | issues?: v.FlatErrors;
21 | reflowListeners: Set<() => void>;
22 | };
23 |
24 | const formContext = createContext({
25 | id: null as null | string,
26 | forms: {} as Record,
27 | });
28 |
29 | export function useFormState(_id?: string) {
30 | const [, reflow] = useState({});
31 | const ctx = use(formContext);
32 | const id = typeof _id === "string" ? _id : ctx.id;
33 | const form = typeof id === "string" ? ctx.forms[id] : null;
34 | if (form == null) {
35 | throw new Error("Form state not found");
36 | }
37 |
38 | useEffect(() => {
39 | const callback = () => reflow({});
40 | form.reflowListeners.add(callback);
41 | return () => {
42 | form.reflowListeners.delete(callback);
43 | };
44 | }, [form]);
45 |
46 | return form;
47 | }
48 |
49 | export type ValidateEvent = "blur" | "change" | "submit";
50 |
51 | export type ValidatedFormProps<
52 | TEntries extends v.ObjectEntries,
53 | TMessage extends v.ErrorMessage | undefined
54 | > = React.ComponentProps<"form"> & {
55 | asChild?: boolean;
56 | initialIssues?: v.FlatErrors;
57 | schema:
58 | | v.ObjectSchema
59 | | v.SchemaWithPipe<[v.ObjectSchema, ...rest: any[]]>;
60 | validateOn?: ValidateEvent | ValidateEvent[];
61 | };
62 |
63 | export function ValidatedForm<
64 | TEntries extends v.ObjectEntries,
65 | TMessage extends v.ErrorMessage | undefined
66 | >({
67 | asChild,
68 | id: _id,
69 | initialIssues,
70 | onBlur,
71 | onChange,
72 | onSubmit,
73 | schema,
74 | validateOn = "submit",
75 | ...props
76 | }: ValidatedFormProps) {
77 | const Comp = asChild ? Slot : "form";
78 | const __id = useId();
79 | const id = typeof _id === "string" ? _id : __id;
80 |
81 | const ctx = use(formContext);
82 |
83 | const instanceCtx = useMemo(
84 | () => ({
85 | ...ctx,
86 | id,
87 | }),
88 | [id, ctx]
89 | );
90 | if (!instanceCtx.forms[id]) {
91 | instanceCtx.forms[id] = {
92 | issues: initialIssues,
93 | reflowListeners: new Set(),
94 | };
95 | }
96 | if (instanceCtx.forms[id].issues !== initialIssues) {
97 | instanceCtx.forms[id].issues = initialIssues;
98 | }
99 |
100 | const formState = instanceCtx.forms[id];
101 |
102 | const reflow = useCallback(() => {
103 | for (const listener of formState.reflowListeners) {
104 | listener();
105 | }
106 | }, [formState]);
107 |
108 | const validateForm = useCallback(
109 | (event: React.FormEvent) => {
110 | const form = event.currentTarget;
111 |
112 | const formData = new FormData(
113 | form,
114 | (event.nativeEvent as unknown as SubmitEvent).submitter
115 | );
116 |
117 | const parsed = v.safeParse(
118 | schema,
119 | Object.fromEntries(formData.entries())
120 | );
121 | if (!parsed.success) {
122 | event.preventDefault();
123 | formState.issues = v.flatten(parsed.issues);
124 | reflow();
125 | return;
126 | }
127 |
128 | formState.issues = undefined;
129 | reflow();
130 | },
131 | [formState, schema, reflow]
132 | );
133 |
134 | return (
135 |
136 | >(
139 | (event) => {
140 | if (validateOn === "blur" || validateOn.includes("blur")) {
141 | validateForm(event);
142 | }
143 |
144 | if (onBlur) {
145 | onBlur(event);
146 | }
147 | },
148 | [onBlur, validateForm, validateOn]
149 | )}
150 | onChange={useCallback>(
151 | (event) => {
152 | if (validateOn === "change" || validateOn.includes("change")) {
153 | validateForm(event);
154 | }
155 |
156 | if (onChange) {
157 | onChange(event);
158 | }
159 | },
160 | [onChange, validateForm, validateOn]
161 | )}
162 | onSubmit={useCallback>(
163 | (event) => {
164 | if (validateOn === "submit" || validateOn.includes("submit")) {
165 | validateForm(event);
166 | }
167 |
168 | if (onSubmit) {
169 | onSubmit(event);
170 | }
171 | },
172 | [onSubmit, validateForm, validateOn]
173 | )}
174 | {...props}
175 | />
176 |
177 | );
178 | }
179 |
180 | export type ValidatedCheckboxProps = React.ComponentProps & {
181 | label: React.ReactNode;
182 | };
183 |
184 | export function ValidatedCheckbox({
185 | form: formId,
186 | id: _id,
187 | label,
188 | name,
189 | ...props
190 | }: ValidatedCheckboxProps) {
191 | const __id = useId();
192 | const errorId = useId();
193 |
194 | const id = typeof _id === "string" ? _id : __id;
195 |
196 | const form = useFormState(formId);
197 |
198 | const errors = form.issues?.nested?.[name as keyof typeof form.issues.nested];
199 |
200 | return (
201 |
202 |
203 |
210 |
213 |
214 | {!!errors?.length && (
215 |
220 | {errors.join(" ")}
221 |
222 | )}
223 |
224 | );
225 | }
226 |
227 | export type ValidatedInputProps = React.ComponentProps & {
228 | label: React.ReactNode;
229 | };
230 |
231 | export function ValidatedInput({
232 | form: formId,
233 | id: _id,
234 | label,
235 | name,
236 | ...props
237 | }: ValidatedInputProps) {
238 | const __id = useId();
239 | const errorId = useId();
240 |
241 | const id = typeof _id === "string" ? _id : __id;
242 |
243 | const form = useFormState(formId);
244 |
245 | const errors = form.issues?.nested?.[name as keyof typeof form.issues.nested];
246 |
247 | return (
248 |
249 |
250 |
257 | {!!errors?.length && (
258 |
263 | {errors.join(" ")}
264 |
265 | )}
266 |
267 | );
268 | }
269 |
270 | export type ValidatedSelectProps = React.ComponentProps & {
271 | label: React.ReactNode;
272 | };
273 |
274 | export function ValidatedSelect({
275 | form: formId,
276 | id: _id,
277 | label,
278 | name,
279 | ...props
280 | }: ValidatedSelectProps) {
281 | const __id = useId();
282 | const errorId = useId();
283 |
284 | const id = typeof _id === "string" ? _id : __id;
285 |
286 | const form = useFormState(formId);
287 |
288 | const errors = form.issues?.nested?.[name as keyof typeof form.issues.nested];
289 |
290 | return (
291 |
292 |
293 |
300 | {!!errors?.length && (
301 |
306 | {errors.join(" ")}
307 |
308 | )}
309 |
310 | );
311 | }
312 |
313 | export type ValidatedTextareaProps = React.ComponentProps & {
314 | label: React.ReactNode;
315 | };
316 |
317 | export function ValidatedTextarea({
318 | form: formId,
319 | id: _id,
320 | label,
321 | name,
322 | ...props
323 | }: ValidatedTextareaProps) {
324 | const __id = useId();
325 | const errorId = useId();
326 |
327 | const id = typeof _id === "string" ? _id : __id;
328 |
329 | const form = useFormState(formId);
330 |
331 | const errors = form.issues?.nested?.[name as keyof typeof form.issues.nested];
332 |
333 | return (
334 |
335 |
336 |
343 | {!!errors?.length && (
344 |
349 | {errors.join(" ")}
350 |
351 | )}
352 |
353 | );
354 | }
355 |
--------------------------------------------------------------------------------
/src/app/global-actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { destoryCookieSession } from "framework/server";
4 |
5 | export function logoutAction() {
6 | destoryCookieSession();
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 | import type { ClassValue } from "clsx";
4 |
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs));
7 | }
8 |
9 | export function randomId() {
10 | const bytes = new Uint8Array(8);
11 | crypto.getRandomValues(bytes);
12 | return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
13 | ""
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/login/login.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTransition } from "react";
4 | import * as v from "valibot";
5 |
6 | import { Button } from "~/components/ui/button";
7 | import { GlobalLoader } from "~/components/ui/global-loader";
8 | import { ValidatedForm, ValidatedInput } from "~/components/ui/validated-form";
9 |
10 | import { LoginSchema } from "./login.shared";
11 |
12 | export function LoginForm({
13 | initialEmail,
14 | initialIssues,
15 | login,
16 | }: {
17 | initialEmail?: string;
18 | initialIssues?: v.FlatErrors;
19 | login: (formData: FormData) => Promise;
20 | }) {
21 | const [loggingIn, startLoggingIn] = useTransition();
22 |
23 | return (
24 | <>
25 |
26 | {
33 | if (event.defaultPrevented) return;
34 | event.preventDefault();
35 |
36 | if (loggingIn) return;
37 |
38 | const formData = new FormData(event.currentTarget);
39 | startLoggingIn(async () => {
40 | await login(formData);
41 | });
42 | }}
43 | >
44 |
53 |
54 |
61 |
62 |
63 |
64 |
65 | Don't have an account?{" "}
66 |
67 | Signup here.
68 |
69 |
70 |
71 | >
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/src/app/login/login.shared.ts:
--------------------------------------------------------------------------------
1 | import * as v from "valibot";
2 |
3 | const invalidEmailMessage = "Invalid email address.";
4 | const invalidPasswordMessage = "Password is required.";
5 |
6 | export const LoginSchema = v.object({
7 | email: v.pipe(v.string(), v.email(invalidEmailMessage)),
8 | password: v.pipe(v.string(), v.nonEmpty(invalidPasswordMessage)),
9 | });
10 |
--------------------------------------------------------------------------------
/src/app/login/login.tsx:
--------------------------------------------------------------------------------
1 | import * as bcrypt from "bcrypt-edge";
2 | import * as v from "valibot";
3 |
4 | import {
5 | getActionState,
6 | getCookieSession,
7 | getEnv,
8 | redirect,
9 | setActionState,
10 | setCookieSession,
11 | setStatus,
12 | } from "framework/server";
13 |
14 | import { LoginForm } from "./login.client";
15 | import { LoginSchema } from "./login.shared";
16 |
17 | export default function Login() {
18 | type LoginState = {
19 | initialEmail?: string;
20 | initialIssues?: v.FlatErrors;
21 | };
22 | const { initialEmail, initialIssues } =
23 | getActionState("login") ?? {};
24 |
25 | const loggedIn = !!getCookieSession("userId");
26 |
27 | if (loggedIn) {
28 | return redirect("/todo");
29 | }
30 |
31 | return (
32 |
33 |
34 |
Login
35 |
36 | {
40 | "use server";
41 |
42 | const { USERS } = getEnv();
43 |
44 | const parsed = v.safeParse(LoginSchema, Object.fromEntries(formData));
45 | if (!parsed.success) {
46 | const rawEmail = formData.get("email");
47 |
48 | setStatus(400);
49 | setActionState("login", {
50 | initialEmail: typeof rawEmail === "string" ? rawEmail : undefined,
51 | initialIssues: v.flatten(parsed.issues),
52 | });
53 | return;
54 | }
55 |
56 | const { email, password } = parsed.output;
57 |
58 | const [hashedPassword, userId] = await Promise.all([
59 | USERS.get(`hashedPassword:${email}`),
60 | USERS.get(`userId:${email}`),
61 | ]);
62 |
63 | if (
64 | !hashedPassword ||
65 | !userId ||
66 | !bcrypt.compareSync(password, hashedPassword)
67 | ) {
68 | setStatus(401);
69 | setActionState("login", {
70 | initialEmail: email,
71 | initialIssues: {
72 | nested: { email: ["Invalid email address or password."] },
73 | },
74 | });
75 | return;
76 | }
77 |
78 | setCookieSession("userId", userId);
79 | redirect("/todo");
80 | }}
81 | />
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/src/app/signup/signup.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTransition } from "react";
4 | import * as v from "valibot";
5 |
6 | import { Button } from "~/components/ui/button";
7 | import { GlobalLoader } from "~/components/ui/global-loader";
8 | import { ValidatedForm, ValidatedInput } from "~/components/ui/validated-form";
9 |
10 | import { SignupSchema } from "./signup.shared";
11 |
12 | export function SignupForm({
13 | initialEmail,
14 | initialIssues,
15 | signup,
16 | }: {
17 | initialEmail?: string;
18 | initialIssues?: v.FlatErrors;
19 | signup: (formData: FormData) => Promise;
20 | }) {
21 | const [signingUp, startSigningUp] = useTransition();
22 |
23 | return (
24 | <>
25 |
26 | {
33 | if (event.defaultPrevented) return;
34 | event.preventDefault();
35 |
36 | if (signingUp) return;
37 |
38 | const formData = new FormData(event.currentTarget);
39 | startSigningUp(async () => {
40 | await signup(formData);
41 | });
42 | }}
43 | >
44 |
53 |
54 |
61 |
62 |
69 |
70 |
71 |
72 |
73 | Already have an account?{" "}
74 |
75 | Login here.
76 |
77 |
78 |
79 | >
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/src/app/signup/signup.shared.ts:
--------------------------------------------------------------------------------
1 | import * as v from "valibot";
2 |
3 | const invalidEmailMessage = "Invalid email address.";
4 | const invalidPasswordMessage = "Password is required.";
5 | const invalidVerifyPasswordMessage = "Passwords do not match.";
6 |
7 | export const SignupSchema = v.pipe(
8 | v.object({
9 | email: v.pipe(v.string(), v.email(invalidEmailMessage)),
10 | password: v.pipe(v.string(), v.nonEmpty(invalidPasswordMessage)),
11 | verifyPassword: v.pipe(v.string(), v.nonEmpty(invalidPasswordMessage)),
12 | }),
13 | v.forward(
14 | v.check(
15 | ({ password, verifyPassword }) => password === verifyPassword,
16 | invalidVerifyPasswordMessage
17 | ),
18 | ["verifyPassword"]
19 | )
20 | );
21 |
--------------------------------------------------------------------------------
/src/app/signup/signup.tsx:
--------------------------------------------------------------------------------
1 | import * as bcrypt from "bcrypt-edge";
2 | import * as v from "valibot";
3 |
4 | import {
5 | getActionState,
6 | getCookieSession,
7 | getEnv,
8 | redirect,
9 | setActionState,
10 | setCookieSession,
11 | setStatus,
12 | } from "framework/server";
13 |
14 | import { SignupForm } from "./signup.client";
15 | import { SignupSchema } from "./signup.shared";
16 |
17 | export default function Signup() {
18 | type SignupState = {
19 | initialEmail?: string;
20 | initialIssues?: v.FlatErrors;
21 | };
22 | const { initialEmail, initialIssues } =
23 | getActionState("signup") ?? {};
24 |
25 | const loggedIn = !!getCookieSession("userId");
26 |
27 | if (loggedIn) {
28 | return redirect("/todo");
29 | }
30 |
31 | return (
32 |
33 |
34 |
Signup
35 |
36 | {
40 | "use server";
41 |
42 | const { USERS } = getEnv();
43 |
44 | const parsed = v.safeParse(
45 | SignupSchema,
46 | Object.fromEntries(formData)
47 | );
48 | if (!parsed.success) {
49 | const rawEmail = formData.get("email");
50 |
51 | setStatus(400);
52 | setActionState("signup", {
53 | initialEmail: typeof rawEmail === "string" ? rawEmail : undefined,
54 | initialIssues: v.flatten(parsed.issues),
55 | });
56 | return;
57 | }
58 |
59 | const { email, password } = parsed.output;
60 |
61 | const existingUserId = await USERS.get(`userId:${email}`);
62 |
63 | if (existingUserId) {
64 | setStatus(401);
65 | setActionState("signup", {
66 | initialEmail: email,
67 | initialIssues: {
68 | nested: { email: ["Invalid email address or password."] },
69 | },
70 | });
71 | return;
72 | }
73 |
74 | const userId = crypto.randomUUID();
75 | await Promise.all([
76 | USERS.put(`hashedPassword:${email}`, bcrypt.hashSync(password, 12)),
77 | USERS.put(`userId:${email}`, userId),
78 | ]);
79 |
80 | setCookieSession("userId", userId);
81 | redirect("/todo");
82 | }}
83 | />
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/src/app/todo/todo.client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Check, Loader, PanelLeftClose, PanelLeftOpen } from "lucide-react";
4 | import {
5 | useActionState,
6 | useCallback,
7 | useOptimistic,
8 | useState,
9 | useTransition,
10 | } from "react";
11 | import { requestFormReset } from "react-dom";
12 | import * as v from "valibot";
13 |
14 | import { Button } from "~/components/ui/button";
15 | import { GlobalLoader } from "~/components/ui/global-loader";
16 | import {
17 | Sidebar,
18 | SidebarContainer,
19 | SidebarMain,
20 | } from "~/components/ui/sidebar";
21 | import { ValidatedForm, ValidatedInput } from "~/components/ui/validated-form";
22 | import { logoutAction } from "~/global-actions";
23 | import { cn } from "~/lib/utils";
24 |
25 | import { AddTodoSchema, CreateTodoListSchema } from "./todo.shared";
26 |
27 | export function Layout({
28 | children,
29 | sidebar,
30 | }: {
31 | children: React.ReactNode;
32 | sidebar: React.ReactNode;
33 | }) {
34 | const [sidebarOpen, setSidebarOpen] = useState(
35 | undefined
36 | );
37 | const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
38 | const closeSidebar = useCallback(() => setSidebarOpen(false), []);
39 |
40 | return (
41 |
42 |
43 |
50 |
51 |
55 |
56 | {sidebar}
57 |
58 |
59 |
64 |
72 |
80 |
81 |
82 |
83 |
86 |
87 | {children}
88 |
89 |
90 |
91 | );
92 | }
93 |
94 | export function TodoListItem({
95 | delete: _delete,
96 | id,
97 | title,
98 | }: {
99 | delete: () => void | Promise;
100 | id: string;
101 | title: string;
102 | }) {
103 | const [deleted, deleteAction, isDeleting] = useActionState(async () => {
104 | await _delete();
105 | return true;
106 | }, false);
107 |
108 | if (deleted) return null;
109 |
110 | if (isDeleting) {
111 | return (
112 |
113 |
114 | Deleting {title}
115 |
116 | );
117 | }
118 |
119 | return (
120 |
121 |
122 | {title}
123 |
124 |
129 |
130 | );
131 | }
132 |
133 | export function AddTodoForm({
134 | add,
135 | initialIssues,
136 | }: {
137 | initialText?: string;
138 | initialIssues?: v.FlatErrors;
139 | add: (formData: FormData) => Promise;
140 | }) {
141 | const [transitioning, startTransition] = useTransition();
142 |
143 | return (
144 | {
151 | if (event.defaultPrevented) return;
152 | event.preventDefault();
153 |
154 | if (transitioning) return;
155 |
156 | const form = event.currentTarget;
157 | const formData = new FormData(form);
158 | startTransition(async () => {
159 | await add(formData);
160 | startTransition(() => {
161 | requestFormReset(form);
162 | });
163 | });
164 | }}
165 | >
166 |
173 |
174 |
175 |
176 | );
177 | }
178 |
179 | export function CreateTodoListForm({
180 | create,
181 | initialIssues,
182 | }: {
183 | initialTitle?: string;
184 | initialIssues?: v.FlatErrors;
185 | create: (formData: FormData) => Promise;
186 | }) {
187 | const [transitioning, startTransition] = useTransition();
188 |
189 | return (
190 | {
197 | if (event.defaultPrevented) return;
198 | event.preventDefault();
199 |
200 | if (transitioning) return;
201 |
202 | const form = event.currentTarget;
203 | const formData = new FormData(form);
204 | startTransition(async () => {
205 | await create(formData);
206 | startTransition(() => {
207 | requestFormReset(form);
208 | });
209 | });
210 | }}
211 | >
212 |
219 |
220 |
221 |
222 | );
223 | }
224 |
225 | export function TodoItem({
226 | completed: _completed,
227 | text,
228 | delete: _delete,
229 | toggle,
230 | }: {
231 | completed: boolean;
232 | text: string;
233 | delete: () => void | Promise;
234 | toggle: () => void | Promise;
235 | }) {
236 | const [deleted, deleteAction, isDeleting] = useActionState(async () => {
237 | await _delete();
238 | return true;
239 | }, false);
240 | const [completed, optimisticToggle] = useOptimistic(
241 | _completed,
242 | (completed) => {
243 | return !completed;
244 | }
245 | );
246 | const [isToggling, startToggling] = useTransition();
247 |
248 | if (deleted) return null;
249 |
250 | if (isDeleting) {
251 | return (
252 |
253 |
254 | Deleting {text}
255 |
256 | );
257 | }
258 |
259 | return (
260 |
261 |
262 |
290 | {text}
291 |
303 |
304 | );
305 | }
306 |
--------------------------------------------------------------------------------
/src/app/todo/todo.shared.ts:
--------------------------------------------------------------------------------
1 | import * as v from "valibot";
2 |
3 | export const AddTodoSchema = v.object({
4 | text: v.pipe(v.string(), v.trim(), v.nonEmpty("Text is required")),
5 | });
6 |
7 | export const CreateTodoListSchema = v.object({
8 | title: v.pipe(v.string(), v.trim(), v.nonEmpty("Title is required")),
9 | });
10 |
--------------------------------------------------------------------------------
/src/app/todo/todo.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 | import * as v from "valibot";
3 |
4 | import {
5 | getActionState,
6 | getCookieSession,
7 | getEnv,
8 | getURL,
9 | redirect,
10 | setActionState,
11 | waitToFlushUntil,
12 | } from "framework/server";
13 |
14 | import { GlobalLoader } from "~/components/ui/global-loader";
15 | import {
16 | AddTodoForm,
17 | CreateTodoListForm,
18 | Layout,
19 | TodoItem,
20 | TodoListItem,
21 | } from "./todo.client";
22 | import { AddTodoSchema, CreateTodoListSchema } from "./todo.shared";
23 |
24 | export default async function TodoRoute() {
25 | const { TODO_LIST, USER } = getEnv();
26 | const url = getURL();
27 | const userId = getCookieSession("userId");
28 |
29 | if (!userId) {
30 | return redirect("/");
31 | }
32 |
33 | const todoListId = url.pathname.replace(/^\/todo\//, "").split("/")[0];
34 |
35 | const [todoList, todos] = await waitToFlushUntil(async () => {
36 | const todoList = todoListId
37 | ? USER.get(USER.idFromName(userId)).getTodoList({ id: todoListId })
38 | : null;
39 |
40 | const todoListApi = todoListId
41 | ? TODO_LIST.get(TODO_LIST.idFromName(todoListId))
42 | : null;
43 | const todos = todoListApi ? todoListApi.listTodos() : null;
44 |
45 | return Promise.all([todoList, todos]);
46 | });
47 |
48 | if (!todoList && todoListId) {
49 | return redirect("/todo");
50 | }
51 |
52 | const todoLists = ;
53 |
54 | return (
55 | <>
56 | {todoList ? `${todoList.title} | TODO` : "TODO"}
57 |
60 |
61 | All Lists
62 |
63 | {todoLists}
64 |
65 | }
66 | >
67 |
68 | {todoList ? (
69 | //
70 |
71 |
{todoList.title}
72 |
73 |
>("add-todo")}
75 | add={async (formData) => {
76 | "use server";
77 |
78 | const parsed = v.safeParse(
79 | AddTodoSchema,
80 | Object.fromEntries(formData)
81 | );
82 |
83 | if (!parsed.success) {
84 | setActionState("add-todo", v.flatten(parsed.issues));
85 | return;
86 | }
87 |
88 | const { text } = parsed.output;
89 |
90 | const { TODO_LIST } = getEnv();
91 | const todoListApi = TODO_LIST.get(
92 | TODO_LIST.idFromName(todoListId!)
93 | );
94 | await todoListApi.addTodo({ text });
95 | }}
96 | />
97 |
98 |
99 | {todos?.map(({ id, text, completed }) => (
100 | {
105 | "use server";
106 | const { TODO_LIST } = getEnv();
107 | const todoListApi = TODO_LIST.get(
108 | TODO_LIST.idFromName(todoListId!)
109 | );
110 | await todoListApi.deleteTodo({ id });
111 | }}
112 | toggle={async () => {
113 | "use server";
114 | const { TODO_LIST } = getEnv();
115 | const todoListApi = TODO_LIST.get(
116 | TODO_LIST.idFromName(todoListId!)
117 | );
118 | await todoListApi.updateTodo({
119 | id,
120 | completed: !completed,
121 | });
122 | }}
123 | />
124 | ))}
125 |
126 |
127 | ) : (
128 |
129 | >(
131 | "create-todo-list"
132 | )}
133 | create={async (formData) => {
134 | "use server";
135 |
136 | const parsed = v.safeParse(
137 | CreateTodoListSchema,
138 | Object.fromEntries(formData)
139 | );
140 | if (!parsed.success) {
141 | setActionState(
142 | "create-todo-list",
143 | v.flatten(parsed.issues)
144 | );
145 | return;
146 | }
147 |
148 | const { title } = parsed.output;
149 |
150 | const { USER } = getEnv();
151 | const userApi = USER.get(USER.idFromName(userId));
152 | const todoList = await userApi.addTodoList({ title });
153 | redirect(`/todo/${todoList.id}`);
154 | }}
155 | />
156 |
157 | {todoLists}
158 |
159 | )}
160 |
161 |
162 | >
163 | );
164 | }
165 |
166 | async function TodoLists({ userId }: { userId: string }) {
167 | const { USER } = getEnv();
168 | const userApi = USER.get(USER.idFromName(userId));
169 |
170 | const todoLists = await userApi.listTodoLists();
171 | return todoLists.length > 0 ? (
172 |
173 | {todoLists.map(({ id, title }) => {
174 | return (
175 | {
180 | "use server";
181 | const { USER } = getEnv();
182 | const userApi = USER.get(USER.idFromName(userId));
183 | await userApi.deleteTodoList({ id });
184 | redirect("/todo");
185 | }}
186 | />
187 | );
188 | })}
189 |
190 | ) : (
191 | No lists yet.
192 | );
193 | }
194 |
--------------------------------------------------------------------------------
/src/browser/entry.browser.tsx:
--------------------------------------------------------------------------------
1 | import { hydrateApp } from 'framework/browser';
2 |
3 | hydrateApp();
4 |
--------------------------------------------------------------------------------
/src/framework/browser.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createFromFetch,
3 | createFromReadableStream,
4 | // @ts-expect-error - no types yet
5 | } from "@jacob-ebey/react-server-dom-vite/client";
6 | import {
7 | startTransition,
8 | StrictMode,
9 | use,
10 | useCallback,
11 | useEffect,
12 | useMemo,
13 | useState,
14 | useSyncExternalStore,
15 | useTransition,
16 | } from "react";
17 | import { hydrateRoot } from "react-dom/client";
18 | import { rscStream } from "rsc-html-stream/client";
19 | // @ts-expect-error - no types yet
20 | import { manifest } from "virtual:react-manifest";
21 |
22 | import { UNSAFE_RouterContext, type RouterContext } from "./client.js";
23 | import { api, callServer } from "./references.browser.js";
24 | import type { UNSAFE_ServerPayload } from "./server.js";
25 |
26 | function getLocationSnapshot() {
27 | return window.location.pathname + window.location.search;
28 | }
29 |
30 | function locationSubscribe(callback: () => void) {
31 | if (window.navigation) {
32 | window.navigation.addEventListener("navigate", callback);
33 | return () => {
34 | window.navigation.removeEventListener("navigate", callback);
35 | };
36 | }
37 |
38 | let current = window.location.href;
39 | let aborted = false;
40 | const interval = setInterval(() => {
41 | if (current !== window.location.href && !aborted) {
42 | current = window.location.href;
43 | callback();
44 | }
45 | }, 500);
46 | return () => {
47 | aborted = true;
48 | clearInterval(interval);
49 | };
50 | }
51 |
52 | function Shell({ payload }: { payload: Promise }) {
53 | const [promise, setPayload] = useState(payload);
54 | const [navigating, startNavigation] = useTransition();
55 |
56 | const { location, root } = use(promise);
57 |
58 | api.updatePayload = useCallback<
59 | React.Dispatch>>
60 | >((payload) => {
61 | startNavigation(() => {
62 | setPayload(payload);
63 | });
64 | }, []);
65 |
66 | const windowLocation = useSyncExternalStore(
67 | locationSubscribe,
68 | getLocationSnapshot,
69 | () => location.pathname + location.search
70 | );
71 |
72 | useEffect(() => {
73 | if (!navigating && location.pathname + location.search !== windowLocation) {
74 | window.history.replaceState(
75 | null,
76 | "",
77 | location.pathname + location.search
78 | );
79 | }
80 | }, [location, windowLocation, navigating]);
81 |
82 | const routerContext = useMemo(
83 | () => ({ location, navigating }),
84 | [location, navigating]
85 | );
86 |
87 | return (
88 |
89 | {root}
90 |
91 | );
92 | }
93 |
94 | export function hydrateApp(container: Element | Document = document) {
95 | const payload: Promise = createFromReadableStream(
96 | rscStream,
97 | manifest,
98 | { callServer }
99 | );
100 |
101 | startTransition(async () => {
102 | hydrateRoot(
103 | container,
104 |
105 |
106 | ,
107 | {
108 | formState: (await payload).formState,
109 | }
110 | );
111 | });
112 |
113 | window.navigation?.addEventListener("navigate", (event) => {
114 | if (
115 | !event.canIntercept ||
116 | event.defaultPrevented ||
117 | event.downloadRequest ||
118 | !event.userInitiated ||
119 | event.navigationType === "reload"
120 | ) {
121 | return;
122 | }
123 |
124 | event.intercept({
125 | async handler() {
126 | const abortController = new AbortController();
127 | let startedTransition = false;
128 | event.signal.addEventListener("abort", () => {
129 | if (startedTransition) return;
130 | abortController.abort();
131 | });
132 | const fetchPromise = fetch(event.destination.url, {
133 | body: event.formData,
134 | headers: {
135 | Accept: "text/x-component",
136 | },
137 | method: event.formData ? "POST" : "GET",
138 | signal: abortController.signal,
139 | });
140 |
141 | const payloadPromise: UNSAFE_ServerPayload = createFromFetch(
142 | fetchPromise,
143 | manifest,
144 | { callServer }
145 | );
146 |
147 | api.updatePayload?.((promise) => {
148 | startedTransition = true;
149 | return Promise.all([promise, payloadPromise]).then(
150 | ([existing, payload]) => ({
151 | ...existing,
152 | ...payload,
153 | })
154 | );
155 | });
156 | },
157 | });
158 | });
159 | }
160 |
--------------------------------------------------------------------------------
/src/framework/client.ts:
--------------------------------------------------------------------------------
1 | import { createContext, use } from "react";
2 |
3 | import type { Location } from "./server";
4 |
5 | export type RouterContext = {
6 | location: Location;
7 | navigating: boolean;
8 | };
9 |
10 | export const UNSAFE_RouterContext = createContext(null);
11 |
12 | function routerContext() {
13 | const ctx = use(UNSAFE_RouterContext);
14 | if (!ctx) {
15 | throw new Error("No router context found");
16 | }
17 | return ctx;
18 | }
19 |
20 | export function useLocation() {
21 | return routerContext().location;
22 | }
23 |
24 | export function useNavigating() {
25 | return routerContext().navigating;
26 | }
27 |
--------------------------------------------------------------------------------
/src/framework/cookie-session.ts:
--------------------------------------------------------------------------------
1 | import { createCookie, isCookie } from "./cookies";
2 | import type {
3 | SessionStorage,
4 | SessionIdStorageStrategy,
5 | SessionData,
6 | } from "./sessions";
7 | import { warnOnceAboutSigningSessionCookie, createSession } from "./sessions";
8 |
9 | export interface CookieSessionStorageOptions {
10 | /**
11 | * The Cookie used to store the session data on the client, or options used
12 | * to automatically create one.
13 | */
14 | cookie?: SessionIdStorageStrategy["cookie"];
15 | }
16 |
17 | /**
18 | * Creates and returns a SessionStorage object that stores all session data
19 | * directly in the session cookie itself.
20 | *
21 | * This has the advantage that no database or other backend services are
22 | * needed, and can help to simplify some load-balanced scenarios. However, it
23 | * also has the limitation that serialized session data may not exceed the
24 | * browser's maximum cookie size. Trade-offs!
25 | */
26 | export function createCookieSessionStorage<
27 | Data = SessionData,
28 | FlashData = Data
29 | >({ cookie: cookieArg }: CookieSessionStorageOptions = {}): SessionStorage<
30 | Data,
31 | FlashData
32 | > {
33 | let cookie = isCookie(cookieArg)
34 | ? cookieArg
35 | : createCookie(cookieArg?.name || "__session", cookieArg);
36 |
37 | warnOnceAboutSigningSessionCookie(cookie);
38 |
39 | return {
40 | async getSession(cookieHeader, options) {
41 | return createSession(
42 | (cookieHeader && (await cookie.parse(cookieHeader, options))) || {}
43 | );
44 | },
45 | async commitSession(session, options) {
46 | let serializedCookie = await cookie.serialize(session.data, options);
47 | if (serializedCookie.length > 4096) {
48 | throw new Error(
49 | "Cookie length will exceed browser maximum. Length: " +
50 | serializedCookie.length
51 | );
52 | }
53 | return serializedCookie;
54 | },
55 | async destroySession(_session, options) {
56 | return cookie.serialize("", {
57 | ...options,
58 | maxAge: undefined,
59 | expires: new Date(0),
60 | });
61 | },
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/src/framework/cookies.ts:
--------------------------------------------------------------------------------
1 | import type { ParseOptions, SerializeOptions } from "cookie";
2 | import { parse, serialize } from "cookie";
3 |
4 | import { sign, unsign } from "./crypto";
5 | import { warnOnce } from "./warnings";
6 |
7 | export type {
8 | ParseOptions as CookieParseOptions,
9 | SerializeOptions as CookieSerializeOptions,
10 | };
11 |
12 | export interface CookieSignatureOptions {
13 | /**
14 | * An array of secrets that may be used to sign/unsign the value of a cookie.
15 | *
16 | * The array makes it easy to rotate secrets. New secrets should be added to
17 | * the beginning of the array. `cookie.serialize()` will always use the first
18 | * value in the array, but `cookie.parse()` may use any of them so that
19 | * cookies that were signed with older secrets still work.
20 | */
21 | secrets?: string[];
22 | }
23 |
24 | export type CookieOptions = ParseOptions &
25 | SerializeOptions &
26 | CookieSignatureOptions;
27 |
28 | /**
29 | * A HTTP cookie.
30 | *
31 | * A Cookie is a logical container for metadata about a HTTP cookie; its name
32 | * and options. But it doesn't contain a value. Instead, it has `parse()` and
33 | * `serialize()` methods that allow a single instance to be reused for
34 | * parsing/encoding multiple different values.
35 | *
36 | * @see https://remix.run/utils/cookies#cookie-api
37 | */
38 | export interface Cookie {
39 | /**
40 | * The name of the cookie, used in the `Cookie` and `Set-Cookie` headers.
41 | */
42 | readonly name: string;
43 |
44 | /**
45 | * True if this cookie uses one or more secrets for verification.
46 | */
47 | readonly isSigned: boolean;
48 |
49 | /**
50 | * The Date this cookie expires.
51 | *
52 | * Note: This is calculated at access time using `maxAge` when no `expires`
53 | * option is provided to `createCookie()`.
54 | */
55 | readonly expires?: Date;
56 |
57 | /**
58 | * Parses a raw `Cookie` header and returns the value of this cookie or
59 | * `null` if it's not present.
60 | */
61 | parse(cookieHeader: string | null, options?: ParseOptions): Promise;
62 |
63 | /**
64 | * Serializes the given value to a string and returns the `Set-Cookie`
65 | * header.
66 | */
67 | serialize(value: any, options?: SerializeOptions): Promise;
68 | }
69 |
70 | /**
71 | * Creates a logical container for managing a browser cookie from the server.
72 | */
73 | export const createCookie = (
74 | name: string,
75 | cookieOptions: CookieOptions = {}
76 | ): Cookie => {
77 | let { secrets = [], ...options } = {
78 | path: "/",
79 | sameSite: "lax" as const,
80 | ...cookieOptions,
81 | };
82 |
83 | warnOnceAboutExpiresCookie(name, options.expires);
84 |
85 | return {
86 | get name() {
87 | return name;
88 | },
89 | get isSigned() {
90 | return secrets.length > 0;
91 | },
92 | get expires() {
93 | // Max-Age takes precedence over Expires
94 | return typeof options.maxAge !== "undefined"
95 | ? new Date(Date.now() + options.maxAge * 1000)
96 | : options.expires;
97 | },
98 | async parse(cookieHeader, parseOptions) {
99 | if (!cookieHeader) return null;
100 | let cookies = parse(cookieHeader, { ...options, ...parseOptions });
101 | if (name in cookies) {
102 | let value = cookies[name];
103 | if (typeof value === "string" && value !== "") {
104 | let decoded = await decodeCookieValue(value, secrets);
105 | return decoded;
106 | } else {
107 | return "";
108 | }
109 | } else {
110 | return null;
111 | }
112 | },
113 | async serialize(value, serializeOptions) {
114 | return serialize(
115 | name,
116 | value === "" ? "" : await encodeCookieValue(value, secrets),
117 | {
118 | ...options,
119 | ...serializeOptions,
120 | }
121 | );
122 | },
123 | };
124 | };
125 |
126 | export type IsCookieFunction = (object: any) => object is Cookie;
127 |
128 | /**
129 | * Returns true if an object is a Remix cookie container.
130 | *
131 | * @see https://remix.run/utils/cookies#iscookie
132 | */
133 | export const isCookie: IsCookieFunction = (object): object is Cookie => {
134 | return (
135 | object != null &&
136 | typeof object.name === "string" &&
137 | typeof object.isSigned === "boolean" &&
138 | typeof object.parse === "function" &&
139 | typeof object.serialize === "function"
140 | );
141 | };
142 |
143 | async function encodeCookieValue(
144 | value: any,
145 | secrets: string[]
146 | ): Promise {
147 | let encoded = encodeData(value);
148 |
149 | if (secrets.length > 0) {
150 | encoded = await sign(encoded, secrets[0]!);
151 | }
152 |
153 | return encoded;
154 | }
155 |
156 | async function decodeCookieValue(
157 | value: string,
158 | secrets: string[]
159 | ): Promise {
160 | if (secrets.length > 0) {
161 | for (let secret of secrets) {
162 | let unsignedValue = await unsign(value, secret);
163 | if (unsignedValue !== false) {
164 | return decodeData(unsignedValue);
165 | }
166 | }
167 |
168 | return null;
169 | }
170 |
171 | return decodeData(value);
172 | }
173 |
174 | function encodeData(value: any): string {
175 | return btoa(myUnescape(encodeURIComponent(JSON.stringify(value))));
176 | }
177 |
178 | function decodeData(value: string): any {
179 | try {
180 | return JSON.parse(decodeURIComponent(myEscape(atob(value))));
181 | } catch (error: unknown) {
182 | return {};
183 | }
184 | }
185 |
186 | // See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.escape.js
187 | function myEscape(value: string): string {
188 | let str = value.toString();
189 | let result = "";
190 | let index = 0;
191 | let chr, code;
192 | while (index < str.length) {
193 | chr = str.charAt(index++);
194 | if (/[\w*+\-./@]/.exec(chr)) {
195 | result += chr;
196 | } else {
197 | code = chr.charCodeAt(0);
198 | if (code < 256) {
199 | result += "%" + hex(code, 2);
200 | } else {
201 | result += "%u" + hex(code, 4).toUpperCase();
202 | }
203 | }
204 | }
205 | return result;
206 | }
207 |
208 | function hex(code: number, length: number): string {
209 | let result = code.toString(16);
210 | while (result.length < length) result = "0" + result;
211 | return result;
212 | }
213 |
214 | // See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.unescape.js
215 | function myUnescape(value: string): string {
216 | let str = value.toString();
217 | let result = "";
218 | let index = 0;
219 | let chr, part;
220 | while (index < str.length) {
221 | chr = str.charAt(index++);
222 | if (chr === "%") {
223 | if (str.charAt(index) === "u") {
224 | part = str.slice(index + 1, index + 5);
225 | if (/^[\da-f]{4}$/i.exec(part)) {
226 | result += String.fromCharCode(parseInt(part, 16));
227 | index += 5;
228 | continue;
229 | }
230 | } else {
231 | part = str.slice(index, index + 2);
232 | if (/^[\da-f]{2}$/i.exec(part)) {
233 | result += String.fromCharCode(parseInt(part, 16));
234 | index += 2;
235 | continue;
236 | }
237 | }
238 | }
239 | result += chr;
240 | }
241 | return result;
242 | }
243 |
244 | function warnOnceAboutExpiresCookie(name: string, expires?: Date) {
245 | warnOnce(
246 | !expires,
247 | `The "${name}" cookie has an "expires" property set. ` +
248 | `This will cause the expires value to not be updated when the session is committed. ` +
249 | `Instead, you should set the expires value when serializing the cookie. ` +
250 | `You can use \`commitSession(session, { expires })\` if using a session storage object, ` +
251 | `or \`cookie.serialize("value", { expires })\` if you're using the cookie directly.`
252 | );
253 | }
254 |
--------------------------------------------------------------------------------
/src/framework/crypto.ts:
--------------------------------------------------------------------------------
1 | const encoder = new TextEncoder();
2 |
3 | export const sign = async (value: string, secret: string): Promise => {
4 | let data = encoder.encode(value);
5 | let key = await createKey(secret, ["sign"]);
6 | let signature = await crypto.subtle.sign("HMAC", key, data);
7 | let hash = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(
8 | /=+$/,
9 | ""
10 | );
11 |
12 | return value + "." + hash;
13 | };
14 |
15 | export const unsign = async (
16 | cookie: string,
17 | secret: string
18 | ): Promise => {
19 | let index = cookie.lastIndexOf(".");
20 | let value = cookie.slice(0, index);
21 | let hash = cookie.slice(index + 1);
22 |
23 | let data = encoder.encode(value);
24 |
25 | let key = await createKey(secret, ["verify"]);
26 | let signature = byteStringToUint8Array(atob(hash));
27 | let valid = await crypto.subtle.verify("HMAC", key, signature, data);
28 |
29 | return valid ? value : false;
30 | };
31 |
32 | const createKey = async (
33 | secret: string,
34 | usages: CryptoKey["usages"]
35 | ): Promise =>
36 | crypto.subtle.importKey(
37 | "raw",
38 | encoder.encode(secret),
39 | { name: "HMAC", hash: "SHA-256" },
40 | false,
41 | usages
42 | );
43 |
44 | function byteStringToUint8Array(byteString: string): Uint8Array {
45 | let array = new Uint8Array(byteString.length);
46 |
47 | for (let i = 0; i < byteString.length; i++) {
48 | array[i] = byteString.charCodeAt(i);
49 | }
50 |
51 | return array;
52 | }
53 |
--------------------------------------------------------------------------------
/src/framework/references.browser.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createFromFetch,
3 | createServerReference as createServerReferenceImp,
4 | encodeReply,
5 | // @ts-expect-error - no types yet
6 | } from "@jacob-ebey/react-server-dom-vite/client";
7 | import { startTransition } from "react";
8 | // @ts-expect-error - no types yet
9 | import { manifest } from "virtual:react-manifest";
10 | import type { UNSAFE_ServerPayload } from "./server";
11 |
12 | export const api: {
13 | updatePayload?: React.Dispatch<
14 | React.SetStateAction>
15 | >;
16 | } = {};
17 |
18 | export async function callServer(id: string, args: unknown) {
19 | const fetchPromise = fetch(
20 | new Request(window.location.href, {
21 | method: "POST",
22 | headers: {
23 | Accept: "text/x-component",
24 | "rsc-action": id,
25 | },
26 | body: await encodeReply(args),
27 | })
28 | );
29 |
30 | const payloadPromise: UNSAFE_ServerPayload = createFromFetch(
31 | fetchPromise,
32 | manifest,
33 | {
34 | callServer,
35 | }
36 | );
37 |
38 | api.updatePayload?.((promise) =>
39 | Promise.all([promise, payloadPromise]).then(([existing, payload]) => ({
40 | ...existing,
41 | ...payload,
42 | }))
43 | );
44 |
45 | return (await payloadPromise).returnValue;
46 | }
47 |
48 | export function createServerReference(imp: unknown, id: string, name: string) {
49 | return createServerReferenceImp(`${id}#${name}`, callServer);
50 | }
51 |
--------------------------------------------------------------------------------
/src/framework/references.server.ts:
--------------------------------------------------------------------------------
1 | // @ts-expect-error - no types yet
2 | import RSD from '@jacob-ebey/react-server-dom-vite/server';
3 |
4 | export const registerServerReference = RSD.registerServerReference;
5 | export const registerClientReference = RSD.registerClientReference;
6 |
--------------------------------------------------------------------------------
/src/framework/references.ssr.ts:
--------------------------------------------------------------------------------
1 | // @ts-expect-error - no types yet
2 | import RSD from '@jacob-ebey/react-server-dom-vite/client';
3 |
4 | export const createServerReference = RSD.createServerReference;
5 |
--------------------------------------------------------------------------------
/src/framework/server.ts:
--------------------------------------------------------------------------------
1 | import { AsyncLocalStorage } from "node:async_hooks";
2 | import * as stream from "node:stream";
3 | // @ts-expect-error - no types yet
4 | import RSD from "@jacob-ebey/react-server-dom-vite/server";
5 | // @ts-expect-error - no types yet
6 | import { manifest } from "virtual:react-manifest";
7 | import type React from "react";
8 | import type { ReactFormState } from "react-dom/client";
9 |
10 | import {
11 | createCookieSessionStorage,
12 | type CookieSessionStorageOptions,
13 | } from "./cookie-session";
14 | import type { Session, SessionData } from "./sessions";
15 |
16 | export type Location = {
17 | pathname: string;
18 | search: string;
19 | };
20 |
21 | export type UNSAFE_ServerPayload = {
22 | formState?: ReactFormState;
23 | location: Location;
24 | returnValue?: unknown;
25 | root: React.JSX.Element;
26 | };
27 |
28 | declare global {
29 | interface AppEnvironment {}
30 | }
31 |
32 | export type UNSAFE_Context = {
33 | stage: "action" | "render" | "sent";
34 | actionState?: Record;
35 | destorySession?: true;
36 | env: AppEnvironment;
37 | headers: Headers;
38 | redirect?: string;
39 | session: Session;
40 | status: number;
41 | statusText?: string;
42 | url: URL;
43 | waitToFlushUntil: Promise[];
44 | };
45 |
46 | export const UNSAFE_ContextStorage = new AsyncLocalStorage();
47 |
48 | function ctx() {
49 | const ctx = UNSAFE_ContextStorage.getStore();
50 | if (!ctx) {
51 | throw new Error("No context store found");
52 | }
53 | return ctx;
54 | }
55 |
56 | function ctxActionsOnly() {
57 | const context = ctx();
58 | if (context.stage !== "action") {
59 | throw new Error("Response already sent");
60 | }
61 | return context;
62 | }
63 |
64 | export function getActionState(key: string) {
65 | return ctx().actionState?.[key] as T | undefined;
66 | }
67 |
68 | export function setActionState(key: string, state: T) {
69 | const context = ctx();
70 | context.actionState = {
71 | ...context.actionState,
72 | [key]: state,
73 | };
74 | }
75 |
76 | export function getCookieSession(key: string) {
77 | return ctx().session.get(key) as T | undefined;
78 | }
79 |
80 | export function setCookieSession(key: string, value: T) {
81 | ctxActionsOnly().session.set(key, value);
82 | }
83 |
84 | export function destoryCookieSession() {
85 | const context = ctxActionsOnly();
86 | context.destorySession = true;
87 | for (const key of Object.keys(context.session.data)) {
88 | context.session.unset(key);
89 | }
90 | }
91 |
92 | export function getEnv() {
93 | return ctx().env;
94 | }
95 |
96 | export function getURL() {
97 | return ctx().url;
98 | }
99 |
100 | export function setHeader(key: string, value: string) {
101 | ctxActionsOnly().headers.set(key, value);
102 | }
103 |
104 | export function setStatus(status: number, statusText?: string) {
105 | const context = ctxActionsOnly();
106 | if (context.redirect) {
107 | throw new Error("Cannot set status after redirect");
108 | }
109 | context.status = status;
110 | context.statusText = statusText;
111 | }
112 |
113 | export function redirect(to: string, status?: number): undefined {
114 | const context = ctx();
115 | if (context.stage === "sent") {
116 | throw new Error("TODO: Implement late redirects");
117 | }
118 |
119 | context.status =
120 | typeof status === "number"
121 | ? status
122 | : context.stage === "action"
123 | ? 303
124 | : 307;
125 | context.redirect = to;
126 | }
127 |
128 | export function waitToFlushUntil(
129 | waitFor: Promise | (() => Promise)
130 | ): Promise {
131 | const context = ctx();
132 | if (context.stage === "sent") {
133 | throw new Error("Response already sent");
134 | }
135 |
136 | const promise = typeof waitFor === "function" ? waitFor() : waitFor;
137 |
138 | context.waitToFlushUntil.push(
139 | Promise.resolve(promise).then(
140 | () => {},
141 | () => {}
142 | )
143 | );
144 |
145 | return promise;
146 | }
147 |
148 | export async function renderApp(
149 | request: Request,
150 | cookie: CookieSessionStorageOptions["cookie"],
151 | env: AppEnvironment,
152 | root: React.JSX.Element
153 | ) {
154 | const sessionStorage = createCookieSessionStorage({ cookie });
155 | const session = await sessionStorage.getSession(
156 | request.headers.get("Cookie")
157 | );
158 |
159 | const url = new URL(request.url);
160 |
161 | let _redirect: string | undefined;
162 | let onRedirect: ((to: string) => void) | undefined;
163 | const ctx: UNSAFE_Context = {
164 | stage: "action",
165 | env,
166 | url,
167 | headers: new Headers(),
168 | session,
169 | status: 200,
170 | waitToFlushUntil: [],
171 | get redirect() {
172 | return _redirect;
173 | },
174 | set redirect(to: string | undefined) {
175 | _redirect = to;
176 | if (to && onRedirect) onRedirect(to);
177 | },
178 | };
179 |
180 | return UNSAFE_ContextStorage.run(ctx, async () => {
181 | let formState: ReactFormState | undefined;
182 | let returnValue: unknown | undefined;
183 |
184 | const actionId = request.headers.get("rsc-action");
185 | try {
186 | if (actionId) {
187 | const reference = manifest.resolveServerReference(actionId);
188 | await reference.preload();
189 | const action = reference.get() as ((...args: unknown[]) => unknown) & {
190 | $$typeof: symbol;
191 | };
192 | if (action.$$typeof !== Symbol.for("react.server.reference")) {
193 | throw new Error("Invalid action");
194 | }
195 |
196 | const body = request.headers
197 | .get("Content-Type")
198 | ?.match(/^multipart\/form-data/)
199 | ? await request.formData()
200 | : await request.text();
201 | const args = await RSD.decodeReply(body, manifest);
202 |
203 | returnValue = action.apply(null, args);
204 | try {
205 | await returnValue;
206 | } catch {}
207 | } else if (request.method === "POST") {
208 | const formData = await request.formData();
209 | const action = await RSD.decodeAction(formData, manifest);
210 | formState = await RSD.decodeFormState(
211 | await action(),
212 | formData,
213 | manifest
214 | );
215 | }
216 | } catch (error) {
217 | console.error(error);
218 | }
219 |
220 | if (ctx.redirect) {
221 | const headers = new Headers(ctx.headers);
222 | headers.append(
223 | "Set-Cookie",
224 | ctx.destorySession
225 | ? await sessionStorage.destroySession(session)
226 | : await sessionStorage.commitSession(session)
227 | );
228 | headers.set("Location", ctx.redirect);
229 |
230 | return new Response(null, {
231 | status: ctx.status,
232 | statusText: ctx.statusText,
233 | headers,
234 | });
235 | }
236 |
237 | const payload = {
238 | formState,
239 | location: {
240 | pathname: url.pathname,
241 | search: url.search,
242 | },
243 | returnValue,
244 | root,
245 | } satisfies UNSAFE_ServerPayload;
246 |
247 | ctx.stage = "render";
248 | const { abort, pipe } = RSD.renderToPipeableStream(payload, manifest);
249 | request.signal.addEventListener("abort", () => abort());
250 | const body = stream.Readable.toWeb(
251 | pipe(new stream.PassThrough())
252 | ) as ReadableStream;
253 |
254 | do {
255 | while (ctx.waitToFlushUntil.length) {
256 | await ctx.waitToFlushUntil.shift();
257 | }
258 | await new Promise((resolve) => setTimeout(resolve, 0));
259 | } while (ctx.waitToFlushUntil.length);
260 |
261 | ctx.stage = "sent";
262 | const headers = new Headers(ctx.headers);
263 | headers.append(
264 | "Set-Cookie",
265 | ctx.destorySession
266 | ? await sessionStorage.destroySession(session)
267 | : await sessionStorage.commitSession(session)
268 | );
269 | if (ctx.redirect) {
270 | headers.set("Location", ctx.redirect);
271 | return new Response(null, {
272 | status: ctx.status,
273 | statusText: ctx.statusText,
274 | headers,
275 | });
276 | }
277 |
278 | let gotLateRedirect = false;
279 | onRedirect = () => {
280 | gotLateRedirect = true;
281 | };
282 |
283 | headers.set("Content-Type", "text/x-component");
284 | return new Response(
285 | body.pipeThrough(
286 | new TransformStream({
287 | flush(controller) {
288 | if (gotLateRedirect) {
289 | throw new Error("TODO: Implement late redirects");
290 | // controller.enqueue(
291 | // new TextEncoder().encode(
292 | // `\n\n${JSON.stringify({
293 | // redirect: ctx.redirect,
294 | // })}\n`
295 | // )
296 | // );
297 | }
298 | },
299 | })
300 | ),
301 | {
302 | status: ctx.status,
303 | statusText: ctx.statusText,
304 | headers,
305 | }
306 | );
307 | });
308 | }
309 |
--------------------------------------------------------------------------------
/src/framework/sessions.ts:
--------------------------------------------------------------------------------
1 | import type { ParseOptions, SerializeOptions } from "cookie";
2 |
3 | import type { Cookie, CookieOptions } from "./cookies";
4 | import { createCookie, isCookie } from "./cookies";
5 | import { warnOnce } from "./warnings";
6 |
7 | /**
8 | * An object of name/value pairs to be used in the session.
9 | */
10 | export interface SessionData {
11 | [name: string]: any;
12 | }
13 |
14 | /**
15 | * Session persists data across HTTP requests.
16 | *
17 | * @see https://remix.run/utils/sessions#session-api
18 | */
19 | export interface Session {
20 | /**
21 | * A unique identifier for this session.
22 | *
23 | * Note: This will be the empty string for newly created sessions and
24 | * sessions that are not backed by a database (i.e. cookie-based sessions).
25 | */
26 | readonly id: string;
27 |
28 | /**
29 | * The raw data contained in this session.
30 | *
31 | * This is useful mostly for SessionStorage internally to access the raw
32 | * session data to persist.
33 | */
34 | readonly data: FlashSessionData;
35 |
36 | /**
37 | * Returns `true` if the session has a value for the given `name`, `false`
38 | * otherwise.
39 | */
40 | has(name: (keyof Data | keyof FlashData) & string): boolean;
41 |
42 | /**
43 | * Returns the value for the given `name` in this session.
44 | */
45 | get(
46 | name: Key
47 | ):
48 | | (Key extends keyof Data ? Data[Key] : undefined)
49 | | (Key extends keyof FlashData ? FlashData[Key] : undefined)
50 | | undefined;
51 |
52 | /**
53 | * Sets a value in the session for the given `name`.
54 | */
55 | set(name: Key, value: Data[Key]): void;
56 |
57 | /**
58 | * Sets a value in the session that is only valid until the next `get()`.
59 | * This can be useful for temporary values, like error messages.
60 | */
61 | flash(
62 | name: Key,
63 | value: FlashData[Key]
64 | ): void;
65 |
66 | /**
67 | * Removes a value from the session.
68 | */
69 | unset(name: keyof Data & string): void;
70 | }
71 |
72 | export type FlashSessionData = Partial<
73 | Data & {
74 | [Key in keyof FlashData as FlashDataKey]: FlashData[Key];
75 | }
76 | >;
77 | type FlashDataKey = `__flash_${Key}__`;
78 | function flash(name: Key): FlashDataKey {
79 | return `__flash_${name}__`;
80 | }
81 |
82 | export type CreateSessionFunction = (
83 | initialData?: Data,
84 | id?: string
85 | ) => Session;
86 |
87 | /**
88 | * Creates a new Session object.
89 | *
90 | * Note: This function is typically not invoked directly by application code.
91 | * Instead, use a `SessionStorage` object's `getSession` method.
92 | *
93 | * @see https://remix.run/utils/sessions#createsession
94 | */
95 | export const createSession: CreateSessionFunction = <
96 | Data = SessionData,
97 | FlashData = Data
98 | >(
99 | initialData: Partial = {},
100 | id = ""
101 | ): Session => {
102 | let map = new Map(Object.entries(initialData)) as Map<
103 | keyof Data | FlashDataKey,
104 | any
105 | >;
106 |
107 | return {
108 | get id() {
109 | return id;
110 | },
111 | get data() {
112 | return Object.fromEntries(map) as FlashSessionData;
113 | },
114 | has(name) {
115 | return (
116 | map.has(name as keyof Data) ||
117 | map.has(flash(name as keyof FlashData & string))
118 | );
119 | },
120 | get(name) {
121 | if (map.has(name as keyof Data)) return map.get(name as keyof Data);
122 |
123 | let flashName = flash(name as keyof FlashData & string);
124 | if (map.has(flashName)) {
125 | let value = map.get(flashName);
126 | map.delete(flashName);
127 | return value;
128 | }
129 |
130 | return undefined;
131 | },
132 | set(name, value) {
133 | map.set(name, value);
134 | },
135 | flash(name, value) {
136 | map.set(flash(name), value);
137 | },
138 | unset(name) {
139 | map.delete(name);
140 | },
141 | };
142 | };
143 |
144 | export type IsSessionFunction = (object: any) => object is Session;
145 |
146 | /**
147 | * Returns true if an object is a Remix session.
148 | *
149 | * @see https://remix.run/utils/sessions#issession
150 | */
151 | export const isSession: IsSessionFunction = (object): object is Session => {
152 | return (
153 | object != null &&
154 | typeof object.id === "string" &&
155 | typeof object.data !== "undefined" &&
156 | typeof object.has === "function" &&
157 | typeof object.get === "function" &&
158 | typeof object.set === "function" &&
159 | typeof object.flash === "function" &&
160 | typeof object.unset === "function"
161 | );
162 | };
163 |
164 | /**
165 | * SessionStorage stores session data between HTTP requests and knows how to
166 | * parse and create cookies.
167 | *
168 | * A SessionStorage creates Session objects using a `Cookie` header as input.
169 | * Then, later it generates the `Set-Cookie` header to be used in the response.
170 | */
171 | export interface SessionStorage {
172 | /**
173 | * Parses a Cookie header from a HTTP request and returns the associated
174 | * Session. If there is no session associated with the cookie, this will
175 | * return a new Session with no data.
176 | */
177 | getSession: (
178 | cookieHeader?: string | null,
179 | options?: ParseOptions
180 | ) => Promise>;
181 |
182 | /**
183 | * Stores all data in the Session and returns the Set-Cookie header to be
184 | * used in the HTTP response.
185 | */
186 | commitSession: (
187 | session: Session,
188 | options?: SerializeOptions
189 | ) => Promise;
190 |
191 | /**
192 | * Deletes all data associated with the Session and returns the Set-Cookie
193 | * header to be used in the HTTP response.
194 | */
195 | destroySession: (
196 | session: Session,
197 | options?: SerializeOptions
198 | ) => Promise;
199 | }
200 |
201 | /**
202 | * SessionIdStorageStrategy is designed to allow anyone to easily build their
203 | * own SessionStorage using `createSessionStorage(strategy)`.
204 | *
205 | * This strategy describes a common scenario where the session id is stored in
206 | * a cookie but the actual session data is stored elsewhere, usually in a
207 | * database or on disk. A set of create, read, update, and delete operations
208 | * are provided for managing the session data.
209 | */
210 | export interface SessionIdStorageStrategy<
211 | Data = SessionData,
212 | FlashData = Data
213 | > {
214 | /**
215 | * The Cookie used to store the session id, or options used to automatically
216 | * create one.
217 | */
218 | cookie?: Cookie | (CookieOptions & { name?: string });
219 |
220 | /**
221 | * Creates a new record with the given data and returns the session id.
222 | */
223 | createData: (
224 | data: FlashSessionData,
225 | expires?: Date
226 | ) => Promise;
227 |
228 | /**
229 | * Returns data for a given session id, or `null` if there isn't any.
230 | */
231 | readData: (id: string) => Promise | null>;
232 |
233 | /**
234 | * Updates data for the given session id.
235 | */
236 | updateData: (
237 | id: string,
238 | data: FlashSessionData,
239 | expires?: Date
240 | ) => Promise;
241 |
242 | /**
243 | * Deletes data for a given session id from the data store.
244 | */
245 | deleteData: (id: string) => Promise;
246 | }
247 |
248 | /**
249 | * Creates a SessionStorage object using a SessionIdStorageStrategy.
250 | *
251 | * Note: This is a low-level API that should only be used if none of the
252 | * existing session storage options meet your requirements.
253 | */
254 | export function createSessionStorage({
255 | cookie: cookieArg,
256 | createData,
257 | readData,
258 | updateData,
259 | deleteData,
260 | }: SessionIdStorageStrategy): SessionStorage {
261 | let cookie = isCookie(cookieArg)
262 | ? cookieArg
263 | : createCookie(cookieArg?.name || "__session", cookieArg);
264 |
265 | warnOnceAboutSigningSessionCookie(cookie);
266 |
267 | return {
268 | async getSession(cookieHeader, options) {
269 | let id = cookieHeader && (await cookie.parse(cookieHeader, options));
270 | let data = id && (await readData(id));
271 | return createSession(data || {}, id || "");
272 | },
273 | async commitSession(session, options) {
274 | let { id, data } = session;
275 | let expires =
276 | options?.maxAge != null
277 | ? new Date(Date.now() + options.maxAge * 1000)
278 | : options?.expires != null
279 | ? options.expires
280 | : cookie.expires;
281 |
282 | if (id) {
283 | await updateData(id, data, expires);
284 | } else {
285 | id = await createData(data, expires);
286 | }
287 |
288 | return cookie.serialize(id, options);
289 | },
290 | async destroySession(session, options) {
291 | await deleteData(session.id);
292 | return cookie.serialize("", {
293 | ...options,
294 | maxAge: undefined,
295 | expires: new Date(0),
296 | });
297 | },
298 | };
299 | }
300 |
301 | export function warnOnceAboutSigningSessionCookie(cookie: Cookie) {
302 | warnOnce(
303 | cookie.isSigned,
304 | `The "${cookie.name}" cookie is not signed, but session cookies should be ` +
305 | `signed to prevent tampering on the client before they are sent back to the ` +
306 | `server. See https://remix.run/utils/cookies#signing-cookies ` +
307 | `for more information.`
308 | );
309 | }
310 |
--------------------------------------------------------------------------------
/src/framework/ssr.tsx:
--------------------------------------------------------------------------------
1 | // @ts-expect-error - no types
2 | import RSD from "@jacob-ebey/react-server-dom-vite/client";
3 | import RDS from "react-dom/server";
4 | import { injectRSCPayload } from "rsc-html-stream/server";
5 | // @ts-expect-error - no types yet
6 | import { bootstrapModules, manifest } from "virtual:react-manifest";
7 |
8 | import { UNSAFE_RouterContext } from "./client";
9 | import type { UNSAFE_ServerPayload } from "./server";
10 |
11 | export async function renderServerResponse(
12 | request: Request,
13 | ASSETS: Fetcher,
14 | sendServerRequest: (request: Request) => Promise
15 | ) {
16 | const isDataRequest = request.headers
17 | .get("Accept")
18 | ?.match(/\btext\/x-component\b/);
19 |
20 | let serverResponse: Response | undefined;
21 | let isPrerendered = false;
22 | if (request.method === "GET") {
23 | const prerenderURL = new URL(request.url);
24 | prerenderURL.pathname =
25 | "/_prerender" +
26 | (prerenderURL.pathname === "/" ? "/index" : prerenderURL.pathname) +
27 | (isDataRequest ? ".data" : ".html");
28 | const prerenderResponse = await ASSETS.fetch(prerenderURL, {
29 | headers: request.headers,
30 | cf: {
31 | cacheTtl: 300,
32 | cacheTtlByStatus: {
33 | "200": 300,
34 | "404": 300,
35 | },
36 | },
37 | });
38 | if (prerenderResponse.status === 200 || prerenderResponse.status === 304) {
39 | const headers = new Headers(prerenderResponse.headers);
40 | if (isDataRequest) {
41 | headers.set("Content-Type", "text/x-component");
42 | } else {
43 | headers.set("Content-Type", "text/html; charset=utf-8");
44 | }
45 | serverResponse = new Response(prerenderResponse.body, {
46 | duplex: "half",
47 | headers,
48 | status: prerenderResponse.status,
49 | statusText: prerenderResponse.statusText,
50 | } as ResponseInit);
51 | isPrerendered = true;
52 | }
53 | }
54 |
55 | if (!serverResponse) {
56 | serverResponse = await sendServerRequest(request);
57 | }
58 |
59 | if (isDataRequest || (!isDataRequest && isPrerendered)) {
60 | return serverResponse;
61 | }
62 |
63 | if (serverResponse.status >= 300 && serverResponse.status < 400) {
64 | return new Response(serverResponse.body, {
65 | headers: serverResponse.headers,
66 | status: serverResponse.status,
67 | duplex: serverResponse.body ? "half" : undefined,
68 | } as ResponseInit);
69 | }
70 |
71 | if (!serverResponse.body) {
72 | throw new Error("Expected response body");
73 | }
74 |
75 | let [rscA, rscB] = serverResponse.body.tee();
76 |
77 | const payload: UNSAFE_ServerPayload = await RSD.createFromReadableStream(
78 | rscA,
79 | manifest,
80 | {
81 | replayConsoleLogs: false,
82 | }
83 | );
84 |
85 | const url = new URL(request.url);
86 | if (
87 | payload.location.pathname + payload.location.search !==
88 | url.pathname + url.search
89 | ) {
90 | return new Response(null, {
91 | headers: serverResponse.headers,
92 | status:
93 | serverResponse.status >= 300 && serverResponse.status < 400
94 | ? serverResponse.status
95 | : 303,
96 | });
97 | }
98 |
99 | const body = await RDS.renderToReadableStream(
100 |
103 | {payload.root}
104 | ,
105 | {
106 | bootstrapModules,
107 | // @ts-expect-error - no types yet
108 | formState: payload.formState,
109 | signal: request.signal,
110 | }
111 | );
112 |
113 | const headers = new Headers(serverResponse.headers);
114 | headers.set("Content-Type", "text/html; charset=utf-8");
115 |
116 | if (request.headers.get("PRERENDER") === "1") {
117 | let tee = rscB.tee();
118 | rscB = tee[0];
119 | let rscPayload = "";
120 | await tee[1].pipeThrough(new TextDecoderStream()).pipeTo(
121 | new WritableStream({
122 | write(chunk) {
123 | rscPayload += chunk;
124 | },
125 | })
126 | );
127 | await body.allReady;
128 | headers.set("X-RSC-Payload", encodeURI(rscPayload));
129 | }
130 |
131 | return new Response(body.pipeThrough(injectRSCPayload(rscB)), {
132 | headers,
133 | status: serverResponse.status,
134 | statusText: serverResponse.statusText,
135 | });
136 | }
137 |
--------------------------------------------------------------------------------
/src/framework/warnings.ts:
--------------------------------------------------------------------------------
1 | const alreadyWarned: { [message: string]: boolean } = {};
2 |
3 | export function warnOnce(condition: boolean, message: string): void {
4 | if (!condition && !alreadyWarned[message]) {
5 | alreadyWarned[message] = true;
6 | console.warn(message);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/server/.dev.vars:
--------------------------------------------------------------------------------
1 | SESSION_SECRET=s3cr3t
2 |
--------------------------------------------------------------------------------
/src/server/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { renderApp } from "framework/server";
2 |
3 | import { TodoList } from "./todo-list";
4 | import { User } from "./user";
5 |
6 | declare global {
7 | interface AppEnvironment {
8 | TODO_LIST: DurableObjectNamespace;
9 | SESSION_SECRET: string;
10 | USER: DurableObjectNamespace;
11 | USERS: KVNamespace;
12 | }
13 | }
14 |
15 | export { TodoList, User };
16 |
17 | export default {
18 | async fetch(request, env) {
19 | const url = new URL(request.url);
20 | const { App } = await import("../app/app");
21 |
22 | return renderApp(
23 | request,
24 | {
25 | httpOnly: true,
26 | secrets: env.SESSION_SECRET ? [env.SESSION_SECRET] : undefined,
27 | secure: url.protocol === "https:",
28 | },
29 | env,
30 |
31 | );
32 | },
33 | } satisfies ExportedHandler;
34 |
--------------------------------------------------------------------------------
/src/server/todo-list.ts:
--------------------------------------------------------------------------------
1 | import { DurableObject } from "cloudflare:workers";
2 |
3 | export type Todo = {
4 | id: string;
5 | text: string;
6 | completed: boolean;
7 | created_at: string;
8 | };
9 |
10 | export type UpdateTodoParams = {
11 | id: string;
12 | text?: string;
13 | completed?: boolean;
14 | };
15 |
16 | export type AddTodoParams = {
17 | text: string;
18 | };
19 |
20 | function rowToTodo(row: any) {
21 | return {
22 | id: row.id,
23 | text: row.text,
24 | completed: row.completed === "true",
25 | created_at: row.created_at,
26 | };
27 | }
28 |
29 | export class TodoList extends DurableObject {
30 | private sql: SqlStorage;
31 |
32 | constructor(state: DurableObjectState, env: unknown) {
33 | super(state, env);
34 | this.sql = state.storage.sql;
35 |
36 | this.sql.exec(`
37 | CREATE TABLE IF NOT EXISTS todos (
38 | id TEXT PRIMARY KEY,
39 | text TEXT NOT NULL,
40 | completed BOOLEAN DEFAULT FALSE,
41 | created_at TEXT NOT NULL
42 | );
43 | `);
44 | }
45 |
46 | listTodos(): Todo[] {
47 | const results = this.sql
48 | .exec("SELECT * FROM todos ORDER BY created_at DESC")
49 | .toArray();
50 | return results.map(rowToTodo);
51 | }
52 |
53 | addTodo({ text }: AddTodoParams): Todo | null {
54 | const todo: Todo = {
55 | id: crypto.randomUUID(),
56 | text,
57 | completed: false,
58 | created_at: new Date().toISOString(),
59 | };
60 |
61 | const result = this.sql.exec(
62 | "INSERT INTO todos (id, text, completed, created_at) VALUES (?, ?, ?, ?)",
63 | todo.id,
64 | todo.text,
65 | todo.completed,
66 | todo.created_at
67 | );
68 |
69 | return result.rowsWritten === 1 ? todo : null;
70 | }
71 |
72 | updateTodo({ id, text, completed }: UpdateTodoParams): boolean {
73 | const existing = this.sql
74 | .exec("SELECT * FROM todos WHERE id = ?", id)
75 | .one();
76 |
77 | if (!existing) {
78 | return false;
79 | }
80 |
81 | const values = rowToTodo(existing);
82 | const newValues: Todo = {
83 | ...values,
84 | text: text ?? values.text,
85 | completed: completed ?? values.completed,
86 | };
87 |
88 | const result = this.sql.exec(
89 | "UPDATE todos SET text = ?, completed = ? WHERE id = ?",
90 | newValues.text,
91 | newValues.completed,
92 | id
93 | );
94 |
95 | return result.rowsWritten === 1;
96 | }
97 |
98 | deleteTodo({ id }: { id: string }): boolean {
99 | const result = this.sql.exec("DELETE FROM todos WHERE id = ?", id);
100 |
101 | return result.rowsWritten === 1;
102 | }
103 |
104 | getTodo({ id }: { id: string }): Todo | null {
105 | const todo = this.sql
106 | .exec("SELECT * FROM todos WHERE id = ?", id)
107 | .one() as unknown as Todo | null;
108 |
109 | return todo ? rowToTodo(todo) : null;
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/server/user.ts:
--------------------------------------------------------------------------------
1 | import { DurableObject } from "cloudflare:workers";
2 |
3 | export type TodoList = {
4 | id: string;
5 | title: string;
6 | created_at: string;
7 | };
8 |
9 | function rowToTodoList(row: any): TodoList {
10 | return {
11 | id: row.id,
12 | title: row.title,
13 | created_at: row.created_at,
14 | };
15 | }
16 |
17 | export class User extends DurableObject {
18 | private sql: SqlStorage;
19 |
20 | constructor(state: DurableObjectState, env: unknown) {
21 | super(state, env);
22 | this.sql = state.storage.sql;
23 |
24 | this.sql.exec(`
25 | CREATE TABLE IF NOT EXISTS todo_lists (
26 | id TEXT PRIMARY KEY,
27 | title TEXT NOT NULL,
28 | created_at TEXT NOT NULL
29 | );
30 | `);
31 | }
32 |
33 | addTodoList({ title }: { title: string }) {
34 | const todoList = {
35 | id: crypto.randomUUID(),
36 | title,
37 | created_at: new Date().toISOString(),
38 | };
39 | this.sql.exec(
40 | "INSERT INTO todo_lists (id, title, created_at) VALUES (?, ?, ?)",
41 | todoList.id,
42 | todoList.title,
43 | todoList.created_at
44 | );
45 | return todoList;
46 | }
47 |
48 | listTodoLists() {
49 | const results = this.sql
50 | .exec("SELECT * FROM todo_lists ORDER BY created_at DESC")
51 | .toArray();
52 | return results.map(rowToTodoList);
53 | }
54 |
55 | getTodoList({ id }: { id: string }): TodoList | null {
56 | const result = this.sql
57 | .exec("SELECT * FROM todo_lists WHERE id = ?", id)
58 | .toArray();
59 | if (result.length === 0) {
60 | return null;
61 | }
62 | return rowToTodoList(result[0]);
63 | }
64 |
65 | updateTodoList({ id, title }: { id: string; title: string }): boolean {
66 | const result = this.sql.exec(
67 | "UPDATE todo_lists SET title = ? WHERE id = ?",
68 | title,
69 | id
70 | );
71 | return result.rowsWritten === 1;
72 | }
73 |
74 | deleteTodoList({ id }: { id: string }): boolean {
75 | const result = this.sql.exec("DELETE FROM todo_lists WHERE id = ?", id);
76 | return result.rowsWritten === 1;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/server/wrangler.toml:
--------------------------------------------------------------------------------
1 | name = "server"
2 | main = "./entry.server.tsx"
3 | compatibility_date = "2024-12-05"
4 | compatibility_flags = ["nodejs_compat"]
5 |
6 | [[kv_namespaces]]
7 | binding = "USERS"
8 | id = "54a67a72e2184faa86397c3a75f3f99d"
9 |
10 | [durable_objects]
11 | bindings = [
12 | { name = "TODO_LIST", class_name = "TodoList" },
13 | { name = "USER", class_name = "User" }
14 | ]
15 |
16 | [[migrations]]
17 | tag = "v1"
18 | new_sqlite_classes = ["TodoList", "User"]
19 |
--------------------------------------------------------------------------------
/src/ssr/entry.ssr.tsx:
--------------------------------------------------------------------------------
1 | import { renderServerResponse } from "framework/ssr";
2 |
3 | type CloudflareEnv = {
4 | ASSETS: Fetcher;
5 | SERVER: Fetcher;
6 | };
7 |
8 | export default {
9 | async fetch(request, { ASSETS, SERVER }) {
10 | return renderServerResponse(request, ASSETS, (request) =>
11 | SERVER.fetch(request)
12 | );
13 | },
14 | } satisfies ExportedHandler;
15 |
--------------------------------------------------------------------------------
/src/ssr/wrangler.toml:
--------------------------------------------------------------------------------
1 | name = "ssr"
2 | main = "./entry.ssr.tsx"
3 | compatibility_date = "2024-09-23"
4 | compatibility_flags = ["nodejs_compat"]
5 | assets = { binding = "ASSETS" }
6 | services = [{ binding = "SERVER", service = "server" }]
7 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | export default {
4 | content: ['./src/**/*.{js,jsx,ts,tsx}'],
5 | theme: {
6 | extend: {
7 | fontFamily: {
8 | sans: ['monospace'],
9 | mono: ['monospace'],
10 | },
11 | colors: {
12 | background: 'var(--background)',
13 | foreground: 'var(--foreground)',
14 | 'foreground-muted': 'var(--foreground-muted)',
15 | primary: 'var(--primary)',
16 | 'primary-foreground': 'var(--primary-foreground)',
17 | destructive: 'var(--destructive)',
18 | 'destructive-foreground': 'var(--destructive-foreground)',
19 | border: 'var(--border)',
20 | },
21 | animation: {
22 | progress: 'progress 1s infinite linear',
23 | },
24 | keyframes: {
25 | progress: {
26 | '0%': { transform: 'translateX(0) scaleX(0)' },
27 | '40%': { transform: 'translateX(0) scaleX(0.4)' },
28 | '100%': { transform: 'translateX(100%) scaleX(0.5)' },
29 | },
30 | },
31 | transformOrigin: {
32 | 'left-right': '0% 50%',
33 | },
34 | },
35 | },
36 | plugins: [],
37 | } satisfies Config;
38 |
--------------------------------------------------------------------------------
/tsconfig.client.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src"],
3 | "compilerOptions": {
4 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
5 | "types": [
6 | "@cloudflare/workers-types",
7 | "@types/dom-navigation",
8 | "@types/node",
9 | "vite/client"
10 | ],
11 | "rootDir": ".",
12 | "paths": {
13 | "~/*": ["./src/app/*"],
14 | "framework/*": ["./src/framework/*"]
15 | },
16 | "allowImportingTsExtensions": true,
17 | "jsx": "react-jsx",
18 | "esModuleInterop": false,
19 | "skipLibCheck": true,
20 | "target": "ES2022",
21 | "resolveJsonModule": true,
22 | "moduleDetection": "force",
23 | "isolatedModules": true,
24 | "verbatimModuleSyntax": true,
25 | "strict": true,
26 | "noUncheckedIndexedAccess": true,
27 | "noImplicitOverride": true,
28 | "module": "Preserve",
29 | "noEmit": true
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.node.json" },
5 | { "path": "./tsconfig.client.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["vite.config.ts", "__tests__"],
3 | "compilerOptions": {
4 | "esModuleInterop": false,
5 | "skipLibCheck": true,
6 | "target": "ES2022",
7 | "resolveJsonModule": true,
8 | "moduleDetection": "force",
9 | "isolatedModules": true,
10 | "verbatimModuleSyntax": true,
11 | "strict": true,
12 | "noUncheckedIndexedAccess": true,
13 | "noImplicitOverride": true,
14 | "module": "Preserve",
15 | "lib": ["ES2022"],
16 | "noEmit": true
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { cloudflare } from "@flarelabs-net/vite-plugin-cloudflare";
2 | import reactServerDOM from "@jacob-ebey/vite-react-server-dom";
3 | import { defineConfig } from "vite";
4 | import tsconfigPaths from "vite-tsconfig-paths";
5 |
6 | export default defineConfig({
7 | environments: {
8 | client: {
9 | build: {
10 | rollupOptions: {
11 | input: "src/browser/entry.browser.tsx",
12 | treeshake: {
13 | moduleSideEffects: () => {
14 | return false;
15 | },
16 | },
17 | },
18 | },
19 | },
20 | },
21 | plugins: [
22 | tsconfigPaths({ configNames: ["tsconfig.client.json"] }),
23 | reactServerDOM({
24 | browserEnvironment: "client",
25 | serverEnvironments: ["server"],
26 | ssrEnvironments: ["ssr"],
27 | runtime: {
28 | browser: {
29 | importFrom: "framework/references.browser",
30 | },
31 | server: {
32 | importFrom: "framework/references.server",
33 | },
34 | ssr: {
35 | importFrom: "framework/references.ssr",
36 | },
37 | },
38 | }),
39 | cloudflare({
40 | persistState: true,
41 | configPath: "src/ssr/wrangler.toml",
42 | auxiliaryWorkers: [
43 | {
44 | configPath: "src/server/wrangler.toml",
45 | },
46 | ],
47 | }),
48 | ],
49 | });
50 |
--------------------------------------------------------------------------------