Skip to content

Commit f2c6b92

Browse files
ryanoleeryan leemattstrom
authored
Add hot reloading to server when using serverless webpack or modules built ontop of it (#262)
* Add hot reloading to server * Update src/function-set.ts Co-authored-by: Matt Strom <5853934+mattstrom@users.noreply.github.com> * Chore: Fix linting Co-authored-by: ryan lee <rol@bluetel.co.uk> Co-authored-by: Matt Strom <5853934+mattstrom@users.noreply.github.com>
1 parent 1f1335d commit f2c6b92

File tree

7 files changed

+189
-25
lines changed

7 files changed

+189
-25
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"watch": "tsc -w",
1313
"test": "snyk test && echo \"No test specified\"",
1414
"lint": "tslint --project tsconfig.json",
15+
"lint:fix": "tslint --fix --project tsconfig.json",
1516
"commit": "git-cz",
1617
"release": "semantic-release --no-ci",
1718
"release:dry-run": "semantic-release --no-ci --dry-run",

src/behavior-router.ts

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import bodyParser from 'body-parser';
33
import connect, { HandleFunction } from 'connect';
44
import cookieParser from 'cookie-parser';
55
import * as fs from 'fs-extra';
6-
import { createServer, IncomingMessage, ServerResponse } from 'http';
6+
import { createServer, Server, IncomingMessage, ServerResponse } from 'http';
77
import { StatusCodes } from 'http-status-codes';
88
import * as os from 'os';
99
import * as path from 'path';
1010
import { URL } from 'url';
11-
import { HttpError, InternalServerError } from './errors/http';
1211

12+
import { HttpError, InternalServerError } from './errors/http';
1313
import { FunctionSet } from './function-set';
1414
import { asyncMiddleware, cloudfrontPost } from './middlewares';
1515
import { CloudFrontLifecycle, Origin, CacheService } from './services';
@@ -35,8 +35,10 @@ export class BehaviorRouter {
3535
private fileDir: string;
3636
private path: string;
3737

38+
private started: boolean = false;
3839
private origins: Map<string, Origin>;
39-
40+
private restarting: boolean = false;
41+
private server: Server | null = null;
4042
private cacheService: CacheService;
4143
private log: (message: string) => void;
4244

@@ -76,12 +78,56 @@ export class BehaviorRouter {
7678
return this.behaviors.get('*') || null;
7779
}
7880

79-
async listen(port: number) {
81+
async start(port: number) {
82+
this.started = true;
83+
84+
return new Promise(async (res, rej) => {
85+
await this.listen(port);
86+
87+
// While the server is in a "restarting state" just restart the server
88+
while (this.restarting) {
89+
this.restarting = false;
90+
await this.listen(port, false);
91+
}
92+
93+
res('Server shutting down ...');
94+
});
95+
}
96+
97+
public hasStarted() {
98+
return this.started;
99+
}
100+
101+
public isRunning() {
102+
return this.server !== null;
103+
}
104+
105+
public async restart() {
106+
if (this.restarting) {
107+
return;
108+
}
109+
110+
this.restarting = true;
111+
112+
this.purgeBehaviourFunctions();
113+
await this.shutdown();
114+
}
115+
116+
private async shutdown() {
117+
if (this.server !== null) {
118+
await this.server.close();
119+
}
120+
this.server = null;
121+
}
122+
123+
private async listen(port: number, verbose: boolean = true) {
80124
try {
81125
await this.extractBehaviors();
82-
this.logStorage();
83-
this.logBehaviors();
84126

127+
if (verbose) {
128+
this.logStorage();
129+
this.logBehaviors();
130+
}
85131
const app = connect();
86132

87133
app.use(cloudfrontPost());
@@ -133,10 +179,11 @@ export class BehaviorRouter {
133179

134180

135181
return new Promise(resolve => {
136-
const server = createServer(app);
137-
138-
server.listen(port);
139-
server.on('close', resolve);
182+
this.server = createServer(app);
183+
this.server.listen(port);
184+
this.server.on('close', (e: string) => {
185+
resolve(e);
186+
});
140187
});
141188
} catch (err) {
142189
console.error(err);
@@ -200,6 +247,12 @@ export class BehaviorRouter {
200247
}
201248
}
202249

250+
private purgeBehaviourFunctions() {
251+
this.behaviors.forEach((behavior) => {
252+
behavior.purgeLoadedFunctions();
253+
});
254+
}
255+
203256
private logStorage() {
204257
this.log(`Cache directory: file://${this.cacheDir}`);
205258
this.log(`Files directory: file://${this.fileDir}`);

src/function-set.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import globToRegExp from 'glob-to-regexp';
55

66
import { Origin } from './services';
77
import { EventType } from './types';
8-
import { CallbackPromise, loadModule } from './utils';
8+
import { CallbackPromise, ModuleLoader} from './utils';
99

1010

1111
export type AsyncCloudFrontRequestHandler = (event: CloudFrontRequestEvent, context: Context) => Promise<CloudFrontRequestResult>;
@@ -15,6 +15,8 @@ const identityRequestHandler = async (event: CloudFrontRequestEvent) => event.Re
1515
const identityResponseHandler = async (event: CloudFrontResponseEvent) => event.Records[0].cf.response;
1616

1717
export class FunctionSet {
18+
protected readonly moduleLoader: ModuleLoader = new ModuleLoader();
19+
1820
public readonly regex: RegExp;
1921

2022
viewerRequest: Annotated<AsyncCloudFrontRequestHandler> = identityRequestHandler;
@@ -52,7 +54,7 @@ export class FunctionSet {
5254
}
5355

5456
async getRequestHandler(path: string): Promise<AsyncCloudFrontRequestHandler> {
55-
const fn = await loadModule(path);
57+
const fn = await this.moduleLoader.loadModule(path);
5658

5759
const handler = async (event: CloudFrontRequestEvent, context: Context) => {
5860
const promise = new CallbackPromise();
@@ -76,7 +78,7 @@ export class FunctionSet {
7678
}
7779

7880
async getResponseHandler(path: string): Promise<AsyncCloudFrontResponseHandler> {
79-
const fn = await loadModule(path);
81+
const fn = await this.moduleLoader.loadModule(path);
8082

8183
const handler = async (event: CloudFrontResponseEvent, context: Context) => {
8284
const deferred = new CallbackPromise();
@@ -93,4 +95,8 @@ export class FunctionSet {
9395

9496
return handler;
9597
}
98+
99+
public purgeLoadedFunctions() {
100+
this.moduleLoader.purgeLoadedModules();
101+
}
96102
}

src/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ class OfflineEdgeLambdaPlugin {
6565

6666
this.hooks = {
6767
'offline:start:init': this.onStart.bind(this),
68-
'offline:start:end': this.onEnd.bind(this)
68+
'offline:start:end': this.onEnd.bind(this),
69+
'webpack:compile:watch:compile': this.onReload.bind(this)
6970
};
7071
}
7172

@@ -74,7 +75,7 @@ class OfflineEdgeLambdaPlugin {
7475
const port = this.options.cloudfrontPort || this.options.port || 8080;
7576

7677
this.log(`CloudFront Offline listening on port ${port}`);
77-
await this.server.listen(port);
78+
await this.server.start(port);
7879
} catch (err) {
7980
console.error(err);
8081
}
@@ -85,6 +86,17 @@ class OfflineEdgeLambdaPlugin {
8586
this.log(`CloudFront Offline storage purged`);
8687
}
8788

89+
async onReload() {
90+
// In the event we have not started the server yet or we are in the process of
91+
// restarting the server ignore our changes
92+
if (!this.server.hasStarted() || !this.server.isRunning()) {
93+
return;
94+
}
95+
96+
console.log('Restarting server due to function update...');
97+
await this.server.restart();
98+
}
99+
88100
private prepareCustomSection() {
89101
const { service } = this.serverless;
90102
service.custom = service.custom || {};

src/utils/clear-module.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* <3 Lovingly borrowed from
3+
* https://github.com/dherault/serverless-offline/blob/master/src/lambda/handler-runner/in-process-runner/InProcessRunner.js
4+
*/
5+
import * as path from 'path';
6+
import * as fs from 'fs';
7+
8+
interface ClearModuleOpts {
9+
cleanup: boolean;
10+
}
11+
12+
export const clearModule = (filePath: string, opts: ClearModuleOpts) => {
13+
const options = opts ?? {};
14+
15+
if (!require || !require.cache) {
16+
return;
17+
}
18+
19+
if (!require.cache[filePath]) {
20+
const dirname = path.dirname(filePath);
21+
for (const fn of fs.readdirSync(dirname)) {
22+
const fullPath = path.resolve(dirname, fn);
23+
if (
24+
fullPath.substr(0, filePath.length + 1) === `${filePath}.` &&
25+
require.cache[fullPath]
26+
) {
27+
filePath = fullPath;
28+
break;
29+
}
30+
}
31+
}
32+
33+
if (require.cache[filePath]) {
34+
35+
// Remove file from parent cache
36+
if (require?.cache[filePath]?.parent) {
37+
let i = require?.cache[filePath]?.parent?.children.length;
38+
if (i) {
39+
do {
40+
i -= 1;
41+
if (require?.cache[filePath]?.parent?.children[i].id === filePath) {
42+
require?.cache[filePath]?.parent?.children.splice(i, 1);
43+
}
44+
} while (i);
45+
}
46+
}
47+
const cld = require?.cache[filePath]?.children;
48+
delete require.cache[filePath];
49+
for (const c of cld as NodeJS.Module[]) {
50+
// Unload any non node_modules children
51+
if (!c.filename.match(/node_modules/)) {
52+
clearModule(c.id, { ...options, cleanup: false });
53+
}
54+
}
55+
if (opts.cleanup) {
56+
// Cleanup any node_modules that are orphans
57+
let cleanup = false;
58+
do {
59+
cleanup = false;
60+
for (const fn of Object.keys(require.cache)) {
61+
if (
62+
require?.cache[fn]?.id !== '.' &&
63+
require?.cache[fn]?.parent &&
64+
require?.cache[fn]?.parent?.id !== '.' &&
65+
!require.cache[require?.cache[fn]?.parent?.id as string]
66+
) {
67+
delete require.cache[fn];
68+
cleanup = true;
69+
}
70+
}
71+
} while (cleanup);
72+
}
73+
}
74+
};

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export * from './convert-to-cf-event';
99
export * from './deferred-promise';
1010
export * from './is-response-result';
1111
export * from './load-module';
12+
export * from './clear-module';
1213
export * from './to-result-response';

src/utils/load-module.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
11
import { resolve } from 'path';
2+
import {clearModule} from './clear-module';
23

3-
export async function loadModule(path: string): Promise<Function> {
4-
const regex = /(.+)\.(.+)/;
5-
const match = regex.exec(path);
4+
export class ModuleLoader {
5+
protected loadedModules: string[] = [];
66

7-
if (!match) {
8-
throw new Error('Could not find module');
9-
}
7+
async loadModule(path: string): Promise<Function> {
8+
const regex = /(.+)\.(.+)/;
9+
const match = regex.exec(path);
10+
11+
if (!match) {
12+
throw new Error('Could not find module');
13+
}
1014

11-
const [, modulePath, functionName] = match;
12-
const absPath = resolve(modulePath);
15+
const [, modulePath, functionName] = match;
16+
const absPath = resolve(modulePath);
1317

14-
const module = await import(absPath);
18+
const module = await import(absPath);
1519

16-
return module[functionName];
20+
this.loadedModules.push(absPath);
21+
22+
return module[functionName];
23+
}
24+
25+
public purgeLoadedModules() {
26+
this.loadedModules.forEach((module) => {
27+
clearModule(module, {
28+
cleanup: true
29+
});
30+
});
31+
32+
this.loadedModules = [];
33+
}
1734
}

0 commit comments

Comments
 (0)