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
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: CI

on:
push:
branches:
- master
pull_request:

jobs:
node:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22, 24]

steps:
- name: Checkout repo
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
registry-url: 'https://registry.npmjs.org'
cache: 'npm'

- name: Install deps
run: npm ci

- name: Build
run: npm run build

- name: Run Node.js tests
run: npm test

bun:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install deps
run: npm ci

- name: Build
run: bun run build

- name: Run Bun tests
run: bun test
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
with:
node-version: '22.x'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'

- name: Ensure package version is 3.x.x
run: |
Expand All @@ -28,7 +29,8 @@ jobs:
3.*) echo "OK: version is 3.x.x" ;;
*) echo "ERROR: package.json version must be 3.x.x for v3 tags" && exit 1 ;;
esac

- name: Publish to NPM with dist-tag "latest"
run: npm run prepack && npm publish --tag latest --//registry.npmjs.org/:_authToken="$NPM_TOKEN"
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
node_modules
*js
!test/fixtures/**/*.mjs
!test/scripts/**/*.mjs
cjs
esm
.idea
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "A library for integrating with Chargebee.",
"scripts": {
"prepack": "npm install && npm run build",
"test": "mocha -r ts-node/register 'test/**/*.test.ts'",
"test": "TS_NODE_PROJECT=./tsconfig.test.json mocha -r ts-node/register 'test/**/*.test.ts'",
"build": "npm run build-esm && npm run build-cjs",
"build-esm": "rm -rf esm && mkdir -p esm && tsc -p tsconfig.esm.json && echo '{\"type\":\"module\"}' > esm/package.json",
"build-cjs": "rm -rf cjs && mkdir -p cjs && tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > cjs/package.json",
Expand Down
36 changes: 36 additions & 0 deletions test/fixtures/load-esm-worker-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Runs inside a Node worker thread ({ type: 'module' }) to ensure the ESM
* worker bundle parses and exposes webhook exports in an isolated context.
*/
import { parentPort, workerData } from 'node:worker_threads';
import { pathToFileURL } from 'node:url';
import path from 'node:path';

const required = [
'default',
'WebhookEventType',
'WebhookContentType',
'basicAuthValidator',
'WebhookError',
'WebhookAuthenticationError',
'WebhookPayloadValidationError',
'WebhookPayloadParseError',
];

try {
const root = workerData.root;
const bundlePath = path.join(root, 'esm/chargebee.esm.worker.js');
const mod = await import(pathToFileURL(bundlePath).href);
const missing = required.filter((k) => mod[k] === undefined);
if (missing.length) {
parentPort.postMessage({ ok: false, error: 'missing exports', missing });
} else {
parentPort.postMessage({ ok: true });
}
} catch (err) {
parentPort.postMessage({
ok: false,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
}
116 changes: 116 additions & 0 deletions test/worker-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { expect } from 'chai';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Worker } from 'node:worker_threads';

/** `type` is supported at runtime for ESM workers; widen options until typings always include it. */
type WorkerThreadOptions = NonNullable<ConstructorParameters<typeof Worker>[1]> & {
type?: 'module' | 'classic';
};

const testDir = path.resolve(process.cwd(), 'test');
const repoRoot = path.join(testDir, '..');
const esmWorkerPath = path.join(repoRoot, 'esm/chargebee.esm.worker.js');

const hasBuiltEsmWorker = fs.existsSync(esmWorkerPath);

function describeIfEsmBuilt(title: string, fn: () => void): void {
if (hasBuiltEsmWorker) {
describe(title, fn);
return;
}
describe.skip(
`${title} (skipped: run \`npm run build\` or \`npm run test:worker-bundle\`)`,
fn,
);
}

const REQUIRED_VALUE_EXPORTS = [
'WebhookEventType',
'WebhookContentType',
'basicAuthValidator',
'WebhookError',
'WebhookAuthenticationError',
'WebhookPayloadValidationError',
'WebhookPayloadParseError',
] as const;

function assertWebhookExports(mod: Record<string, unknown>, label: string): void {
for (const name of REQUIRED_VALUE_EXPORTS) {
expect(mod, `${label} must export ${name}`).to.have.property(name);
expect(mod[name], `${label}.${name}`).to.not.equal(undefined);
}

expect(mod.default, `${label} default export`).to.be.a('function');
expect(mod.WebhookEventType, `${label}.WebhookEventType`).to.be.an('object');
expect(mod.WebhookContentType, `${label}.WebhookContentType`).to.equal(mod.WebhookEventType);
expect(mod.basicAuthValidator, `${label}.basicAuthValidator`).to.be.a('function');

const Err = mod.WebhookError as typeof Error;
expect(new Err('x')).to.be.instanceof(Error);
expect((new Err('x') as Error).name).to.equal('WebhookError');

const AuthErr = mod.WebhookAuthenticationError as typeof Error;
expect(new AuthErr('a')).to.be.instanceof(Err);

const ValErr = mod.WebhookPayloadValidationError as typeof Error;
expect(new ValErr('v')).to.be.instanceof(Err);

const ParseErr = mod.WebhookPayloadParseError as typeof Error;
expect(new ParseErr('p')).to.be.instanceof(Err);
}

describeIfEsmBuilt('Worker entry bundle (ESM)', () => {
it('exposes webhook value exports from built ESM worker entry', async function () {
try {
const mod = (await import(pathToFileURL(esmWorkerPath).href)) as Record<string, unknown>;
assertWebhookExports(mod, 'esm/chargebee.esm.worker.js');
} catch (error) {
const mod = require(esmWorkerPath);
assertWebhookExports(mod, 'esm/chargebee.esm.worker.js');
}
});
});

describeIfEsmBuilt('Worker thread can load ESM worker bundle', () => {
it('parses the bundle and receives all webhook exports inside a worker', function (done) {
const fixture = path.join(testDir, 'fixtures', 'load-esm-worker-bundle.mjs');
const workerOptions: WorkerThreadOptions = {
workerData: { root: repoRoot },
type: 'module',
};
const worker = new Worker(fixture, workerOptions);
let settled = false;
Comment thread
cb-srinaths marked this conversation as resolved.
const finish = (err?: Error): void => {
if (settled) {
return;
}
settled = true;
void worker.terminate().finally(() => {
if (err) {
done(err);
} else {
done();
}
});
};
worker.on('message', (msg: { ok: boolean; error?: string; missing?: string[]; stack?: string }) => {
try {
expect(msg.ok, JSON.stringify(msg)).to.be.true;
finish();
} catch (e) {
finish(e as Error);
}
});
worker.on('error', finish);
worker.on('exit', (code) => {
if (settled) {
return;
}
if (code !== 0) {
finish(new Error(`worker exited with code ${code}`));
}
});
});
});
20 changes: 20 additions & 0 deletions tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"noEmit": true,
"rootDir": ".",
"module": "ES2022",
"moduleResolution": "node",
"target": "ES2022",
"types": ["node", "mocha"],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"ts-node": {
"experimentalResolver": true,
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node"
}
}
}
Loading