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

feat: Support ESM and Typescript configs #30

Merged
merged 4 commits into from
Mar 9, 2025
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
2 changes: 0 additions & 2 deletions docs/guides/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,6 @@ const serverlessConfiguration: Serverless = {
module.exports = serverlessConfiguration;
```

Note: when deploying using a `serverless.ts` file, `ts-node` needs to be installed separately as a dev dependency.

For the sake of simplicity, most examples in the documentation refer to the `serverless.yml` format. However, all functionalities work with the other available service file formats.

### Plugins
Expand Down
94 changes: 16 additions & 78 deletions lib/configuration/read.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,63 +5,9 @@ const isPlainObject = require('type/plain-object/is');
const path = require('path');
const fsp = require('fs').promises;
const yaml = require('js-yaml');
const getRequire = require('../utils/get-require');
const spawn = require('child-process-ext/spawn');
const cloudformationSchema = require('@serverless/utils/cloudformation-schema');
const ServerlessError = require('../serverless-error');

const resolveTsNode = async (serviceDir) => {
// 1. If installed aside of a Framework, use it
try {
return getRequire(__dirname).resolve('ts-node');
} catch (slsDepError) {
if (slsDepError.code !== 'MODULE_NOT_FOUND') {
throw new ServerlessError(
`Cannot resolve "ts-node" due to: ${slsDepError.message}`,
'TS_NODE_RESOLUTION_ERROR'
);
}

// 2. If installed in a service, use it
try {
return getRequire(serviceDir).resolve('ts-node');
} catch (serviceDepError) {
if (serviceDepError.code !== 'MODULE_NOT_FOUND') {
throw new ServerlessError(
`Cannot resolve "ts-node" due to: ${serviceDepError.message}`,
'TS_NODE_IN_SERVICE_RESOLUTION_ERROR'
);
}

// 3. If installed globally, use it
const { stdoutBuffer } = await (async () => {
try {
return await spawn('npm', ['root', '-g']);
} catch (error) {
if (error.code !== 'ENOENT') {
throw new ServerlessError(
`Cannot resolve "ts-node" due to unexpected "npm" error: ${error.message}`,
'TS_NODE_NPM_RESOLUTION_ERROR'
);
}
throw new ServerlessError('"ts-node" not found', 'TS_NODE_NOT_FOUND');
}
})();
try {
return require.resolve(`${String(stdoutBuffer).trim()}/ts-node`);
} catch (globalDepError) {
if (globalDepError.code !== 'MODULE_NOT_FOUND') {
throw new ServerlessError(
`Cannot resolve "ts-node" due to: ${globalDepError.message}`,
'TS_NODE_NPM_GLOBAL_RESOLUTION_ERROR'
);
}
throw new ServerlessError('"ts-node" not found', 'TS_NODE_NOT_FOUND');
}
}
}
};

const readConfigurationFile = async (configurationPath) => {
try {
return await fsp.readFile(configurationPath, 'utf8');
Expand Down Expand Up @@ -107,30 +53,22 @@ const parseConfigurationFile = async (configurationPath) => {
);
}
}
case '.ts': {
if (!process[Symbol.for('ts-node.register.instance')]) {
const tsNodePath = await (async () => {
try {
return await resolveTsNode(path.dirname(configurationPath));
} catch (error) {
throw new ServerlessError(
`Cannot parse "${path.basename(
configurationPath
)}": Resolution of "ts-node" failed with: ${error.message}`,
'CONFIGURATION_RESOLUTION_ERROR'
);
}
})();
try {
require(tsNodePath).register();
} catch (error) {
throw new ServerlessError(
`Cannot parse "${path.basename(
configurationPath
)}": Register of "ts-node" failed with: ${error.message}`,
'CONFIGURATION_RESOLUTION_ERROR'
);
}
case '.ts':
case '.mts': {
try {
const { createJiti } = require('jiti');
const jiti = createJiti(null, { interopDefault: true });

const content = await jiti.import(configurationPath, { default: true });

return content;
} catch (error) {
throw new ServerlessError(
`Cannot parse "${path.basename(configurationPath)}": Initialization error: ${
error && error.stack ? error.stack : error
}`,
'CONFIGURATION_INITIALIZATION_ERROR'
);
}
}
// fallthrough
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"graceful-fs": "^4.2.11",
"https-proxy-agent": "^5.0.1",
"is-docker": "^2.2.1",
"jiti": "^2.4.2",
"js-yaml": "^4.1.0",
"json-cycle": "^1.5.0",
"json-refs": "^3.0.15",
Expand Down
63 changes: 21 additions & 42 deletions test/unit/lib/configuration/read.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const { expect } = chai;

