├── 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 |
26 |
27 | );
28 | }
29 | return (
30 |
31 |
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 |
161 |
162 | );
163 | }
164 | return (
165 |
166 |
169 |
170 | );
171 | }
172 | ```
--------------------------------------------------------------------------------