create-cfast 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/index.js +681 -0
- package/package.json +37 -0
- package/templates/admin/app/admin.server.ts +87 -0
- package/templates/admin/app/routes/admin.tsx +18 -0
- package/templates/admin/package.json +6 -0
- package/templates/auth/app/auth.client.ts +6 -0
- package/templates/auth/app/auth.helpers.server.ts +35 -0
- package/templates/auth/app/auth.setup.server.ts +17 -0
- package/templates/auth/app/db/schema.ts +106 -0
- package/templates/auth/app/routes/auth.$.tsx +10 -0
- package/templates/auth/app/routes/login.tsx +25 -0
- package/templates/auth/package.json +8 -0
- package/templates/base/_gitignore +14 -0
- package/templates/base/app/entry.server.tsx +38 -0
- package/templates/base/app/permissions.ts +32 -0
- package/templates/base/app/routes/_index.tsx +8 -0
- package/templates/base/package.json +32 -0
- package/templates/base/react-router.config.ts +8 -0
- package/templates/base/tsconfig.cloudflare.json +26 -0
- package/templates/base/tsconfig.json +14 -0
- package/templates/base/tsconfig.node.json +13 -0
- package/templates/base/workers/app.ts +29 -0
- package/templates/db/app/db/client.ts +8 -0
- package/templates/db/app/db/schema.ts +9 -0
- package/templates/db/drizzle.config.ts +7 -0
- package/templates/db/package.json +14 -0
- package/templates/email/app/email/send.ts +10 -0
- package/templates/email/app/email/templates/welcome.tsx +19 -0
- package/templates/email/app/email.server.ts +33 -0
- package/templates/email/package.json +6 -0
- package/templates/storage/package.json +5 -0
- package/templates/ui/app/actions.server.ts +12 -0
- package/templates/ui/app/components/Header.tsx +30 -0
- package/templates/ui/package.json +10 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createAdminLoader, createAdminAction, introspectSchema } from "@cfast/admin";
|
|
2
|
+
import type { AdminAuthConfig, AdminUser } from "@cfast/admin";
|
|
3
|
+
import { createDb } from "@cfast/db";
|
|
4
|
+
import type { DbConfig } from "@cfast/db";
|
|
5
|
+
import { requireAuthContext, hasRole } from "~/auth.helpers.server";
|
|
6
|
+
import { initAuth } from "~/auth.setup.server";
|
|
7
|
+
import { env } from "~/env";
|
|
8
|
+
import * as schema from "~/db/schema";
|
|
9
|
+
|
|
10
|
+
const auth: AdminAuthConfig = {
|
|
11
|
+
async requireUser(request: Request) {
|
|
12
|
+
const ctx = await requireAuthContext(request);
|
|
13
|
+
const user: AdminUser = {
|
|
14
|
+
id: ctx.user.id,
|
|
15
|
+
email: ctx.user.email,
|
|
16
|
+
name: ctx.user.name,
|
|
17
|
+
avatarUrl: null,
|
|
18
|
+
roles: ctx.user.roles,
|
|
19
|
+
};
|
|
20
|
+
return { user, grants: ctx.grants };
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
hasRole(user: AdminUser, role: string) {
|
|
24
|
+
return hasRole(
|
|
25
|
+
user as Parameters<typeof hasRole>[0],
|
|
26
|
+
role as Parameters<typeof hasRole>[1],
|
|
27
|
+
);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async getRoles(userId: string) {
|
|
31
|
+
const e = env.get();
|
|
32
|
+
const authInstance = initAuth({ d1: e.DB, appUrl: e.APP_URL });
|
|
33
|
+
return authInstance.getRoles(userId);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async setRole(userId: string, role: string) {
|
|
37
|
+
const e = env.get();
|
|
38
|
+
const authInstance = initAuth({ d1: e.DB, appUrl: e.APP_URL });
|
|
39
|
+
await authInstance.setRole(userId, role);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async removeRole(userId: string, role: string) {
|
|
43
|
+
const e = env.get();
|
|
44
|
+
const authInstance = initAuth({ d1: e.DB, appUrl: e.APP_URL });
|
|
45
|
+
await authInstance.removeRole(userId, role);
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async setRoles(userId: string, roles: string[]) {
|
|
49
|
+
const e = env.get();
|
|
50
|
+
const authInstance = initAuth({ d1: e.DB, appUrl: e.APP_URL });
|
|
51
|
+
await authInstance.setRoles(userId, roles);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function createDbForAdmin(grants: unknown[], user: { id: string } | null) {
|
|
56
|
+
const e = env.get();
|
|
57
|
+
return createDb({
|
|
58
|
+
d1: e.DB,
|
|
59
|
+
schema: schema as unknown as DbConfig["schema"],
|
|
60
|
+
grants: grants as Parameters<typeof createDb>[0]["grants"],
|
|
61
|
+
user,
|
|
62
|
+
cache: false,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const adminConfig = {
|
|
67
|
+
db: createDbForAdmin,
|
|
68
|
+
auth,
|
|
69
|
+
schema: {
|
|
70
|
+
items: schema.items,
|
|
71
|
+
},
|
|
72
|
+
users: {
|
|
73
|
+
assignableRoles: ["admin", "member"],
|
|
74
|
+
},
|
|
75
|
+
dashboard: {
|
|
76
|
+
widgets: [
|
|
77
|
+
{ type: "count" as const, table: "items", label: "Total Items" },
|
|
78
|
+
{ type: "recent" as const, table: "items", label: "Recent Items", limit: 5 },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
requiredRole: "admin",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const tableMetas = introspectSchema(adminConfig.schema);
|
|
85
|
+
|
|
86
|
+
export const adminLoader = createAdminLoader(adminConfig, tableMetas);
|
|
87
|
+
export const adminAction = createAdminAction(adminConfig, tableMetas);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
|
|
2
|
+
import { useLoaderData } from "react-router";
|
|
3
|
+
import { AdminPanel } from "@cfast/admin/client";
|
|
4
|
+
import { joyAdminComponents } from "@cfast/ui/joy";
|
|
5
|
+
import { adminLoader, adminAction } from "~/admin.server";
|
|
6
|
+
|
|
7
|
+
export async function loader(args: LoaderFunctionArgs) {
|
|
8
|
+
return adminLoader(args.request);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function action(args: ActionFunctionArgs) {
|
|
12
|
+
return adminAction(args.request);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function Admin() {
|
|
16
|
+
const data = useLoaderData<typeof loader>();
|
|
17
|
+
return <AdminPanel data={data} components={joyAdminComponents} />;
|
|
18
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { initAuth } from "./auth.setup.server";
|
|
2
|
+
import { env } from "./env";
|
|
3
|
+
import type { Grant } from "@cfast/permissions";
|
|
4
|
+
import type { AuthUser } from "./permissions";
|
|
5
|
+
|
|
6
|
+
export { hasRole, hasAnyRole } from "./permissions";
|
|
7
|
+
export type { UserRole } from "./permissions";
|
|
8
|
+
|
|
9
|
+
export type AuthContext = { user: AuthUser | null; grants: Grant[] };
|
|
10
|
+
export type AuthenticatedContext = { user: AuthUser; grants: Grant[] };
|
|
11
|
+
|
|
12
|
+
function getAuth() {
|
|
13
|
+
const e = env.get();
|
|
14
|
+
return initAuth({ d1: e.DB, appUrl: e.APP_URL });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function getAuthContext(request: Request): Promise<AuthContext> {
|
|
18
|
+
const ctx = await getAuth().createContext(request);
|
|
19
|
+
return ctx as AuthContext;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function requireAuthContext(request: Request): Promise<AuthenticatedContext> {
|
|
23
|
+
const ctx = await getAuth().requireUser(request);
|
|
24
|
+
return ctx as AuthenticatedContext;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function getUser(request: Request): Promise<AuthUser | null> {
|
|
28
|
+
const ctx = await getAuthContext(request);
|
|
29
|
+
return ctx.user;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function requireUser(request: Request): Promise<AuthUser> {
|
|
33
|
+
const ctx = await requireAuthContext(request);
|
|
34
|
+
return ctx.user;
|
|
35
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createAuth } from "@cfast/auth";
|
|
2
|
+
import * as schema from "./db/schema";
|
|
3
|
+
import { permissions } from "./permissions";
|
|
4
|
+
import { env } from "./env";
|
|
5
|
+
|
|
6
|
+
export const initAuth = createAuth({
|
|
7
|
+
permissions,
|
|
8
|
+
schema,
|
|
9
|
+
magicLink: {
|
|
10
|
+
sendMagicLink: async ({ email, url }) => {
|
|
11
|
+
// TODO: integrate with @cfast/email when email feature is enabled
|
|
12
|
+
console.log(`Magic link for ${email}: ${url}`);
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
session: { expiresIn: "30d" },
|
|
16
|
+
defaultRoles: ["member"],
|
|
17
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { relations } from "drizzle-orm";
|
|
2
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
3
|
+
|
|
4
|
+
// Auth tables (required by Better Auth + @cfast/auth)
|
|
5
|
+
export const users = sqliteTable("users", {
|
|
6
|
+
id: text("id").primaryKey(),
|
|
7
|
+
email: text("email").notNull().unique(),
|
|
8
|
+
name: text("name").notNull(),
|
|
9
|
+
avatarUrl: text("avatar_url"),
|
|
10
|
+
emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false),
|
|
11
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
12
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const sessions = sqliteTable("sessions", {
|
|
16
|
+
id: text("id").primaryKey(),
|
|
17
|
+
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
18
|
+
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
19
|
+
token: text("token").notNull().unique(),
|
|
20
|
+
ipAddress: text("ip_address"),
|
|
21
|
+
userAgent: text("user_agent"),
|
|
22
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
23
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const accounts = sqliteTable("accounts", {
|
|
27
|
+
id: text("id").primaryKey(),
|
|
28
|
+
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
29
|
+
accountId: text("account_id").notNull(),
|
|
30
|
+
providerId: text("provider_id").notNull(),
|
|
31
|
+
accessToken: text("access_token"),
|
|
32
|
+
refreshToken: text("refresh_token"),
|
|
33
|
+
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }),
|
|
34
|
+
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
|
|
35
|
+
scope: text("scope"),
|
|
36
|
+
idToken: text("id_token"),
|
|
37
|
+
password: text("password"),
|
|
38
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
39
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const verifications = sqliteTable("verifications", {
|
|
43
|
+
id: text("id").primaryKey(),
|
|
44
|
+
identifier: text("identifier").notNull(),
|
|
45
|
+
value: text("value").notNull(),
|
|
46
|
+
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
47
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
48
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const passkeys = sqliteTable("passkeys", {
|
|
52
|
+
id: text("id").primaryKey(),
|
|
53
|
+
name: text("name"),
|
|
54
|
+
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
55
|
+
publicKey: text("public_key").notNull(),
|
|
56
|
+
credentialId: text("credential_id").notNull().unique(),
|
|
57
|
+
counter: integer("counter").notNull().default(0),
|
|
58
|
+
deviceType: text("device_type"),
|
|
59
|
+
backedUp: integer("backed_up", { mode: "boolean" }).default(false),
|
|
60
|
+
transports: text("transports"),
|
|
61
|
+
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const roles = sqliteTable("roles", {
|
|
65
|
+
id: text("id").primaryKey(),
|
|
66
|
+
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
67
|
+
role: text("role").notNull(),
|
|
68
|
+
grantedBy: text("granted_by").references(() => users.id),
|
|
69
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// App tables — add your own here
|
|
73
|
+
export const items = sqliteTable("items", {
|
|
74
|
+
id: text("id").primaryKey(),
|
|
75
|
+
title: text("title").notNull(),
|
|
76
|
+
content: text("content").notNull().default(""),
|
|
77
|
+
authorId: text("author_id").notNull().references(() => users.id),
|
|
78
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
79
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Relations
|
|
83
|
+
export const usersRelations = relations(users, ({ many }) => ({
|
|
84
|
+
items: many(items),
|
|
85
|
+
roles: many(roles),
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
export const itemsRelations = relations(items, ({ one }) => ({
|
|
89
|
+
author: one(users, { fields: [items.authorId], references: [users.id] }),
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
export const sessionsRelations = relations(sessions, ({ one }) => ({
|
|
93
|
+
user: one(users, { fields: [sessions.userId], references: [users.id] }),
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
export const accountsRelations = relations(accounts, ({ one }) => ({
|
|
97
|
+
user: one(users, { fields: [accounts.userId], references: [users.id] }),
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
export const passkeysRelations = relations(passkeys, ({ one }) => ({
|
|
101
|
+
user: one(users, { fields: [passkeys.userId], references: [users.id] }),
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
export const rolesRelations = relations(roles, ({ one }) => ({
|
|
105
|
+
user: one(users, { fields: [roles.userId], references: [users.id] }),
|
|
106
|
+
}));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createAuthRouteHandlers } from "@cfast/auth";
|
|
2
|
+
import { initAuth } from "~/auth.setup.server";
|
|
3
|
+
import { env } from "~/env";
|
|
4
|
+
|
|
5
|
+
const { loader, action } = createAuthRouteHandlers(() => {
|
|
6
|
+
const e = env.get();
|
|
7
|
+
return initAuth({ d1: e.DB, appUrl: e.APP_URL });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export { loader, action };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { LoaderFunctionArgs } from "react-router";
|
|
2
|
+
import { redirect, useLoaderData } from "react-router";
|
|
3
|
+
import { LoginPage } from "@cfast/auth/client";
|
|
4
|
+
import { joyLoginComponents } from "@cfast/ui/joy";
|
|
5
|
+
import { getUser } from "~/auth.helpers.server";
|
|
6
|
+
import { authClient } from "~/auth.client";
|
|
7
|
+
|
|
8
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
9
|
+
const user = await getUser(request);
|
|
10
|
+
if (user) throw redirect("/");
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function Login() {
|
|
15
|
+
useLoaderData<typeof loader>();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<LoginPage
|
|
19
|
+
authClient={authClient}
|
|
20
|
+
components={joyLoginComponents}
|
|
21
|
+
title="Sign In"
|
|
22
|
+
subtitle="Sign in to {{projectName}}"
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { AppLoadContext, EntryContext } from "react-router";
|
|
2
|
+
import { ServerRouter } from "react-router";
|
|
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
|
+
routerContext: EntryContext,
|
|
11
|
+
_loadContext: AppLoadContext,
|
|
12
|
+
) {
|
|
13
|
+
let shellRendered = false;
|
|
14
|
+
const userAgent = request.headers.get("user-agent");
|
|
15
|
+
|
|
16
|
+
const body = await renderToReadableStream(
|
|
17
|
+
<ServerRouter context={routerContext} url={request.url} />,
|
|
18
|
+
{
|
|
19
|
+
onError(error: unknown) {
|
|
20
|
+
responseStatusCode = 500;
|
|
21
|
+
if (shellRendered) {
|
|
22
|
+
console.error(error);
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
shellRendered = true;
|
|
28
|
+
|
|
29
|
+
if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
|
|
30
|
+
await body.allReady;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
responseHeaders.set("Content-Type", "text/html");
|
|
34
|
+
return new Response(body, {
|
|
35
|
+
headers: responseHeaders,
|
|
36
|
+
status: responseStatusCode,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { definePermissions } from "@cfast/permissions";
|
|
2
|
+
|
|
3
|
+
export type UserRole = "admin" | "member";
|
|
4
|
+
|
|
5
|
+
export type AuthUser = {
|
|
6
|
+
id: string;
|
|
7
|
+
email: string;
|
|
8
|
+
name: string;
|
|
9
|
+
roles: UserRole[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const appRoles = ["member", "admin"] as const;
|
|
13
|
+
|
|
14
|
+
export const permissions = definePermissions<AuthUser>()({
|
|
15
|
+
roles: appRoles,
|
|
16
|
+
hierarchy: {
|
|
17
|
+
admin: ["member"],
|
|
18
|
+
},
|
|
19
|
+
grants: (grant) => ({
|
|
20
|
+
member: [],
|
|
21
|
+
admin: [grant("manage", "all")],
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export function hasRole(user: AuthUser, role: UserRole): boolean {
|
|
26
|
+
if (user.roles.includes("admin")) return true;
|
|
27
|
+
return user.roles.includes(role);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hasAnyRole(user: AuthUser, checkRoles: UserRole[]): boolean {
|
|
31
|
+
return checkRoles.some((role) => hasRole(user, role));
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export default function Index() {
|
|
2
|
+
return (
|
|
3
|
+
<div style={{ fontFamily: "system-ui, sans-serif", padding: "2rem", maxWidth: "600px", margin: "0 auto" }}>
|
|
4
|
+
<h1>Welcome to {{projectName}}</h1>
|
|
5
|
+
<p>Get started by editing <code>app/routes/_index.tsx</code></p>
|
|
6
|
+
</div>
|
|
7
|
+
);
|
|
8
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "react-router build",
|
|
7
|
+
"dev": "react-router dev",
|
|
8
|
+
"preview": "pnpm run build && vite preview",
|
|
9
|
+
"typecheck": "react-router typegen && tsc -b",
|
|
10
|
+
"deploy:staging": "pnpm run build && wrangler deploy --env staging",
|
|
11
|
+
"deploy:production": "pnpm run build && wrangler deploy --env production"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@cfast/core": "^0.1.0",
|
|
15
|
+
"@cfast/env": "^0.1.0",
|
|
16
|
+
"@cfast/permissions": "^0.1.0",
|
|
17
|
+
"isbot": "^5.1.31",
|
|
18
|
+
"react": "^19.2.4",
|
|
19
|
+
"react-dom": "^19.2.4",
|
|
20
|
+
"react-router": "^7.13.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@cloudflare/vite-plugin": "^1.27.0",
|
|
24
|
+
"@cloudflare/workers-types": "^4.20260310.1",
|
|
25
|
+
"@react-router/dev": "^7.13.1",
|
|
26
|
+
"@types/react": "^19.2.7",
|
|
27
|
+
"@types/react-dom": "^19.2.3",
|
|
28
|
+
"typescript": "^5.9.2",
|
|
29
|
+
"vite": "^8.0.0",
|
|
30
|
+
"wrangler": "^4.72.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"include": [
|
|
4
|
+
".react-router/types/**/*",
|
|
5
|
+
"app/**/*",
|
|
6
|
+
"app/**/.server/**/*",
|
|
7
|
+
"app/**/.client/**/*",
|
|
8
|
+
"workers/**/*",
|
|
9
|
+
"worker-configuration.d.ts"
|
|
10
|
+
],
|
|
11
|
+
"compilerOptions": {
|
|
12
|
+
"composite": true,
|
|
13
|
+
"strict": true,
|
|
14
|
+
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
|
15
|
+
"types": ["vite/client"],
|
|
16
|
+
"target": "ES2022",
|
|
17
|
+
"module": "ES2022",
|
|
18
|
+
"moduleResolution": "bundler",
|
|
19
|
+
"jsx": "react-jsx",
|
|
20
|
+
"baseUrl": ".",
|
|
21
|
+
"rootDirs": [".", "./.react-router/types"],
|
|
22
|
+
"paths": { "~/*": ["./app/*"] },
|
|
23
|
+
"esModuleInterop": true,
|
|
24
|
+
"resolveJsonModule": true
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [],
|
|
3
|
+
"references": [
|
|
4
|
+
{ "path": "./tsconfig.node.json" },
|
|
5
|
+
{ "path": "./tsconfig.cloudflare.json" }
|
|
6
|
+
],
|
|
7
|
+
"compilerOptions": {
|
|
8
|
+
"checkJs": true,
|
|
9
|
+
"verbatimModuleSyntax": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"noEmit": true
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"include": ["vite.config.ts"],
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"composite": true,
|
|
6
|
+
"strict": true,
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"lib": ["ES2022", "DOM"],
|
|
9
|
+
"target": "ES2022",
|
|
10
|
+
"module": "ES2022",
|
|
11
|
+
"moduleResolution": "bundler"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createRequestHandler } from "react-router";
|
|
2
|
+
import { app } from "../app/cfast.server";
|
|
3
|
+
import { env } from "../app/env";
|
|
4
|
+
|
|
5
|
+
declare module "react-router" {
|
|
6
|
+
export interface AppLoadContext {
|
|
7
|
+
cloudflare: {
|
|
8
|
+
env: Env;
|
|
9
|
+
ctx: ExecutionContext;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Env = ReturnType<typeof env.get>;
|
|
15
|
+
|
|
16
|
+
const requestHandler = createRequestHandler(
|
|
17
|
+
() => import("virtual:react-router/server-build"),
|
|
18
|
+
import.meta.env.MODE,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
async fetch(request: Request, rawEnv: Record<string, unknown>, ctx: ExecutionContext) {
|
|
23
|
+
app.init(rawEnv);
|
|
24
|
+
env.init(rawEnv);
|
|
25
|
+
return requestHandler(request, {
|
|
26
|
+
cloudflare: { env: env.get(), ctx },
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
2
|
+
|
|
3
|
+
export const items = sqliteTable("items", {
|
|
4
|
+
id: text("id").primaryKey(),
|
|
5
|
+
title: text("title").notNull(),
|
|
6
|
+
content: text("content").notNull().default(""),
|
|
7
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
8
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
|
|
9
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@cfast/db": "^0.1.0",
|
|
4
|
+
"drizzle-orm": "^0.45.1"
|
|
5
|
+
},
|
|
6
|
+
"devDependencies": {
|
|
7
|
+
"drizzle-kit": "^0.31.9"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"db:generate": "drizzle-kit generate",
|
|
11
|
+
"db:migrate:local": "wrangler d1 migrations apply DB --local",
|
|
12
|
+
"db:migrate:remote": "wrangler d1 migrations apply DB --remote"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { email } from "~/email.server";
|
|
2
|
+
import { WelcomeEmail } from "./templates/welcome";
|
|
3
|
+
|
|
4
|
+
export async function sendWelcomeEmail(emailAddress: string, name: string) {
|
|
5
|
+
await email.send({
|
|
6
|
+
to: emailAddress,
|
|
7
|
+
subject: "Welcome to {{projectName}}!",
|
|
8
|
+
react: WelcomeEmail({ name }),
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface WelcomeEmailProps {
|
|
2
|
+
name: string;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function WelcomeEmail({ name }: WelcomeEmailProps) {
|
|
6
|
+
return (
|
|
7
|
+
<html>
|
|
8
|
+
<head />
|
|
9
|
+
<body style={{ fontFamily: "sans-serif", padding: "20px" }}>
|
|
10
|
+
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
|
|
11
|
+
<h1>Welcome to {{projectName}}!</h1>
|
|
12
|
+
<p>Hi {name}, your account has been created successfully.</p>
|
|
13
|
+
<hr />
|
|
14
|
+
<p style={{ color: "#666", fontSize: "12px" }}>{{projectName}}</p>
|
|
15
|
+
</div>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createEmailClient } from "@cfast/email";
|
|
2
|
+
import { mailgun } from "@cfast/email/mailgun";
|
|
3
|
+
import { console as consoleProvider } from "@cfast/email/console";
|
|
4
|
+
import type { EmailProvider } from "@cfast/email";
|
|
5
|
+
import { env } from "~/env";
|
|
6
|
+
|
|
7
|
+
let cachedProvider: EmailProvider | null = null;
|
|
8
|
+
function getProvider(): EmailProvider {
|
|
9
|
+
if (!cachedProvider) {
|
|
10
|
+
const e = env.get();
|
|
11
|
+
if (e.MAILGUN_API_KEY === "test-key") {
|
|
12
|
+
cachedProvider = consoleProvider();
|
|
13
|
+
} else {
|
|
14
|
+
cachedProvider = mailgun(() => ({
|
|
15
|
+
apiKey: env.get().MAILGUN_API_KEY,
|
|
16
|
+
domain: env.get().MAILGUN_DOMAIN,
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return cachedProvider;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const lazyProvider: EmailProvider = {
|
|
24
|
+
name: "lazy",
|
|
25
|
+
send(message) {
|
|
26
|
+
return getProvider().send(message);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const email = createEmailClient({
|
|
31
|
+
provider: lazyProvider,
|
|
32
|
+
from: () => `{{projectName}} <noreply@${env.get().MAILGUN_DOMAIN}>`,
|
|
33
|
+
});
|