easy-ping 0.10.1 → 0.10.3

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.
Files changed (130) hide show
  1. package/README.md +10 -5
  2. package/dist/adapters/drizzle.cjs +0 -2
  3. package/dist/adapters/drizzle.js +3 -5
  4. package/dist/adapters/mongodb.cjs +0 -2
  5. package/dist/adapters/mongodb.js +4 -6
  6. package/dist/adapters/mysql.cjs +0 -2
  7. package/dist/adapters/mysql.js +6 -8
  8. package/dist/adapters/postgres.cjs +0 -2
  9. package/dist/adapters/postgres.js +8 -10
  10. package/dist/adapters/sqlite.cjs +0 -2
  11. package/dist/adapters/sqlite.js +6 -8
  12. package/dist/browser.cjs +0 -2
  13. package/dist/browser.js +0 -2
  14. package/dist/{chunk-JKTMUMQK.js → chunk-2UOEAG6H.js} +1 -3
  15. package/dist/{chunk-R3BE67L7.js → chunk-74KSKP6B.js} +0 -2
  16. package/dist/{chunk-O2PBBL5X.js → chunk-7C4K5WKW.js} +1 -3
  17. package/dist/{chunk-CK5UPVYP.js → chunk-CRSGL5SG.js} +0 -2
  18. package/dist/{chunk-PTGHMCCG.js → chunk-D3UX3WWD.js} +0 -2
  19. package/dist/{chunk-CMWOIYVQ.js → chunk-EN3NMHSI.js} +4 -6
  20. package/dist/{chunk-ORZKH224.js → chunk-G32BYQAT.js} +0 -2
  21. package/dist/{chunk-A4HX5YTG.js → chunk-IXXPJMYS.js} +3 -5
  22. package/dist/{chunk-CFLVAF2R.js → chunk-JDR3U7X4.js} +2 -4
  23. package/dist/{chunk-AEYYTE5I.js → chunk-JRZ2U4IT.js} +0 -2
  24. package/dist/{chunk-UM2L26PV.js → chunk-KOS3YZMZ.js} +1 -3
  25. package/dist/{chunk-HSB5T3LD.js → chunk-OKIQ5BEV.js} +1 -3
  26. package/dist/{chunk-JN3UCQBP.js → chunk-SZWEW3WN.js} +0 -2
  27. package/dist/{chunk-SSHVZHSJ.js → chunk-VA4W4UFI.js} +0 -2
  28. package/dist/client.cjs +0 -2
  29. package/dist/client.js +0 -2
  30. package/dist/index.cjs +0 -2
  31. package/dist/index.js +12 -14
  32. package/dist/node.cjs +0 -2
  33. package/dist/node.js +3 -5
  34. package/dist/plugins/digests.cjs +0 -2
  35. package/dist/plugins/digests.js +1 -3
  36. package/dist/plugins/mobile-push.cjs +0 -2
  37. package/dist/plugins/mobile-push.js +3 -5
  38. package/dist/plugins/preferences-client.cjs +0 -2
  39. package/dist/plugins/preferences-client.js +1 -3
  40. package/dist/plugins/preferences-react.cjs +0 -1
  41. package/dist/plugins/preferences-react.js +0 -1
  42. package/dist/plugins/preferences.cjs +0 -2
  43. package/dist/plugins/preferences.js +3 -5
  44. package/dist/plugins/push.cjs +0 -2
  45. package/dist/plugins/push.js +4 -6
  46. package/dist/plugins/telegram.cjs +0 -2
  47. package/dist/plugins/telegram.js +5 -7
  48. package/dist/providers/expo-push.cjs +0 -2
  49. package/dist/providers/expo-push.js +0 -2
  50. package/dist/providers/resend.cjs +0 -2
  51. package/dist/providers/resend.js +0 -2
  52. package/dist/providers/telegram.cjs +0 -2
  53. package/dist/providers/telegram.js +1 -3
  54. package/dist/providers/web-push.cjs +0 -2
  55. package/dist/providers/web-push.js +1 -3
  56. package/dist/react-native.cjs +0 -1
  57. package/dist/react-native.js +0 -1
  58. package/dist/react.cjs +0 -1
  59. package/dist/react.js +0 -1
  60. package/dist/schema.cjs +0 -2
  61. package/dist/schema.js +4 -6
  62. package/dist/sw.cjs +0 -2
  63. package/dist/sw.js +0 -2
  64. package/dist/testing.cjs +0 -2
  65. package/dist/testing.js +0 -2
  66. package/package.json +7 -5
  67. package/dist/adapters/drizzle.cjs.map +0 -1
  68. package/dist/adapters/drizzle.js.map +0 -1
  69. package/dist/adapters/mongodb.cjs.map +0 -1
  70. package/dist/adapters/mongodb.js.map +0 -1
  71. package/dist/adapters/mysql.cjs.map +0 -1
  72. package/dist/adapters/mysql.js.map +0 -1
  73. package/dist/adapters/postgres.cjs.map +0 -1
  74. package/dist/adapters/postgres.js.map +0 -1
  75. package/dist/adapters/sqlite.cjs.map +0 -1
  76. package/dist/adapters/sqlite.js.map +0 -1
  77. package/dist/browser.cjs.map +0 -1
  78. package/dist/browser.js.map +0 -1
  79. package/dist/chunk-A4HX5YTG.js.map +0 -1
  80. package/dist/chunk-AEYYTE5I.js.map +0 -1
  81. package/dist/chunk-CFLVAF2R.js.map +0 -1
  82. package/dist/chunk-CK5UPVYP.js.map +0 -1
  83. package/dist/chunk-CMWOIYVQ.js.map +0 -1
  84. package/dist/chunk-HSB5T3LD.js.map +0 -1
  85. package/dist/chunk-JKTMUMQK.js.map +0 -1
  86. package/dist/chunk-JN3UCQBP.js.map +0 -1
  87. package/dist/chunk-O2PBBL5X.js.map +0 -1
  88. package/dist/chunk-ORZKH224.js.map +0 -1
  89. package/dist/chunk-PTGHMCCG.js.map +0 -1
  90. package/dist/chunk-R3BE67L7.js.map +0 -1
  91. package/dist/chunk-SSHVZHSJ.js.map +0 -1
  92. package/dist/chunk-UM2L26PV.js.map +0 -1
  93. package/dist/client.cjs.map +0 -1
  94. package/dist/client.js.map +0 -1
  95. package/dist/index.cjs.map +0 -1
  96. package/dist/index.js.map +0 -1
  97. package/dist/node.cjs.map +0 -1
  98. package/dist/node.js.map +0 -1
  99. package/dist/plugins/digests.cjs.map +0 -1
  100. package/dist/plugins/digests.js.map +0 -1
  101. package/dist/plugins/mobile-push.cjs.map +0 -1
  102. package/dist/plugins/mobile-push.js.map +0 -1
  103. package/dist/plugins/preferences-client.cjs.map +0 -1
  104. package/dist/plugins/preferences-client.js.map +0 -1
  105. package/dist/plugins/preferences-react.cjs.map +0 -1
  106. package/dist/plugins/preferences-react.js.map +0 -1
  107. package/dist/plugins/preferences.cjs.map +0 -1
  108. package/dist/plugins/preferences.js.map +0 -1
  109. package/dist/plugins/push.cjs.map +0 -1
  110. package/dist/plugins/push.js.map +0 -1
  111. package/dist/plugins/telegram.cjs.map +0 -1
  112. package/dist/plugins/telegram.js.map +0 -1
  113. package/dist/providers/expo-push.cjs.map +0 -1
  114. package/dist/providers/expo-push.js.map +0 -1
  115. package/dist/providers/resend.cjs.map +0 -1
  116. package/dist/providers/resend.js.map +0 -1
  117. package/dist/providers/telegram.cjs.map +0 -1
  118. package/dist/providers/telegram.js.map +0 -1
  119. package/dist/providers/web-push.cjs.map +0 -1
  120. package/dist/providers/web-push.js.map +0 -1
  121. package/dist/react-native.cjs.map +0 -1
  122. package/dist/react-native.js.map +0 -1
  123. package/dist/react.cjs.map +0 -1
  124. package/dist/react.js.map +0 -1
  125. package/dist/schema.cjs.map +0 -1
  126. package/dist/schema.js.map +0 -1
  127. package/dist/sw.cjs.map +0 -1
  128. package/dist/sw.js.map +0 -1
  129. package/dist/testing.cjs.map +0 -1
  130. package/dist/testing.js.map +0 -1