const fsp = require('fs').promises;
const fse = require('fs-extra');
const proxyquire = require('proxyquire');
const readConfiguration = require('../../../../lib/configuration/read');

describe('test/unit/lib/configuration/read.test.js', () => {
Expand Down Expand Up @@ -58,7 +57,7 @@ describe('test/unit/lib/configuration/read.test.js', () => {
expect(await readConfiguration(configurationPath)).to.deep.equal(configuration);
});

it('should read "serverless.js"', async () => {
it('should read "serverless.js" as CJS', async () => {
configurationPath = 'serverless.js';
const configuration = {
service: 'test-js',
Expand All @@ -68,23 +67,7 @@ describe('test/unit/lib/configuration/read.test.js', () => {
expect(await readConfiguration(configurationPath)).to.deep.equal(configuration);
});

it('should read "serverless.ts"', async () => {
await fse.ensureDir('node_modules');
try {
await fsp.writeFile('node_modules/ts-node.js', 'module.exports.register = () => null;');
configurationPath = 'serverless.ts';
const configuration = {
service: 'test-ts',
provider: { name: 'aws' },
};
await fsp.writeFile(configurationPath, `module.exports = ${JSON.stringify(configuration)}`);
expect(await readConfiguration(configurationPath)).to.deep.equal(configuration);
} finally {
await fse.remove('node_modules');
}
});

it('should read "serverless.cjs"', async () => {
it('should read "serverless.cjs" as CJS', async () => {
configurationPath = 'serverless.cjs';
const configuration = {
service: 'test-js',
Expand All @@ -94,7 +77,7 @@ describe('test/unit/lib/configuration/read.test.js', () => {
expect(await readConfiguration(configurationPath)).to.deep.equal(configuration);
});

it('should read "serverless.mjs"', async () => {
it('should read "serverless.mjs" as ESM', async () => {
configurationPath = 'serverless.mjs';
const configuration = {
service: 'test-js',
Expand All @@ -104,10 +87,9 @@ describe('test/unit/lib/configuration/read.test.js', () => {
expect(await readConfiguration(configurationPath)).to.deep.equal(configuration);
});

it('should register ts-node only if it is not already registered', async () => {
it('should read "serverless.ts" as CJS', async () => {
await fse.ensureDir('node_modules');
try {
expect(process[Symbol.for('ts-node.register.instance')]).to.be.undefined;
process[Symbol.for('ts-node.register.instance')] = 'foo';
configurationPath = 'serverless.ts';
const configuration = {
service: 'test-ts',
Expand All @@ -116,7 +98,22 @@ describe('test/unit/lib/configuration/read.test.js', () => {
await fsp.writeFile(configurationPath, `module.exports = ${JSON.stringify(configuration)}`);
expect(await readConfiguration(configurationPath)).to.deep.equal(configuration);
} finally {
delete process[Symbol.for('ts-node.register.instance')];
await fse.remove('node_modules');
}
});

it('should read "serverless.ts" as ESM', async () => {
await fse.ensureDir('node_modules');
try {
configurationPath = 'serverless.ts';
const configuration = {
service: 'test-ts',
provider: { name: 'aws' },
};
await fsp.writeFile(configurationPath, `export default ${JSON.stringify(configuration)}`);
expect(await readConfiguration(configurationPath)).to.deep.equal(configuration);
} finally {
await fse.remove('node_modules');
}
});

Expand Down Expand Up @@ -179,24 +176,6 @@ describe('test/unit/lib/configuration/read.test.js', () => {
);
});

it('should reject TS configuration if "ts-node" is not found', async () => {
// Test against different service dir, to not fall into cached `require.resolve` value
configurationPath = 'other/serverless-errored.ts';
const configuration = {
service: 'test-ts',
provider: { name: 'aws' },
};
await fse.ensureFile(configurationPath);
await fsp.writeFile(configurationPath, `module.exports = ${JSON.stringify(configuration)}`);
await expect(
proxyquire('../../../../lib/configuration/read', {
'child-process-ext/spawn': async () => {
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
},
})(configurationPath)
).to.eventually.be.rejected.and.have.property('code', 'CONFIGURATION_RESOLUTION_ERROR');
});

it('should reject non object configuration', async () => {
configurationPath = 'serverless.json';
await fsp.writeFile(configurationPath, JSON.stringify([]));
Expand Down