Skip to content

Commit c1c404a

Browse files
authored
Support ES Modules (GoogleCloudPlatform#292)
* PoC ESM support. * Improve logic for identifying ES modules. * Better comments. * Use read-pkg-up to read nearest package.json. * Fix linter issues. * Improve code style. * Pull out inlined constant as a const. * Resolve module path to file URL for windows support. * Drop the newline. It's cleaner.
1 parent 369af96 commit c1c404a

File tree

12 files changed

+216
-101
lines changed

12 files changed

+216
-101
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
**/node_modules
22
build/
3+
test/data/esm_*

package-lock.json

Lines changed: 30 additions & 76 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
"body-parser": "^1.18.3",
1313
"express": "^4.16.4",
1414
"minimist": "^1.2.5",
15-
"on-finished": "^2.3.0"
15+
"on-finished": "^2.3.0",
16+
"read-pkg-up": "^7.0.1",
17+
"semver": "^7.3.5"
1618
},
1719
"scripts": {
1820
"test": "mocha build/test --recursive",
@@ -41,6 +43,7 @@
4143
"@types/mocha": "8.2.2",
4244
"@types/node": "11.15.50",
4345
"@types/on-finished": "2.3.1",
46+
"@types/semver": "^7.3.6",
4447
"@types/sinon": "^10.0.0",
4548
"@types/supertest": "2.0.11",
4649
"gts": "3.1.0",

src/index.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -96,22 +96,23 @@ Documentation:
9696
process.exit(0);
9797
}
9898

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

106-
const SERVER = getServer(USER_FUNCTION!, SIGNATURE_TYPE!);
107-
const ERROR_HANDLER = new ErrorHandler(SERVER);
106+
const SERVER = getServer(userFunction!, SIGNATURE_TYPE!);
107+
const ERROR_HANDLER = new ErrorHandler(SERVER);
108108

109-
SERVER.listen(PORT, () => {
110-
ERROR_HANDLER.register();
111-
if (process.env.NODE_ENV !== NodeEnv.PRODUCTION) {
112-
console.log('Serving function...');
113-
console.log(`Function: ${TARGET}`);
114-
console.log(`Signature type: ${SIGNATURE_TYPE}`);
115-
console.log(`URL: http://localhost:${PORT}/`);
116-
}
117-
}).setTimeout(0); // Disable automatic timeout on incoming connections.
109+
SERVER.listen(PORT, () => {
110+
ERROR_HANDLER.register();
111+
if (process.env.NODE_ENV !== NodeEnv.PRODUCTION) {
112+
console.log('Serving function...');
113+
console.log(`Function: ${TARGET}`);
114+
console.log(`Signature type: ${SIGNATURE_TYPE}`);
115+
console.log(`URL: http://localhost:${PORT}/`);
116+
}
117+
}).setTimeout(0); // Disable automatic timeout on incoming connections.
118+
});

src/loader.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,99 @@
1818
* @packageDocumentation
1919
*/
2020

21+
import * as path from 'path';
22+
import * as semver from 'semver';
23+
import * as readPkgUp from 'read-pkg-up';
24+
import {pathToFileURL} from 'url';
2125
/**
2226
* Import function signature type's definition.
2327
*/
2428
import {HandlerFunction} from './functions';
2529

