Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support ES Modules #292

Merged
merged 9 commits into from
Jun 21, 2021
Merged
Changes from 4 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
106 changes: 30 additions & 76 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -12,7 +12,9 @@
"body-parser": "^1.18.3",
"express": "^4.16.4",
"minimist": "^1.2.5",
"on-finished": "^2.3.0"
"on-finished": "^2.3.0",
"read-pkg-up": "^7.0.1",
"semver": "^7.3.5"
},
"scripts": {
"test": "mocha build/test --recursive",
@@ -41,6 +43,7 @@
"@types/mocha": "8.2.2",
"@types/node": "11.15.50",
"@types/on-finished": "2.3.1",
"@types/semver": "^7.3.6",
"@types/sinon": "^10.0.0",
"@types/supertest": "2.0.11",
"gts": "3.1.0",
35 changes: 18 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -96,22 +96,23 @@ Documentation:
process.exit(0);
}

const USER_FUNCTION = getUserFunction(CODE_LOCATION, TARGET);
if (!USER_FUNCTION) {
console.error('Could not load the function, shutting down.');
// eslint-disable-next-line no-process-exit
process.exit(1);
}
getUserFunction(CODE_LOCATION, TARGET).then(USER_FUNCTION => {
if (!USER_FUNCTION) {
console.error('Could not load the function, shutting down.');
// eslint-disable-next-line no-process-exit
process.exit(1);
}

const SERVER = getServer(USER_FUNCTION!, SIGNATURE_TYPE!);
const ERROR_HANDLER = new ErrorHandler(SERVER);
const SERVER = getServer(USER_FUNCTION!, SIGNATURE_TYPE!);
const ERROR_HANDLER = new ErrorHandler(SERVER);

SERVER.listen(PORT, () => {
ERROR_HANDLER.register();
if (process.env.NODE_ENV !== NodeEnv.PRODUCTION) {
console.log('Serving function...');
console.log(`Function: ${TARGET}`);
console.log(`Signature type: ${SIGNATURE_TYPE}`);
console.log(`URL: http://localhost:${PORT}/`);
}
}).setTimeout(0); // Disable automatic timeout on incoming connections.
SERVER.listen(PORT, () => {
ERROR_HANDLER.register();
if (process.env.NODE_ENV !== NodeEnv.PRODUCTION) {
console.log('Serving function...');
console.log(`Function: ${TARGET}`);
console.log(`Signature type: ${SIGNATURE_TYPE}`);
console.log(`URL: http://localhost:${PORT}/`);
}
}).setTimeout(0); // Disable automatic timeout on incoming connections.
});
70 changes: 66 additions & 4 deletions src/loader.ts
Original file line number Diff line number Diff line change
@@ -18,29 +18,91 @@
* @packageDocumentation
*/

import * as path from 'path';
import * as semver from 'semver';
import * as readPkgUp from 'read-pkg-up';
/**
* Import function signature type's definition.
*/
import {HandlerFunction} from './functions';

// Dynamic import function required to load user code packaged as an
// ES module is only available on Node.js v13.2.0 and up.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#browser_compatibility
// Exported for testing.
export const MIN_NODE_VERSION_ESMODULES = '13.2.0';

/**
* Determines format of the given module (CommonJS vs ES module).
*
* Implements "algorithm" described at:
* https://nodejs.org/api/packages.html#packages_type
*
* In words:
* 1. A module with .mjs extension is an ES module.
* 2. A module with .clj extension is CommonJS.
* 3. A module with .js extensions where the nearest package.json's
* with "type": "module" is an ES module
* 4. Otherwise, it is CommonJS.
*
* @returns {Promise<'commonjs' | 'module'>} Module format ('commonjs' or 'module')
*/
async function moduleFormat(
modulePath: string
): Promise<'commonjs' | 'module'> {
if (/\.mjs$/.test(modulePath)) return 'module';
if (/\.cjs$/.test(modulePath)) return 'commonjs';

const pkg = await readPkgUp({
cwd: path.dirname(modulePath),
normalize: false,
});

// Default to commonjs unless package.json specifies type as 'module'.
return pkg?.packageJson.type === 'module' ? 'module' : 'commonjs';
}

/**
* Dynamically load import function to prevent TypeScript from
* transpiling into a require.
*
* See https://github.com/microsoft/TypeScript/issues/43329.
*/
const dynamicImport = new Function(
'modulePath',
'return import(modulePath)'
) as (modulePath: string) => Promise<any>;

