diff --git a/.gitignore b/.gitignore index 4159e39..f15fda7 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ testem.log .DS_Store Thumbs.db +# env +.env* +!.env.example + .nx/cache -.env.local examples/complete/package-lock.json diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 08428e2..ae4a1ed 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -18,3 +18,5 @@ To try the plugin in another local repo then 4. `npx nx release publish` to publish the plugin to the local registry 5. Follow the [Install Instructions](../README.md#install) as normal in your other local repository, replacing the version with the one you just published. 6. If you make changes to the plugin, repeat steps 3&4 changing the version number. You will also need to update the `package.json` in your local repo to point to the new version. And perform a fresh install of the plugin in your local repo. + +Note: To test the deploy functionality, you will need to have an AWS account and [AWS credentials configured](https://www.serverless.com/framework/docs/providers/aws/guide/credentials). diff --git a/examples/complete/package.json b/examples/complete/package.json index 7776399..c161e63 100644 --- a/examples/complete/package.json +++ b/examples/complete/package.json @@ -2,7 +2,9 @@ "name": "@serverless-rspack/complete", "version": "0.0.0", "scripts": { - "package": "sls package --verbose" + "package": "sls package --verbose", + "deploy": "sls deploy --verbose", + "deploy:func:app3": "sls deploy function -f app3 --verbose" }, "dependencies": { "isin-validator": "^1.1.1", diff --git a/libs/serverless-rspack/src/lib/hooks/deploy-function/after-package-function.ts b/libs/serverless-rspack/src/lib/hooks/deploy-function/after-package-function.ts new file mode 100644 index 0000000..16c05cf --- /dev/null +++ b/libs/serverless-rspack/src/lib/hooks/deploy-function/after-package-function.ts @@ -0,0 +1,8 @@ +import type { RspackServerlessPlugin } from '../../serverless-rspack.js'; + +export async function AfterDeployFunctionPackageFunction( + this: RspackServerlessPlugin +) { + this.log.verbose('[sls-rspack] after:deploy:function:packageFunction'); + await this.cleanup(); +} diff --git a/libs/serverless-rspack/src/lib/hooks/deploy-function/before-package-function.ts b/libs/serverless-rspack/src/lib/hooks/deploy-function/before-package-function.ts new file mode 100644 index 0000000..0e8a53f --- /dev/null +++ b/libs/serverless-rspack/src/lib/hooks/deploy-function/before-package-function.ts @@ -0,0 +1,34 @@ +import type { RspackServerlessPlugin } from '../../serverless-rspack.js'; +import { isDeployFunctionOptions } from '../../types.js'; + +export async function BeforeDeployFunctionPackageFunction( + this: RspackServerlessPlugin +) { + this.log.verbose('[sls-rspack] before:deploy:function:packageFunction'); + + if (!isDeployFunctionOptions(this.options)) { + throw new this.serverless.classes.Error( + 'This hook only supports deploy function options' + ); + } + + const deployFunc = this.options.function; + + if (!(deployFunc in this.functionEntries)) { + throw new this.serverless.classes.Error( + `Function ${deployFunc} not found in function entries` + ); + } + + const entry = this.functionEntries[deployFunc]; + + await this.bundle({ + [deployFunc]: entry, + }); + + this.functionEntries = {}; + this.functionEntries[deployFunc] = entry; + + await this.scripts(); + await this.pack(); +} diff --git a/libs/serverless-rspack/src/lib/pack.ts b/libs/serverless-rspack/src/lib/pack.ts index c0943b3..30e0f68 100644 --- a/libs/serverless-rspack/src/lib/pack.ts +++ b/libs/serverless-rspack/src/lib/pack.ts @@ -64,6 +64,12 @@ export async function pack(this: RspackServerlessPlugin) { */ function zipDirectory(sourceDir: string, outPath: string) { const archive = archiver('zip', { zlib: { level: 9 } }); + + const outDir = path.dirname(outPath); + + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } const stream = fs.createWriteStream(outPath); return new Promise((resolve, reject) => { diff --git a/libs/serverless-rspack/src/lib/serverless-rspack.ts b/libs/serverless-rspack/src/lib/serverless-rspack.ts index 2cc33d4..d8b6e42 100644 --- a/libs/serverless-rspack/src/lib/serverless-rspack.ts +++ b/libs/serverless-rspack/src/lib/serverless-rspack.ts @@ -8,6 +8,8 @@ import type ServerlessPlugin from 'serverless/classes/Plugin'; import { bundle } from './bundle.js'; import { SERVERLESS_FOLDER, WORK_FOLDER } from './constants.js'; import { determineFileParts, isNodeFunction } from './helpers.js'; +import { AfterDeployFunctionPackageFunction } from './hooks/deploy-function/after-package-function.js'; +import { BeforeDeployFunctionPackageFunction } from './hooks/deploy-function/before-package-function.js'; import { Initialize } from './hooks/initialize.js'; import { BeforeInvokeLocalInvoke } from './hooks/invoke-local/before-invoke.js'; import { AfterPackageCreateDeploymentArtifacts } from './hooks/package/after-create-deployment-artifacts.js'; @@ -20,7 +22,6 @@ import { PluginOptionsSchema, RsPackFunctionDefinitionHandler, } from './types.js'; - export class RspackServerlessPlugin implements ServerlessPlugin { serviceDirPath: string; buildOutputFolder: string; @@ -72,12 +73,10 @@ export class RspackServerlessPlugin implements ServerlessPlugin { BeforePackageCreateDeploymentArtifacts.bind(this), 'after:package:createDeploymentArtifacts': AfterPackageCreateDeploymentArtifacts.bind(this), - // 'before:deploy:function:packageFunction': () => { - // this.log.verbose('after:deploy:function:packageFunction'); - // }, - // 'after:deploy:function:packageFunction': () => { - // this.log.verbose('after:deploy:function:packageFunction'); - // }, + 'before:deploy:function:packageFunction': + BeforeDeployFunctionPackageFunction.bind(this), + 'after:deploy:function:packageFunction': + AfterDeployFunctionPackageFunction.bind(this), 'before:invoke:local:invoke': BeforeInvokeLocalInvoke.bind(this), }; } diff --git a/libs/serverless-rspack/src/lib/types.ts b/libs/serverless-rspack/src/lib/types.ts index 1f0dde4..749876d 100644 --- a/libs/serverless-rspack/src/lib/types.ts +++ b/libs/serverless-rspack/src/lib/types.ts @@ -49,6 +49,12 @@ export function isInvokeOptions( return typeof options.function === 'string'; } +export function isDeployFunctionOptions( + options: Serverless.Options +): options is Serverless.Options & { function: string } { + return typeof options.function === 'string'; +} + export type PluginFunctionEntries = { [name: string]: { import: string; diff --git a/libs/serverless-rspack/src/test/hooks/deploy-function/after-package-function.spec.ts b/libs/serverless-rspack/src/test/hooks/deploy-function/after-package-function.spec.ts new file mode 100644 index 0000000..174a183 --- /dev/null +++ b/libs/serverless-rspack/src/test/hooks/deploy-function/after-package-function.spec.ts @@ -0,0 +1,72 @@ +import { rm } from 'node:fs/promises'; +import { RspackServerlessPlugin } from '../../../lib/serverless-rspack.js'; +import { PluginOptions } from '../../../lib/types.js'; +import { logger, mockOptions, mockServerlessConfig } from '../../test-utils.js'; + +jest.mock('../../../lib/bundle', () => ({ + bundle: jest.fn(), +})); + +jest.mock('node:fs', () => ({ + readdirSync: () => ['hello1.ts', 'hello2.ts'], +})); + +jest.mock('node:fs/promises', () => ({ + rm: jest.fn(), +})); + +afterEach(() => { + jest.resetModules(); + jest.resetAllMocks(); +}); + +describe('after:deploy:function:packageFunction hook', () => { + it('should be defined', () => { + const serverless = mockServerlessConfig(); + const plugin = new RspackServerlessPlugin(serverless, mockOptions, logger); + + expect(plugin.hooks['after:deploy:function:packageFunction']).toBeDefined(); + }); + + it('should by default remove the build dir', async () => { + const serverless = mockServerlessConfig(); + const plugin = new RspackServerlessPlugin(serverless, mockOptions, logger); + + plugin.pluginOptions = {} as Required; + + await plugin.hooks['after:deploy:function:packageFunction'](); + + expect(rm).toHaveBeenCalledWith('/workDir/.rspack', { + recursive: true, + }); + }); + + it('should remove the build dir when keepOutputDirectory is false', async () => { + const serverless = mockServerlessConfig(); + const plugin = new RspackServerlessPlugin(serverless, mockOptions, logger); + + plugin.pluginOptions = { + keepOutputDirectory: false, + } as Required; + + await plugin.hooks['after:deploy:function:packageFunction'](); + + expect(rm).toHaveBeenCalledWith('/workDir/.rspack', { + recursive: true, + }); + }); + + it('keep the build dir when keepOutputDirectory is true', async () => { + const serverless = mockServerlessConfig(); + const plugin = new RspackServerlessPlugin(serverless, mockOptions, logger); + + plugin.pluginOptions = { + keepOutputDirectory: true, + } as Required; + + await plugin.hooks['after:deploy:function:packageFunction'](); + expect(rm).not.toHaveBeenCalledWith('/workDir/.rspack', { + recursive: true, + }); + }); +}); diff --git a/libs/serverless-rspack/src/test/hooks/deploy-function/before-package-function.spec.ts b/libs/serverless-rspack/src/test/hooks/deploy-function/before-package-function.spec.ts new file mode 100644 index 0000000..48d1a15 --- /dev/null +++ b/libs/serverless-rspack/src/test/hooks/deploy-function/before-package-function.spec.ts @@ -0,0 +1,160 @@ +import { bundle } from '../../../lib/bundle.js'; +import { pack } from '../../../lib/pack.js'; +import { scripts } from '../../../lib/scripts.js'; +import { RspackServerlessPlugin } from '../../../lib/serverless-rspack.js'; +import { logger, mockOptions, mockServerlessConfig } from '../../test-utils.js'; + +jest.mock('../../../lib/bundle', () => ({ + bundle: jest.fn(), +})); + +jest.mock('../../../lib/pack', () => ({ + pack: jest.fn(), +})); + +jest.mock('../../../lib/scripts', () => ({ + scripts: jest.fn(), +})); + +afterEach(() => { + jest.resetModules(); + jest.resetAllMocks(); +}); + +describe('before:deploy:function:packageFunction', () => { + let plugin: RspackServerlessPlugin; + + it('should be defined', () => { + const serverless = mockServerlessConfig(); + const plugin = new RspackServerlessPlugin(serverless, mockOptions, logger); + + expect( + plugin.hooks['before:deploy:function:packageFunction'] + ).toBeDefined(); + }); + + it('should throw error if options are invalid', async () => { + const serverless = mockServerlessConfig(); + plugin = new RspackServerlessPlugin(serverless, {}, logger); + + try { + await plugin.hooks['before:deploy:function:packageFunction'](); + fail('Expected function to throw an error'); + } catch (error) { + expect(serverless.classes.Error).toHaveBeenCalledTimes(1); + expect(serverless.classes.Error).toHaveBeenCalledWith( + 'This hook only supports deploy function options' + ); + } + }); + + it('should throw error if function not found in entries', async () => { + const serverless = mockServerlessConfig(); + plugin = new RspackServerlessPlugin( + serverless, + { ...mockOptions, function: 'nonexistentFunc' }, + logger + ); + plugin.functionEntries = {}; + try { + await plugin.hooks['before:deploy:function:packageFunction'](); + fail('Expected function to throw an error'); + } catch (error) { + expect(serverless.classes.Error).toHaveBeenCalledTimes(1); + expect(serverless.classes.Error).toHaveBeenCalledWith( + 'Function nonexistentFunc not found in function entries' + ); + } + }); + + it('should bundle the entries', async () => { + const serverless = mockServerlessConfig(); + const plugin = new RspackServerlessPlugin( + serverless, + { ...mockOptions, function: 'myFunc' }, + logger + ); + + plugin.functionEntries = { + myFunc: { import: 'test/path/entry.ts', filename: 'test/path/entry.ts' }, + }; + + await plugin.hooks['before:deploy:function:packageFunction'](); + + expect(bundle).toHaveBeenCalledTimes(1); + expect(bundle).toHaveBeenCalledWith(plugin.functionEntries); + }); + + it('should run scripts', async () => { + const serverless = mockServerlessConfig(); + const plugin = new RspackServerlessPlugin( + serverless, + { ...mockOptions, function: 'myFunc' }, + logger + ); + + plugin.functionEntries = { + myFunc: { import: 'test/path/entry.ts', filename: 'test/path/entry.ts' }, + }; + + await plugin.hooks['before:deploy:function:packageFunction'](); + + expect(scripts).toHaveBeenCalledTimes(1); + expect(scripts).toHaveBeenCalledWith(); + }); + + it('should pack the entries', async () => { + const serverless = mockServerlessConfig(); + const plugin = new RspackServerlessPlugin( + serverless, + { ...mockOptions, function: 'myFunc' }, + logger + ); + + plugin.functionEntries = { + myFunc: { import: 'test/path/entry.ts', filename: 'test/path/entry.ts' }, + }; + + await plugin.hooks['before:deploy:function:packageFunction'](); + + expect(pack).toHaveBeenCalledTimes(1); + }); + + it('should bundle before scripts', async () => { + const serverless = mockServerlessConfig(); + const plugin = new RspackServerlessPlugin( + serverless, + { ...mockOptions, function: 'myFunc' }, + logger + ); + + plugin.functionEntries = { + myFunc: { import: 'test/path/entry.ts', filename: 'test/path/entry.ts' }, + }; + + await plugin.hooks['before:deploy:function:packageFunction'](); + + expect(jest.mocked(bundle).mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(scripts).mock.invocationCallOrder[0] + ); + }); + + it('should pack after scripts', async () => { + const serverless = mockServerlessConfig(); + const plugin = new RspackServerlessPlugin( + serverless, + { ...mockOptions, function: 'myFunc' }, + logger + ); + + plugin.functionEntries = { + myFunc: { import: 'test/path/entry.ts', filename: 'test/path/entry.ts' }, + }; + + await plugin.hooks['before:deploy:function:packageFunction'](); + + expect(jest.mocked(scripts).mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(pack).mock.invocationCallOrder[0] + ); + }); +}); diff --git a/libs/serverless-rspack/src/test/serverless-rspack.spec.ts b/libs/serverless-rspack/src/test/serverless-rspack.spec.ts index dfcdeab..d0c76e1 100644 --- a/libs/serverless-rspack/src/test/serverless-rspack.spec.ts +++ b/libs/serverless-rspack/src/test/serverless-rspack.spec.ts @@ -74,6 +74,8 @@ describe('RspackServerlessPlugin', () => { 'initialize', 'before:package:createDeploymentArtifacts', 'after:package:createDeploymentArtifacts', + 'before:deploy:function:packageFunction', + 'after:deploy:function:packageFunction', 'before:invoke:local:invoke', ]); });