@hogsend/engine 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 +8 -0
- package/package.json +9 -7
- package/src/app.ts +1 -1
- package/src/buckets/registry-singleton.ts +5 -17
- package/src/container.ts +13 -5
- package/src/env.ts +7 -0
- package/src/index.ts +24 -0
- package/src/journeys/define-journey.ts +4 -2
- package/src/journeys/journey-context.ts +1 -2
- package/src/journeys/registry-singleton.ts +5 -17
- package/src/lib/alerting.ts +0 -4
- package/src/lib/analytics-singleton.ts +25 -0
- package/src/lib/boot.ts +166 -0
- package/src/lib/bucket-posthog-sync.ts +6 -4
- package/src/lib/email-service-types.ts +19 -10
- package/src/lib/email.ts +15 -13
- package/src/lib/mailer.ts +9 -9
- package/src/lib/notifications.ts +7 -8
- package/src/lib/posthog.ts +2 -4
- package/src/lib/singleton.ts +56 -0
- package/src/lib/tracked.ts +1 -1
- package/src/lib/tracking-events.ts +1 -1
- package/src/lib/worker-heartbeat.ts +78 -0
- package/src/routes/health.ts +16 -1
- package/src/worker.ts +28 -5
- package/src/workflows/check-alerts.ts +0 -1
- package/src/workflows/send-email.ts +35 -33
package/README.md
CHANGED
|
@@ -5,6 +5,14 @@ The Hogsend framework: `createApp`, `createHogsendClient`, `createWorker`,
|
|
|
5
5
|
built-in routes, and the registries. This is the public API surface consumers
|
|
6
6
|
build their lifecycle apps on top of.
|
|
7
7
|
|
|
8
|
+
The engine also re-exports the capability-provider contracts owned by
|
|
9
|
+
`@hogsend/core` — `EmailProvider` and `PostHogService` (plus their supporting
|
|
10
|
+
types) — so `@hogsend/engine` is the **canonical author import** for them when
|
|
11
|
+
writing a custom provider. (The contract-level `SendEmailOptions` is the one
|
|
12
|
+
exception: it stays on `@hogsend/core`, since the engine exports a different,
|
|
13
|
+
higher-level `SendEmailOptions`.) See
|
|
14
|
+
[docs/adr/0001-provider-boundary.md](https://github.com/dougwithseismic/hogsend/blob/main/docs/adr/0001-provider-boundary.md).
|
|
15
|
+
|
|
8
16
|
Scaffold a consumer app with `pnpm dlx create-hogsend@latest`. The engine line
|
|
9
17
|
(`@hogsend/engine`, `@hogsend/db`, `@hogsend/core`) is versioned in lockstep so a
|
|
10
18
|
new engine migration always bumps the engine and DB together; the boot guard
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"types": "./src/index.ts",
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./src/index.ts",
|
|
15
|
-
"./worker": "./src/worker.ts"
|
|
15
|
+
"./worker": "./src/worker.ts",
|
|
16
|
+
"./package.json": "./package.json"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"src",
|
|
@@ -32,14 +33,15 @@
|
|
|
32
33
|
"hono": "^4.12.22",
|
|
33
34
|
"ioredis": "^5.10.1",
|
|
34
35
|
"papaparse": "^5.5.3",
|
|
36
|
+
"picocolors": "^1.1.1",
|
|
35
37
|
"resend": "^6.12.3",
|
|
36
38
|
"winston": "^3.19.0",
|
|
37
39
|
"zod": "^4.4.3",
|
|
38
|
-
"@hogsend/core": "^0.
|
|
39
|
-
"@hogsend/db": "^0.
|
|
40
|
-
"@hogsend/email": "^0.
|
|
41
|
-
"@hogsend/plugin-posthog": "^0.
|
|
42
|
-
"@hogsend/plugin-resend": "^0.
|
|
40
|
+
"@hogsend/core": "^0.5.0",
|
|
41
|
+
"@hogsend/db": "^0.5.0",
|
|
42
|
+
"@hogsend/email": "^0.5.0",
|
|
43
|
+
"@hogsend/plugin-posthog": "^0.5.0",
|
|
44
|
+
"@hogsend/plugin-resend": "^0.5.0"
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
45
47
|
"@types/node": "^22.15.3",
|
package/src/app.ts
CHANGED
|
@@ -103,7 +103,7 @@ export function createApp(
|
|
|
103
103
|
// No-op when no built dist is present, so an unbuilt studio never crashes boot.
|
|
104
104
|
const studio = mountStudio(app);
|
|
105
105
|
if (studio.mounted) {
|
|
106
|
-
container.logger.
|
|
106
|
+
container.logger.debug(
|
|
107
107
|
`Studio mounted at /studio (dist: ${studio.distPath})`,
|
|
108
108
|
);
|
|
109
109
|
}
|
|
@@ -1,21 +1,9 @@
|
|
|
1
1
|
import type { BucketRegistry } from "@hogsend/core/registry";
|
|
2
|
+
import { createSingleton } from "../lib/singleton.js";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export function setBucketRegistry(registry: BucketRegistry): void {
|
|
6
|
-
_registry = registry;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function getBucketRegistrySingleton(): BucketRegistry {
|
|
10
|
-
if (!_registry) {
|
|
11
|
-
throw new Error(
|
|
12
|
-
"Bucket registry not initialized. Call setBucketRegistry() at startup.",
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
return _registry;
|
|
16
|
-
}
|
|
4
|
+
const singleton = createSingleton<BucketRegistry>("Bucket registry");
|
|
17
5
|
|
|
6
|
+
export const setBucketRegistry = singleton.set;
|
|
7
|
+
export const getBucketRegistrySingleton = singleton.get;
|
|
18
8
|
/** Reset the singleton — only for test cleanup. */
|
|
19
|
-
export
|
|
20
|
-
_registry = undefined;
|
|
21
|
-
}
|
|
9
|
+
export const resetBucketRegistry = singleton.reset;
|
package/src/container.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
-
import type { TimeZone } from "@hogsend/core";
|
|
2
|
+
import type { EmailProvider, PostHogService, TimeZone } from "@hogsend/core";
|
|
3
3
|
import type { BucketRegistry, JourneyRegistry } from "@hogsend/core/registry";
|
|
4
4
|
import type { SendWindow } from "@hogsend/core/schedule";
|
|
5
5
|
import {
|
|
@@ -9,11 +9,9 @@ import {
|
|
|
9
9
|
type JournalShape,
|
|
10
10
|
} from "@hogsend/db";
|
|
11
11
|
import type { TemplateRegistry } from "@hogsend/email";
|
|
12
|
-
import type { PostHogService } from "@hogsend/plugin-posthog";
|
|
13
12
|
import {
|
|
14
13
|
createResendClient,
|
|
15
14
|
createResendProvider,
|
|
16
|
-
type EmailProvider,
|
|
17
15
|
} from "@hogsend/plugin-resend";
|
|
18
16
|
import type { Resend } from "resend";
|
|
19
17
|
import type { DefinedBucket } from "./buckets/define-bucket.js";
|
|
@@ -22,6 +20,7 @@ import { env } from "./env.js";
|
|
|
22
20
|
import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
|
|
23
21
|
import type { DefinedJourney } from "./journeys/define-journey.js";
|
|
24
22
|
import { buildJourneyRegistry } from "./journeys/registry.js";
|
|
23
|
+
import { setAnalytics } from "./lib/analytics-singleton.js";
|
|
25
24
|
import { type Auth, createAuth } from "./lib/auth.js";
|
|
26
25
|
import { setEmailService } from "./lib/email.js";
|
|
27
26
|
import type {
|
|
@@ -251,8 +250,17 @@ export function createHogsendClient(
|
|
|
251
250
|
|
|
252
251
|
const analytics = opts.analytics ?? getPostHog();
|
|
253
252
|
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
// Expose the resolved analytics instance to the module-level task-execution
|
|
254
|
+
// sites that have no client reference (the journey durable task in
|
|
255
|
+
// define-journey, the bucket PostHog sync). `createHogsendClient` runs in both
|
|
256
|
+
// the API and worker, so this is installed before any worker task runs. May be
|
|
257
|
+
// undefined (no POSTHOG_API_KEY) — the reads stay no-ops.
|
|
258
|
+
setAnalytics(analytics);
|
|
259
|
+
|
|
260
|
+
// Counts are surfaced by the boot banner / structured ready log (lib/boot.ts);
|
|
261
|
+
// keep these at debug for non-boot contexts (tests, REPL, library use).
|
|
262
|
+
logger.debug(`Journey registry loaded: ${registry.count()} journeys`);
|
|
263
|
+
logger.debug(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
|
|
256
264
|
|
|
257
265
|
return {
|
|
258
266
|
env,
|
package/src/env.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { createEnv } from "@t3-oss/env-core";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The HTTP API contract version — surfaced in the OpenAPI document
|
|
6
|
+
* (`info.version`) and the `GET /v1/health` body. This is the WIRE contract
|
|
7
|
+
* version and is independent of the `@hogsend/engine` npm package version: bump
|
|
8
|
+
* it only on an API-breaking change (a new `/vN` route family), NOT on every
|
|
9
|
+
* package release. The `/v1` route prefix is hardcoded separately.
|
|
10
|
+
*/
|
|
4
11
|
export const API_VERSION = "0.0.1";
|
|
5
12
|
|
|
6
13
|
export const env = createEnv({
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
// Content (journeys, webhook sources, workflows) is injected into these
|
|
4
4
|
// factories by client app code; the engine never imports content.
|
|
5
5
|
|
|
6
|
+
// --- Capability-provider contracts (canonical origin: @hogsend/core) ---
|
|
7
|
+
// Email provider contract + analytics contract, re-exported so consumers can
|
|
8
|
+
// import them from `@hogsend/engine`. (`SendEmailOptions` is intentionally
|
|
9
|
+
// omitted here: the engine's public `SendEmailOptions` is the high-level
|
|
10
|
+
// journey-facing send options from `./lib/email.js`; the provider-contract
|
|
11
|
+
// `SendEmailOptions` remains available via `@hogsend/core`.)
|
|
12
|
+
export type {
|
|
13
|
+
BatchEmailItem,
|
|
14
|
+
CaptureOptions,
|
|
15
|
+
EmailProvider,
|
|
16
|
+
PostHogService,
|
|
17
|
+
SendResult,
|
|
18
|
+
WebhookEvent,
|
|
19
|
+
WebhookEventType,
|
|
20
|
+
WebhookHandlerMap,
|
|
21
|
+
} from "@hogsend/core";
|
|
6
22
|
// Core helpers used by content journeys (days/hours/minutes, condition + journey
|
|
7
23
|
// types) so content can import everything from `@hogsend/engine`.
|
|
8
24
|
export * from "@hogsend/core";
|
|
@@ -73,6 +89,14 @@ export {
|
|
|
73
89
|
type BatchedBackfillResult,
|
|
74
90
|
runBatchedBackfill,
|
|
75
91
|
} from "./lib/backfill.js";
|
|
92
|
+
// --- Boot output (engine-owned startup banner / structured ready log) ---
|
|
93
|
+
export {
|
|
94
|
+
type ApiReadyInfo,
|
|
95
|
+
getEngineVersion,
|
|
96
|
+
reportApiReady,
|
|
97
|
+
reportWorkerReady,
|
|
98
|
+
type WorkerReadyInfo,
|
|
99
|
+
} from "./lib/boot.js";
|
|
76
100
|
// --- Bucket transition emission (shared by real-time / cron / fast-expiry) ---
|
|
77
101
|
export {
|
|
78
102
|
type BucketTransitionSource,
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from "@hogsend/core/types";
|
|
8
8
|
import { contacts, journeyConfigs, journeyStates } from "@hogsend/db";
|
|
9
9
|
import { and, eq, inArray, notInArray } from "drizzle-orm";
|
|
10
|
+
import { getAnalytics } from "../lib/analytics-singleton.js";
|
|
10
11
|
import { getDb } from "../lib/db.js";
|
|
11
12
|
import {
|
|
12
13
|
checkEmailPreferences,
|
|
@@ -14,7 +15,6 @@ import {
|
|
|
14
15
|
} from "../lib/enrollment-guards.js";
|
|
15
16
|
import { hatchet } from "../lib/hatchet.js";
|
|
16
17
|
import { createLogger } from "../lib/logger.js";
|
|
17
|
-
import { getPostHog } from "../lib/posthog.js";
|
|
18
18
|
import { resolveTimezoneWithSource } from "../lib/timezone.js";
|
|
19
19
|
import { getClientScheduleDefaults } from "./client-defaults-singleton.js";
|
|
20
20
|
import { JOURNEY_EXECUTION_TIMEOUT } from "./constants.js";
|
|
@@ -131,7 +131,9 @@ export function defineJourney(options: {
|
|
|
131
131
|
journeyName: meta.name,
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
// The injected analytics instance (set by createHogsendClient). Same
|
|
135
|
+
// object as container.analytics; undefined when POSTHOG_API_KEY is unset.
|
|
136
|
+
const posthog = getAnalytics();
|
|
135
137
|
const scheduleDefaults = getClientScheduleDefaults();
|
|
136
138
|
|
|
137
139
|
// Resolve the user's timezone via the precedence chain (explicit is N/A
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
SleepCondition,
|
|
8
8
|
UserEventCondition,
|
|
9
9
|
} from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
10
|
-
import type { DurationObject } from "@hogsend/core";
|
|
10
|
+
import type { DurationObject, PostHogService } from "@hogsend/core";
|
|
11
11
|
import { durationToMs, evaluateEventCondition } from "@hogsend/core";
|
|
12
12
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
13
13
|
import {
|
|
@@ -26,7 +26,6 @@ import type {
|
|
|
26
26
|
WhenBuilder,
|
|
27
27
|
} from "@hogsend/core/types";
|
|
28
28
|
import { type Database, emailSends, journeyStates } from "@hogsend/db";
|
|
29
|
-
import type { PostHogService } from "@hogsend/plugin-posthog";
|
|
30
29
|
import { and, count, eq, max, notInArray } from "drizzle-orm";
|
|
31
30
|
import { checkEmailPreferences } from "../lib/enrollment-guards.js";
|
|
32
31
|
import { ingestEvent } from "../lib/ingestion.js";
|
|
@@ -1,21 +1,9 @@
|
|
|
1
1
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
2
|
+
import { createSingleton } from "../lib/singleton.js";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export function setJourneyRegistry(registry: JourneyRegistry): void {
|
|
6
|
-
_registry = registry;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function getJourneyRegistrySingleton(): JourneyRegistry {
|
|
10
|
-
if (!_registry) {
|
|
11
|
-
throw new Error(
|
|
12
|
-
"Journey registry not initialized. Call setJourneyRegistry() at startup.",
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
return _registry;
|
|
16
|
-
}
|
|
4
|
+
const singleton = createSingleton<JourneyRegistry>("Journey registry");
|
|
17
5
|
|
|
6
|
+
export const setJourneyRegistry = singleton.set;
|
|
7
|
+
export const getJourneyRegistrySingleton = singleton.get;
|
|
18
8
|
/** Reset the singleton — only for test cleanup. */
|
|
19
|
-
export
|
|
20
|
-
_registry = undefined;
|
|
21
|
-
}
|
|
9
|
+
export const resetJourneyRegistry = singleton.reset;
|
package/src/lib/alerting.ts
CHANGED
|
@@ -17,7 +17,6 @@ async function dispatchAlert(opts: {
|
|
|
17
17
|
rule: typeof alertRules.$inferSelect;
|
|
18
18
|
message: string;
|
|
19
19
|
payload: Record<string, unknown>;
|
|
20
|
-
resendApiKey?: string;
|
|
21
20
|
}): Promise<{ deliveryStatus: string; error?: string }> {
|
|
22
21
|
const config = opts.rule.channelConfig as Record<string, string>;
|
|
23
22
|
switch (opts.rule.channel) {
|
|
@@ -45,7 +44,6 @@ async function dispatchAlert(opts: {
|
|
|
45
44
|
to: config.to ?? "",
|
|
46
45
|
subject: `[Hogsend Alert] ${opts.rule.name}`,
|
|
47
46
|
body: `<p>${opts.message}</p><pre>${JSON.stringify(opts.payload, null, 2)}</pre>`,
|
|
48
|
-
resendApiKey: opts.resendApiKey ?? "",
|
|
49
47
|
});
|
|
50
48
|
return result.ok
|
|
51
49
|
? { deliveryStatus: "sent" }
|
|
@@ -62,7 +60,6 @@ async function dispatchAlert(opts: {
|
|
|
62
60
|
export async function checkAlertRules(opts: {
|
|
63
61
|
db: Database;
|
|
64
62
|
logger: Logger;
|
|
65
|
-
resendApiKey?: string;
|
|
66
63
|
}): Promise<void> {
|
|
67
64
|
const { db, logger } = opts;
|
|
68
65
|
|
|
@@ -174,7 +171,6 @@ export async function checkAlertRules(opts: {
|
|
|
174
171
|
rule,
|
|
175
172
|
message,
|
|
176
173
|
payload,
|
|
177
|
-
resendApiKey: opts.resendApiKey,
|
|
178
174
|
});
|
|
179
175
|
|
|
180
176
|
await Promise.all([
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PostHogService } from "@hogsend/core";
|
|
2
|
+
import { createOptionalSingleton } from "./singleton.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The injected analytics service, set once by `createHogsendClient` and read by
|
|
6
|
+
* the module-level task-execution sites that have no client reference of their
|
|
7
|
+
* own (the journey durable task in `define-journey`, the bucket PostHog sync).
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the journey/bucket-registry + client-schedule-defaults singletons:
|
|
10
|
+
* `createHogsendClient` runs in BOTH the API and worker processes, so by the
|
|
11
|
+
* time any worker task executes, the container has already installed the
|
|
12
|
+
* resolved `analytics` instance here — the SAME object exposed on
|
|
13
|
+
* `HogsendClient.analytics`. This makes a swapped `opts.analytics` actually
|
|
14
|
+
* load-bearing at those sites instead of falling back to the module singleton.
|
|
15
|
+
*
|
|
16
|
+
* `undefined` is a first-class value: when `POSTHOG_API_KEY` is unset the
|
|
17
|
+
* container resolves `analytics` to `undefined` and installs it here, so every
|
|
18
|
+
* read remains a no-op exactly as before — hence the optional singleton variant.
|
|
19
|
+
*/
|
|
20
|
+
const singleton = createOptionalSingleton<PostHogService>();
|
|
21
|
+
|
|
22
|
+
export const setAnalytics = singleton.set;
|
|
23
|
+
export const getAnalytics = singleton.get;
|
|
24
|
+
/** Reset the singleton — only for test cleanup. */
|
|
25
|
+
export const resetAnalytics = singleton.reset;
|
package/src/lib/boot.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import color from "picocolors";
|
|
3
|
+
import type { HogsendClient } from "../container.js";
|
|
4
|
+
import { API_VERSION } from "../env.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Engine-owned boot output. ONE place renders the "we're up" message for the
|
|
8
|
+
* API and the worker, so every scaffolded `create-hogsend` app gets the same
|
|
9
|
+
* polished startup for free — the entry points just call these.
|
|
10
|
+
*
|
|
11
|
+
* Two modes, picked from the environment (never a flag):
|
|
12
|
+
* - banner: a branded, minimal box (the `create-hogsend` look — magenta badge,
|
|
13
|
+
* ✓ checks, cyan links) printed straight to stdout. Only when stdout is a TTY
|
|
14
|
+
* AND `NODE_ENV === "development"`, i.e. an interactive `pnpm dev`.
|
|
15
|
+
* - structured: a single `logger.info("… ready", { … })` line. Everywhere else
|
|
16
|
+
* (production, CI, piped output, tests) so log scraping stays intact.
|
|
17
|
+
*
|
|
18
|
+
* The scattered `registry loaded` / `studio mounted` / `server running` lines
|
|
19
|
+
* are demoted to `debug`; this banner is the single source of truth on boot.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Last-resort value when the runtime manifest read below fails (e.g. a pruned
|
|
23
|
+
// node_modules behind a bundler). The read is authoritative in every normal dev
|
|
24
|
+
// run and deploy, so this is effectively never hit — `"unknown"` keeps it honest
|
|
25
|
+
// rather than risking a stale hard-coded version slipping into structured logs.
|
|
26
|
+
const FALLBACK_ENGINE_VERSION = "unknown";
|
|
27
|
+
|
|
28
|
+
// Conventional Vite dev-server origin for the Studio package (`pnpm dev` starts
|
|
29
|
+
// it). In production the Studio is served by the API at `${API_PUBLIC_URL}/studio`.
|
|
30
|
+
const STUDIO_DEV_URL = "http://localhost:5173";
|
|
31
|
+
const DOCS_URL = "https://docs.hogsend.com";
|
|
32
|
+
|
|
33
|
+
let cachedEngineVersion: string | undefined;
|
|
34
|
+
|
|
35
|
+
/** The running `@hogsend/engine` package version (e.g. "0.4.0"). */
|
|
36
|
+
export function getEngineVersion(): string {
|
|
37
|
+
if (cachedEngineVersion) return cachedEngineVersion;
|
|
38
|
+
try {
|
|
39
|
+
// Resolve the engine's own manifest from the consumer's module graph —
|
|
40
|
+
// correct under tsx (workspace symlink) and a bundled deploy alike. Needs
|
|
41
|
+
// `"./package.json"` in this package's `exports`.
|
|
42
|
+
const require = createRequire(import.meta.url);
|
|
43
|
+
const pkg = require("@hogsend/engine/package.json") as { version?: string };
|
|
44
|
+
cachedEngineVersion = pkg.version ?? FALLBACK_ENGINE_VERSION;
|
|
45
|
+
} catch {
|
|
46
|
+
cachedEngineVersion = FALLBACK_ENGINE_VERSION;
|
|
47
|
+
}
|
|
48
|
+
return cachedEngineVersion;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const BADGE = color.bgMagenta(color.black(" hogsend "));
|
|
52
|
+
|
|
53
|
+
/** Interactive `pnpm dev` in a real terminal — the only place the banner shows. */
|
|
54
|
+
function bannerMode(client: HogsendClient): boolean {
|
|
55
|
+
return Boolean(process.stdout.isTTY) && client.env.NODE_ENV === "development";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function plural(n: number, word: string): string {
|
|
59
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeBanner(lines: (string | null)[]): void {
|
|
63
|
+
const body = lines.filter((l): l is string => l !== null).join("\n");
|
|
64
|
+
process.stdout.write(`\n${body}\n\n`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ApiReadyInfo {
|
|
68
|
+
client: HogsendClient;
|
|
69
|
+
/** The port the HTTP server bound to. */
|
|
70
|
+
port: number;
|
|
71
|
+
/** Applied engine-track schema version, when the boot guard ran. */
|
|
72
|
+
schemaVersion?: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Render the API "ready" output (banner in dev TTY, structured log otherwise). */
|
|
76
|
+
export function reportApiReady(info: ApiReadyInfo): void {
|
|
77
|
+
const { client, port } = info;
|
|
78
|
+
const engineVersion = getEngineVersion();
|
|
79
|
+
const journeys = client.registry.count();
|
|
80
|
+
const buckets = client.bucketRegistry.count();
|
|
81
|
+
const templates = Object.keys(client.templates).length;
|
|
82
|
+
const localUrl = `http://localhost:${port}`;
|
|
83
|
+
|
|
84
|
+
if (!bannerMode(client)) {
|
|
85
|
+
client.logger.info("Hogsend API ready", {
|
|
86
|
+
engineVersion,
|
|
87
|
+
apiVersion: API_VERSION,
|
|
88
|
+
port,
|
|
89
|
+
url: client.env.API_PUBLIC_URL,
|
|
90
|
+
journeys,
|
|
91
|
+
buckets,
|
|
92
|
+
templates,
|
|
93
|
+
schema: info.schemaVersion ?? undefined,
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const dim = color.dim;
|
|
99
|
+
const ok = color.green("✓");
|
|
100
|
+
const loaded = [
|
|
101
|
+
plural(journeys, "journey"),
|
|
102
|
+
plural(buckets, "bucket"),
|
|
103
|
+
plural(templates, "template"),
|
|
104
|
+
].join(dim(" · "));
|
|
105
|
+
const label = (text: string) => dim(text.padEnd(7));
|
|
106
|
+
|
|
107
|
+
writeBanner([
|
|
108
|
+
`${BADGE} ${dim(`engine ${engineVersion} · api ${API_VERSION}`)}`,
|
|
109
|
+
"",
|
|
110
|
+
` ${ok} ${loaded}`,
|
|
111
|
+
info.schemaVersion
|
|
112
|
+
? ` ${ok} schema in sync ${dim(`(${info.schemaVersion})`)}`
|
|
113
|
+
: null,
|
|
114
|
+
"",
|
|
115
|
+
` ${label("API")}${color.cyan(localUrl)}`,
|
|
116
|
+
` ${label("Docs")}${color.cyan(`${localUrl}/docs`)}`,
|
|
117
|
+
` ${label("Studio")}${color.cyan(STUDIO_DEV_URL)}`,
|
|
118
|
+
` ${label("Guides")}${color.cyan(DOCS_URL)}`,
|
|
119
|
+
"",
|
|
120
|
+
` ${dim("Next")} fire a test event in ${color.cyan("Studio › Debug")} ${dim("·")} run the worker: ${color.cyan("pnpm worker:dev")}`,
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface WorkerReadyInfo {
|
|
125
|
+
client: HogsendClient;
|
|
126
|
+
journeyTasks: number;
|
|
127
|
+
bucketTasks: number;
|
|
128
|
+
builtinTasks: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Render the worker "ready" output (banner in dev TTY, structured log otherwise). */
|
|
132
|
+
export function reportWorkerReady(info: WorkerReadyInfo): void {
|
|
133
|
+
const { client, journeyTasks, bucketTasks, builtinTasks } = info;
|
|
134
|
+
const engineVersion = getEngineVersion();
|
|
135
|
+
const hatchetHost = client.env.HATCHET_CLIENT_HOST_PORT;
|
|
136
|
+
|
|
137
|
+
if (!bannerMode(client)) {
|
|
138
|
+
client.logger.info("Hogsend worker ready", {
|
|
139
|
+
engineVersion,
|
|
140
|
+
hatchet: hatchetHost,
|
|
141
|
+
namespace: client.env.HATCHET_CLIENT_NAMESPACE || undefined,
|
|
142
|
+
journeyTasks,
|
|
143
|
+
bucketTasks,
|
|
144
|
+
builtinTasks,
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const dim = color.dim;
|
|
150
|
+
const ok = color.green("✓");
|
|
151
|
+
const tasks = [
|
|
152
|
+
plural(journeyTasks, "journey task"),
|
|
153
|
+
plural(bucketTasks, "bucket task"),
|
|
154
|
+
plural(builtinTasks, "built-in task"),
|
|
155
|
+
].join(dim(" · "));
|
|
156
|
+
|
|
157
|
+
writeBanner([
|
|
158
|
+
`${BADGE} ${dim(`worker · engine ${engineVersion}`)}`,
|
|
159
|
+
"",
|
|
160
|
+
` ${ok} registered on Hatchet ${dim(`(${hatchetHost})`)}`,
|
|
161
|
+
` ${ok} ${tasks}`,
|
|
162
|
+
"",
|
|
163
|
+
` ${dim("Listening — journeys fire as events arrive.")}`,
|
|
164
|
+
` ${dim("Send one:")} ${color.cyan("POST /v1/ingest")} ${dim("· or Studio › Debug")}`,
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { BucketMeta } from "@hogsend/core";
|
|
2
|
+
import { getAnalytics } from "./analytics-singleton.js";
|
|
2
3
|
import type { Logger } from "./logger.js";
|
|
3
|
-
import { getPostHog } from "./posthog.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Optional PostHog person-property mirror for a bucket transition (Section 12).
|
|
7
7
|
*
|
|
8
8
|
* OFF BY DEFAULT — a no-op unless `meta.syncToPostHog === true`. Also a no-op
|
|
9
|
-
* without `POSTHOG_API_KEY` (
|
|
9
|
+
* without `POSTHOG_API_KEY` (the injected analytics is undefined), so self-host
|
|
10
10
|
* setups that omit PostHog silently do nothing — documented, not broken.
|
|
11
11
|
*
|
|
12
12
|
* On JOIN it `$set`s a boolean person property `true`; on LEAVE it `$unset`s the
|
|
@@ -32,8 +32,10 @@ export function syncBucketToPostHog(opts: {
|
|
|
32
32
|
|
|
33
33
|
if (!bucket.syncToPostHog) return;
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// The injected analytics instance (set by createHogsendClient). Same object as
|
|
36
|
+
// container.analytics; undefined when POSTHOG_API_KEY is unset.
|
|
37
|
+
const posthog = getAnalytics();
|
|
38
|
+
if (!posthog) return; // no analytics configured → silent no-op
|
|
37
39
|
|
|
38
40
|
const propertyKey =
|
|
39
41
|
bucket.postHogPropertyKey ?? `hogsend_bucket_${bucket.id}`;
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
BatchEmailItem,
|
|
3
|
+
DurationObject,
|
|
4
|
+
SendEmailOptions,
|
|
5
|
+
SendResult,
|
|
6
|
+
WebhookEventType,
|
|
7
|
+
WebhookHandlerMap,
|
|
8
|
+
} from "@hogsend/core";
|
|
2
9
|
import type {
|
|
3
10
|
EmailServiceRenderOptions,
|
|
4
11
|
EmailServiceRenderResult,
|
|
@@ -7,20 +14,22 @@ import type {
|
|
|
7
14
|
TemplateRegistry,
|
|
8
15
|
TemplateRegistryMap,
|
|
9
16
|
} from "@hogsend/email";
|
|
10
|
-
import type {
|
|
11
|
-
BatchEmailItem,
|
|
12
|
-
SendEmailOptions,
|
|
13
|
-
SendResult,
|
|
14
|
-
WebhookEventType,
|
|
15
|
-
WebhookHandlerMap,
|
|
16
|
-
} from "@hogsend/plugin-resend";
|
|
17
17
|
import type { Logger } from "./logger.js";
|
|
18
18
|
|
|
19
19
|
export type {
|
|
20
20
|
BatchEmailItem,
|
|
21
21
|
SendEmailOptions,
|
|
22
22
|
SendResult,
|
|
23
|
-
} from "@hogsend/
|
|
23
|
+
} from "@hogsend/core";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Input to the mailer's low-level {@link EmailService.sendRaw}: the provider
|
|
27
|
+
* contract's `SendEmailOptions`, but `from` is optional — the mailer resolves it
|
|
28
|
+
* from `config.defaultFrom` when absent (see `resolveFrom` in mailer.ts). The
|
|
29
|
+
* wire contract keeps `from` required because the provider always receives a
|
|
30
|
+
* resolved address.
|
|
31
|
+
*/
|
|
32
|
+
export type SendRawOptions = Omit<SendEmailOptions, "from"> & { from?: string };
|
|
24
33
|
|
|
25
34
|
// ---------------------------------------------------------------------------
|
|
26
35
|
// Tracked email (high-level API)
|
|
@@ -136,7 +145,7 @@ export interface EmailService {
|
|
|
136
145
|
options: EmailServiceSendOptions<K>,
|
|
137
146
|
): Promise<TrackedSendResult>;
|
|
138
147
|
|
|
139
|
-
sendRaw(options:
|
|
148
|
+
sendRaw(options: SendRawOptions): Promise<SendResult>;
|
|
140
149
|
|
|
141
150
|
sendBatch(options: { emails: BatchEmailItem[] }): Promise<{
|
|
142
151
|
results: SendResult[];
|
package/src/lib/email.ts
CHANGED
|
@@ -3,21 +3,23 @@ import type {
|
|
|
3
3
|
EmailService,
|
|
4
4
|
EmailServiceSendOptions,
|
|
5
5
|
} from "./email-service-types.js";
|
|
6
|
+
import { createSingleton } from "./singleton.js";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
const _service = createSingleton<EmailService>("Email service");
|
|
8
9
|
|
|
9
|
-
export
|
|
10
|
-
_service = service;
|
|
11
|
-
}
|
|
10
|
+
export const setEmailService = _service.set;
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The injected {@link EmailService} (set by `createHogsendClient` →
|
|
14
|
+
* `setEmailService`). Exposed so module-level task-execution sites with no
|
|
15
|
+
* client reference (the `send-email` Hatchet task, the alerting task) deliver
|
|
16
|
+
* through the same provider-backed mailer the container built, honoring a
|
|
17
|
+
* swapped provider instead of constructing a raw Resend client of their own.
|
|
18
|
+
* Throws if read before the container has installed the service — same
|
|
19
|
+
* guarantee as the journey/bucket registry singletons (the container always
|
|
20
|
+
* runs first in both the API and worker processes).
|
|
21
|
+
*/
|
|
22
|
+
export const getEmailService = _service.get;
|
|
21
23
|
|
|
22
24
|
export interface SendEmailOptions {
|
|
23
25
|
to: string;
|
|
@@ -37,7 +39,7 @@ export interface SendEmailResult {
|
|
|
37
39
|
export async function sendEmail(
|
|
38
40
|
opts: SendEmailOptions,
|
|
39
41
|
): Promise<SendEmailResult> {
|
|
40
|
-
const service =
|
|
42
|
+
const service = getEmailService();
|
|
41
43
|
|
|
42
44
|
let unsubscribeUrl: string | undefined;
|
|
43
45
|
if (process.env.API_PUBLIC_URL && process.env.BETTER_AUTH_SECRET) {
|
package/src/lib/mailer.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BatchEmailItem,
|
|
3
|
+
EmailProvider,
|
|
4
|
+
WebhookEvent,
|
|
5
|
+
WebhookEventType,
|
|
6
|
+
WebhookHandlerMap,
|
|
7
|
+
} from "@hogsend/core";
|
|
1
8
|
import type { Database } from "@hogsend/db";
|
|
2
9
|
import { emailPreferences, emailSends } from "@hogsend/db";
|
|
3
10
|
import type {
|
|
@@ -6,13 +13,6 @@ import type {
|
|
|
6
13
|
TemplateName,
|
|
7
14
|
} from "@hogsend/email";
|
|
8
15
|
import { getTemplate, renderToHtml, renderToPlainText } from "@hogsend/email";
|
|
9
|
-
import type {
|
|
10
|
-
BatchEmailItem,
|
|
11
|
-
EmailProvider,
|
|
12
|
-
WebhookEvent,
|
|
13
|
-
WebhookEventType,
|
|
14
|
-
WebhookHandlerMap,
|
|
15
|
-
} from "@hogsend/plugin-resend";
|
|
16
16
|
import { eq, sql } from "drizzle-orm";
|
|
17
17
|
import type {
|
|
18
18
|
EmailService,
|
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
EmailServiceSendOptions,
|
|
21
21
|
EmailServiceWebhookOptions,
|
|
22
22
|
EmailServiceWebhookResult,
|
|
23
|
-
|
|
23
|
+
SendRawOptions,
|
|
24
24
|
SendResult,
|
|
25
25
|
TrackedSendResult,
|
|
26
26
|
} from "./email-service-types.js";
|
|
@@ -125,7 +125,7 @@ export function createTrackedMailer(
|
|
|
125
125
|
};
|
|
126
126
|
},
|
|
127
127
|
|
|
128
|
-
async sendRaw(options:
|
|
128
|
+
async sendRaw(options: SendRawOptions): Promise<SendResult> {
|
|
129
129
|
return provider.send({ ...options, from: resolveFrom(options.from) });
|
|
130
130
|
},
|
|
131
131
|
|
package/src/lib/notifications.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getEmailService } from "./email.js";
|
|
2
|
+
|
|
1
3
|
export async function sendWebhook(
|
|
2
4
|
url: string,
|
|
3
5
|
payload: Record<string, unknown>,
|
|
@@ -32,25 +34,22 @@ export async function sendSlackNotification(
|
|
|
32
34
|
return sendWebhook(webhookUrl, { text });
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
import { createResendClient } from "@hogsend/plugin-resend";
|
|
36
|
-
|
|
37
37
|
export async function sendEmailNotification(opts: {
|
|
38
38
|
to: string;
|
|
39
39
|
subject: string;
|
|
40
40
|
body: string;
|
|
41
|
-
resendApiKey: string;
|
|
42
41
|
}): Promise<{ ok: boolean; error?: string }> {
|
|
43
42
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
// Route through the injected EmailProvider (via the mailer's `sendRaw`)
|
|
44
|
+
// instead of constructing a raw Resend client from process.env. The
|
|
45
|
+
// alerting task runs under the worker, where createHogsendClient has already
|
|
46
|
+
// installed the email service.
|
|
47
|
+
await getEmailService().sendRaw({
|
|
46
48
|
from: "Hogsend Alerts <alerts@hogsend.com>",
|
|
47
49
|
to: opts.to,
|
|
48
50
|
subject: opts.subject,
|
|
49
51
|
html: opts.body,
|
|
50
52
|
});
|
|
51
|
-
if (error) {
|
|
52
|
-
return { ok: false, error: error.message };
|
|
53
|
-
}
|
|
54
53
|
return { ok: true };
|
|
55
54
|
} catch (err) {
|
|
56
55
|
return {
|
package/src/lib/posthog.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type PostHogService,
|
|
4
|
-
} from "@hogsend/plugin-posthog";
|
|
1
|
+
import type { PostHogService } from "@hogsend/core";
|
|
2
|
+
import { createPostHogService } from "@hogsend/plugin-posthog";
|
|
5
3
|
import { getRedis } from "./redis.js";
|
|
6
4
|
|
|
7
5
|
let _posthog: PostHogService | undefined;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny process-singletons: a value set once by `createHogsendClient` at startup
|
|
3
|
+
* and read by module-level task-execution sites that have no client reference of
|
|
4
|
+
* their own (journey/bucket durable tasks, the send-email task). Replaces the
|
|
5
|
+
* hand-rolled `let _x` + set/get/reset trios that were copied across the engine.
|
|
6
|
+
*
|
|
7
|
+
* Two variants:
|
|
8
|
+
* - `createSingleton` — required: `get()` throws if read before it is set.
|
|
9
|
+
* - `createOptionalSingleton` — `undefined` is a legitimate value (e.g. analytics
|
|
10
|
+
* with no `POSTHOG_API_KEY`); `get()` returns `T | undefined` and never throws.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Singleton<T> {
|
|
14
|
+
set(value: T): void;
|
|
15
|
+
get(): T;
|
|
16
|
+
reset(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OptionalSingleton<T> {
|
|
20
|
+
set(value: T | undefined): void;
|
|
21
|
+
get(): T | undefined;
|
|
22
|
+
reset(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createSingleton<T>(name: string): Singleton<T> {
|
|
26
|
+
let value: T | undefined;
|
|
27
|
+
return {
|
|
28
|
+
set(next: T): void {
|
|
29
|
+
value = next;
|
|
30
|
+
},
|
|
31
|
+
get(): T {
|
|
32
|
+
if (value === undefined) {
|
|
33
|
+
throw new Error(`${name} not initialized. Call its setter at startup.`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
},
|
|
37
|
+
reset(): void {
|
|
38
|
+
value = undefined;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createOptionalSingleton<T>(): OptionalSingleton<T> {
|
|
44
|
+
let value: T | undefined;
|
|
45
|
+
return {
|
|
46
|
+
set(next: T | undefined): void {
|
|
47
|
+
value = next;
|
|
48
|
+
},
|
|
49
|
+
get(): T | undefined {
|
|
50
|
+
return value;
|
|
51
|
+
},
|
|
52
|
+
reset(): void {
|
|
53
|
+
value = undefined;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/lib/tracked.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EmailProvider } from "@hogsend/core";
|
|
1
2
|
import type { Database } from "@hogsend/db";
|
|
2
3
|
import { emailPreferences, emailSends } from "@hogsend/db";
|
|
3
4
|
import type {
|
|
@@ -7,7 +8,6 @@ import type {
|
|
|
7
8
|
TemplateRegistry,
|
|
8
9
|
} from "@hogsend/email";
|
|
9
10
|
import { getTemplate, renderToHtml } from "@hogsend/email";
|
|
10
|
-
import type { EmailProvider } from "@hogsend/plugin-resend";
|
|
11
11
|
import { eq } from "drizzle-orm";
|
|
12
12
|
import type {
|
|
13
13
|
FrequencyCapConfig,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
+
import type { PostHogService } from "@hogsend/core";
|
|
2
3
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
3
4
|
import { type Database, emailSends, journeyStates } from "@hogsend/db";
|
|
4
|
-
import type { PostHogService } from "@hogsend/plugin-posthog";
|
|
5
5
|
import { eq } from "drizzle-orm";
|
|
6
6
|
import { ingestEvent } from "./ingestion.js";
|
|
7
7
|
import type { Logger } from "./logger.js";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Logger } from "./logger.js";
|
|
2
|
+
import { getRedis } from "./redis.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Worker liveness heartbeat. The worker and API are separate processes, so the
|
|
6
|
+
* API (and the Studio, via `GET /v1/health`) cannot otherwise tell whether a
|
|
7
|
+
* worker is actually connected — which is exactly the "journeys silently don't
|
|
8
|
+
* fire because the worker isn't running" footgun. The worker writes a TTL'd key
|
|
9
|
+
* to Redis on an interval; readers treat its presence as "a worker is alive".
|
|
10
|
+
*
|
|
11
|
+
* Redis is the channel because the health route already probes Redis and both
|
|
12
|
+
* processes can reach it — no direct process-to-process coupling, no migration.
|
|
13
|
+
* Everything here is best-effort: a missing/unreachable Redis never crashes the
|
|
14
|
+
* worker and simply reads back as "down".
|
|
15
|
+
*/
|
|
16
|
+
const HEARTBEAT_KEY = "hogsend:worker:heartbeat";
|
|
17
|
+
const TTL_SECONDS = 30;
|
|
18
|
+
const REFRESH_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
export interface WorkerHeartbeat {
|
|
21
|
+
/** True when a fresh worker heartbeat is present in Redis. */
|
|
22
|
+
alive: boolean;
|
|
23
|
+
/** ISO timestamp the worker last wrote, when alive. */
|
|
24
|
+
lastSeenAt?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Begin writing the worker heartbeat. Writes once immediately, then refreshes
|
|
29
|
+
* every {@link REFRESH_MS} with a {@link TTL_SECONDS} expiry — so an ungraceful
|
|
30
|
+
* worker death is reflected as "down" within the TTL. Returns a stop function
|
|
31
|
+
* that clears the timer and deletes the key for an immediate "down" signal on
|
|
32
|
+
* graceful shutdown.
|
|
33
|
+
*/
|
|
34
|
+
export function startWorkerHeartbeat(logger: Logger): () => Promise<void> {
|
|
35
|
+
let warned = false;
|
|
36
|
+
const write = async () => {
|
|
37
|
+
try {
|
|
38
|
+
await getRedis().set(
|
|
39
|
+
HEARTBEAT_KEY,
|
|
40
|
+
new Date().toISOString(),
|
|
41
|
+
"EX",
|
|
42
|
+
TTL_SECONDS,
|
|
43
|
+
);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// Log the first failure only — a Redis-less deploy would otherwise spam.
|
|
46
|
+
if (!warned) {
|
|
47
|
+
warned = true;
|
|
48
|
+
logger.debug("Worker heartbeat write failed (Redis unreachable?)", {
|
|
49
|
+
error: err instanceof Error ? err.message : String(err),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
void write();
|
|
56
|
+
const timer = setInterval(() => void write(), REFRESH_MS);
|
|
57
|
+
// Never hold the process open for the heartbeat alone.
|
|
58
|
+
timer.unref?.();
|
|
59
|
+
|
|
60
|
+
return async () => {
|
|
61
|
+
clearInterval(timer);
|
|
62
|
+
try {
|
|
63
|
+
await getRedis().del(HEARTBEAT_KEY);
|
|
64
|
+
} catch {
|
|
65
|
+
// Best-effort — the TTL expires it anyway.
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read the current worker heartbeat. Resolves to `{ alive: false }` if Redis is unreachable. */
|
|
71
|
+
export async function getWorkerHeartbeat(): Promise<WorkerHeartbeat> {
|
|
72
|
+
try {
|
|
73
|
+
const lastSeenAt = await getRedis().get(HEARTBEAT_KEY);
|
|
74
|
+
return lastSeenAt ? { alive: true, lastSeenAt } : { alive: false };
|
|
75
|
+
} catch {
|
|
76
|
+
return { alive: false };
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/routes/health.ts
CHANGED
|
@@ -4,12 +4,21 @@ import { sql } from "drizzle-orm";
|
|
|
4
4
|
import type { AppEnv } from "../app.js";
|
|
5
5
|
import { API_VERSION } from "../env.js";
|
|
6
6
|
import { getRedis } from "../lib/redis.js";
|
|
7
|
+
import { getWorkerHeartbeat } from "../lib/worker-heartbeat.js";
|
|
7
8
|
|
|
8
9
|
const componentSchema = z.object({
|
|
9
10
|
status: z.enum(["up", "down"]),
|
|
10
11
|
latencyMs: z.number().optional(),
|
|
11
12
|
});
|
|
12
13
|
|
|
14
|
+
// Worker connectivity, derived from the Redis heartbeat. Informational only —
|
|
15
|
+
// the worker is a separate service, so its absence does NOT make the API
|
|
16
|
+
// "degraded" (that would falsely fail the API's own healthcheck).
|
|
17
|
+
const workerComponentSchema = z.object({
|
|
18
|
+
status: z.enum(["up", "down"]),
|
|
19
|
+
lastSeenAt: z.string().optional(),
|
|
20
|
+
});
|
|
21
|
+
|
|
13
22
|
// Per-track schema version block. Two tracks: `engine` (bundled @hogsend/db
|
|
14
23
|
// migrations) and `client` (the client repo's own migrations). See
|
|
15
24
|
// docs/UPGRADING.md "Two-track migrations".
|
|
@@ -28,6 +37,7 @@ const healthResponseSchema = z.object({
|
|
|
28
37
|
components: z.object({
|
|
29
38
|
database: componentSchema,
|
|
30
39
|
redis: componentSchema,
|
|
40
|
+
worker: workerComponentSchema,
|
|
31
41
|
}),
|
|
32
42
|
schema: z.object({
|
|
33
43
|
engine: trackSchema,
|
|
@@ -73,7 +83,7 @@ export const healthRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
73
83
|
async (c) => {
|
|
74
84
|
const { db, clientJournal } = c.get("container");
|
|
75
85
|
|
|
76
|
-
const [dbCheck, redisCheck, engine, client] = await Promise.all([
|
|
86
|
+
const [dbCheck, redisCheck, heartbeat, engine, client] = await Promise.all([
|
|
77
87
|
checkComponent(async () => {
|
|
78
88
|
await db.execute(sql`SELECT 1`);
|
|
79
89
|
}),
|
|
@@ -86,6 +96,7 @@ export const healthRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
86
96
|
// host is genuinely unreachable → a truthful "down").
|
|
87
97
|
await getRedis().ping();
|
|
88
98
|
}),
|
|
99
|
+
getWorkerHeartbeat(),
|
|
89
100
|
getEngineSchemaVersion(db),
|
|
90
101
|
getClientSchemaVersion(db, clientJournal ?? { entries: [] }),
|
|
91
102
|
]);
|
|
@@ -123,6 +134,10 @@ export const healthRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
123
134
|
components: {
|
|
124
135
|
database: dbCheck,
|
|
125
136
|
redis: redisCheck,
|
|
137
|
+
worker: {
|
|
138
|
+
status: heartbeat.alive ? ("up" as const) : ("down" as const),
|
|
139
|
+
lastSeenAt: heartbeat.lastSeenAt,
|
|
140
|
+
},
|
|
126
141
|
},
|
|
127
142
|
},
|
|
128
143
|
200,
|
package/src/worker.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { selectBucketTasks } from "./buckets/registry.js";
|
|
|
3
3
|
import type { HogsendClient } from "./container.js";
|
|
4
4
|
import type { DefinedJourney } from "./journeys/define-journey.js";
|
|
5
5
|
import { selectJourneyTasks } from "./journeys/registry.js";
|
|
6
|
+
import { reportWorkerReady } from "./lib/boot.js";
|
|
6
7
|
import { hatchet } from "./lib/hatchet.js";
|
|
7
|
-
import { getPostHog } from "./lib/posthog.js";
|
|
8
8
|
import { getRedisIfConnected } from "./lib/redis.js";
|
|
9
|
+
import { startWorkerHeartbeat } from "./lib/worker-heartbeat.js";
|
|
9
10
|
import {
|
|
10
11
|
bucketBackfillTask,
|
|
11
12
|
enqueueBucketBackfills,
|
|
@@ -63,21 +64,43 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
|
|
|
63
64
|
// Hatchet's worker is created lazily on start so signal wiring can own its
|
|
64
65
|
// lifecycle. `_worker` is captured for stop().
|
|
65
66
|
let _worker: Awaited<ReturnType<typeof hatchet.worker>> | undefined;
|
|
67
|
+
let _stopHeartbeat: (() => Promise<void>) | undefined;
|
|
66
68
|
|
|
67
69
|
async function stop(): Promise<void> {
|
|
70
|
+
// Delete the heartbeat first (an immediate "worker down" signal) before the
|
|
71
|
+
// Redis connection is torn down below.
|
|
72
|
+
await _stopHeartbeat?.();
|
|
68
73
|
await Promise.allSettled([
|
|
69
74
|
_worker?.stop(),
|
|
70
|
-
|
|
75
|
+
// Shut down the injected analytics instance (same object the worker's
|
|
76
|
+
// tasks use), not the module singleton. Undefined when no analytics is
|
|
77
|
+
// configured — the optional chain makes that a no-op.
|
|
78
|
+
container.analytics?.shutdown(),
|
|
71
79
|
getRedisIfConnected()?.quit(),
|
|
72
80
|
]);
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
async function start(): Promise<void> {
|
|
84
|
+
// Emit BEFORE the Hatchet handshake: proves the process booted past init
|
|
85
|
+
// even while the connection is still establishing (the worker banner /
|
|
86
|
+
// "ready" line only fires once `hatchet.worker()` resolves).
|
|
87
|
+
container.logger.info("Hogsend worker starting", {
|
|
88
|
+
hatchet: container.env.HATCHET_CLIENT_HOST_PORT,
|
|
89
|
+
});
|
|
90
|
+
|
|
76
91
|
_worker = await hatchet.worker("hogsend-worker", { workflows });
|
|
77
92
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
reportWorkerReady({
|
|
94
|
+
client: container,
|
|
95
|
+
journeyTasks: journeyTasks.length,
|
|
96
|
+
bucketTasks: bucketTasks.length,
|
|
97
|
+
builtinTasks:
|
|
98
|
+
baseWorkflows.length - journeyTasks.length - bucketTasks.length,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Publish liveness so the API + Studio can show "worker connected"
|
|
102
|
+
// (read via GET /v1/health). Best-effort; never blocks the listener.
|
|
103
|
+
_stopHeartbeat = startWorkerHeartbeat(container.logger);
|
|
81
104
|
|
|
82
105
|
// Boot-time backfill / criteria-change re-eval (Section 6.6 B): diff each
|
|
83
106
|
// enabled bucket's criteriaHash against bucket_configs and trigger a
|
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
import { NonRetryableError } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
-
import {
|
|
2
|
+
import { EmailSendError } from "@hogsend/email";
|
|
3
|
+
import { getEmailService } from "../lib/email.js";
|
|
3
4
|
import { hatchet } from "../lib/hatchet.js";
|
|
4
5
|
|
|
5
|
-
const resend = createResendClient({
|
|
6
|
-
apiKey: process.env.RESEND_API_KEY ?? "",
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
const NON_RETRYABLE_CODES = new Set([
|
|
10
|
-
"validation_error",
|
|
11
|
-
"missing_required_field",
|
|
12
|
-
"invalid_api_key",
|
|
13
|
-
"not_found",
|
|
14
|
-
"restricted_api_key",
|
|
15
|
-
]);
|
|
16
|
-
|
|
17
6
|
export const sendEmailTask = hatchet.task({
|
|
18
7
|
name: "send-email",
|
|
19
|
-
|
|
8
|
+
// The EmailProvider owns transient-failure backoff internally (classified
|
|
9
|
+
// exponential retry in its `send`), and permanent failures fail fast below via
|
|
10
|
+
// NonRetryableError — so Hatchet's retry is just ONE durability re-attempt for a
|
|
11
|
+
// worker crash/timeout, not a second transient-retry loop layered on the
|
|
12
|
+
// provider's. (Previously 3, which multiplied with the provider's own retries.)
|
|
13
|
+
retries: 1,
|
|
20
14
|
executionTimeout: "30s",
|
|
21
15
|
backoff: { factor: 2, maxSeconds: 30 },
|
|
22
16
|
fn: async (input: {
|
|
@@ -28,27 +22,35 @@ export const sendEmailTask = hatchet.task({
|
|
|
28
22
|
tags?: Array<{ name: string; value: string }>;
|
|
29
23
|
headers?: Record<string, string>;
|
|
30
24
|
}) => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
tags: input.tags,
|
|
41
|
-
headers: input.headers,
|
|
42
|
-
});
|
|
25
|
+
// Deliver through the injected, provider-backed mailer (set by
|
|
26
|
+
// createHogsendClient → setEmailService). `sendRaw` calls the swappable
|
|
27
|
+
// EmailProvider's `send`, resolving the default `from` from the mailer
|
|
28
|
+
// config — so a swapped provider is honored and this task no longer
|
|
29
|
+
// constructs a raw Resend client of its own. The provider already retries
|
|
30
|
+
// transient failures internally and surfaces a classified EmailSendError;
|
|
31
|
+
// map a non-retryable one to Hatchet's NonRetryableError so the task's own
|
|
32
|
+
// retry/backoff doesn't re-attempt a permanent failure.
|
|
33
|
+
const emailService = getEmailService();
|
|
43
34
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
35
|
+
try {
|
|
36
|
+
// `from` is optional: when absent the mailer's `resolveFrom` falls back to
|
|
37
|
+
// its configured defaultFrom (env.RESEND_FROM_EMAIL).
|
|
38
|
+
const result = await emailService.sendRaw({
|
|
39
|
+
from: input.from,
|
|
40
|
+
to: input.to,
|
|
41
|
+
subject: input.subject,
|
|
42
|
+
html: input.html,
|
|
43
|
+
replyTo: input.replyTo,
|
|
44
|
+
tags: input.tags,
|
|
45
|
+
headers: input.headers,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return { emailId: result.id };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof EmailSendError && !error.retryable) {
|
|
51
|
+
throw new NonRetryableError(error.message);
|
|
48
52
|
}
|
|
49
|
-
throw
|
|
53
|
+
throw error;
|
|
50
54
|
}
|
|
51
|
-
|
|
52
|
-
return { emailId: data?.id ?? "" };
|
|
53
55
|
},
|
|
54
56
|
});
|