@hogsend/cli 0.11.0 → 0.12.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.
Files changed (32) hide show
  1. package/dist/bin.js +1985 -807
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +4 -4
  4. package/skills/hogsend-integrate/SKILL.md +198 -0
  5. package/skills/hogsend-integrate/references/auth-billing-seams.md +199 -0
  6. package/skills/hogsend-integrate/references/framework-recipes.md +208 -0
  7. package/skills/hogsend-integrate/references/verification.md +86 -0
  8. package/skills/hogsend-migrate/SKILL.md +147 -0
  9. package/skills/hogsend-migrate/references/customerio-mapping.md +93 -0
  10. package/skills/hogsend-migrate/references/cutover-checklist.md +136 -0
  11. package/skills/hogsend-migrate/references/loops-mapping.md +132 -0
  12. package/skills/hogsend-migrate/references/resend-broadcasts-mapping.md +120 -0
  13. package/src/__tests__/dev.test.ts +323 -0
  14. package/src/__tests__/dns-apply.test.ts +297 -0
  15. package/src/__tests__/dns.test.ts +143 -0
  16. package/src/__tests__/domain-command.test.ts +216 -0
  17. package/src/__tests__/proc.test.ts +177 -0
  18. package/src/__tests__/setup-steps.test.ts +363 -0
  19. package/src/commands/dev.ts +444 -0
  20. package/src/commands/domain.ts +437 -0
  21. package/src/commands/events.ts +4 -1
  22. package/src/commands/index.ts +4 -0
  23. package/src/commands/setup.ts +34 -163
  24. package/src/lib/dns-apply.ts +218 -0
  25. package/src/lib/dns.ts +217 -0
  26. package/src/lib/proc.ts +189 -0
  27. package/src/lib/setup-steps.ts +333 -0
  28. package/studio/assets/index-CSXAjTbe.js +265 -0
  29. package/studio/assets/index-DCsT0fnT.css +1 -0
  30. package/studio/index.html +2 -2
  31. package/studio/assets/index-BBOTQnww.js +0 -250
  32. package/studio/assets/index-DnfpcXbb.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "tsup": "^8.5.1",
33
33
  "tsx": "^4.22.4",
34
34
  "vitest": "^4.1.7",
35
- "@hogsend/studio": "^0.11.0",
35
+ "@hogsend/studio": "^0.12.0",
36
36
  "@repo/typescript-config": "0.0.0"
37
37
  },
38
38
  "engines": {
@@ -42,8 +42,8 @@
42
42
  "@clack/prompts": "^1.5.0",
43
43
  "better-auth": "^1.6.11",
44
44
  "picocolors": "^1.1.1",
45
- "@hogsend/db": "^0.11.0",
46
- "@hogsend/engine": "^0.11.0"
45
+ "@hogsend/db": "^0.12.0",
46
+ "@hogsend/engine": "^0.12.0"
47
47
  },
