Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@ 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/<version>`, 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
legitimately `0` and reading only those reported a paying customer as
`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,
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>` (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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
32 changes: 27 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -262,12 +275,21 @@ export class HttpClient {
options: RequestOptions,
timeout: number,
): Promise<Response> {
const headers: Record<string, string> = {
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 = {
Expand Down
86 changes: 85 additions & 1 deletion tests/client.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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/<version> 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
// ---------------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions tsup.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
Expand All @@ -10,4 +18,7 @@ export default defineConfig({
minify: false,
target: 'node20',
outDir: 'dist',
define: {
__SDK_VERSION__: JSON.stringify(version),
},
})
10 changes: 10 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
Expand Down
Loading