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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/line-relay/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ docs/line/
| POST | `/reply` | RELAY_SECRET | Send response via LINE Push API |
| GET | `/content/:id` | RELAY_SECRET | Proxy LINE Content API (images) |
| DELETE | `/messages` | RELAY_SECRET | Ack consumed messages |
| GET | `/health` | none | Health check |
| GET | `/health` | none | JSON health, queue depth, and timestamp |

### Secrets (Cloudflare Workers)

Expand Down
4 changes: 2 additions & 2 deletions docs/line/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ RELAY_SECRET=your-64-char-hex-secret
| POST | `/reply` | RELAY_SECRET | Send response via LINE Push API |
| GET | `/content/:id` | RELAY_SECRET | Proxy LINE Content API (images) |
| DELETE | `/messages` | RELAY_SECRET | Ack consumed messages |
| GET | `/health` | none | Health check |
| GET | `/health` | none | JSON health, queue depth, and timestamp |

---

Expand Down Expand Up @@ -156,7 +156,7 @@ RELAY_SECRET=your-64-char-hex-secret

```bash
curl https://line-relay.your-subdomain.workers.dev/health
# Expected: ok
# Expected: {"service":"line-relay","version":"1.0.0","status":"ok",...}
```

### Check queued messages
Expand Down
62 changes: 61 additions & 1 deletion external_plugins/line-channel/relay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,63 @@ function json(data: unknown, status = 200): Response {
})
}

const RELAY_VERSION = '1.0.0'
const HEALTH_STARTED_AT_KEY = '_health:started-at'

async function getStartedAt(queue: KVNamespace, now: number): Promise<number> {
const value = await queue.get(HEALTH_STARTED_AT_KEY)
const startedAt = Number(value)

if (Number.isFinite(startedAt) && startedAt > 0) {
return startedAt
}

await queue.put(HEALTH_STARTED_AT_KEY, String(now))
return now
}

async function countKeys(queue: KVNamespace, prefix: string): Promise<number> {
let count = 0
let cursor: string | undefined

do {
const page = await queue.list({ prefix, cursor })
count += page.keys.length
cursor = page.list_complete ? undefined : page.cursor
} while (cursor)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance consideration: This paginated walk runs on every GET /health (and GET / too). KV list() returns up to 1000 keys per page, so if the queue accumulates messages, each health check becomes O(n/1000) KV reads.

If monitoring polls every 10–30 s this could add meaningful latency and KV read charges.

Two lighter alternatives:

  1. Counter key — increment a _meta:queue-depth key on enqueue (handleWebhook) and decrement on delete (handleDeleteMessages). Health check becomes a single get().
  2. Cached count — store the count in a KV key with a short TTL (e.g. 30 s) and recompute only on cache miss.

Same pattern applies to the WhatsApp relay's identical countKeys.


return count
}

async function handleHealth(env: Env): Promise<Response> {
const now = Date.now()

try {
const [startedAt, messageCount, unsendCount] = await Promise.all([
getStartedAt(env.LINE_QUEUE, now),
countKeys(env.LINE_QUEUE, 'msg:'),
countKeys(env.LINE_QUEUE, 'unsend:'),
])

return json({
service: 'line-relay',
version: RELAY_VERSION,
status: 'ok',
uptime_seconds: Math.floor((now - startedAt) / 1000),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming nit: Because _health:started-at persists in KV across Worker restarts and redeployments, this value reflects "time since first health check ever" rather than actual process uptime. A new deploy won't reset it unless the KV key is manually deleted.

Consider renaming to age_seconds or first_seen_age_seconds — or add a brief note in the health-endpoint docs about the semantics so operators don't misread it as process uptime.

queue_depth: messageCount + unsendCount,
timestamp: new Date(now).toISOString(),
})
} catch (error) {
console.error('[health] failed', error)
return json({
service: 'line-relay',
version: RELAY_VERSION,
status: 'degraded',
timestamp: new Date(now).toISOString(),
}, 503)
}
}

// ── Handlers ───────────────────────────────────────────────

async function handleWebhook(request: Request, env: Env): Promise<Response> {
Expand Down Expand Up @@ -150,6 +207,9 @@ async function handleGetMessages(request: Request, env: Env): Promise<Response>
const messages: QueuedMessage[] = []

for (const key of allKeys.keys) {
if (!key.name.startsWith('msg:') && !key.name.startsWith('unsend:')) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good defensive guard. The unfiltered list() on line 207 would return the _health:started-at key, and the catch {} on line 214 would silently swallow the resulting parse error — so without this guard, the health key would be invisible but would still cost a wasted get() call per poll.

The WhatsApp relay doesn't need this because its handleGetMessages already filters with { prefix: 'msg:' }.

continue
}
const val = await env.LINE_QUEUE.get(key.name)
if (val) {
try {
Expand Down Expand Up @@ -267,7 +327,7 @@ export default {
const path = url.pathname

if (path === '/health' || path === '/') {
return new Response('ok')
return handleHealth(env)
}

if (path === '/webhook' && request.method === 'POST') {
Expand Down
58 changes: 57 additions & 1 deletion external_plugins/whatsapp-channel/relay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,62 @@ function json(data: unknown, status = 200): Response {
})
}

const RELAY_VERSION = '1.0.0'
const HEALTH_STARTED_AT_KEY = '_health:started-at'

async function getStartedAt(queue: KVNamespace, now: number): Promise<number> {
const value = await queue.get(HEALTH_STARTED_AT_KEY)
const startedAt = Number(value)

if (Number.isFinite(startedAt) && startedAt > 0) {
return startedAt
}

await queue.put(HEALTH_STARTED_AT_KEY, String(now))
return now
}

async function countKeys(queue: KVNamespace, prefix: string): Promise<number> {
let count = 0
let cursor: string | undefined

do {
const page = await queue.list({ prefix, cursor })
count += page.keys.length
cursor = page.list_complete ? undefined : page.cursor
} while (cursor)

return count
}

async function handleHealth(env: Env): Promise<Response> {
const now = Date.now()

try {
const [startedAt, queueDepth] = await Promise.all([
getStartedAt(env.WA_QUEUE, now),
countKeys(env.WA_QUEUE, 'msg:'),
])

return json({
service: 'whatsapp-relay',
version: RELAY_VERSION,
status: 'ok',
uptime_seconds: Math.floor((now - startedAt) / 1000),
queue_depth: queueDepth,
timestamp: new Date(now).toISOString(),
})
} catch (error) {
console.error('[health] failed', error)
return json({
service: 'whatsapp-relay',
version: RELAY_VERSION,
status: 'degraded',
timestamp: new Date(now).toISOString(),
}, 503)
}
}

// ── Webhook Verification (GET) ────────────────────────────

function handleVerify(request: Request, env: Env): Response {
Expand Down Expand Up @@ -317,7 +373,7 @@ export default {
const path = url.pathname

if (path === '/health' || path === '/') {
return new Response('ok')
return handleHealth(env)
}

if (path === '/webhook' && request.method === 'GET') {
Expand Down