Skip to content

Commit

Permalink
Add JS-shim and types for bundleAsync() so the public API is just `…
Browse files Browse the repository at this point in the history
…(...args) => Promise<Result>` (parcel-bundler#174)

This hides the fact that N-API sends an `error` as the first parameter and a `callback` as the last parameter, expecting the function to propagate errors and invoke the callback. This is unergonomic, so this shim presents a traditional async API on top of these implementation details.

Also includes tests for all the common use cases and error conditions of custom JS resolvers.
  • Loading branch information
dgp1130 committed Jun 6, 2022
1 parent 84eb491 commit b777c29
Show file tree
Hide file tree
Showing 6 changed files with 329 additions and 0 deletions.
15 changes: 15 additions & 0 deletions node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,18 @@ export declare function browserslistToTargets(browserslist: string[]): Targets;
* Bundles a CSS file and its dependencies, inlining @import rules.
*/
export declare function bundle(options: BundleOptions): TransformResult;

export interface AsyncBundleOptions extends BundleOptions {
resolver?: Resolver;
}

export interface Resolver {
read?: (file: string) => string | Promise<string>;
resolve?: (specifier: string, originatingFile: string) => string | Promise<string>;
}

/**
* Bundles a CSS file and its dependencies asynchronously, inlining @import rules.
* Also supports custom resolvers.
*/
export declare function bundleAsync(options: AsyncBundleOptions): Promise<TransformResult>;
45 changes: 45 additions & 0 deletions node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,48 @@ if (process.env.CSS_TRANSFORMER_WASM) {
}

module.exports.browserslistToTargets = require('./browserslistToTargets');

// Include a small JS-shim for `bundleAsync()` to convert make `resolver` more ergonomic.
const {bundleAsync} = module.exports;
module.exports.bundleAsync = (opts, ...rest) => {
return bundleAsync({
...opts,
resolver: opts.resolver && {
...opts.resolver,
read: opts.resolver.read && normalizeJsCallback(opts.resolver.read.bind(opts.resolver)),
resolve: opts.resolver.resolve && normalizeJsCallback(opts.resolver.resolve.bind(opts.resolver)),
},
}, ...rest);
};

// `napi-rs` ignores JS function return values, so any results must be passed back to
// the Rust side via a callback rather than a returned value or `Promise`. This
// callback also follows NodeJS conventions (`callback(err, result)`). Managing the
// error and callback are annoying for users, so this converts a typical JS function
// which returns its result in a `Promise` into a N-API-compatible function which
// accepts and propagates its results to a callback.
function normalizeJsCallback(func) {
return (...args) => {
// Splice out `[...args, callback]`.
const funcArgs = args.slice(0, -1);
const callback = args[args.length - 1];

// Invoke the inner function, normalize to a `Promise`, and then invoke the callback
// function with Node conventions of `callback(err, result)`.
toPromise(() => func(...funcArgs)).then(
(result) => callback(null, result),
(err) => callback(err, null),
);
};
}

// Converts the given function execution to return a `Promise` instead of returning or
// erroring synchronously. This is different from `Promise.resolve(func())` in that a
// synchronous `throw` statement is converted to a `Promise` rejection.
function toPromise(func) {
try {
return Promise.resolve(func());
} catch (err) {
return Promise.reject(err);
}
}
262 changes: 262 additions & 0 deletions test-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import path from 'path';
import css from './node/index.js';

(async function testResolver() {
const inMemoryFs = new Map(Object.entries({
'foo.css': `
@import 'root:bar.css';
.foo { color: red; }
`.trim(),

'bar.css': `
@import 'root:hello/world.css';
.bar { color: green; }
`.trim(),

'hello/world.css': `
.baz { color: blue; }
`.trim(),
}));

const { code: buffer } = await css.bundleAsync({
filename: 'foo.css',
resolver: {
read(file) {
const result = inMemoryFs.get(path.normalize(file));
if (!result) throw new Error(`Could not find ${file} in ${
Array.from(inMemoryFs.keys()).join(', ')}.`);
return result;
},

resolve(specifier) {
return specifier.slice('root:'.length);
},
},
});
const code = buffer.toString('utf-8').trim();

const expected = `
.baz {
color: #00f;
}
.bar {
color: green;
}
.foo {
color: red;
}
`.trim();
if (code !== expected) throw new Error(`\`testResolver()\` failed. Expected:\n${expected}\n\nGot:\n${code}`);
})();

(async function testOnlyCustomRead() {
const inMemoryFs = new Map(Object.entries({
'foo.css': `
@import 'hello/world.css';
.foo { color: red; }
`.trim(),

'hello/world.css': `
@import '../bar.css';
.bar { color: green; }
`.trim(),

'bar.css': `
.baz { color: blue; }
`.trim(),
}));

const { code: buffer } = await css.bundleAsync({
filename: 'foo.css',
resolver: {
read(file) {
const result = inMemoryFs.get(path.normalize(file));
if (!result) throw new Error(`Could not find ${file} in ${
Array.from(inMemoryFs.keys()).join(', ')}.`);
return result;
},
},
});
const code = buffer.toString('utf-8').trim();

const expected = `
.baz {
color: #00f;
}
.bar {
color: green;
}
.foo {
color: red;
}
`.trim();
if (code !== expected) throw new Error(`\`testOnlyCustomRead()\` failed. Expected:\n${expected}\n\nGot:\n${code}`);
})();

(async function testOnlyCustomResolve() {
const root = path.join('tests', 'testdata', 'css');
const { code: buffer } = await css.bundleAsync({
filename: path.join(root, 'foo.css'),
resolver: {
resolve(specifier) {
// Strip `root:` prefix off specifier and resolve it as an absolute path
// in the test data root.
return path.join(root, specifier.slice('root:'.length));
},
},
});
const code = buffer.toString('utf-8').trim();

const expected = `
.baz {
color: #00f;
}
.bar {
color: green;
}
.foo {
color: red;
}
`.trim();
if (code !== expected) throw new Error(`\`testOnlyCustomResolve()\` failed. Expected:\n${expected}\n\nGot:\n${code}`);
})();

(async function testReadThrow() {
let error = undefined;
try {
await css.bundleAsync({
filename: 'foo.css',
resolver: {
read(file) {
throw new Error(`Oh noes! Failed to read \`${file}\`.`);
}
},
});
} catch (err) {
error = err;
}

if (!error) throw new Error(`\`testReadThrow()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`);
if (!error.message.includes(`\`read()\` threw error:`) || !error.message.includes(`Oh noes! Failed to read \`foo.css\`.`)) {
throw new Error(`\`testReadThrow()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`);
}
})();

(async function testResolveThrow() {
let error = undefined;
try {
await css.bundleAsync({
filename: 'tests/testdata/css/foo.css',
resolver: {
resolve(specifier, originatingFile) {
throw new Error(`Oh noes! Failed to resolve \`${specifier}\` from \`${
originatingFile}\`.`);
}
},
});
} catch (err) {
error = err;
}

if (!error) throw new Error(`\`testResolveThrow()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`);
if (!error.message.includes(`\`resolve()\` threw error:`) || !error.message.includes(`Oh noes! Failed to resolve \`root:hello/world.css\` from \`tests/testdata/css/foo.css\`.`)) {
throw new Error(`\`testResolveThrow()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`);
}
})();

(async function testReadReturnNonString() {
let error = undefined;
try {
await css.bundleAsync({
filename: 'foo.css',
resolver: {
read() {
return 1234; // Returns a non-string value.
}
},
});
} catch (err) {
error = err;
}

if (!error) throw new Error(`\`testReadReturnNonString()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`);
if (!error.message.includes(`Expected \`read()\` to return a value of type \`String\`, but it returned a value of type \`Number\` instead.`)) {
throw new Error(`\`testReadReturnNonString()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`);
}
})();

(async function testResolveReturnNonString() {
let error = undefined;
try {
await css.bundleAsync({
filename: 'tests/testdata/css/foo.css',
resolver: {
resolve() {
return 1234; // Returns a non-string value.
}
},
});
} catch (err) {
error = err;
}

if (!error) throw new Error(`\`testResolveReturnNonString()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`);
if (!error.message.includes(`Expected \`resolve()\` to return a value of type \`String\`, but it returned a value of type \`Number\` instead.`)) {
throw new Error(`\`testResolveReturnNonString()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`);
}
})();

(async function testThis() {
const inMemoryFs = new Map(Object.entries({
'foo.css': `
@import './bar.css';
.foo { color: red; }
`.trim(),

'bar.css': `
@import './hello/world.css';
.bar { color: green; }
`.trim(),

'hello/world.css': `
.baz { color: blue; }
`.trim(),
}));

let readThis = undefined;
let resolveThis = undefined;
const resolver = {
read: function (file) {
readThis = this;
const result = inMemoryFs.get(path.normalize(file));
if (!result) throw new Error(`Could not find ${file} in ${
Array.from(inMemoryFs.keys()).join(', ')}.`);
return result;
},
resolve: function (specifier, originatingFile) {
resolveThis = this;
return path.join(originatingFile, '..', specifier);
},
};
await css.bundleAsync({
filename: 'foo.css',
resolver,
});

if (readThis !== resolver) throw new Error(`\`testThis()\` failed. Expected \`read()\` to be called with the \`resolver\` as \`this\`, but instead it was called with:\n${readThis}`);
if (resolveThis !== resolver) throw new Error(`\`testThis()\` failed. Expected \`resolve()\` to be called with the \`resolver\` as \`this\`, but instead it was called with:\n${resolveThis}`);
})();

console.log('PASSED!');
1 change: 1 addition & 0 deletions tests/testdata/css/baz.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.baz { color: blue; }
3 changes: 3 additions & 0 deletions tests/testdata/css/foo.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@import 'root:hello/world.css';

.foo { color: red; }
3 changes: 3 additions & 0 deletions tests/testdata/css/hello/world.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@import 'root:baz.css';

.bar { color: green; }

0 comments on commit b777c29

Please sign in to comment.