s4d 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +13 -0
- package/README.md +42 -0
- package/dist/DevServer.d.ts +39 -0
- package/dist/DevServer.js +321 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +11 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +51 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2024 Morris Brodersen <mb@morrisbrodersen.de>
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose
|
|
4
|
+
with or without fee is hereby granted, provided that the above copyright notice
|
|
5
|
+
and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
8
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
9
|
+
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
10
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
|
11
|
+
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
|
12
|
+
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
|
13
|
+
THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# s4d
|
|
2
|
+
|
|
3
|
+
Minimal web development server with live reload.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install s4d --save-dev # project local installation (preferred)
|
|
9
|
+
npm install s4d --global # global installation
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
s4d [options] <webroot>
|
|
16
|
+
|
|
17
|
+
--help, -h Show this help.
|
|
18
|
+
--host <host> Set hostname. Default is "localhost".
|
|
19
|
+
Set "0.0.0.0" to expose over network.
|
|
20
|
+
--port <port> Set port. Default is 8080.
|
|
21
|
+
--spa Single-page application mode.
|
|
22
|
+
Serves /index.html for URLs that cannot be resolved to a file.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
s4d public
|
|
29
|
+
s4d --port 3000 public
|
|
30
|
+
s4d --port 3000 --spa public
|
|
31
|
+
s4d --port 3000 --host 0.0.0.0 --spa public # Expose over network
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
When installed locally in a project, run with `npx s4d [args...]`,
|
|
35
|
+
unless running from an npm script.
|
|
36
|
+
|
|
37
|
+
## Live Reload Behavior
|
|
38
|
+
|
|
39
|
+
When a file under webroot is modified,
|
|
40
|
+
attempts to reload stylesheets and images in-place
|
|
41
|
+
if they match the modified file.
|
|
42
|
+
In all other cases, a hard page reload is triggered.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as http from 'http';
|
|
3
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
4
|
+
export interface DevServerOptions {
|
|
5
|
+
webroot: string;
|
|
6
|
+
port: number;
|
|
7
|
+
host?: string;
|
|
8
|
+
spa?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare class DevServer {
|
|
11
|
+
protected webroot: string;
|
|
12
|
+
protected port: number;
|
|
13
|
+
protected host?: string;
|
|
14
|
+
protected spa?: boolean;
|
|
15
|
+
protected server: http.Server;
|
|
16
|
+
protected webSocketServer: WebSocketServer;
|
|
17
|
+
protected fileWatcher?: fs.FSWatcher;
|
|
18
|
+
protected fileCache: Map<any, any>;
|
|
19
|
+
protected webSockets: Set<WebSocket>;
|
|
20
|
+
constructor(options: DevServerOptions);
|
|
21
|
+
static cli(argv: string[]): Promise<1 | undefined>;
|
|
22
|
+
static help(): string;
|
|
23
|
+
start(): Promise<void>;
|
|
24
|
+
startServer(): Promise<void>;
|
|
25
|
+
getBaseURL(): string;
|
|
26
|
+
handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void>;
|
|
27
|
+
startWatching(): Promise<void>;
|
|
28
|
+
broadcast(message: Record<string, unknown>): void;
|
|
29
|
+
transformFileContents(file: string, contents: string | Buffer): Promise<string | Buffer>;
|
|
30
|
+
getClientScript(): string;
|
|
31
|
+
resolveFileCached(file: string): Promise<any>;
|
|
32
|
+
resolveFile(file: string): Promise<{
|
|
33
|
+
contents: string | Buffer;
|
|
34
|
+
contentType: string;
|
|
35
|
+
version: string;
|
|
36
|
+
}>;
|
|
37
|
+
invalidateFile(file: string): void;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as http from 'http';
|
|
4
|
+
import mime from 'mime';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
7
|
+
export class DevServer {
|
|
8
|
+
webroot;
|
|
9
|
+
port;
|
|
10
|
+
host;
|
|
11
|
+
spa;
|
|
12
|
+
server;
|
|
13
|
+
webSocketServer;
|
|
14
|
+
fileWatcher;
|
|
15
|
+
fileCache = new Map();
|
|
16
|
+
webSockets = new Set();
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.webroot = options.webroot;
|
|
19
|
+
this.port = options.port;
|
|
20
|
+
this.host = options.host;
|
|
21
|
+
this.spa = options.spa;
|
|
22
|
+
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
23
|
+
this.webSocketServer = new WebSocketServer({ server: this.server });
|
|
24
|
+
this.webSocketServer.on('connection', (webSocket) => this.webSockets.add(webSocket));
|
|
25
|
+
}
|
|
26
|
+
static async cli(argv) {
|
|
27
|
+
for (let i = 0; i < argv.length; ++i) {
|
|
28
|
+
switch (argv[i]) {
|
|
29
|
+
case '-h':
|
|
30
|
+
case '--help':
|
|
31
|
+
console.log(this.help());
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
let webroot;
|
|
36
|
+
let port = 8080;
|
|
37
|
+
let host = 'localhost';
|
|
38
|
+
let spa = false;
|
|
39
|
+
try {
|
|
40
|
+
for (let i = 0; i < argv.length; ++i) {
|
|
41
|
+
switch (argv[i]) {
|
|
42
|
+
case '--port':
|
|
43
|
+
if (i + 1 >= argv.length) {
|
|
44
|
+
throw new Error('--port requires an argument');
|
|
45
|
+
}
|
|
46
|
+
port = parseInt(argv[++i], 10);
|
|
47
|
+
if (!(port >= 0 && port <= 65535)) {
|
|
48
|
+
throw new Error(`Invalid port "${port}"`);
|
|
49
|
+
}
|
|
50
|
+
break;
|
|
51
|
+
case '--host':
|
|
52
|
+
if (i + 1 >= argv.length) {
|
|
53
|
+
throw new Error('--host requires an argument');
|
|
54
|
+
}
|
|
55
|
+
host = argv[++i];
|
|
56
|
+
case '--spa':
|
|
57
|
+
spa = true;
|
|
58
|
+
break;
|
|
59
|
+
default:
|
|
60
|
+
if (webroot) {
|
|
61
|
+
throw new Error(`Unexpected argument "${argv[i]}"`);
|
|
62
|
+
}
|
|
63
|
+
webroot = argv[i];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!webroot) {
|
|
67
|
+
throw new Error('Webroot is required');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
console.error(`Error: ${err.message}\n`);
|
|
72
|
+
console.error(this.help());
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
const devServer = new DevServer({ webroot, port, host, spa });
|
|
76
|
+
await devServer.start();
|
|
77
|
+
console.error(`Development server started: ${devServer.getBaseURL()}`);
|
|
78
|
+
}
|
|
79
|
+
static help() {
|
|
80
|
+
return `s4d [options] <webroot>
|
|
81
|
+
|
|
82
|
+
--help, -h Show this help.
|
|
83
|
+
--host <host> Set hostname. Default is "localhost".
|
|
84
|
+
Set "0.0.0.0" to expose over network.
|
|
85
|
+
--port <port> Set port. Default is 8080.
|
|
86
|
+
--spa Single-page application mode.
|
|
87
|
+
Serves /index.html for URLs that cannot be resolved to a file.
|
|
88
|
+
|
|
89
|
+
`;
|
|
90
|
+
}
|
|
91
|
+
async start() {
|
|
92
|
+
try {
|
|
93
|
+
await this.startWatching();
|
|
94
|
+
await this.startServer();
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
await this.close();
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async startServer() {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
this.server.on('listening', () => {
|
|
104
|
+
const address = this.server.address();
|
|
105
|
+
if (address && typeof address !== 'string') {
|
|
106
|
+
this.port = address.port;
|
|
107
|
+
resolve();
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
throw new Error(`Unexpected server address: ${address}`);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
this.server.on('error', reject);
|
|
114
|
+
this.server.listen(this.port, this.host);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
getBaseURL() {
|
|
118
|
+
const hostname = !this.host || this.host === '0.0.0.0' ? 'localhost' : this.host;
|
|
119
|
+
return `http://${hostname}:${this.port}`;
|
|
120
|
+
}
|
|
121
|
+
async handleRequest(req, res) {
|
|
122
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
123
|
+
res.setHeader('content-type', 'text/plain');
|
|
124
|
+
res.writeHead(405);
|
|
125
|
+
res.end('Method not allowed');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const url = new URL(req.url ?? '/', this.getBaseURL());
|
|
129
|
+
try {
|
|
130
|
+
const file = path.join(this.webroot, path.resolve('.', url.pathname));
|
|
131
|
+
const { contents, contentType, version } = await this.resolveFileCached(file);
|
|
132
|
+
if (req.headers['if-none-match'] === version) {
|
|
133
|
+
res.writeHead(304);
|
|
134
|
+
res.end();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
res.setHeader('content-type', contentType);
|
|
138
|
+
res.setHeader('etag', version);
|
|
139
|
+
res.writeHead(200);
|
|
140
|
+
if (req.method === 'HEAD') {
|
|
141
|
+
res.end();
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
res.end(contents);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (err.code === 'ENOENT') {
|
|
149
|
+
res.setHeader('content-type', 'text/plain');
|
|
150
|
+
res.writeHead(404);
|
|
151
|
+
res.end('Not found');
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
console.error(err);
|
|
155
|
+
res.setHeader('content-type', 'text/plain');
|
|
156
|
+
res.writeHead(500);
|
|
157
|
+
res.end(`Internal server error: ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async startWatching() {
|
|
162
|
+
this.fileWatcher = fs.watch(this.webroot, { recursive: true });
|
|
163
|
+
this.fileWatcher.on('change', (_, filename) => {
|
|
164
|
+
if (typeof filename === 'string') {
|
|
165
|
+
this.invalidateFile(path.join(this.webroot, filename));
|
|
166
|
+
this.broadcast({ type: 'change', url: filename });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
broadcast(message) {
|
|
171
|
+
for (const webSocket of this.webSockets) {
|
|
172
|
+
if (webSocket.readyState === WebSocket.OPEN) {
|
|
173
|
+
webSocket.send(JSON.stringify(message));
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
this.webSockets.delete(webSocket);
|
|
177
|
+
webSocket.terminate();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async transformFileContents(file, contents) {
|
|
182
|
+
const ext = path.extname(file);
|
|
183
|
+
if (ext === '.html') {
|
|
184
|
+
return `${contents}<script type="module" src="__DEV__.js"></script>`;
|
|
185
|
+
}
|
|
186
|
+
return contents;
|
|
187
|
+
}
|
|
188
|
+
getClientScript() {
|
|
189
|
+
return `if (!navigator.webdriver) {
|
|
190
|
+
const wsProtocol = location.protocol === 'http:' ? 'ws://' : 'wss://';
|
|
191
|
+
const socket = new WebSocket(wsProtocol + location.host);
|
|
192
|
+
|
|
193
|
+
const hotURLs = new Set();
|
|
194
|
+
|
|
195
|
+
socket.addEventListener('message', (message) => {
|
|
196
|
+
if (!message.data) return;
|
|
197
|
+
|
|
198
|
+
const data = JSON.parse(message.data);
|
|
199
|
+
|
|
200
|
+
if (hotURLs.has(data.url)) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (hotURLs.size === 0) {
|
|
205
|
+
setTimeout(() => hotURLs.clear(), 50);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
hotURLs.add(data.url);
|
|
209
|
+
|
|
210
|
+
let reload = true;
|
|
211
|
+
|
|
212
|
+
// Hot reload stylesheets
|
|
213
|
+
document.querySelectorAll('link[rel=stylesheet]').forEach((el) => {
|
|
214
|
+
const href = el.getAttribute('href');
|
|
215
|
+
|
|
216
|
+
if (endsWithURL(data.url, href)) {
|
|
217
|
+
reload = false;
|
|
218
|
+
|
|
219
|
+
el.setAttribute('href', href);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Hot reload images
|
|
224
|
+
const refetchImages = new Map();
|
|
225
|
+
|
|
226
|
+
document.querySelectorAll('img').forEach(async (el) => {
|
|
227
|
+
const src = el.getAttribute('src');
|
|
228
|
+
const srcset = el.getAttribute('srcset');
|
|
229
|
+
|
|
230
|
+
if (
|
|
231
|
+
src && endsWithURL(data.url, src) ||
|
|
232
|
+
srcset && containsURL(srcset, data.url)
|
|
233
|
+
) {
|
|
234
|
+
reload = false;
|
|
235
|
+
|
|
236
|
+
let promise = refetchImages.get(data.url);
|
|
237
|
+
|
|
238
|
+
if (!promise) {
|
|
239
|
+
promise = fetch(data.url, { cache: 'reload' });
|
|
240
|
+
refetchImages.set(data.url, promise);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await promise;
|
|
244
|
+
|
|
245
|
+
if (src) el.setAttribute('src', src);
|
|
246
|
+
if (srcset) el.setAttribute('srcset', srcset);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Otherwise, reload page
|
|
251
|
+
if (reload) {
|
|
252
|
+
location.reload();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
socket.addEventListener('open', () => {
|
|
257
|
+
console.info('Development server connected');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
socket.addEventListener('close', () => {
|
|
261
|
+
console.warn('Development server disconnected');
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function endsWithURL(candidate, target) {
|
|
266
|
+
return candidate.endsWith(target.replace(/^\\.?\\//, ''));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function containsURL(candidate, target) {
|
|
270
|
+
return candidate.match(target.replace(/^\\.?\\//, ''));
|
|
271
|
+
}`;
|
|
272
|
+
}
|
|
273
|
+
async resolveFileCached(file) {
|
|
274
|
+
const cached = this.fileCache.get(file);
|
|
275
|
+
if (cached)
|
|
276
|
+
return cached;
|
|
277
|
+
const promise = this.resolveFile(file);
|
|
278
|
+
this.fileCache.set(file, promise);
|
|
279
|
+
return promise;
|
|
280
|
+
}
|
|
281
|
+
async resolveFile(file) {
|
|
282
|
+
if (file.match(/__DEV__\.js$/)) {
|
|
283
|
+
return {
|
|
284
|
+
contents: this.getClientScript(),
|
|
285
|
+
contentType: 'application/javascript',
|
|
286
|
+
version: '1',
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
let contents;
|
|
290
|
+
try {
|
|
291
|
+
const stat = await fs.promises.lstat(file);
|
|
292
|
+
if (stat.isDirectory()) {
|
|
293
|
+
file = path.join(file, 'index.html');
|
|
294
|
+
}
|
|
295
|
+
contents = await fs.promises.readFile(file);
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
if (this.spa && err.code === 'ENOENT') {
|
|
299
|
+
file = path.join(this.webroot, 'index.html');
|
|
300
|
+
contents = await fs.promises.readFile(file);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
contents = await this.transformFileContents(file, contents);
|
|
307
|
+
const contentType = mime.getType(file) ?? 'application/octet-stream';
|
|
308
|
+
const version = crypto.createHash('sha1').update(contents).digest('base64');
|
|
309
|
+
return { contents, contentType, version };
|
|
310
|
+
}
|
|
311
|
+
invalidateFile(file) {
|
|
312
|
+
this.fileCache.delete(file);
|
|
313
|
+
this.fileCache.delete(file.replace(/index.html$/, ''));
|
|
314
|
+
this.fileCache.delete(file.replace(/\/index.html$/, ''));
|
|
315
|
+
}
|
|
316
|
+
async close() {
|
|
317
|
+
this.server.close();
|
|
318
|
+
if (this.fileWatcher)
|
|
319
|
+
this.fileWatcher.close();
|
|
320
|
+
}
|
|
321
|
+
}
|
package/dist/bin.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/bin.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './DevServer.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './DevServer.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "s4d",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal web development server with live reload",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"local",
|
|
7
|
+
"development",
|
|
8
|
+
"server"
|
|
9
|
+
],
|
|
10
|
+
"author": "Morris Brodersen <mb@morrisbrodersen.de>",
|
|
11
|
+
"license": "ISC",
|
|
12
|
+
"homepage": "https://github.com/morris/s4d",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/morris/s4d.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/morris/s4d/issues"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"exports": "./dist/index.js",
|
|
25
|
+
"bin": "./dist/bin.js",
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc --project tsconfig.build.json",
|
|
31
|
+
"format": "prettier --write .",
|
|
32
|
+
"format-check": "prettier --check .",
|
|
33
|
+
"lint": "eslint .",
|
|
34
|
+
"test": "playwright test",
|
|
35
|
+
"dev": "tsc --project tsconfig.build.json && node dist/bin.js test/fixture"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"mime": "^4.0.1",
|
|
39
|
+
"ws": "^8.16.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@playwright/test": "^1.44.1",
|
|
43
|
+
"@types/node": "^20.11.30",
|
|
44
|
+
"@types/ws": "^8.5.10",
|
|
45
|
+
"@typescript-eslint/eslint-plugin": "^7.7.1",
|
|
46
|
+
"@typescript-eslint/parser": "^7.7.1",
|
|
47
|
+
"eslint": "^8.57.0",
|
|
48
|
+
"prettier": "^3.3.1",
|
|
49
|
+
"typescript": "^5.4.5"
|
|
50
|
+
}
|
|
51
|
+
}
|