diff --git a/CHANGELOG.md b/CHANGELOG.md index eed918d..60da2f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,14 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [2.5.1] - 2026-09-18 + ### Added +- Every request now carries `X-SudoMock-Client: node-sdk/`, and the + same value as `User-Agent` wherever the runtime lets a script set one. The + API can tell this SDK's traffic, and its exact version, apart from every + other client. Browsers reserve `User-Agent` and drop it; the request is still + identified by `X-SudoMock-Client`. - `UsageInfo.prepaidBalance` and `UsageInfo.prepaidBalanceCurrency`, the money an account holds and spends per render. An account funded this way carries no subscription allowance, so its `creditsLimit` and `creditsRemaining` are @@ -13,6 +20,9 @@ project adheres to [Semantic Versioning](https://semver.org/). `0 / 0 credits` with a bar stuck at 0%. ### Fixed +- The version in `User-Agent` had been a hand-copied constant and reported + `2.3.0` through the 2.4.0 and 2.5.0 releases. It is now read from + `package.json` at build time, so it cannot lag behind the published version. - `account.get()` rebuilds its result key by key, which silently dropped any field not named in that map. The API had been sending the balance already; the SDK discarded it before the caller ever saw it. Both fields are now mapped, @@ -21,6 +31,10 @@ project adheres to [Semantic Versioning](https://semver.org/). Nothing was removed or renamed. +### Changed +- The `User-Agent` prefix is `node-sdk/` (was `sudomock-node/`), matching the + `X-SudoMock-Client` value byte for byte. + ### Changed (BREAKING) - Placement is typed per target kind, and sizing has exactly one answer per request. `AISurfacePlacement` takes a `coverage` percentage OR an explicit diff --git a/README.md b/README.md index 053a97a..c5800f1 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ const client = new SudoMock('sm_xxx', { The API key can be passed as the first argument or via the `SUDOMOCK_API_KEY` environment variable. +Every request identifies itself with `X-SudoMock-Client: node-sdk/` (and the same value as `User-Agent` where the runtime allows it), so you can pick the SDK's traffic out of your own logs and proxy rules. + ### Mockups ```typescript diff --git a/package-lock.json b/package-lock.json index 9e87447..ab35997 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sudomock", - "version": "2.3.0", + "version": "2.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sudomock", - "version": "2.3.0", + "version": "2.5.1", "license": "MIT", "devDependencies": { "@types/node": "^22.19.15", diff --git a/package.json b/package.json index c76b03b..ce4f91b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sudomock", - "version": "2.5.0", + "version": "2.5.1", "description": "Official Node.js/TypeScript SDK for the SudoMock API - mockup generation from PSD files", "type": "module", "main": "./dist/index.cjs", diff --git a/src/client.ts b/src/client.ts index c451228..bd9364b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -80,8 +80,21 @@ export interface ClientConfig { maxRetries: number } -/** SDK version, surfaced in the User-Agent header. Keep in sync with package.json. */ -const SDK_VERSION = '2.3.0' +/** + * Injected from package.json by tsup (build) and vitest (test), so the value + * can never lag behind the published version. Falls back when the source is + * run without either, e.g. through a plain TypeScript loader. + */ +declare const __SDK_VERSION__: string +export const SDK_VERSION: string = + typeof __SDK_VERSION__ === 'string' ? __SDK_VERSION__ : '0.0.0-dev' + +/** + * How this SDK introduces itself to the API. Sent as `X-SudoMock-Client` on + * every request, and as `User-Agent` wherever the runtime lets a script set + * one (browsers reserve `User-Agent` and drop it silently). + */ +export const CLIENT_ID = `node-sdk/${SDK_VERSION}` /** Initial backoff in ms for exponential retry */ const INITIAL_BACKOFF_MS = 500 @@ -262,12 +275,21 @@ export class HttpClient { options: RequestOptions, timeout: number, ): Promise { - const headers: Record = { + const headers = new Headers({ 'x-api-key': this.config.apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json', - 'User-Agent': `sudomock-node/${SDK_VERSION}`, - ...options.headers, + 'X-SudoMock-Client': CLIENT_ID, + }) + try { + headers.set('User-Agent', CLIENT_ID) + } catch { + // A runtime that refuses to let scripts set User-Agent (the browser rule) + // may throw instead of dropping it. The request is still identified by + // X-SudoMock-Client above, so the header is a courtesy, not a requirement. + } + for (const [name, value] of Object.entries(options.headers ?? {})) { + headers.set(name, value) } const init: RequestInit = { diff --git a/tests/client.test.ts b/tests/client.test.ts index 20a797a..e2f3eb5 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { describe, it, expect, vi } from 'vitest' import { http, HttpResponse } from 'msw' import SudoMock from '../src/index' +import { CLIENT_ID, HttpClient, SDK_VERSION } from '../src/client' import { SudoMockError, AuthenticationError, @@ -53,6 +55,88 @@ describe('SudoMock client', () => { }) }) +// --------------------------------------------------------------------------- +// Client identity headers +// --------------------------------------------------------------------------- + +describe('client identity', () => { + const packageVersion = ( + JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), + ) as { version: string } + ).version + + /** Capture the request headers of the next GET /mockups. */ + function captureListHeaders(): () => Headers | undefined { + let captured: Headers | undefined + server.use( + http.get(`${TEST_BASE_URL}/api/v1/mockups`, ({ request }) => { + captured = request.headers + return HttpResponse.json({ + success: true, + data: { mockups: [], total: 0, limit: 20, offset: 0 }, + }) + }), + ) + return () => captured + } + + it('reports the package.json version, not a hand-copied constant', () => { + expect(SDK_VERSION).toBe(packageVersion) + expect(CLIENT_ID).toBe(`node-sdk/${packageVersion}`) + }) + + it('sends X-SudoMock-Client and User-Agent as node-sdk/ on every request', async () => { + const headers = captureListHeaders() + + await createClient().mockups.list() + + expect(headers()?.get('X-SudoMock-Client')).toBe(`node-sdk/${packageVersion}`) + expect(headers()?.get('User-Agent')).toBe(`node-sdk/${packageVersion}`) + }) + + it('still identifies itself when the runtime refuses a User-Agent header (browser rule)', async () => { + const originalSet = Headers.prototype.set + const spy = vi + .spyOn(Headers.prototype, 'set') + .mockImplementation(function (this: Headers, name: string, value: string) { + if (name.toLowerCase() === 'user-agent' && value === CLIENT_ID) { + throw new TypeError('User-Agent is a forbidden request header') + } + return originalSet.call(this, name, value) + }) + try { + const headers = captureListHeaders() + + await createClient().mockups.list() + + expect(headers()?.get('X-SudoMock-Client')).toBe(`node-sdk/${packageVersion}`) + expect(headers()?.get('User-Agent')).not.toBe(CLIENT_ID) + } finally { + spy.mockRestore() + } + }) + + it('lets a per-request header override a default, case-insensitively', async () => { + const headers = captureListHeaders() + const transport = new HttpClient({ + apiKey: TEST_API_KEY, + baseUrl: TEST_BASE_URL, + timeout: 5_000, + maxRetries: 0, + }) + + await transport.request({ + method: 'GET', + path: '/api/v1/mockups', + headers: { accept: 'application/json; charset=utf-8' }, + }) + + expect(headers()?.get('Accept')).toBe('application/json; charset=utf-8') + expect(headers()?.get('X-SudoMock-Client')).toBe(CLIENT_ID) + }) +}) + // --------------------------------------------------------------------------- // Authentication errors // --------------------------------------------------------------------------- diff --git a/tsup.config.ts b/tsup.config.ts index fe84ea4..55da78b 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,5 +1,13 @@ +import { readFileSync } from 'node:fs' import { defineConfig } from 'tsup' +// package.json is the single source of the SDK version. It is injected here at +// build time (and by vitest.config.ts at test time) so the constant can never +// drift from the published version again. +const { version } = JSON.parse(readFileSync('package.json', 'utf8')) as { + version: string +} + export default defineConfig({ entry: ['src/index.ts'], format: ['esm', 'cjs'], @@ -10,4 +18,7 @@ export default defineConfig({ minify: false, target: 'node20', outDir: 'dist', + define: { + __SDK_VERSION__: JSON.stringify(version), + }, }) diff --git a/vitest.config.ts b/vitest.config.ts index c58da64..e71be15 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,16 @@ +import { readFileSync } from 'node:fs' import { defineConfig } from 'vitest/config' +// Same injection as tsup.config.ts: tests run against the unbundled source, so +// the version constant has to be supplied here too. +const { version } = JSON.parse(readFileSync('package.json', 'utf8')) as { + version: string +} + export default defineConfig({ + define: { + __SDK_VERSION__: JSON.stringify(version), + }, test: { globals: true, setupFiles: ['./tests/setup.ts'],