package/dist/node.cjs.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/core/handler.ts","../src/node/index.ts"],"names":["done"],"mappings":"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","file":"node.cjs","sourcesContent":["import type { DatabaseAdapter } from \"./adapter\";\nimport type { SessionConfig } from \"./config\";\nimport type { Logger } from \"./errors\";\nimport type { Promisable, RouteDefinition } from \"./plugin\";\nimport type { Runner } from \"./runner\";\nimport { inboxChannel, type Signals } from \"./signals\";\nimport { verifyToken } from \"./tokens\";\n\nexport type EventsSettings = {\n heartbeatMs: number;\n probeIntervalMs: number;\n maxDurationMs: number;\n maxStreamsPerUser: number;\n maxStreams: number;\n};\n\n/** Unread chunks a stream may hold before it is closed on a consumer that stopped reading. */\nconst STREAM_HIGH_WATER_MARK = 256;\n\nexport type HandlerDeps = {\n adapter: DatabaseAdapter;\n session: SessionConfig;\n runner: Runner;\n logger: Logger;\n basePath: string;\n secret: string;\n signals: Signals;\n /** `false` unmounts GET /events. */\n events: EventsSettings | false;\n /** Fire-and-forget delivery pass after a request. Rate-limited by the instance. */\n sweepOnRequest?: (() => void) | undefined;\n cronSecret?: string | undefined;\n /** Guards plugin machine routes. The instance defaults it to cronSecret. */\n machineSecret?: string | undefined;\n cronMaxSweeps?: number | undefined;\n pluginRoutes?: readonly RouteDefinition[] | undefined;\n /** Returns a 429 to send, or null to proceed. Runs after onRequest, before routing. */\n rateLimit?: ((request: Request) => Response | null) | undefined;\n /** Origins other than the request's own host that may POST. `*.example.com` allowed. */\n trustedOrigins?: readonly string[] | undefined;\n /** Runs before routing. Returning a Response short-circuits; use it for rate limiting. */\n // biome-ignore lint/suspicious/noConfusingVoidType: a hook that returns nothing is the common case\n onRequest?: ((request: Request) => Promisable<Response | undefined | void>) | undefined;\n maxBodyBytes?: number | undefined;\n};\n\n/** Every JSON body is capped here. Nothing legitimate the routes accept is bigger. */\nexport const MAX_BODY_BYTES = 64 * 1024;\n\nconst MAX_READ_IDS = 200;\n\nconst SESSION_ERROR: unique symbol = Symbol(\"session-error\");\n\n// Per-user data: no shared caching, no sniffing.\nconst PRIVATE_HEADERS: Record<string, string> = {\n \"cache-control\": \"private, no-store\",\n \"x-content-type-options\": \"nosniff\",\n};\n\nconst json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json\", ...PRIVATE_HEADERS, ...headers },\n });\n\nconst empty = (status: number) => new Response(null, { status, headers: PRIVATE_HEADERS });\n\n/** Plugin responses get the same headers, without touching their body. */\nfunction harden(response: Response): Response {\n const headers = new Headers(response.headers);\n for (const [name, value] of Object.entries(PRIVATE_HEADERS)) {\n if (!headers.has(name)) headers.set(name, value);\n }\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n\nconst encoder = new TextEncoder();\n\n/** Constant time over SHA-256 digests, so neither content nor length leaks. Works on edge runtimes. */\nasync function timingSafeEqual(a: string, b: string): Promise<boolean> {\n const [left, right] = await Promise.all(\n [a, b].map(\n async (value) => new Uint8Array(await crypto.subtle.digest(\"SHA-256\", encoder.encode(value))),\n ),\n );\n let diff = 0;\n for (let i = 0; i < 32; i += 1) diff |= (left?.[i] ?? 0) ^ (right?.[i] ?? 0);\n return diff === 0;\n}\n\n/** Anchored at a segment boundary: `/foo/api/notifications/cron` is not a cron request. */\nfunction routePath(url: URL, basePath: string): string {\n const { pathname } = url;\n if (pathname === basePath) return \"/\";\n if (pathname.startsWith(`${basePath}/`)) return pathname.slice(basePath.length);\n return pathname;\n}\n\nexport type JsonBody = { body: Record<string, unknown> } | { error: Response };\n\n/** Reads a JSON object body with a streaming byte cap. Malformed JSON is 400; an empty body is `{}`. */\nexport async function readJsonBody(\n request: Request,\n maxBytes: number = MAX_BODY_BYTES,\n): Promise<JsonBody> {\n const tooLarge = () => ({ error: json({ error: `body exceeds ${maxBytes} bytes` }, 413) });\n\n const declared = Number(request.headers.get(\"content-length\"));\n if (Number.isFinite(declared) && declared > maxBytes) return tooLarge();\n if (!request.body) return { body: {} };\n\n const reader = request.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > maxBytes) {\n await reader.cancel();\n return tooLarge();\n }\n chunks.push(value);\n }\n\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.byteLength;\n }\n\n const text = new TextDecoder().decode(bytes);\n if (text.trim() === \"\") return { body: {} };\n\n try {\n const parsed: unknown = JSON.parse(text);\n return {\n body:\n parsed && typeof parsed === \"object\" && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {},\n };\n } catch {\n return { error: json({ error: \"body must be a JSON object\" }, 400) };\n }\n}\n\nconst isJsonContentType = (request: Request) =>\n /^application\\/json\\s*(;|$)/i.test((request.headers.get(\"content-type\") ?? \"\").trim());\n\nfunction originAllowed(origin: string, url: URL, request: Request, trusted: readonly string[]) {\n let host: string;\n try {\n host = new URL(origin).host;\n } catch {\n return false;\n }\n\n if (host === url.host) return true;\n // Behind a proxy that rewrites Host, the public host arrives here instead.\n const forwarded = request.headers.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim();\n if (forwarded && host === forwarded) return true;\n\n return trusted.some((entry) =>\n entry.startsWith(\"*.\") ? host.endsWith(entry.slice(1)) : entry === origin,\n );\n}\n\n/** CSRF: a cross-site form can neither send `application/json` without a preflight nor forge `Origin`. */\nfunction csrfCheck(request: Request, url: URL, trusted: readonly string[]): Response | null {\n if (!isJsonContentType(request)) {\n return json({ error: \"content-type must be application/json\" }, 415);\n }\n\n const origin = request.headers.get(\"origin\");\n if (origin === null) return null;\n if (origin === \"null\" || !originAllowed(origin, url, request, trusted)) return empty(403);\n\n return null;\n}\n\nexport function createHandler(deps: HandlerDeps) {\n const trustedOrigins = deps.trustedOrigins ?? [];\n const maxBodyBytes = deps.maxBodyBytes ?? MAX_BODY_BYTES;\n\n let warnedBadUserId = false;\n\n async function resolveUserId(request: Request): Promise<string | null | typeof SESSION_ERROR> {\n let id: unknown;\n try {\n id = await deps.session.getUserId(request);\n } catch (error) {\n // A broken session lookup is a 500, never a 401. RFC 0002 §1.\n deps.logger.error(\"session.getUserId threw\", { error });\n return SESSION_ERROR;\n }\n if (id === null || id === undefined) return null;\n if (typeof id !== \"string\" || id === \"\") {\n if (!warnedBadUserId) {\n warnedBadUserId = true;\n deps.logger.warn(\n `session.getUserId returned ${id === \"\" ? \"an empty string\" : typeof id}; treating the ` +\n \"request as unauthenticated. Return a non-empty string or null.\",\n );\n }\n return null;\n }\n return id;\n }\n\n async function bearerMatches(request: Request, secret: string | undefined): Promise<boolean> {\n if (!secret) return false;\n const header = request.headers.get(\"authorization\") ?? \"\";\n const presented = header.startsWith(\"Bearer \") ? header.slice(7) : header;\n return timingSafeEqual(presented, secret);\n }\n\n async function handleCron(request: Request): Promise<Response> {\n if (!deps.cronSecret) return empty(404);\n if (!(await bearerMatches(request, deps.cronSecret))) return empty(401);\n\n // Bounded so an overlapping scheduler does not hold a request open for the whole backlog.\n const result = await deps.runner.drain(\n deps.cronMaxSweeps === undefined ? {} : { maxSweeps: deps.cronMaxSweeps },\n );\n return json(result);\n }\n\n /** Plugin routes first; auth is enforced here so a plugin cannot forget it. */\n async function handlePluginRoute(\n route: RouteDefinition,\n request: Request,\n url: URL,\n ): Promise<Response> {\n if (route.scope.type === \"user\") {\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === SESSION_ERROR) return empty(500);\n if (userId === null) return empty(401);\n return harden(await route.handler({ request, userId, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"machine\") {\n if (!deps.machineSecret) return empty(404);\n if (!(await bearerMatches(request, deps.machineSecret))) return empty(401);\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"signed\") {\n const token =\n url.searchParams.get(\"token\") ??\n (request.headers.get(\"authorization\") ?? \"\").replace(/^Bearer /, \"\");\n\n const claims = await verifyToken(deps.secret, token, route.scope.purpose);\n // One response for every failure mode. RFC 0002 §5.\n if (!claims) return empty(400);\n\n return harden(await route.handler({ request, userId: claims.uid, claims, params: {} }));\n }\n\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n async function route(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const path = routePath(url, deps.basePath);\n\n if (deps.onRequest) {\n const early = await deps.onRequest(request);\n if (early instanceof Response) return early;\n }\n\n if (deps.rateLimit) {\n const limited = deps.rateLimit(request);\n if (limited) return limited;\n }\n\n if (path === \"/cron\") {\n if (request.method !== \"POST\") return empty(405);\n return handleCron(request);\n }\n\n const pluginRoute = deps.pluginRoutes?.find(\n (candidate) => candidate.path === path && candidate.method === request.method,\n );\n if (pluginRoute) return handlePluginRoute(pluginRoute, request, url);\n\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === SESSION_ERROR) return empty(500);\n if (userId === null) return empty(401);\n\n if (request.method === \"GET\" && path === \"/events\") {\n if (!deps.events) return empty(404);\n // A session can open streams for free; without a cap one account can hold every socket.\n if ((openStreams.get(userId) ?? 0) >= deps.events.maxStreamsPerUser) {\n return json({ error: \"too many open event streams\" }, 429, { \"retry-after\": \"30\" });\n }\n if (totalStreams >= deps.events.maxStreams) {\n return json({ error: \"event streams unavailable\" }, 503, { \"retry-after\": \"30\" });\n }\n return events(request, userId, deps.events);\n }\n\n if (request.method === \"GET\" && path === \"/\") {\n const limitParam = Number(url.searchParams.get(\"limit\") ?? 20);\n const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;\n const cursor = url.searchParams.get(\"cursor\");\n\n // unseenCount rides on the first page only: a cursor request is scrollback, not a poll.\n const [page, unseenCount] = await Promise.all([\n deps.adapter.listNotifications({\n userId,\n limit,\n ...(cursor ? { cursor } : {}),\n ...(url.searchParams.get(\"unreadOnly\") === \"true\" ? { unreadOnly: true } : {}),\n }),\n cursor ? Promise.resolve(null) : deps.adapter.countUnseen(userId),\n ]);\n\n return json(unseenCount === null ? page : { ...page, unseenCount });\n }\n\n if (request.method === \"GET\" && path === \"/count\") {\n return json({ unseen: await deps.adapter.countUnseen(userId) });\n }\n\n if (request.method === \"POST\" && path === \"/seen\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const before = typeof parsed.body.before === \"string\" ? new Date(parsed.body.before) : null;\n\n // +1ms: Postgres keeps microseconds, an ISO string milliseconds; without it markSeen misses the newest row.\n const cutoff =\n before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : new Date();\n\n await deps.adapter.markSeen(userId, cutoff);\n deps.signals.publish(inboxChannel(userId));\n return json({ ok: true });\n }\n\n if (request.method === \"POST\" && path === \"/read\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const ids = Array.isArray(parsed.body.ids)\n ? parsed.body.ids.filter((id): id is string => typeof id === \"string\")\n : [];\n if (ids.length === 0) return json({ error: \"ids must be a non-empty string array\" }, 400);\n\n if (ids.length > MAX_READ_IDS) {\n return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);\n }\n\n // 404, not 403: a 403 confirms the row exists. RFC 0002 §2.\n const updated = await deps.adapter.markRead(userId, ids);\n if (updated > 0) deps.signals.publish(inboxChannel(userId));\n return updated === 0 ? empty(404) : json({ updated });\n }\n\n if (request.method === \"POST\" && path === \"/read-all\") {\n const updated = await deps.adapter.markAllRead(userId);\n if (updated > 0) deps.signals.publish(inboxChannel(userId));\n return json({ updated });\n }\n\n return empty(404);\n }\n\n const openStreams = new Map<string, number>();\n let totalStreams = 0;\n\n const trackStream = (userId: string) => {\n openStreams.set(userId, (openStreams.get(userId) ?? 0) + 1);\n totalStreams += 1;\n return () => {\n const remaining = (openStreams.get(userId) ?? 1) - 1;\n if (remaining > 0) openStreams.set(userId, remaining);\n else openStreams.delete(userId);\n totalStreams -= 1;\n };\n };\n\n /** A change fingerprint cheap enough to take every probe interval. */\n async function fingerprint(userId: string): Promise<string> {\n const [page, unseen] = await Promise.all([\n deps.adapter.listNotifications({ userId, limit: 1 }),\n deps.adapter.countUnseen(userId),\n ]);\n const newest = page.notifications[0];\n return `${newest?.id ?? \"\"}|${newest?.readAt ? 1 : 0}|${unseen}`;\n }\n\n // One probe per user, however many streams that user holds: N tabs must not mean N queries.\n type Probe = { listeners: Set<() => void>; timer: ReturnType<typeof setInterval>; last?: string };\n const probes = new Map<string, Probe>();\n\n function subscribeProbe(userId: string, onChanged: () => void, intervalMs: number) {\n let probe = probes.get(userId);\n if (!probe) {\n const created: Probe = {\n listeners: new Set(),\n timer: setInterval(() => {\n fingerprint(userId)\n .then((value) => {\n if (created.last !== undefined && value !== created.last) {\n for (const listener of created.listeners) listener();\n }\n created.last = value;\n })\n .catch((error) => deps.logger.error(\"events probe failed\", { error }));\n }, intervalMs),\n };\n probe = created;\n probes.set(userId, created);\n fingerprint(userId)\n .then((value) => {\n if (created.last === undefined) created.last = value;\n })\n .catch(() => {});\n }\n probe.listeners.add(onChanged);\n return () => {\n const current = probes.get(userId);\n if (!current) return;\n current.listeners.delete(onChanged);\n if (current.listeners.size === 0) {\n clearInterval(current.timer);\n probes.delete(userId);\n }\n };\n }\n\n /**\n * Server-Sent Events: `ready` once, `changed` whenever this user's inbox\n * moves, comments as keepalive. No payload ever travels on the stream; the\n * client fetches the feed on `changed`. RFC 0006 §4D.\n */\n function events(request: Request, userId: string, settings: EventsSettings): Response {\n const encoder = new TextEncoder();\n const timers: ReturnType<typeof setInterval>[] = [];\n const unsubscribes: (() => void)[] = [];\n let closed = false;\n let cleanup = () => {};\n\n const stream = new ReadableStream<Uint8Array>(\n {\n start(controller) {\n const write = (text: string) => {\n if (closed) return;\n // A consumer that stopped reading would make this queue grow without bound.\n if (controller.desiredSize !== null && controller.desiredSize <= 0) {\n cleanup();\n return;\n }\n try {\n controller.enqueue(encoder.encode(text));\n } catch {\n cleanup();\n }\n };\n cleanup = () => {\n if (closed) return;\n closed = true;\n for (const unsubscribe of unsubscribes) unsubscribe();\n for (const timer of timers) clearInterval(timer);\n try {\n controller.close();\n } catch {\n // already closed by the consumer\n }\n };\n const changed = () => write(\"event: changed\\ndata: {}\\n\\n\");\n\n unsubscribes.push(trackStream(userId));\n write(\"retry: 3000\\n\\nevent: ready\\ndata: {}\\n\\n\");\n unsubscribes.push(deps.signals.subscribe(inboxChannel(userId), changed));\n timers.push(setInterval(() => write(\": keepalive\\n\\n\"), settings.heartbeatMs));\n\n if (!deps.signals.crossProcess && settings.probeIntervalMs > 0) {\n unsubscribes.push(subscribeProbe(userId, changed, settings.probeIntervalMs));\n }\n\n if (settings.maxDurationMs > 0) {\n timers.push(\n setTimeout(cleanup, settings.maxDurationMs) as ReturnType<typeof setInterval>,\n );\n }\n request.signal?.addEventListener(\"abort\", cleanup, { once: true });\n },\n cancel() {\n cleanup();\n },\n },\n { highWaterMark: STREAM_HIGH_WATER_MARK },\n );\n\n return new Response(stream, {\n headers: {\n \"content-type\": \"text/event-stream; charset=utf-8\",\n \"cache-control\": \"no-cache, no-store\",\n connection: \"keep-alive\",\n \"x-accel-buffering\": \"no\",\n \"x-content-type-options\": \"nosniff\",\n },\n });\n }\n\n async function handle(request: Request): Promise<Response> {\n let response: Response | undefined;\n try {\n response = await route(request);\n return response;\n } catch (error) {\n // A thrown driver error must not escape: unhandled rejection on Node, stack trace elsewhere.\n deps.logger.error(\"request failed\", { url: request.url, error });\n return empty(500);\n } finally {\n // Only a served request earns a sweep; anonymous 401s must not drive database work.\n if (response && response.status < 400) deps.sweepOnRequest?.();\n }\n }\n\n return {\n handle,\n GET: handle,\n POST: handle,\n };\n}\n","import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { MAX_BODY_BYTES } from \"../core/handler\";\n\n/** Adapts the web-standard handler to Node's http/Express signature. */\nexport type NodeHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;\n\nexport type NodeHandlerOptions = {\n /** Defaults to 64 KiB. Enforced here because this reads the raw stream, bypassing body-parser. */\n maxBodyBytes?: number;\n /** Called when the handler itself throws. Defaults to console.error; the response is already a 500. */\n onError?: (error: unknown) => void;\n};\n\nclass BodyTooLargeError extends Error {}\n\nfunction readBody(req: IncomingMessage, maxBytes: number): Promise<Buffer | undefined> {\n if (req.method === \"GET\" || req.method === \"HEAD\") return Promise.resolve(undefined);\n\n const declared = Number(req.headers[\"content-length\"]);\n if (Number.isFinite(declared) && declared > maxBytes) {\n return Promise.reject(new BodyTooLargeError());\n }\n\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let total = 0;\n req.on(\"data\", (chunk: Buffer) => {\n total += chunk.length;\n if (total > maxBytes) {\n req.destroy();\n reject(new BodyTooLargeError());\n return;\n }\n chunks.push(chunk);\n });\n req.on(\"end\", () => resolve(chunks.length ? Buffer.concat(chunks) : undefined));\n req.on(\"error\", reject);\n });\n}\n\nexport function toWebRequest(req: IncomingMessage, body?: Buffer, signal?: AbortSignal): Request {\n // Behind a load balancer this is the only signal that the site is https.\n const forwarded = String(req.headers[\"x-forwarded-proto\"] ?? \"\")\n .split(\",\")[0]\n ?.trim()\n .toLowerCase();\n const protocol = forwarded === \"https\" || forwarded === \"http\" ? forwarded : \"http\";\n const host = req.headers.host ?? \"localhost\";\n\n const headers = new Headers();\n for (const [key, value] of Object.entries(req.headers)) {\n if (value === undefined) continue;\n if (Array.isArray(value)) for (const item of value) headers.append(key, item);\n else headers.set(key, value);\n }\n\n return new Request(`${protocol}://${host}${req.url ?? \"/\"}`, {\n method: req.method ?? \"GET\",\n headers,\n ...(body ? { body: new Uint8Array(body) } : {}),\n ...(signal ? { signal } : {}),\n });\n}\n\nexport function toNodeHandler(\n handler: (request: Request) => Promise<Response>,\n options: NodeHandlerOptions = {},\n): NodeHandler {\n const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;\n const onError = options.onError ?? ((error: unknown) => console.error(\"[easy-ping]\", error));\n\n return async (req, res) => {\n // A client that goes away aborts the request, so an open event stream cleans up.\n const disconnect = new AbortController();\n res.on(\"close\", () => disconnect.abort());\n\n try {\n const body = await readBody(req, maxBodyBytes);\n const response = await handler(toWebRequest(req, body, disconnect.signal));\n\n res.statusCode = response.status;\n // forEach: Headers is only iterable with \"DOM.Iterable\" in lib.\n response.headers.forEach((value, key) => {\n res.setHeader(key, value);\n });\n\n if (!response.body) {\n res.end();\n return;\n }\n\n // Streamed, never buffered: GET /events stays open and every chunk must reach the client now.\n res.flushHeaders();\n const reader = response.body.getReader();\n const cancel = () => reader.cancel().catch(() => {});\n disconnect.signal.addEventListener(\"abort\", cancel, { once: true });\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n if (!res.write(value)) {\n await new Promise<void>((resolve) => {\n const done = () => {\n res.off(\"drain\", done);\n disconnect.signal.removeEventListener(\"abort\", done);\n resolve();\n };\n res.once(\"drain\", done);\n disconnect.signal.addEventListener(\"abort\", done, { once: true });\n });\n if (disconnect.signal.aborted) break;\n }\n }\n } finally {\n disconnect.signal.removeEventListener(\"abort\", cancel);\n }\n res.end();\n } catch (error) {\n // Respond and swallow: a rethrow is an unhandled rejection under Express.\n if (!res.headersSent) res.statusCode = error instanceof BodyTooLargeError ? 413 : 500;\n res.end();\n if (!(error instanceof BodyTooLargeError)) onError(error);\n }\n };\n}\n"]}
package/dist/node.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/node/index.ts"],"names":["done"],"mappings":"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","file":"node.js","sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { MAX_BODY_BYTES } from \"../core/handler\";\n\n/** Adapts the web-standard handler to Node's http/Express signature. */\nexport type NodeHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;\n\nexport type NodeHandlerOptions = {\n /** Defaults to 64 KiB. Enforced here because this reads the raw stream, bypassing body-parser. */\n maxBodyBytes?: number;\n /** Called when the handler itself throws. Defaults to console.error; the response is already a 500. */\n onError?: (error: unknown) => void;\n};\n\nclass BodyTooLargeError extends Error {}\n\nfunction readBody(req: IncomingMessage, maxBytes: number): Promise<Buffer | undefined> {\n if (req.method === \"GET\" || req.method === \"HEAD\") return Promise.resolve(undefined);\n\n const declared = Number(req.headers[\"content-length\"]);\n if (Number.isFinite(declared) && declared > maxBytes) {\n return Promise.reject(new BodyTooLargeError());\n }\n\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let total = 0;\n req.on(\"data\", (chunk: Buffer) => {\n total += chunk.length;\n if (total > maxBytes) {\n req.destroy();\n reject(new BodyTooLargeError());\n return;\n }\n chunks.push(chunk);\n });\n req.on(\"end\", () => resolve(chunks.length ? Buffer.concat(chunks) : undefined));\n req.on(\"error\", reject);\n });\n}\n\nexport function toWebRequest(req: IncomingMessage, body?: Buffer, signal?: AbortSignal): Request {\n // Behind a load balancer this is the only signal that the site is https.\n const forwarded = String(req.headers[\"x-forwarded-proto\"] ?? \"\")\n .split(\",\")[0]\n ?.trim()\n .toLowerCase();\n const protocol = forwarded === \"https\" || forwarded === \"http\" ? forwarded : \"http\";\n const host = req.headers.host ?? \"localhost\";\n\n const headers = new Headers();\n for (const [key, value] of Object.entries(req.headers)) {\n if (value === undefined) continue;\n if (Array.isArray(value)) for (const item of value) headers.append(key, item);\n else headers.set(key, value);\n }\n\n return new Request(`${protocol}://${host}${req.url ?? \"/\"}`, {\n method: req.method ?? \"GET\",\n headers,\n ...(body ? { body: new Uint8Array(body) } : {}),\n ...(signal ? { signal } : {}),\n });\n}\n\nexport function toNodeHandler(\n handler: (request: Request) => Promise<Response>,\n options: NodeHandlerOptions = {},\n): NodeHandler {\n const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;\n const onError = options.onError ?? ((error: unknown) => console.error(\"[easy-ping]\", error));\n\n return async (req, res) => {\n // A client that goes away aborts the request, so an open event stream cleans up.\n const disconnect = new AbortController();\n res.on(\"close\", () => disconnect.abort());\n\n try {\n const body = await readBody(req, maxBodyBytes);\n const response = await handler(toWebRequest(req, body, disconnect.signal));\n\n res.statusCode = response.status;\n // forEach: Headers is only iterable with \"DOM.Iterable\" in lib.\n response.headers.forEach((value, key) => {\n res.setHeader(key, value);\n });\n\n if (!response.body) {\n res.end();\n return;\n }\n\n // Streamed, never buffered: GET /events stays open and every chunk must reach the client now.\n res.flushHeaders();\n const reader = response.body.getReader();\n const cancel = () => reader.cancel().catch(() => {});\n disconnect.signal.addEventListener(\"abort\", cancel, { once: true });\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n if (!res.write(value)) {\n await new Promise<void>((resolve) => {\n const done = () => {\n res.off(\"drain\", done);\n disconnect.signal.removeEventListener(\"abort\", done);\n resolve();\n };\n res.once(\"drain\", done);\n disconnect.signal.addEventListener(\"abort\", done, { once: true });\n });\n if (disconnect.signal.aborted) break;\n }\n }\n } finally {\n disconnect.signal.removeEventListener(\"abort\", cancel);\n }\n res.end();\n } catch (error) {\n // Respond and swallow: a rethrow is an unhandled rejection under Express.\n if (!res.headersSent) res.statusCode = error instanceof BodyTooLargeError ? 413 : 500;\n res.end();\n if (!(error instanceof BodyTooLargeError)) onError(error);\n }\n };\n}\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/schema/declaration.ts","../../src/plugins/digests/schedule.ts","../../src/plugins/digests/index.ts"],"names":[],"mappings":"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","file":"digests.cjs","sourcesContent":["import type { SchemaDeclaration } from \"../core/plugin\";\n\nexport const toSnakeCase = (value: string) => value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);\n\n/** Core tables in the plugin format. A drift test asserts it agrees with the Drizzle schema. */\nexport const coreSchema = {\n notification: {\n tableName: \"notification\",\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n payload: { type: \"json\", required: true },\n actorId: { type: \"string\" },\n groupKey: { type: \"string\" },\n dedupeKey: { type: \"string\" },\n seenAt: { type: \"date\" },\n readAt: { type: \"date\" },\n archivedAt: { type: \"date\" },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [\n { on: [\"userId\", \"createdAt\"], name: \"notification_feed_idx\" },\n { on: [\"userId\", \"groupKey\"], name: \"notification_group_idx\" },\n { on: [\"userId\", \"dedupeKey\"], unique: true, name: \"notification_dedupe_idx\" },\n ],\n },\n\n notificationDelivery: {\n tableName: \"notification_delivery\",\n fields: {\n id: { type: \"string\", required: true },\n notificationId: { type: \"string\", required: true },\n channel: { type: \"string\", required: true },\n status: { type: \"string\", required: true, default: \"pending\" },\n attempts: { type: \"number\", required: true, default: 0 },\n maxAttempts: { type: \"number\", required: true, default: 5 },\n notBefore: { type: \"date\", required: true, defaultNow: true },\n claimedAt: { type: \"date\" },\n claimedBy: { type: \"string\" },\n lastError: { type: \"string\" },\n updatedAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [\n { on: [\"status\", \"notBefore\"], name: \"delivery_claim_idx\" },\n { on: [\"notificationId\"], name: \"delivery_notification_idx\" },\n ],\n },\n\n notificationPreference: {\n tableName: \"notification_preference\",\n fields: {\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n channel: { type: \"string\", required: true },\n enabled: { type: \"boolean\", required: true, default: true },\n frequency: { type: \"string\", required: true, default: \"instant\" },\n updatedAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\n} satisfies SchemaDeclaration;\n","export type DigestWindow = \"daily\" | \"weekly\";\n\nexport type LocalMoment = {\n /** YYYY-MM-DD in the recipient's zone. */\n date: string;\n hour: number;\n /** 0 = Sunday. */\n weekday: number;\n};\n\nconst WEEKDAYS = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\n\n/** Wall clock in the recipient's zone. hourCycle h23: hour12:false renders midnight as \"24\" in some locales. */\nexport function localMoment(timezone: string, now: Date): LocalMoment {\n const formatter = new Intl.DateTimeFormat(\"en-CA\", {\n timeZone: timezone,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n hourCycle: \"h23\",\n weekday: \"short\",\n });\n\n const parts: Record<string, string> = {};\n for (const part of formatter.formatToParts(now)) parts[part.type] = part.value;\n\n return {\n date: `${parts.year}-${parts.month}-${parts.day}`,\n hour: Number(parts.hour ?? 0),\n weekday: Math.max(0, WEEKDAYS.indexOf(parts.weekday ?? \"Sun\")),\n };\n}\n\nconst shiftDays = (date: string, days: number): string => {\n const shifted = new Date(`${date}T00:00:00Z`);\n shifted.setUTCDate(shifted.getUTCDate() - days);\n return shifted.toISOString().slice(0, 10);\n};\n\n/** The digest period a moment falls in, keyed off the local calendar so DST cannot double or skip a send. */\nexport function periodKey(window: DigestWindow, moment: LocalMoment, sendWeekday: number): string {\n if (window === \"daily\") return moment.date;\n\n const since = (moment.weekday - sendWeekday + 7) % 7;\n return shiftDays(moment.date, since);\n}\n\n/** Whether the send moment for the current period has arrived. */\nexport function isDue(\n window: DigestWindow,\n moment: LocalMoment,\n sendHour: number,\n sendWeekday: number,\n): boolean {\n if (window === \"daily\") return moment.hour >= sendHour;\n\n const period = periodKey(\"weekly\", moment, sendWeekday);\n // Past the send day means a run was missed; catch up rather than skip a week.\n return moment.date > period || moment.hour >= sendHour;\n}\n","import type {\n AfterSendContext,\n EasyPingPlugin,\n PluginInitContext,\n PrepareContext,\n ResolveChannelsContext,\n} from \"../../core/plugin\";\nimport type { Frequency } from \"../../core/types\";\nimport { coreSchema } from \"../../schema/declaration\";\nimport { type DigestWindow, isDue, localMoment, periodKey } from \"./schedule\";\n\nconst ENTRY = \"notification_digest_entry\";\nconst STATE = \"notification_digest_state\";\n\nexport type DigestEntry = {\n id: string;\n userId: string;\n type: string;\n notificationId: string;\n payload: unknown;\n window: DigestWindow;\n createdAt: Date;\n};\n\nexport type DigestsOptions = {\n /** The type the composed digest is sent as; the app defines its template over `{ items }`. */\n digestType?: string;\n /** Local hour at which a digest becomes due. */\n sendHour?: number;\n /** Weekly digests only; 0 = Sunday. */\n sendWeekday?: number;\n /** Channels a digest defers. Only email makes sense today. */\n channel?: \"email\";\n /** Safety valve on one cron run. */\n maxUsersPerRun?: number;\n};\n\ntype Prepared = {\n /** userId -> window, for recipients whose preference defers this type. */\n deferred: Map<string, DigestWindow>;\n};\n\nconst isDigestWindow = (frequency: Frequency): frequency is DigestWindow =>\n frequency === \"daily\" || frequency === \"weekly\";\n\n/** One email containing N items, rather than N deferred emails at 9am. Depends on `preferences` for the frequency. */\nexport function digests(options: DigestsOptions = {}): EasyPingPlugin<\"digests\"> {\n const digestType = options.digestType ?? \"digest\";\n const sendHour = options.sendHour ?? 9;\n const sendWeekday = options.sendWeekday ?? 1;\n const channel = options.channel ?? \"email\";\n const maxUsersPerRun = options.maxUsersPerRun ?? 500;\n\n let ctx: PluginInitContext;\n\n /** Reads the frequency the preferences plugin stored for this type. */\n async function windowFor(\n userIds: readonly string[],\n type: string,\n ): Promise<Map<string, DigestWindow>> {\n const rows = await ctx.store.find<{\n userId: string;\n type: string;\n channel: string;\n frequency: Frequency;\n enabled: boolean;\n }>(\"notification_preference\", { userId: { in: [...userIds] }, type, channel });\n\n const windows = new Map<string, DigestWindow>();\n for (const row of rows) {\n if (row.enabled && isDigestWindow(row.frequency)) windows.set(row.userId, row.frequency);\n }\n return windows;\n }\n\n return {\n id: \"digests\",\n dependsOn: [\"preferences\"],\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n digestEntry: {\n tableName: ENTRY,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n notificationId: { type: \"string\", required: true },\n payload: { type: \"json\" },\n window: { type: \"string\", required: true },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [{ on: [\"userId\", \"window\"], name: \"digest_entry_user_idx\" }],\n },\n\n digestState: {\n tableName: STATE,\n fields: {\n userId: { type: \"string\", required: true },\n window: { type: \"string\", required: true },\n /** Last period actually sent. The idempotency key for the schedule. */\n lastPeriod: { type: \"string\", required: true },\n updatedAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"userId\", \"window\"],\n },\n\n // Read-only here. The full core declaration, not a subset, so migration planners\n // comparing it against the live table do not see updated_at as undeclared.\n notificationPreference: { ...coreSchema.notificationPreference, readOnly: true },\n },\n\n hooks: {\n prepare: async (context: PrepareContext): Promise<Prepared> => {\n if (context.type === digestType) return { deferred: new Map() };\n\n return {\n deferred: await windowFor(\n context.recipients.map((recipient) => recipient.userId),\n context.type,\n ),\n };\n },\n\n resolveChannels: (context: ResolveChannelsContext) => {\n const prepared = context.prepared as Prepared | undefined;\n if (!prepared?.deferred.has(context.recipient.userId)) return context.decisions;\n\n return context.decisions.map((decision) =>\n decision.channel === channel ? { ...decision, action: \"skip\" as const } : decision,\n );\n },\n\n // The only hook that sees notification ids.\n afterSend: async (context: AfterSendContext) => {\n const prepared = context.prepared as Prepared | undefined;\n if (!prepared || prepared.deferred.size === 0) return;\n\n const entries = context.notifications\n .filter((notification) => prepared.deferred.has(notification.userId))\n .map((notification) => ({\n id: crypto.randomUUID(),\n userId: notification.userId,\n type: context.type,\n notificationId: notification.id,\n payload: context.payload,\n window: prepared.deferred.get(notification.userId) as DigestWindow,\n createdAt: new Date(),\n }));\n\n if (entries.length > 0) await ctx.store.insert(ENTRY, entries);\n },\n },\n\n routes: [\n {\n /** Point an hourly cron here: \"9am\" is 24 different UTC moments that shift with DST. */\n path: \"/digests/cron\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => {\n const now = new Date();\n\n const pending = await ctx.store.find<DigestEntry>(ENTRY, {}, { limit: 10_000 });\n if (pending.length === 0) return Response.json({ sent: 0, users: 0 });\n\n const byUser = new Map<string, DigestEntry[]>();\n for (const entry of pending) {\n const bucket = byUser.get(entry.userId);\n if (bucket) bucket.push(entry);\n else byUser.set(entry.userId, [entry]);\n }\n\n const userIds = [...byUser.keys()].slice(0, maxUsersPerRun);\n const recipients = await ctx.getRecipients(userIds);\n const zones = new Map(recipients.map((r) => [r.userId, r.timezone]));\n\n const states = await ctx.store.find<{\n userId: string;\n window: DigestWindow;\n lastPeriod: string;\n }>(STATE, { userId: { in: userIds } });\n\n const lastPeriods = new Map(states.map((s) => [`${s.userId} ${s.window}`, s.lastPeriod]));\n\n let sent = 0;\n\n for (const userId of userIds) {\n const entries = byUser.get(userId) ?? [];\n const moment = localMoment(zones.get(userId) ?? \"UTC\", now);\n\n for (const window of [\"daily\", \"weekly\"] as const) {\n const forWindow = entries.filter((entry) => entry.window === window);\n if (forWindow.length === 0) continue;\n\n const period = periodKey(window, moment, sendWeekday);\n // Idempotent on the period key, so a cron that runs twice cannot send twice.\n if (lastPeriods.get(`${userId} ${window}`) === period) continue;\n if (!isDue(window, moment, sendHour, sendWeekday)) continue;\n\n await ctx.send(digestType, {\n to: userId,\n payload: {\n window,\n period,\n items: forWindow.map((entry) => ({\n type: entry.type,\n payload: entry.payload,\n createdAt: entry.createdAt,\n })),\n },\n dedupeKey: `digest:${window}:${period}:${userId}`,\n });\n\n await ctx.store.upsert(\n STATE,\n [{ userId, window, lastPeriod: period, updatedAt: new Date() }],\n { onConflict: [\"userId\", \"window\"] },\n );\n\n // Only after the send is recorded: a crash re-sends rather than drops.\n await ctx.store.remove(ENTRY, {\n id: { in: forWindow.map((entry) => entry.id) },\n });\n\n sent += 1;\n }\n }\n\n return Response.json({ sent, users: userIds.length });\n },\n },\n ],\n };\n}\n\nexport type { DigestWindow, LocalMoment } from \"./schedule\";\nexport { isDue, localMoment, periodKey } from \"./schedule\";\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/plugins/digests/schedule.ts","../../src/plugins/digests/index.ts"],"names":[],"mappings":"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","file":"digests.js","sourcesContent":["export type DigestWindow = \"daily\" | \"weekly\";\n\nexport type LocalMoment = {\n /** YYYY-MM-DD in the recipient's zone. */\n date: string;\n hour: number;\n /** 0 = Sunday. */\n weekday: number;\n};\n\nconst WEEKDAYS = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\n\n/** Wall clock in the recipient's zone. hourCycle h23: hour12:false renders midnight as \"24\" in some locales. */\nexport function localMoment(timezone: string, now: Date): LocalMoment {\n const formatter = new Intl.DateTimeFormat(\"en-CA\", {\n timeZone: timezone,\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n hourCycle: \"h23\",\n weekday: \"short\",\n });\n\n const parts: Record<string, string> = {};\n for (const part of formatter.formatToParts(now)) parts[part.type] = part.value;\n\n return {\n date: `${parts.year}-${parts.month}-${parts.day}`,\n hour: Number(parts.hour ?? 0),\n weekday: Math.max(0, WEEKDAYS.indexOf(parts.weekday ?? \"Sun\")),\n };\n}\n\nconst shiftDays = (date: string, days: number): string => {\n const shifted = new Date(`${date}T00:00:00Z`);\n shifted.setUTCDate(shifted.getUTCDate() - days);\n return shifted.toISOString().slice(0, 10);\n};\n\n/** The digest period a moment falls in, keyed off the local calendar so DST cannot double or skip a send. */\nexport function periodKey(window: DigestWindow, moment: LocalMoment, sendWeekday: number): string {\n if (window === \"daily\") return moment.date;\n\n const since = (moment.weekday - sendWeekday + 7) % 7;\n return shiftDays(moment.date, since);\n}\n\n/** Whether the send moment for the current period has arrived. */\nexport function isDue(\n window: DigestWindow,\n moment: LocalMoment,\n sendHour: number,\n sendWeekday: number,\n): boolean {\n if (window === \"daily\") return moment.hour >= sendHour;\n\n const period = periodKey(\"weekly\", moment, sendWeekday);\n // Past the send day means a run was missed; catch up rather than skip a week.\n return moment.date > period || moment.hour >= sendHour;\n}\n","import type {\n AfterSendContext,\n EasyPingPlugin,\n PluginInitContext,\n PrepareContext,\n ResolveChannelsContext,\n} from \"../../core/plugin\";\nimport type { Frequency } from \"../../core/types\";\nimport { coreSchema } from \"../../schema/declaration\";\nimport { type DigestWindow, isDue, localMoment, periodKey } from \"./schedule\";\n\nconst ENTRY = \"notification_digest_entry\";\nconst STATE = \"notification_digest_state\";\n\nexport type DigestEntry = {\n id: string;\n userId: string;\n type: string;\n notificationId: string;\n payload: unknown;\n window: DigestWindow;\n createdAt: Date;\n};\n\nexport type DigestsOptions = {\n /** The type the composed digest is sent as; the app defines its template over `{ items }`. */\n digestType?: string;\n /** Local hour at which a digest becomes due. */\n sendHour?: number;\n /** Weekly digests only; 0 = Sunday. */\n sendWeekday?: number;\n /** Channels a digest defers. Only email makes sense today. */\n channel?: \"email\";\n /** Safety valve on one cron run. */\n maxUsersPerRun?: number;\n};\n\ntype Prepared = {\n /** userId -> window, for recipients whose preference defers this type. */\n deferred: Map<string, DigestWindow>;\n};\n\nconst isDigestWindow = (frequency: Frequency): frequency is DigestWindow =>\n frequency === \"daily\" || frequency === \"weekly\";\n\n/** One email containing N items, rather than N deferred emails at 9am. Depends on `preferences` for the frequency. */\nexport function digests(options: DigestsOptions = {}): EasyPingPlugin<\"digests\"> {\n const digestType = options.digestType ?? \"digest\";\n const sendHour = options.sendHour ?? 9;\n const sendWeekday = options.sendWeekday ?? 1;\n const channel = options.channel ?? \"email\";\n const maxUsersPerRun = options.maxUsersPerRun ?? 500;\n\n let ctx: PluginInitContext;\n\n /** Reads the frequency the preferences plugin stored for this type. */\n async function windowFor(\n userIds: readonly string[],\n type: string,\n ): Promise<Map<string, DigestWindow>> {\n const rows = await ctx.store.find<{\n userId: string;\n type: string;\n channel: string;\n frequency: Frequency;\n enabled: boolean;\n }>(\"notification_preference\", { userId: { in: [...userIds] }, type, channel });\n\n const windows = new Map<string, DigestWindow>();\n for (const row of rows) {\n if (row.enabled && isDigestWindow(row.frequency)) windows.set(row.userId, row.frequency);\n }\n return windows;\n }\n\n return {\n id: \"digests\",\n dependsOn: [\"preferences\"],\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n digestEntry: {\n tableName: ENTRY,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n notificationId: { type: \"string\", required: true },\n payload: { type: \"json\" },\n window: { type: \"string\", required: true },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [{ on: [\"userId\", \"window\"], name: \"digest_entry_user_idx\" }],\n },\n\n digestState: {\n tableName: STATE,\n fields: {\n userId: { type: \"string\", required: true },\n window: { type: \"string\", required: true },\n /** Last period actually sent. The idempotency key for the schedule. */\n lastPeriod: { type: \"string\", required: true },\n updatedAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"userId\", \"window\"],\n },\n\n // Read-only here. The full core declaration, not a subset, so migration planners\n // comparing it against the live table do not see updated_at as undeclared.\n notificationPreference: { ...coreSchema.notificationPreference, readOnly: true },\n },\n\n hooks: {\n prepare: async (context: PrepareContext): Promise<Prepared> => {\n if (context.type === digestType) return { deferred: new Map() };\n\n return {\n deferred: await windowFor(\n context.recipients.map((recipient) => recipient.userId),\n context.type,\n ),\n };\n },\n\n resolveChannels: (context: ResolveChannelsContext) => {\n const prepared = context.prepared as Prepared | undefined;\n if (!prepared?.deferred.has(context.recipient.userId)) return context.decisions;\n\n return context.decisions.map((decision) =>\n decision.channel === channel ? { ...decision, action: \"skip\" as const } : decision,\n );\n },\n\n // The only hook that sees notification ids.\n afterSend: async (context: AfterSendContext) => {\n const prepared = context.prepared as Prepared | undefined;\n if (!prepared || prepared.deferred.size === 0) return;\n\n const entries = context.notifications\n .filter((notification) => prepared.deferred.has(notification.userId))\n .map((notification) => ({\n id: crypto.randomUUID(),\n userId: notification.userId,\n type: context.type,\n notificationId: notification.id,\n payload: context.payload,\n window: prepared.deferred.get(notification.userId) as DigestWindow,\n createdAt: new Date(),\n }));\n\n if (entries.length > 0) await ctx.store.insert(ENTRY, entries);\n },\n },\n\n routes: [\n {\n /** Point an hourly cron here: \"9am\" is 24 different UTC moments that shift with DST. */\n path: \"/digests/cron\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => {\n const now = new Date();\n\n const pending = await ctx.store.find<DigestEntry>(ENTRY, {}, { limit: 10_000 });\n if (pending.length === 0) return Response.json({ sent: 0, users: 0 });\n\n const byUser = new Map<string, DigestEntry[]>();\n for (const entry of pending) {\n const bucket = byUser.get(entry.userId);\n if (bucket) bucket.push(entry);\n else byUser.set(entry.userId, [entry]);\n }\n\n const userIds = [...byUser.keys()].slice(0, maxUsersPerRun);\n const recipients = await ctx.getRecipients(userIds);\n const zones = new Map(recipients.map((r) => [r.userId, r.timezone]));\n\n const states = await ctx.store.find<{\n userId: string;\n window: DigestWindow;\n lastPeriod: string;\n }>(STATE, { userId: { in: userIds } });\n\n const lastPeriods = new Map(states.map((s) => [`${s.userId} ${s.window}`, s.lastPeriod]));\n\n let sent = 0;\n\n for (const userId of userIds) {\n const entries = byUser.get(userId) ?? [];\n const moment = localMoment(zones.get(userId) ?? \"UTC\", now);\n\n for (const window of [\"daily\", \"weekly\"] as const) {\n const forWindow = entries.filter((entry) => entry.window === window);\n if (forWindow.length === 0) continue;\n\n const period = periodKey(window, moment, sendWeekday);\n // Idempotent on the period key, so a cron that runs twice cannot send twice.\n if (lastPeriods.get(`${userId} ${window}`) === period) continue;\n if (!isDue(window, moment, sendHour, sendWeekday)) continue;\n\n await ctx.send(digestType, {\n to: userId,\n payload: {\n window,\n period,\n items: forWindow.map((entry) => ({\n type: entry.type,\n payload: entry.payload,\n createdAt: entry.createdAt,\n })),\n },\n dedupeKey: `digest:${window}:${period}:${userId}`,\n });\n\n await ctx.store.upsert(\n STATE,\n [{ userId, window, lastPeriod: period, updatedAt: new Date() }],\n { onConflict: [\"userId\", \"window\"] },\n );\n\n // Only after the send is recorded: a crash re-sends rather than drops.\n await ctx.store.remove(ENTRY, {\n id: { in: forWindow.map((entry) => entry.id) },\n });\n\n sent += 1;\n }\n }\n\n return Response.json({ sent, users: userIds.length });\n },\n },\n ],\n };\n}\n\nexport type { DigestWindow, LocalMoment } from \"./schedule\";\nexport { isDue, localMoment, periodKey } from \"./schedule\";\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/core/handler.ts","../../src/plugins/mobile-push/index.ts"],"names":[],"mappings":"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","file":"mobile-push.cjs","sourcesContent":["import type { DatabaseAdapter } from \"./adapter\";\nimport type { SessionConfig } from \"./config\";\nimport type { Logger } from \"./errors\";\nimport type { Promisable, RouteDefinition } from \"./plugin\";\nimport type { Runner } from \"./runner\";\nimport { inboxChannel, type Signals } from \"./signals\";\nimport { verifyToken } from \"./tokens\";\n\nexport type EventsSettings = {\n heartbeatMs: number;\n probeIntervalMs: number;\n maxDurationMs: number;\n maxStreamsPerUser: number;\n maxStreams: number;\n};\n\n/** Unread chunks a stream may hold before it is closed on a consumer that stopped reading. */\nconst STREAM_HIGH_WATER_MARK = 256;\n\nexport type HandlerDeps = {\n adapter: DatabaseAdapter;\n session: SessionConfig;\n runner: Runner;\n logger: Logger;\n basePath: string;\n secret: string;\n signals: Signals;\n /** `false` unmounts GET /events. */\n events: EventsSettings | false;\n /** Fire-and-forget delivery pass after a request. Rate-limited by the instance. */\n sweepOnRequest?: (() => void) | undefined;\n cronSecret?: string | undefined;\n /** Guards plugin machine routes. The instance defaults it to cronSecret. */\n machineSecret?: string | undefined;\n cronMaxSweeps?: number | undefined;\n pluginRoutes?: readonly RouteDefinition[] | undefined;\n /** Returns a 429 to send, or null to proceed. Runs after onRequest, before routing. */\n rateLimit?: ((request: Request) => Response | null) | undefined;\n /** Origins other than the request's own host that may POST. `*.example.com` allowed. */\n trustedOrigins?: readonly string[] | undefined;\n /** Runs before routing. Returning a Response short-circuits; use it for rate limiting. */\n // biome-ignore lint/suspicious/noConfusingVoidType: a hook that returns nothing is the common case\n onRequest?: ((request: Request) => Promisable<Response | undefined | void>) | undefined;\n maxBodyBytes?: number | undefined;\n};\n\n/** Every JSON body is capped here. Nothing legitimate the routes accept is bigger. */\nexport const MAX_BODY_BYTES = 64 * 1024;\n\nconst MAX_READ_IDS = 200;\n\nconst SESSION_ERROR: unique symbol = Symbol(\"session-error\");\n\n// Per-user data: no shared caching, no sniffing.\nconst PRIVATE_HEADERS: Record<string, string> = {\n \"cache-control\": \"private, no-store\",\n \"x-content-type-options\": \"nosniff\",\n};\n\nconst json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json\", ...PRIVATE_HEADERS, ...headers },\n });\n\nconst empty = (status: number) => new Response(null, { status, headers: PRIVATE_HEADERS });\n\n/** Plugin responses get the same headers, without touching their body. */\nfunction harden(response: Response): Response {\n const headers = new Headers(response.headers);\n for (const [name, value] of Object.entries(PRIVATE_HEADERS)) {\n if (!headers.has(name)) headers.set(name, value);\n }\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n\nconst encoder = new TextEncoder();\n\n/** Constant time over SHA-256 digests, so neither content nor length leaks. Works on edge runtimes. */\nasync function timingSafeEqual(a: string, b: string): Promise<boolean> {\n const [left, right] = await Promise.all(\n [a, b].map(\n async (value) => new Uint8Array(await crypto.subtle.digest(\"SHA-256\", encoder.encode(value))),\n ),\n );\n let diff = 0;\n for (let i = 0; i < 32; i += 1) diff |= (left?.[i] ?? 0) ^ (right?.[i] ?? 0);\n return diff === 0;\n}\n\n/** Anchored at a segment boundary: `/foo/api/notifications/cron` is not a cron request. */\nfunction routePath(url: URL, basePath: string): string {\n const { pathname } = url;\n if (pathname === basePath) return \"/\";\n if (pathname.startsWith(`${basePath}/`)) return pathname.slice(basePath.length);\n return pathname;\n}\n\nexport type JsonBody = { body: Record<string, unknown> } | { error: Response };\n\n/** Reads a JSON object body with a streaming byte cap. Malformed JSON is 400; an empty body is `{}`. */\nexport async function readJsonBody(\n request: Request,\n maxBytes: number = MAX_BODY_BYTES,\n): Promise<JsonBody> {\n const tooLarge = () => ({ error: json({ error: `body exceeds ${maxBytes} bytes` }, 413) });\n\n const declared = Number(request.headers.get(\"content-length\"));\n if (Number.isFinite(declared) && declared > maxBytes) return tooLarge();\n if (!request.body) return { body: {} };\n\n const reader = request.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > maxBytes) {\n await reader.cancel();\n return tooLarge();\n }\n chunks.push(value);\n }\n\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.byteLength;\n }\n\n const text = new TextDecoder().decode(bytes);\n if (text.trim() === \"\") return { body: {} };\n\n try {\n const parsed: unknown = JSON.parse(text);\n return {\n body:\n parsed && typeof parsed === \"object\" && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {},\n };\n } catch {\n return { error: json({ error: \"body must be a JSON object\" }, 400) };\n }\n}\n\nconst isJsonContentType = (request: Request) =>\n /^application\\/json\\s*(;|$)/i.test((request.headers.get(\"content-type\") ?? \"\").trim());\n\nfunction originAllowed(origin: string, url: URL, request: Request, trusted: readonly string[]) {\n let host: string;\n try {\n host = new URL(origin).host;\n } catch {\n return false;\n }\n\n if (host === url.host) return true;\n // Behind a proxy that rewrites Host, the public host arrives here instead.\n const forwarded = request.headers.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim();\n if (forwarded && host === forwarded) return true;\n\n return trusted.some((entry) =>\n entry.startsWith(\"*.\") ? host.endsWith(entry.slice(1)) : entry === origin,\n );\n}\n\n/** CSRF: a cross-site form can neither send `application/json` without a preflight nor forge `Origin`. */\nfunction csrfCheck(request: Request, url: URL, trusted: readonly string[]): Response | null {\n if (!isJsonContentType(request)) {\n return json({ error: \"content-type must be application/json\" }, 415);\n }\n\n const origin = request.headers.get(\"origin\");\n if (origin === null) return null;\n if (origin === \"null\" || !originAllowed(origin, url, request, trusted)) return empty(403);\n\n return null;\n}\n\nexport function createHandler(deps: HandlerDeps) {\n const trustedOrigins = deps.trustedOrigins ?? [];\n const maxBodyBytes = deps.maxBodyBytes ?? MAX_BODY_BYTES;\n\n let warnedBadUserId = false;\n\n async function resolveUserId(request: Request): Promise<string | null | typeof SESSION_ERROR> {\n let id: unknown;\n try {\n id = await deps.session.getUserId(request);\n } catch (error) {\n // A broken session lookup is a 500, never a 401. RFC 0002 §1.\n deps.logger.error(\"session.getUserId threw\", { error });\n return SESSION_ERROR;\n }\n if (id === null || id === undefined) return null;\n if (typeof id !== \"string\" || id === \"\") {\n if (!warnedBadUserId) {\n warnedBadUserId = true;\n deps.logger.warn(\n `session.getUserId returned ${id === \"\" ? \"an empty string\" : typeof id}; treating the ` +\n \"request as unauthenticated. Return a non-empty string or null.\",\n );\n }\n return null;\n }\n return id;\n }\n\n async function bearerMatches(request: Request, secret: string | undefined): Promise<boolean> {\n if (!secret) return false;\n const header = request.headers.get(\"authorization\") ?? \"\";\n const presented = header.startsWith(\"Bearer \") ? header.slice(7) : header;\n return timingSafeEqual(presented, secret);\n }\n\n async function handleCron(request: Request): Promise<Response> {\n if (!deps.cronSecret) return empty(404);\n if (!(await bearerMatches(request, deps.cronSecret))) return empty(401);\n\n // Bounded so an overlapping scheduler does not hold a request open for the whole backlog.\n const result = await deps.runner.drain(\n deps.cronMaxSweeps === undefined ? {} : { maxSweeps: deps.cronMaxSweeps },\n );\n return json(result);\n }\n\n /** Plugin routes first; auth is enforced here so a plugin cannot forget it. */\n async function handlePluginRoute(\n route: RouteDefinition,\n request: Request,\n url: URL,\n ): Promise<Response> {\n if (route.scope.type === \"user\") {\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === SESSION_ERROR) return empty(500);\n if (userId === null) return empty(401);\n return harden(await route.handler({ request, userId, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"machine\") {\n if (!deps.machineSecret) return empty(404);\n if (!(await bearerMatches(request, deps.machineSecret))) return empty(401);\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"signed\") {\n const token =\n url.searchParams.get(\"token\") ??\n (request.headers.get(\"authorization\") ?? \"\").replace(/^Bearer /, \"\");\n\n const claims = await verifyToken(deps.secret, token, route.scope.purpose);\n // One response for every failure mode. RFC 0002 §5.\n if (!claims) return empty(400);\n\n return harden(await route.handler({ request, userId: claims.uid, claims, params: {} }));\n }\n\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n async function route(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const path = routePath(url, deps.basePath);\n\n if (deps.onRequest) {\n const early = await deps.onRequest(request);\n if (early instanceof Response) return early;\n }\n\n if (deps.rateLimit) {\n const limited = deps.rateLimit(request);\n if (limited) return limited;\n }\n\n if (path === \"/cron\") {\n if (request.method !== \"POST\") return empty(405);\n return handleCron(request);\n }\n\n const pluginRoute = deps.pluginRoutes?.find(\n (candidate) => candidate.path === path && candidate.method === request.method,\n );\n if (pluginRoute) return handlePluginRoute(pluginRoute, request, url);\n\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === SESSION_ERROR) return empty(500);\n if (userId === null) return empty(401);\n\n if (request.method === \"GET\" && path === \"/events\") {\n if (!deps.events) return empty(404);\n // A session can open streams for free; without a cap one account can hold every socket.\n if ((openStreams.get(userId) ?? 0) >= deps.events.maxStreamsPerUser) {\n return json({ error: \"too many open event streams\" }, 429, { \"retry-after\": \"30\" });\n }\n if (totalStreams >= deps.events.maxStreams) {\n return json({ error: \"event streams unavailable\" }, 503, { \"retry-after\": \"30\" });\n }\n return events(request, userId, deps.events);\n }\n\n if (request.method === \"GET\" && path === \"/\") {\n const limitParam = Number(url.searchParams.get(\"limit\") ?? 20);\n const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;\n const cursor = url.searchParams.get(\"cursor\");\n\n // unseenCount rides on the first page only: a cursor request is scrollback, not a poll.\n const [page, unseenCount] = await Promise.all([\n deps.adapter.listNotifications({\n userId,\n limit,\n ...(cursor ? { cursor } : {}),\n ...(url.searchParams.get(\"unreadOnly\") === \"true\" ? { unreadOnly: true } : {}),\n }),\n cursor ? Promise.resolve(null) : deps.adapter.countUnseen(userId),\n ]);\n\n return json(unseenCount === null ? page : { ...page, unseenCount });\n }\n\n if (request.method === \"GET\" && path === \"/count\") {\n return json({ unseen: await deps.adapter.countUnseen(userId) });\n }\n\n if (request.method === \"POST\" && path === \"/seen\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const before = typeof parsed.body.before === \"string\" ? new Date(parsed.body.before) : null;\n\n // +1ms: Postgres keeps microseconds, an ISO string milliseconds; without it markSeen misses the newest row.\n const cutoff =\n before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : new Date();\n\n await deps.adapter.markSeen(userId, cutoff);\n deps.signals.publish(inboxChannel(userId));\n return json({ ok: true });\n }\n\n if (request.method === \"POST\" && path === \"/read\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const ids = Array.isArray(parsed.body.ids)\n ? parsed.body.ids.filter((id): id is string => typeof id === \"string\")\n : [];\n if (ids.length === 0) return json({ error: \"ids must be a non-empty string array\" }, 400);\n\n if (ids.length > MAX_READ_IDS) {\n return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);\n }\n\n // 404, not 403: a 403 confirms the row exists. RFC 0002 §2.\n const updated = await deps.adapter.markRead(userId, ids);\n if (updated > 0) deps.signals.publish(inboxChannel(userId));\n return updated === 0 ? empty(404) : json({ updated });\n }\n\n if (request.method === \"POST\" && path === \"/read-all\") {\n const updated = await deps.adapter.markAllRead(userId);\n if (updated > 0) deps.signals.publish(inboxChannel(userId));\n return json({ updated });\n }\n\n return empty(404);\n }\n\n const openStreams = new Map<string, number>();\n let totalStreams = 0;\n\n const trackStream = (userId: string) => {\n openStreams.set(userId, (openStreams.get(userId) ?? 0) + 1);\n totalStreams += 1;\n return () => {\n const remaining = (openStreams.get(userId) ?? 1) - 1;\n if (remaining > 0) openStreams.set(userId, remaining);\n else openStreams.delete(userId);\n totalStreams -= 1;\n };\n };\n\n /** A change fingerprint cheap enough to take every probe interval. */\n async function fingerprint(userId: string): Promise<string> {\n const [page, unseen] = await Promise.all([\n deps.adapter.listNotifications({ userId, limit: 1 }),\n deps.adapter.countUnseen(userId),\n ]);\n const newest = page.notifications[0];\n return `${newest?.id ?? \"\"}|${newest?.readAt ? 1 : 0}|${unseen}`;\n }\n\n // One probe per user, however many streams that user holds: N tabs must not mean N queries.\n type Probe = { listeners: Set<() => void>; timer: ReturnType<typeof setInterval>; last?: string };\n const probes = new Map<string, Probe>();\n\n function subscribeProbe(userId: string, onChanged: () => void, intervalMs: number) {\n let probe = probes.get(userId);\n if (!probe) {\n const created: Probe = {\n listeners: new Set(),\n timer: setInterval(() => {\n fingerprint(userId)\n .then((value) => {\n if (created.last !== undefined && value !== created.last) {\n for (const listener of created.listeners) listener();\n }\n created.last = value;\n })\n .catch((error) => deps.logger.error(\"events probe failed\", { error }));\n }, intervalMs),\n };\n probe = created;\n probes.set(userId, created);\n fingerprint(userId)\n .then((value) => {\n if (created.last === undefined) created.last = value;\n })\n .catch(() => {});\n }\n probe.listeners.add(onChanged);\n return () => {\n const current = probes.get(userId);\n if (!current) return;\n current.listeners.delete(onChanged);\n if (current.listeners.size === 0) {\n clearInterval(current.timer);\n probes.delete(userId);\n }\n };\n }\n\n /**\n * Server-Sent Events: `ready` once, `changed` whenever this user's inbox\n * moves, comments as keepalive. No payload ever travels on the stream; the\n * client fetches the feed on `changed`. RFC 0006 §4D.\n */\n function events(request: Request, userId: string, settings: EventsSettings): Response {\n const encoder = new TextEncoder();\n const timers: ReturnType<typeof setInterval>[] = [];\n const unsubscribes: (() => void)[] = [];\n let closed = false;\n let cleanup = () => {};\n\n const stream = new ReadableStream<Uint8Array>(\n {\n start(controller) {\n const write = (text: string) => {\n if (closed) return;\n // A consumer that stopped reading would make this queue grow without bound.\n if (controller.desiredSize !== null && controller.desiredSize <= 0) {\n cleanup();\n return;\n }\n try {\n controller.enqueue(encoder.encode(text));\n } catch {\n cleanup();\n }\n };\n cleanup = () => {\n if (closed) return;\n closed = true;\n for (const unsubscribe of unsubscribes) unsubscribe();\n for (const timer of timers) clearInterval(timer);\n try {\n controller.close();\n } catch {\n // already closed by the consumer\n }\n };\n const changed = () => write(\"event: changed\\ndata: {}\\n\\n\");\n\n unsubscribes.push(trackStream(userId));\n write(\"retry: 3000\\n\\nevent: ready\\ndata: {}\\n\\n\");\n unsubscribes.push(deps.signals.subscribe(inboxChannel(userId), changed));\n timers.push(setInterval(() => write(\": keepalive\\n\\n\"), settings.heartbeatMs));\n\n if (!deps.signals.crossProcess && settings.probeIntervalMs > 0) {\n unsubscribes.push(subscribeProbe(userId, changed, settings.probeIntervalMs));\n }\n\n if (settings.maxDurationMs > 0) {\n timers.push(\n setTimeout(cleanup, settings.maxDurationMs) as ReturnType<typeof setInterval>,\n );\n }\n request.signal?.addEventListener(\"abort\", cleanup, { once: true });\n },\n cancel() {\n cleanup();\n },\n },\n { highWaterMark: STREAM_HIGH_WATER_MARK },\n );\n\n return new Response(stream, {\n headers: {\n \"content-type\": \"text/event-stream; charset=utf-8\",\n \"cache-control\": \"no-cache, no-store\",\n connection: \"keep-alive\",\n \"x-accel-buffering\": \"no\",\n \"x-content-type-options\": \"nosniff\",\n },\n });\n }\n\n async function handle(request: Request): Promise<Response> {\n let response: Response | undefined;\n try {\n response = await route(request);\n return response;\n } catch (error) {\n // A thrown driver error must not escape: unhandled rejection on Node, stack trace elsewhere.\n deps.logger.error(\"request failed\", { url: request.url, error });\n return empty(500);\n } finally {\n // Only a served request earns a sweep; anonymous 401s must not drive database work.\n if (response && response.status < 400) deps.sweepOnRequest?.();\n }\n }\n\n return {\n handle,\n GET: handle,\n POST: handle,\n };\n}\n","import type { DeliveryOutcome } from \"../../core/adapter\";\nimport { readJsonBody } from \"../../core/handler\";\nimport type {\n DeliverContext,\n EasyPingPlugin,\n PluginInitContext,\n RouteDefinition,\n SchemaDeclaration,\n} from \"../../core/plugin\";\nimport type { MobilePushMessage, MobilePushProvider } from \"../../providers/expo-push\";\n\nconst DEVICE = \"notification_mobile_push_device\";\nconst TICKET = \"notification_mobile_push_ticket\";\n\nexport type MobilePushPlatform = \"ios\" | \"android\" | \"web\";\n\nexport type MobilePushDevice = {\n id: string;\n userId: string;\n token: string;\n platform: MobilePushPlatform;\n deviceName: string | null;\n createdAt: Date;\n lastSeenAt: Date;\n};\n\nexport type MobilePushTicketRow = {\n id: string;\n deviceId: string;\n createdAt: Date;\n};\n\nexport type MobilePushRendered = Omit<MobilePushMessage, \"token\">;\n\nexport type MobilePushOptions = {\n provider: MobilePushProvider;\n /** What the OS notification shows. `data` rides along to the app's tap handler. */\n render: (input: { type: string; payload: unknown }) => MobilePushRendered;\n /** Devices untouched for this long are pruned by POST /mobile-push/prune. Default 180 days. */\n staleAfterDays?: number | undefined;\n /** Registering past this evicts the user's least recently seen device. Default 20. */\n maxDevicesPerUser?: number | undefined;\n};\n\nconst PLATFORMS: readonly MobilePushPlatform[] = [\"ios\", \"android\", \"web\"];\n\nconst MAX_TOKEN_LENGTH = 512;\n\n/** The tables this plugin owns, exported so DDL needs no plugin instance. */\nexport const mobilePushSchema = {\n mobilePushDevice: {\n tableName: DEVICE,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n token: { type: \"string\", required: true },\n platform: { type: \"string\", required: true },\n deviceName: { type: \"string\" },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n lastSeenAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [\n { on: [\"userId\"], name: \"mobile_push_device_user_idx\" },\n // One row per token: re-registering updates instead of duplicating.\n { on: [\"token\"], unique: true, name: \"mobile_push_device_token_idx\" },\n ],\n },\n mobilePushTicket: {\n tableName: TICKET,\n fields: {\n id: { type: \"string\", required: true },\n deviceId: { type: \"string\", required: true },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [{ on: [\"createdAt\"], name: \"mobile_push_ticket_created_idx\" }],\n },\n} as const satisfies SchemaDeclaration;\n\nconst isDuplicate = (error: unknown): boolean => {\n const code = (error as { code?: unknown })?.code;\n if (code === \"23505\" || code === 11000) return true;\n const text = error instanceof Error ? error.message : String(error);\n return /duplicate|unique/i.test(text);\n};\n\nexport type MobilePushPlugin = EasyPingPlugin<\"mobilePush\", typeof mobilePushSchema> & {\n /**\n * Asks the provider for the verdict on accepted messages and prunes devices\n * it reports gone. Expo learns \"device not registered\" from APNs/FCM only\n * after accepting the message, so this is where uninstalls are caught.\n * Also mounted as POST /mobile-push/receipts for the cron.\n */\n checkReceipts(): Promise<{ checked: number; pruned: number }>;\n};\n\n/** Native push through a token registry. Fans out per device; dead tokens are pruned on the spot or by receipt. */\nexport function mobilePush(options: MobilePushOptions): MobilePushPlugin {\n let ctx: PluginInitContext;\n const staleAfterDays = options.staleAfterDays ?? 180;\n const maxDevices = Math.max(1, options.maxDevicesPerUser ?? 20);\n\n async function checkReceipts() {\n if (!options.provider.receipts) return { checked: 0, pruned: 0 };\n const tickets = await ctx.store.find<MobilePushTicketRow>(\n TICKET,\n {},\n { orderBy: { field: \"createdAt\", direction: \"asc\" }, limit: 1000 },\n );\n if (tickets.length === 0) return { checked: 0, pruned: 0 };\n\n const receipts = await options.provider.receipts(tickets.map((ticket) => ticket.id));\n const byTicket = new Map(tickets.map((ticket) => [ticket.id, ticket.deviceId]));\n const gone = receipts.flatMap((receipt) =>\n !receipt.ok && receipt.gone ? [byTicket.get(receipt.ticketId) ?? \"\"] : [],\n );\n const settled = receipts.map((receipt) => receipt.ticketId);\n\n if (gone.length > 0) await ctx.store.remove(DEVICE, { id: { in: gone.filter(Boolean) } });\n if (settled.length > 0) await ctx.store.remove(TICKET, { id: { in: settled } });\n // Receipts the service has already forgotten (older than a day) would sit forever.\n const cutoff = new Date(Date.now() - 24 * 3_600_000);\n await ctx.store.remove(TICKET, { createdAt: { lt: cutoff } });\n\n return { checked: settled.length, pruned: gone.length };\n }\n\n const routes: RouteDefinition[] = [\n {\n path: \"/mobile-push/devices\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n\n const { token, platform, deviceName } = parsed.body as {\n token?: unknown;\n platform?: unknown;\n deviceName?: unknown;\n };\n if (\n typeof token !== \"string\" ||\n token.length > MAX_TOKEN_LENGTH ||\n !options.provider.isValidToken(token)\n ) {\n return Response.json(\n { error: `token is not a valid ${options.provider.name} push token` },\n { status: 400 },\n );\n }\n if (typeof platform !== \"string\" || !PLATFORMS.includes(platform as MobilePushPlatform)) {\n return Response.json(\n { error: `platform must be one of ${PLATFORMS.join(\", \")}` },\n { status: 400 },\n );\n }\n const name = typeof deviceName === \"string\" ? deviceName.slice(0, 120) : null;\n const owner = userId ?? \"\";\n const now = new Date();\n\n // A token belongs to one account for life; upserting on token alone would let anyone re-home it.\n const [existing] = await ctx.store.find<MobilePushDevice>(DEVICE, { token }, { limit: 1 });\n if (existing && existing.userId !== owner) {\n return Response.json(\n { error: \"token is registered to another account\" },\n { status: 409 },\n );\n }\n if (existing) {\n await ctx.store.update(\n DEVICE,\n { id: existing.id },\n { platform, deviceName: name, lastSeenAt: now },\n );\n return Response.json({ ok: true });\n }\n\n const devices = await ctx.store.find<MobilePushDevice>(\n DEVICE,\n { userId: owner },\n { orderBy: { field: \"lastSeenAt\", direction: \"asc\" } },\n );\n const excess = devices.length - maxDevices + 1;\n if (excess > 0) {\n await ctx.store.remove(DEVICE, {\n id: { in: devices.slice(0, excess).map((device) => device.id) },\n });\n }\n try {\n await ctx.store.insert(DEVICE, [\n {\n id: crypto.randomUUID(),\n userId: owner,\n token,\n platform,\n deviceName: name,\n createdAt: now,\n lastSeenAt: now,\n },\n ]);\n } catch (error) {\n if (isDuplicate(error)) {\n return Response.json(\n { error: \"token is registered to another account\" },\n { status: 409 },\n );\n }\n throw error;\n }\n return Response.json({ ok: true });\n },\n },\n {\n path: \"/mobile-push/devices\",\n method: \"GET\",\n scope: { type: \"user\" },\n handler: async ({ userId }) => {\n const devices = await ctx.store.find<MobilePushDevice>(\n DEVICE,\n { userId: userId ?? \"\" },\n { orderBy: { field: \"lastSeenAt\", direction: \"desc\" } },\n );\n return Response.json({\n devices: devices.map((device) => ({\n platform: device.platform,\n deviceName: device.deviceName,\n // The token itself is not returned: it is a capability, not a label.\n tokenSuffix: device.token.slice(-6),\n lastSeenAt: device.lastSeenAt.toISOString(),\n })),\n });\n },\n },\n {\n path: \"/mobile-push/devices/remove\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n const { token } = parsed.body;\n if (typeof token !== \"string\" || token === \"\") {\n return Response.json({ error: \"token is required\" }, { status: 400 });\n }\n const removed = await ctx.store.remove(DEVICE, { userId: userId ?? \"\", token });\n return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });\n },\n },\n {\n path: \"/mobile-push/receipts\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => Response.json(await checkReceipts()),\n },\n {\n path: \"/mobile-push/prune\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => {\n const cutoff = new Date(Date.now() - staleAfterDays * 86_400_000);\n const removed = await ctx.store.remove(DEVICE, { lastSeenAt: { lt: cutoff } });\n return Response.json({ removed });\n },\n },\n ];\n\n return {\n id: \"mobilePush\",\n channels: [\"mobilePush\"],\n schema: mobilePushSchema,\n\n init: (context) => {\n ctx = context;\n },\n\n hooks: {\n deliver: async (context: DeliverContext): Promise<DeliveryOutcome> => {\n const devices = await ctx.store.find<MobilePushDevice>(DEVICE, {\n userId: context.notification.userId,\n });\n if (devices.length === 0) return { result: \"skipped\", reason: \"no registered devices\" };\n\n const rendered = options.render({\n type: context.notification.type,\n payload: context.notification.payload,\n });\n const data = {\n ...rendered.data,\n notificationId: context.notification.id,\n type: context.notification.type,\n };\n\n const tickets = await options.provider.send(\n devices.map((device) => ({ ...rendered, data, token: device.token })),\n context.signal,\n );\n\n const prune: string[] = [];\n const accepted: { id: string; deviceId: string; createdAt: Date }[] = [];\n let delivered = 0;\n let retryable = false;\n let lastError = \"\";\n const now = new Date();\n\n tickets.forEach((ticket, index) => {\n const device = devices[index];\n if (!device) return;\n if (ticket.ok) {\n delivered += 1;\n if (ticket.ticketId)\n accepted.push({ id: ticket.ticketId, deviceId: device.id, createdAt: now });\n return;\n }\n lastError = ticket.error;\n if (ticket.gone) prune.push(device.id);\n else if (ticket.retryable) retryable = true;\n });\n\n if (prune.length > 0) await ctx.store.remove(DEVICE, { id: { in: prune } });\n if (accepted.length > 0 && options.provider.receipts) {\n // Best effort: a failed bookkeeping insert must not fail a delivered push.\n await ctx.store\n .insert(TICKET, accepted)\n .catch((error) =>\n ctx.logger.warn(`mobile push: could not record tickets: ${String(error)}`),\n );\n }\n\n if (delivered > 0) return { result: \"sent\" };\n if (prune.length === devices.length) {\n return { result: \"failed\", error: \"every device token is gone\", retryable: false };\n }\n return { result: \"failed\", error: lastError || \"no device accepted the push\", retryable };\n },\n },\n\n routes,\n checkReceipts,\n };\n}\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/plugins/mobile-push/index.ts"],"names":[],"mappings":"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","file":"mobile-push.js","sourcesContent":["import type { DeliveryOutcome } from \"../../core/adapter\";\nimport { readJsonBody } from \"../../core/handler\";\nimport type {\n DeliverContext,\n EasyPingPlugin,\n PluginInitContext,\n RouteDefinition,\n SchemaDeclaration,\n} from \"../../core/plugin\";\nimport type { MobilePushMessage, MobilePushProvider } from \"../../providers/expo-push\";\n\nconst DEVICE = \"notification_mobile_push_device\";\nconst TICKET = \"notification_mobile_push_ticket\";\n\nexport type MobilePushPlatform = \"ios\" | \"android\" | \"web\";\n\nexport type MobilePushDevice = {\n id: string;\n userId: string;\n token: string;\n platform: MobilePushPlatform;\n deviceName: string | null;\n createdAt: Date;\n lastSeenAt: Date;\n};\n\nexport type MobilePushTicketRow = {\n id: string;\n deviceId: string;\n createdAt: Date;\n};\n\nexport type MobilePushRendered = Omit<MobilePushMessage, \"token\">;\n\nexport type MobilePushOptions = {\n provider: MobilePushProvider;\n /** What the OS notification shows. `data` rides along to the app's tap handler. */\n render: (input: { type: string; payload: unknown }) => MobilePushRendered;\n /** Devices untouched for this long are pruned by POST /mobile-push/prune. Default 180 days. */\n staleAfterDays?: number | undefined;\n /** Registering past this evicts the user's least recently seen device. Default 20. */\n maxDevicesPerUser?: number | undefined;\n};\n\nconst PLATFORMS: readonly MobilePushPlatform[] = [\"ios\", \"android\", \"web\"];\n\nconst MAX_TOKEN_LENGTH = 512;\n\n/** The tables this plugin owns, exported so DDL needs no plugin instance. */\nexport const mobilePushSchema = {\n mobilePushDevice: {\n tableName: DEVICE,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n token: { type: \"string\", required: true },\n platform: { type: \"string\", required: true },\n deviceName: { type: \"string\" },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n lastSeenAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [\n { on: [\"userId\"], name: \"mobile_push_device_user_idx\" },\n // One row per token: re-registering updates instead of duplicating.\n { on: [\"token\"], unique: true, name: \"mobile_push_device_token_idx\" },\n ],\n },\n mobilePushTicket: {\n tableName: TICKET,\n fields: {\n id: { type: \"string\", required: true },\n deviceId: { type: \"string\", required: true },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [{ on: [\"createdAt\"], name: \"mobile_push_ticket_created_idx\" }],\n },\n} as const satisfies SchemaDeclaration;\n\nconst isDuplicate = (error: unknown): boolean => {\n const code = (error as { code?: unknown })?.code;\n if (code === \"23505\" || code === 11000) return true;\n const text = error instanceof Error ? error.message : String(error);\n return /duplicate|unique/i.test(text);\n};\n\nexport type MobilePushPlugin = EasyPingPlugin<\"mobilePush\", typeof mobilePushSchema> & {\n /**\n * Asks the provider for the verdict on accepted messages and prunes devices\n * it reports gone. Expo learns \"device not registered\" from APNs/FCM only\n * after accepting the message, so this is where uninstalls are caught.\n * Also mounted as POST /mobile-push/receipts for the cron.\n */\n checkReceipts(): Promise<{ checked: number; pruned: number }>;\n};\n\n/** Native push through a token registry. Fans out per device; dead tokens are pruned on the spot or by receipt. */\nexport function mobilePush(options: MobilePushOptions): MobilePushPlugin {\n let ctx: PluginInitContext;\n const staleAfterDays = options.staleAfterDays ?? 180;\n const maxDevices = Math.max(1, options.maxDevicesPerUser ?? 20);\n\n async function checkReceipts() {\n if (!options.provider.receipts) return { checked: 0, pruned: 0 };\n const tickets = await ctx.store.find<MobilePushTicketRow>(\n TICKET,\n {},\n { orderBy: { field: \"createdAt\", direction: \"asc\" }, limit: 1000 },\n );\n if (tickets.length === 0) return { checked: 0, pruned: 0 };\n\n const receipts = await options.provider.receipts(tickets.map((ticket) => ticket.id));\n const byTicket = new Map(tickets.map((ticket) => [ticket.id, ticket.deviceId]));\n const gone = receipts.flatMap((receipt) =>\n !receipt.ok && receipt.gone ? [byTicket.get(receipt.ticketId) ?? \"\"] : [],\n );\n const settled = receipts.map((receipt) => receipt.ticketId);\n\n if (gone.length > 0) await ctx.store.remove(DEVICE, { id: { in: gone.filter(Boolean) } });\n if (settled.length > 0) await ctx.store.remove(TICKET, { id: { in: settled } });\n // Receipts the service has already forgotten (older than a day) would sit forever.\n const cutoff = new Date(Date.now() - 24 * 3_600_000);\n await ctx.store.remove(TICKET, { createdAt: { lt: cutoff } });\n\n return { checked: settled.length, pruned: gone.length };\n }\n\n const routes: RouteDefinition[] = [\n {\n path: \"/mobile-push/devices\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n\n const { token, platform, deviceName } = parsed.body as {\n token?: unknown;\n platform?: unknown;\n deviceName?: unknown;\n };\n if (\n typeof token !== \"string\" ||\n token.length > MAX_TOKEN_LENGTH ||\n !options.provider.isValidToken(token)\n ) {\n return Response.json(\n { error: `token is not a valid ${options.provider.name} push token` },\n { status: 400 },\n );\n }\n if (typeof platform !== \"string\" || !PLATFORMS.includes(platform as MobilePushPlatform)) {\n return Response.json(\n { error: `platform must be one of ${PLATFORMS.join(\", \")}` },\n { status: 400 },\n );\n }\n const name = typeof deviceName === \"string\" ? deviceName.slice(0, 120) : null;\n const owner = userId ?? \"\";\n const now = new Date();\n\n // A token belongs to one account for life; upserting on token alone would let anyone re-home it.\n const [existing] = await ctx.store.find<MobilePushDevice>(DEVICE, { token }, { limit: 1 });\n if (existing && existing.userId !== owner) {\n return Response.json(\n { error: \"token is registered to another account\" },\n { status: 409 },\n );\n }\n if (existing) {\n await ctx.store.update(\n DEVICE,\n { id: existing.id },\n { platform, deviceName: name, lastSeenAt: now },\n );\n return Response.json({ ok: true });\n }\n\n const devices = await ctx.store.find<MobilePushDevice>(\n DEVICE,\n { userId: owner },\n { orderBy: { field: \"lastSeenAt\", direction: \"asc\" } },\n );\n const excess = devices.length - maxDevices + 1;\n if (excess > 0) {\n await ctx.store.remove(DEVICE, {\n id: { in: devices.slice(0, excess).map((device) => device.id) },\n });\n }\n try {\n await ctx.store.insert(DEVICE, [\n {\n id: crypto.randomUUID(),\n userId: owner,\n token,\n platform,\n deviceName: name,\n createdAt: now,\n lastSeenAt: now,\n },\n ]);\n } catch (error) {\n if (isDuplicate(error)) {\n return Response.json(\n { error: \"token is registered to another account\" },\n { status: 409 },\n );\n }\n throw error;\n }\n return Response.json({ ok: true });\n },\n },\n {\n path: \"/mobile-push/devices\",\n method: \"GET\",\n scope: { type: \"user\" },\n handler: async ({ userId }) => {\n const devices = await ctx.store.find<MobilePushDevice>(\n DEVICE,\n { userId: userId ?? \"\" },\n { orderBy: { field: \"lastSeenAt\", direction: \"desc\" } },\n );\n return Response.json({\n devices: devices.map((device) => ({\n platform: device.platform,\n deviceName: device.deviceName,\n // The token itself is not returned: it is a capability, not a label.\n tokenSuffix: device.token.slice(-6),\n lastSeenAt: device.lastSeenAt.toISOString(),\n })),\n });\n },\n },\n {\n path: \"/mobile-push/devices/remove\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n const { token } = parsed.body;\n if (typeof token !== \"string\" || token === \"\") {\n return Response.json({ error: \"token is required\" }, { status: 400 });\n }\n const removed = await ctx.store.remove(DEVICE, { userId: userId ?? \"\", token });\n return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });\n },\n },\n {\n path: \"/mobile-push/receipts\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => Response.json(await checkReceipts()),\n },\n {\n path: \"/mobile-push/prune\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => {\n const cutoff = new Date(Date.now() - staleAfterDays * 86_400_000);\n const removed = await ctx.store.remove(DEVICE, { lastSeenAt: { lt: cutoff } });\n return Response.json({ removed });\n },\n },\n ];\n\n return {\n id: \"mobilePush\",\n channels: [\"mobilePush\"],\n schema: mobilePushSchema,\n\n init: (context) => {\n ctx = context;\n },\n\n hooks: {\n deliver: async (context: DeliverContext): Promise<DeliveryOutcome> => {\n const devices = await ctx.store.find<MobilePushDevice>(DEVICE, {\n userId: context.notification.userId,\n });\n if (devices.length === 0) return { result: \"skipped\", reason: \"no registered devices\" };\n\n const rendered = options.render({\n type: context.notification.type,\n payload: context.notification.payload,\n });\n const data = {\n ...rendered.data,\n notificationId: context.notification.id,\n type: context.notification.type,\n };\n\n const tickets = await options.provider.send(\n devices.map((device) => ({ ...rendered, data, token: device.token })),\n context.signal,\n );\n\n const prune: string[] = [];\n const accepted: { id: string; deviceId: string; createdAt: Date }[] = [];\n let delivered = 0;\n let retryable = false;\n let lastError = \"\";\n const now = new Date();\n\n tickets.forEach((ticket, index) => {\n const device = devices[index];\n if (!device) return;\n if (ticket.ok) {\n delivered += 1;\n if (ticket.ticketId)\n accepted.push({ id: ticket.ticketId, deviceId: device.id, createdAt: now });\n return;\n }\n lastError = ticket.error;\n if (ticket.gone) prune.push(device.id);\n else if (ticket.retryable) retryable = true;\n });\n\n if (prune.length > 0) await ctx.store.remove(DEVICE, { id: { in: prune } });\n if (accepted.length > 0 && options.provider.receipts) {\n // Best effort: a failed bookkeeping insert must not fail a delivered push.\n await ctx.store\n .insert(TICKET, accepted)\n .catch((error) =>\n ctx.logger.warn(`mobile push: could not record tickets: ${String(error)}`),\n );\n }\n\n if (delivered > 0) return { result: \"sent\" };\n if (prune.length === devices.length) {\n return { result: \"failed\", error: \"every device token is gone\", retryable: false };\n }\n return { result: \"failed\", error: lastError || \"no device accepted the push\", retryable };\n },\n },\n\n routes,\n checkReceipts,\n };\n}\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/core/plugin.ts","../../src/plugins/preferences/client.ts"],"names":[],"mappings":";;;AA4LO,SAAS,mBACd,MAAA,EAC6B;AAC7B,EAAA,OAAO,MAAA;AACT;;;AC5KO,IAAM,iBAAA,GAAoB,MAC/B,kBAAA,CAAmB;AAAA,EACjB,EAAA,EAAI,aAAA;AAAA,EACJ,oBAAoB,EAAC;AAAA,EAErB,OAAA,EAAS,CAAC,KAAA,MAAW;AAAA,IACnB,eAAA,EAAiB,YAId,MAAM,KAAA,CAAM,cAAc,CAAA;AAAA,IAK7B,gBAAA,EAAkB,OAAO,KAAA,KACtB,MAAM,MAAM,cAAA,EAAgB;AAAA,MAC3B,MAAA,EAAQ,MAAA;AAAA,MACR,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,KAAK;AAAA,KAC3B;AAAA,GACL;AACF,CAAC","file":"preferences-client.cjs","sourcesContent":["import type { DeliveryOutcome } from \"./adapter\";\nimport type { Logger } from \"./errors\";\nimport type { PluginStore } from \"./store\";\nimport type { SignRequest, TokenClaims } from \"./tokens\";\nimport type { Channel, Recipient } from \"./types\";\n\nexport type Promisable<T> = T | Promise<T>;\n\nexport type PrepareContext = {\n type: string;\n recipients: readonly Recipient[];\n};\n\nexport type BeforeSendContext = {\n type: string;\n recipient: Recipient;\n payload: unknown;\n actorId: string | null;\n dedupeKey: string | null;\n /** Whatever this plugin's own prepare() returned for this send. */\n prepared: unknown;\n};\n\nexport type BeforeSendResult =\n | { action: \"continue\"; payload?: unknown; dedupeKey?: string }\n | { action: \"cancel\"; reason: string };\n\nexport type ChannelDecision = {\n channel: Channel;\n action: \"send\" | \"skip\" | \"defer\";\n /** defer only. Written to notification_delivery.not_before. */\n notBefore?: Date | undefined;\n};\n\nexport type ResolveChannelsContext = {\n type: string;\n recipient: Recipient;\n payload: unknown;\n decisions: readonly ChannelDecision[];\n /** Whatever this plugin's own prepare() returned for this send. */\n prepared: unknown;\n};\n\nexport type DeliverContext = {\n channel: Channel;\n deliveryId: string;\n attempt: number;\n recipient: Recipient;\n notification: { id: string; userId: string; type: string; payload: unknown };\n /** Aborted on the runner's timeout. */\n signal: AbortSignal;\n};\n\nexport type AfterSendContext = {\n type: string;\n payload: unknown;\n /** Committed rows. Ids exist only at this point. */\n notifications: readonly { id: string; userId: string }[];\n /** Whatever this plugin's own prepare() returned for this send. */\n prepared: unknown;\n};\n\nexport type AfterDeliverContext = {\n deliveryId: string;\n notificationId: string;\n userId: string;\n channel: Channel;\n outcome: DeliveryOutcome;\n attempt: number;\n isTerminal: boolean;\n};\n\n/** beforeSend and resolveChannels fail closed; afterDeliver fails open. */\nexport type PluginHooks = {\n /** Once per send, for bulk loading — the per-recipient hooks would be an N+1. */\n prepare?: (ctx: PrepareContext) => Promisable<unknown>;\n beforeSend?: (ctx: BeforeSendContext) => Promisable<BeforeSendResult>;\n resolveChannels?: (ctx: ResolveChannelsContext) => Promisable<readonly ChannelDecision[]>;\n /** Once per send, after commit. The only hook that sees notification ids. Fails open. */\n afterSend?: (ctx: AfterSendContext) => Promisable<void>;\n /** Delivers one of the plugin's declared `channels`. Core carries inApp and email. */\n deliver?: (ctx: DeliverContext) => Promisable<DeliveryOutcome>;\n afterDeliver?: (ctx: AfterDeliverContext) => Promisable<void>;\n};\n\nexport type FieldType = \"string\" | \"number\" | \"boolean\" | \"date\" | \"json\";\n\nexport type FieldDeclaration = {\n type: FieldType;\n required?: boolean;\n default?: string | number | boolean;\n /** date fields only. Renders as defaultNow() / CURRENT_TIMESTAMP. */\n defaultNow?: boolean;\n};\n\nexport type IndexDeclaration = {\n on: readonly string[];\n unique?: boolean;\n name?: string;\n};\n\nexport type TableDeclaration = {\n tableName: string;\n fields: Record<string, FieldDeclaration>;\n primaryKey?: readonly string[];\n indexes?: readonly IndexDeclaration[];\n readOnly?: boolean;\n};\n\nexport type SchemaDeclaration = Record<string, TableDeclaration>;\n\nexport type RouteScope =\n | { type: \"user\" }\n | { type: \"machine\" }\n | { type: \"signed\"; purpose: string }\n | { type: \"custom\"; justification: string };\n\nexport type RouteContext = {\n request: Request;\n /** Resolved for user-scoped routes, so a handler cannot forget to scope. */\n userId: string | null;\n /** Verified before dispatch, so a handler cannot forget to check. */\n claims: TokenClaims | null;\n params: Record<string, string>;\n};\n\nexport type RouteDefinition = {\n path: string;\n method: \"GET\" | \"POST\";\n scope: RouteScope;\n handler: (ctx: RouteContext) => Promise<Response>;\n};\n\n/** Handed to a plugin at startup so it need not be given the database twice. */\nexport type PluginInitContext = {\n /** Scoped to the tables this plugin declares in schema(). */\n store: PluginStore;\n /** Mints tokens for this plugin's own `signed` routes only. The secret itself is never handed out. */\n sign: (request: SignRequest) => Promise<string>;\n logger: Logger;\n /** Configured notification names, so a route can refuse to store rows for types that do not exist. */\n notificationTypes: readonly string[];\n /** The app's resolver, for plugins that need timezones or addresses. */\n getRecipients: (userIds: readonly string[]) => Promise<readonly Recipient[]>;\n /** The instance's own send(), so a composed message inherits retry, backoff and idempotency. */\n send: (\n type: string,\n args: { to: string | readonly string[]; payload: unknown; dedupeKey?: string },\n ) => Promise<unknown>;\n};\n\nexport type EasyPingPlugin<\n TId extends string = string,\n TSchema extends SchemaDeclaration = SchemaDeclaration,\n> = {\n id: TId;\n dependsOn?: readonly string[];\n schema?: TSchema;\n routes?: readonly RouteDefinition[];\n hooks?: PluginHooks;\n /** Channels this plugin delivers. Makes them usable in a notification. */\n channels?: readonly Channel[];\n /** Called once by easyPing() before any hook or route can run. */\n init?: (ctx: PluginInitContext) => void;\n};\n\nexport type AnyPlugin = EasyPingPlugin<string, SchemaDeclaration>;\n\nexport function definePlugin<TId extends string, TSchema extends SchemaDeclaration>(\n plugin: EasyPingPlugin<TId, TSchema>,\n): EasyPingPlugin<TId, TSchema> {\n return plugin;\n}\n\nexport type ClientFetch = (path: string, init?: RequestInit) => Promise<unknown>;\n\nexport type ClientPlugin<\n TId extends string = string,\n TActions extends Record<string, unknown> = Record<string, unknown>,\n> = {\n id: TId;\n /** Phantom. Links client types to the server plugin so a mismatch is a type error. */\n $InferServerPlugin?: AnyPlugin;\n actions: (fetch: ClientFetch) => TActions;\n};\n\nexport type AnyClientPlugin = ClientPlugin<string, Record<string, unknown>>;\n\nexport function defineClientPlugin<TId extends string, TActions extends Record<string, unknown>>(\n plugin: ClientPlugin<TId, TActions>,\n): ClientPlugin<TId, TActions> {\n return plugin;\n}\n\nexport type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (\n k: infer I,\n) => void\n ? I\n : never;\n\n/** Naked type parameter, so the conditional distributes over the union. */\ntype ExtractActions<TPlugin> = TPlugin extends { actions: (fetch: never) => infer TActions }\n ? TActions\n : never;\n\n/** Indexes the tuple rather than recursing: a fold blows the instantiation depth limit. */\nexport type ActionsOf<TPlugins extends readonly AnyClientPlugin[]> = UnionToIntersection<\n ExtractActions<TPlugins[number]>\n>;\n","import { defineClientPlugin } from \"../../core/plugin\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { preferences } from \"./index\";\n\nexport type PreferenceView = {\n userId: string;\n type: string;\n channel: Channel;\n enabled: boolean;\n frequency: Frequency;\n};\n\nexport type UpdatePreferenceInput = {\n type: string;\n channel: Channel;\n enabled?: boolean;\n frequency?: Frequency;\n};\n\n/** Paired by phantom type: importing the server plugin's value would bundle the DB. */\nexport const preferencesClient = () =>\n defineClientPlugin({\n id: \"preferences\",\n $InferServerPlugin: {} as ReturnType<typeof preferences>,\n\n actions: (fetch) => ({\n listPreferences: async (): Promise<{\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n }> =>\n (await fetch(\"/preferences\")) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n },\n\n updatePreference: async (input: UpdatePreferenceInput): Promise<{ ok: boolean }> =>\n (await fetch(\"/preferences\", {\n method: \"POST\",\n body: JSON.stringify(input),\n })) as { ok: boolean },\n }),\n });\n\nexport type PreferencesClient = ReturnType<typeof preferencesClient>;\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/plugins/preferences/client.ts"],"names":[],"mappings":";;;AAoBO,IAAM,iBAAA,GAAoB,MAC/B,kBAAA,CAAmB;AAAA,EACjB,EAAA,EAAI,aAAA;AAAA,EACJ,oBAAoB,EAAC;AAAA,EAErB,OAAA,EAAS,CAAC,KAAA,MAAW;AAAA,IACnB,eAAA,EAAiB,YAId,MAAM,KAAA,CAAM,cAAc,CAAA;AAAA,IAK7B,gBAAA,EAAkB,OAAO,KAAA,KACtB,MAAM,MAAM,cAAA,EAAgB;AAAA,MAC3B,MAAA,EAAQ,MAAA;AAAA,MACR,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,KAAK;AAAA,KAC3B;AAAA,GACL;AACF,CAAC","file":"preferences-client.js","sourcesContent":["import { defineClientPlugin } from \"../../core/plugin\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { preferences } from \"./index\";\n\nexport type PreferenceView = {\n userId: string;\n type: string;\n channel: Channel;\n enabled: boolean;\n frequency: Frequency;\n};\n\nexport type UpdatePreferenceInput = {\n type: string;\n channel: Channel;\n enabled?: boolean;\n frequency?: Frequency;\n};\n\n/** Paired by phantom type: importing the server plugin's value would bundle the DB. */\nexport const preferencesClient = () =>\n defineClientPlugin({\n id: \"preferences\",\n $InferServerPlugin: {} as ReturnType<typeof preferences>,\n\n actions: (fetch) => ({\n listPreferences: async (): Promise<{\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n }> =>\n (await fetch(\"/preferences\")) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n },\n\n updatePreference: async (input: UpdatePreferenceInput): Promise<{ ok: boolean }> =>\n (await fetch(\"/preferences\", {\n method: \"POST\",\n body: JSON.stringify(input),\n })) as { ok: boolean },\n }),\n });\n\nexport type PreferencesClient = ReturnType<typeof preferencesClient>;\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/plugins/preferences/react.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n\nexport type UsePreferencesOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n};\n\nexport type PreferenceKey = { type: string; channel: Channel };\n\nexport type UsePreferencesResult = {\n preferences: readonly PreferenceView[];\n defaultEnabled: boolean;\n isLoading: boolean;\n error: Error | null;\n /** Resolved value for a cell, falling back to the configured default. */\n isEnabled: (key: PreferenceKey) => boolean;\n setEnabled: (key: PreferenceKey, enabled: boolean) => Promise<void>;\n setFrequency: (key: PreferenceKey, frequency: Frequency) => Promise<void>;\n refresh: () => Promise<void>;\n};\n\nconst matches = (row: PreferenceView, key: PreferenceKey) =>\n row.type === key.type && row.channel === key.channel;\n\n/** Headless: state and persistence only, no markup. */\nexport function usePreferences(options: UsePreferencesOptions = {}): UsePreferencesResult {\n const { baseUrl = \"/api/notifications\", fetch: providedFetch } = options;\n\n const doFetch = useMemo(\n () =>\n providedFetch ??\n ((...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args)),\n [providedFetch],\n );\n\n const [preferences, setPreferences] = useState<readonly PreferenceView[]>([]);\n const [defaultEnabled, setDefaultEnabled] = useState(true);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n\n const refresh = useCallback(async () => {\n try {\n const response = await doFetch(`${baseUrl}/preferences`);\n if (!response.ok) throw new Error(`GET /preferences failed: ${response.status}`);\n\n const body = (await response.json()) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n };\n\n setPreferences(body.preferences);\n setDefaultEnabled(body.defaultEnabled);\n setError(null);\n } catch (caught) {\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n } finally {\n setIsLoading(false);\n }\n }, [baseUrl, doFetch]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n const persist = useCallback(\n async (input: UpdatePreferenceInput, optimistic: Partial<PreferenceView>) => {\n const previous = preferences;\n\n // Optimistic: apply now, revert on failure.\n setPreferences((rows) => {\n const existing = rows.find((row) => matches(row, input));\n if (existing) {\n return rows.map((row) => (matches(row, input) ? { ...row, ...optimistic } : row));\n }\n return [\n ...rows,\n {\n userId: \"\",\n type: input.type,\n channel: input.channel,\n enabled: true,\n frequency: \"instant\" as Frequency,\n ...optimistic,\n },\n ];\n });\n\n try {\n const response = await doFetch(`${baseUrl}/preferences`, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(input),\n });\n if (!response.ok) throw new Error(`POST /preferences failed: ${response.status}`);\n } catch (caught) {\n setPreferences(previous);\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n throw caught;\n }\n },\n [baseUrl, doFetch, preferences],\n );\n\n return {\n preferences,\n defaultEnabled,\n isLoading,\n error,\n\n isEnabled: useCallback(\n (key: PreferenceKey) => {\n const row = preferences.find((candidate) => matches(candidate, key));\n return row ? row.enabled && row.frequency !== \"off\" : defaultEnabled;\n },\n [preferences, defaultEnabled],\n ),\n\n setEnabled: useCallback(\n (key: PreferenceKey, enabled: boolean) =>\n persist(\n { ...key, enabled, frequency: enabled ? \"instant\" : \"off\" },\n { enabled, frequency: enabled ? \"instant\" : \"off\" },\n ),\n [persist],\n ),\n\n setFrequency: useCallback(\n (key: PreferenceKey, frequency: Frequency) =>\n persist(\n { ...key, enabled: frequency !== \"off\", frequency },\n { enabled: frequency !== \"off\", frequency },\n ),\n [persist],\n ),\n\n refresh,\n };\n}\n\nexport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/plugins/preferences/react.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport type { Channel, Frequency } from \"../../core/types\";\nimport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n\nexport type UsePreferencesOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n};\n\nexport type PreferenceKey = { type: string; channel: Channel };\n\nexport type UsePreferencesResult = {\n preferences: readonly PreferenceView[];\n defaultEnabled: boolean;\n isLoading: boolean;\n error: Error | null;\n /** Resolved value for a cell, falling back to the configured default. */\n isEnabled: (key: PreferenceKey) => boolean;\n setEnabled: (key: PreferenceKey, enabled: boolean) => Promise<void>;\n setFrequency: (key: PreferenceKey, frequency: Frequency) => Promise<void>;\n refresh: () => Promise<void>;\n};\n\nconst matches = (row: PreferenceView, key: PreferenceKey) =>\n row.type === key.type && row.channel === key.channel;\n\n/** Headless: state and persistence only, no markup. */\nexport function usePreferences(options: UsePreferencesOptions = {}): UsePreferencesResult {\n const { baseUrl = \"/api/notifications\", fetch: providedFetch } = options;\n\n const doFetch = useMemo(\n () =>\n providedFetch ??\n ((...args: Parameters<typeof globalThis.fetch>) => globalThis.fetch(...args)),\n [providedFetch],\n );\n\n const [preferences, setPreferences] = useState<readonly PreferenceView[]>([]);\n const [defaultEnabled, setDefaultEnabled] = useState(true);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n\n const refresh = useCallback(async () => {\n try {\n const response = await doFetch(`${baseUrl}/preferences`);\n if (!response.ok) throw new Error(`GET /preferences failed: ${response.status}`);\n\n const body = (await response.json()) as {\n preferences: PreferenceView[];\n defaultEnabled: boolean;\n };\n\n setPreferences(body.preferences);\n setDefaultEnabled(body.defaultEnabled);\n setError(null);\n } catch (caught) {\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n } finally {\n setIsLoading(false);\n }\n }, [baseUrl, doFetch]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n const persist = useCallback(\n async (input: UpdatePreferenceInput, optimistic: Partial<PreferenceView>) => {\n const previous = preferences;\n\n // Optimistic: apply now, revert on failure.\n setPreferences((rows) => {\n const existing = rows.find((row) => matches(row, input));\n if (existing) {\n return rows.map((row) => (matches(row, input) ? { ...row, ...optimistic } : row));\n }\n return [\n ...rows,\n {\n userId: \"\",\n type: input.type,\n channel: input.channel,\n enabled: true,\n frequency: \"instant\" as Frequency,\n ...optimistic,\n },\n ];\n });\n\n try {\n const response = await doFetch(`${baseUrl}/preferences`, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(input),\n });\n if (!response.ok) throw new Error(`POST /preferences failed: ${response.status}`);\n } catch (caught) {\n setPreferences(previous);\n setError(caught instanceof Error ? caught : new Error(String(caught)));\n throw caught;\n }\n },\n [baseUrl, doFetch, preferences],\n );\n\n return {\n preferences,\n defaultEnabled,\n isLoading,\n error,\n\n isEnabled: useCallback(\n (key: PreferenceKey) => {\n const row = preferences.find((candidate) => matches(candidate, key));\n return row ? row.enabled && row.frequency !== \"off\" : defaultEnabled;\n },\n [preferences, defaultEnabled],\n ),\n\n setEnabled: useCallback(\n (key: PreferenceKey, enabled: boolean) =>\n persist(\n { ...key, enabled, frequency: enabled ? \"instant\" : \"off\" },\n { enabled, frequency: enabled ? \"instant\" : \"off\" },\n ),\n [persist],\n ),\n\n setFrequency: useCallback(\n (key: PreferenceKey, frequency: Frequency) =>\n persist(\n { ...key, enabled: frequency !== \"off\", frequency },\n { enabled: frequency !== \"off\", frequency },\n ),\n [persist],\n ),\n\n refresh,\n };\n}\n\nexport type { PreferenceView, UpdatePreferenceInput } from \"./client\";\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/core/base64url.ts","../../src/core/tokens.ts","../../src/core/handler.ts","../../src/plugins/preferences/index.ts"],"names":[],"mappings":"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","file":"preferences.cjs","sourcesContent":["// Web APIs, not Buffer: this runs on Workers and Vercel Edge.\n\nexport function encodeBase64Url(input: string | Uint8Array): string {\n const bytes = typeof input === \"string\" ? new TextEncoder().encode(input) : input;\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n// Uint8Array<ArrayBuffer>: bare Uint8Array widens and is not a BufferSource.\nexport function decodeBase64UrlBytes(value: string): Uint8Array<ArrayBuffer> | null {\n try {\n const binary = atob(value.replace(/-/g, \"+\").replace(/_/g, \"/\"));\n return Uint8Array.from(binary, (char) => char.charCodeAt(0));\n } catch {\n return null;\n }\n}\n\nexport function decodeBase64Url(value: string): string | null {\n const bytes = decodeBase64UrlBytes(value);\n return bytes ? new TextDecoder().decode(bytes) : null;\n}\n","import { decodeBase64Url, decodeBase64UrlBytes, encodeBase64Url } from \"./base64url\";\n\nexport type TokenClaims = {\n /** Subject — the user the token acts for. */\n uid: string;\n /** What the token is allowed to do. Verification requires an exact match. */\n purpose: string;\n /** Expiry, epoch seconds. */\n exp: number;\n /** Issued at, epoch seconds. Set by signToken; lets a route refuse a token older than a later change. */\n iat?: number;\n /** Free-form, purpose-specific. */\n data?: Record<string, string>;\n};\n\nconst encoder = new TextEncoder();\n\n// Changing this invalidates every outstanding token. Bump it on purpose only.\nconst KEY_SALT = encoder.encode(\"easy-ping/tokens/v1\");\n\n/** One HMAC key per purpose, derived with HKDF, so an unsubscribe key signs nothing else. */\nasync function hmacKey(secret: string, purpose: string): Promise<CryptoKey> {\n const master = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), \"HKDF\", false, [\n \"deriveKey\",\n ]);\n return crypto.subtle.deriveKey(\n { name: \"HKDF\", hash: \"SHA-256\", salt: KEY_SALT, info: encoder.encode(purpose) },\n master,\n { name: \"HMAC\", hash: \"SHA-256\", length: 256 },\n false,\n [\"sign\", \"verify\"],\n );\n}\n\nexport async function signToken(\n secret: string,\n claims: TokenClaims,\n now: Date = new Date(),\n): Promise<string> {\n const stamped: TokenClaims = { ...claims, iat: claims.iat ?? Math.floor(now.getTime() / 1000) };\n const body = encodeBase64Url(JSON.stringify(stamped));\n const signature = await crypto.subtle.sign(\n \"HMAC\",\n await hmacKey(secret, claims.purpose),\n encoder.encode(body),\n );\n return `${body}.${encodeBase64Url(new Uint8Array(signature))}`;\n}\n\n/** null for every failure mode. Distinguishing them tells an attacker which part to fix. */\nexport async function verifyToken(\n secret: string,\n token: string,\n purpose: string,\n now: Date = new Date(),\n): Promise<TokenClaims | null> {\n const separator = token.lastIndexOf(\".\");\n if (separator <= 0) return null;\n\n const body = token.slice(0, separator);\n const signature = decodeBase64UrlBytes(token.slice(separator + 1));\n if (!signature) return null;\n\n // Verified under the expected purpose's key before the body is parsed. subtle.verify is constant-time.\n const valid = await crypto.subtle.verify(\n \"HMAC\",\n await hmacKey(secret, purpose),\n signature,\n encoder.encode(body),\n );\n if (!valid) return null;\n\n const decoded = decodeBase64Url(body);\n if (!decoded) return null;\n\n let claims: TokenClaims;\n try {\n claims = JSON.parse(decoded) as TokenClaims;\n } catch {\n return null;\n }\n\n if (typeof claims?.uid !== \"string\" || typeof claims.exp !== \"number\") return null;\n if (claims.purpose !== purpose) return null;\n if (claims.exp * 1000 <= now.getTime()) return null;\n if (claims.iat !== undefined && typeof claims.iat !== \"number\") return null;\n\n if (claims.data !== undefined) {\n if (typeof claims.data !== \"object\" || claims.data === null || Array.isArray(claims.data)) {\n return null;\n }\n for (const value of Object.values(claims.data)) {\n if (typeof value !== \"string\") return null;\n }\n }\n\n return claims;\n}\n\nexport const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;\n/** Ninety days. A link that must outlive this should be re-issued, not made immortal. */\nexport const MAX_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;\n\nexport const expiresIn = (seconds: number, now: Date = new Date()): number =>\n Math.floor(now.getTime() / 1000) + Math.min(Math.max(seconds, 0), MAX_TOKEN_TTL_SECONDS);\n\nexport type SignRequest = {\n uid: string;\n purpose: string;\n data?: Record<string, string>;\n /** Defaults to 30 days, capped at 90. */\n ttlSeconds?: number;\n};\n\n/** What a plugin gets instead of the secret: signs only its own declared purposes. */\nexport function createScopedSigner(\n secret: string,\n pluginId: string,\n purposes: ReadonlySet<string>,\n onViolation: (message: string) => Error,\n): (request: SignRequest) => Promise<string> {\n return (request) => {\n if (!purposes.has(request.purpose)) {\n throw onViolation(\n `plugin \"${pluginId}\" tried to sign a token for purpose \"${request.purpose}\", ` +\n \"which none of its signed routes declare.\",\n );\n }\n return signToken(secret, {\n uid: request.uid,\n purpose: request.purpose,\n exp: expiresIn(request.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS),\n ...(request.data ? { data: request.data } : {}),\n });\n };\n}\n","import type { DatabaseAdapter } from \"./adapter\";\nimport type { SessionConfig } from \"./config\";\nimport type { Logger } from \"./errors\";\nimport type { Promisable, RouteDefinition } from \"./plugin\";\nimport type { Runner } from \"./runner\";\nimport { inboxChannel, type Signals } from \"./signals\";\nimport { verifyToken } from \"./tokens\";\n\nexport type EventsSettings = {\n heartbeatMs: number;\n probeIntervalMs: number;\n maxDurationMs: number;\n maxStreamsPerUser: number;\n maxStreams: number;\n};\n\n/** Unread chunks a stream may hold before it is closed on a consumer that stopped reading. */\nconst STREAM_HIGH_WATER_MARK = 256;\n\nexport type HandlerDeps = {\n adapter: DatabaseAdapter;\n session: SessionConfig;\n runner: Runner;\n logger: Logger;\n basePath: string;\n secret: string;\n signals: Signals;\n /** `false` unmounts GET /events. */\n events: EventsSettings | false;\n /** Fire-and-forget delivery pass after a request. Rate-limited by the instance. */\n sweepOnRequest?: (() => void) | undefined;\n cronSecret?: string | undefined;\n /** Guards plugin machine routes. The instance defaults it to cronSecret. */\n machineSecret?: string | undefined;\n cronMaxSweeps?: number | undefined;\n pluginRoutes?: readonly RouteDefinition[] | undefined;\n /** Returns a 429 to send, or null to proceed. Runs after onRequest, before routing. */\n rateLimit?: ((request: Request) => Response | null) | undefined;\n /** Origins other than the request's own host that may POST. `*.example.com` allowed. */\n trustedOrigins?: readonly string[] | undefined;\n /** Runs before routing. Returning a Response short-circuits; use it for rate limiting. */\n // biome-ignore lint/suspicious/noConfusingVoidType: a hook that returns nothing is the common case\n onRequest?: ((request: Request) => Promisable<Response | undefined | void>) | undefined;\n maxBodyBytes?: number | undefined;\n};\n\n/** Every JSON body is capped here. Nothing legitimate the routes accept is bigger. */\nexport const MAX_BODY_BYTES = 64 * 1024;\n\nconst MAX_READ_IDS = 200;\n\nconst SESSION_ERROR: unique symbol = Symbol(\"session-error\");\n\n// Per-user data: no shared caching, no sniffing.\nconst PRIVATE_HEADERS: Record<string, string> = {\n \"cache-control\": \"private, no-store\",\n \"x-content-type-options\": \"nosniff\",\n};\n\nconst json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json\", ...PRIVATE_HEADERS, ...headers },\n });\n\nconst empty = (status: number) => new Response(null, { status, headers: PRIVATE_HEADERS });\n\n/** Plugin responses get the same headers, without touching their body. */\nfunction harden(response: Response): Response {\n const headers = new Headers(response.headers);\n for (const [name, value] of Object.entries(PRIVATE_HEADERS)) {\n if (!headers.has(name)) headers.set(name, value);\n }\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n\nconst encoder = new TextEncoder();\n\n/** Constant time over SHA-256 digests, so neither content nor length leaks. Works on edge runtimes. */\nasync function timingSafeEqual(a: string, b: string): Promise<boolean> {\n const [left, right] = await Promise.all(\n [a, b].map(\n async (value) => new Uint8Array(await crypto.subtle.digest(\"SHA-256\", encoder.encode(value))),\n ),\n );\n let diff = 0;\n for (let i = 0; i < 32; i += 1) diff |= (left?.[i] ?? 0) ^ (right?.[i] ?? 0);\n return diff === 0;\n}\n\n/** Anchored at a segment boundary: `/foo/api/notifications/cron` is not a cron request. */\nfunction routePath(url: URL, basePath: string): string {\n const { pathname } = url;\n if (pathname === basePath) return \"/\";\n if (pathname.startsWith(`${basePath}/`)) return pathname.slice(basePath.length);\n return pathname;\n}\n\nexport type JsonBody = { body: Record<string, unknown> } | { error: Response };\n\n/** Reads a JSON object body with a streaming byte cap. Malformed JSON is 400; an empty body is `{}`. */\nexport async function readJsonBody(\n request: Request,\n maxBytes: number = MAX_BODY_BYTES,\n): Promise<JsonBody> {\n const tooLarge = () => ({ error: json({ error: `body exceeds ${maxBytes} bytes` }, 413) });\n\n const declared = Number(request.headers.get(\"content-length\"));\n if (Number.isFinite(declared) && declared > maxBytes) return tooLarge();\n if (!request.body) return { body: {} };\n\n const reader = request.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > maxBytes) {\n await reader.cancel();\n return tooLarge();\n }\n chunks.push(value);\n }\n\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.byteLength;\n }\n\n const text = new TextDecoder().decode(bytes);\n if (text.trim() === \"\") return { body: {} };\n\n try {\n const parsed: unknown = JSON.parse(text);\n return {\n body:\n parsed && typeof parsed === \"object\" && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {},\n };\n } catch {\n return { error: json({ error: \"body must be a JSON object\" }, 400) };\n }\n}\n\nconst isJsonContentType = (request: Request) =>\n /^application\\/json\\s*(;|$)/i.test((request.headers.get(\"content-type\") ?? \"\").trim());\n\nfunction originAllowed(origin: string, url: URL, request: Request, trusted: readonly string[]) {\n let host: string;\n try {\n host = new URL(origin).host;\n } catch {\n return false;\n }\n\n if (host === url.host) return true;\n // Behind a proxy that rewrites Host, the public host arrives here instead.\n const forwarded = request.headers.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim();\n if (forwarded && host === forwarded) return true;\n\n return trusted.some((entry) =>\n entry.startsWith(\"*.\") ? host.endsWith(entry.slice(1)) : entry === origin,\n );\n}\n\n/** CSRF: a cross-site form can neither send `application/json` without a preflight nor forge `Origin`. */\nfunction csrfCheck(request: Request, url: URL, trusted: readonly string[]): Response | null {\n if (!isJsonContentType(request)) {\n return json({ error: \"content-type must be application/json\" }, 415);\n }\n\n const origin = request.headers.get(\"origin\");\n if (origin === null) return null;\n if (origin === \"null\" || !originAllowed(origin, url, request, trusted)) return empty(403);\n\n return null;\n}\n\nexport function createHandler(deps: HandlerDeps) {\n const trustedOrigins = deps.trustedOrigins ?? [];\n const maxBodyBytes = deps.maxBodyBytes ?? MAX_BODY_BYTES;\n\n let warnedBadUserId = false;\n\n async function resolveUserId(request: Request): Promise<string | null | typeof SESSION_ERROR> {\n let id: unknown;\n try {\n id = await deps.session.getUserId(request);\n } catch (error) {\n // A broken session lookup is a 500, never a 401. RFC 0002 §1.\n deps.logger.error(\"session.getUserId threw\", { error });\n return SESSION_ERROR;\n }\n if (id === null || id === undefined) return null;\n if (typeof id !== \"string\" || id === \"\") {\n if (!warnedBadUserId) {\n warnedBadUserId = true;\n deps.logger.warn(\n `session.getUserId returned ${id === \"\" ? \"an empty string\" : typeof id}; treating the ` +\n \"request as unauthenticated. Return a non-empty string or null.\",\n );\n }\n return null;\n }\n return id;\n }\n\n async function bearerMatches(request: Request, secret: string | undefined): Promise<boolean> {\n if (!secret) return false;\n const header = request.headers.get(\"authorization\") ?? \"\";\n const presented = header.startsWith(\"Bearer \") ? header.slice(7) : header;\n return timingSafeEqual(presented, secret);\n }\n\n async function handleCron(request: Request): Promise<Response> {\n if (!deps.cronSecret) return empty(404);\n if (!(await bearerMatches(request, deps.cronSecret))) return empty(401);\n\n // Bounded so an overlapping scheduler does not hold a request open for the whole backlog.\n const result = await deps.runner.drain(\n deps.cronMaxSweeps === undefined ? {} : { maxSweeps: deps.cronMaxSweeps },\n );\n return json(result);\n }\n\n /** Plugin routes first; auth is enforced here so a plugin cannot forget it. */\n async function handlePluginRoute(\n route: RouteDefinition,\n request: Request,\n url: URL,\n ): Promise<Response> {\n if (route.scope.type === \"user\") {\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === SESSION_ERROR) return empty(500);\n if (userId === null) return empty(401);\n return harden(await route.handler({ request, userId, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"machine\") {\n if (!deps.machineSecret) return empty(404);\n if (!(await bearerMatches(request, deps.machineSecret))) return empty(401);\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"signed\") {\n const token =\n url.searchParams.get(\"token\") ??\n (request.headers.get(\"authorization\") ?? \"\").replace(/^Bearer /, \"\");\n\n const claims = await verifyToken(deps.secret, token, route.scope.purpose);\n // One response for every failure mode. RFC 0002 §5.\n if (!claims) return empty(400);\n\n return harden(await route.handler({ request, userId: claims.uid, claims, params: {} }));\n }\n\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n async function route(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const path = routePath(url, deps.basePath);\n\n if (deps.onRequest) {\n const early = await deps.onRequest(request);\n if (early instanceof Response) return early;\n }\n\n if (deps.rateLimit) {\n const limited = deps.rateLimit(request);\n if (limited) return limited;\n }\n\n if (path === \"/cron\") {\n if (request.method !== \"POST\") return empty(405);\n return handleCron(request);\n }\n\n const pluginRoute = deps.pluginRoutes?.find(\n (candidate) => candidate.path === path && candidate.method === request.method,\n );\n if (pluginRoute) return handlePluginRoute(pluginRoute, request, url);\n\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === SESSION_ERROR) return empty(500);\n if (userId === null) return empty(401);\n\n if (request.method === \"GET\" && path === \"/events\") {\n if (!deps.events) return empty(404);\n // A session can open streams for free; without a cap one account can hold every socket.\n if ((openStreams.get(userId) ?? 0) >= deps.events.maxStreamsPerUser) {\n return json({ error: \"too many open event streams\" }, 429, { \"retry-after\": \"30\" });\n }\n if (totalStreams >= deps.events.maxStreams) {\n return json({ error: \"event streams unavailable\" }, 503, { \"retry-after\": \"30\" });\n }\n return events(request, userId, deps.events);\n }\n\n if (request.method === \"GET\" && path === \"/\") {\n const limitParam = Number(url.searchParams.get(\"limit\") ?? 20);\n const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;\n const cursor = url.searchParams.get(\"cursor\");\n\n // unseenCount rides on the first page only: a cursor request is scrollback, not a poll.\n const [page, unseenCount] = await Promise.all([\n deps.adapter.listNotifications({\n userId,\n limit,\n ...(cursor ? { cursor } : {}),\n ...(url.searchParams.get(\"unreadOnly\") === \"true\" ? { unreadOnly: true } : {}),\n }),\n cursor ? Promise.resolve(null) : deps.adapter.countUnseen(userId),\n ]);\n\n return json(unseenCount === null ? page : { ...page, unseenCount });\n }\n\n if (request.method === \"GET\" && path === \"/count\") {\n return json({ unseen: await deps.adapter.countUnseen(userId) });\n }\n\n if (request.method === \"POST\" && path === \"/seen\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const before = typeof parsed.body.before === \"string\" ? new Date(parsed.body.before) : null;\n\n // +1ms: Postgres keeps microseconds, an ISO string milliseconds; without it markSeen misses the newest row.\n const cutoff =\n before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : new Date();\n\n await deps.adapter.markSeen(userId, cutoff);\n deps.signals.publish(inboxChannel(userId));\n return json({ ok: true });\n }\n\n if (request.method === \"POST\" && path === \"/read\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const ids = Array.isArray(parsed.body.ids)\n ? parsed.body.ids.filter((id): id is string => typeof id === \"string\")\n : [];\n if (ids.length === 0) return json({ error: \"ids must be a non-empty string array\" }, 400);\n\n if (ids.length > MAX_READ_IDS) {\n return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);\n }\n\n // 404, not 403: a 403 confirms the row exists. RFC 0002 §2.\n const updated = await deps.adapter.markRead(userId, ids);\n if (updated > 0) deps.signals.publish(inboxChannel(userId));\n return updated === 0 ? empty(404) : json({ updated });\n }\n\n if (request.method === \"POST\" && path === \"/read-all\") {\n const updated = await deps.adapter.markAllRead(userId);\n if (updated > 0) deps.signals.publish(inboxChannel(userId));\n return json({ updated });\n }\n\n return empty(404);\n }\n\n const openStreams = new Map<string, number>();\n let totalStreams = 0;\n\n const trackStream = (userId: string) => {\n openStreams.set(userId, (openStreams.get(userId) ?? 0) + 1);\n totalStreams += 1;\n return () => {\n const remaining = (openStreams.get(userId) ?? 1) - 1;\n if (remaining > 0) openStreams.set(userId, remaining);\n else openStreams.delete(userId);\n totalStreams -= 1;\n };\n };\n\n /** A change fingerprint cheap enough to take every probe interval. */\n async function fingerprint(userId: string): Promise<string> {\n const [page, unseen] = await Promise.all([\n deps.adapter.listNotifications({ userId, limit: 1 }),\n deps.adapter.countUnseen(userId),\n ]);\n const newest = page.notifications[0];\n return `${newest?.id ?? \"\"}|${newest?.readAt ? 1 : 0}|${unseen}`;\n }\n\n // One probe per user, however many streams that user holds: N tabs must not mean N queries.\n type Probe = { listeners: Set<() => void>; timer: ReturnType<typeof setInterval>; last?: string };\n const probes = new Map<string, Probe>();\n\n function subscribeProbe(userId: string, onChanged: () => void, intervalMs: number) {\n let probe = probes.get(userId);\n if (!probe) {\n const created: Probe = {\n listeners: new Set(),\n timer: setInterval(() => {\n fingerprint(userId)\n .then((value) => {\n if (created.last !== undefined && value !== created.last) {\n for (const listener of created.listeners) listener();\n }\n created.last = value;\n })\n .catch((error) => deps.logger.error(\"events probe failed\", { error }));\n }, intervalMs),\n };\n probe = created;\n probes.set(userId, created);\n fingerprint(userId)\n .then((value) => {\n if (created.last === undefined) created.last = value;\n })\n .catch(() => {});\n }\n probe.listeners.add(onChanged);\n return () => {\n const current = probes.get(userId);\n if (!current) return;\n current.listeners.delete(onChanged);\n if (current.listeners.size === 0) {\n clearInterval(current.timer);\n probes.delete(userId);\n }\n };\n }\n\n /**\n * Server-Sent Events: `ready` once, `changed` whenever this user's inbox\n * moves, comments as keepalive. No payload ever travels on the stream; the\n * client fetches the feed on `changed`. RFC 0006 §4D.\n */\n function events(request: Request, userId: string, settings: EventsSettings): Response {\n const encoder = new TextEncoder();\n const timers: ReturnType<typeof setInterval>[] = [];\n const unsubscribes: (() => void)[] = [];\n let closed = false;\n let cleanup = () => {};\n\n const stream = new ReadableStream<Uint8Array>(\n {\n start(controller) {\n const write = (text: string) => {\n if (closed) return;\n // A consumer that stopped reading would make this queue grow without bound.\n if (controller.desiredSize !== null && controller.desiredSize <= 0) {\n cleanup();\n return;\n }\n try {\n controller.enqueue(encoder.encode(text));\n } catch {\n cleanup();\n }\n };\n cleanup = () => {\n if (closed) return;\n closed = true;\n for (const unsubscribe of unsubscribes) unsubscribe();\n for (const timer of timers) clearInterval(timer);\n try {\n controller.close();\n } catch {\n // already closed by the consumer\n }\n };\n const changed = () => write(\"event: changed\\ndata: {}\\n\\n\");\n\n unsubscribes.push(trackStream(userId));\n write(\"retry: 3000\\n\\nevent: ready\\ndata: {}\\n\\n\");\n unsubscribes.push(deps.signals.subscribe(inboxChannel(userId), changed));\n timers.push(setInterval(() => write(\": keepalive\\n\\n\"), settings.heartbeatMs));\n\n if (!deps.signals.crossProcess && settings.probeIntervalMs > 0) {\n unsubscribes.push(subscribeProbe(userId, changed, settings.probeIntervalMs));\n }\n\n if (settings.maxDurationMs > 0) {\n timers.push(\n setTimeout(cleanup, settings.maxDurationMs) as ReturnType<typeof setInterval>,\n );\n }\n request.signal?.addEventListener(\"abort\", cleanup, { once: true });\n },\n cancel() {\n cleanup();\n },\n },\n { highWaterMark: STREAM_HIGH_WATER_MARK },\n );\n\n return new Response(stream, {\n headers: {\n \"content-type\": \"text/event-stream; charset=utf-8\",\n \"cache-control\": \"no-cache, no-store\",\n connection: \"keep-alive\",\n \"x-accel-buffering\": \"no\",\n \"x-content-type-options\": \"nosniff\",\n },\n });\n }\n\n async function handle(request: Request): Promise<Response> {\n let response: Response | undefined;\n try {\n response = await route(request);\n return response;\n } catch (error) {\n // A thrown driver error must not escape: unhandled rejection on Node, stack trace elsewhere.\n deps.logger.error(\"request failed\", { url: request.url, error });\n return empty(500);\n } finally {\n // Only a served request earns a sweep; anonymous 401s must not drive database work.\n if (response && response.status < 400) deps.sweepOnRequest?.();\n }\n }\n\n return {\n handle,\n GET: handle,\n POST: handle,\n };\n}\n","import { readJsonBody } from \"../../core/handler\";\nimport type {\n ChannelDecision,\n EasyPingPlugin,\n PluginInitContext,\n PrepareContext,\n ResolveChannelsContext,\n} from \"../../core/plugin\";\nimport { DEFAULT_TOKEN_TTL_SECONDS, expiresIn, signToken } from \"../../core/tokens\";\nimport type { Channel, Frequency } from \"../../core/types\";\n\nexport const UNSUBSCRIBE_PURPOSE = \"unsubscribe\";\n\nconst TABLE = \"notification_preference\";\n\nconst CHANNELS: readonly Channel[] = [\n \"inApp\",\n \"email\",\n \"push\",\n \"mobilePush\",\n \"telegram\",\n \"sms\",\n \"slack\",\n];\nconst FREQUENCIES: readonly Frequency[] = [\"instant\", \"daily\", \"weekly\", \"off\"];\n\nconst isChannel = (value: unknown): value is Channel => CHANNELS.includes(value as Channel);\nconst isFrequency = (value: unknown): value is Frequency =>\n FREQUENCIES.includes(value as Frequency);\n\nexport type PreferenceRow = {\n userId: string;\n type: string;\n channel: Channel;\n enabled: boolean;\n frequency: Frequency;\n /** Last explicit change. An unsubscribe token issued before it is refused. */\n updatedAt?: Date;\n};\n\nexport type PreferencesOptions = {\n /** What an absent row means. Flip to false for opt-in-only notifications. */\n defaultEnabled?: boolean;\n /** Types that ignore preferences entirely: receipts, security alerts. */\n alwaysSend?: readonly string[];\n unsubscribeTtlSeconds?: number;\n};\n\nconst key = (userId: string, type: string, channel: string) => `${userId} ${type} ${channel}`;\n\ntype PreparedPreferences = Map<string, PreferenceRow>;\n\nexport function buildUnsubscribeToken(\n secret: string,\n userId: string,\n type: string,\n channel: Channel,\n ttlSeconds = DEFAULT_TOKEN_TTL_SECONDS,\n): Promise<string> {\n return signToken(secret, {\n uid: userId,\n purpose: UNSUBSCRIBE_PURPOSE,\n exp: expiresIn(ttlSeconds),\n data: { type, channel },\n });\n}\n\nexport function preferences(options: PreferencesOptions = {}): EasyPingPlugin<\"preferences\"> {\n const defaultEnabled = options.defaultEnabled ?? true;\n const alwaysSend = new Set(options.alwaysSend ?? []);\n\n // Assigned by init(), which easyPing() runs before any hook or route.\n let ctx: PluginInitContext;\n\n const isAllowed = (userId: string, type: string, channel: Channel, rows: PreparedPreferences) => {\n const row = rows.get(key(userId, type, channel));\n return row ? row.enabled && row.frequency !== \"off\" : defaultEnabled;\n };\n\n const load = (userIds: readonly string[]) =>\n ctx.store.find<PreferenceRow>(TABLE, { userId: { in: [...userIds] } });\n\n const save = (row: PreferenceRow) =>\n ctx.store.upsert(TABLE, [{ ...row, updatedAt: new Date() }], {\n onConflict: [\"userId\", \"type\", \"channel\"],\n });\n\n return {\n id: \"preferences\",\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n notificationPreference: {\n tableName: TABLE,\n fields: {\n userId: { type: \"string\", required: true },\n type: { type: \"string\", required: true },\n channel: { type: \"string\", required: true },\n enabled: { type: \"boolean\", required: true, default: true },\n frequency: { type: \"string\", required: true, default: \"instant\" },\n updatedAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\n },\n\n hooks: {\n /** One query per send; doing this per recipient would be an N+1. */\n prepare: async (context: PrepareContext): Promise<PreparedPreferences> => {\n if (alwaysSend.has(context.type)) return new Map();\n\n const rows = await load(context.recipients.map((recipient) => recipient.userId));\n return new Map(rows.map((row) => [key(row.userId, row.type, row.channel), row]));\n },\n\n resolveChannels: (context: ResolveChannelsContext) => {\n if (alwaysSend.has(context.type)) return context.decisions;\n\n // prepare() failed: fail closed rather than assume everyone opted in.\n if (!(context.prepared instanceof Map)) {\n return context.decisions.map((decision) => ({ ...decision, action: \"skip\" as const }));\n }\n\n const rows = context.prepared as PreparedPreferences;\n\n return context.decisions.map<ChannelDecision>((decision) =>\n isAllowed(context.recipient.userId, context.type, decision.channel, rows)\n ? decision\n : { ...decision, action: \"skip\" },\n );\n },\n },\n\n routes: [\n {\n path: \"/preferences\",\n method: \"GET\",\n scope: { type: \"user\" },\n handler: async ({ userId }) => {\n const rows = await load([userId ?? \"\"]);\n return Response.json({ preferences: rows, defaultEnabled });\n },\n },\n\n {\n path: \"/preferences\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n const body = parsed.body;\n\n const bad = (error: string) => Response.json({ error }, { status: 400 });\n\n // Only configured types, or every invented string becomes a row.\n if (typeof body.type !== \"string\" || !ctx.notificationTypes.includes(body.type)) {\n return bad(\"type must be a configured notification type\");\n }\n if (!isChannel(body.channel)) return bad(`channel must be one of ${CHANNELS.join(\", \")}`);\n if (body.enabled !== undefined && typeof body.enabled !== \"boolean\") {\n return bad(\"enabled must be a boolean\");\n }\n if (body.frequency !== undefined && !isFrequency(body.frequency)) {\n return bad(`frequency must be one of ${FREQUENCIES.join(\", \")}`);\n }\n\n // userId comes from the resolved session, never from the body.\n await save({\n userId: userId ?? \"\",\n type: body.type,\n channel: body.channel,\n enabled: body.enabled ?? true,\n frequency: body.frequency ?? \"instant\",\n });\n\n return Response.json({ ok: true });\n },\n },\n\n {\n // No session in a mail client; the link carries the proof.\n path: \"/unsubscribe\",\n method: \"POST\",\n scope: { type: \"signed\", purpose: UNSUBSCRIBE_PURPOSE },\n handler: async ({ claims }) => {\n const type = claims?.data?.type;\n const channel = claims?.data?.channel;\n if (!claims || !type || !isChannel(channel)) {\n return Response.json({ error: \"invalid token\" }, { status: 400 });\n }\n\n const [current] = await ctx.store.find<PreferenceRow>(\n TABLE,\n { userId: claims.uid, type, channel },\n { limit: 1 },\n );\n\n // Already off: a second click, or a mail client retrying, is fine.\n if (current && !current.enabled && current.frequency === \"off\") {\n return Response.json({ ok: true, unsubscribed: { type, channel } });\n }\n\n // Changed after the email went out: an old link must not undo a newer decision.\n if (\n current?.updatedAt &&\n claims.iat !== undefined &&\n current.updatedAt.getTime() > (claims.iat + 1) * 1000\n ) {\n return Response.json({ error: \"invalid token\" }, { status: 400 });\n }\n\n await save({\n userId: claims.uid,\n type,\n channel,\n enabled: false,\n frequency: \"off\",\n });\n\n // One-click (Gmail, Yahoo) requires the POST itself to unsubscribe.\n return Response.json({ ok: true, unsubscribed: { type, channel } });\n },\n },\n ],\n };\n}\n"]}