hybridq 0.1.1

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 ADDED
@@ -0,0 +1,165 @@
1
+ # hybridq
2
+
3
+ A lightweight, **embedded hybrid background worker** for serverless. Run safe,
4
+ batched, deferred tasks directly inside Vercel / Next.js / Cloudflare Workers /
5
+ AWS Lambda — **no persistent worker server**. Incoming traffic ("hybrid
6
+ triggers") drains the queue, with strict self-limiting budgets so you never blow
7
+ past a function timeout.
8
+
9
+ ```bash
10
+ pnpm add hybridq
11
+ # optional drivers (pick one)
12
+ pnpm add @upstash/redis # edge / connectionless
13
+ pnpm add ioredis # node TCP
14
+ ```
15
+
16
+ ## How it works
17
+
18
+ 1. **Producers** call `queue.enqueue(payload)` from any API route.
19
+ 2. Jobs live in a **pluggable store** (`MemoryAdapter` for dev, `RedisAdapter`
20
+ for prod) — serverless is stateless, so the queue must be external.
21
+ 3. **Triggers** drain the queue: either piggy-backing on normal inbound requests
22
+ (`withTrigger`) or a dedicated authenticated endpoint (`createTriggerEndpoint`)
23
+ that your browser or a cron pings.
24
+ 4. Every run respects a **budget** (`maxJobsPerTrigger`, `maxExecutionTimeMs`,
25
+ optional `maxCpuBudgetPct`). When the budget is hit, unprocessed jobs are
26
+ released back and the function returns cleanly.
27
+
28
+ Crash-safety is built in: claimed jobs hold a short **lease**; if the function
29
+ dies mid-run, the lease lapses and the work is reclaimed — nothing is lost.
30
+
31
+ ## Quick start (local / dev)
32
+
33
+ ```ts
34
+ import { defineQueue, MemoryAdapter } from "hybridq/server";
35
+
36
+ const queue = defineQueue<{ to: string }>({
37
+ name: "emails",
38
+ adapter: new MemoryAdapter(),
39
+ budget: { maxJobsPerTrigger: 25, maxExecutionTimeMs: 8000 },
40
+ });
41
+
42
+ await queue.enqueue({ to: "user@example.com" });
43
+
44
+ const report = await queue.process(async (job) => {
45
+ await sendEmail(job.payload.to);
46
+ });
47
+ // { processed, failed, released, stoppedBy, elapsedMs }
48
+ ```
49
+
50
+ ## Production (Upstash Redis + encryption + lock)
51
+
52
+ ```ts
53
+ import { Redis } from "@upstash/redis";
54
+ import {
55
+ defineQueue,
56
+ RedisAdapter,
57
+ fromUpstash,
58
+ RedisLock,
59
+ createPayloadCipher,
60
+ } from "hybridq/server";
61
+
62
+ const driver = fromUpstash(Redis.fromEnv());
63
+ const cipher = createPayloadCipher(process.env.HYBRIDQ_ENC_KEY); // payload-at-rest
64
+
65
+ export const queue = defineQueue<{ email: string }>({
66
+ name: "emails",
67
+ adapter: new RedisAdapter(driver, { cipher }),
68
+ lock: new RedisLock(driver), // single-flight: no double-processing
69
+ budget: { maxJobsPerTrigger: 50, maxExecutionTimeMs: 9000 },
70
+ });
71
+ ```
72
+
73
+ For `ioredis`, reuse one socket across invocations:
74
+
75
+ ```ts
76
+ import IORedis from "ioredis";
77
+ import { fromIORedis, getSharedIORedis } from "hybridq/server";
78
+
79
+ const driver = getSharedIORedis(() =>
80
+ fromIORedis(new IORedis(process.env.REDIS_URL!)),
81
+ );
82
+ ```
83
+
84
+ ## Hybrid trigger inside a Next.js route handler
85
+
86
+ ```ts
87
+ // app/api/orders/route.ts
88
+ import { withTrigger } from "hybridq/server";
89
+ import { queue } from "@/lib/queue";
90
+
91
+ export const POST = withTrigger(
92
+ async (req) => {
93
+ await queue.enqueue({ email: "buyer@example.com" });
94
+ return Response.json({ ok: true }); // user gets this immediately…
95
+ },
96
+ {
97
+ queue,
98
+ handler: async (job) => sendReceipt(job.payload.email),
99
+ // …draining happens AFTER the response, on the same warm function:
100
+ // ctx is Cloudflare's ExecutionContext; on Vercel pass `{ waitUntil }`.
101
+ skipIfEmpty: true,
102
+ },
103
+ );
104
+ ```
105
+
106
+ ## Dedicated authenticated trigger endpoint
107
+
108
+ ```ts
109
+ // app/api/_worker/route.ts
110
+ import { createTriggerEndpoint } from "hybridq/server";
111
+ import { queue } from "@/lib/queue";
112
+
113
+ export const POST = createTriggerEndpoint({
114
+ queue,
115
+ handler: async (job) => sendReceipt(job.payload.email),
116
+ auth: {
117
+ secret: process.env.WORKER_SECRET, // X-Worker-Trigger-Secret, or…
118
+ hmacSecret: process.env.WORKER_HMAC, // short-lived signed tokens
119
+ },
120
+ });
121
+ ```
122
+
123
+ ## Browser ambient triggers
124
+
125
+ The browser never holds the worker secret. Mint a short-lived HMAC token on the
126
+ server and inject it into the page; the client just forwards it.
127
+
128
+ ```ts
129
+ // server: mint a token for the page
130
+ import { signTrigger } from "hybridq/server";
131
+ const token = signTrigger(process.env.WORKER_HMAC!, "/api/_worker");
132
+ ```
133
+
134
+ ```ts
135
+ // client: ping on activity + during idle time, throttled
136
+ import { startTriggerClient } from "hybridq/client";
137
+
138
+ const trigger = startTriggerClient({
139
+ endpoint: "/api/_worker",
140
+ token, // from the server-rendered page
141
+ throttleMs: 5000,
142
+ useIdleCallback: true,
143
+ });
144
+ // trigger.stop() on SPA unmount
145
+ ```
146
+
147
+ ## Security
148
+
149
+ | Concern | Mechanism |
150
+ | --- | --- |
151
+ | Trigger abuse / DDoS | Pre-shared `X-Worker-Trigger-Secret` (constant-time compare) **or** replay-resistant HMAC tokens (`signTrigger`/`verifyTrigger`). |
152
+ | Sensitive payloads in Redis | Optional AES-256-GCM `createPayloadCipher` — payloads are sealed before they touch the store. |
153
+ | Duplicate processing | `RedisLock` (SET NX PX + compare-and-delete) gives single-flight drains; lease TTLs auto-recover crashes. |
154
+
155
+ ## API surface
156
+
157
+ - `hybridq/server`: `defineQueue` / `Queue`, `MemoryAdapter`, `RedisAdapter`
158
+ (`fromUpstash`, `fromIORedis`, `getSharedIORedis`), `drain`, `RedisLock` /
159
+ `MemoryLock`, `withTrigger`, `createTriggerEndpoint`, `authorizeTrigger`,
160
+ `createPayloadCipher`, `signTrigger`, `verifyTrigger`, plus all types.
161
+ - `hybridq/client`: `startTriggerClient`.
162
+
163
+ ## License
164
+
165
+ MIT
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ // src/client/trigger.ts
4
+ var DEFAULT_EVENTS = ["click", "keydown", "visibilitychange"];
5
+ function resolveToken(token) {
6
+ return typeof token === "function" ? token() : token;
7
+ }
8
+ function startTriggerClient(opts) {
9
+ const noop = { ping() {
10
+ }, stop() {
11
+ } };
12
+ if (typeof window === "undefined" || typeof document === "undefined") {
13
+ return noop;
14
+ }
15
+ const {
16
+ endpoint,
17
+ token,
18
+ throttleMs = 5e3,
19
+ events = DEFAULT_EVENTS,
20
+ useIdleCallback = true,
21
+ idleIntervalMs = 3e4,
22
+ pingOnHide = true
23
+ } = opts;
24
+ let lastPing = 0;
25
+ let stopped = false;
26
+ let idleTimer;
27
+ const send = (useBeacon) => {
28
+ const tok = resolveToken(token);
29
+ const headers = { "content-type": "text/plain" };
30
+ if (tok) headers["x-worker-trigger-token"] = tok;
31
+ if (useBeacon && !tok && typeof navigator.sendBeacon === "function") {
32
+ navigator.sendBeacon(endpoint);
33
+ return;
34
+ }
35
+ void fetch(endpoint, {
36
+ method: "POST",
37
+ headers,
38
+ // keepalive lets the request outlive a navigation.
39
+ keepalive: true,
40
+ credentials: "same-origin",
41
+ body: ""
42
+ }).catch(() => {
43
+ });
44
+ };
45
+ const ping = (useBeacon = false) => {
46
+ if (stopped) return;
47
+ const now = Date.now();
48
+ if (now - lastPing < throttleMs) return;
49
+ lastPing = now;
50
+ send(useBeacon);
51
+ };
52
+ const onEvent = () => {
53
+ if (document.visibilityState === "hidden") return;
54
+ ping(false);
55
+ };
56
+ for (const ev of events) {
57
+ document.addEventListener(ev, onEvent, { passive: true });
58
+ }
59
+ const onHide = () => {
60
+ if (pingOnHide && document.visibilityState === "hidden") {
61
+ lastPing = Date.now();
62
+ send(true);
63
+ }
64
+ };
65
+ if (pingOnHide) document.addEventListener("visibilitychange", onHide);
66
+ const ric = typeof requestIdleCallback === "function" ? requestIdleCallback : void 0;
67
+ const scheduleIdle = () => {
68
+ if (stopped || !useIdleCallback) return;
69
+ idleTimer = setTimeout(() => {
70
+ const run = () => {
71
+ if (document.visibilityState === "visible") ping(false);
72
+ scheduleIdle();
73
+ };
74
+ if (ric) ric(run, { timeout: 1e3 });
75
+ else run();
76
+ }, idleIntervalMs);
77
+ };
78
+ scheduleIdle();
79
+ return {
80
+ ping: () => ping(false),
81
+ stop: () => {
82
+ stopped = true;
83
+ for (const ev of events) document.removeEventListener(ev, onEvent);
84
+ if (pingOnHide) document.removeEventListener("visibilitychange", onHide);
85
+ if (idleTimer) clearTimeout(idleTimer);
86
+ }
87
+ };
88
+ }
89
+
90
+ exports.startTriggerClient = startTriggerClient;
91
+ //# sourceMappingURL=index.cjs.map
92
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/client/trigger.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Browser-side ambient trigger.\n *\n * Turns ordinary user activity into safe, throttled pings that nudge the\n * serverless worker to drain its queue. Strictly browser-only: no node\n * builtins, so it tree-shakes into a tiny client bundle.\n *\n * Security model: the browser NEVER holds the worker's real secret. Instead the\n * server mints a short-lived HMAC token (see server `signTrigger`) and injects\n * it into the page; the client just forwards it. Same-origin requests can also\n * rely on the session cookie, in which case no token is needed.\n */\n\nexport interface TriggerClientOptions {\n /** Endpoint created with `createTriggerEndpoint` (e.g. \"/api/_worker\"). */\n endpoint: string;\n /**\n * Short-lived signed token minted server-side and embedded in the page.\n * Sent as `X-Worker-Trigger-Token`. Omit for cookie-authenticated endpoints.\n */\n token?: string | (() => string | undefined);\n /** Minimum ms between pings regardless of activity. Default 5000. */\n throttleMs?: number;\n /** Fire on these DOM events. Default click + keydown + visibilitychange. */\n events?: string[];\n /** Also ping during browser idle time via requestIdleCallback. Default true. */\n useIdleCallback?: boolean;\n /** Idle poll cadence in ms when nothing else fires. Default 30000. */\n idleIntervalMs?: number;\n /** Send a final ping on page hide so tail jobs aren't stranded. Default true. */\n pingOnHide?: boolean;\n}\n\nexport interface TriggerHandle {\n /** Force a ping immediately (respects throttle). */\n ping(): void;\n /** Detach all listeners and stop idle polling. */\n stop(): void;\n}\n\nconst DEFAULT_EVENTS = [\"click\", \"keydown\", \"visibilitychange\"];\n\nfunction resolveToken(\n token: TriggerClientOptions[\"token\"],\n): string | undefined {\n return typeof token === \"function\" ? token() : token;\n}\n\n/**\n * Start ambient triggering. Returns a handle to stop it (e.g. on SPA unmount).\n * No-ops gracefully in non-browser environments (SSR safe).\n */\nexport function startTriggerClient(\n opts: TriggerClientOptions,\n): TriggerHandle {\n const noop: TriggerHandle = { ping() {}, stop() {} };\n if (typeof window === \"undefined\" || typeof document === \"undefined\") {\n return noop; // SSR / non-browser: do nothing.\n }\n\n const {\n endpoint,\n token,\n throttleMs = 5000,\n events = DEFAULT_EVENTS,\n useIdleCallback = true,\n idleIntervalMs = 30_000,\n pingOnHide = true,\n } = opts;\n\n let lastPing = 0;\n let stopped = false;\n let idleTimer: ReturnType<typeof setTimeout> | undefined;\n\n const send = (useBeacon: boolean): void => {\n const tok = resolveToken(token);\n const headers: Record<string, string> = { \"content-type\": \"text/plain\" };\n if (tok) headers[\"x-worker-trigger-token\"] = tok;\n\n // sendBeacon survives page unload but can't set headers; only use it on\n // hide and when no token header is required (cookie-auth path).\n if (useBeacon && !tok && typeof navigator.sendBeacon === \"function\") {\n navigator.sendBeacon(endpoint);\n return;\n }\n\n void fetch(endpoint, {\n method: \"POST\",\n headers,\n // keepalive lets the request outlive a navigation.\n keepalive: true,\n credentials: \"same-origin\",\n body: \"\",\n }).catch(() => {\n /* triggering is best-effort; ignore network errors */\n });\n };\n\n const ping = (useBeacon = false): void => {\n if (stopped) return;\n const now = Date.now();\n if (now - lastPing < throttleMs) return; // throttle\n lastPing = now;\n send(useBeacon);\n };\n\n const onEvent = (): void => {\n if (document.visibilityState === \"hidden\") return; // handled by hide path\n ping(false);\n };\n\n for (const ev of events) {\n document.addEventListener(ev, onEvent, { passive: true });\n }\n\n const onHide = (): void => {\n if (pingOnHide && document.visibilityState === \"hidden\") {\n // Bypass throttle on hide: this is our last chance to flush.\n lastPing = Date.now();\n send(true);\n }\n };\n if (pingOnHide) document.addEventListener(\"visibilitychange\", onHide);\n\n // Idle loop: only pings when the tab is visible to avoid background spam.\n const ric: typeof requestIdleCallback | undefined =\n typeof requestIdleCallback === \"function\"\n ? requestIdleCallback\n : undefined;\n\n const scheduleIdle = (): void => {\n if (stopped || !useIdleCallback) return;\n idleTimer = setTimeout(() => {\n const run = () => {\n if (document.visibilityState === \"visible\") ping(false);\n scheduleIdle();\n };\n if (ric) ric(run, { timeout: 1000 });\n else run();\n }, idleIntervalMs);\n };\n scheduleIdle();\n\n return {\n ping: () => ping(false),\n stop: () => {\n stopped = true;\n for (const ev of events) document.removeEventListener(ev, onEvent);\n if (pingOnHide) document.removeEventListener(\"visibilitychange\", onHide);\n if (idleTimer) clearTimeout(idleTimer);\n },\n };\n}\n"]}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Browser-side ambient trigger.
3
+ *
4
+ * Turns ordinary user activity into safe, throttled pings that nudge the
5
+ * serverless worker to drain its queue. Strictly browser-only: no node
6
+ * builtins, so it tree-shakes into a tiny client bundle.
7
+ *
8
+ * Security model: the browser NEVER holds the worker's real secret. Instead the
9
+ * server mints a short-lived HMAC token (see server `signTrigger`) and injects
10
+ * it into the page; the client just forwards it. Same-origin requests can also
11
+ * rely on the session cookie, in which case no token is needed.
12
+ */
13
+ interface TriggerClientOptions {
14
+ /** Endpoint created with `createTriggerEndpoint` (e.g. "/api/_worker"). */
15
+ endpoint: string;
16
+ /**
17
+ * Short-lived signed token minted server-side and embedded in the page.
18
+ * Sent as `X-Worker-Trigger-Token`. Omit for cookie-authenticated endpoints.
19
+ */
20
+ token?: string | (() => string | undefined);
21
+ /** Minimum ms between pings regardless of activity. Default 5000. */
22
+ throttleMs?: number;
23
+ /** Fire on these DOM events. Default click + keydown + visibilitychange. */
24
+ events?: string[];
25
+ /** Also ping during browser idle time via requestIdleCallback. Default true. */
26
+ useIdleCallback?: boolean;
27
+ /** Idle poll cadence in ms when nothing else fires. Default 30000. */
28
+ idleIntervalMs?: number;
29
+ /** Send a final ping on page hide so tail jobs aren't stranded. Default true. */
30
+ pingOnHide?: boolean;
31
+ }
32
+ interface TriggerHandle {
33
+ /** Force a ping immediately (respects throttle). */
34
+ ping(): void;
35
+ /** Detach all listeners and stop idle polling. */
36
+ stop(): void;
37
+ }
38
+ /**
39
+ * Start ambient triggering. Returns a handle to stop it (e.g. on SPA unmount).
40
+ * No-ops gracefully in non-browser environments (SSR safe).
41
+ */
42
+ declare function startTriggerClient(opts: TriggerClientOptions): TriggerHandle;
43
+
44
+ export { type TriggerClientOptions, type TriggerHandle, startTriggerClient };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Browser-side ambient trigger.
3
+ *
4
+ * Turns ordinary user activity into safe, throttled pings that nudge the
5
+ * serverless worker to drain its queue. Strictly browser-only: no node
6
+ * builtins, so it tree-shakes into a tiny client bundle.
7
+ *
8
+ * Security model: the browser NEVER holds the worker's real secret. Instead the
9
+ * server mints a short-lived HMAC token (see server `signTrigger`) and injects
10
+ * it into the page; the client just forwards it. Same-origin requests can also
11
+ * rely on the session cookie, in which case no token is needed.
12
+ */
13
+ interface TriggerClientOptions {
14
+ /** Endpoint created with `createTriggerEndpoint` (e.g. "/api/_worker"). */
15
+ endpoint: string;
16
+ /**
17
+ * Short-lived signed token minted server-side and embedded in the page.
18
+ * Sent as `X-Worker-Trigger-Token`. Omit for cookie-authenticated endpoints.
19
+ */
20
+ token?: string | (() => string | undefined);
21
+ /** Minimum ms between pings regardless of activity. Default 5000. */
22
+ throttleMs?: number;
23
+ /** Fire on these DOM events. Default click + keydown + visibilitychange. */
24
+ events?: string[];
25
+ /** Also ping during browser idle time via requestIdleCallback. Default true. */
26
+ useIdleCallback?: boolean;
27
+ /** Idle poll cadence in ms when nothing else fires. Default 30000. */
28
+ idleIntervalMs?: number;
29
+ /** Send a final ping on page hide so tail jobs aren't stranded. Default true. */
30
+ pingOnHide?: boolean;
31
+ }
32
+ interface TriggerHandle {
33
+ /** Force a ping immediately (respects throttle). */
34
+ ping(): void;
35
+ /** Detach all listeners and stop idle polling. */
36
+ stop(): void;
37
+ }
38
+ /**
39
+ * Start ambient triggering. Returns a handle to stop it (e.g. on SPA unmount).
40
+ * No-ops gracefully in non-browser environments (SSR safe).
41
+ */
42
+ declare function startTriggerClient(opts: TriggerClientOptions): TriggerHandle;
43
+
44
+ export { type TriggerClientOptions, type TriggerHandle, startTriggerClient };
@@ -0,0 +1,90 @@
1
+ // src/client/trigger.ts
2
+ var DEFAULT_EVENTS = ["click", "keydown", "visibilitychange"];
3
+ function resolveToken(token) {
4
+ return typeof token === "function" ? token() : token;
5
+ }
6
+ function startTriggerClient(opts) {
7
+ const noop = { ping() {
8
+ }, stop() {
9
+ } };
10
+ if (typeof window === "undefined" || typeof document === "undefined") {
11
+ return noop;
12
+ }
13
+ const {
14
+ endpoint,
15
+ token,
16
+ throttleMs = 5e3,
17
+ events = DEFAULT_EVENTS,
18
+ useIdleCallback = true,
19
+ idleIntervalMs = 3e4,
20
+ pingOnHide = true
21
+ } = opts;
22
+ let lastPing = 0;
23
+ let stopped = false;
24
+ let idleTimer;
25
+ const send = (useBeacon) => {
26
+ const tok = resolveToken(token);
27
+ const headers = { "content-type": "text/plain" };
28
+ if (tok) headers["x-worker-trigger-token"] = tok;
29
+ if (useBeacon && !tok && typeof navigator.sendBeacon === "function") {
30
+ navigator.sendBeacon(endpoint);
31
+ return;
32
+ }
33
+ void fetch(endpoint, {
34
+ method: "POST",
35
+ headers,
36
+ // keepalive lets the request outlive a navigation.
37
+ keepalive: true,
38
+ credentials: "same-origin",
39
+ body: ""
40
+ }).catch(() => {
41
+ });
42
+ };
43
+ const ping = (useBeacon = false) => {
44
+ if (stopped) return;
45
+ const now = Date.now();
46
+ if (now - lastPing < throttleMs) return;
47
+ lastPing = now;
48
+ send(useBeacon);
49
+ };
50
+ const onEvent = () => {
51
+ if (document.visibilityState === "hidden") return;
52
+ ping(false);
53
+ };
54
+ for (const ev of events) {
55
+ document.addEventListener(ev, onEvent, { passive: true });
56
+ }
57
+ const onHide = () => {
58
+ if (pingOnHide && document.visibilityState === "hidden") {
59
+ lastPing = Date.now();
60
+ send(true);
61
+ }
62
+ };
63
+ if (pingOnHide) document.addEventListener("visibilitychange", onHide);
64
+ const ric = typeof requestIdleCallback === "function" ? requestIdleCallback : void 0;
65
+ const scheduleIdle = () => {
66
+ if (stopped || !useIdleCallback) return;
67
+ idleTimer = setTimeout(() => {
68
+ const run = () => {
69
+ if (document.visibilityState === "visible") ping(false);
70
+ scheduleIdle();
71
+ };
72
+ if (ric) ric(run, { timeout: 1e3 });
73
+ else run();
74
+ }, idleIntervalMs);
75
+ };
76
+ scheduleIdle();
77
+ return {
78
+ ping: () => ping(false),
79
+ stop: () => {
80
+ stopped = true;
81
+ for (const ev of events) document.removeEventListener(ev, onEvent);
82
+ if (pingOnHide) document.removeEventListener("visibilitychange", onHide);
83
+ if (idleTimer) clearTimeout(idleTimer);
84
+ }
85
+ };
86
+ }
87
+
88
+ export { startTriggerClient };
89
+ //# sourceMappingURL=index.js.map
90
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/client/trigger.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Browser-side ambient trigger.\n *\n * Turns ordinary user activity into safe, throttled pings that nudge the\n * serverless worker to drain its queue. Strictly browser-only: no node\n * builtins, so it tree-shakes into a tiny client bundle.\n *\n * Security model: the browser NEVER holds the worker's real secret. Instead the\n * server mints a short-lived HMAC token (see server `signTrigger`) and injects\n * it into the page; the client just forwards it. Same-origin requests can also\n * rely on the session cookie, in which case no token is needed.\n */\n\nexport interface TriggerClientOptions {\n /** Endpoint created with `createTriggerEndpoint` (e.g. \"/api/_worker\"). */\n endpoint: string;\n /**\n * Short-lived signed token minted server-side and embedded in the page.\n * Sent as `X-Worker-Trigger-Token`. Omit for cookie-authenticated endpoints.\n */\n token?: string | (() => string | undefined);\n /** Minimum ms between pings regardless of activity. Default 5000. */\n throttleMs?: number;\n /** Fire on these DOM events. Default click + keydown + visibilitychange. */\n events?: string[];\n /** Also ping during browser idle time via requestIdleCallback. Default true. */\n useIdleCallback?: boolean;\n /** Idle poll cadence in ms when nothing else fires. Default 30000. */\n idleIntervalMs?: number;\n /** Send a final ping on page hide so tail jobs aren't stranded. Default true. */\n pingOnHide?: boolean;\n}\n\nexport interface TriggerHandle {\n /** Force a ping immediately (respects throttle). */\n ping(): void;\n /** Detach all listeners and stop idle polling. */\n stop(): void;\n}\n\nconst DEFAULT_EVENTS = [\"click\", \"keydown\", \"visibilitychange\"];\n\nfunction resolveToken(\n token: TriggerClientOptions[\"token\"],\n): string | undefined {\n return typeof token === \"function\" ? token() : token;\n}\n\n/**\n * Start ambient triggering. Returns a handle to stop it (e.g. on SPA unmount).\n * No-ops gracefully in non-browser environments (SSR safe).\n */\nexport function startTriggerClient(\n opts: TriggerClientOptions,\n): TriggerHandle {\n const noop: TriggerHandle = { ping() {}, stop() {} };\n if (typeof window === \"undefined\" || typeof document === \"undefined\") {\n return noop; // SSR / non-browser: do nothing.\n }\n\n const {\n endpoint,\n token,\n throttleMs = 5000,\n events = DEFAULT_EVENTS,\n useIdleCallback = true,\n idleIntervalMs = 30_000,\n pingOnHide = true,\n } = opts;\n\n let lastPing = 0;\n let stopped = false;\n let idleTimer: ReturnType<typeof setTimeout> | undefined;\n\n const send = (useBeacon: boolean): void => {\n const tok = resolveToken(token);\n const headers: Record<string, string> = { \"content-type\": \"text/plain\" };\n if (tok) headers[\"x-worker-trigger-token\"] = tok;\n\n // sendBeacon survives page unload but can't set headers; only use it on\n // hide and when no token header is required (cookie-auth path).\n if (useBeacon && !tok && typeof navigator.sendBeacon === \"function\") {\n navigator.sendBeacon(endpoint);\n return;\n }\n\n void fetch(endpoint, {\n method: \"POST\",\n headers,\n // keepalive lets the request outlive a navigation.\n keepalive: true,\n credentials: \"same-origin\",\n body: \"\",\n }).catch(() => {\n /* triggering is best-effort; ignore network errors */\n });\n };\n\n const ping = (useBeacon = false): void => {\n if (stopped) return;\n const now = Date.now();\n if (now - lastPing < throttleMs) return; // throttle\n lastPing = now;\n send(useBeacon);\n };\n\n const onEvent = (): void => {\n if (document.visibilityState === \"hidden\") return; // handled by hide path\n ping(false);\n };\n\n for (const ev of events) {\n document.addEventListener(ev, onEvent, { passive: true });\n }\n\n const onHide = (): void => {\n if (pingOnHide && document.visibilityState === \"hidden\") {\n // Bypass throttle on hide: this is our last chance to flush.\n lastPing = Date.now();\n send(true);\n }\n };\n if (pingOnHide) document.addEventListener(\"visibilitychange\", onHide);\n\n // Idle loop: only pings when the tab is visible to avoid background spam.\n const ric: typeof requestIdleCallback | undefined =\n typeof requestIdleCallback === \"function\"\n ? requestIdleCallback\n : undefined;\n\n const scheduleIdle = (): void => {\n if (stopped || !useIdleCallback) return;\n idleTimer = setTimeout(() => {\n const run = () => {\n if (document.visibilityState === \"visible\") ping(false);\n scheduleIdle();\n };\n if (ric) ric(run, { timeout: 1000 });\n else run();\n }, idleIntervalMs);\n };\n scheduleIdle();\n\n return {\n ping: () => ping(false),\n stop: () => {\n stopped = true;\n for (const ev of events) document.removeEventListener(ev, onEvent);\n if (pingOnHide) document.removeEventListener(\"visibilitychange\", onHide);\n if (idleTimer) clearTimeout(idleTimer);\n },\n };\n}\n"]}