Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ node_modules
# Some caches
.*cache
.trash
tsconfig.tsbuildinfo
*.tsbuildinfo

# Compiled dist
dist
Expand Down
262 changes: 262 additions & 0 deletions examples/event-board/common/api/events.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/**
* Event categories supported by the mock API.
*/
export const eventCategories = [
'conference',
'meetup',
'workshop',
'webinar'
]

const DEFAULT_LIMIT = 3
const MAX_LIMIT = 20
const events = [
{
id: '1',
slug: 'react-ssr-workshop',
title: 'React SSR Workshop',
description: 'A hands-on workshop about server rendering, hydration, and app architecture.',
startsAt: new Date('2026-05-12T18:00:00Z').getTime(),
location: 'Online',
category: 'workshop',
attendees: 24
},
{
id: '2',
slug: 'frontend-meetup-spring',
title: 'Frontend Meetup: Spring Edition',
description: 'Short talks about modern frontend tooling, routing, and state management.',
startsAt: new Date('2026-05-21T19:30:00Z').getTime(),
location: 'Berlin',
category: 'meetup',
attendees: 58
},
{
id: '3',
slug: 'state-management-webinar',
title: 'State Management Webinar',
description: 'A practical session on signals, derived state, and data fetching.',
startsAt: new Date('2026-06-03T17:00:00Z').getTime(),
location: 'Online',
category: 'webinar',
attendees: 102
},
{
id: '4',
slug: 'vite-plugin-night',
title: 'Vite Plugin Night',
description: 'A meetup about Vite plugins, build pipelines, and developer experience.',
startsAt: new Date('2026-06-11T18:30:00Z').getTime(),
location: 'Prague',
category: 'meetup',
attendees: 41
},
{
id: '5',
slug: 'web-platform-conference',
title: 'Web Platform Conference',
description: 'A one-day conference about browser APIs, performance, and modern web apps.',
startsAt: new Date('2026-06-24T09:00:00Z').getTime(),
location: 'Amsterdam',
category: 'conference',
attendees: 180
},
{
id: '6',
slug: 'hydration-deep-dive',
title: 'Hydration Deep Dive',
description: 'A technical webinar about SSR, RSC, cache hydration, and client handoff.',
startsAt: new Date('2026-07-02T16:00:00Z').getTime(),
location: 'Online',
category: 'webinar',
attendees: 76
}
]

function normalizeLimit(value) {
const limit = Number(value || DEFAULT_LIMIT)

if (!Number.isFinite(limit) || limit < 1) {
return DEFAULT_LIMIT
}

return Math.min(limit, MAX_LIMIT)
}

function normalizeCategory(value) {
if (!value) {
return null
}

return eventCategories.includes(value) ? value : undefined
}

function slugify(value) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}

function uniqueSlug(title) {
const base = slugify(title) || 'event'
let slug = base
let index = 2

while (findEvent(slug)) {
slug = `${base}-${index}`
index += 1
}

return slug
}

function sortEvents(items) {
return [...items].sort((a, b) => a.startsAt - b.startsAt || a.id.localeCompare(b.id))
}

function validateEventInput(input) {
const errors = {}

if (!input || typeof input !== 'object') {
return {
form: 'Expected event payload'
}
}

if (typeof input.title !== 'string' || !input.title.trim()) {
errors.title = 'Title is required'
}

if (typeof input.description !== 'string' || !input.description.trim()) {
errors.description = 'Description is required'
}

if (typeof input.startsAt !== 'number' || !Number.isFinite(input.startsAt)) {
errors.startsAt = 'Date and time are required'
}

if (typeof input.location !== 'string' || !input.location.trim()) {
errors.location = 'Location is required'
}

if (!eventCategories.includes(input.category)) {
errors.category = 'Category is invalid'
}

return errors
}

