diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 514294f..7a36780 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,7 +43,7 @@ jobs: - name: Lint run: bun run lint - - name: Build the test utilities + - name: Generate the vendored modules run: bun run build:vendor - name: Typecheck diff --git a/.gitignore b/.gitignore index e2120ad..f29cf6c 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,5 @@ src/modules/build website/.vitepress/cache docs + +src/modules/generated diff --git a/README.md b/README.md index 7088203..ccaba61 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This TypeScript package allows you to safely execute **JavaScript AND TypeScript - **Custom Node Modules**: Custom node modules are mountable. - **Fetch Client**: Can provide a fetch client to make http(s) calls. - **Test-Runner**: Includes a test runner and chai based `expect`. +- **Decimal numbers**: Ships decimal.js. The guest imports it, or gets the class `Decimal` as a global. - **Performance**: Benefit from the lightweight and efficient QuickJS engine. - **Versatility**: Easily integrate with existing TypeScript projects. - **Simplicity**: User-friendly API for executing and managing JavaScript and TypeScript code in the sandbox. @@ -74,6 +75,7 @@ This lib is based on: - [quickjs-emscripten-sync](https://github.com/reearth/quickjs-emscripten-sync) - [memfs](https://github.com/streamich/memfs) - [Chai](https://www.chaijs.com) +- [decimal.js](https://mikemcl.github.io/decimal.js/) Tools used: diff --git a/biome.json b/biome.json index 724673c..c0bb9c6 100644 --- a/biome.json +++ b/biome.json @@ -8,6 +8,7 @@ "!**/node_modules", "!**/.tshy", "!**/build", + "!**/src/modules/generated", "!**/cookieconsent2.js", "!**/cache", "!**/cookieconsent-init2.js", diff --git a/bun.lock b/bun.lock index bd9d1c3..7516258 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "@types/node": "^25.9.1", "autocannon": "^8.0.0", "chai": "^6.2.2", + "decimal.js": "^10.6.0", "git-cliff": "^2.13.1", "hono": "^4.12.23", "jsr": "^0.14.3", @@ -596,6 +597,8 @@ "date-fns": ["date-fns@1.30.1", "", {}, "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], diff --git a/package.json b/package.json index bedfc5b..2934606 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@loop-payments/quickjs", - "version": "3.1.0", + "version": "3.2.0", "description": "A typescript package to execute JavaScript and TypeScript code in a WebAssembly QuickJS sandbox", "engines": { "node": ">=18.0.0" @@ -41,7 +41,8 @@ ], "exports": { "./package.json": "./package.json", - ".": "./src/index.ts" + ".": "./src/index.ts", + "./decimal-source": "./src/modules/generated/decimal.js" } }, "type": "module", @@ -93,6 +94,7 @@ "@types/node": "^25.9.1", "autocannon": "^8.0.0", "chai": "^6.2.2", + "decimal.js": "^10.6.0", "git-cliff": "^2.13.1", "hono": "^4.12.23", "jsr": "^0.14.3", @@ -132,6 +134,16 @@ "types": "./dist/commonjs/index.d.ts", "default": "./dist/commonjs/index.js" } + }, + "./decimal-source": { + "import": { + "types": "./dist/esm/modules/generated/decimal.d.ts", + "default": "./dist/esm/modules/generated/decimal.js" + }, + "require": { + "types": "./dist/commonjs/modules/generated/decimal.d.ts", + "default": "./dist/commonjs/modules/generated/decimal.js" + } } }, "main": "./dist/commonjs/index.js", diff --git a/src/createVirtualFileSystem.ts b/src/createVirtualFileSystem.ts index fee6084..301f207 100644 --- a/src/createVirtualFileSystem.ts +++ b/src/createVirtualFileSystem.ts @@ -8,6 +8,7 @@ import bufferModule from './modules/buffer.js' import eventModule from './modules/events.js' import fsModule from './modules/fs.js' import fsPromisesModule from './modules/fs_promises.js' +import decimalModule from './modules/generated/decimal.js' import moduleModule from './modules/module.js' import compatibilityEventTarget from './modules/nodeCompatibility/eventTarget.js' import compatibilityHeaders from './modules/nodeCompatibility/headers.js' @@ -68,7 +69,11 @@ export const createVirtualFileSystem = (runtimeOptions: RuntimeOptions = {}) => assert: { 'index.js': assertModule, }, - + // The package decimal.js is not a node module. The sandbox mounts it + // always, because the host code works with decimal strings. + 'decimal.js': { + 'index.js': decimalModule, + }, async_hooks: { 'index.js': "throw new Error('module async_hooks not implemented')", }, diff --git a/src/sandbox/asyncVersion/prepareAsyncNodeCompatibility.ts b/src/sandbox/asyncVersion/prepareAsyncNodeCompatibility.ts index 0fab458..fb43995 100644 --- a/src/sandbox/asyncVersion/prepareAsyncNodeCompatibility.ts +++ b/src/sandbox/asyncVersion/prepareAsyncNodeCompatibility.ts @@ -13,6 +13,7 @@ export const prepareAsyncNodeCompatibility = async (vm: QuickJSAsyncContext, san import '@node_compatibility/request'; import '@node_compatibility/response'; ${sandboxOptions.enableTestUtils ? "import 'test'" : ''} + ${sandboxOptions.enableDecimalGlobal ? "import Decimal from 'decimal.js'; globalThis.Decimal = Decimal;" : ''} `, undefined, { type: 'module' }, diff --git a/src/sandbox/syncVersion/prepareNodeCompatibility.ts b/src/sandbox/syncVersion/prepareNodeCompatibility.ts index 39c8fcb..c7082a1 100644 --- a/src/sandbox/syncVersion/prepareNodeCompatibility.ts +++ b/src/sandbox/syncVersion/prepareNodeCompatibility.ts @@ -13,6 +13,7 @@ export const prepareNodeCompatibility = (vm: QuickJSContext, sandboxOptions: San import '@node_compatibility/request'; import '@node_compatibility/response'; ${sandboxOptions.enableTestUtils ? "import 'test'" : ''} + ${sandboxOptions.enableDecimalGlobal ? "import Decimal from 'decimal.js'; globalThis.Decimal = Decimal;" : ''} `, undefined, { type: 'module' }, diff --git a/src/test/async/decimal.test.ts b/src/test/async/decimal.test.ts new file mode 100644 index 0000000..e73f05b --- /dev/null +++ b/src/test/async/decimal.test.ts @@ -0,0 +1,64 @@ +import { beforeAll, describe, expect, it } from 'bun:test' +import variant from '@jitl/quickjs-ng-wasmfile-release-asyncify' +import { loadAsyncQuickJs } from '../../loadAsyncQuickJs.js' +import type { OkResponse } from '../../types/OkResponse.js' + +describe('async - decimal.js', () => { + let runtime: Awaited> + + beforeAll(async () => { + runtime = await loadAsyncQuickJs(variant) + }) + + const runCode = async (code: string, options: object = {}) => { + return await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(code) + }, options) + } + + it('can import decimal.js without an option', async () => { + const code = ` + import Decimal from 'decimal.js' + export default new Decimal('0.1').plus('0.2').toString() + ` + + const result = (await runCode(code)) as OkResponse + + expect(result.ok).toBeTrue() + expect(result.data).toBe('0.3') + }) + + it('does not register the global Decimal by default', async () => { + const code = ` + export default typeof Decimal + ` + + const result = (await runCode(code)) as OkResponse + + expect(result.ok).toBeTrue() + expect(result.data).toBe('undefined') + }) + + it('registers the global Decimal when enableDecimalGlobal is true', async () => { + const code = ` + export default new Decimal('1').dividedBy('3').toFixed(10) + ` + + const result = (await runCode(code, { enableDecimalGlobal: true })) as OkResponse + + expect(result.ok).toBeTrue() + expect(result.data).toBe('0.3333333333') + }) + + it('keeps the global Decimal and the imported Decimal identical', async () => { + const code = ` + import Decimal from 'decimal.js' + export default Decimal === globalThis.Decimal + ` + + const result = (await runCode(code, { enableDecimalGlobal: true })) as OkResponse + + expect(result.ok).toBeTrue() + expect(result.data).toBeTrue() + }) +}) diff --git a/src/test/sync/decimal.test.ts b/src/test/sync/decimal.test.ts new file mode 100644 index 0000000..4af4854 --- /dev/null +++ b/src/test/sync/decimal.test.ts @@ -0,0 +1,64 @@ +import { beforeAll, describe, expect, it } from 'bun:test' +import variant from '@jitl/quickjs-ng-wasmfile-release-sync' +import { loadQuickJs } from '../../loadQuickJs.js' +import type { OkResponse } from '../../types/OkResponse.js' + +describe('sync - decimal.js', () => { + let runtime: Awaited> + + beforeAll(async () => { + runtime = await loadQuickJs(variant) + }) + + const runCode = async (code: string, options: object = {}) => { + return await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(code) + }, options) + } + + it('can import decimal.js without an option', async () => { + const code = ` + import Decimal from 'decimal.js' + export default new Decimal('0.1').plus('0.2').toString() + ` + + const result = (await runCode(code)) as OkResponse + + expect(result.ok).toBeTrue() + expect(result.data).toBe('0.3') + }) + + it('does not register the global Decimal by default', async () => { + const code = ` + export default typeof Decimal + ` + + const result = (await runCode(code)) as OkResponse + + expect(result.ok).toBeTrue() + expect(result.data).toBe('undefined') + }) + + it('registers the global Decimal when enableDecimalGlobal is true', async () => { + const code = ` + export default new Decimal('1').dividedBy('3').toFixed(10) + ` + + const result = (await runCode(code, { enableDecimalGlobal: true })) as OkResponse + + expect(result.ok).toBeTrue() + expect(result.data).toBe('0.3333333333') + }) + + it('keeps the global Decimal and the imported Decimal identical', async () => { + const code = ` + import Decimal from 'decimal.js' + export default Decimal === globalThis.Decimal + ` + + const result = (await runCode(code, { enableDecimalGlobal: true })) as OkResponse + + expect(result.ok).toBeTrue() + expect(result.data).toBeTrue() + }) +}) diff --git a/src/types/RuntimeOptions.ts b/src/types/RuntimeOptions.ts index 4cb0a77..92d5126 100644 --- a/src/types/RuntimeOptions.ts +++ b/src/types/RuntimeOptions.ts @@ -31,6 +31,14 @@ export type RuntimeOptions = { * The custom fetch adapter provided as host function in the QuickJS runtime */ fetchAdapter?: typeof fetch + /** + * Register the class Decimal from the package decimal.js as a global. + * The module decimal.js is always available for import. This option only + * makes the import unnecessary. + * The sandbox compiles the source of decimal.js for every execution when + * this option is enabled. + */ + enableDecimalGlobal?: boolean /** * Includes test framework * If enabled, the packages chai and mocha become available diff --git a/src/types/SandboxOptions.ts b/src/types/SandboxOptions.ts index f0141f9..e70e831 100644 --- a/src/types/SandboxOptions.ts +++ b/src/types/SandboxOptions.ts @@ -53,6 +53,14 @@ export type SandboxBaseOptions = { * They are registered global */ enableTestUtils?: boolean + /** + * Register the class Decimal from the package decimal.js as a global. + * The module decimal.js is always available for import. This option only + * makes the import unnecessary. + * The sandbox compiles the source of decimal.js for every execution when + * this option is enabled. + */ + enableDecimalGlobal?: boolean /** * Per default, the console log inside of QuickJS is passed to the host console log. * Here, you can customize the handling and provide your own logging methods. diff --git a/vendor.ts b/vendor.ts index b062075..0287bdd 100644 --- a/vendor.ts +++ b/vendor.ts @@ -1,3 +1,4 @@ +import { createRequire } from 'node:module' import { join } from 'node:path' const testRunnerResult = await Bun.build({ @@ -13,3 +14,23 @@ for (const res of testRunnerResult.outputs) { console.info('test lib generated') } + +// The sandbox mounts the decimal.js source into the virtual file system, so the +// source must be a string in the package instead of a file on disk. A file on +// disk is not readable in a worker thread bundle or in the Temporal workflow vm. +const require = createRequire(import.meta.url) +const decimalSource = await Bun.file(require.resolve('decimal.js/decimal.mjs')).text() +const decimalVersion = require('decimal.js/package.json').version + +Bun.write( + join('src', 'modules', 'generated', 'decimal.js'), + [ + `// Generated by vendor.ts from decimal.js ${decimalVersion}. Do not edit.`, + '/** @type {string} */', + `const decimalJsSource = ${JSON.stringify(decimalSource)}`, + 'export default decimalJsSource', + '', + ].join('\n'), +) + +console.info(`decimal.js ${decimalVersion} module generated`) diff --git a/website/docs/runtime-options.md b/website/docs/runtime-options.md index 188dfbd..e466727 100644 --- a/website/docs/runtime-options.md +++ b/website/docs/runtime-options.md @@ -46,6 +46,22 @@ These options apply to both synchronous and asynchronous sandbox instances. | ----------------- | --------- | ------------------------------------------- | | `enableTestUtils` | `boolean` | Enables test frameworks (`chai` & `mocha`). | +### 🔢 Decimal Numbers + +The sandbox always mounts the package [decimal.js](https://mikemcl.github.io/decimal.js/). The guest code imports it with `import Decimal from 'decimal.js'`. The sandbox only compiles the source when the guest code imports the module. + +| Option | Type | Description | +| --------------------- | --------- | ---------------------------------------------------------------------- | +| `enableDecimalGlobal` | `boolean` | Registers the class `Decimal` as a global. The guest needs no import. | + +The package also exports the source of decimal.js as a string: + +```ts +import decimalJsSource from '@loop-payments/quickjs/decimal-source' +``` + +Use this export when you embed the QuickJS engine yourself and you must give the same `Decimal` class to the guest. The subpath holds one string constant and imports nothing else, so a bundler does not pull the sandbox and the module memfs into the output. The host also does not read a file at runtime. A bundler, a worker thread, and the Temporal workflow vm all support this. + ### 📢 Console Customization You can override console methods for custom logging behavior.