Skip to content
Closed
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
19 changes: 10 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,16 @@ the schema.

#### Examples

Runnable scripts in `packages/payments/examples/` (`receive.ts`, `send.ts`) run
against the live API with credentials from `examples/.env` (gitignored; copy
`examples/.env.example`). `verify-webhook.mjs`/`verify-webhook.cjs` need no
credentials (webhook verification is offline) and run in CI as
`pnpm run test:examples`, exercising the built `dist`/`dist-cjs` output of
both packages — the regression check for the dual build. `send.cts`/
`receive.cts` are CJS counterparts of `send.ts`/`receive.ts` that are only
type-checked in CI (`pnpm run typecheck:examples`), never executed, since
running them would hit the live API the same as their `.ts` counterparts.
Runnable scripts in `packages/payments/examples/` (`receive.ts`, `send.ts`,
`watch.ts`) run against the live API with credentials from `examples/.env`
(gitignored; copy `examples/.env.example`). `verify-webhook.mjs`/
`verify-webhook.cjs` need no credentials (webhook verification is offline)
and run in CI as `pnpm run test:examples`, exercising the built
`dist`/`dist-cjs` output of both packages — the regression check for the
dual build. `send.cts`/`receive.cts`/`watch.cts` are CJS counterparts of
`send.ts`/`receive.ts`/`watch.ts` that are only type-checked in CI
(`pnpm run typecheck:examples`), never executed, since running them would
hit the live API the same as their `.ts` counterparts.

## Key constraints

Expand Down
5 changes: 5 additions & 0 deletions packages/payments/examples/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ AMBOSS_API_KEY=
# Amount to invoice, in the wallet asset's base unit (sats for BTC). Default 1000.
# RECEIVE_AMOUNT_SATS=1000
# RECEIVE_DESCRIPTION=

# --- Watch (examples/watch.ts) ---------------------------------------------
# Transaction to stream events for. Leave unset to mint a fresh invoice via
# WALLET_ID + RECEIVE_AMOUNT_SATS (above) and watch that instead.
# TRANSACTION_ID=
39 changes: 39 additions & 0 deletions packages/payments/examples/watch.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* CJS type-check example for @ambosstech/payments' streaming flow -- the
* compile-only counterpart to watch.ts. Written as `require()`-based
* CommonJS TypeScript (`.cts` forces CJS module/resolution semantics under
* `moduleResolution: NodeNext`, so the `@ambosstech/payments` import below
* resolves via the package's `require` export condition against the built
* `dist-cjs/` declarations).
*
* Like watch.ts, running this for real would mint a live invoice and open a
* live SSE connection -- so this file is only type-checked in CI, never
* executed:
* pnpm exec tsc --noEmit -p tsconfig.examples.cjs.json
*/
import type * as PaymentsModule from '@ambosstech/payments';

const { Payments, ApiError } = require('@ambosstech/payments') as typeof PaymentsModule;

async function watch(): Promise<void> {
const payments = new Payments({ serviceApiKey: 'amb_live_example' });

const transaction = await payments.transactions.createReceive({
wallet_id: 'wallet_1',
amount: '1000',
});

try {
for await (const event of payments.transactions.watch(transaction.id)) {
console.log(event.event_type, event.data);
}
} catch (error) {
if (error instanceof ApiError) {
console.error('API error:', error.status, error.message, error.graphqlErrors);
} else {
console.error('unexpected error:', error);
}
}
}