48
48
  "scripts": {
49
49
  "prebuild": "node scripts/bundle-studio.mjs",
@@ -0,0 +1,198 @@
1
+ ---
2
+ name: hogsend-integrate
3
+ description: Use when wiring an EXISTING product codebase — Next.js (App or Pages Router), Express, Hono, Remix, SvelteKit, or any Node server — to a running Hogsend instance via @hogsend/client. The outside-in playbook — detect the host stack, find the signup/auth/billing seams (better-auth, Clerk, Supabase, Stripe, NextAuth), install @hogsend/client, add contacts.upsert on signup + events.send on key actions + emails.send for transactional, wire HOGSEND_API_URL/HOGSEND_API_KEY env, then verify ingestion with `hogsend events`. NOT for code inside a Hogsend app itself (there, use hogsend-client-sdk for app code or the authoring skills for journeys/emails) and NOT for migrating off Loops/Customer.io/Resend Broadcasts (that is hogsend-migrate).
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Integrate a host app with Hogsend
11
+
12
+ This skill drives the **outside-in** flow: you are working in someone's PRODUCT
13
+ codebase (a SaaS app, a marketing site backend — NOT a Hogsend app), and the
14
+ goal is to wire it to a running Hogsend instance so that signups become
15
+ contacts, key actions become events (which trigger journeys), and transactional
16
+ sends go through Hogsend's tracked pipeline.
17
+
18
+ The shape of the work: **detect the stack → find the seams → wire the client →
19
+ add the calls → verify ingestion.** Confirm the seams with the user before
20
+ editing; everything else is mechanical.
21
+
22
+ ## Step 0 — orientation guard (am I in the right codebase?)
23
+
24
+ Check `package.json` first. If it depends on `@hogsend/engine` — or the project
25
+ has `src/journeys/` + `src/emails/` — you are INSIDE a Hogsend app, and this
26
+ skill does not apply:
27
+
28
+ - App code inside a Hogsend app → the **hogsend-client-sdk** skill (the
29
+ scaffold already ships a preconfigured `hs` at `src/lib/hogsend.ts`).
30
+ - Journeys / emails / lists / webhook sources → the **hogsend-authoring-\***
31
+ skills.
32
+
33
+ This skill targets the HOST product. The only Hogsend dependency you will add
34
+ here is `@hogsend/client`.
35
+
36
+ ## Step 1 — detect the stack
37
+
38
+ Probe, don't ask. Each framework decides where server-side code lives and where
39
+ the shared client module goes (full wiring per framework →
40
+ `references/framework-recipes.md`):
41
+
42
+ | Probe | Stack | Server-side code lives in |
43
+ |---|---|---|
44
+ | `next.config.*` + `app/` (or `src/app/`) | Next.js App Router | route handlers `app/**/route.ts`, server actions |
45
+ | `next.config.*` + `pages/api/` | Next.js Pages Router | `pages/api/**` |
46
+ | `express` dep + `app.post(`/`router.post(` | Express | route handlers, middleware |
47
+ | `hono` dep + `new Hono(` | Hono | route handlers |
48
+ | `@remix-run/*` (or `react-router` v7 framework mode) | Remix | `action`/`loader` exports |
49
+ | `@sveltejs/kit` dep | SvelteKit | `src/routes/**/+server.ts`, `+page.server.ts`, `src/lib/server/` |
50
+
51
+ **The one rule that never changes: the client is server-only.** `HOGSEND_API_KEY`
52
+ must NEVER reach the browser bundle — no `NEXT_PUBLIC_`/`VITE_`/`PUBLIC_`
53
+ prefixes, no client-component imports. Fire events from route handlers, server
54
+ actions, API routes, and webhooks.
55
+
56
+ ## Step 2 — find the signup/auth/billing seams
57
+
58
+ Grep for the places identity is created and money changes hands. Report what you
59
+ find as a short table and **confirm with the user before editing**. Detection
60
+ greps + a wired snippet per provider → `references/auth-billing-seams.md`:
61
+
62
+ | Seam | Grep for |
63
+ |---|---|
64
+ | better-auth | `betterAuth(`, `databaseHooks` |
65
+ | Clerk | `clerkMiddleware`, a webhook handler verifying `svix-*` headers |
66
+ | Supabase Auth | `supabase.auth.signUp`, `auth.admin`, auth webhook handlers |
67
+ | NextAuth / Auth.js | `NextAuth(`, `events:` (esp. `createUser`) |
68
+ | Stripe | `stripe.webhooks.constructEvent`, `checkout.session.completed`, `customer.subscription.` |
69
+ | Hand-rolled | `signup`/`register` route handlers, `INSERT INTO users`, ORM `user.create` |
70
+
71
+ Also note: if the team prefers zero host-code for a provider, Hogsend itself
72
+ ships **inbound webhook presets** for Clerk/Supabase/Stripe/Segment (point the
73
+ provider's webhook at the Hogsend instance's `POST /v1/webhooks/{clerk,supabase,stripe,segment}`,
74
+ set the secret env on the HOGSEND side). Host-side `@hogsend/client` calls and
75
+ Hogsend-side presets are alternatives — don't double-fire the same event.
76
+
77
+ ## Step 3 — wire the client (one shared server module)
78
+
79
+ ```bash
80
+ pnpm add @hogsend/client # or npm i / yarn add — never hand-edit versions
81
+ ```
82
+
83
+ Create ONE server-only module exporting a singleton (placement per framework in
84
+ `references/framework-recipes.md`; e.g. `lib/hogsend.ts`, or
85
+ `hogsend.server.ts` where the framework enforces server-only by suffix):
86
+
87
+ ```ts
88
+ // lib/hogsend.ts — server-only. Do not import from client components.
89
+ import { Hogsend } from "@hogsend/client";
90
+
91
+ export const hogsend = new Hogsend({
92
+ baseUrl: process.env.HOGSEND_API_URL!,
93
+ apiKey: process.env.HOGSEND_API_KEY!,
94
+ });
95
+ ```
96
+
97
+ **Host-app env convention** (add to `.env` + the project's env example file):
98
+
99
+ ```bash
100
+ HOGSEND_API_URL=https://hogsend.your-company.com # the Hogsend API base URL
101
+ HOGSEND_API_KEY=hsk_... # ingest-scoped data-plane key
102
+ ```
103
+
104
+ The key needs the `ingest` scope (or `full-admin`, which implies it) — minted in
105
+ the Hogsend app. These names match what the `hogsend` CLI reads, so verification
106
+ (Step 5) works with the same env. Do NOT conflate with the Hogsend app's own
107
+ internal `API_PUBLIC_URL` — that var belongs inside the scaffolded Hogsend app,
108
+ not the host.
109
+
110
+ ## Step 4 — add the calls at the seams
111
+
112
+ Three call patterns cover almost every integration (full SDK surface, identity
113
+ rules, and error types → the **hogsend-client-sdk** skill):
114
+
115
+ ```ts
116
+ import { hogsend } from "../lib/hogsend.js";
117
+
118
+ // 1. Signup → upsert the contact
119
+ await hogsend.contacts.upsert({
120
+ email: user.email,
121
+ userId: user.id, // your stable external id
122
+ properties: { name: user.name, plan: "free" },
123
+ });
124
+
125
+ // 2. Key actions → send events (these trigger journeys on the Hogsend side)
126
+ await hogsend.events.send({
127
+ userId: user.id,
128
+ name: "subscription_started",
129
+ eventProperties: { plan: "pro", amount: 49 }, // facts about THIS event
130
+ contactProperties: { plan: "pro" }, // durable facts, merged onto the contact
131
+ idempotencyKey: stripeEvent.id, // dedupes webhook retries
132
+ });
133
+
134
+ // 3. Transactional → send through Hogsend's tracked pipeline
135
+ await hogsend.emails.send({
136
+ to: user.email, // or userId: user.id
137
+ template: "password-reset", // a key from the HOGSEND app's registry
138
+ props: { resetUrl },
139
+ });
140
+ ```
141
+
142
+ Rules that matter:
143
+
144
+ - **Every write needs an identity** — at least one of `email` / `userId`.
145
+ - **The property-bag split:** `eventProperties` describe the event and are what
146
+ journey `trigger.where`/`exitOn` rules evaluate; `contactProperties` merge
147
+ onto the contact. Don't conflate them.
148
+ - **Idempotency on webhook-driven sends:** pass the provider's event id (e.g.
149
+ the Stripe event id) as `idempotencyKey` so retries don't double-ingest.
150
+ - **`template` keys live in the Hogsend app**, not the host — list what exists
151
+ before wiring a send (ask the user, or check the Hogsend app's
152
+ `src/emails/registry.ts`). A new template is authored on the Hogsend side
153
+ (the **hogsend-authoring-emails** skill).
154
+ - **Hot paths:** `events.send` returns 202 once stored. Don't let analytics
155
+ block a signup response — catch and log, or fire-and-forget with an error
156
+ handler. Catch `RateLimitError` (has `retryAfter` seconds) and
157
+ `HogsendAPIError` (`status === 0` = transport failure) from
158
+ `@hogsend/client`. If you pass `lists`, check the result's `listsError`.
159
+
160
+ Pick 3-5 high-signal events (signup, activation moment, subscription started/
161
+ cancelled) over instrumenting everything — journeys trigger on these names, so
162
+ agree the event names with the user and keep them stable.
163
+
164
+ ## Step 5 — verify the loop end-to-end
165
+
166
+ Use the `hogsend` CLI against the same instance (`HOGSEND_API_URL` is read from
167
+ env; full transcript → `references/verification.md`):
168
+
169
+ ```bash
170
+ # 1. Fire a test event through the data plane (or trigger the real code path)
171
+ hogsend events send signup --user-id test_agent_$(date +%s) --prop source=integration-test --json
172
+
173
+ # 2. Confirm it was stored (admin key required for the read path)
174
+ hogsend events <that-user-id> --json
175
+ ```
176
+
177
+ `stored: true` on the send + the event appearing in the read = the pipe works.
178
+ Then exercise the REAL seam (sign a test user up, replay a Stripe test webhook)
179
+ and confirm the same way. Fallbacks: `hogsend doctor --json` (instance health),
180
+ `hogsend contacts get <userId> --json` (contact landed), and Studio's contacts
181
+ view on the Hogsend instance.
182
+
183
+ ## What happens on the Hogsend side
184
+
185
+ Wiring the host is half the story — events only DO something when a journey
186
+ triggers on them. Defining journeys/templates/lists happens in the Hogsend app:
187
+ **hogsend-authoring-journeys**, **hogsend-authoring-emails**,
188
+ **hogsend-authoring-lists**. For the complete `@hogsend/client` API surface, the
189
+ **hogsend-client-sdk** skill.
190
+
191
+ ## Task playbooks — load the matching reference
192
+
193
+ - **Per-framework wiring (module placement, route-handler examples, env
194
+ loading)** → `references/framework-recipes.md`
195
+ - **Per-provider seam detection + wired snippets (better-auth, Clerk, Supabase,
196
+ Stripe, NextAuth)** → `references/auth-billing-seams.md`
197
+ - **The verification transcript (CLI flags, expected output, failure
198
+ triage)** → `references/verification.md`
@@ -0,0 +1,199 @@
1
+ # Auth + billing seams — detection and wiring per provider
2
+
3
+ How to FIND the place identity is created (or money moves) in the host
4
+ codebase, and what to add there. Provider APIs evolve — treat the greps as
5
+ "look for", verify against the project's actual code, and confirm the seam
6
+ table with the user before editing.
7
+
8
+ For Clerk, Supabase, Stripe, and Segment there is always a **zero-host-code
9
+ alternative**: Hogsend ships inbound webhook presets at
10
+ `POST /v1/webhooks/{clerk,supabase,stripe,segment}` on the Hogsend instance
11
+ (signature-verified; enabled by setting the provider's secret env var on the
12
+ HOGSEND side, e.g. `STRIPE_WEBHOOK_SECRET`). Use the preset when the team wants
13
+ the provider's full lifecycle mirrored without touching the host app; use
14
+ host-side `@hogsend/client` calls when they want control over event names and
15
+ properties. **Pick one per provider — never both** (you'd double-ingest).
16
+
17
+ ## better-auth
18
+
19
+ **Detect:** `better-auth` in package.json; grep `betterAuth(` for the config
20
+ (commonly `lib/auth.ts` / `src/auth.ts`); look for `databaseHooks`.
21
+
22
+ **Wire:** the `databaseHooks.user.create.after` hook fires once per new user —
23
+ the cleanest signup seam:
24
+
25
+ ```ts
26
+ import { betterAuth } from "better-auth";
27
+ import { hogsend } from "./hogsend"; // the shared server module
28
+
29
+ export const auth = betterAuth({
30
+ // …existing config…
31
+ databaseHooks: {
32
+ user: {
33
+ create: {
34
+ after: async (user) => {
35
+ await hogsend.contacts.upsert({
36
+ email: user.email,
37
+ userId: user.id,
38
+ properties: { name: user.name },
39
+ });
40
+ await hogsend.events.send({ userId: user.id, name: "signup" });
41
+ },
42
+ },
43
+ },
44
+ },
45
+ });
46
+ ```
47
+
48
+ ## Clerk
49
+
50
+ **Detect:** `@clerk/nextjs` (or another `@clerk/*` SDK); grep
51
+ `clerkMiddleware`. An existing Clerk webhook handler verifies `svix-id` /
52
+ `svix-timestamp` / `svix-signature` headers.
53
+
54
+ **Wire (host-side):** Clerk's reliable signup signal is its `user.created`
55
+ webhook (client-side callbacks miss OAuth signups). If the app already has a
56
+ Clerk webhook route, add Hogsend calls to its `user.created` branch:
57
+
58
+ ```ts
59
+ // inside the verified Clerk webhook handler
60
+ if (evt.type === "user.created") {
61
+ const u = evt.data;
62
+ const email = u.email_addresses?.[0]?.email_address;
63
+ await hogsend.contacts.upsert({
64
+ email,
65
+ userId: u.id,
66
+ properties: { firstName: u.first_name, lastName: u.last_name },
67
+ });
68
+ await hogsend.events.send({
69
+ userId: u.id,
70
+ name: "signup",
71
+ idempotencyKey: `clerk_${u.id}_created`,
72
+ });
73
+ }
74
+ ```
75
+
76
+ **Or (zero host code):** point a Clerk webhook endpoint at the Hogsend
77
+ instance's `POST /v1/webhooks/clerk` and set `CLERK_WEBHOOK_SECRET` on the
78
+ Hogsend deployment. The preset maps `user.created/updated/deleted` and
79
+ `waitlistEntry.created` automatically.
80
+
81
+ ## Supabase Auth
82
+
83
+ **Detect:** `@supabase/supabase-js` / `@supabase/ssr`; grep
84
+ `supabase.auth.signUp`, `auth.admin`, or a database webhook/trigger on
85
+ `auth.users`.
86
+
87
+ **Wire (host-side):** after a successful `signUp` call (server-side — e.g. a
88
+ route handler using the server client):
89
+
90
+ ```ts
91
+ const { data, error } = await supabase.auth.signUp({ email, password });
92
+ if (!error && data.user) {
93
+ await hogsend.contacts.upsert({ email, userId: data.user.id });
94
+ await hogsend.events.send({ userId: data.user.id, name: "signup" });
95
+ }
96
+ ```
97
+
98
+ Caveat: client-side-only `signUp` calls have no server seam — either move the
99
+ call server-side, add a database webhook on `auth.users`, or use the preset.
100
+
101
+ **Or (zero host code):** point a Supabase database webhook (on `auth.users`
102
+ inserts) at the Hogsend instance's `POST /v1/webhooks/supabase` and set
103
+ `SUPABASE_WEBHOOK_SECRET` on the Hogsend deployment.
104
+
105
+ ## NextAuth / Auth.js
106
+
107
+ **Detect:** `next-auth` / `@auth/core`; grep `NextAuth(`. The config commonly
108
+ lives in `auth.ts` / `app/api/auth/[...nextauth]/route.ts`.
109
+
110
+ **Wire:** the `events.createUser` event fires once when the adapter persists a
111
+ new user:
112
+
113
+ ```ts
114
+ export const { handlers, auth } = NextAuth({
115
+ // …existing config…
116
+ events: {
117
+ async createUser({ user }) {
118
+ await hogsend.contacts.upsert({
119
+ email: user.email ?? undefined,
120
+ userId: user.id,
121
+ properties: { name: user.name },
122
+ });
123
+ await hogsend.events.send({ userId: user.id!, name: "signup" });
124
+ },
125
+ },
126
+ });
127
+ ```
128
+
129
+ (`events.createUser` requires a database adapter; with pure JWT sessions there
130
+ is no persistent user creation moment — instrument the app's own
131
+ profile-creation step instead.)
132
+
133
+ ## Stripe (billing)
134
+
135
+ **Detect:** `stripe` dep; grep `stripe.webhooks.constructEvent` for the webhook
136
+ handler, then `checkout.session.completed` / `customer.subscription.` for the
137
+ lifecycle branches.
138
+
139
+ **Wire (host-side):** add events inside the existing verified webhook handler.
140
+ ALWAYS pass the Stripe event id as `idempotencyKey` — Stripe retries
141
+ deliveries:
142
+
143
+ ```ts
144
+ // inside the handler, after constructEvent succeeded
145
+ switch (event.type) {
146
+ case "checkout.session.completed": {
147
+ const session = event.data.object;
148
+ await hogsend.events.send({
149
+ email: session.customer_details?.email ?? undefined,
150
+ userId: session.client_reference_id ?? undefined,
151
+ name: "subscription_started",
152
+ eventProperties: {
153
+ amount: session.amount_total,
154
+ currency: session.currency,
155
+ },
156
+ contactProperties: { plan: "pro" },
157
+ idempotencyKey: event.id,
158
+ });
159
+ break;
160
+ }
161
+ case "customer.subscription.deleted": {
162
+ await hogsend.events.send({
163
+ userId: lookupUserIdByCustomer(event.data.object.customer), // app-specific
164
+ name: "subscription_cancelled",
165
+ contactProperties: { plan: "free" },
166
+ idempotencyKey: event.id,
167
+ });
168
+ break;
169
+ }
170
+ }
171
+ ```
172
+
173
+ Identity note: Stripe events carry a Stripe customer id, not your user id. Use
174
+ whatever mapping the app already has (`client_reference_id`, a `userId` in
175
+ `metadata`, or a customers table) — at least one of `email`/`userId` is
176
+ required on every send.
177
+
178
+ **Or (zero host code):** add a second Stripe webhook endpoint pointed at the
179
+ Hogsend instance's `POST /v1/webhooks/stripe` and set `STRIPE_WEBHOOK_SECRET`
180
+ on the Hogsend deployment.
181
+
182
+ ## Hand-rolled auth
183
+
184
+ **Detect:** grep `signup`, `register`, `createUser`, ORM calls
185
+ (`prisma.user.create`, `db.insert(users)`), `INSERT INTO users`.
186
+
187
+ **Wire:** add the upsert + event immediately after the user row is committed
188
+ (after, not before — don't ingest users whose creation then rolls back).
189
+
190
+ ## Output of this step
191
+
192
+ Before editing, present the seams found:
193
+
194
+ | Seam | File | Action |
195
+ |---|---|---|
196
+ | better-auth signup | `src/lib/auth.ts` | add `databaseHooks.user.create.after` |
197
+ | Stripe webhook | `app/api/stripe/route.ts` | add events on 2 branches |
198
+
199
+ …and get a yes. Then wire, then verify (`references/verification.md`).
@@ -0,0 +1,208 @@
1
+ # Framework recipes — wiring `@hogsend/client` per stack
2
+
3
+ One shared server-only module, one import per seam. Adjust paths to the
4
+ project's conventions (`src/` prefix, path aliases) — the probes in SKILL.md
5
+ tell you which recipe applies.
6
+
7
+ In every recipe the module is the same; only the placement changes:
8
+
9
+ ```ts
10
+ import { Hogsend } from "@hogsend/client";
11
+
12
+ export const hogsend = new Hogsend({
13
+ baseUrl: process.env.HOGSEND_API_URL!,
14
+ apiKey: process.env.HOGSEND_API_KEY!,
15
+ });
16
+ ```
17
+
18
+ Env (`.env` + the project's `.env.example`):
19
+
20
+ ```bash
21
+ HOGSEND_API_URL=https://hogsend.your-company.com
22
+ HOGSEND_API_KEY=hsk_... # ingest-scoped key, server-side only
23
+ ```
24
+
25
+ ## Next.js — App Router
26
+
27
+ - **Module:** `lib/hogsend.ts` (or `src/lib/hogsend.ts`). Add
28
+ `import "server-only";` at the top if the project uses the `server-only`
29
+ package — it turns an accidental client-component import into a build error.
30
+ - **Call from:** route handlers (`app/**/route.ts`), server actions
31
+ (`"use server"`), and webhook handlers. Never from `"use client"` components.
32
+
33
+ ```ts
34
+ // app/api/signup/route.ts
35
+ import { hogsend } from "@/lib/hogsend";
36
+
37
+ export async function POST(req: Request) {
38
+ const { email, name } = await req.json();
39
+ const user = await createUser({ email, name }); // the app's existing logic
40
+
41
+ await hogsend.contacts.upsert({
42
+ email,
43
+ userId: user.id,
44
+ properties: { name },
45
+ });
46
+ await hogsend.events.send({ userId: user.id, name: "signup" });
47
+
48
+ return Response.json({ ok: true });
49
+ }
50
+ ```
51
+
52
+ Env note: server-side `process.env.HOGSEND_API_KEY` works out of the box (Next
53
+ loads `.env*`). Never add a `NEXT_PUBLIC_` variant.
54
+
55
+ ## Next.js — Pages Router
56
+
57
+ - **Module:** `lib/hogsend.ts`. **Call from:** `pages/api/**` handlers and
58
+ `getServerSideProps` only.
59
+
60
+ ```ts
61
+ // pages/api/signup.ts
62
+ import type { NextApiRequest, NextApiResponse } from "next";
63
+ import { hogsend } from "../../lib/hogsend";
64
+
65
+ export default async function handler(
66
+ req: NextApiRequest,
67
+ res: NextApiResponse,
68
+ ) {
69
+ const user = await createUser(req.body);
70
+ await hogsend.contacts.upsert({ email: user.email, userId: user.id });
71
+ await hogsend.events.send({ userId: user.id, name: "signup" });
72
+ res.status(200).json({ ok: true });
73
+ }
74
+ ```
75
+
76
+ ## Express
77
+
78
+ - **Module:** wherever the project keeps shared services (`src/lib/hogsend.ts`,
79
+ `src/services/hogsend.ts`). Ensure env is loaded before the module is
80
+ imported (the project's existing `dotenv` setup usually covers this).
81
+
82
+ ```ts
83
+ // src/routes/auth.ts
84
+ import { Router } from "express";
85
+ import { hogsend } from "../lib/hogsend.js";
86
+
87
+ const router = Router();
88
+
89
+ router.post("/signup", async (req, res, next) => {
90
+ try {
91
+ const user = await createUser(req.body);
92
+ await hogsend.contacts.upsert({ email: user.email, userId: user.id });
93
+ await hogsend.events.send({ userId: user.id, name: "signup" });
94
+ res.json({ ok: true });
95
+ } catch (err) {
96
+ next(err);
97
+ }
98
+ });
99
+ ```
100
+
101
+ ## Hono
102
+
103
+ - **Module:** `src/lib/hogsend.ts`. On Node, `process.env` works directly. On
104
+ edge runtimes (Cloudflare Workers), env arrives per-request via `c.env` — in
105
+ that case construct the client inside the handler (or a middleware) from
106
+ `c.env.HOGSEND_API_URL` / `c.env.HOGSEND_API_KEY` instead of a module-level
107
+ singleton.
108
+
109
+ ```ts
110
+ // src/routes/auth.ts (Node runtime)
111
+ import { Hono } from "hono";
112
+ import { hogsend } from "../lib/hogsend.js";
113
+
114
+ const auth = new Hono();
115
+
116
+ auth.post("/signup", async (c) => {
117
+ const body = await c.req.json();
118
+ const user = await createUser(body);
119
+ await hogsend.contacts.upsert({ email: user.email, userId: user.id });
120
+ await hogsend.events.send({ userId: user.id, name: "signup" });
121
+ return c.json({ ok: true });
122
+ });
123
+ ```
124
+
125
+ ## Remix (and React Router v7 framework mode)
126
+
127
+ - **Module:** `app/lib/hogsend.server.ts` — the `.server.ts` suffix makes the
128
+ bundler exclude it from the client build (this is the framework's own
129
+ server-only mechanism; use it).
130
+ - **Call from:** `action` / `loader` exports and resource routes.
131
+
132
+ ```ts
133
+ // app/routes/signup.tsx
134
+ import type { ActionFunctionArgs } from "@remix-run/node";
135
+ import { hogsend } from "~/lib/hogsend.server";
136
+
137
+ export async function action({ request }: ActionFunctionArgs) {
138
+ const form = await request.formData();
139
+ const user = await createUser(Object.fromEntries(form));
140
+ await hogsend.contacts.upsert({ email: user.email, userId: user.id });
141
+ await hogsend.events.send({ userId: user.id, name: "signup" });
142
+ return { ok: true };
143
+ }
144
+ ```
145
+
146
+ ## SvelteKit
147
+
148
+ - **Module:** `src/lib/server/hogsend.ts` — anything under `src/lib/server/`
149
+ is enforced server-only by SvelteKit. Prefer the framework's private env
150
+ module over `process.env`:
151
+
152
+ ```ts
153
+ // src/lib/server/hogsend.ts
154
+ import { env } from "$env/dynamic/private";
155
+ import { Hogsend } from "@hogsend/client";
156
+
157
+ export const hogsend = new Hogsend({
158
+ baseUrl: env.HOGSEND_API_URL!,
159
+ apiKey: env.HOGSEND_API_KEY!,
160
+ });
161
+ ```
162
+
163
+ - **Call from:** `+server.ts` endpoints and `+page.server.ts` actions:
164
+
165
+ ```ts
166
+ // src/routes/signup/+page.server.ts
167
+ import { hogsend } from "$lib/server/hogsend";
168
+
169
+ export const actions = {
170
+ default: async ({ request }) => {
171
+ const form = await request.formData();
172
+ const user = await createUser(Object.fromEntries(form));
173
+ await hogsend.contacts.upsert({ email: user.email, userId: user.id });
174
+ await hogsend.events.send({ userId: user.id, name: "signup" });
175
+ return { ok: true };
176
+ },
177
+ };
178
+ ```
179
+
180
+ ## Anything else (Fastify, NestJS, plain Node, cron workers)
181
+
182
+ The pattern is identical: one shared module holding the singleton, imported by
183
+ server-side handlers. `@hogsend/client` is a thin wrapper over native `fetch`
184
+ (ESM + CJS; declares `engines.node >= 22`), so it runs in any modern Node
185
+ server. For NestJS, wrap it in an
186
+ injectable provider; for queues/crons, import the same module from the job
187
+ handler.
188
+
189
+ ## Hot-path guidance (applies to every framework)
190
+
191
+ Don't let lifecycle instrumentation take down a signup. Two acceptable shapes:
192
+
193
+ ```ts
194
+ // a) awaited, but non-fatal
195
+ try {
196
+ await hogsend.events.send({ userId: user.id, name: "signup" });
197
+ } catch (err) {
198
+ console.error("hogsend ingest failed", err); // log + continue
199
+ }
200
+
201
+ // b) fire-and-forget with an attached handler (never a bare floating promise)
202
+ void hogsend.events
203
+ .send({ userId: user.id, name: "signup" })
204
+ .catch((err) => console.error("hogsend ingest failed", err));
205
+ ```
206
+
207
+ For webhook handlers (Stripe, Clerk), prefer (a) awaited — the provider retries
208
+ on 5xx, and `idempotencyKey` makes the retry safe.