Skip to content

Commit b799b34

Browse files
committed
feat: add support for .mjs file output
1 parent 2c1d2ac commit b799b34

File tree

5 files changed

+341
-2
lines changed

5 files changed

+341
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ See [example folder](examples) for some example configurations.
8383
| `nativeZip` | Uses the system's `zip` executable to create archives. _NOTE_: This will produce non-deterministic archives which causes a Serverless deployment update on every deploy. | `false` |
8484
| `outputBuildFolder` | The output folder for Esbuild builds within the work folder. | `'.build'` |
8585
| `outputWorkFolder` | The output folder for this plugin where all the bundle preparation is done. | `'.esbuild'` |
86+
| `outputFileExtension` | The file extension used for the bundled output file. This will override the esbuild `outExtension` option | `'.js'` |
8687
| `packagePath` | Path to the `package.json` file for `external` dependency resolution. | `'./package.json'` |
8788
| `packager` | Packager to use for `external` dependency resolution. Values: `npm`, `yarn`, `pnpm` | `'npm'` |
8889
| `packagerOptions` | Extra options for packagers for `external` dependency resolution. | [Packager Options](#packager-options) |

src/bundle.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,29 @@ export async function bundle(this: EsbuildServerlessPlugin, incremental = false)
2727
plugins: this.plugins,
2828
};
2929

30+
if (
31+
this.buildOptions.platform === 'neutral' &&
32+
this.buildOptions.outputFileExtension === '.cjs'
33+
) {
34+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
35+
// @ts-ignore Serverless typings (as of v3.0.2) are incorrect
36+
throw new this.serverless.classes.Error(
37+
'ERROR: platform "neutral" should not output a file with extension ".cjs".'
38+
);
39+
}
40+
41+
if (this.buildOptions.platform === 'node' && this.buildOptions.outputFileExtension === '.mjs') {
42+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
43+
// @ts-ignore Serverless typings (as of v3.0.2) are incorrect
44+
throw new this.serverless.classes.Error(
45+
'ERROR: platform "node" should not output a file with extension ".mjs".'
46+
);
47+
}
48+
49+
if (this.buildOptions.outputFileExtension !== '.js') {
50+
config.outExtension = { '.js': this.buildOptions.outputFileExtension };
51+
}
52+
3053
// esbuild v0.7.0 introduced config options validation, so I have to delete plugin specific options from esbuild config.
3154
delete config['concurrency'];
3255
delete config['exclude'];
@@ -38,10 +61,12 @@ export async function bundle(this: EsbuildServerlessPlugin, incremental = false)
3861
delete config['packagerOptions'];
3962
delete config['installExtraArgs'];
4063
delete config['disableIncremental'];
64+
delete config['outputFileExtension'];
4165