watch().catch((error: unknown) => console.error(error));
78 changes: 78 additions & 0 deletions packages/payments/examples/watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Manual smoke test for the @ambosstech/payments SDK streaming flow.
*
* Run from packages/payments (Node 24 runs .ts directly):
* node --env-file=examples/.env examples/watch.ts
* or with tsx:
* pnpm exec tsx --env-file=examples/.env examples/watch.ts
*
* Behaviour:
* 1. If TRANSACTION_ID is unset, mints a Lightning invoice via
* transactions.createReceive (same as receive.ts -- needs WALLET_ID) and
* watches that.
* 2. If TRANSACTION_ID is set, watches it directly.
* 3. Opens an SSE connection via transactions.watch and logs each
* PaymentEvent as it arrives.
*
* This blocks until the server closes the stream -- the transaction reaches a
* terminal status (COMPLETED/FAILED/EXPIRED) or the token's 30-minute max
* stream lifetime elapses. Ctrl-C to exit early.
*
* Copy examples/.env.example -> examples/.env and fill it in. Do NOT commit .env.
*/
import { Payments, ApiError } from '@ambosstech/payments';

function required(name: string): string {
const value = process.env[name];
if (!value) {
console.error(`Missing required env var: ${name}`);
process.exit(1);
}
return value;
}

async function main(): Promise<void> {
const serviceApiKey = required('AMBOSS_API_KEY'); // the scoped payments service key (amb_live...)
const baseUrl = process.env.AMBOSS_BASE_URL; // optional; defaults to https://rails.amboss.tech/graphql

const payments = new Payments({ serviceApiKey, ...(baseUrl ? { baseUrl } : {}) });

let transactionId = process.env.TRANSACTION_ID;

if (!transactionId) {
const walletId = required('WALLET_ID'); // needed to mint an invoice to watch
const amount = process.env.RECEIVE_AMOUNT_SATS ?? '1000';

console.log('--- no TRANSACTION_ID set, minting an invoice to watch ---');
const transaction = await payments.transactions.createReceive({
wallet_id: walletId,
amount,
});
transactionId = transaction.id;
console.log('payment_request:', transaction.payment_request);
}

console.log(`\n--- watching transaction ${transactionId} ---`);
console.log(
'(waiting for events; blocks until the transaction settles/expires or the stream closes)\n',
);

try {
for await (const event of payments.transactions.watch(transactionId)) {
console.log(`[${new Date().toISOString()}] ${event.event_type}`, JSON.stringify(event.data));
}
console.log('\n--- stream closed ---');
} catch (error) {
if (error instanceof ApiError) {
console.error('\n❌ API error:', error.status, error.message, error.graphqlErrors);
} else {
console.error('\n❌ unexpected error:', error);
}
process.exit(1);
}
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
48 changes: 48 additions & 0 deletions packages/payments/src/resources/streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,51 @@ describe('Wallets.watchEvents', () => {
assert.deepEqual(events, [SAMPLE_EVENT]);
});
});

describe('Transactions.watch — mid-stream error handling', () => {
it('rejects with NetworkError (unwrapped) for a malformed JSON event payload', async () => {
const sse = `data: {not valid json\n\n`;
const payments = new Payments({
serviceApiKey: 'amb_live_test',
fetch: buildFetch({ streamBody: sse }),
});

await assert.rejects(
() => collect(payments.transactions.watch('tx1')),
(err: unknown) =>
err instanceof NetworkError &&
err.message === 'Received malformed SSE payment event payload',
);
});

it('rejects with NetworkError when the response body stream errors after opening', async () => {
const encoder = new TextEncoder();
let pulled = false;
const erroringBody = new ReadableStream<Uint8Array>({
pull(controller) {
if (!pulled) {
// First read succeeds and is consumed by parseSseStream before the
// second read (below) rejects — proves the error surfaces only
// after some events have already been yielded, not just up front.
pulled = true;
controller.enqueue(encoder.encode(`data: ${JSON.stringify(SAMPLE_EVENT)}\n\n`));
return;
}
controller.error(new Error('connection reset'));
},
});
const payments = new Payments({
serviceApiKey: 'amb_live_test',
fetch: buildFetch({ streamBody: erroringBody }),
});

const events: PaymentEvent[] = [];
await assert.rejects(
async () => {
for await (const event of payments.transactions.watch('tx1')) events.push(event);
},
(err: unknown) => err instanceof NetworkError,
);
assert.deepEqual(events, [SAMPLE_EVENT]);
});
});
Loading