-
Full-stack Cloudflare Workers
-
{JSON.stringify(data, null, 2)}
+
+
Login
+
+
Don't have an account? Signup
);
};
+const Signup = () => {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const navigate = useNavigate();
+
+ const handleSignup = async (e: any) => {
+ e.preventDefault();
+ const res = await fetch(`${API_BASE}/signup`, {
+ method: 'POST',
+ body: JSON.stringify({ username, password }),
+ headers: { 'Content-Type': 'application/json' }
+ });
+ if (res.ok) {
+ alert('Signup success! Please login.');
+ navigate('/login');
+ } else {
+ alert('Signup failed');
+ }
+ };
+
+ return (
+
+
Signup
+
+
Already have an account? Login
+
+ );
+};
+
+const Feed = ({ token }: { token: string }) => {
+ const [posts, setPosts] = useState
([]);
+ const [content, setContent] = useState('');
+
+ const fetchFeed = async () => {
+ const res = await fetch(`${API_BASE}/protected/feed`, {
+ headers: { 'Authorization': `Bearer ${token}` }
+ });
+ const data = await res.json();
+ setPosts(data);
+ };
+
+ const handlePost = async (e: any) => {
+ e.preventDefault();
+ await fetch(`${API_BASE}/protected/posts`, {
+ method: 'POST',
+ body: JSON.stringify({ content }),
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
+ });
+ setContent('');
+ fetchFeed();
+ };
+
+ const handleLike = async (id: number) => {
+ await fetch(`${API_BASE}/protected/like/${id}`, {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}` }
+ });
+ alert('Liked!');
+ };
+
+ const handleFollow = async (userId: number) => {
+ await fetch(`${API_BASE}/protected/follow/${userId}`, {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}` }
+ });
+ alert('Followed!');
+ fetchFeed();
+ };
+
+ useEffect(() => { fetchFeed(); }, []);
+
+ return (
+
+
+
What's on your mind?
+
+
+
+ {posts.map(post => (
+
+
@{post.username}
+
{post.content}
+
+
+
{post.createdAt}
+
+ ))}
+
+
+ );
+};
+
+const App = () => {
+ const [token, setToken] = useState(localStorage.getItem('token'));
+
+ const logout = () => {
+ localStorage.clear();
+ setToken(null);
+ };
+
+ return (
+
+
+
+
+ : Please Login} />
+ } />
+ } />
+
+
+
+ );
+};
+
export default App;
diff --git a/src/db/schema.ts b/src/db/schema.ts
index bee584c..87c59e3 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -1,6 +1,36 @@
-import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
+import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core';
+import { sql } from 'drizzle-orm';
-export const testTable = sqliteTable('test', {
+export const usersTable = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
- name: text('name').notNull(),
+ username: text('username').notNull().unique(),
+ passwordHash: text('password_hash').notNull(),
+ createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
+});
+
+export const postsTable = sqliteTable('posts', {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ userId: integer('user_id').notNull().references(() => usersTable.id),
+ content: text('content').notNull(),
+ createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
+});
+
+export const followsTable = sqliteTable('follows', {
+ followerId: integer('follower_id').notNull().references(() => usersTable.id),
+ followingId: integer('following_id').notNull().references(() => usersTable.id),
+ createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
+}, (table) => {
+ return {
+ pk: primaryKey({ columns: [table.followerId, table.followingId] }),
+ };
+});
+
+export const likesTable = sqliteTable('likes', {
+ userId: integer('user_id').notNull().references(() => usersTable.id),
+ postId: integer('post_id').notNull().references(() => postsTable.id),
+ createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
+}, (table) => {
+ return {
+ pk: primaryKey({ columns: [table.userId, table.postId] }),
+ };
});
diff --git a/src/index.test.ts b/src/index.test.ts
new file mode 100644
index 0000000..274f52e
--- /dev/null
+++ b/src/index.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect, vi } from 'vitest'
+import app from './index'
+
+describe('Backend API', () => {
+ it('GET /api/test should return JSON', async () => {
+ // Mock the environment with a fake D1 database
+ const mockData = [{ id: 1, name: 'Test Entry' }]
+ const mockStmt = {
+ bind: vi.fn().mockReturnThis(),
+ all: vi.fn().mockResolvedValue({ results: mockData, success: true }),
+ raw: vi.fn().mockResolvedValue(mockData),
+ run: vi.fn().mockResolvedValue({ success: true }),
+ }
+ const env = {
+ DB: {
+ prepare: vi.fn().mockReturnValue(mockStmt)
+ }
+ }
+ // Drizzle-D1 requires a specific structure for mocked responses if it uses metadata
+ // For this simple case, we'll bypass the ORM and return direct json if needed,
+ // but let's try to fix the mock for drizzle.
+
+ const res = await app.request('/api/test', {}, env as any)
+ if (res.status === 500) {
+ const err = await res.text()
+ console.error(err)
+ }
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body).toEqual([{ id: 1, name: 'Test Entry' }])
+ })
+
+ it('GET /api/raw-test should return JSON from raw D1', async () => {
+ const mockData = [{ id: 1, name: 'Test Entry' }]
+ const env = {
+ DB: {
+ prepare: vi.fn().mockReturnValue({
+ all: vi.fn().mockResolvedValue({ results: mockData })
+ })
+ }
+ }
+ const res = await app.request('/api/raw-test', {}, env as any)
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body).toEqual(mockData)
+ })
+
+ it('GET / should return HTML with root div', async () => {
+ const res = await app.request('/')
+ expect(res.status).toBe(200)
+ const text = await res.text()
+ expect(text).toContain('')
+ })
+})
diff --git a/src/index.ts b/src/index.ts
index d26759b..2f07281 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,20 +1,137 @@
import { Hono } from 'hono'
+import { jwt, sign, verify } from 'hono/jwt'
import { drizzle } from 'drizzle-orm/d1'
-import { testTable } from './db/schema'
+import { eq, and, inArray, desc } from 'drizzle-orm'
+import { usersTable, postsTable, followsTable, likesTable } from './db/schema'
type Bindings = {
DB: D1Database
+ JWT_SECRET: string
}
const app = new Hono<{ Bindings: Bindings }>()
-app.get('/api/test', async (c) => {
- const db = drizzle(c.env.DB)
- const result = await db.select().from(testTable).all()
- return c.json(result)
+// Middleware for JWT protection on some routes
+app.use('/api/protected/*', async (c, next) => {
+ const jwtMiddleware = jwt({
+ secret: c.env.JWT_SECRET || 'supersecret',
+ alg: 'HS256'
+ })
+ return jwtMiddleware(c, next)
})
-// For static serving (to be built by Vite)
+// --- AUTH ---
+app.post('/api/signup', async (c) => {
+ const { username, password } = await c.req.json()
+ const db = drizzle(c.env.DB)
+
+ try {
+ const result = await db.insert(usersTable).values({
+ username,
+ passwordHash: password, // In a real app, hash this!
+ }).returning()
+
+ return c.json({ success: true, user: result[0] })
+ } catch (e) {
+ console.error('Signup error:', e)
+ return c.json({ error: 'Username taken or database error', details: String(e) }, 400)
+ }
+})
+
+app.post('/api/login', async (c) => {
+ const { username, password } = await c.req.json()
+ const db = drizzle(c.env.DB)
+
+ const user = await db.select().from(usersTable)
+ .where(and(eq(usersTable.username, username), eq(usersTable.passwordHash, password)))
+ .get()
+
+ if (!user) return c.json({ error: 'Invalid credentials' }, 401)
+
+ const token = await sign({ id: user.id, username: user.username }, c.env.JWT_SECRET || 'supersecret', 'HS256')
+ return c.json({ token, user })
+})
+
+// --- POSTS & FEED ---
+app.get('/api/protected/feed', async (c) => {
+ const payload = c.get('jwtPayload')
+ const db = drizzle(c.env.DB)
+
+ try {
+ // Get followed users
+ const followed = await db.select({ id: followsTable.followingId })
+ .from(followsTable)
+ .where(eq(followsTable.followerId, payload.id))
+ .all()
+
+ const followedIds = [payload.id, ...followed.map(f => f.id)]
+
+ const feed = await db.select({
+ id: postsTable.id,
+ content: postsTable.content,
+ createdAt: postsTable.createdAt,
+ username: usersTable.username,
+ userId: usersTable.id
+ })
+ .from(postsTable)
+ .leftJoin(usersTable, eq(postsTable.userId, usersTable.id))
+ .where(inArray(postsTable.userId, followedIds))
+ .orderBy(desc(postsTable.createdAt))
+ .all()
+
+ return c.json(feed)
+ } catch (e) {
+ console.error('Feed error:', e)
+ return c.json({ error: 'Failed to fetch feed', details: String(e) }, 500)
+ }
+})
+
+app.post('/api/protected/posts', async (c) => {
+ const payload = c.get('jwtPayload')
+ const { content } = await c.req.json()
+ const db = drizzle(c.env.DB)
+
+ try {
+ const result = await db.insert(postsTable).values({
+ userId: payload.id,
+ content,
+ }).returning()
+
+ return c.json(result[0])
+ } catch (e) {
+ console.error('Post creation error:', e)
+ return c.json({ error: 'Failed to create post', details: String(e) }, 500)
+ }
+})
+
+// --- FOLLOW & LIKE ---
+app.post('/api/protected/follow/:id', async (c) => {
+ const payload = c.get('jwtPayload')
+ const targetId = parseInt(c.req.param('id'))
+ const db = drizzle(c.env.DB)
+
+ await db.insert(followsTable).values({
+ followerId: payload.id,
+ followingId: targetId,
+ }).run()
+
+ return c.json({ success: true })
+})
+
+app.post('/api/protected/like/:id', async (c) => {
+ const payload = c.get('jwtPayload')
+ const postId = parseInt(c.req.param('id'))
+ const db = drizzle(c.env.DB)
+
+ await db.insert(likesTable).values({
+ userId: payload.id,
+ postId,
+ }).run()
+
+ return c.json({ success: true })
+})
+
+// Serve React App
app.get('*', async (c) => {
return c.html(`
@@ -22,7 +139,24 @@ app.get('*', async (c) => {
- Full-stack Worker
+ Social Media App
+ ${import.meta.env?.PROD ? '' : `
+
+
+ `}
+
diff --git a/src/test-setup.ts b/src/test-setup.ts
new file mode 100644
index 0000000..b5ee9fd
--- /dev/null
+++ b/src/test-setup.ts
@@ -0,0 +1,11 @@
+import '@testing-library/jest-dom/vitest'
+import { vi } from 'vitest'
+
+// Mock the Cloudflare D1 environment
+vi.stubGlobal('D1Database', class {
+ prepare() { return this }
+ bind() { return this }
+ all() { return Promise.resolve({ results: [] }) }
+ first() { return Promise.resolve(null) }
+ run() { return Promise.resolve({ success: true }) }
+})
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..356bb2e
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite'
+import devServer from '@hono/vite-dev-server'
+import cloudflareAdapter from '@hono/vite-dev-server/cloudflare'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [
+ react(),
+ devServer({
+ entry: 'src/index.ts', // Hono entry point
+ adapter: cloudflareAdapter,
+ }),
+ ],
+})
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..b79e11a
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vitest/config'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: './src/test-setup.ts',
+ },
+})
diff --git a/wrangler.toml b/wrangler.toml
index 2f5f1c1..367f664 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -5,4 +5,5 @@ main = "src/index.ts"
[[d1_databases]]
binding = "DB"
database_name = "test-db"
-database_id = "test-db-id"
+database_id = "1f2607d4-7076-4854-a34d-90357c600726"
+migrations_dir = "drizzle"