vasp-cli 0.3.0 → 0.4.0
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/dist/vasp +25 -24
- package/package.json +1 -1
- package/starters/recipe.vasp +79 -0
- package/templates/shared/auth/client/Login.vue.hbs +1 -1
- package/templates/shared/auth/client/Register.vue.hbs +1 -1
- package/templates/shared/drizzle/drizzle.config.hbs +10 -0
- package/templates/shared/drizzle/schema.hbs +14 -3
- package/templates/shared/jobs/_job.hbs +12 -2
- package/templates/shared/package.json.hbs +4 -1
- package/templates/shared/server/index.hbs +8 -0
- package/templates/shared/server/middleware/csrf.hbs +34 -0
- package/templates/shared/server/middleware/rateLimit.hbs +44 -0
- package/templates/shared/server/routes/crud/_crud.hbs +47 -3
- package/templates/shared/server/routes/realtime/_channel.hbs +58 -10
- package/templates/ssr/js/plugins/vasp.client.js.hbs +11 -1
- package/templates/ssr/ts/plugins/vasp.client.ts.hbs +11 -1
- package/templates/templates/shared/.env.hbs +14 -0
- package/templates/templates/shared/README.md.hbs +53 -0
- package/templates/templates/shared/auth/server/index.hbs +3 -8
- package/templates/templates/shared/auth/server/plugin.hbs +9 -0
- package/templates/templates/shared/auth/server/providers/github.hbs +1 -1
- package/templates/templates/shared/auth/server/providers/google.hbs +1 -1
- package/templates/templates/shared/auth/server/providers/usernameAndPassword.hbs +3 -6
- package/templates/templates/shared/bunfig.toml.hbs +3 -0
- package/templates/templates/shared/package.json.hbs +4 -1
- package/templates/templates/shared/server/index.hbs +13 -0
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
app RecipeApp {
|
|
2
|
+
title: "Recipe App"
|
|
3
|
+
db: Drizzle
|
|
4
|
+
ssr: false
|
|
5
|
+
typescript: false
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
auth RecipeAuth {
|
|
9
|
+
userEntity: User
|
|
10
|
+
methods: [usernameAndPassword]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
entity User {
|
|
14
|
+
id: Int @id
|
|
15
|
+
username: String @unique
|
|
16
|
+
password: String
|
|
17
|
+
createdAt: DateTime @default(now)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
entity Recipe {
|
|
21
|
+
id: Int @id
|
|
22
|
+
title: String
|
|
23
|
+
description: String
|
|
24
|
+
ingredients: String
|
|
25
|
+
instructions: String
|
|
26
|
+
cookTime: Int
|
|
27
|
+
servings: Int
|
|
28
|
+
imageUrl: String
|
|
29
|
+
createdAt: DateTime @default(now)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
route HomeRoute {
|
|
33
|
+
path: "/"
|
|
34
|
+
to: HomePage
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
route RecipesRoute {
|
|
38
|
+
path: "/recipes"
|
|
39
|
+
to: RecipesPage
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
route AddRecipeRoute {
|
|
43
|
+
path: "/recipes/add"
|
|
44
|
+
to: AddRecipePage
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
page HomePage {
|
|
48
|
+
component: import Home from "@src/pages/Home.vue"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
page RecipesPage {
|
|
52
|
+
component: import Recipes from "@src/pages/Recipes.vue"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
page AddRecipePage {
|
|
56
|
+
component: import AddRecipe from "@src/pages/AddRecipe.vue"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
crud Recipe {
|
|
60
|
+
entity: Recipe
|
|
61
|
+
operations: [list, create, update, delete]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
query getRecipes {
|
|
65
|
+
fn: import { getRecipes } from "@src/queries.js"
|
|
66
|
+
entities: [Recipe]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
action createRecipe {
|
|
70
|
+
fn: import { createRecipe } from "@src/actions.js"
|
|
71
|
+
entities: [Recipe]
|
|
72
|
+
auth: true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
action deleteRecipe {
|
|
76
|
+
fn: import { deleteRecipe } from "@src/actions.js"
|
|
77
|
+
entities: [Recipe]
|
|
78
|
+
auth: true
|
|
79
|
+
}
|
|
@@ -38,7 +38,7 @@ async function handleLogin() {
|
|
|
38
38
|
await login(username.value, password.value)
|
|
39
39
|
router.push('/')
|
|
40
40
|
} catch (err) {
|
|
41
|
-
error.value = err?.data?.error || 'Login failed'
|
|
41
|
+
error.value = err?.data?.error || err?.message || 'Login failed'
|
|
42
42
|
} finally {
|
|
43
43
|
loading.value = false
|
|
44
44
|
}
|
|
@@ -34,7 +34,7 @@ async function handleRegister() {
|
|
|
34
34
|
await register(username.value, password.value, email.value || undefined)
|
|
35
35
|
router.push('/')
|
|
36
36
|
} catch (err) {
|
|
37
|
-
error.value = err?.data?.error || 'Registration failed'
|
|
37
|
+
error.value = err?.data?.error || err?.message || 'Registration failed'
|
|
38
38
|
} finally {
|
|
39
39
|
loading.value = false
|
|
40
40
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pgTable, serial, text,
|
|
1
|
+
import { pgTable, serial, text, integer, boolean, timestamp, doublePrecision } from 'drizzle-orm/pg-core'
|
|
2
2
|
{{#if isTypeScript}}
|
|
3
3
|
import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'
|
|
4
4
|
{{/if}}
|
|
@@ -21,14 +21,25 @@ export type NewUser = InferInsertModel<typeof users>
|
|
|
21
21
|
{{/if}}
|
|
22
22
|
|
|
23
23
|
{{/if}}
|
|
24
|
-
{{#each
|
|
25
|
-
|
|
24
|
+
{{#each crudsWithFields}}
|
|
25
|
+
{{#if hasEntity}}
|
|
26
|
+
// {{entity}} table — generated from entity block
|
|
27
|
+
export const {{camelCase entity}}s = pgTable('{{camelCase entity}}s', {
|
|
28
|
+
{{#each fields}}
|
|
29
|
+
{{camelCase name}}: {{{drizzleColumn name type modifiers}}},
|
|
30
|
+
{{/each}}
|
|
31
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
32
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
33
|
+
})
|
|
34
|
+
{{else}}
|
|
35
|
+
// {{entity}} table — no entity block found, add your columns below
|
|
26
36
|
export const {{camelCase entity}}s = pgTable('{{camelCase entity}}s', {
|
|
27
37
|
id: serial('id').primaryKey(),
|
|
28
38
|
// TODO: Add your {{entity}} columns here
|
|
29
39
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
30
40
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
31
41
|
})
|
|
42
|
+
{{/if}}
|
|
32
43
|
{{#if ../isTypeScript}}
|
|
33
44
|
export type {{pascalCase entity}} = InferSelectModel<typeof {{camelCase entity}}s>
|
|
34
45
|
export type New{{pascalCase entity}} = InferInsertModel<typeof {{camelCase entity}}s>
|
|
@@ -12,13 +12,23 @@ export async function register{{pascalCase name}}Worker() {
|
|
|
12
12
|
await boss.work(JOB_NAME, async (job) => {
|
|
13
13
|
await {{namedExport}}(job.data)
|
|
14
14
|
})
|
|
15
|
+
{{#if hasSchedule}}
|
|
16
|
+
|
|
17
|
+
// Register cron schedule: {{schedule}}
|
|
18
|
+
await boss.schedule(JOB_NAME, '{{schedule}}', {})
|
|
19
|
+
{{/if}}
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
|
-
* Schedule a '{{name}}' job.
|
|
23
|
+
* Schedule a '{{name}}' job on demand.
|
|
19
24
|
* @param {unknown} data - Data to pass to the job handler
|
|
20
25
|
*/
|
|
21
26
|
export async function schedule{{pascalCase name}}(data) {
|
|
22
27
|
const boss = await getBoss()
|
|
23
|
-
return boss.send(JOB_NAME, data
|
|
28
|
+
return boss.send(JOB_NAME, data, {
|
|
29
|
+
retryLimit: Number(process.env.JOB_RETRY_LIMIT) || 3,
|
|
30
|
+
retryDelay: Number(process.env.JOB_RETRY_DELAY) || 60,
|
|
31
|
+
retryBackoff: true,
|
|
32
|
+
expireInMinutes: Number(process.env.JOB_EXPIRE_MINUTES) || 15,
|
|
33
|
+
})
|
|
24
34
|
}
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
"dev": "vasp start",
|
|
8
8
|
"build": "vasp build",
|
|
9
9
|
"dev:server": "bun --hot server/index.{{ext}}",
|
|
10
|
-
"dev:client": "{{#if isSpa}}vite{{else}}nuxt dev{{/if}}"
|
|
10
|
+
"dev:client": "{{#if isSpa}}vite{{else}}nuxt dev{{/if}}",
|
|
11
|
+
"db:generate": "bunx drizzle-kit generate",
|
|
12
|
+
"db:migrate": "bunx drizzle-kit migrate",
|
|
13
|
+
"db:studio": "bunx drizzle-kit studio"
|
|
11
14
|
},
|
|
12
15
|
"dependencies": {
|
|
13
16
|
"@vasp-framework/runtime": "^0.1.0",
|
|
@@ -2,6 +2,10 @@ import { Elysia } from 'elysia'
|
|
|
2
2
|
import { cors } from '@elysiajs/cors'
|
|
3
3
|
import { staticPlugin } from '@elysiajs/static'
|
|
4
4
|
import { db } from './db/client.{{ext}}'
|
|
5
|
+
import { rateLimit } from './middleware/rateLimit.{{ext}}'
|
|
6
|
+
{{#if isSsr}}
|
|
7
|
+
import { csrfProtection } from './middleware/csrf.{{ext}}'
|
|
8
|
+
{{/if}}
|
|
5
9
|
{{#if hasAuth}}
|
|
6
10
|
import { authRoutes } from './auth/index.{{ext}}'
|
|
7
11
|
{{/if}}
|
|
@@ -28,6 +32,10 @@ const app = new Elysia()
|
|
|
28
32
|
origin: process.env.CORS_ORIGIN || 'http://localhost:{{frontendPort}}',
|
|
29
33
|
credentials: true,
|
|
30
34
|
}))
|
|
35
|
+
.use(rateLimit())
|
|
36
|
+
{{#if isSsr}}
|
|
37
|
+
.use(csrfProtection())
|
|
38
|
+
{{/if}}
|
|
31
39
|
.get('/api/health', () => ({ status: 'ok', version: '{{vaspVersion}}' }))
|
|
32
40
|
{{#if hasAuth}}
|
|
33
41
|
.use(authRoutes)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Elysia } from 'elysia'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
const CSRF_COOKIE = 'vasp-csrf'
|
|
5
|
+
const CSRF_HEADER = 'x-csrf-token'
|
|
6
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CSRF protection middleware — double-submit cookie pattern.
|
|
10
|
+
* On every response, sets a CSRF cookie with a random token.
|
|
11
|
+
* On state-changing requests (POST, PUT, DELETE), validates that the
|
|
12
|
+
* x-csrf-token header matches the cookie value.
|
|
13
|
+
*/
|
|
14
|
+
export function csrfProtection() {
|
|
15
|
+
return new Elysia({ name: 'csrf' })
|
|
16
|
+
.onRequest(({ request, set, cookie }) => {
|
|
17
|
+
// Generate token if not already set
|
|
18
|
+
if (!cookie[CSRF_COOKIE]?.value) {
|
|
19
|
+
const token = randomBytes(32).toString('hex')
|
|
20
|
+
cookie[CSRF_COOKIE].set({ value: token, httpOnly: false, sameSite: 'strict', path: '/' })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Skip validation for safe methods
|
|
24
|
+
if (SAFE_METHODS.has(request.method)) return
|
|
25
|
+
|
|
26
|
+
const cookieToken = cookie[CSRF_COOKIE]?.value
|
|
27
|
+
const headerToken = request.headers.get(CSRF_HEADER)
|
|
28
|
+
|
|
29
|
+
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
|
30
|
+
set.status = 403
|
|
31
|
+
return { error: 'CSRF token mismatch' }
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Elysia } from 'elysia'
|
|
2
|
+
|
|
3
|
+
const MAX_REQUESTS = Number(process.env.RATE_LIMIT_MAX) || 100
|
|
4
|
+
const WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000
|
|
5
|
+
|
|
6
|
+
const hits = new Map()
|
|
7
|
+
|
|
8
|
+
// Clean up expired entries every 60 seconds
|
|
9
|
+
setInterval(() => {
|
|
10
|
+
const now = Date.now()
|
|
11
|
+
for (const [key, entry] of hits) {
|
|
12
|
+
if (now - entry.start > WINDOW_MS) hits.delete(key)
|
|
13
|
+
}
|
|
14
|
+
}, 60_000)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Simple in-memory sliding-window rate limiter.
|
|
18
|
+
* Limits each IP to MAX_REQUESTS per WINDOW_MS.
|
|
19
|
+
*/
|
|
20
|
+
export function rateLimit() {
|
|
21
|
+
return new Elysia({ name: 'rate-limit' })
|
|
22
|
+
.onBeforeHandle(({ request, set }) => {
|
|
23
|
+
const ip = request.headers.get('x-forwarded-for')
|
|
24
|
+
|| request.headers.get('x-real-ip')
|
|
25
|
+
|| 'unknown'
|
|
26
|
+
const now = Date.now()
|
|
27
|
+
let entry = hits.get(ip)
|
|
28
|
+
|
|
29
|
+
if (!entry || now - entry.start > WINDOW_MS) {
|
|
30
|
+
entry = { start: now, count: 0 }
|
|
31
|
+
hits.set(ip, entry)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
entry.count++
|
|
35
|
+
|
|
36
|
+
set.headers['x-ratelimit-limit'] = String(MAX_REQUESTS)
|
|
37
|
+
set.headers['x-ratelimit-remaining'] = String(Math.max(0, MAX_REQUESTS - entry.count))
|
|
38
|
+
|
|
39
|
+
if (entry.count > MAX_REQUESTS) {
|
|
40
|
+
set.status = 429
|
|
41
|
+
return { error: 'Too many requests. Please try again later.' }
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
@@ -1,23 +1,61 @@
|
|
|
1
1
|
import { Elysia, t } from 'elysia'
|
|
2
2
|
import { db } from '../../db/client.{{ext}}'
|
|
3
|
-
import { eq } from 'drizzle-orm'
|
|
3
|
+
import { eq, sql, asc, desc, and, ilike } from 'drizzle-orm'
|
|
4
4
|
import { {{camelCase entity}}s } from '../../../drizzle/schema.{{ext}}'
|
|
5
5
|
{{#if hasAuth}}
|
|
6
6
|
import { requireAuth } from '../../auth/middleware.{{ext}}'
|
|
7
7
|
{{/if}}
|
|
8
|
+
{{#if hasRealtime}}
|
|
9
|
+
import { publish{{pascalCase entity}} } from '../realtime/{{camelCase realtimeName}}.{{ext}}'
|
|
10
|
+
{{/if}}
|
|
8
11
|
|
|
9
12
|
export const {{camelCase entity}}CrudRoutes = new Elysia({ prefix: '/api/crud/{{camelCase entity}}' })
|
|
10
13
|
{{#if hasAuth}}
|
|
11
14
|
.use(requireAuth)
|
|
12
15
|
{{/if}}
|
|
13
16
|
{{#if (includes operations "list")}}
|
|
14
|
-
.get('/', async () => {
|
|
15
|
-
|
|
17
|
+
.get('/', async ({ query }) => {
|
|
18
|
+
const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 100)
|
|
19
|
+
const offset = Math.max(Number(query.offset) || 0, 0)
|
|
20
|
+
|
|
21
|
+
const table = {{camelCase entity}}s
|
|
22
|
+
|
|
23
|
+
// Multi-column sorting: orderBy=col1,col2 & dir=asc,desc
|
|
24
|
+
const orderByFields = (query.orderBy ?? 'id').split(',')
|
|
25
|
+
const directions = (query.dir ?? 'asc').split(',')
|
|
26
|
+
const orderClauses = orderByFields.map((field, i) => {
|
|
27
|
+
const col = table[field.trim()] ?? table.id
|
|
28
|
+
const dirFn = (directions[i] ?? directions[0] ?? 'asc').trim() === 'desc' ? desc : asc
|
|
29
|
+
return dirFn(col)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Build WHERE conditions from filter.* query params
|
|
33
|
+
const conditions = []
|
|
34
|
+
for (const [key, value] of Object.entries(query)) {
|
|
35
|
+
if (!key.startsWith('filter.')) continue
|
|
36
|
+
const field = key.slice(7)
|
|
37
|
+
if (!table[field]) continue
|
|
38
|
+
conditions.push(eq(table[field], value))
|
|
39
|
+
}
|
|
40
|
+
const where = conditions.length > 0 ? and(...conditions) : undefined
|
|
41
|
+
|
|
42
|
+
const baseQuery = db.select().from(table)
|
|
43
|
+
const countQuery = db.select({ count: sql`count(*)::int` }).from(table)
|
|
44
|
+
|
|
45
|
+
const [data, countResult] = await Promise.all([
|
|
46
|
+
(where ? baseQuery.where(where) : baseQuery).orderBy(...orderClauses).limit(limit).offset(offset),
|
|
47
|
+
where ? countQuery.where(where) : countQuery,
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
return { data, total: countResult[0]?.count ?? 0, limit, offset }
|
|
16
51
|
})
|
|
17
52
|
{{/if}}
|
|
18
53
|
{{#if (includes operations "create")}}
|
|
19
54
|
.post('/', async ({ body }) => {
|
|
20
55
|
const [created] = await db.insert({{camelCase entity}}s).values(body).returning()
|
|
56
|
+
{{#if hasRealtime}}
|
|
57
|
+
publish{{pascalCase entity}}('created', created)
|
|
58
|
+
{{/if}}
|
|
21
59
|
return created
|
|
22
60
|
})
|
|
23
61
|
{{/if}}
|
|
@@ -30,6 +68,9 @@ export const {{camelCase entity}}CrudRoutes = new Elysia({ prefix: '/api/crud/{{
|
|
|
30
68
|
.put('/:id', async ({ params: { id }, body, set }) => {
|
|
31
69
|
const [updated] = await db.update({{camelCase entity}}s).set(body).where(eq({{camelCase entity}}s.id, Number(id))).returning()
|
|
32
70
|
if (!updated) { set.status = 404; return { error: 'Not found' } }
|
|
71
|
+
{{#if hasRealtime}}
|
|
72
|
+
publish{{pascalCase entity}}('updated', updated)
|
|
73
|
+
{{/if}}
|
|
33
74
|
return updated
|
|
34
75
|
})
|
|
35
76
|
{{/if}}
|
|
@@ -37,6 +78,9 @@ export const {{camelCase entity}}CrudRoutes = new Elysia({ prefix: '/api/crud/{{
|
|
|
37
78
|
.delete('/:id', async ({ params: { id }, set }) => {
|
|
38
79
|
const [deleted] = await db.delete({{camelCase entity}}s).where(eq({{camelCase entity}}s.id, Number(id))).returning()
|
|
39
80
|
if (!deleted) { set.status = 404; return { error: 'Not found' } }
|
|
81
|
+
{{#if hasRealtime}}
|
|
82
|
+
publish{{pascalCase entity}}('deleted', deleted)
|
|
83
|
+
{{/if}}
|
|
40
84
|
return { ok: true }
|
|
41
85
|
})
|
|
42
86
|
{{/if}}
|
|
@@ -1,30 +1,78 @@
|
|
|
1
1
|
import { Elysia } from 'elysia'
|
|
2
|
+
{{#if ../hasAuth}}
|
|
3
|
+
import jwt from '@elysiajs/jwt'
|
|
4
|
+
{{/if}}
|
|
2
5
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
const
|
|
6
|
+
// Room-based subscriber map for '{{name}}' channel
|
|
7
|
+
// Each room key maps to a set of connected WebSocket clients
|
|
8
|
+
const rooms = new Map()
|
|
9
|
+
|
|
10
|
+
function getRoom(roomId) {
|
|
11
|
+
if (!rooms.has(roomId)) rooms.set(roomId, new Set())
|
|
12
|
+
return rooms.get(roomId)
|
|
13
|
+
}
|
|
6
14
|
|
|
7
15
|
/**
|
|
8
|
-
* Publish a realtime event to all
|
|
16
|
+
* Publish a realtime event to all subscribers in a specific room of '{{name}}'.
|
|
9
17
|
* Called automatically by CRUD mutation handlers.
|
|
10
18
|
*/
|
|
11
|
-
export function publish{{pascalCase name}}(event, data) {
|
|
19
|
+
export function publish{{pascalCase name}}(event, data, roomId = 'default') {
|
|
20
|
+
const room = rooms.get(roomId)
|
|
21
|
+
if (!room) return
|
|
22
|
+
const message = JSON.stringify({ channel: '{{camelCase name}}', room: roomId, event, data })
|
|
23
|
+
for (const ws of room) {
|
|
24
|
+
try { ws.send(message) } catch { room.delete(ws) }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Broadcast to all rooms of '{{name}}'.
|
|
30
|
+
*/
|
|
31
|
+
export function broadcast{{pascalCase name}}(event, data) {
|
|
12
32
|
const message = JSON.stringify({ channel: '{{camelCase name}}', event, data })
|
|
13
|
-
for (const
|
|
14
|
-
|
|
33
|
+
for (const [, room] of rooms) {
|
|
34
|
+
for (const ws of room) {
|
|
35
|
+
try { ws.send(message) } catch { room.delete(ws) }
|
|
36
|
+
}
|
|
15
37
|
}
|
|
16
38
|
}
|
|
17
39
|
|
|
18
40
|
export const {{camelCase name}}Channel = new Elysia()
|
|
41
|
+
{{#if ../hasAuth}}
|
|
42
|
+
.use(jwt({ name: 'jwt', secret: process.env.JWT_SECRET || 'vasp-dev-secret' }))
|
|
43
|
+
{{/if}}
|
|
19
44
|
.ws('/ws/{{camelCase name}}', {
|
|
45
|
+
{{#if ../hasAuth}}
|
|
46
|
+
async beforeHandle({ jwt: jwtPlugin, request }) {
|
|
47
|
+
const url = new URL(request.url)
|
|
48
|
+
const token = url.searchParams.get('token')
|
|
49
|
+
if (!token) return new Response('Unauthorized', { status: 401 })
|
|
50
|
+
const payload = await jwtPlugin.verify(token)
|
|
51
|
+
if (!payload) return new Response('Unauthorized', { status: 401 })
|
|
52
|
+
},
|
|
53
|
+
{{/if}}
|
|
20
54
|
open(ws) {
|
|
21
|
-
|
|
55
|
+
const room = ws.data?.query?.room ?? 'default'
|
|
56
|
+
getRoom(room).add(ws)
|
|
57
|
+
ws.data._room = room
|
|
22
58
|
},
|
|
23
59
|
message(ws, msg) {
|
|
24
|
-
//
|
|
60
|
+
// Client can switch rooms via { action: 'join', room: 'roomId' }
|
|
61
|
+
try {
|
|
62
|
+
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg
|
|
63
|
+
if (parsed.action === 'join' && parsed.room) {
|
|
64
|
+
const oldRoom = ws.data._room
|
|
65
|
+
if (oldRoom) rooms.get(oldRoom)?.delete(ws)
|
|
66
|
+
ws.data._room = parsed.room
|
|
67
|
+
getRoom(parsed.room).add(ws)
|
|
68
|
+
ws.send(JSON.stringify({ ack: 'joined', room: parsed.room }))
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
} catch { /* ignore parse errors */ }
|
|
25
72
|
ws.send(JSON.stringify({ ack: msg }))
|
|
26
73
|
},
|
|
27
74
|
close(ws) {
|
|
28
|
-
|
|
75
|
+
const room = ws.data?._room
|
|
76
|
+
if (room) rooms.get(room)?.delete(ws)
|
|
29
77
|
},
|
|
30
78
|
})
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
// Runs only on the client after hydration — routes calls to the Elysia backend via ofetch
|
|
3
3
|
import { $fetch } from 'ofetch'
|
|
4
4
|
|
|
5
|
+
function getCsrfToken() {
|
|
6
|
+
const match = document.cookie.match(/(?:^|;\s*)vasp-csrf=([^;]*)/)
|
|
7
|
+
return match ? decodeURIComponent(match[1]) : undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
export default defineNuxtPlugin((nuxtApp) => {
|
|
6
11
|
const config = useRuntimeConfig()
|
|
7
12
|
const baseURL = config.public.apiBase
|
|
@@ -11,7 +16,12 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
11
16
|
return $fetch(`/queries/${name}`, { baseURL, method: 'GET', query: args })
|
|
12
17
|
},
|
|
13
18
|
async action(name, args) {
|
|
14
|
-
return $fetch(`/actions/${name}`, {
|
|
19
|
+
return $fetch(`/actions/${name}`, {
|
|
20
|
+
baseURL,
|
|
21
|
+
method: 'POST',
|
|
22
|
+
body: args,
|
|
23
|
+
headers: { 'x-csrf-token': getCsrfToken() ?? '' },
|
|
24
|
+
})
|
|
15
25
|
},
|
|
16
26
|
})
|
|
17
27
|
})
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
// Runs only on the client after hydration — routes calls to the Elysia backend via ofetch
|
|
3
3
|
import { $fetch } from 'ofetch'
|
|
4
4
|
|
|
5
|
+
function getCsrfToken(): string | undefined {
|
|
6
|
+
const match = document.cookie.match(/(?:^|;\s*)vasp-csrf=([^;]*)/)
|
|
7
|
+
return match ? decodeURIComponent(match[1]) : undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
export default defineNuxtPlugin((nuxtApp) => {
|
|
6
11
|
const config = useRuntimeConfig()
|
|
7
12
|
const baseURL = config.public.apiBase as string
|
|
@@ -11,7 +16,12 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
11
16
|
return $fetch<T>(`/queries/${name}`, { baseURL, method: 'GET', query: args as Record<string, unknown> })
|
|
12
17
|
},
|
|
13
18
|
async action<T = unknown>(name: string, args?: unknown): Promise<T> {
|
|
14
|
-
return $fetch<T>(`/actions/${name}`, {
|
|
19
|
+
return $fetch<T>(`/actions/${name}`, {
|
|
20
|
+
baseURL,
|
|
21
|
+
method: 'POST',
|
|
22
|
+
body: args,
|
|
23
|
+
headers: { 'x-csrf-token': getCsrfToken() ?? '' },
|
|
24
|
+
})
|
|
15
25
|
},
|
|
16
26
|
})
|
|
17
27
|
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
DATABASE_URL=postgres://user:password@localhost:5432/{{kebabCase appName}}
|
|
2
|
+
PORT={{backendPort}}
|
|
3
|
+
VITE_API_URL=http://localhost:{{backendPort}}/api
|
|
4
|
+
{{#if hasAuth}}
|
|
5
|
+
JWT_SECRET=change-me-in-production
|
|
6
|
+
{{#if (includes authMethods "google")}}
|
|
7
|
+
GOOGLE_CLIENT_ID=
|
|
8
|
+
GOOGLE_CLIENT_SECRET=
|
|
9
|
+
{{/if}}
|
|
10
|
+
{{#if (includes authMethods "github")}}
|
|
11
|
+
GITHUB_CLIENT_ID=
|
|
12
|
+
GITHUB_CLIENT_SECRET=
|
|
13
|
+
{{/if}}
|
|
14
|
+
{{/if}}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# {{appTitle}}
|
|
2
|
+
|
|
3
|
+
A full-stack app built with [Vasp](https://github.com/AliBeigi/Vasp).
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Install dependencies
|
|
9
|
+
bun install
|
|
10
|
+
|
|
11
|
+
# Set up your database
|
|
12
|
+
# Make sure PostgreSQL is running, then push the schema:
|
|
13
|
+
bun run db:push
|
|
14
|
+
|
|
15
|
+
# Start the dev server (backend + frontend)
|
|
16
|
+
vasp start
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Project Structure
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
main.vasp # Vasp declarative config
|
|
23
|
+
server/ # Elysia backend
|
|
24
|
+
routes/ # API routes (queries, actions, CRUD)
|
|
25
|
+
db/ # Drizzle DB client
|
|
26
|
+
middleware/ # Rate limiting{{#if hasAuth}}, auth{{/if}}
|
|
27
|
+
{{#if hasJobs}} jobs/ # Background jobs (PgBoss)
|
|
28
|
+
{{/if}}src/ # Frontend source
|
|
29
|
+
pages/ # Vue page components
|
|
30
|
+
components/ # Shared components
|
|
31
|
+
drizzle/ # Database schema & migrations
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Scripts
|
|
35
|
+
|
|
36
|
+
| Command | Description |
|
|
37
|
+
|---------|-------------|
|
|
38
|
+
| `vasp start` | Start dev server (backend + frontend) |
|
|
39
|
+
| `vasp build` | Production build |
|
|
40
|
+
| `bun run db:push` | Push schema to database |
|
|
41
|
+
| `bun run db:generate` | Generate a migration |
|
|
42
|
+
| `bun run db:migrate` | Run migrations |
|
|
43
|
+
| `bun run db:studio` | Open Drizzle Studio |
|
|
44
|
+
|
|
45
|
+
## Environment Variables
|
|
46
|
+
|
|
47
|
+
Copy `.env.example` to `.env` and update the values:
|
|
48
|
+
|
|
49
|
+
- `DATABASE_URL` — PostgreSQL connection string
|
|
50
|
+
- `PORT` — Backend server port (default: {{backendPort}})
|
|
51
|
+
- `VITE_API_URL` — Frontend API base URL
|
|
52
|
+
{{#if hasAuth}}- `JWT_SECRET` — Secret for JWT token signing
|
|
53
|
+
{{/if}}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { Elysia } from 'elysia'
|
|
2
|
-
import { jwt } from '@elysiajs/jwt'
|
|
3
|
-
import { cookie } from '@elysiajs/cookie'
|
|
4
2
|
import { db } from '../db/client.{{ext}}'
|
|
5
3
|
import { users } from '../../drizzle/schema.{{ext}}'
|
|
6
4
|
import { eq } from 'drizzle-orm'
|
|
5
|
+
import { authPlugin } from './plugin.{{ext}}'
|
|
7
6
|
{{#if (includes authMethods "usernameAndPassword")}}
|
|
8
7
|
import { usernameAndPasswordRoutes } from './providers/usernameAndPassword.{{ext}}'
|
|
9
8
|
{{/if}}
|
|
@@ -14,13 +13,9 @@ import { googleRoutes } from './providers/google.{{ext}}'
|
|
|
14
13
|
import { githubRoutes } from './providers/github.{{ext}}'
|
|
15
14
|
{{/if}}
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
export { authPlugin } from './plugin.{{ext}}'
|
|
18
17
|
|
|
19
|
-
export const
|
|
20
|
-
.use(jwt({ name: 'jwt', secret: JWT_SECRET }))
|
|
21
|
-
.use(cookie())
|
|
22
|
-
|
|
23
|
-
export const authRoutes = new Elysia({ prefix: '/auth' })
|
|
18
|
+
export const authRoutes = new Elysia({ prefix: '/api/auth' })
|
|
24
19
|
.use(authPlugin)
|
|
25
20
|
{{#if (includes authMethods "usernameAndPassword")}}
|
|
26
21
|
.use(usernameAndPasswordRoutes)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Elysia } from 'elysia'
|
|
2
|
+
import { jwt } from '@elysiajs/jwt'
|
|
3
|
+
import { cookie } from '@elysiajs/cookie'
|
|
4
|
+
|
|
5
|
+
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'
|
|
6
|
+
|
|
7
|
+
export const authPlugin = new Elysia({ name: 'auth-plugin' })
|
|
8
|
+
.use(jwt({ name: 'jwt', secret: JWT_SECRET }))
|
|
9
|
+
.use(cookie())
|
|
@@ -2,7 +2,7 @@ import { Elysia } from 'elysia'
|
|
|
2
2
|
import { db } from '../../db/client.{{ext}}'
|
|
3
3
|
import { users } from '../../../drizzle/schema.{{ext}}'
|
|
4
4
|
import { eq } from 'drizzle-orm'
|
|
5
|
-
import { authPlugin } from '../
|
|
5
|
+
import { authPlugin } from '../plugin.{{ext}}'
|
|
6
6
|
|
|
7
7
|
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || ''
|
|
8
8
|
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || ''
|
|
@@ -2,7 +2,7 @@ import { Elysia } from 'elysia'
|
|
|
2
2
|
import { db } from '../../db/client.{{ext}}'
|
|
3
3
|
import { users } from '../../../drizzle/schema.{{ext}}'
|
|
4
4
|
import { eq } from 'drizzle-orm'
|
|
5
|
-
import { authPlugin } from '../
|
|
5
|
+
import { authPlugin } from '../plugin.{{ext}}'
|
|
6
6
|
|
|
7
7
|
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''
|
|
8
8
|
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''
|