easy-ping 0.4.0 → 0.5.0
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.
- package/README.md +159 -78
- package/dist/adapter-B7R4rXtJ.d.cts +17 -0
- package/dist/adapter-B7R4rXtJ.d.ts +17 -0
- package/dist/{adapter-BfLe2J0t.d.cts → adapter-D3lTy-4E.d.cts} +6 -32
- package/dist/{adapter-BfLe2J0t.d.ts → adapter-D3lTy-4E.d.ts} +6 -32
- package/dist/adapters/drizzle.cjs +1 -2
- package/dist/adapters/drizzle.cjs.map +1 -1
- package/dist/adapters/drizzle.d.cts +1 -1
- package/dist/adapters/drizzle.d.ts +1 -1
- package/dist/adapters/drizzle.js +3 -4
- package/dist/adapters/drizzle.js.map +1 -1
- package/dist/adapters/mongodb.cjs +2 -5
- package/dist/adapters/mongodb.cjs.map +1 -1
- package/dist/adapters/mongodb.d.cts +4 -17
- package/dist/adapters/mongodb.d.ts +4 -17
- package/dist/adapters/mongodb.js +4 -7
- package/dist/adapters/mongodb.js.map +1 -1
- package/dist/adapters/mysql.cjs +605 -0
- package/dist/adapters/mysql.cjs.map +1 -0
- package/dist/adapters/mysql.d.cts +38 -0
- package/dist/adapters/mysql.d.ts +38 -0
- package/dist/adapters/mysql.js +51 -0
- package/dist/adapters/mysql.js.map +1 -0
- package/dist/adapters/postgres.cjs +16 -9
- package/dist/adapters/postgres.cjs.map +1 -1
- package/dist/adapters/postgres.d.cts +9 -54
- package/dist/adapters/postgres.d.ts +9 -54
- package/dist/adapters/postgres.js +6 -41
- package/dist/adapters/postgres.js.map +1 -1
- package/dist/adapters/sqlite.cjs +593 -0
- package/dist/adapters/sqlite.cjs.map +1 -0
- package/dist/adapters/sqlite.d.cts +37 -0
- package/dist/adapters/sqlite.d.ts +37 -0
- package/dist/adapters/sqlite.js +53 -0
- package/dist/adapters/sqlite.js.map +1 -0
- package/dist/browser.cjs.map +1 -1
- package/dist/browser.d.cts +2 -14
- package/dist/browser.d.ts +2 -14
- package/dist/browser.js.map +1 -1
- package/dist/chunk-2EYKPF5A.js +393 -0
- package/dist/chunk-2EYKPF5A.js.map +1 -0
- package/dist/{chunk-3Y2MBUAW.js → chunk-3VOQOGDR.js} +4 -3
- package/dist/chunk-3VOQOGDR.js.map +1 -0
- package/dist/{chunk-PSJHXZPR.js → chunk-AEYYTE5I.js} +2 -2
- package/dist/chunk-AEYYTE5I.js.map +1 -0
- package/dist/{chunk-DG3ZL5NP.js → chunk-BECMTNK7.js} +2 -2
- package/dist/chunk-BECMTNK7.js.map +1 -0
- package/dist/chunk-CK5UPVYP.js +48 -0
- package/dist/chunk-CK5UPVYP.js.map +1 -0
- package/dist/{chunk-W6I3B2CI.js → chunk-E4IZ2CQC.js} +2 -2
- package/dist/{chunk-W6I3B2CI.js.map → chunk-E4IZ2CQC.js.map} +1 -1
- package/dist/{chunk-CYKAM7LE.js → chunk-GJNLOQJS.js} +2 -2
- package/dist/chunk-GJNLOQJS.js.map +1 -0
- package/dist/chunk-HY47TQMY.js +52 -0
- package/dist/chunk-HY47TQMY.js.map +1 -0
- package/dist/chunk-M4R77BJD.js +64 -0
- package/dist/chunk-M4R77BJD.js.map +1 -0
- package/dist/{chunk-YEMRRMJQ.js → chunk-XIDVLYNQ.js} +14 -4
- package/dist/chunk-XIDVLYNQ.js.map +1 -0
- package/dist/client.cjs.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -60
- package/dist/index.d.ts +15 -60
- package/dist/index.js +9 -10
- package/dist/index.js.map +1 -1
- package/dist/node.cjs.map +1 -1
- package/dist/node.js +1 -1
- package/dist/node.js.map +1 -1
- package/dist/{plugin-C9Hoxus2.d.ts → plugin-CHd2ZiGC.d.cts} +5 -21
- package/dist/{plugin-CiMsGlQe.d.cts → plugin-Cg9usuUi.d.ts} +5 -21
- package/dist/plugins/digests.cjs +1 -4
- package/dist/plugins/digests.cjs.map +1 -1
- package/dist/plugins/digests.d.cts +6 -31
- package/dist/plugins/digests.d.ts +6 -31
- package/dist/plugins/digests.js +1 -4
- package/dist/plugins/digests.js.map +1 -1
- package/dist/plugins/preferences-client.cjs.map +1 -1
- package/dist/plugins/preferences-client.d.cts +2 -2
- package/dist/plugins/preferences-client.d.ts +2 -2
- package/dist/plugins/preferences-client.js +1 -1
- package/dist/plugins/preferences-react.cjs.map +1 -1
- package/dist/plugins/preferences-react.js.map +1 -1
- package/dist/plugins/preferences.cjs +1 -2
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.d.cts +2 -2
- package/dist/plugins/preferences.d.ts +2 -2
- package/dist/plugins/preferences.js +2 -3
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.cjs +1 -2
- package/dist/plugins/push.cjs.map +1 -1
- package/dist/plugins/push.d.cts +6 -26
- package/dist/plugins/push.d.ts +6 -26
- package/dist/plugins/push.js +3 -4
- package/dist/plugins/push.js.map +1 -1
- package/dist/providers/web-push.cjs.map +1 -1
- package/dist/providers/web-push.d.cts +3 -10
- package/dist/providers/web-push.d.ts +3 -10
- package/dist/providers/web-push.js.map +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -4
- package/dist/react.d.ts +1 -4
- package/dist/react.js.map +1 -1
- package/dist/schema.cjs +119 -1
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +20 -14
- package/dist/schema.d.ts +20 -14
- package/dist/schema.js +4 -2
- package/dist/testing.cjs +3 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -22
- package/dist/testing.d.ts +4 -22
- package/dist/testing.js +3 -19
- package/dist/testing.js.map +1 -1
- package/package.json +12 -1
- package/dist/chunk-3Y2MBUAW.js.map +0 -1
- package/dist/chunk-CYKAM7LE.js.map +0 -1
- package/dist/chunk-DG3ZL5NP.js.map +0 -1
- package/dist/chunk-PSJHXZPR.js.map +0 -1
- package/dist/chunk-YEMRRMJQ.js.map +0 -1
package/dist/node.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/core/handler.ts","../src/node/index.ts"],"names":[],"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 { verifyToken } from \"./tokens\";\n\nexport type HandlerDeps = {\n adapter: DatabaseAdapter;\n session: SessionConfig;\n runner: Runner;\n logger: Logger;\n basePath: string;\n secret: string;\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\n// The inbox is per-user data: never let a shared cache keep it, never let a\n// browser sniff it into something executable.\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) =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json\", ...PRIVATE_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/**\n * Constant-time comparison over SHA-256 digests. Node's timingSafeEqual is\n * unavailable on edge runtimes, `===` leaks through timing, and comparing the\n * raw strings would still reveal the secret's length.\n */\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/**\n * Reads a JSON object body with a byte cap enforced while streaming, so an\n * oversized body is refused before it is buffered. Malformed JSON is 400; an\n * empty body is `{}`. Plugin routes should use this rather than\n * `request.json()`, which has no limit.\n */\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/**\n * Two cheap CSRF defences for cookie-authenticated POSTs. A cross-site form\n * cannot send `application/json` without a preflight the browser will fail,\n * and it cannot forge `Origin`. Neither depends on the host app's SameSite\n * policy, which the library cannot see.\n */\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 async function resolveUserId(request: Request): Promise<string | null | \"error\"> {\n try {\n return await deps.session.getUserId(request);\n } catch (error) {\n // A broken session lookup is a 500, never a 401. Collapsing them makes\n // an outage look like a mass logout. RFC 0002 §1.\n deps.logger.error(\"session.getUserId threw\", { error });\n return \"error\";\n }\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: an overlapping scheduler must not hold a request open for as\n // long as the backlog lasts.\n const result = await deps.runner.drain(\n deps.cronMaxSweeps === undefined ? {} : { maxSweeps: deps.cronMaxSweeps },\n );\n return json(result);\n }\n\n /**\n * Plugin routes are dispatched before core ones and carry their scope with\n * them. Authentication happens here rather than in the plugin handler, so a\n * plugin physically cannot forget to check a signature or scope a query.\n */\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 === \"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. Distinguishing expired from\n // forged tells an attacker which part to fix. 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 === \"error\") return empty(500);\n if (userId === null) return empty(401);\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 // The badge count rides along with the first page, so a polling client\n // needs one request per tick instead of two. Paginating past the first\n // page omits it: 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 // Defaults to now, but a client should send the timestamp of the newest\n // notification it has actually rendered. Otherwise anything that arrived\n // between the last poll and the click is marked seen without ever having\n // produced a badge — the user silently misses it.\n //\n // +1ms because Postgres keeps microseconds while an ISO string carries\n // only milliseconds. Without it the truncated cutoff falls *before* the\n // very row it was taken from, and markSeen matches nothing at all.\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 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 // Unbounded, this builds a query with as many bind parameters as the\n // caller cares to send. Postgres caps at 65535 and degrades long before.\n if (ids.length > MAX_READ_IDS) {\n return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);\n }\n\n // Scoped by userId in the adapter. A miss is 404, not 403 — a 403 would\n // confirm the row exists and belongs to someone else. RFC 0002 §2.\n const updated = await deps.adapter.markRead(userId, ids);\n return updated === 0 ? empty(404) : json({ updated });\n }\n\n if (request.method === \"POST\" && path === \"/read-all\") {\n return json({ updated: await deps.adapter.markAllRead(userId) });\n }\n\n return empty(404);\n }\n\n async function handle(request: Request): Promise<Response> {\n try {\n return await route(request);\n } catch (error) {\n // Never let a driver error escape: on Node it becomes an unhandled\n // rejection, on other hosts a stack trace in the response body.\n deps.logger.error(\"request failed\", { url: request.url, error });\n return empty(500);\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): Request {\n // Behind a load balancer this is the only signal that the site is https.\n const protocol = (req.headers[\"x-forwarded-proto\"] as string | undefined) ?? \"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 });\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 try {\n const response = await handler(toWebRequest(req, await readBody(req, maxBodyBytes)));\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 const buffer = response.body ? Buffer.from(await response.arrayBuffer()) : null;\n res.end(buffer ?? undefined);\n } catch (error) {\n // Respond and swallow. Rethrowing here is an unhandled rejection under\n // Express, and Node terminates the process on those.\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
|
+
{"version":3,"sources":["../src/core/handler.ts","../src/node/index.ts"],"names":[],"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 { verifyToken } from \"./tokens\";\n\nexport type HandlerDeps = {\n adapter: DatabaseAdapter;\n session: SessionConfig;\n runner: Runner;\n logger: Logger;\n basePath: string;\n secret: string;\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\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) =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json\", ...PRIVATE_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 async function resolveUserId(request: Request): Promise<string | null | \"error\"> {\n try {\n return 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 \"error\";\n }\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 === \"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 === \"error\") return empty(500);\n if (userId === null) return empty(401);\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 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 return updated === 0 ? empty(404) : json({ updated });\n }\n\n if (request.method === \"POST\" && path === \"/read-all\") {\n return json({ updated: await deps.adapter.markAllRead(userId) });\n }\n\n return empty(404);\n }\n\n async function handle(request: Request): Promise<Response> {\n try {\n return await route(request);\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 }\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): Request {\n // Behind a load balancer this is the only signal that the site is https.\n const protocol = (req.headers[\"x-forwarded-proto\"] as string | undefined) ?? \"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 });\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 try {\n const response = await handler(toWebRequest(req, await readBody(req, maxBodyBytes)));\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 const buffer = response.body ? Buffer.from(await response.arrayBuffer()) : null;\n res.end(buffer ?? undefined);\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
CHANGED
package/dist/node.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/node/index.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"sources":["../src/node/index.ts"],"names":[],"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): Request {\n // Behind a load balancer this is the only signal that the site is https.\n const protocol = (req.headers[\"x-forwarded-proto\"] as string | undefined) ?? \"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 });\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 try {\n const response = await handler(toWebRequest(req, await readBody(req, maxBodyBytes)));\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 const buffer = response.body ? Buffer.from(await response.arrayBuffer()) : null;\n res.end(buffer ?? undefined);\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,4 +1,4 @@
|
|
|
1
|
-
import { R as Recipient, C as Channel, f as DeliveryOutcome, k as PluginStore } from './adapter-
|
|
1
|
+
import { R as Recipient, C as Channel, f as DeliveryOutcome, k as PluginStore } from './adapter-D3lTy-4E.cjs';
|
|
2
2
|
|
|
3
3
|
declare class EasyPingError extends Error {
|
|
4
4
|
constructor(message: string);
|
|
@@ -127,17 +127,9 @@ type PluginHooks = {
|
|
|
127
127
|
prepare?: (ctx: PrepareContext) => Promisable<unknown>;
|
|
128
128
|
beforeSend?: (ctx: BeforeSendContext) => Promisable<BeforeSendResult>;
|
|
129
129
|
resolveChannels?: (ctx: ResolveChannelsContext) => Promisable<readonly ChannelDecision[]>;
|
|
130
|
-
/**
|
|
131
|
-
* Once per send, after the rows are committed. The only hook that sees
|
|
132
|
-
* notification ids, which digests needs to bucket an entry against one.
|
|
133
|
-
* Fails open: the notification is already written.
|
|
134
|
-
*/
|
|
130
|
+
/** Once per send, after commit. The only hook that sees notification ids. Fails open. */
|
|
135
131
|
afterSend?: (ctx: AfterSendContext) => Promisable<void>;
|
|
136
|
-
/**
|
|
137
|
-
* Delivers one of the plugin's declared `channels`. Core carries inApp and
|
|
138
|
-
* email; everything else arrives here, which is how push, sms and slack are
|
|
139
|
-
* added without core learning about them.
|
|
140
|
-
*/
|
|
132
|
+
/** Delivers one of the plugin's declared `channels`. Core carries inApp and email. */
|
|
141
133
|
deliver?: (ctx: DeliverContext) => Promisable<DeliveryOutcome>;
|
|
142
134
|
afterDeliver?: (ctx: AfterDeliverContext) => Promisable<void>;
|
|
143
135
|
};
|
|
@@ -190,22 +182,14 @@ type RouteDefinition = {
|
|
|
190
182
|
type PluginInitContext = {
|
|
191
183
|
/** Scoped to the tables this plugin declares in schema(). */
|
|
192
184
|
store: PluginStore;
|
|
193
|
-
/**
|
|
194
|
-
* Mints tokens for this plugin's own `signed` routes and nothing else. The
|
|
195
|
-
* master secret itself is never handed out: a plugin cannot forge another
|
|
196
|
-
* plugin's links, or anything a future signed flow relies on.
|
|
197
|
-
*/
|
|
185
|
+
/** Mints tokens for this plugin's own `signed` routes only. The secret itself is never handed out. */
|
|
198
186
|
sign: (request: SignRequest) => Promise<string>;
|
|
199
187
|
logger: Logger;
|
|
200
188
|
/** Configured notification names, so a route can refuse to store rows for types that do not exist. */
|
|
201
189
|
notificationTypes: readonly string[];
|
|
202
190
|
/** The app's resolver, for plugins that need timezones or addresses. */
|
|
203
191
|
getRecipients: (userIds: readonly string[]) => Promise<readonly Recipient[]>;
|
|
204
|
-
/**
|
|
205
|
-
* The instance's own send(). A plugin composing a message (a digest, say)
|
|
206
|
-
* routes it back through the pipeline rather than reaching for a provider,
|
|
207
|
-
* so it inherits retry, backoff and idempotency for free.
|
|
208
|
-
*/
|
|
192
|
+
/** The instance's own send(), so a composed message inherits retry, backoff and idempotency. */
|
|
209
193
|
send: (type: string, args: {
|
|
210
194
|
to: string | readonly string[];
|
|
211
195
|
payload: unknown;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { R as Recipient, C as Channel, f as DeliveryOutcome, k as PluginStore } from './adapter-
|
|
1
|
+
import { R as Recipient, C as Channel, f as DeliveryOutcome, k as PluginStore } from './adapter-D3lTy-4E.js';
|
|
2
2
|
|
|
3
3
|
declare class EasyPingError extends Error {
|
|
4
4
|
constructor(message: string);
|
|
@@ -127,17 +127,9 @@ type PluginHooks = {
|
|
|
127
127
|
prepare?: (ctx: PrepareContext) => Promisable<unknown>;
|
|
128
128
|
beforeSend?: (ctx: BeforeSendContext) => Promisable<BeforeSendResult>;
|
|
129
129
|
resolveChannels?: (ctx: ResolveChannelsContext) => Promisable<readonly ChannelDecision[]>;
|
|
130
|
-
/**
|
|
131
|
-
* Once per send, after the rows are committed. The only hook that sees
|
|
132
|
-
* notification ids, which digests needs to bucket an entry against one.
|
|
133
|
-
* Fails open: the notification is already written.
|
|
134
|
-
*/
|
|
130
|
+
/** Once per send, after commit. The only hook that sees notification ids. Fails open. */
|
|
135
131
|
afterSend?: (ctx: AfterSendContext) => Promisable<void>;
|
|
136
|
-
/**
|
|
137
|
-
* Delivers one of the plugin's declared `channels`. Core carries inApp and
|
|
138
|
-
* email; everything else arrives here, which is how push, sms and slack are
|
|
139
|
-
* added without core learning about them.
|
|
140
|
-
*/
|
|
132
|
+
/** Delivers one of the plugin's declared `channels`. Core carries inApp and email. */
|
|
141
133
|
deliver?: (ctx: DeliverContext) => Promisable<DeliveryOutcome>;
|
|
142
134
|
afterDeliver?: (ctx: AfterDeliverContext) => Promisable<void>;
|
|
143
135
|
};
|
|
@@ -190,22 +182,14 @@ type RouteDefinition = {
|
|
|
190
182
|
type PluginInitContext = {
|
|
191
183
|
/** Scoped to the tables this plugin declares in schema(). */
|
|
192
184
|
store: PluginStore;
|
|
193
|
-
/**
|
|
194
|
-
* Mints tokens for this plugin's own `signed` routes and nothing else. The
|
|
195
|
-
* master secret itself is never handed out: a plugin cannot forge another
|
|
196
|
-
* plugin's links, or anything a future signed flow relies on.
|
|
197
|
-
*/
|
|
185
|
+
/** Mints tokens for this plugin's own `signed` routes only. The secret itself is never handed out. */
|
|
198
186
|
sign: (request: SignRequest) => Promise<string>;
|
|
199
187
|
logger: Logger;
|
|
200
188
|
/** Configured notification names, so a route can refuse to store rows for types that do not exist. */
|
|
201
189
|
notificationTypes: readonly string[];
|
|
202
190
|
/** The app's resolver, for plugins that need timezones or addresses. */
|
|
203
191
|
getRecipients: (userIds: readonly string[]) => Promise<readonly Recipient[]>;
|
|
204
|
-
/**
|
|
205
|
-
* The instance's own send(). A plugin composing a message (a digest, say)
|
|
206
|
-
* routes it back through the pipeline rather than reaching for a provider,
|
|
207
|
-
* so it inherits retry, backoff and idempotency for free.
|
|
208
|
-
*/
|
|
192
|
+
/** The instance's own send(), so a composed message inherits retry, backoff and idempotency. */
|
|
209
193
|
send: (type: string, args: {
|
|
210
194
|
to: string | readonly string[];
|
|
211
195
|
payload: unknown;
|
package/dist/plugins/digests.cjs
CHANGED
|
@@ -135,10 +135,7 @@ function digests(options = {}) {
|
|
|
135
135
|
},
|
|
136
136
|
routes: [
|
|
137
137
|
{
|
|
138
|
-
/**
|
|
139
|
-
* Point an hourly cron here. Hourly rather than daily because "9am"
|
|
140
|
-
* is 24 different UTC moments, and which ones shift with DST.
|
|
141
|
-
*/
|
|
138
|
+
/** Point an hourly cron here: "9am" is 24 different UTC moments that shift with DST. */
|
|
142
139
|
path: "/digests/cron",
|
|
143
140
|
method: "POST",
|
|
144
141
|
scope: { type: "machine" },
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/plugins/digests/schedule.ts","../../src/plugins/digests/index.ts"],"names":[],"mappings":"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","file":"digests.cjs","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/**\n * Reads the wall clock in a recipient's zone.\n *\n * hourCycle h23 rather than hour12:false — the latter renders midnight as \"24\"\n * in several locales, which silently makes the send-hour comparison wrong for\n * one hour a day.\n */\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/**\n * Identifies the digest period a moment falls in.\n *\n * Scheduling keys off this string rather than bucketing users by UTC offset.\n * Offsets shift twice a year, and getting that wrong sends someone two digests\n * or none. A period key cannot: it is derived from the local calendar, so DST\n * is already accounted for and a missed cron run simply catches up.\n */\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 { 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 /**\n * The notification type the composed digest is sent as. The app defines it\n * with an email template taking `{ items }`.\n */\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/**\n * Collects notifications a user has chosen to receive on a schedule, then\n * sends them as one message.\n *\n * Deliberately not implemented with `defer`: deferring N emails to 9am sends N\n * emails at 9am. A digest is one email containing N items, so the channel is\n * skipped and the item is bucketed instead.\n *\n * Depends on `preferences` for the per-type frequency it reads.\n */\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; owned by the preferences plugin.\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 },\n frequency: { type: \"string\", required: true },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\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 /**\n * Point an hourly cron here. Hourly rather than daily because \"9am\"\n * is 24 different UTC moments, and which ones shift with DST.\n */\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 // The schedule is idempotent on this comparison, not on timing:\n // a cron that runs twice in an hour 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, so a crash re-sends rather\n // than dropping the batch.\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
|
+
{"version":3,"sources":["../../src/plugins/digests/schedule.ts","../../src/plugins/digests/index.ts"],"names":[],"mappings":";;;AAUA,IAAM,QAAA,GAAW,CAAC,KAAA,EAAO,KAAA,EAAO,OAAO,KAAA,EAAO,KAAA,EAAO,OAAO,KAAK,CAAA;AAG1D,SAAS,WAAA,CAAY,UAAkB,GAAA,EAAwB;AACpE,EAAA,MAAM,SAAA,GAAY,IAAI,IAAA,CAAK,cAAA,CAAe,OAAA,EAAS;AAAA,IACjD,QAAA,EAAU,QAAA;AAAA,IACV,IAAA,EAAM,SAAA;AAAA,IACN,KAAA,EAAO,SAAA;AAAA,IACP,GAAA,EAAK,SAAA;AAAA,IACL,IAAA,EAAM,SAAA;AAAA,IACN,SAAA,EAAW,KAAA;AAAA,IACX,OAAA,EAAS;AAAA,GACV,CAAA;AAED,EAAA,MAAM,QAAgC,EAAC;AACvC,EAAA,KAAA,MAAW,IAAA,IAAQ,UAAU,aAAA,CAAc,GAAG,GAAG,KAAA,CAAM,IAAA,CAAK,IAAI,CAAA,GAAI,IAAA,CAAK,KAAA;AAEzE,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,GAAG,KAAA,CAAM,IAAI,IAAI,KAAA,CAAM,KAAK,CAAA,CAAA,EAAI,KAAA,CAAM,GAAG,CAAA,CAAA;AAAA,IAC/C,IAAA,EAAM,MAAA,CAAO,KAAA,CAAM,IAAA,IAAQ,CAAC,CAAA;AAAA,IAC5B,OAAA,EAAS,KAAK,GAAA,CAAI,CAAA,EAAG,SAAS,OAAA,CAAQ,KAAA,CAAM,OAAA,IAAW,KAAK,CAAC;AAAA,GAC/D;AACF;AAEA,IAAM,SAAA,GAAY,CAAC,IAAA,EAAc,IAAA,KAAyB;AACxD,EAAA,MAAM,OAAA,mBAAU,IAAI,IAAA,CAAK,CAAA,EAAG,IAAI,CAAA,UAAA,CAAY,CAAA;AAC5C,EAAA,OAAA,CAAQ,UAAA,CAAW,OAAA,CAAQ,UAAA,EAAW,GAAI,IAAI,CAAA;AAC9C,EAAA,OAAO,OAAA,CAAQ,WAAA,EAAY,CAAE,KAAA,CAAM,GAAG,EAAE,CAAA;AAC1C,CAAA;AAGO,SAAS,SAAA,CAAU,MAAA,EAAsB,MAAA,EAAqB,WAAA,EAA6B;AAChG,EAAA,IAAI,MAAA,KAAW,OAAA,EAAS,OAAO,MAAA,CAAO,IAAA;AAEtC,EAAA,MAAM,KAAA,GAAA,CAAS,MAAA,CAAO,OAAA,GAAU,WAAA,GAAc,CAAA,IAAK,CAAA;AACnD,EAAA,OAAO,SAAA,CAAU,MAAA,CAAO,IAAA,EAAM,KAAK,CAAA;AACrC;AAGO,SAAS,KAAA,CACd,MAAA,EACA,MAAA,EACA,QAAA,EACA,WAAA,EACS;AACT,EAAA,IAAI,MAAA,KAAW,OAAA,EAAS,OAAO,MAAA,CAAO,IAAA,IAAQ,QAAA;AAE9C,EAAA,MAAM,MAAA,GAAS,SAAA,CAAU,QAAA,EAAU,MAAA,EAAQ,WAAW,CAAA;AAEtD,EAAA,OAAO,MAAA,CAAO,IAAA,GAAO,MAAA,IAAU,MAAA,CAAO,IAAA,IAAQ,QAAA;AAChD;;;AClDA,IAAM,KAAA,GAAQ,2BAAA;AACd,IAAM,KAAA,GAAQ,2BAAA;AA8Bd,IAAM,cAAA,GAAiB,CAAC,SAAA,KACtB,SAAA,KAAc,WAAW,SAAA,KAAc,QAAA;AAGlC,SAAS,OAAA,CAAQ,OAAA,GAA0B,EAAC,EAA8B;AAC/E,EAAA,MAAM,UAAA,GAAa,QAAQ,UAAA,IAAc,QAAA;AACzC,EAAA,MAAM,QAAA,GAAW,QAAQ,QAAA,IAAY,CAAA;AACrC,EAAA,MAAM,WAAA,GAAc,QAAQ,WAAA,IAAe,CAAA;AAC3C,EAAA,MAAM,OAAA,GAAU,QAAQ,OAAA,IAAW,OAAA;AACnC,EAAA,MAAM,cAAA,GAAiB,QAAQ,cAAA,IAAkB,GAAA;AAEjD,EAAA,IAAI,GAAA;AAGJ,EAAA,eAAe,SAAA,CACb,SACA,IAAA,EACoC;AACpC,IAAA,MAAM,OAAO,MAAM,GAAA,CAAI,KAAA,CAAM,IAAA,CAM1B,2BAA2B,EAAE,MAAA,EAAQ,EAAE,EAAA,EAAI,CAAC,GAAG,OAAO,GAAE,EAAG,IAAA,EAAM,SAAS,CAAA;AAE7E,IAAA,MAAM,OAAA,uBAAc,GAAA,EAA0B;AAC9C,IAAA,KAAA,MAAW,OAAO,IAAA,EAAM;AACtB,MAAA,IAAI,GAAA,CAAI,OAAA,IAAW,cAAA,CAAe,GAAA,CAAI,SAAS,CAAA,EAAG,OAAA,CAAQ,GAAA,CAAI,GAAA,CAAI,MAAA,EAAQ,GAAA,CAAI,SAAS,CAAA;AAAA,IACzF;AACA,IAAA,OAAO,OAAA;AAAA,EACT;AAEA,EAAA,OAAO;AAAA,IACL,EAAA,EAAI,SAAA;AAAA,IACJ,SAAA,EAAW,CAAC,aAAa,CAAA;AAAA,IAEzB,IAAA,EAAM,CAAC,OAAA,KAAY;AACjB,MAAA,GAAA,GAAM,OAAA;AAAA,IACR,CAAA;AAAA,IAEA,MAAA,EAAQ;AAAA,MACN,WAAA,EAAa;AAAA,QACX,SAAA,EAAW,KAAA;AAAA,QACX,MAAA,EAAQ;AAAA,UACN,EAAA,EAAI,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACrC,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,IAAA,EAAM,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACvC,cAAA,EAAgB,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACjD,OAAA,EAAS,EAAE,IAAA,EAAM,MAAA,EAAO;AAAA,UACxB,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,WAAW,EAAE,IAAA,EAAM,QAAQ,QAAA,EAAU,IAAA,EAAM,YAAY,IAAA;AAAK,SAC9D;AAAA,QACA,UAAA,EAAY,CAAC,IAAI,CAAA;AAAA,QACjB,OAAA,EAAS,CAAC,EAAE,EAAA,EAAI,CAAC,UAAU,QAAQ,CAAA,EAAG,IAAA,EAAM,uBAAA,EAAyB;AAAA,OACvE;AAAA,MAEA,WAAA,EAAa;AAAA,QACX,SAAA,EAAW,KAAA;AAAA,QACX,MAAA,EAAQ;AAAA,UACN,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA;AAAA,UAEzC,UAAA,EAAY,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UAC7C,WAAW,EAAE,IAAA,EAAM,QAAQ,QAAA,EAAU,IAAA,EAAM,YAAY,IAAA;AAAK,SAC9D;AAAA,QACA,UAAA,EAAY,CAAC,QAAA,EAAU,QAAQ;AAAA,OACjC;AAAA;AAAA,MAGA,sBAAA,EAAwB;AAAA,QACtB,SAAA,EAAW,yBAAA;AAAA,QACX,MAAA,EAAQ;AAAA,UACN,MAAA,EAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACzC,IAAA,EAAM,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UACvC,OAAA,EAAS,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA,EAAK;AAAA,UAC1C,OAAA,EAAS,EAAE,IAAA,EAAM,SAAA,EAAW,UAAU,IAAA,EAAK;AAAA,UAC3C,SAAA,EAAW,EAAE,IAAA,EAAM,QAAA,EAAU,UAAU,IAAA;AAAK,SAC9C;AAAA,QACA,UAAA,EAAY,CAAC,QAAA,EAAU,MAAA,EAAQ,SAAS;AAAA;AAC1C,KACF;AAAA,IAEA,KAAA,EAAO;AAAA,MACL,OAAA,EAAS,OAAO,OAAA,KAA+C;AAC7D,QAAA,IAAI,OAAA,CAAQ,SAAS,UAAA,EAAY,OAAO,EAAE,QAAA,kBAAU,IAAI,KAAI,EAAE;AAE9D,QAAA,OAAO;AAAA,UACL,UAAU,MAAM,SAAA;AAAA,YACd,QAAQ,UAAA,CAAW,GAAA,CAAI,CAAC,SAAA,KAAc,UAAU,MAAM,CAAA;AAAA,YACtD,OAAA,CAAQ;AAAA;AACV,SACF;AAAA,MACF,CAAA;AAAA,MAEA,eAAA,EAAiB,CAAC,OAAA,KAAoC;AACpD,QAAA,MAAM,WAAW,OAAA,CAAQ,QAAA;AACzB,QAAA,IAAI,CAAC,UAAU,QAAA,CAAS,GAAA,CAAI,QAAQ,SAAA,CAAU,MAAM,CAAA,EAAG,OAAO,OAAA,CAAQ,SAAA;AAEtE,QAAA,OAAO,QAAQ,SAAA,CAAU,GAAA;AAAA,UAAI,CAAC,QAAA,KAC5B,QAAA,CAAS,OAAA,KAAY,OAAA,GAAU,EAAE,GAAG,QAAA,EAAU,MAAA,EAAQ,MAAA,EAAgB,GAAI;AAAA,SAC5E;AAAA,MACF,CAAA;AAAA;AAAA,MAGA,SAAA,EAAW,OAAO,OAAA,KAA8B;AAC9C,QAAA,MAAM,WAAW,OAAA,CAAQ,QAAA;AACzB,QAAA,IAAI,CAAC,QAAA,IAAY,QAAA,CAAS,QAAA,CAAS,SAAS,CAAA,EAAG;AAE/C,QAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,aAAA,CACrB,MAAA,CAAO,CAAC,YAAA,KAAiB,QAAA,CAAS,QAAA,CAAS,GAAA,CAAI,aAAa,MAAM,CAAC,CAAA,CACnE,GAAA,CAAI,CAAC,YAAA,MAAkB;AAAA,UACtB,EAAA,EAAI,OAAO,UAAA,EAAW;AAAA,UACtB,QAAQ,YAAA,CAAa,MAAA;AAAA,UACrB,MAAM,OAAA,CAAQ,IAAA;AAAA,UACd,gBAAgB,YAAA,CAAa,EAAA;AAAA,UAC7B,SAAS,OAAA,CAAQ,OAAA;AAAA,UACjB,MAAA,EAAQ,QAAA,CAAS,QAAA,CAAS,GAAA,CAAI,aAAa,MAAM,CAAA;AAAA,UACjD,SAAA,sBAAe,IAAA;AAAK,SACtB,CAAE,CAAA;AAEJ,QAAA,IAAI,OAAA,CAAQ,SAAS,CAAA,EAAG,MAAM,IAAI,KAAA,CAAM,MAAA,CAAO,OAAO,OAAO,CAAA;AAAA,MAC/D;AAAA,KACF;AAAA,IAEA,MAAA,EAAQ;AAAA,MACN;AAAA;AAAA,QAEE,IAAA,EAAM,eAAA;AAAA,QACN,MAAA,EAAQ,MAAA;AAAA,QACR,KAAA,EAAO,EAAE,IAAA,EAAM,SAAA,EAAU;AAAA,QACzB,SAAS,YAAY;AACnB,UAAA,MAAM,GAAA,uBAAU,IAAA,EAAK;AAErB,UAAA,MAAM,OAAA,GAAU,MAAM,GAAA,CAAI,KAAA,CAAM,IAAA,CAAkB,KAAA,EAAO,EAAC,EAAG,EAAE,KAAA,EAAO,GAAA,EAAQ,CAAA;AAC9E,UAAA,IAAI,OAAA,CAAQ,MAAA,KAAW,CAAA,EAAG,OAAO,QAAA,CAAS,IAAA,CAAK,EAAE,IAAA,EAAM,CAAA,EAAG,KAAA,EAAO,CAAA,EAAG,CAAA;AAEpE,UAAA,MAAM,MAAA,uBAAa,GAAA,EAA2B;AAC9C,UAAA,KAAA,MAAW,SAAS,OAAA,EAAS;AAC3B,YAAA,MAAM,MAAA,GAAS,MAAA,CAAO,GAAA,CAAI,KAAA,CAAM,MAAM,CAAA;AACtC,YAAA,IAAI,MAAA,EAAQ,MAAA,CAAO,IAAA,CAAK,KAAK,CAAA;AAAA,wBACjB,GAAA,CAAI,KAAA,CAAM,MAAA,EAAQ,CAAC,KAAK,CAAC,CAAA;AAAA,UACvC;AAEA,UAAA,MAAM,OAAA,GAAU,CAAC,GAAG,MAAA,CAAO,MAAM,CAAA,CAAE,KAAA,CAAM,CAAA,EAAG,cAAc,CAAA;AAC1D,UAAA,MAAM,UAAA,GAAa,MAAM,GAAA,CAAI,aAAA,CAAc,OAAO,CAAA;AAClD,UAAA,MAAM,KAAA,GAAQ,IAAI,GAAA,CAAI,UAAA,CAAW,GAAA,CAAI,CAAC,CAAA,KAAM,CAAC,CAAA,CAAE,MAAA,EAAQ,CAAA,CAAE,QAAQ,CAAC,CAAC,CAAA;AAEnE,UAAA,MAAM,MAAA,GAAS,MAAM,GAAA,CAAI,KAAA,CAAM,IAAA,CAI5B,KAAA,EAAO,EAAE,MAAA,EAAQ,EAAE,EAAA,EAAI,OAAA,EAAQ,EAAG,CAAA;AAErC,UAAA,MAAM,cAAc,IAAI,GAAA,CAAI,OAAO,GAAA,CAAI,CAAC,MAAM,CAAC,CAAA,EAAG,CAAA,CAAE,MAAM,IAAI,CAAA,CAAE,MAAM,IAAI,CAAA,CAAE,UAAU,CAAC,CAAC,CAAA;AAExF,UAAA,IAAI,IAAA,GAAO,CAAA;AAEX,UAAA,KAAA,MAAW,UAAU,OAAA,EAAS;AAC5B,YAAA,MAAM,OAAA,GAAU,MAAA,CAAO,GAAA,CAAI,MAAM,KAAK,EAAC;AACvC,YAAA,MAAM,SAAS,WAAA,CAAY,KAAA,CAAM,IAAI,MAAM,CAAA,IAAK,OAAO,GAAG,CAAA;AAE1D,YAAA,KAAA,MAAW,MAAA,IAAU,CAAC,OAAA,EAAS,QAAQ,CAAA,EAAY;AACjD,cAAA,MAAM,YAAY,OAAA,CAAQ,MAAA,CAAO,CAAC,KAAA,KAAU,KAAA,CAAM,WAAW,MAAM,CAAA;AACnE,cAAA,IAAI,SAAA,CAAU,WAAW,CAAA,EAAG;AAE5B,cAAA,MAAM,MAAA,GAAS,SAAA,CAAU,MAAA,EAAQ,MAAA,EAAQ,WAAW,CAAA;AAEpD,cAAA,IAAI,WAAA,CAAY,IAAI,CAAA,EAAG,MAAM,IAAI,MAAM,CAAA,CAAE,MAAM,MAAA,EAAQ;AACvD,cAAA,IAAI,CAAC,KAAA,CAAM,MAAA,EAAQ,MAAA,EAAQ,QAAA,EAAU,WAAW,CAAA,EAAG;AAEnD,cAAA,MAAM,GAAA,CAAI,KAAK,UAAA,EAAY;AAAA,gBACzB,EAAA,EAAI,MAAA;AAAA,gBACJ,OAAA,EAAS;AAAA,kBACP,MAAA;AAAA,kBACA,MAAA;AAAA,kBACA,KAAA,EAAO,SAAA,CAAU,GAAA,CAAI,CAAC,KAAA,MAAW;AAAA,oBAC/B,MAAM,KAAA,CAAM,IAAA;AAAA,oBACZ,SAAS,KAAA,CAAM,OAAA;AAAA,oBACf,WAAW,KAAA,CAAM;AAAA,mBACnB,CAAE;AAAA,iBACJ;AAAA,gBACA,WAAW,CAAA,OAAA,EAAU,MAAM,CAAA,CAAA,EAAI,MAAM,IAAI,MAAM,CAAA;AAAA,eAChD,CAAA;AAED,cAAA,MAAM,IAAI,KAAA,CAAM,MAAA;AAAA,gBACd,KAAA;AAAA,gBACA,CAAC,EAAE,MAAA,EAAQ,MAAA,EAAQ,UAAA,EAAY,QAAQ,SAAA,kBAAW,IAAI,IAAA,EAAK,EAAG,CAAA;AAAA,gBAC9D,EAAE,UAAA,EAAY,CAAC,QAAA,EAAU,QAAQ,CAAA;AAAE,eACrC;AAGA,cAAA,MAAM,GAAA,CAAI,KAAA,CAAM,MAAA,CAAO,KAAA,EAAO;AAAA,gBAC5B,EAAA,EAAI,EAAE,EAAA,EAAI,SAAA,CAAU,IAAI,CAAC,KAAA,KAAU,KAAA,CAAM,EAAE,CAAA;AAAE,eAC9C,CAAA;AAED,cAAA,IAAA,IAAQ,CAAA;AAAA,YACV;AAAA,UACF;AAEA,UAAA,OAAO,SAAS,IAAA,CAAK,EAAE,MAAM,KAAA,EAAO,OAAA,CAAQ,QAAQ,CAAA;AAAA,QACtD;AAAA;AACF;AACF,GACF;AACF","file":"digests.cjs","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 { 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; owned by the preferences plugin.\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 },\n frequency: { type: \"string\", required: true },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\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,5 +1,5 @@
|
|
|
1
|
-
import { h as EasyPingPlugin } from '../plugin-
|
|
2
|
-
import '../adapter-
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-CHd2ZiGC.cjs';
|
|
2
|
+
import '../adapter-D3lTy-4E.cjs';
|
|
3
3
|
|
|
4
4
|
type DigestWindow = "daily" | "weekly";
|
|
5
5
|
type LocalMoment = {
|
|
@@ -9,22 +9,9 @@ type LocalMoment = {
|
|
|
9
9
|
/** 0 = Sunday. */
|
|
10
10
|
weekday: number;
|
|
11
11
|
};
|
|
12
|
-
/**
|
|
13
|
-
* Reads the wall clock in a recipient's zone.
|
|
14
|
-
*
|
|
15
|
-
* hourCycle h23 rather than hour12:false — the latter renders midnight as "24"
|
|
16
|
-
* in several locales, which silently makes the send-hour comparison wrong for
|
|
17
|
-
* one hour a day.
|
|
18
|
-
*/
|
|
12
|
+
/** Wall clock in the recipient's zone. hourCycle h23: hour12:false renders midnight as "24" in some locales. */
|
|
19
13
|
declare function localMoment(timezone: string, now: Date): LocalMoment;
|
|
20
|
-
/**
|
|
21
|
-
* Identifies the digest period a moment falls in.
|
|
22
|
-
*
|
|
23
|
-
* Scheduling keys off this string rather than bucketing users by UTC offset.
|
|
24
|
-
* Offsets shift twice a year, and getting that wrong sends someone two digests
|
|
25
|
-
* or none. A period key cannot: it is derived from the local calendar, so DST
|
|
26
|
-
* is already accounted for and a missed cron run simply catches up.
|
|
27
|
-
*/
|
|
14
|
+
/** The digest period a moment falls in, keyed off the local calendar so DST cannot double or skip a send. */
|
|
28
15
|
declare function periodKey(window: DigestWindow, moment: LocalMoment, sendWeekday: number): string;
|
|
29
16
|
/** Whether the send moment for the current period has arrived. */
|
|
30
17
|
declare function isDue(window: DigestWindow, moment: LocalMoment, sendHour: number, sendWeekday: number): boolean;
|
|
@@ -39,10 +26,7 @@ type DigestEntry = {
|
|
|
39
26
|
createdAt: Date;
|
|
40
27
|
};
|
|
41
28
|
type DigestsOptions = {
|
|
42
|
-
/**
|
|
43
|
-
* The notification type the composed digest is sent as. The app defines it
|
|
44
|
-
* with an email template taking `{ items }`.
|
|
45
|
-
*/
|
|
29
|
+
/** The type the composed digest is sent as; the app defines its template over `{ items }`. */
|
|
46
30
|
digestType?: string;
|
|
47
31
|
/** Local hour at which a digest becomes due. */
|
|
48
32
|
sendHour?: number;
|
|
@@ -53,16 +37,7 @@ type DigestsOptions = {
|
|
|
53
37
|
/** Safety valve on one cron run. */
|
|
54
38
|
maxUsersPerRun?: number;
|
|
55
39
|
};
|
|
56
|
-
/**
|
|
57
|
-
* Collects notifications a user has chosen to receive on a schedule, then
|
|
58
|
-
* sends them as one message.
|
|
59
|
-
*
|
|
60
|
-
* Deliberately not implemented with `defer`: deferring N emails to 9am sends N
|
|
61
|
-
* emails at 9am. A digest is one email containing N items, so the channel is
|
|
62
|
-
* skipped and the item is bucketed instead.
|
|
63
|
-
*
|
|
64
|
-
* Depends on `preferences` for the per-type frequency it reads.
|
|
65
|
-
*/
|
|
40
|
+
/** One email containing N items, rather than N deferred emails at 9am. Depends on `preferences` for the frequency. */
|
|
66
41
|
declare function digests(options?: DigestsOptions): EasyPingPlugin<"digests">;
|
|
67
42
|
|
|
68
43
|
export { type DigestEntry, type DigestWindow, type DigestsOptions, type LocalMoment, digests, isDue, localMoment, periodKey };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { h as EasyPingPlugin } from '../plugin-
|
|
2
|
-
import '../adapter-
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-Cg9usuUi.js';
|
|
2
|
+
import '../adapter-D3lTy-4E.js';
|
|
3
3
|
|
|
4
4
|
type DigestWindow = "daily" | "weekly";
|
|
5
5
|
type LocalMoment = {
|
|
@@ -9,22 +9,9 @@ type LocalMoment = {
|
|
|
9
9
|
/** 0 = Sunday. */
|
|
10
10
|
weekday: number;
|
|
11
11
|
};
|
|
12
|
-
/**
|
|
13
|
-
* Reads the wall clock in a recipient's zone.
|
|
14
|
-
*
|
|
15
|
-
* hourCycle h23 rather than hour12:false — the latter renders midnight as "24"
|
|
16
|
-
* in several locales, which silently makes the send-hour comparison wrong for
|
|
17
|
-
* one hour a day.
|
|
18
|
-
*/
|
|
12
|
+
/** Wall clock in the recipient's zone. hourCycle h23: hour12:false renders midnight as "24" in some locales. */
|
|
19
13
|
declare function localMoment(timezone: string, now: Date): LocalMoment;
|
|
20
|
-
/**
|
|
21
|
-
* Identifies the digest period a moment falls in.
|
|
22
|
-
*
|
|
23
|
-
* Scheduling keys off this string rather than bucketing users by UTC offset.
|
|
24
|
-
* Offsets shift twice a year, and getting that wrong sends someone two digests
|
|
25
|
-
* or none. A period key cannot: it is derived from the local calendar, so DST
|
|
26
|
-
* is already accounted for and a missed cron run simply catches up.
|
|
27
|
-
*/
|
|
14
|
+
/** The digest period a moment falls in, keyed off the local calendar so DST cannot double or skip a send. */
|
|
28
15
|
declare function periodKey(window: DigestWindow, moment: LocalMoment, sendWeekday: number): string;
|
|
29
16
|
/** Whether the send moment for the current period has arrived. */
|
|
30
17
|
declare function isDue(window: DigestWindow, moment: LocalMoment, sendHour: number, sendWeekday: number): boolean;
|
|
@@ -39,10 +26,7 @@ type DigestEntry = {
|
|
|
39
26
|
createdAt: Date;
|
|
40
27
|
};
|
|
41
28
|
type DigestsOptions = {
|
|
42
|
-
/**
|
|
43
|
-
* The notification type the composed digest is sent as. The app defines it
|
|
44
|
-
* with an email template taking `{ items }`.
|
|
45
|
-
*/
|
|
29
|
+
/** The type the composed digest is sent as; the app defines its template over `{ items }`. */
|
|
46
30
|
digestType?: string;
|
|
47
31
|
/** Local hour at which a digest becomes due. */
|
|
48
32
|
sendHour?: number;
|
|
@@ -53,16 +37,7 @@ type DigestsOptions = {
|
|
|
53
37
|
/** Safety valve on one cron run. */
|
|
54
38
|
maxUsersPerRun?: number;
|
|
55
39
|
};
|
|
56
|
-
/**
|
|
57
|
-
* Collects notifications a user has chosen to receive on a schedule, then
|
|
58
|
-
* sends them as one message.
|
|
59
|
-
*
|
|
60
|
-
* Deliberately not implemented with `defer`: deferring N emails to 9am sends N
|
|
61
|
-
* emails at 9am. A digest is one email containing N items, so the channel is
|
|
62
|
-
* skipped and the item is bucketed instead.
|
|
63
|
-
*
|
|
64
|
-
* Depends on `preferences` for the per-type frequency it reads.
|
|
65
|
-
*/
|
|
40
|
+
/** One email containing N items, rather than N deferred emails at 9am. Depends on `preferences` for the frequency. */
|
|
66
41
|
declare function digests(options?: DigestsOptions): EasyPingPlugin<"digests">;
|
|
67
42
|
|
|
68
43
|
export { type DigestEntry, type DigestWindow, type DigestsOptions, type LocalMoment, digests, isDue, localMoment, periodKey };
|
package/dist/plugins/digests.js
CHANGED
|
@@ -133,10 +133,7 @@ function digests(options = {}) {
|
|
|
133
133
|
},
|
|
134
134
|
routes: [
|
|
135
135
|
{
|
|
136
|
-
/**
|
|
137
|
-
* Point an hourly cron here. Hourly rather than daily because "9am"
|
|
138
|
-
* is 24 different UTC moments, and which ones shift with DST.
|
|
139
|
-
*/
|
|
136
|
+
/** Point an hourly cron here: "9am" is 24 different UTC moments that shift with DST. */
|
|
140
137
|
path: "/digests/cron",
|
|
141
138
|
method: "POST",
|
|
142
139
|
scope: { type: "machine" },
|
|
@@ -1 +1 @@
|
|
|
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/**\n * Reads the wall clock in a recipient's zone.\n *\n * hourCycle h23 rather than hour12:false — the latter renders midnight as \"24\"\n * in several locales, which silently makes the send-hour comparison wrong for\n * one hour a day.\n */\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/**\n * Identifies the digest period a moment falls in.\n *\n * Scheduling keys off this string rather than bucketing users by UTC offset.\n * Offsets shift twice a year, and getting that wrong sends someone two digests\n * or none. A period key cannot: it is derived from the local calendar, so DST\n * is already accounted for and a missed cron run simply catches up.\n */\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 { 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 /**\n * The notification type the composed digest is sent as. The app defines it\n * with an email template taking `{ items }`.\n */\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/**\n * Collects notifications a user has chosen to receive on a schedule, then\n * sends them as one message.\n *\n * Deliberately not implemented with `defer`: deferring N emails to 9am sends N\n * emails at 9am. A digest is one email containing N items, so the channel is\n * skipped and the item is bucketed instead.\n *\n * Depends on `preferences` for the per-type frequency it reads.\n */\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; owned by the preferences plugin.\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 },\n frequency: { type: \"string\", required: true },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\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 /**\n * Point an hourly cron here. Hourly rather than daily because \"9am\"\n * is 24 different UTC moments, and which ones shift with DST.\n */\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 // The schedule is idempotent on this comparison, not on timing:\n // a cron that runs twice in an hour 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, so a crash re-sends rather\n // than dropping the batch.\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
|
+
{"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 { 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; owned by the preferences plugin.\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 },\n frequency: { type: \"string\", required: true },\n },\n primaryKey: [\"userId\", \"type\", \"channel\"],\n },\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"]}
|