initial commit

This commit is contained in:
AmanDevelops 2026-03-03 14:08:08 +05:30
parent 24415d4a20
commit c81c3f4d11
No known key found for this signature in database
GPG Key ID: 066A6D36885E8A32
20 changed files with 3581 additions and 28 deletions

View File

@ -6,7 +6,7 @@ on:
- main - main
jobs: jobs:
deploy: test-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
@ -20,6 +20,12 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Run tests
run: npm test
- name: Build app
run: npm run build
- name: Deploy to Cloudflare Workers - name: Deploy to Cloudflare Workers
run: npx wrangler deploy --minify run: npx wrangler deploy --minify
env: env:

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Dependencies
node_modules/
.npm/
# Build outputs
dist/
.wrangler/
.hono/
# Database / Storage
.wrangler/state/
*.sqlite
*.sqlite-journal
# Environment variables & Secrets
.env
.env.*
.dev.vars
.wrangler/config/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Testing
coverage/
# IDE / OS Files
.DS_Store
.vscode/
.idea/
*.swp
*.swo

View File

@ -7,7 +7,7 @@ export default defineConfig({
driver: 'd1-http', driver: 'd1-http',
dbCredentials: { dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: 'test-db-id', databaseId: '1f2607d4-7076-4854-a34d-90357c600726',
token: process.env.CLOUDFLARE_API_TOKEN!, token: process.env.CLOUDFLARE_API_TOKEN!,
}, },
}); });

View File

@ -0,0 +1,4 @@
CREATE TABLE `test` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL
);

View File

@ -0,0 +1,35 @@
CREATE TABLE `follows` (
`follower_id` integer NOT NULL,
`following_id` integer NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY(`follower_id`, `following_id`),
FOREIGN KEY (`follower_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`following_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `likes` (
`user_id` integer NOT NULL,
`post_id` integer NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY(`user_id`, `post_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`post_id`) REFERENCES `posts`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `posts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`content` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint
DROP TABLE `test`;

View File

@ -0,0 +1,42 @@
{
"version": "6",
"dialect": "sqlite",
"id": "613fcd4f-28f5-485c-affa-5e04815a2b0d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"test": {
"name": "test",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,252 @@
{
"version": "6",
"dialect": "sqlite",
"id": "49178e52-ef6b-4d62-b773-b4acb26eae3f",
"prevId": "613fcd4f-28f5-485c-affa-5e04815a2b0d",
"tables": {
"follows": {
"name": "follows",
"columns": {
"follower_id": {
"name": "follower_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"following_id": {
"name": "following_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"follows_follower_id_users_id_fk": {
"name": "follows_follower_id_users_id_fk",
"tableFrom": "follows",
"tableTo": "users",
"columnsFrom": [
"follower_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"follows_following_id_users_id_fk": {
"name": "follows_following_id_users_id_fk",
"tableFrom": "follows",
"tableTo": "users",
"columnsFrom": [
"following_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"follows_follower_id_following_id_pk": {
"columns": [
"follower_id",
"following_id"
],
"name": "follows_follower_id_following_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"likes": {
"name": "likes",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"post_id": {
"name": "post_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"likes_user_id_users_id_fk": {
"name": "likes_user_id_users_id_fk",
"tableFrom": "likes",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"likes_post_id_posts_id_fk": {
"name": "likes_post_id_posts_id_fk",
"tableFrom": "likes",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"likes_user_id_post_id_pk": {
"columns": [
"user_id",
"post_id"
],
"name": "likes_user_id_post_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"posts": {
"name": "posts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"posts_user_id_users_id_fk": {
"name": "posts_user_id_users_id_fk",
"tableFrom": "posts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1772525981058,
"tag": "0000_numerous_stryfe",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1772526212686,
"tag": "0001_eminent_tyger_tiger",
"breakpoints": true
}
]
}

1384
node_modules/.package-lock.json generated vendored

File diff suppressed because it is too large Load Diff

1340
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,10 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "dev": "vite",
"build": "vite build",
"deploy": "wrangler deploy --minify",
"test": "vitest run"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -14,16 +17,24 @@
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"hono": "^4.12.3", "hono": "^4.12.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260305.0", "@cloudflare/workers-types": "^4.20260305.0",
"@hono/vite-build": "^1.9.3",
"@hono/vite-dev-server": "^0.25.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"jsdom": "^28.1.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.0.18",
"wrangler": "^4.69.0" "wrangler": "^4.69.0"
} }
} }

24
src/App.test.tsx Normal file
View File

@ -0,0 +1,24 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import App from './App'
describe('Frontend App', () => {
it('renders loading state and then data', async () => {
// Mock the global fetch function
const mockData = [{ id: 1, name: 'Test Entry' }]
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve(mockData),
})
render(<App />)
// Check for title
expect(screen.getByText(/Full-stack Cloudflare Workers/i)).toBeInTheDocument()
// Wait for data to be displayed
await waitFor(() => {
const pre = screen.getByText(/Test Entry/i)
expect(pre).toBeInTheDocument()
})
})
})

View File

@ -1,20 +1,170 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, Link, useNavigate } from 'react-router-dom';
const App = () => { const API_BASE = '/api';
const [data, setData] = useState<any[]>([]);
useEffect(() => { const Login = ({ setAuth }: any) => {
fetch('/api/test') const [username, setUsername] = useState('');
.then((res) => res.json()) const [password, setPassword] = useState('');
.then((json) => setData(json)); const navigate = useNavigate();
}, []);
const handleLogin = async (e: any) => {
e.preventDefault();
const res = await fetch(`${API_BASE}/login`, {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
const data = await res.json();
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
setAuth(data.token);
navigate('/');
} else {
alert('Login failed');
}
};
return ( return (
<div> <div className="card">
<h1>Full-stack Cloudflare Workers</h1> <h2>Login</h2>
<pre>{JSON.stringify(data, null, 2)}</pre> <form onSubmit={handleLogin}>
<input placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} /><br/><br/>
<input placeholder="Password" type="password" value={password} onChange={e => setPassword(e.target.value)} /><br/><br/>
<button className="btn" type="submit">Login</button>
</form>
<p>Don't have an account? <Link to="/signup">Signup</Link></p>
</div> </div>
); );
}; };
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 (
<div className="card">
<h2>Signup</h2>
<form onSubmit={handleSignup}>
<input placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} /><br/><br/>
<input placeholder="Password" type="password" value={password} onChange={e => setPassword(e.target.value)} /><br/><br/>
<button className="btn" type="submit">Signup</button>
</form>
<p>Already have an account? <Link to="/login">Login</Link></p>
</div>
);
};
const Feed = ({ token }: { token: string }) => {
const [posts, setPosts] = useState<any[]>([]);
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 (
<div>
<div className="card">
<h3>What's on your mind?</h3>
<form onSubmit={handlePost}>
<textarea style={{width:'100%', marginBottom:'10px'}} value={content} onChange={e => setContent(e.target.value)} />
<button className="btn" type="submit">Post</button>
</form>
</div>
<div>
{posts.map(post => (
<div key={post.id} className="card">
<strong>@{post.username}</strong>
<p>{post.content}</p>
<button className="btn" onClick={() => handleLike(post.id)}>Like</button>
<button className="btn" style={{marginLeft:'10px', background:'#6c757d'}} onClick={() => handleFollow(post.userId)}>Follow</button>
<small style={{display:'block', marginTop:'10px', color:'#777'}}>{post.createdAt}</small>
</div>
))}
</div>
</div>
);
};
const App = () => {
const [token, setToken] = useState(localStorage.getItem('token'));
const logout = () => {
localStorage.clear();
setToken(null);
};
return (
<BrowserRouter>
<div className="container">
<nav className="nav">
<Link to="/">Feed</Link> |
{!token ? (
<> <Link to="/login">Login</Link> | <Link to="/signup">Signup</Link> </>
) : (
<button onClick={logout} style={{marginLeft:'10px'}}>Logout</button>
)}
</nav>
<Routes>
<Route path="/" element={token ? <Feed token={token} /> : <Link to="/login">Please Login</Link>} />
<Route path="/login" element={<Login setAuth={setToken} />} />
<Route path="/signup" element={<Signup />} />
</Routes>
</div>
</BrowserRouter>
);
};
export default App; export default App;

View File

@ -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 }), 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] }),
};
}); });

