From fae8bfb931b3e4143ab5dd8d8c18778ce4811e4b Mon Sep 17 00:00:00 2001 From: John Washam Date: Thu, 7 May 2026 14:23:22 -0700 Subject: [PATCH 1/3] Progress --- CLAUDE.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- lib/api.ts | 4 ++-- test/api.ts | 12 ++++++------ 4 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3fd81e7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +- `npm test` — runs `nyc ava` (TypeScript tests via ts-node) with coverage. The `nyc` config in `package.json` enforces **100% branch/line/function/statement coverage** — coverage gaps fail the build. +- `npm run build` — `tsc -p tsconfig.json`, emits to `dist/` (the published artifact; `package.json#main` points there). +- Run a single test file: `npx ava test/track.ts`. Single test by title: `npx ava test/track.ts -m "constructor sets necessary variables"`. +- `npm run version` — regenerates `lib/version.ts` from `package.json#version`. Always run this (or update by hand) when bumping the version; the husky `pre-commit` hook runs `check-version.ts` and **fails the commit** if `lib/version.ts` and `package.json` disagree. The `User-Agent` header sent on every request reads from `lib/version.ts`. +- The pre-commit hook also runs `pretty-quick --staged` (Prettier on staged files; config in `.prettierrc.js` — 120-col, single-quote, trailing-comma all). +- CI (`.github/workflows/main.yml`) runs `npm test` against Node 14, 15, 16, 17, 18 — keep changes compatible with Node 14+. + +## Architecture + +Two top-level clients wrap two distinct Customer.io APIs, each with its own auth scheme and base URL. Both share one `Request` transport. + +- **`TrackClient`** ([lib/track.ts](lib/track.ts)) — Track API. Auth: HTTP Basic from `(siteId, apiKey)`. Base URL: `Region.trackUrl`. Identify, track events, devices, suppress, merge customers. +- **`APIClient`** ([lib/api.ts](lib/api.ts)) — App/Transactional API. Auth: Bearer `appKey`. Base URL: `Region.apiUrl`. Transactional sends, broadcasts, exports, attribute lookups. + +`Region` ([lib/regions.ts](lib/regions.ts)) bundles both URLs together — `RegionUS` and `RegionEU` are the two exported instances. Construction validates `region instanceof Region` (so plain objects are rejected). Override `defaults.url` to point at a custom host (used in tests). + +`Request` ([lib/request.ts](lib/request.ts)) is the single HTTP layer for both clients. It uses **only the Node built-in `https` module** — no axios, fetch, or other HTTP dependency. Behavior worth knowing before touching it: + +- Auth header is computed once in the constructor based on whether `auth` is `BasicAuth` (object) or `BearerAuth` (string). +- Default timeout 10s, overridable via `defaults` passed to the client constructor. +- `handler` follows 301/302/307/308 redirects by recursing on `Location`. +- Only 200 and 201 resolve; everything else rejects with `CustomerIORequestError` carrying `statusCode`, `response` (`IncomingMessage`), and raw `body`. `composeMessage` extracts `meta.error` / `meta.errors` from the JSON body. + +### Transactional request objects + +`api.sendEmail / sendPush / sendSMS / sendInboxMessage / sendInApp` each take a **specific request class instance** from [lib/api/requests.ts](lib/api/requests.ts) and use `instanceof` to validate — passing a plain object intentionally throws. This is by design; do not loosen the check. `SendEmailRequest` additionally exposes `attach(name, data, { encode })` which base64-encodes by default. + +### `triggerBroadcast` recipient-shape rule + +`APIClient.triggerBroadcast` ([lib/api.ts](lib/api.ts)) inspects the `recipients` arg for one of the "custom" recipient fields (`ids`, `emails`, `per_user_data`, `data_file_url`); when one is present, it whitelists only the fields allowed for that key (per `BROADCASTS_ALLOWED_RECIPIENT_FIELDS`) and sends them flat alongside `data`. Otherwise it falls through and sends the full `recipients` object nested under `recipients`. Keep the whitelist in sync with the API docs if adding fields. + +### Public surface + +[index.ts](index.ts) is the package entry — only what's re-exported there is public API. Anything not re-exported (e.g. internal utils, `Request`) is implementation detail. `IdentifierType` and `CustomerIORequestError` are exported by name; everything else flows through `export *`. + +## Conventions specific to this repo + +- TypeScript is strict (`tsconfig.json`: `strict`, `noImplicitAny`, `strictNullChecks`, `noUnusedParameters`). The build sets `noEmitOnError: true`, so type errors block `dist/` output. +- Tests use **ava + sinon + nyc**, written in TypeScript and executed via `ts-node/register` (configured in `package.json#ava`). Mirror existing patterns: `sinon.stub` `Request.prototype` methods rather than hitting the network. +- New code paths must come with tests — the 100% coverage gate will reject untested branches. +- Customer-id values flowing into URL paths are `encodeURIComponent`-ed (see `track.ts`); preserve this when adding new endpoints that take ids in the path. +- `isEmpty` / `MissingParamError` ([lib/utils.ts](lib/utils.ts)) is the standard required-param check — use it for new methods rather than ad-hoc validation. diff --git a/README.md b/README.md index 304ba70..fda6853 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,7 @@ api ### api.triggerBroadcast(campaign_id, data, recipients) -Trigger an email broadcast using the email campaign's id. You can also optionally pass along custom data that will be merged with the liquid template, and additional conditions to filter recipients. +Trigger an email broadcast using the broadcast ID. You can also optionally pass along custom data that will be merged with the liquid template, and additional conditions to filter recipients. ```javascript api.triggerBroadcast(1, { name: "foo" }, { segment: { id: 7 } }); diff --git a/lib/api.ts b/lib/api.ts index e96bda0..b5ba0a6 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -123,7 +123,7 @@ export class APIClient { return this.request.get(`${this.apiRoot}/customers?email=${cleanEmail(email)}`); } - triggerBroadcast(id: string | number, data: RequestData, recipients: Recipients) { + triggerBroadcast(broadcastId: string | number, data: RequestData, recipients: Recipients) { let payload = {}; let customRecipientField = ( Object.keys(BROADCASTS_ALLOWED_RECIPIENT_FIELDS) as BroadcastsAllowedRecipientFieldsKeys[] @@ -138,7 +138,7 @@ export class APIClient { }; } - return this.request.post(`${this.apiRoot}/api/campaigns/${id}/triggers`, payload); + return this.request.post(`${this.apiRoot}/campaigns/${broadcastId}/triggers`, payload); } listExports() { diff --git a/test/api.ts b/test/api.ts index ed73a68..dd68f6a 100644 --- a/test/api.ts +++ b/test/api.ts @@ -255,7 +255,7 @@ test('#triggerBroadcast works', (t) => { sinon.stub(t.context.client.request, 'post'); t.context.client.triggerBroadcast(1, { type: 'data' }, { type: 'recipients' }); t.truthy( - (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/api/campaigns/1/triggers`, { + (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/campaigns/1/triggers`, { data: { type: 'data' }, recipients: { type: 'recipients' }, }), @@ -274,7 +274,7 @@ test('#triggerBroadcast works with emails', (t) => { }, ); t.truthy( - (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/api/campaigns/1/triggers`, { + (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/campaigns/1/triggers`, { data: { type: 'data' }, emails: ['test@email.com'], email_ignore_missing: true, @@ -287,7 +287,7 @@ test('#triggerBroadcast works with ids', (t) => { sinon.stub(t.context.client.request, 'post'); t.context.client.triggerBroadcast(1, { type: 'data' }, { ids: [1], id_ignore_missing: true }); t.truthy( - (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/api/campaigns/1/triggers`, { + (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/campaigns/1/triggers`, { data: { type: 'data' }, ids: [1], id_ignore_missing: true, @@ -300,7 +300,7 @@ test('#triggerBroadcast works with per_user_data', (t) => { const per_user_data = [{ id: 1, data: { very: 'important' } }]; t.context.client.triggerBroadcast(1, { type: 'data' }, { per_user_data, id_ignore_missing: true }); t.truthy( - (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/api/campaigns/1/triggers`, { + (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/campaigns/1/triggers`, { data: { type: 'data' }, per_user_data, id_ignore_missing: true, @@ -313,7 +313,7 @@ test('#triggerBroadcast works with data_file_url', (t) => { const data_file_url = 'https://my.s3.bucket.com'; t.context.client.triggerBroadcast(1, { type: 'data' }, { data_file_url, id_ignore_missing: true }); t.truthy( - (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/api/campaigns/1/triggers`, { + (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/campaigns/1/triggers`, { data: { type: 'data' }, data_file_url, id_ignore_missing: true, @@ -334,7 +334,7 @@ test('#triggerBroadcast discards extraneous fields', (t) => { }, ); t.truthy( - (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/api/campaigns/1/triggers`, { + (t.context.client.request.post as SinonStub).calledWith(`${RegionUS.apiUrl}/campaigns/1/triggers`, { data: { type: 'data' }, ids: [1], id_ignore_missing: true, From 84c43bb5ffc1bbd92b4b126a51904822cb73c938 Mon Sep 17 00:00:00 2001 From: John Washam Date: Thu, 7 May 2026 14:43:57 -0700 Subject: [PATCH 2/3] Updates test to also accept JSON errors in Node 19+ --- test/request.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/request.ts b/test/request.ts index 08aad55..0fb26fe 100644 --- a/test/request.ts +++ b/test/request.ts @@ -345,10 +345,7 @@ test.serial('#handler makes a request and rejects with a bad JSON response', asy t.fail(); } catch (err: any) { - t.is( - err.message, - 'Unable to parse JSON. Error: SyntaxError: Unexpected token < in JSON at position 0 \nBody:\n ', - ); + t.regex(err.message, /Unexpected token <|Unable to parse JSON/); } }); From 71b61af7a495f2df61237d9d5ddc999bbdcd854a Mon Sep 17 00:00:00 2001 From: John Washam Date: Thu, 7 May 2026 14:51:58 -0700 Subject: [PATCH 3/3] Removes CLAUDE.md --- CLAUDE.md | 48 ------------------------------------------------ 1 file changed, 48 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 3fd81e7..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,48 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -- `npm test` — runs `nyc ava` (TypeScript tests via ts-node) with coverage. The `nyc` config in `package.json` enforces **100% branch/line/function/statement coverage** — coverage gaps fail the build. -- `npm run build` — `tsc -p tsconfig.json`, emits to `dist/` (the published artifact; `package.json#main` points there). -- Run a single test file: `npx ava test/track.ts`. Single test by title: `npx ava test/track.ts -m "constructor sets necessary variables"`. -- `npm run version` — regenerates `lib/version.ts` from `package.json#version`. Always run this (or update by hand) when bumping the version; the husky `pre-commit` hook runs `check-version.ts` and **fails the commit** if `lib/version.ts` and `package.json` disagree. The `User-Agent` header sent on every request reads from `lib/version.ts`. -- The pre-commit hook also runs `pretty-quick --staged` (Prettier on staged files; config in `.prettierrc.js` — 120-col, single-quote, trailing-comma all). -- CI (`.github/workflows/main.yml`) runs `npm test` against Node 14, 15, 16, 17, 18 — keep changes compatible with Node 14+. - -## Architecture - -Two top-level clients wrap two distinct Customer.io APIs, each with its own auth scheme and base URL. Both share one `Request` transport. - -- **`TrackClient`** ([lib/track.ts](lib/track.ts)) — Track API. Auth: HTTP Basic from `(siteId, apiKey)`. Base URL: `Region.trackUrl`. Identify, track events, devices, suppress, merge customers. -- **`APIClient`** ([lib/api.ts](lib/api.ts)) — App/Transactional API. Auth: Bearer `appKey`. Base URL: `Region.apiUrl`. Transactional sends, broadcasts, exports, attribute lookups. - -`Region` ([lib/regions.ts](lib/regions.ts)) bundles both URLs together — `RegionUS` and `RegionEU` are the two exported instances. Construction validates `region instanceof Region` (so plain objects are rejected). Override `defaults.url` to point at a custom host (used in tests). - -`Request` ([lib/request.ts](lib/request.ts)) is the single HTTP layer for both clients. It uses **only the Node built-in `https` module** — no axios, fetch, or other HTTP dependency. Behavior worth knowing before touching it: - -- Auth header is computed once in the constructor based on whether `auth` is `BasicAuth` (object) or `BearerAuth` (string). -- Default timeout 10s, overridable via `defaults` passed to the client constructor. -- `handler` follows 301/302/307/308 redirects by recursing on `Location`. -- Only 200 and 201 resolve; everything else rejects with `CustomerIORequestError` carrying `statusCode`, `response` (`IncomingMessage`), and raw `body`. `composeMessage` extracts `meta.error` / `meta.errors` from the JSON body. - -### Transactional request objects - -`api.sendEmail / sendPush / sendSMS / sendInboxMessage / sendInApp` each take a **specific request class instance** from [lib/api/requests.ts](lib/api/requests.ts) and use `instanceof` to validate — passing a plain object intentionally throws. This is by design; do not loosen the check. `SendEmailRequest` additionally exposes `attach(name, data, { encode })` which base64-encodes by default. - -### `triggerBroadcast` recipient-shape rule - -`APIClient.triggerBroadcast` ([lib/api.ts](lib/api.ts)) inspects the `recipients` arg for one of the "custom" recipient fields (`ids`, `emails`, `per_user_data`, `data_file_url`); when one is present, it whitelists only the fields allowed for that key (per `BROADCASTS_ALLOWED_RECIPIENT_FIELDS`) and sends them flat alongside `data`. Otherwise it falls through and sends the full `recipients` object nested under `recipients`. Keep the whitelist in sync with the API docs if adding fields. - -### Public surface - -[index.ts](index.ts) is the package entry — only what's re-exported there is public API. Anything not re-exported (e.g. internal utils, `Request`) is implementation detail. `IdentifierType` and `CustomerIORequestError` are exported by name; everything else flows through `export *`. - -## Conventions specific to this repo - -- TypeScript is strict (`tsconfig.json`: `strict`, `noImplicitAny`, `strictNullChecks`, `noUnusedParameters`). The build sets `noEmitOnError: true`, so type errors block `dist/` output. -- Tests use **ava + sinon + nyc**, written in TypeScript and executed via `ts-node/register` (configured in `package.json#ava`). Mirror existing patterns: `sinon.stub` `Request.prototype` methods rather than hitting the network. -- New code paths must come with tests — the 100% coverage gate will reject untested branches. -- Customer-id values flowing into URL paths are `encodeURIComponent`-ed (see `track.ts`); preserve this when adding new endpoints that take ids in the path. -- `isEmpty` / `MissingParamError` ([lib/utils.ts](lib/utils.ts)) is the standard required-param check — use it for new methods rather than ad-hoc validation.