diff --git a/packages/post-kit-client/LICENSE b/packages/post-kit-client/LICENSE new file mode 100644 index 0000000..c8b783b --- /dev/null +++ b/packages/post-kit-client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Singleton SD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/post-kit-client/README.md b/packages/post-kit-client/README.md new file mode 100644 index 0000000..f8f3b86 --- /dev/null +++ b/packages/post-kit-client/README.md @@ -0,0 +1,78 @@ +# `@singleton-sd/post-kit-client` + +Thin typed SDK for **trusted server-side** Node.js / TypeScript consumers calling +the PostKit API (`POST /emails/send`). + +## Server-side only + +Do **not** ship this package (or long-lived `POSTKIT_API_KEY` values) to the +browser. Public contact forms and other client UIs must POST to **your own +server endpoint**; that intermediary then uses `PostKitClient` with +`POSTKIT_API_KEY` from Azure Key Vault (`ssd-global-kv-prod-ae`) only — never +from browser code or long-lived app settings. + +## Install + +```bash +pnpm add @singleton-sd/post-kit-client +``` + +## Usage + +```ts +import { PostKitClient } from '@singleton-sd/post-kit-client'; + +const postKit = new PostKitClient({ + endpoint: process.env.POSTKIT_URL!, + apiKey: process.env.POSTKIT_API_KEY!, +}); + +await postKit.send({ + template: 'marketing.contact-us', + to: 'hello@example.com', + variables: { name, email, message }, +}); +``` + +### Options + +| Option | Description | +| --- | --- | +| `endpoint` | Base URL of the PostKit API (trailing slash stripped) | +| `apiKey` | Bearer token for `Authorization` | +| `timeout` | Request timeout ms (default `30_000`). Pass `0` to disable; with no per-call `AbortSignal`, the request then runs indefinitely | +| `fetch` | Injectable `fetch` (for tests); defaults to `globalThis.fetch` | + +Auth lives on the constructor so the strategy can evolve without changing +`send()`. + +### Errors + +Non-2xx responses and client-side failures throw `PostKitRequestError`: + +- `status` — HTTP status when available +- `code` — API `PostKitErrorCode`, or `'TIMEOUT'` / `'NETWORK_ERROR'` +- `correlationId` — from the error body when present + +```ts +import { PostKitClient, PostKitRequestError } from '@singleton-sd/post-kit-client'; + +try { + await postKit.send(request, { signal: AbortSignal.timeout(5_000) }); +} catch (err) { + if (err instanceof PostKitRequestError) { + console.error(err.code, err.status, err.correlationId); + } + throw err; +} +``` + +Request/response bodies use types from `@singleton-sd/post-kit-types` +(`SendRequest`, `SendResponse`, `PostKitErrorResponse`). + +## Development + +```bash +pnpm test +pnpm build +``` diff --git a/packages/post-kit-client/package.json b/packages/post-kit-client/package.json new file mode 100644 index 0000000..6a912e6 --- /dev/null +++ b/packages/post-kit-client/package.json @@ -0,0 +1,38 @@ +{ + "name": "@singleton-sd/post-kit-client", + "version": "0.1.0", + "private": false, + "description": "Trusted server-side TypeScript client for the PostKit API", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE" + ], + "scripts": { + "build": "pnpm --filter @singleton-sd/post-kit-types run build && tsc -p tsconfig.json", + "lint": "echo \"lint:client — covered by root eslint on staged files\"", + "test": "pnpm --filter @singleton-sd/post-kit-types run build && tsc -p tsconfig.spec.json && node --import tsx --test src/**/*.spec.ts" + }, + "devDependencies": { + "@types/node": "^20.17.9", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=20.18.1" + }, + "dependencies": { + "@singleton-sd/post-kit-types": "workspace:*" + } +} diff --git a/packages/post-kit-client/src/client.spec.ts b/packages/post-kit-client/src/client.spec.ts new file mode 100644 index 0000000..3ea1063 --- /dev/null +++ b/packages/post-kit-client/src/client.spec.ts @@ -0,0 +1,268 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + PostKitErrorCode, + type SendRequest, + type SendResponse, +} from '@singleton-sd/post-kit-types'; +import { PostKitClient } from './client'; +import { PostKitRequestError } from './errors'; + +const SEND_REQUEST: SendRequest = { + template: 'marketing.contact-us', + to: 'hello@example.com', + variables: { name: 'Jane', email: 'jane@example.com', message: 'Hi' }, +}; + +const SEND_RESPONSE: SendResponse = { + id: 'corr-success-1', + status: 'sent', +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('PostKitClient', () => { + it('POSTs SendRequest to {endpoint}/emails/send with Bearer auth and returns SendResponse', async () => { + let capturedUrl = ''; + let capturedInit: RequestInit | undefined; + + const fetchMock: typeof fetch = async (input, init) => { + capturedUrl = String(input); + capturedInit = init; + return jsonResponse(200, SEND_RESPONSE); + }; + + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + fetch: fetchMock, + }); + + const result = await client.send(SEND_REQUEST); + + assert.equal(capturedUrl, 'https://postkit.example.com/emails/send'); + assert.equal(capturedInit?.method, 'POST'); + assert.equal( + (capturedInit?.headers as Record)['Authorization'], + 'Bearer pk_test_key', + ); + assert.equal( + (capturedInit?.headers as Record)['Content-Type'], + 'application/json', + ); + assert.deepEqual(JSON.parse(String(capturedInit?.body)), SEND_REQUEST); + assert.deepEqual(result, SEND_RESPONSE); + }); + + it('strips a trailing slash from the endpoint base URL', async () => { + let capturedUrl = ''; + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com/', + apiKey: 'pk_test_key', + fetch: async (input) => { + capturedUrl = String(input); + return jsonResponse(200, SEND_RESPONSE); + }, + }); + + await client.send(SEND_REQUEST); + assert.equal(capturedUrl, 'https://postkit.example.com/emails/send'); + }); + + it('throws PostKitRequestError with status, code, and correlationId on non-2xx', async () => { + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + fetch: async () => + jsonResponse(404, { + error: 'Template not found', + code: PostKitErrorCode.TEMPLATE_NOT_FOUND, + correlationId: 'corr-err-1', + }), + }); + + await assert.rejects( + () => client.send(SEND_REQUEST), + (err: unknown) => { + assert.ok(err instanceof PostKitRequestError); + assert.equal(err.status, 404); + assert.equal(err.code, PostKitErrorCode.TEMPLATE_NOT_FOUND); + assert.equal(err.correlationId, 'corr-err-1'); + assert.match(err.message, /Template not found/); + return true; + }, + ); + }); + + it('throws TIMEOUT when the request exceeds the client timeout', async () => { + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + timeout: 20, + fetch: async (_input, init) => { + await new Promise((resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error('expected AbortSignal')); + return; + } + if (signal.aborted) { + reject(new DOMException('The operation was aborted.', 'AbortError')); + return; + } + signal.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted.', 'AbortError')), + { once: true }, + ); + }); + return jsonResponse(200, SEND_RESPONSE); + }, + }); + + await assert.rejects( + () => client.send(SEND_REQUEST), + (err: unknown) => { + assert.ok(err instanceof PostKitRequestError); + assert.equal(err.code, 'TIMEOUT'); + assert.equal(err.status, undefined); + return true; + }, + ); + }); + + it('throws TIMEOUT when fetch rejects with native TimeoutError', async () => { + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + fetch: async () => { + throw new DOMException('The operation timed out.', 'TimeoutError'); + }, + }); + + await assert.rejects( + () => client.send(SEND_REQUEST), + (err: unknown) => { + assert.ok(err instanceof PostKitRequestError); + assert.equal(err.code, 'TIMEOUT'); + return true; + }, + ); + }); + + it('throws TIMEOUT when response body read aborts after headers arrive', async () => { + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + timeout: 20, + fetch: async (_input, init) => + ({ + ok: true, + json: async () => { + await new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error('expected AbortSignal')); + return; + } + signal.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted.', 'AbortError')), + { once: true }, + ); + }); + return SEND_RESPONSE; + }, + }) as Response, + }); + + await assert.rejects( + () => client.send(SEND_REQUEST), + (err: unknown) => { + assert.ok(err instanceof PostKitRequestError); + assert.equal(err.code, 'TIMEOUT'); + return true; + }, + ); + }); + + it('threads a custom AbortSignal through to fetch', async () => { + const controller = new AbortController(); + let seenSignal: AbortSignal | undefined; + + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + timeout: 0, + fetch: async (_input, init) => { + seenSignal = init?.signal ?? undefined; + return jsonResponse(200, SEND_RESPONSE); + }, + }); + + await client.send(SEND_REQUEST, { signal: controller.signal }); + assert.ok(seenSignal); + assert.equal(seenSignal.aborted, false); + controller.abort(); + assert.equal(seenSignal.aborted, true); + }); + + it('throws NETWORK_ERROR when fetch fails with a non-abort error', async () => { + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + fetch: async () => { + throw new TypeError('fetch failed'); + }, + }); + + await assert.rejects( + () => client.send(SEND_REQUEST), + (err: unknown) => { + assert.ok(err instanceof PostKitRequestError); + assert.equal(err.code, 'NETWORK_ERROR'); + assert.equal(err.status, undefined); + assert.match(err.message, /fetch failed/); + return true; + }, + ); + }); + + it('rethrows when the caller AbortSignal aborts the request', async () => { + const controller = new AbortController(); + const client = new PostKitClient({ + endpoint: 'https://postkit.example.com', + apiKey: 'pk_test_key', + timeout: 30_000, + fetch: async (_input, init) => { + await new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error('expected AbortSignal')); + return; + } + signal.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted.', 'AbortError')), + { once: true }, + ); + }); + return jsonResponse(200, SEND_RESPONSE); + }, + }); + + const pending = client.send(SEND_REQUEST, { signal: controller.signal }); + controller.abort(); + + await assert.rejects(pending, (err: unknown) => { + assert.ok(err instanceof DOMException || (err instanceof Error && err.name === 'AbortError')); + assert.notEqual(err instanceof PostKitRequestError && err.code === 'TIMEOUT', true); + return true; + }); + }); +}); diff --git a/packages/post-kit-client/src/client.ts b/packages/post-kit-client/src/client.ts new file mode 100644 index 0000000..566ff77 --- /dev/null +++ b/packages/post-kit-client/src/client.ts @@ -0,0 +1,174 @@ +import type { PostKitErrorResponse, SendRequest, SendResponse } from '@singleton-sd/post-kit-types'; +import { PostKitRequestError } from './errors'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Options for {@link PostKitClient}. + * + * Auth is configured here (Bearer `apiKey`) so the strategy can evolve without + * changing the `send()` surface. + */ +export interface PostKitClientOptions { + /** Base URL of the PostKit API (trailing slash is stripped). */ + endpoint: string; + /** Bearer token for the `Authorization` header. */ + apiKey: string; + /** + * Request timeout in milliseconds. Defaults to `30_000`. + * Pass `0` to disable the client timeout (the request then runs until the + * optional per-call `AbortSignal` fires, or indefinitely if none is given). + */ + timeout?: number; + /** Injectable `fetch` implementation (defaults to `globalThis.fetch`). */ + fetch?: typeof globalThis.fetch; +} + +export interface SendOptions { + /** Optional abort signal threaded through to `fetch`. */ + signal?: AbortSignal; +} + +/** + * Thin typed client for trusted server-side callers of the PostKit API. + * + * Do not embed long-lived API keys in browser code. Public forms must POST to + * your own server endpoint, which then calls PostKit with this client. + */ +export class PostKitClient { + private readonly endpoint: string; + private readonly apiKey: string; + private readonly timeoutMs: number; + private readonly fetchImpl: typeof globalThis.fetch; + + constructor(options: PostKitClientOptions) { + if (!options.endpoint) { + throw new Error('PostKitClient: endpoint is required'); + } + if (!options.apiKey) { + throw new Error('PostKitClient: apiKey is required'); + } + this.endpoint = options.endpoint.replace(/\/+$/, ''); + this.apiKey = options.apiKey; + this.timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS; + this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis); + } + + /** + * Send a templated email via `POST {endpoint}/emails/send`. + */ + async send(request: SendRequest, options?: SendOptions): Promise { + const url = `${this.endpoint}/emails/send`; + const callerSignal = options?.signal; + + try { + const response = await this.fetchImpl(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + signal: this.combineSignals(callerSignal), + }); + + if (response.ok) { + return (await response.json()) as SendResponse; + } + + throw await this.mapHttpError(response); + } catch (err) { + if (err instanceof PostKitRequestError) { + throw err; + } + throw this.mapFetchError(err, callerSignal); + } + } + + private combineSignals(callerSignal: AbortSignal | undefined): AbortSignal | undefined { + const signals: AbortSignal[] = []; + if (callerSignal) { + signals.push(callerSignal); + } + if (this.timeoutMs > 0) { + signals.push(AbortSignal.timeout(this.timeoutMs)); + } + if (signals.length === 0) { + return undefined; + } + if (signals.length === 1) { + return signals[0]; + } + return AbortSignal.any(signals); + } + + private mapFetchError(err: unknown, callerSignal: AbortSignal | undefined): never { + if (isTimeoutLikeError(err)) { + if (callerSignal?.aborted) { + throw err; + } + throw new PostKitRequestError({ + message: 'PostKit request timed out', + code: 'TIMEOUT', + cause: err, + }); + } + + if (isAbortError(err)) { + if (callerSignal?.aborted) { + throw err; + } + throw new PostKitRequestError({ + message: 'PostKit request timed out', + code: 'TIMEOUT', + cause: err, + }); + } + + const message = err instanceof Error ? err.message : 'Network request failed'; + throw new PostKitRequestError({ + message, + code: 'NETWORK_ERROR', + cause: err, + }); + } + + private async mapHttpError(response: Response): Promise { + let body: Partial = {}; + try { + body = (await response.json()) as Partial; + } catch { + // Non-JSON error body — fall through with empty fields. + } + + const message = + typeof body.error === 'string' && body.error.length > 0 + ? body.error + : `PostKit request failed with status ${response.status}`; + + throw new PostKitRequestError({ + message, + code: body.code ?? `HTTP_${response.status}`, + status: response.status, + correlationId: body.correlationId, + }); + } +} + +function isAbortError(err: unknown): boolean { + return ( + (err instanceof Error && err.name === 'AbortError') || + (typeof DOMException !== 'undefined' && + err instanceof DOMException && + err.name === 'AbortError') + ); +} + +function isTimeoutLikeError(err: unknown): boolean { + return ( + (err instanceof Error && err.name === 'TimeoutError') || + (typeof DOMException !== 'undefined' && + err instanceof DOMException && + err.name === 'TimeoutError') + ); +} diff --git a/packages/post-kit-client/src/errors.ts b/packages/post-kit-client/src/errors.ts new file mode 100644 index 0000000..b21679c --- /dev/null +++ b/packages/post-kit-client/src/errors.ts @@ -0,0 +1,31 @@ +import type { PostKitErrorCode } from '@singleton-sd/post-kit-types'; + +/** + * Error thrown by {@link PostKitClient} for HTTP, timeout, and network failures. + * + * `code` is a {@link PostKitErrorCode} from the API, or `'TIMEOUT'` / `'NETWORK_ERROR'` + * for client-side failures. + */ +export class PostKitRequestError extends Error { + readonly status: number | undefined; + readonly code: PostKitErrorCode | 'TIMEOUT' | 'NETWORK_ERROR' | string; + readonly correlationId: string | undefined; + + constructor(options: { + message: string; + code: PostKitErrorCode | 'TIMEOUT' | 'NETWORK_ERROR' | string; + status?: number; + correlationId?: string; + cause?: unknown; + }) { + super(options.message); + this.name = 'PostKitRequestError'; + this.status = options.status; + this.code = options.code; + this.correlationId = options.correlationId; + if (options.cause !== undefined) { + // Assign after super for ES2021 targets without ErrorOptions in lib. + (this as Error & { cause?: unknown }).cause = options.cause; + } + } +} diff --git a/packages/post-kit-client/src/index.ts b/packages/post-kit-client/src/index.ts new file mode 100644 index 0000000..06109e2 --- /dev/null +++ b/packages/post-kit-client/src/index.ts @@ -0,0 +1,2 @@ +export { PostKitClient, type PostKitClientOptions, type SendOptions } from './client'; +export { PostKitRequestError } from './errors'; diff --git a/packages/post-kit-client/tsconfig.json b/packages/post-kit-client/tsconfig.json new file mode 100644 index 0000000..7989add --- /dev/null +++ b/packages/post-kit-client/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/packages/post-kit-client/tsconfig.spec.json b/packages/post-kit-client/tsconfig.spec.json new file mode 100644 index 0000000..c4325e5 --- /dev/null +++ b/packages/post-kit-client/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "module": "es2022", + "moduleResolution": "bundler" + }, + "include": ["src/**/*"], + "exclude": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39f9d0a..b8712f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,22 @@ importers: specifier: ^5.7.2 version: 5.9.3 + packages/post-kit-client: + dependencies: + '@singleton-sd/post-kit-types': + specifier: workspace:* + version: link:../post-kit-types + devDependencies: + '@types/node': + specifier: ^20.17.9 + version: 20.19.43 + tsx: + specifier: ^4.19.2 + version: 4.23.12 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + packages/post-kit-compiler: dependencies: '@singleton-sd/post-kit-types':