├── app ├── index.css ├── db.server.ts ├── routes │ ├── auth.google.callback.tsx │ ├── auth.google.tsx │ ├── login.tsx │ ├── _index.tsx │ └── validated-form-example.tsx ├── components │ └── Layout.tsx ├── schema.ts ├── entry.client.tsx ├── root.tsx ├── entry.server.tsx └── services │ └── auth.server.ts ├── public ├── _headers ├── favicon.ico └── _routes.json ├── postcss.config.cjs ├── .vscode └── settings.json ├── remix.env.d.ts ├── .dev.vars.example ├── .gitignore ├── migrations ├── 0000_conscious_ironclad.sql └── meta │ ├── _journal.json │ └── 0000_snapshot.json ├── wrangler.toml ├── panda.config.ts ├── server.ts ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── README.md ├── docs ├── 02_release.md ├── 01_setup.md └── 00_how_it_generate.md ├── remix.config.js ├── tsconfig.json └── package.json /app/index.css: -------------------------------------------------------------------------------- 1 | @layer reset, base, tokens, recipes, utilities; -------------------------------------------------------------------------------- /public/_headers: -------------------------------------------------------------------------------- 1 | /build/* 2 | Cache-Control: public, max-age=31536000, s-maxage=31536000 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mizchi/remix-d1-bullets/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@pandacss/dev/postcss': {}, 4 | }, 5 | } -------------------------------------------------------------------------------- /public/_routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "include": ["/*"], 4 | "exclude": ["/build/*"] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "rome.rome" 5 | } 6 | } -------------------------------------------------------------------------------- /app/db.server.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/d1'; 2 | 3 | export function createClient(db: D1Database) { 4 | return drizzle(db); 5 | } -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /.dev.vars.example: -------------------------------------------------------------------------------- 1 | GOOGLE_AUTH_CALLBACK_URL="http://localhost:8788/auth/google/callback" 2 | GOOGLE_AUTH_CLIENT_ID="" 3 | GOOGLE_AUTH_CLIENT_SECRET="" 4 | SESSION_SECRET="" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /functions/\[\[path\]\].js 5 | /functions/\[\[path\]\].js.map 6 | /public/build 7 | .dev.vars 8 | .wrangler 9 | styled-system -------------------------------------------------------------------------------- /migrations/0000_conscious_ironclad.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `users` ( 2 | `id` integer PRIMARY KEY NOT NULL, 3 | `googleProfileId` text NOT NULL, 4 | `iconUrl` text, 5 | `displayName` text NOT NULL, 6 | `registeredAt` integer NOT NULL 7 | ); 8 | -------------------------------------------------------------------------------- /migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1683450767302, 9 | "tag": "0000_conscious_ironclad", 10 | "breakpoints": false 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | compatibility_date = "2023-07-10" 2 | compatibility_flags = [] 3 | 4 | # kv_namespaces = [ 5 | # {binding = "SESSION_KV", id = "...", preview_id = "..." } 6 | # ] 7 | 8 | # [[ d1_databases ]] 9 | # binding = "DB" 10 | # database_name = "mydb" 11 | # database_id = "mydb-id" -------------------------------------------------------------------------------- /panda.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@pandacss/dev" 2 | 3 | export default defineConfig({ 4 | preflight: false, 5 | outExtension: 'js', 6 | include: ["./app/**/*.{js,jsx,ts,tsx}"], 7 | exclude: [], 8 | theme: { 9 | extend: {} 10 | }, 11 | outdir: "styled-system", 12 | }) -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages"; 2 | import * as build from "@remix-run/dev/server-build"; 3 | 4 | export const onRequest = createPagesFunctionHandler({ 5 | build, 6 | getLoadContext: (context) => context.env, 7 | mode: process.env.NODE_ENV, 8 | }); 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Use Node.js 18 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | - run: corepack enable pnpm 19 | - run: pnpm install --frozen-lockfile 20 | -------------------------------------------------------------------------------- /app/routes/auth.google.callback.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderArgs } from '@remix-run/node'; 2 | import { getAuthenticator } from '../services/auth.server' 3 | 4 | export let loader = ({ request, context }: LoaderArgs) => { 5 | // console.log('[auth.google.callback] loader()', context); 6 | const authenticator = getAuthenticator(context) 7 | return authenticator.authenticate('google', request, { 8 | successRedirect: '/', 9 | failureRedirect: '/login', 10 | }) 11 | } -------------------------------------------------------------------------------- /app/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | export function Layout(props: { children: React.ReactNode }) { 3 | return ( 4 | <> 5 |
6 |

RemixTestApp

7 | 12 |
13 |
14 |
15 | {props.children} 16 |
17 | 18 | ); 19 | } -------------------------------------------------------------------------------- /app/schema.ts: -------------------------------------------------------------------------------- 1 | /* 2 | DO NOT RENAME THIS FILE FOR DRIZZLE-ORM TO WORK 3 | */ 4 | import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; 5 | 6 | export const users = sqliteTable('users', { 7 | id: integer('id').primaryKey().notNull(), 8 | googleProfileId: text('googleProfileId').notNull(), 9 | iconUrl: text('iconUrl'), 10 | displayName: text('displayName').notNull(), 11 | registeredAt: integer('registeredAt', { mode: 'timestamp' }).notNull(), 12 | }); 13 | -------------------------------------------------------------------------------- /app/routes/auth.google.tsx: -------------------------------------------------------------------------------- 1 | import { redirect, type ActionArgs } from "@remix-run/cloudflare"; 2 | import { getAuthenticator } from '~/services/auth.server' 3 | 4 | export const loader = () => redirect('/login') 5 | 6 | export const action = ({ request, context }: ActionArgs) => { 7 | // console.log('[auth.google] action()', context); 8 | const authenticator = getAuthenticator(context); 9 | return authenticator.authenticate('google', request, { 10 | successRedirect: '/', 11 | failureRedirect: '/login', 12 | }) 13 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remix-d1-bullets 2 | 3 | My silver bullets. 4 | 5 | ## Stack 6 | 7 | - cloudflare-pages-functions 8 | - remix + `@remix-run/cloudflare-pages` 9 | - remix-auth | Google OAuth 10 | - remix-validated-form + zod 11 | - D1 + DrizzleORM 12 | - panda-css 13 | - radix-ui 14 | - GitHub Actions CI and Release 15 | 16 | ## How to develop 17 | 18 | - [Setup](docs/01_setup.md) 19 | - [Release](docs/02_d1_migration.md) 20 | 21 | If you want to know thin project from scratch, see [how it generate](docs/00_how_it_generate.md) 22 | 23 | ## LICENSE 24 | 25 | MIT -------------------------------------------------------------------------------- /docs/02_release.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | ## How migration works 4 | 5 | TBD 6 | 7 | ## GitHub Release Flow 8 | 9 | ```bash 10 | # edit app/schema.ts 11 | $ pnpm gen:migrate 12 | $ pnpm wrangler d1 migrations apply mydb --local 13 | $ git add app/schema.ts 14 | $ git commit -m "xxx" 15 | # PR to release/xxx branch 16 | # $ git push origin main:release/$(date +%s) 17 | ``` 18 | 19 | When PR merged, run `.github/workflows/release.yml` 20 | 21 | ## Release from local 22 | 23 | ```bash 24 | $ pnpm wrangler d1 migrations apply mydb 25 | $ pnpm build:prod 26 | $ pnpm wrangler pages publish ./public 27 | ``` 28 | 29 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | module.exports = { 3 | devServerBroadcastDelay: 1000, 4 | ignoredRouteFiles: ["**/.*"], 5 | server: "./server.ts", 6 | serverBuildPath: "functions/[[path]].js", 7 | serverConditions: ["worker"], 8 | serverDependenciesToBundle: "all", 9 | serverMainFields: ["browser", "module", "main"], 10 | serverMinify: true, 11 | serverModuleFormat: "esm", 12 | serverPlatform: "neutral", 13 | future: { 14 | v2_headers: true, 15 | v2_errorBoundary: true, 16 | v2_meta: true, 17 | v2_normalizeFormMethod: true, 18 | v2_routeConvention: true, 19 | }, 20 | postcss: true 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["DOM", "DOM.Iterable", "ES2020"], 4 | "isolatedModules": true, 5 | "esModuleInterop": true, 6 | "jsx": "react-jsx", 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "target": "ES2020", 10 | "strict": true, 11 | "allowJs": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "baseUrl": ".", 14 | "paths": { 15 | "~/*": ["./app/*"], 16 | }, 17 | 18 | // Remix takes care of building everything in `remix build`. 19 | "noEmit": true, 20 | "skipLibCheck": true, 21 | }, 22 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"] 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ release/* ] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Use Node.js 18 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | - run: corepack enable pnpm 17 | - run: pnpm install --frozen-lockfile 18 | # setup cloudflare wrangler 19 | - run: pnpm wrangler d1 migrations apply dzltest 20 | env: 21 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 22 | - run: pnpm build:prod 23 | - run: pnpm wrangler pages publish ./publish 24 | env: 25 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 26 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Links, 3 | LiveReload, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "@remix-run/react"; 9 | import { LinksFunction } from "@remix-run/cloudflare"; 10 | import styles from './index.css'; 11 | 12 | export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }] 13 | 14 | export default function App() { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { AppLoadContext, EntryContext } from "@remix-run/cloudflare"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import isbot from "isbot"; 4 | import { renderToReadableStream } from "react-dom/server"; 5 | 6 | export default async function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | remixContext: EntryContext, 11 | loadContext: AppLoadContext 12 | ) { 13 | const body = await renderToReadableStream( 14 | , 15 | { 16 | signal: request.signal, 17 | onError(error: unknown) { 18 | console.error('[error]', error); 19 | responseStatusCode = 500; 20 | }, 21 | } 22 | ); 23 | 24 | if (isbot(request.headers.get("user-agent"))) { 25 | await body.allReady; 26 | } 27 | 28 | responseHeaders.set("Content-Type", "text/html"); 29 | return new Response(body, { 30 | headers: responseHeaders, 31 | status: responseStatusCode, 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/cloudflare"; 2 | import { json } from "@remix-run/cloudflare"; 3 | import { useLoaderData } from "@remix-run/react"; 4 | import { Form } from "@remix-run/react"; 5 | import { getAuthenticator } from "../services/auth.server"; 6 | import { Layout } from "../components/Layout"; 7 | 8 | export async function loader({ request, context }: LoaderArgs) { 9 | const authenticator = getAuthenticator(context); 10 | const user = await authenticator.isAuthenticated(request); 11 | return json({ 12 | user, 13 | }); 14 | }; 15 | 16 | export default function Login() { 17 | const { user } = useLoaderData(); 18 | 19 | if (user) { 20 | return ( 21 | 22 |
{JSON.stringify(user)}
23 |
24 | 25 |
26 |
27 | ); 28 | } 29 | return ( 30 | 31 |
32 | 33 |
34 |
35 | ); 36 | } 37 | 38 | -------------------------------------------------------------------------------- /migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "id": "033429fa-23af-4d9a-8fc4-be8cb423d50e", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "users": { 8 | "name": "users", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "googleProfileId": { 18 | "name": "googleProfileId", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "iconUrl": { 24 | "name": "iconUrl", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": false 28 | }, 29 | "displayName": { 30 | "name": "displayName", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "registeredAt": { 36 | "name": "registeredAt", 37 | "type": "integer", 38 | "primaryKey": false, 39 | "notNull": true, 40 | "autoincrement": false 41 | } 42 | }, 43 | "indexes": {}, 44 | "foreignKeys": {}, 45 | "compositePrimaryKeys": {} 46 | } 47 | }, 48 | "enums": {}, 49 | "_meta": { 50 | "schemas": {}, 51 | "tables": {}, 52 | "columns": {} 53 | } 54 | } -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from '../components/Layout'; 2 | import type { V2_MetaFunction } from "@remix-run/cloudflare"; 3 | import { css } from "../../styled-system/css/index.js"; 4 | // Popover.tsx 5 | import * as Popover from '@radix-ui/react-popover'; 6 | 7 | export const meta: V2_MetaFunction = () => { 8 | return [{ title: "New Remix App" }]; 9 | }; 10 | 11 | export default function Index() { 12 | return ( 13 | 14 |
Home1
15 | 16 |
17 | ); 18 | } 19 | 20 | const PopoverComponent = () => ( 21 | 22 | More info 23 | 24 | 25 | Some more info… 26 | 27 | 28 | 29 | 30 | ); 31 | 32 | // style definitions 33 | const triggerStyle = { 34 | backgroundColor: '#ddd', 35 | padding: '10px', 36 | border: 'none', 37 | borderRadius: '4px', 38 | cursor: 'pointer', 39 | ':hover': { 40 | backgroundColor: '#ccc', 41 | } 42 | }; 43 | 44 | const contentStyle = { 45 | backgroundColor: '#fff', 46 | border: '1px solid #ddd', 47 | borderRadius: '4px', 48 | padding: '20px', 49 | boxShadow: '0 2px 10px rgba(0,0,0,0.1)', 50 | }; 51 | 52 | const arrowStyle = { 53 | color: '#ddd', 54 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "scripts": { 5 | "prepare": "panda codegen", 6 | "dev": "NODE_ENV=development npm-run-all build --parallel dev:*", 7 | "dev:remix": "remix watch", 8 | "dev:panda": "panda codegen --watch", 9 | "dev:wrangler": "wrangler pages dev ./public", 10 | "build": "remix build", 11 | "build:prod": "NODE_ENV=production remix build", 12 | "start": "NODE_ENV=production wrangler pages dev ./public", 13 | "start2": "wrangler pages dev --compatibility-date=2023-06-21 ./public", 14 | "typecheck": "tsc -p . --noEmit", 15 | "gen:migrate": "drizzle-kit generate:sqlite --out migrations --schema app/schema.ts", 16 | "release": "pnpm build:prod && wrangler pages publish ./public" 17 | }, 18 | "dependencies": { 19 | "@remix-run/cloudflare": "^1.18.1", 20 | "@remix-run/cloudflare-pages": "^1.18.1", 21 | "@remix-run/react": "^1.18.1", 22 | "cross-env": "^7.0.3", 23 | "isbot": "^3.6.8", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0" 26 | }, 27 | "devDependencies": { 28 | "@cloudflare/workers-types": "^4.20230710.1", 29 | "@pandacss/dev": "^0.6.0", 30 | "@radix-ui/react-popover": "^1.0.6", 31 | "@remix-run/dev": "^1.18.1", 32 | "@remix-run/eslint-config": "^1.18.1", 33 | "@remix-run/node": "^1.18.1", 34 | "@remix-validated-form/with-zod": "^2.0.6", 35 | "@types/node": "^20.4.2", 36 | "@types/react": "^18.2.15", 37 | "@types/react-dom": "^18.2.7", 38 | "better-sqlite3": "^8.4.0", 39 | "drizzle-kit": "^0.19.5", 40 | "drizzle-orm": "^0.27.2", 41 | "eslint": "^8.44.0", 42 | "npm-run-all": "^4.1.5", 43 | "remix-auth": "^3.5.0", 44 | "remix-auth-form": "^1.3.0", 45 | "remix-auth-google": "^1.2.0", 46 | "remix-validated-form": "^5.0.2", 47 | "typescript": "^5.1.6", 48 | "wrangler": "^3.2.0", 49 | "zod": "^3.21.4" 50 | }, 51 | "engines": { 52 | "node": ">=16.13" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/services/auth.server.ts: -------------------------------------------------------------------------------- 1 | import type { AppLoadContext } from "@remix-run/cloudflare"; 2 | import { 3 | createCookie, 4 | createWorkersKVSessionStorage, 5 | } from "@remix-run/cloudflare"; 6 | 7 | import { Authenticator } from "remix-auth"; 8 | import { GoogleStrategy } from "remix-auth-google"; 9 | import { users } from "../schema" 10 | import { InferModel } from "drizzle-orm"; 11 | import { createClient } from "~/db.server"; 12 | 13 | export type AuthUser = { 14 | id: number; 15 | }; 16 | 17 | type CreateUser = InferModel 18 | 19 | let _authenticator: Authenticator | undefined; 20 | export function getAuthenticator(context: AppLoadContext): Authenticator { 21 | if (_authenticator == null) { 22 | const cookie = createCookie("__session", { 23 | secrets: [context.SESSION_SECRET as string], 24 | path: "/", 25 | sameSite: "lax", 26 | httpOnly: true, 27 | secure: process.env.NODE_ENV == "production", 28 | }); 29 | console.log('[auth.server] cookie', cookie); 30 | const sessionStorage = createWorkersKVSessionStorage({ 31 | kv: context.SESSION_KV as KVNamespace, 32 | cookie 33 | }); 34 | _authenticator = new Authenticator(sessionStorage); 35 | const googleAuth = new GoogleStrategy({ 36 | clientID: context.GOOGLE_AUTH_CLIENT_ID as string, 37 | clientSecret: context.GOOGLE_AUTH_CLIENT_SECRET as string, 38 | callbackURL: context.GOOGLE_AUTH_CALLBACK_URL as string, 39 | }, async ({ profile }) => { 40 | const db = createClient(context.DB as D1Database); 41 | const newUser: CreateUser = { 42 | googleProfileId: profile.id, 43 | iconUrl: profile.photos?.[0].value, 44 | displayName: profile.displayName, 45 | registeredAt: new Date(), 46 | }; 47 | const ret = await db.insert(users).values(newUser).returning().get(); 48 | return { 49 | id: ret.id, 50 | }; 51 | }); 52 | _authenticator.use(googleAuth); 53 | } 54 | return _authenticator; 55 | } 56 | -------------------------------------------------------------------------------- /app/routes/validated-form-example.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This is an example of using remix-validated-form and zod 3 | * If you drop this libraries, bundle size will be reduced by -100kb. (400kb to 300kb at first) 4 | */ 5 | import { DataFunctionArgs, json } from "@remix-run/cloudflare"; 6 | import { useActionData } from "@remix-run/react"; 7 | import { withZod } from "@remix-validated-form/with-zod"; 8 | import { 9 | ValidatedForm, 10 | useField, 11 | useIsSubmitting, 12 | validationError, 13 | } from "remix-validated-form"; 14 | import { z } from "zod"; 15 | 16 | const validator = withZod( 17 | z.object({ 18 | firstName: z 19 | .string() 20 | .min(1, { message: "First name is required" }), 21 | lastName: z 22 | .string() 23 | .min(1, { message: "Last name is required" }), 24 | email: z 25 | .string() 26 | .min(1, { message: "Email is required" }) 27 | .email("Must be a valid email"), 28 | }) 29 | ); 30 | 31 | export const action = async ({ 32 | request, 33 | }: DataFunctionArgs) => { 34 | const data = await validator.validate( 35 | await request.formData() 36 | ); 37 | if (data.error) return validationError(data.error); 38 | const { firstName, lastName, email } = data.data; 39 | 40 | return json({ 41 | title: `Hi ${firstName} ${lastName}!`, 42 | description: `Your email is ${email}`, 43 | }); 44 | }; 45 | 46 | export default function ValidatedFormExample() { 47 | const data = useActionData(); 48 | return ( 49 | <> 50 |

51 | This is remix-validated-form example 52 |

53 | 54 | 55 | 56 | 57 | {data && ( 58 |
{JSON.stringify(data)}
59 | )} 60 | 61 |
62 | 63 | ); 64 | } 65 | 66 | const InputWithLabel = ({ name, label }: { name: string, label: string }) => { 67 | const { error, getInputProps } = useField(name); 68 | return ( 69 |
70 | 71 | 72 | {error && ( 73 | {error} 74 | )} 75 |
76 | ); 77 | }; 78 | 79 | const SubmitButton = () => { 80 | const isSubmitting = useIsSubmitting(); 81 | return ( 82 | 85 | ); 86 | }; 87 | 88 | -------------------------------------------------------------------------------- /docs/01_setup.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | ## Steps 4 | 5 | - Google OAuth 6 | - Cloudflare 7 | - GitHub Actions 8 | - Local 9 | 10 | ## Setup Google OAuth 11 | 12 | - Enter https://console.cloud.google.com/ 13 | - Search "APIs & Service" 14 | - Create "OAuth 2.0 Client IDs" 15 | 16 | and fullfill forms. 17 | 18 | ### Authorized JavaScript origins 19 | 20 | http://localhost:8788 and `your-deploy-url` 21 | 22 | ### Authorized redirect URIs 23 | 24 | http://localhost:8788/auth/google/callback and `your-deploy-url/auth/google/callback` 25 | 26 | ## Setup cloudflare project 27 | 28 | Now `wrangler pages publish ...` does not read `wrangler.toml` bindings. So you should put values on cloudflare pages dashboard by hand. 29 | 30 | ### Create a project 31 | 32 | If you does not create project, create at first. 33 | 34 | https://dash.cloudflare.com > Pages > Create a Project 35 | 36 | ### Setting environments and bindings 37 | 38 | https://dash.cloudflare.com > Pages > `ProjectName` > Settings > Environment variables 39 | 40 | Fullfil production values like `.dev.vars` 41 | 42 | ``` 43 | GOOGLE_AUTH_CALLBACK_URL="..." 44 | GOOGLE_AUTH_CLIENT_ID="..." 45 | GOOGLE_AUTH_CLIENT_SECRET="..." 46 | SESSION_SECRET="..." # random hash you choice 47 | ``` 48 | 49 | and copy them to local `.dev.vars` 50 | 51 | ### KV bindings 52 | 53 | KV for session storage. 54 | 55 | ``` 56 | $ pnpm wrangler kv:namespace create session-kv 57 | $ pnpm wrangler kv:namespace create session-kv-preview --preview 58 | ``` 59 | 60 | https://dash.cloudflare.com > Pages > `ProjectName` > Settings > Functions > KV namespace bindings 61 | 62 | ``` 63 | SESSION_KV= 64 | ``` 65 | 66 | and put to `wrangler toml` for local dev. 67 | 68 | ```toml 69 | kv_namespaces = [ 70 | {binding = "SESSION_KV", id = "...", preview_id = "..." } 71 | ] 72 | ``` 73 | 74 | ### D1 database bindings 75 | 76 | https://dash.cloudflare.com > Pages > `ProjectName` > Settings > Functions > D1 77 | 78 | ```bash 79 | $ pnpm wrangler d1 create mydb 80 | ``` 81 | 82 | ``` 83 | DB=mydb 84 | ``` 85 | 86 | and put it to `wrangler.toml` for local dev. 87 | 88 | ```toml 89 | [[ d1_databases ]] 90 | binding = "DB" 91 | database_name = "mydb" 92 | database_id = "mydb-id" 93 | ``` 94 | 95 | ## GitHub Actions 96 | 97 | Generate cloudflare token to GitHub Actions Secrets with pages publish permission. 98 | 99 | ``` 100 | CLOUDFLARE_API_TOKEN=... 101 | ``` 102 | 103 | ## Setup local 104 | 105 | ```bash 106 | $ pnpm install 107 | $ cp .dev.vars.example .dev.vars 108 | ``` 109 | 110 | `wrangler.toml` for local bindings 111 | 112 | ```toml 113 | compatibility_date = "2023-04-30" 114 | compatibility_flags = ["streams_enable_constructors"] 115 | 116 | kv_namespaces = [ 117 | {binding = "SESSION_KV", id = "...", preview_id = "..." } 118 | ] 119 | 120 | [[ d1_databases ]] 121 | binding = "DB" 122 | database_name = "mydb" 123 | database_id = "mydb-id" 124 | ``` 125 | 126 | `.dev.vars` for local dev 127 | 128 | ``` 129 | GOOGLE_AUTH_CALLBACK_URL="..." 130 | GOOGLE_AUTH_CLIENT_ID="..." 131 | GOOGLE_AUTH_CLIENT_SECRET="..." 132 | SESSION_SECRET="..." # random hash you choice 133 | ``` 134 | 135 | Run first migration. 136 | 137 | ```bash 138 | # Check app/schema.ts and migrations/0000_conscious_ironclad.sql 139 | # If you want to use your schema, fix src/schema.ts and remove migrations/*. 140 | 141 | $ pnpm gen:migrate 142 | $ pnpm wrangler d1 migrations apply mydb --local 143 | ``` 144 | -------------------------------------------------------------------------------- /docs/00_how_it_generate.md: -------------------------------------------------------------------------------- 1 | # How this project generates 2 | 3 | from bare to this project. 4 | 5 | ```bash 6 | $ npx create-remix@latest remix-d1-bullets 7 | # Select TypeScript / CloudflarePages 8 | $ cd remix-d1-bullets 9 | $ rm -r node_modules package-lock.json 10 | $ pnpm install 11 | $ pnpm add drizzle-kit drizzle-orm better-sqlite3 remix-auth remix-auth-google -D 12 | ``` 13 | 14 | ## Put Schema 15 | 16 | app/schema.ts 17 | 18 | ```ts 19 | /* 20 | DO NOT RENAME THIS FILE FOR DRIZZLE-ORM TO WORK 21 | */ 22 | import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; 23 | 24 | export const users = sqliteTable('users', { 25 | id: integer('id').primaryKey().notNull(), 26 | googleProfileId: text('googleProfileId').notNull(), 27 | iconUrl: text('iconUrl'), 28 | displayName: text('displayName').notNull(), 29 | registeredAt: integer('registeredAt', { mode: 'timestamp' }).notNull(), 30 | }); 31 | ``` 32 | 33 | and generate migaration. 34 | 35 | ```bash 36 | $ pnpm drizzle-kit generate:sqlite --out migrations --schema src/schema.ts 37 | ``` 38 | 39 | ## Auth Service 40 | 41 | app/services/auth.server.ts 42 | 43 | ```ts 44 | import type { AppLoadContext } from "@remix-run/cloudflare"; 45 | import { 46 | createCookie, 47 | createWorkersKVSessionStorage, 48 | } from "@remix-run/cloudflare"; 49 | 50 | import { Authenticator } from "remix-auth"; 51 | import { GoogleStrategy } from "remix-auth-google"; 52 | import { users } from "../schema" 53 | import { InferModel } from "drizzle-orm"; 54 | import { createClient } from "~/db.server"; 55 | 56 | export type AuthUser = { 57 | id: number; 58 | }; 59 | 60 | type CreateUser = InferModel 61 | 62 | let _authenticator: Authenticator | undefined; 63 | export function getAuthenticator(context: AppLoadContext): Authenticator { 64 | if (_authenticator == null) { 65 | const cookie = createCookie("__session", { 66 | secrets: [context.SESSION_SECRET as string], 67 | path: "/", 68 | sameSite: "lax", 69 | httpOnly: true, 70 | secure: process.env.NODE_ENV == "production", 71 | }); 72 | console.log('[auth.server] cookie', cookie); 73 | const sessionStorage = createWorkersKVSessionStorage({ 74 | kv: context.SESSION_KV as KVNamespace, 75 | cookie 76 | }); 77 | _authenticator = new Authenticator(sessionStorage); 78 | const googleAuth = new GoogleStrategy({ 79 | clientID: context.GOOGLE_AUTH_CLIENT_ID as string, 80 | clientSecret: context.GOOGLE_AUTH_CLIENT_SECRET as string, 81 | callbackURL: context.GOOGLE_AUTH_CALLBACK_URL as string, 82 | }, async ({ profile }) => { 83 | const db = createClient(context.DB as D1Database); 84 | const newUser: CreateUser = { 85 | googleProfileId: profile.id, 86 | iconUrl: profile.photos?.[0].value, 87 | displayName: profile.displayName, 88 | registeredAt: new Date(), 89 | }; 90 | const ret = await db.insert(users).values(newUser).returning().get(); 91 | return { 92 | id: ret.id, 93 | }; 94 | }); 95 | _authenticator.use(googleAuth); 96 | } 97 | return _authenticator; 98 | } 99 | ``` 100 | 101 | `.dev.vars` and production enviroments require them. 102 | 103 | ``` 104 | GOOGLE_AUTH_CALLBACK_URL="http://localhost:8788/auth/google/callback" 105 | GOOGLE_AUTH_CLIENT_ID="" 106 | GOOGLE_AUTH_CLIENT_SECRET="" 107 | SESSION_SECRET="" 108 | ``` 109 | 110 | usage example 111 | 112 | ```tsx 113 | // app/routes/* 114 | import type { LoaderArgs } from "@remix-run/cloudflare"; 115 | import { json } from "@remix-run/cloudflare"; 116 | import { getAuthenticator } from "../services/auth.server"; 117 | 118 | export async function loader({ request, context }: LoaderArgs) { 119 | const authenticator = getAuthenticator(context); 120 | const user = await authenticator.isAuthenticated(request); 121 | return json({ 122 | user, 123 | }); 124 | }; 125 | ``` 126 | 127 | and put API and callback 128 | 129 | - `app/routes/auth.google.tsx` 130 | - `app/routes/auth.google.callback.tsx` 131 | 132 | Implement login page. 133 | 134 | ```tsx 135 | // app/routes/login.tsx 136 | import type { LoaderArgs } from "@remix-run/cloudflare"; 137 | import { json } from "@remix-run/cloudflare"; 138 | import { useLoaderData } from "@remix-run/react"; 139 | import { Form } from "@remix-run/react"; 140 | import { getAuthenticator } from "../services/auth.server"; 141 | import { Layout } from "../components/Layout"; 142 | 143 | export async function loader({ request, context }: LoaderArgs) { 144 | const authenticator = getAuthenticator(context); 145 | const user = await authenticator.isAuthenticated(request); 146 | return json({ 147 | user, 148 | }); 149 | }; 150 | 151 | export default function Login() { 152 | const { user } = useLoaderData(); 153 | 154 | if (user) { 155 | return ( 156 | 157 |
{JSON.stringify(user)}
158 |
159 | 160 |
161 |
162 | ); 163 | } 164 | return ( 165 | 166 |
167 | 168 |
169 |
170 | ); 171 | } 172 | ``` --------------------------------------------------------------------------------