/**
* Returns user's function from function file.
* Returns null if function can't be retrieved.
* @return User's function or null.
*/
export function getUserFunction(
export async function getUserFunction(
codeLocation: string,
functionTarget: string
): HandlerFunction | null {
): Promise<HandlerFunction | null> {
try {
const functionModulePath = getFunctionModulePath(codeLocation);
if (functionModulePath === null) {
console.error('Provided code is not a loadable module.');
return null;
}

// eslint-disable-next-line @typescript-eslint/no-var-requires
const functionModule = require(functionModulePath);
let functionModule;
if ((await moduleFormat(functionModulePath)) === 'module') {
if (semver.lt(process.version, MIN_NODE_VERSION_ESMODULES)) {
console.error(
`Cannot load ES Module on Node.js ${process.version}. ` +
'Please upgrade to Node.js v13.2.0 and up.'
);
return null;
}
functionModule = await dynamicImport(functionModulePath);
} else {
functionModule = require(functionModulePath);
}

let userFunction = functionTarget
.split('.')
.reduce((code, functionTargetPart) => {
12 changes: 12 additions & 0 deletions test/data/esm_mjs/foo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*eslint no-unused-vars: "off"*/
/**
* Test HTTP function to test function loading.
*
* @param {!Object} req request context.
* @param {!Object} res response context.
*/
function testFunction(req, res) {
return 'PASS';
}

export {testFunction};
3 changes: 3 additions & 0 deletions test/data/esm_mjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"main": "foo.mjs"
}
12 changes: 12 additions & 0 deletions test/data/esm_nested/nested/foo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*eslint no-unused-vars: "off"*/
/**
* Test HTTP function to test function loading.
*
* @param {!Object} req request context.
* @param {!Object} res response context.
*/
function testFunction(req, res) {
return 'PASS';
}

export {testFunction};
4 changes: 4 additions & 0 deletions test/data/esm_nested/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"main": "nested/foo.js",
"type": "module"
}
12 changes: 12 additions & 0 deletions test/data/esm_type/foo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*eslint no-unused-vars: "off"*/
/**
* Test HTTP function to test function loading.
*
* @param {!Object} req request context.
* @param {!Object} res response context.
*/
function testFunction(req, res) {
return 'PASS';
}

export {testFunction};
4 changes: 4 additions & 0 deletions test/data/esm_type/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"main": "foo.js",
"type": "module"
}
45 changes: 42 additions & 3 deletions test/loader.ts
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@

import * as assert from 'assert';
import * as express from 'express';
import * as semver from 'semver';
import * as functions from '../src/functions';
import * as loader from '../src/loader';

@@ -38,13 +39,51 @@ describe('loading function', () => {
];

for (const test of testData) {
it(`should load ${test.name}`, () => {
const loadedFunction = loader.getUserFunction(
it(`should load ${test.name}`, async () => {
const loadedFunction = (await loader.getUserFunction(
process.cwd() + test.codeLocation,
test.target
) as functions.HttpFunction;
)) as functions.HttpFunction;
const returned = loadedFunction(express.request, express.response);
assert.strictEqual(returned, 'PASS');
});
}

const esmTestData: TestData[] = [
{
name: 'specified in package.json type field',
codeLocation: '/test/data/esm_type',
target: 'testFunction',
},
{
name: 'nested dir, specified in package.json type field',
codeLocation: '/test/data/esm_nested',
target: 'testFunction',
},
{
name: '.mjs extension',
codeLocation: '/test/data/esm_mjs',
target: 'testFunction',
},
];

for (const test of esmTestData) {
const loadFn: () => Promise<functions.HttpFunction> = async () => {
return loader.getUserFunction(
process.cwd() + test.codeLocation,
test.target
) as Promise<functions.HttpFunction>;
};
if (semver.lt(process.version, loader.MIN_NODE_VERSION_ESMODULES)) {
it(`should fail to load function in an ES module ${test.name}`, async () => {
assert.rejects(loadFn);
});
} else {
it(`should load function in an ES module ${test.name}`, async () => {
const loadedFunction = await loadFn();
const returned = loadedFunction(express.request, express.response);
assert.strictEqual(returned, 'PASS');
});
}
}
});