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
5 changes: 5 additions & 0 deletions .changeset/bind-default-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@passmint/node': patch
---

Bind the default `fetch` to `globalThis` so the SDK works on Cloudflare Workers. The client stored the bare `globalThis.fetch` and invoked it as a method (`this.fetchImpl(...)`); workerd requires `fetch` to be called with its own global as the receiver, so every request threw `TypeError: Illegal invocation` unless a custom `fetch` was passed in options. Node's undici is not receiver-sensitive, which is why tests never caught it.
7 changes: 6 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,12 @@ export class PassmintHttpClient {
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES
this.fetchImpl = options.fetch ?? globalThis.fetch
// Bind the default fetch to its own global: we invoke it as
// `this.fetchImpl(...)`, and receiver-sensitive runtimes (Cloudflare's
// workerd) throw "TypeError: Illegal invocation" when fetch is called
// with any other `this`. Node's undici doesn't care, so only Workers
// users ever saw it.
this.fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis)
if (!this.fetchImpl) {
throw new PassmintError(
'global fetch is not available; pass a fetch implementation in options.fetch',
Expand Down
27 changes: 27 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,33 @@ describe('PassmintHttpClient constructor', () => {
})
})

describe('default fetch binding', () => {
const originalFetch = globalThis.fetch

afterEach(() => {
globalThis.fetch = originalFetch
})

it('invokes the default global fetch without a foreign receiver (workerd compatibility)', async () => {
// Cloudflare's workerd throws "Illegal invocation" when fetch is called
// with anything other than its own global as `this`. Node's undici does
// not care, so reproduce workerd's strictness in the stub — if the
// client ever stores the default fetch unbound again, this test fails.
globalThis.fetch = async function (this: unknown, ...args: Parameters<typeof fetch>) {
void args
if (this !== undefined && this !== globalThis) {
throw new TypeError('Illegal invocation')
}
return new Response(JSON.stringify({ ok: true }), { status: 200 })
} as typeof fetch

const client = new PassmintHttpClient({ apiKey: 'pmk_test_1', maxRetries: 0 })
await expect(client.request({ method: 'GET', path: '/v1/me' })).resolves.toEqual({
ok: true,
})
})
})

describe('VERSION', () => {
it('matches package.json (regenerate with: node scripts/sync-version.mjs)', () => {
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
Expand Down
Loading