4266
/** Build the files */
4367
const bundleMapper = async (entry: string): Promise<FileBuildResult> => {
44-
const bundlePath = entry.slice(0, entry.lastIndexOf('.')) + '.js';
68+
const bundlePath =
69+
entry.slice(0, entry.lastIndexOf('.')) + this.buildOptions.outputFileExtension;
4570

4671
// check cache
4772
if (this.buildCache) {

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ class EsbuildServerlessPlugin implements ServerlessPlugin {
242242
keepOutputDirectory: false,
243243
packagerOptions: {},
244244
platform: 'node',
245+
outputFileExtension: '.js',
245246
};
246247

247248
const runtimeMatcher = providerRuntimeMatcher[this.serverless.service.provider.name];

src/tests/bundle.test.ts

Lines changed: 312 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { PartialDeep } from 'type-fest';
22
import EsbuildServerlessPlugin from '..';
33
import { bundle } from '../bundle';
44
import { build } from 'esbuild';
5-
import { FunctionBuildResult, FunctionEntry } from '../types';
5+
import { Configuration, FunctionBuildResult, FunctionEntry } from '../types';
66
import pMap from 'p-map';
77
import { mocked } from 'ts-jest/utils';
88

@@ -16,6 +16,9 @@ const esbuildPlugin = (override?: Partial<EsbuildServerlessPlugin>): EsbuildServ
1616
cli: {
1717
log: jest.fn(),
1818
},
19+
classes: {
20+
Error: Error,
21+
},
1922
},
2023
buildOptions: {
2124
concurrency: Infinity,
@@ -30,6 +33,7 @@ const esbuildPlugin = (override?: Partial<EsbuildServerlessPlugin>): EsbuildServ
3033
keepOutputDirectory: false,
3134
packagerOptions: {},
3235
platform: 'node',
36+
outputFileExtension: '.js',
3337
},
3438
plugins: [],
3539
buildDirPath: '/workdir/.esbuild',
@@ -194,3 +198,310 @@ it('should filter out non esbuild options', async () => {
194198
target: 'node12',
195199
});
196200
});
201+
202+
describe('buildOption platform node', () => {
203+
it('should set buildResults buildPath after compilation is complete with default extension', async () => {
204+
const functionEntries: FunctionEntry[] = [
205+
{
206+
entry: 'file1.ts',
207+
func: {
208+
events: [],
209+
handler: 'file1.handler',
210+
},
211+
functionAlias: 'func1',
212+
},
213+
{
214+
entry: 'file2.ts',
215+
func: {
216+
events: [],
217+
handler: 'file2.handler',
218+
},
219+
functionAlias: 'func2',
220+
},
221+
];
222+
223+
const expectedResults: FunctionBuildResult[] = [
224+
{
225+
bundlePath: 'file1.js',
226+
func: { events: [], handler: 'file1.handler' },
227+
functionAlias: 'func1',
228+
},
229+
{
230+
bundlePath: 'file2.js',
231+
func: { events: [], handler: 'file2.handler' },
232+
functionAlias: 'func2',
233+
},
234+
];
235+
236+
const plugin = esbuildPlugin({ functionEntries });
237+
238+
await bundle.call(plugin);
239+
240+
expect(plugin.buildResults).toStrictEqual(expectedResults);
241+
});
242+
243+
it('should set buildResults buildPath after compilation is complete with ".cjs" extension', async () => {
244+
const functionEntries: FunctionEntry[] = [
245+
{
246+
entry: 'file1.ts',
247+
func: {
248+
events: [],
249+
handler: 'file1.handler',
250+
},
251+
functionAlias: 'func1',
252+
},
253+
{
254+
entry: 'file2.ts',
255+
func: {
256+
events: [],
257+
handler: 'file2.handler',
258+
},
259+
functionAlias: 'func2',
260+
},
261+
];
262+
263+
const buildOptions: Partial<Configuration> = {
264+
concurrency: Infinity,
265+
bundle: true,
266+
target: 'node12',
267+
external: [],
268+
exclude: ['aws-sdk'],
269+
nativeZip: false,
270+
packager: 'npm',
271+
installExtraArgs: [],
272+
watch: {},
273+
keepOutputDirectory: false,
274+
packagerOptions: {},
275+
platform: 'node',
276+
outputFileExtension: '.cjs',
277+
};
278+
279+
const expectedResults: FunctionBuildResult[] = [
280+
{
281+
bundlePath: 'file1.cjs',
282+
func: { events: [], handler: 'file1.handler' },
283+
functionAlias: 'func1',
284+
},
285+
{
286+
bundlePath: 'file2.cjs',
287+
func: { events: [], handler: 'file2.handler' },
288+
functionAlias: 'func2',
289+
},
290+
];
291+
292+
const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any });
293+
294+
await bundle.call(plugin);
295+
296+
expect(plugin.buildResults).toStrictEqual(expectedResults);
297+
});
298+
299+
it('should error when trying to use ".mjs" extension', async () => {
300+
const functionEntries: FunctionEntry[] = [
301+
{
302+
entry: 'file1.ts',
303+
func: {
304+
events: [],
305+
handler: 'file1.handler',
306+
},
307+
functionAlias: 'func1',
308+
},
309+
{
310+
entry: 'file2.ts',
311+
func: {
312+
events: [],
313+
handler: 'file2.handler',
314+
},
315+
functionAlias: 'func2',
316+
},
317+
];
318+
319+
const buildOptions: Partial<Configuration> = {
320+
concurrency: Infinity,
321+
bundle: true,
322+
target: 'node12',
323+
external: [],
324+
exclude: ['aws-sdk'],
325+
nativeZip: false,
326+
packager: 'npm',
327+
installExtraArgs: [],
328+
watch: {},
329+
keepOutputDirectory: false,
330+
packagerOptions: {},
331+
platform: 'node',
332+
outputFileExtension: '.mjs',
333+
};
334+
335+
const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any });
336+
337+
const expectedError = 'ERROR: platform "node" should not output a file with extension ".mjs".';
338+
339+
try {
340+
await bundle.call(plugin);
341+
} catch (error) {
342+
expect(error).toHaveProperty('message', expectedError);
343+
}
344+
});
345+
});
346+
347+
describe('buildOption platform neutral', () => {
348+
it('should set buildResults buildPath after compilation is complete with default extension', async () => {
349+
const functionEntries: FunctionEntry[] = [
350+
{
351+
entry: 'file1.ts',
352+
func: {
353+
events: [],
354+
handler: 'file1.handler',
355+
},
356+
functionAlias: 'func1',
357+
},
358+
{
359+
entry: 'file2.ts',
360+
func: {
361+
events: [],
362+
handler: 'file2.handler',
363+
},
364+
functionAlias: 'func2',
365+
},
366+
];
367+
368+
const buildOptions: Partial<Configuration> = {
369+
concurrency: Infinity,
370+
bundle: true,
371+
target: 'node12',
372+
external: [],
373+
exclude: ['aws-sdk'],
374+
nativeZip: false,
375+
packager: 'npm',
376+
installExtraArgs: [],
377+
watch: {},
378+
keepOutputDirectory: false,
379+
packagerOptions: {},
380+
platform: 'neutral',
381+
outputFileExtension: '.js',
382+
};
383+
384+
const expectedResults: FunctionBuildResult[] = [
385+
{
386+
bundlePath: 'file1.js',
387+
func: { events: [], handler: 'file1.handler' },
388+
functionAlias: 'func1',
389+
},
390+
{
391+
bundlePath: 'file2.js',
392+
func: { events: [], handler: 'file2.handler' },
393+
functionAlias: 'func2',
394+
},
395+
];
396+
397+
const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any });
398+
399+
await bundle.call(plugin);
400+
401+
expect(plugin.buildResults).toStrictEqual(expectedResults);
402+
});
403+
404+
it('should set buildResults buildPath after compilation is complete with ".mjs" extension', async () => {
405+
const functionEntries: FunctionEntry[] = [
406+
{
407+
entry: 'file1.ts',
408+
func: {
409+
events: [],
410+
handler: 'file1.handler',
411+
},
412+
functionAlias: 'func1',
413+
},
414+
{
415+
entry: 'file2.ts',
416+
func: {
417+
events: [],
418+
handler: 'file2.handler',
419+
},
420+
functionAlias: 'func2',
421+
},
422+
];
423+
424+
const buildOptions: Partial<Configuration> = {
425+
concurrency: Infinity,
426+
bundle: true,
427+
target: 'node12',
428+
external: [],
429+
exclude: ['aws-sdk'],
430+
nativeZip: false,
431+
packager: 'npm',
432+
installExtraArgs: [],
433+
watch: {},
434+
keepOutputDirectory: false,
435+
packagerOptions: {},
436+
platform: 'neutral',
437+
outputFileExtension: '.mjs',
438+
};
439+
440+
const expectedResults: FunctionBuildResult[] = [
441+
{
442+
bundlePath: 'file1.mjs',
443+
func: { events: [], handler: 'file1.handler' },
444+
functionAlias: 'func1',
445+
},
446+
{
447+
bundlePath: 'file2.mjs',
448+
func: { events: [], handler: 'file2.handler' },
449+
functionAlias: 'func2',
450+
},
451+
];
452+
453+
const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any });
454+
455+
await bundle.call(plugin);
456+
457+
expect(plugin.buildResults).toStrictEqual(expectedResults);
458+
});
459+
460+
it('should error when trying to use ".cjs" extension', async () => {
461+
const functionEntries: FunctionEntry[] = [
462+
{
463+
entry: 'file1.ts',
464+
func: {
465+
events: [],
466+
handler: 'file1.handler',
467+
},
468+
functionAlias: 'func1',
469+
},
470+
{
471+
entry: 'file2.ts',
472+
func: {
473+
events: [],
474+
handler: 'file2.handler',
475+
},
476+
functionAlias: 'func2',
477+
},
478+
];
479+
480+
const buildOptions: Partial<Configuration> = {
481+
concurrency: Infinity,
482+
bundle: true,
483+
target: 'node12',
484+
external: [],
485+
exclude: ['aws-sdk'],
486+
nativeZip: false,
487+
packager: 'npm',
488+
installExtraArgs: [],
489+
watch: {},
490+
keepOutputDirectory: false,
491+
packagerOptions: {},
492+
platform: 'neutral',
493+
outputFileExtension: '.cjs',
494+
};
495+
496+
const plugin = esbuildPlugin({ functionEntries, buildOptions: buildOptions as any });
497+
498+
const expectedError =
499+
'ERROR: platform "neutral" should not output a file with extension ".cjs".';
500+
501+
try {
502+
await bundle.call(plugin);
503+
} catch (error) {
504+
expect(error).toHaveProperty('message', expectedError);
505+
}
506+
});
507+
});

0 commit comments

Comments
 (0)