add sse helper
This commit is contained in:
parent
a8d3a8203e
commit
1a8d422834
54
README.md
54
README.md
|
|
@ -189,6 +189,60 @@ Install the `vscode-styled-components` VSCode extension to highlight inline `js`
|
||||||
|
|
||||||
Anything in `pub/` is served as-is. Simple stuff.
|
Anything in `pub/` is served as-is. Simple stuff.
|
||||||
|
|
||||||
|
### Server-Sent Events (SSE)
|
||||||
|
|
||||||
|
`hype` provides a simple `app.sse()` helper for streaming data to clients:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Hype } from "hype"
|
||||||
|
|
||||||
|
const app = new Hype()
|
||||||
|
|
||||||
|
// Stream current time every second
|
||||||
|
app.sse('/api/time', (send) => {
|
||||||
|
send({ time: Date.now() })
|
||||||
|
const interval = setInterval(() => send({ time: Date.now() }), 1000)
|
||||||
|
return () => clearInterval(interval) // cleanup on disconnect
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The `send()` function:
|
||||||
|
- Automatically JSON.stringify's objects (strings are sent as-is)
|
||||||
|
- Accepts an optional second parameter for named events: `send(data, 'eventName')`
|
||||||
|
|
||||||
|
The handler can return a cleanup function that's called when the client disconnects.
|
||||||
|
|
||||||
|
You also have access to the Hono context as the second parameter:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.sse('/api/user-events', (send, c) => {
|
||||||
|
const userId = c.req.query('userId')
|
||||||
|
// ... subscribe to user-specific events
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client-side usage:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const events = new EventSource('/api/time')
|
||||||
|
|
||||||
|
events.onmessage = (e) => {
|
||||||
|
const data = JSON.parse(e.data)
|
||||||
|
console.log('Time:', data.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For named events:
|
||||||
|
events.addEventListener('eventName', (e) => {
|
||||||
|
console.log('Got event:', e.data)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Test with curl:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -N http://localhost:3000/api/time
|
||||||
|
```
|
||||||
|
|
||||||
### utils
|
### utils
|
||||||
|
|
||||||
`hype` includes helpful utils for your webapp:
|
`hype` includes helpful utils for your webapp:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export default () => (
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/about">About This Website</a></li>
|
<li><a href="/about">About This Website</a></li>
|
||||||
<li><a href="/js-test">JS Test</a></li>
|
<li><a href="/js-test">JS Test</a></li>
|
||||||
|
<li><a href="/sse">SSE Demo</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
41
examples/ssr/src/pages/sse.tsx
Normal file
41
examples/ssr/src/pages/sse.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { js } from 'hype'
|
||||||
|
|
||||||
|
export default () => (
|
||||||
|
<section>
|
||||||
|
<h1>SSE Demo</h1>
|
||||||
|
|
||||||
|
<div id="status">Connecting...</div>
|
||||||
|
<div id="time" style="font-size: 2em; font-family: monospace;"></div>
|
||||||
|
<div id="count">Events received: 0</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 2em;">
|
||||||
|
<a href="/">Back to Home</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{js`
|
||||||
|
const statusEl = document.getElementById('status')
|
||||||
|
const timeEl = document.getElementById('time')
|
||||||
|
const countEl = document.getElementById('count')
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
const events = new EventSource('/api/time')
|
||||||
|
|
||||||
|
events.onopen = () => {
|
||||||
|
statusEl.textContent = 'Connected'
|
||||||
|
statusEl.style.color = 'green'
|
||||||
|
}
|
||||||
|
|
||||||
|
events.onmessage = (e) => {
|
||||||
|
const data = JSON.parse(e.data)
|
||||||
|
timeEl.textContent = new Date(data.time).toLocaleTimeString()
|
||||||
|
count++
|
||||||
|
countEl.textContent = 'Events received: ' + count
|
||||||
|
}
|
||||||
|
|
||||||
|
events.onerror = () => {
|
||||||
|
statusEl.textContent = 'Disconnected'
|
||||||
|
statusEl.style.color = 'red'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
@ -5,4 +5,11 @@ const app = new Hype({})
|
||||||
// custom routes go here
|
// custom routes go here
|
||||||
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
|
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
|
||||||
|
|
||||||
|
// SSE example: streams current time every second
|
||||||
|
app.sse('/api/time', (send) => {
|
||||||
|
send({ time: Date.now() })
|
||||||
|
const interval = setInterval(() => send({ time: Date.now() }), 1000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
})
|
||||||
|
|
||||||
export default app.defaults
|
export default app.defaults
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { join } from 'path'
|
||||||
import { render as formatHTML } from './lib/html-formatter'
|
import { render as formatHTML } from './lib/html-formatter'
|
||||||
import { type Context, Hono, type Schema, type Env } from 'hono'
|
import { type Context, Hono, type Schema, type Env } from 'hono'
|
||||||
import { serveStatic } from 'hono/bun'
|
import { serveStatic } from 'hono/bun'
|
||||||
|
import { streamSSE } from 'hono/streaming'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
|
|
||||||
import { transpile } from './utils'
|
import { transpile } from './utils'
|
||||||
|
|
@ -25,6 +26,11 @@ export type HypeProps = {
|
||||||
layout?: boolean
|
layout?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SSEHandler<E extends Env> = (
|
||||||
|
send: (data: unknown, event?: string) => Promise<void>,
|
||||||
|
c: Context<E>
|
||||||
|
) => void | (() => void) | Promise<void | (() => void)>
|
||||||
|
|
||||||
export class Hype<
|
export class Hype<
|
||||||
E extends Env = Env,
|
E extends Env = Env,
|
||||||
S extends Schema = {},
|
S extends Schema = {},
|
||||||
|
|
@ -39,6 +45,29 @@ export class Hype<
|
||||||
this.props = props ?? {}
|
this.props = props ?? {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sse(path: string, handler: SSEHandler<E>) {
|
||||||
|
return this.get(path, (c) => {
|
||||||
|
return streamSSE(c, async (stream) => {
|
||||||
|
const send = async (data: unknown, event?: string) => {
|
||||||
|
await stream.writeSSE({
|
||||||
|
data: typeof data === 'string' ? data : JSON.stringify(data),
|
||||||
|
event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = await handler(send, c)
|
||||||
|
|
||||||
|
// Keep stream open until client disconnects
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
stream.onAbort(() => {
|
||||||
|
cleanup?.()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
registerRoutes() {
|
registerRoutes() {
|
||||||
if (this.routesRegistered) return
|
if (this.routesRegistered) return
|
||||||
this.routesRegistered = true
|
this.routesRegistered = true
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user