@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.
- package/dist/bin.js +1985 -807
- package/dist/bin.js.map +1 -1
- package/package.json +4 -4
- package/skills/hogsend-integrate/SKILL.md +198 -0
- package/skills/hogsend-integrate/references/auth-billing-seams.md +199 -0
- package/skills/hogsend-integrate/references/framework-recipes.md +208 -0
- package/skills/hogsend-integrate/references/verification.md +86 -0
- package/skills/hogsend-migrate/SKILL.md +147 -0
- package/skills/hogsend-migrate/references/customerio-mapping.md +93 -0
- package/skills/hogsend-migrate/references/cutover-checklist.md +136 -0
- package/skills/hogsend-migrate/references/loops-mapping.md +132 -0
- package/skills/hogsend-migrate/references/resend-broadcasts-mapping.md +120 -0
- package/src/__tests__/dev.test.ts +323 -0
- package/src/__tests__/dns-apply.test.ts +297 -0
- package/src/__tests__/dns.test.ts +143 -0
- package/src/__tests__/domain-command.test.ts +216 -0
- package/src/__tests__/proc.test.ts +177 -0
- package/src/__tests__/setup-steps.test.ts +363 -0
- package/src/commands/dev.ts +444 -0
- package/src/commands/domain.ts +437 -0
- package/src/commands/events.ts +4 -1
- package/src/commands/index.ts +4 -0
- package/src/commands/setup.ts +34 -163
- package/src/lib/dns-apply.ts +218 -0
- package/src/lib/dns.ts +217 -0
- package/src/lib/proc.ts +189 -0
- package/src/lib/setup-steps.ts +333 -0
- package/studio/assets/index-CSXAjTbe.js +265 -0
- package/studio/assets/index-DCsT0fnT.css +1 -0
- package/studio/index.html +2 -2
- package/studio/assets/index-BBOTQnww.js +0 -250
- 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.
|
|
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.
|
|
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.
|
|
46
|
-
"@hogsend/engine": "^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.
|