/**
* Return a filtered and cursor-paginated page of events.
* @param {object} query - Query values from the HTTP request.
* @param {string | undefined} query.q - Search text.
* @param {string | undefined} query.category - Category filter.
* @param {string | undefined} query.cursor - Anchor cursor based on `startsAt`.
* @param {string | undefined} query.limit - Page size.
* @returns {{ status: number, body: { events: object[], nextCursor?: number } | { error: string } }} API response payload.
*/
export function listEvents(query) {
const q = query.q?.trim().toLowerCase()
const category = normalizeCategory(query.category)
const cursor = Number(query.cursor || 0)
const limit = normalizeLimit(query.limit)

if (category === undefined) {
return {
status: 400,
body: {
error: 'Unknown event category'
}
}
}

const filtered = sortEvents(events).filter((event) => {
if (category && event.category !== category) {
return false
}

if (Number.isFinite(cursor) && cursor > 0 && event.startsAt <= cursor) {
return false
}

if (!q) {
return true
}

return `${event.title} ${event.description} ${event.location}`.toLowerCase().includes(q)
})
const pageItems = filtered.slice(0, limit)
const hasMore = filtered.length > pageItems.length

return {
status: 200,
body: {
events: pageItems,
nextCursor: hasMore ? pageItems[pageItems.length - 1]?.startsAt : undefined
}
}
}

/**
* Find an event by slug.
* @param {string} slug - Event slug.
* @returns {object | null} Event object or `null` when it does not exist.
*/
export function findEvent(slug) {
return events.find(event => event.slug === slug) || null
}

/**
* Create a new event in the in-memory store.
* @param {object} input - Event form payload.
* @returns {{ status: number, body: object }} API response payload.
*/
export function createEvent(input) {
const errors = validateEventInput(input)

if (Object.keys(errors).length > 0) {
return {
status: 400,
body: {
errors
}
}
}

const event = {
id: crypto.randomUUID(),
slug: uniqueSlug(input.title),
title: input.title.trim(),
description: input.description.trim(),
startsAt: input.startsAt,
location: input.location.trim(),
category: input.category,
attendees: 0
}

events.push(event)

return {
status: 201,
body: event
}
}

/**
* Increment attendees count for an event.
* @param {string} id - Event id.
* @returns {object | null} Updated event object or `null` when it does not exist.
*/
export function rsvpEvent(id) {
const event = events.find(item => item.id === id) || null

if (!event) {
return null
}

event.attendees += 1

return event
}
71 changes: 71 additions & 0 deletions examples/event-board/common/api/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Hono } from 'hono'
import {
createEvent,
findEvent,
listEvents,
rsvpEvent
} from './events.js'

const HTTP_BAD_REQUEST = 400
const HTTP_NOT_FOUND = 404

export function createApiApp() {
const app = new Hono()

app.get('/api/events', (c) => {
const result = listEvents({
q: c.req.query('q'),
category: c.req.query('category'),
cursor: c.req.query('cursor'),
limit: c.req.query('limit')
})

return c.json(result.body, result.status)
})

app.get('/api/events/:slug', (c) => {
const event = findEvent(c.req.param('slug'))

if (!event) {
return c.json(null, HTTP_NOT_FOUND)
}

return c.json(event)
})

app.post('/api/events', async (c) => {
let body

try {
body = await c.req.json()
} catch {
return c.json({
errors: {
form: 'Expected JSON payload'
}
}, HTTP_BAD_REQUEST)
}

const result = createEvent(body)

return c.json(result.body, result.status)
})

app.post('/api/events/:id/rsvp', (c) => {
const event = rsvpEvent(c.req.param('id'))

if (!event) {
return c.json(null, HTTP_NOT_FOUND)
}

return c.json(event)
})

return app
}

export function api() {
const app = createApiApp()

return c => app.fetch(c.req.raw)
}
20 changes: 20 additions & 0 deletions examples/event-board/common/api/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { compress } from 'hono/compress'
import { api } from './index.js'

const DEFAULT_API_PORT = 3001
const API_PORT = Number(process.env.PORT || DEFAULT_API_PORT)
const app = new Hono()

app.use(compress())
app.use('/api/*', api())

app.get('/', c => c.redirect('http://localhost:5173'))

serve({
fetch: app.fetch,
port: API_PORT
}, (info) => {
console.info(`event board api started at http://localhost:${info.port}`)
})
28 changes: 28 additions & 0 deletions examples/event-board/common/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import bundlerConfig from '@trigen/eslint-config/bundler'
import moduleConfig from '@trigen/eslint-config/module'
import tsConfig from '@trigen/eslint-config/typescript'
import env from '@trigen/eslint-config/env'
import rootConfig from '../../../eslint.config.js'

export default [
...rootConfig,
...bundlerConfig,
...tsConfig,
env.node,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
}
},
rules: {
'@typescript-eslint/no-magic-numbers': 'off',
'import/no-default-export': 'off'
}
},
...moduleConfig.map(config => ({
files: ['**/*.js'],
...config
}))
]
Loading