Skip to content

Commit 3fea1bd

Browse files
committed
fix: http endpoint
1 parent 5b85f42 commit 3fea1bd

3 files changed

Lines changed: 123 additions & 0 deletions

File tree

.changeset/legal-bobcats-mix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hyperstar": minor
3+
---
4+
5+
Http endpoint

examples/hn-uncensored/index.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,33 @@ app.repeat("hnPoll", {
709709
},
710710
})
711711

712+
// Cron webhook - allows external services (cron-job.org, Uptime Robot, etc.) to trigger polls
713+
// This enables Sprites hibernation while still collecting data periodically
714+
app.http("/cron", async (ctx) => {
715+
const cronSecret = process.env.CRON_SECRET
716+
const providedSecret = ctx.req.headers.get("x-cron-secret") ?? ctx.url.searchParams.get("secret")
717+
718+
// Authenticate if CRON_SECRET is set
719+
if (cronSecret && providedSecret !== cronSecret) {
720+
console.log(`${LOG_PREFIX} ⚠️ Cron webhook rejected - invalid secret`)
721+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
722+
status: 401,
723+
headers: { "Content-Type": "application/json" },
724+
})
725+
}
726+
727+
console.log(`${LOG_PREFIX} 🔔 Cron webhook triggered`)
728+
await runPoll(ctx, { source: "manual" }) // Respects poll interval
729+
730+
return new Response(JSON.stringify({
731+
ok: true,
732+
timestamp: new Date().toISOString(),
733+
message: "Poll triggered successfully"
734+
}), {
735+
headers: { "Content-Type": "application/json" },
736+
})
737+
})
738+
712739
const server = app
713740
.app({
714741
store: {
@@ -1489,9 +1516,12 @@ console.log(`
14891516
║ HN Uncensored Monitor ║
14901517
╠═══════════════════════════════════════════════════════════════╣
14911518
║ http://localhost:${server.port}
1519+
║ http://localhost:${server.port}/cron (webhook for external cron) ║
14921520
║ ║
14931521
║ Polling: ${DEFAULT_POLL_MINUTES} minutes ║
14941522
║ Persist: ./data/hn-uncensored.json ║
1523+
║ ║
1524+
║ Set CRON_SECRET env var to secure the webhook ║
14951525
╚═══════════════════════════════════════════════════════════════╝
14961526
`)
14971527

packages/hyperstar/src/server.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,35 @@ export interface CronHandle {
380380
readonly isPaused: boolean
381381
}
382382

383+
/**
384+
* HTTP route handler context.
385+
* Provides access to store and update functions for custom HTTP endpoints.
386+
*/
387+
export interface HttpHandlerContext<S extends object> {
388+
readonly req: Request
389+
readonly url: URL
390+
readonly getStore: () => S
391+
readonly update: (fn: (s: S) => S) => void
392+
}
393+
394+
/**
395+
* HTTP route handler function.
396+
* Returns a Response or void (void returns 200 OK with empty body).
397+
*/
398+
export type HttpHandler<S extends object> = (
399+
ctx: HttpHandlerContext<S>
400+
) => Response | Promise<Response> | void | Promise<void>
401+
402+
/**
403+
* HTTP route configuration.
404+
*/
405+
export interface HttpConfig<S extends object> {
406+
/** HTTP method(s) to match. Defaults to ["GET", "POST"] */
407+
readonly method?: string | string[]
408+
/** Route handler */
409+
readonly handler: HttpHandler<S>
410+
}
411+
383412
// ============================================================================
384413
// App Configuration (passed to .app())
385414
// ============================================================================
@@ -486,6 +515,26 @@ export interface HyperstarFactory<
486515
handler: (ctx: UserTriggerContext<S, U>, change: UserTriggerChange<T>) => void
487516
}): void
488517

518+
/**
519+
* Define a custom HTTP endpoint.
520+
* Useful for webhooks, cron triggers, health checks, APIs.
521+
*
522+
* @example
523+
* ```tsx
524+
* app.http("/cron", async (ctx) => {
525+
* await runPoll(ctx)
526+
* return new Response(JSON.stringify({ ok: true }))
527+
* })
528+
*
529+
* // With specific method
530+
* app.http("/api/data", { method: "GET", handler: (ctx) => {
531+
* return Response.json(ctx.getStore())
532+
* }})
533+
* ```
534+
*/
535+
http(path: string, handler: HttpHandler<S>): void
536+
http(path: string, config: HttpConfig<S>): void
537+
489538
/**
490539
* Configure the app with store, view, and other options.
491540
* Returns a servable app.
@@ -560,6 +609,7 @@ export const createHyperstar = <
560609
const cronDefs: Array<{ id: string; config: CronConfig<S, U> }> = []
561610
const triggerDefs: Array<{ id: string; config: Omit<TriggerConfig<S, unknown>, "id"> }> = []
562611
const userTriggerDefs: Array<{ id: string; config: Omit<UserTriggerConfig<S, U, unknown>, "id"> }> = []
612+
const httpDefs: Array<{ path: string; methods: string[]; handler: HttpHandler<S> }> = []
563613

564614
// Create signal handles lazily via Proxy - handles are created on first access
565615
// Uses universal handles with all methods; TypeScript restricts visibility based on Signals type
@@ -629,6 +679,16 @@ export const createHyperstar = <
629679
userTriggerDefs.push({ id, config: config as UserTriggerConfig<S, U, unknown> })
630680
},
631681

682+
http(path: string, handlerOrConfig: HttpHandler<S> | HttpConfig<S>): void {
683+
const isConfig = typeof handlerOrConfig === "object" && "handler" in handlerOrConfig
684+
const handler = isConfig ? handlerOrConfig.handler : handlerOrConfig
685+
const methodInput = isConfig ? handlerOrConfig.method : undefined
686+
const methods = methodInput
687+
? (Array.isArray(methodInput) ? methodInput : [methodInput]).map(m => m.toUpperCase())
688+
: ["GET", "POST"]
689+
httpDefs.push({ path, methods, handler })
690+
},
691+
632692
app(config: HyperstarConfig<S, U, Signals>): HyperstarApp {
633693
// Signal defaults from config
634694
const signalDefaults: Record<string, unknown> = config.signals ?? {}
@@ -1035,6 +1095,34 @@ export const createHyperstar = <
10351095
}
10361096
}
10371097

1098+
// Check custom HTTP routes
1099+
for (const { path: routePath, methods, handler } of httpDefs) {
1100+
if (reqPath === routePath && methods.includes(req.method)) {
1101+
try {
1102+
const ctx: HttpHandlerContext<S> = {
1103+
req,
1104+
url,
1105+
getStore,
1106+
update: updateStore,
1107+
}
1108+
const result = await handler(ctx)
1109+
if (result instanceof Response) {
1110+
return result
1111+
}
1112+
// void return = 200 OK
1113+
return new Response(JSON.stringify({ ok: true }), {
1114+
headers: { "Content-Type": "application/json" },
1115+
})
1116+
} catch (error) {
1117+
console.error(`[hyperstar] HTTP handler error for ${routePath}:`, error)
1118+
return new Response(JSON.stringify({ error: String(error) }), {
1119+
status: 500,
1120+
headers: { "Content-Type": "application/json" },
1121+
})
1122+
}
1123+
}
1124+
}
1125+
10381126
return new Response("Not Found", { status: 404 })
10391127
}
10401128

0 commit comments

Comments
 (0)