30+
// Dynamic import function required to load user code packaged as an
31+
// ES module is only available on Node.js v13.2.0 and up.
32+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#browser_compatibility
33+
// Exported for testing.
34+
export const MIN_NODE_VERSION_ESMODULES = '13.2.0';
35+
36+
/**
37+
* Determines whether the given module is an ES module.
38+
*
39+
* Implements "algorithm" described at:
40+
* https://nodejs.org/api/packages.html#packages_type
41+
*
42+
* In words:
43+
* 1. A module with .mjs extension is an ES module.
44+
* 2. A module with .clj extension is not an ES module.
45+
* 3. A module with .js extensions where the nearest package.json's
46+
* with "type": "module" is an ES module.
47+
* 4. Otherwise, it is not an ES module.
48+
*
49+
* @returns {Promise<boolean>} True if module is an ES module.
50+
*/
51+
async function isEsModule(modulePath: string): Promise<boolean> {
52+
const ext = path.extname(modulePath);
53+
if (ext === '.mjs') {
54+
return true;
55+
}
56+
if (ext === '.cjs') {
57+
return false;
58+
}
59+
60+
const pkg = await readPkgUp({
61+
cwd: path.dirname(modulePath),
62+
normalize: false,
63+
});
64+
65+
// If package.json specifies type as 'module', it's an ES module.
66+
return pkg?.packageJson.type === 'module';
67+
}
68+
69+
/**
70+
* Dynamically load import function to prevent TypeScript from
71+
* transpiling into a require.
72+
*
73+
* See https://github.com/microsoft/TypeScript/issues/43329.
74+
*/
75+
const dynamicImport = new Function(
76+
'modulePath',
77+
'return import(modulePath)'
78+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
79+
) as (modulePath: string) => Promise<any>;
80+
2681
/**
2782
* Returns user's function from function file.
2883
* Returns null if function can't be retrieved.
2984
* @return User's function or null.
3085
*/
31-
export function getUserFunction(
86+
export async function getUserFunction(
3287
codeLocation: string,
3388
functionTarget: string
34-
): HandlerFunction | null {
89+
): Promise<HandlerFunction | null> {
3590
try {
3691
const functionModulePath = getFunctionModulePath(codeLocation);
3792
if (functionModulePath === null) {
3893
console.error('Provided code is not a loadable module.');
3994
return null;
4095
}
4196

42-
// eslint-disable-next-line @typescript-eslint/no-var-requires
43-
const functionModule = require(functionModulePath);
97+
let functionModule;
98+
const esModule = await isEsModule(functionModulePath);
99+
if (esModule) {
100+
if (semver.lt(process.version, MIN_NODE_VERSION_ESMODULES)) {
101+
console.error(
102+
`Cannot load ES Module on Node.js ${process.version}. ` +
103+
`Please upgrade to Node.js v${MIN_NODE_VERSION_ESMODULES} and up.`
104+
);
105+
return null;
106+
}
107+
// Resolve module path to file:// URL. Required for windows support.
108+
const fpath = pathToFileURL(functionModulePath);
109+
functionModule = await dynamicImport(fpath.href);
110+
} else {
111+
functionModule = require(functionModulePath);
112+
}
113+
44114
let userFunction = functionTarget
45115
.split('.')
46116
.reduce((code, functionTargetPart) => {

test/data/esm_mjs/foo.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*eslint no-unused-vars: "off"*/
2+
/**
3+
* Test HTTP function to test function loading.
4+
*
5+
* @param {!Object} req request context.
6+
* @param {!Object} res response context.
7+
*/
8+
function testFunction(req, res) {
9+
return 'PASS';
10+
}
11+
12+
export {testFunction};

test/data/esm_mjs/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"main": "foo.mjs"
3+
}

test/data/esm_nested/nested/foo.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*eslint no-unused-vars: "off"*/
2+
/**
3+
* Test HTTP function to test function loading.
4+
*
5+
* @param {!Object} req request context.
6+
* @param {!Object} res response context.
7+
*/
8+
function testFunction(req, res) {
9+
return 'PASS';
10+
}
11+
12+
export {testFunction};

test/data/esm_nested/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"main": "nested/foo.js",
3+
"type": "module"
4+
}

test/data/esm_type/foo.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*eslint no-unused-vars: "off"*/
2+
/**
3+
* Test HTTP function to test function loading.
4+
*
5+
* @param {!Object} req request context.
6+
* @param {!Object} res response context.
7+
*/
8+
function testFunction(req, res) {
9+
return 'PASS';
10+
}
11+
12+
export {testFunction};

0 commit comments

Comments
 (0)