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
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
**/node_modules
build/
test/data/esm_*
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
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
35 changes: 18 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(userFunction => {
if (!userFunction) {
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(userFunction!, 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.
});
78 changes: 74 additions & 4 deletions src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,99 @@
* @packageDocumentation
*/

import * as path from 'path';
import * as semver from 'semver';
import * as readPkgUp from 'read-pkg-up';
import {pathToFileURL} from 'url';
/**
* 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 whether the given module is an 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 not an ES module.
* 3. A module with .js extensions where the nearest package.json's
* with "type": "module" is an ES module.
* 4. Otherwise, it is not an ES module.
*
* @returns {Promise<boolean>} True if module is an ES module.
*/
async function isEsModule(modulePath: string): Promise<boolean> {
const ext = path.extname(modulePath);
if (ext === '.mjs') {
return true;
}
if (ext === '.cjs') {
return false;
}

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

// If package.json specifies type as 'module', it's an ES module.
return pkg?.packageJson.type === 'module';
}

/**
* 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)'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) 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;
const esModule = await isEsModule(functionModulePath);
if (esModule) {
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 v${MIN_NODE_VERSION_ESMODULES} and up.`
);
return null;
}
// Resolve module path to file:// URL. Required for windows support.
const fpath = pathToFileURL(functionModulePath);
functionModule = await dynamicImport(fpath.href);
} else {
functionModule = require(functionModulePath);
}

let userFunction = functionTarget
.split('.')
.reduce((code, functionTargetPart) => {
Expand Down
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
Expand Up @@ -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';

Expand All @@ -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');
});
}
}
});