54
src/index.test.ts Normal file
View File

@ -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('<div id="root"></div>')
})
})

View File

@ -1,20 +1,137 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { jwt, sign, verify } from 'hono/jwt'
import { drizzle } from 'drizzle-orm/d1' 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 = { type Bindings = {
DB: D1Database DB: D1Database
JWT_SECRET: string
} }
const app = new Hono<{ Bindings: Bindings }>() const app = new Hono<{ Bindings: Bindings }>()
app.get('/api/test', async (c) => { // Middleware for JWT protection on some routes
const db = drizzle(c.env.DB) app.use('/api/protected/*', async (c, next) => {
const result = await db.select().from(testTable).all() const jwtMiddleware = jwt({
return c.json(result) 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) => { app.get('*', async (c) => {
return c.html(` return c.html(`
<!DOCTYPE html> <!DOCTYPE html>
@ -22,7 +139,24 @@ app.get('*', async (c) => {
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Full-stack Worker</title> <title>Social Media App</title>
${import.meta.env?.PROD ? '' : `
<script type="module">
import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__reactRefreshLog = () => {}
</script>
<script type="module" src="/@vite/client"></script>
`}
<style>
body { font-family: sans-serif; background: #f0f2f5; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: auto; }
.card { background: white; padding: 15px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
.btn { background: #007bff; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; }
.nav { margin-bottom: 20px; }
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

11
src/test-setup.ts Normal file
View File

@ -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 }) }
})

14
vite.config.ts Normal file
View File

@ -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,
}),
],
})

11
vitest.config.ts Normal file
View File

@ -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',
},
})

View File

@ -5,4 +5,5 @@ main = "src/index.ts"
[[d1_databases]] [[d1_databases]]
binding = "DB" binding = "DB"
database_name = "test-db" database_name = "test-db"
database_id = "test-db-id" database_id = "1f2607d4-7076-4854-a34d-90357c600726"
migrations_dir = "drizzle"