@jsenv/server 15.3.3 → 15.4.1
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/package.json +3 -2
- package/src/fetch_filesystem.js +119 -106
- package/src/internal/filesystem.js +18 -4
- package/src/start_server.js +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/server",
|
|
3
|
-
"version": "15.
|
|
3
|
+
"version": "15.4.1",
|
|
4
4
|
"description": "Write your Node.js server using pure functions",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"ws": "8.18.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"eventsource": "
|
|
40
|
+
"eventsource": "3.0.2",
|
|
41
|
+
"undici": "7.2.3"
|
|
41
42
|
}
|
|
42
43
|
}
|
package/src/fetch_filesystem.js
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
|
|
8
8
|
import { createReadStream, readFile, statSync } from "node:fs";
|
|
9
|
-
|
|
10
9
|
import { pickContentEncoding } from "./content_negotiation/pick_content_encoding.js";
|
|
11
10
|
import { convertFileSystemErrorToResponseProperties } from "./internal/convertFileSystemErrorToResponseProperties.js";
|
|
12
11
|
import { bufferToEtag } from "./internal/etag.js";
|
|
@@ -35,6 +34,7 @@ export const fetchFileSystem = async (
|
|
|
35
34
|
: "no-store",
|
|
36
35
|
canReadDirectory = false,
|
|
37
36
|
rootDirectoryUrl, // = `${pathToFileURL(process.cwd())}/`,
|
|
37
|
+
ENOENTFallback = () => {},
|
|
38
38
|
} = {},
|
|
39
39
|
) => {
|
|
40
40
|
const urlString = asUrlString(filesystemUrl);
|
|
@@ -92,100 +92,109 @@ export const fetchFileSystem = async (
|
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
95
|
+
const serveFile = async (fileUrl) => {
|
|
96
|
+
try {
|
|
97
|
+
const [readStatTiming, fileStat] = timeFunction(
|
|
98
|
+
"file service>read file stat",
|
|
99
|
+
() => statSync(new URL(fileUrl)),
|
|
100
|
+
);
|
|
101
|
+
if (fileStat.isDirectory()) {
|
|
102
|
+
if (canReadDirectory) {
|
|
103
|
+
return serveDirectory(fileUrl, {
|
|
104
|
+
headers,
|
|
105
|
+
canReadDirectory,
|
|
106
|
+
rootDirectoryUrl,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
status: 403,
|
|
111
|
+
statusText: "not allowed to read directory",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// not a file, give up
|
|
115
|
+
if (!fileStat.isFile()) {
|
|
116
|
+
return {
|
|
117
|
+
status: 404,
|
|
118
|
+
timing: readStatTiming,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const clientCacheResponse = await getClientCacheResponse({
|
|
123
|
+
headers,
|
|
124
|
+
etagEnabled,
|
|
125
|
+
etagMemory,
|
|
126
|
+
etagMemoryMaxSize,
|
|
127
|
+
mtimeEnabled,
|
|
128
|
+
fileStat,
|
|
129
|
+
fileUrl,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// send 304 (redirect response to client cache)
|
|
133
|
+
// because the response body does not have to be transmitted
|
|
134
|
+
if (clientCacheResponse.status === 304) {
|
|
135
|
+
return composeTwoResponses(
|
|
136
|
+
{
|
|
137
|
+
timing: readStatTiming,
|
|
138
|
+
headers: {
|
|
139
|
+
...(cacheControl ? { "cache-control": cacheControl } : {}),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
clientCacheResponse,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let response;
|
|
147
|
+
if (compressionEnabled && fileStat.size >= compressionSizeThreshold) {
|
|
148
|
+
const compressedResponse = await getCompressedResponse({
|
|
104
149
|
headers,
|
|
105
|
-
|
|
106
|
-
|
|
150
|
+
fileUrl,
|
|
151
|
+
});
|
|
152
|
+
if (compressedResponse) {
|
|
153
|
+
response = compressedResponse;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!response) {
|
|
157
|
+
response = await getRawResponse({
|
|
158
|
+
fileStat,
|
|
159
|
+
fileUrl,
|
|
107
160
|
});
|
|
108
161
|
}
|
|
109
|
-
return {
|
|
110
|
-
status: 403,
|
|
111
|
-
statusText: "not allowed to read directory",
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
// not a file, give up
|
|
115
|
-
if (!sourceStat.isFile()) {
|
|
116
|
-
return {
|
|
117
|
-
status: 404,
|
|
118
|
-
timing: readStatTiming,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const clientCacheResponse = await getClientCacheResponse({
|
|
123
|
-
headers,
|
|
124
|
-
etagEnabled,
|
|
125
|
-
etagMemory,
|
|
126
|
-
etagMemoryMaxSize,
|
|
127
|
-
mtimeEnabled,
|
|
128
|
-
sourceStat,
|
|
129
|
-
sourceUrl,
|
|
130
|
-
});
|
|
131
162
|
|
|
132
|
-
|
|
133
|
-
// because the response body does not have to be transmitted
|
|
134
|
-
if (clientCacheResponse.status === 304) {
|
|
135
|
-
return composeTwoResponses(
|
|
163
|
+
const intermediateResponse = composeTwoResponses(
|
|
136
164
|
{
|
|
137
165
|
timing: readStatTiming,
|
|
138
166
|
headers: {
|
|
139
167
|
...(cacheControl ? { "cache-control": cacheControl } : {}),
|
|
168
|
+
// even if client cache is disabled, server can still
|
|
169
|
+
// send his own cache control but client should just ignore it
|
|
170
|
+
// and keep sending cache-control: 'no-store'
|
|
171
|
+
// if not, uncomment the line below to preserve client
|
|
172
|
+
// desire to ignore cache
|
|
173
|
+
// ...(headers["cache-control"] === "no-store" ? { "cache-control": "no-store" } : {}),
|
|
140
174
|
},
|
|
141
175
|
},
|
|
142
|
-
|
|
176
|
+
response,
|
|
143
177
|
);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
});
|
|
152
|
-
if (compressedResponse) {
|
|
153
|
-
response = compressedResponse;
|
|
178
|
+
return composeTwoResponses(intermediateResponse, clientCacheResponse);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if (e.code === "ENOENT") {
|
|
181
|
+
const fallbackFileUrl = ENOENTFallback();
|
|
182
|
+
if (fallbackFileUrl) {
|
|
183
|
+
return serveFile(fallbackFileUrl);
|
|
184
|
+
}
|
|
154
185
|
}
|
|
186
|
+
return composeTwoResponses(
|
|
187
|
+
{
|
|
188
|
+
headers: {
|
|
189
|
+
...(cacheControl ? { "cache-control": cacheControl } : {}),
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
convertFileSystemErrorToResponseProperties(e) || {},
|
|
193
|
+
);
|
|
155
194
|
}
|
|
156
|
-
|
|
157
|
-
response = await getRawResponse({
|
|
158
|
-
sourceStat,
|
|
159
|
-
sourceUrl,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
195
|
+
};
|
|
162
196
|
|
|
163
|
-
|
|
164
|
-
{
|
|
165
|
-
timing: readStatTiming,
|
|
166
|
-
headers: {
|
|
167
|
-
...(cacheControl ? { "cache-control": cacheControl } : {}),
|
|
168
|
-
// even if client cache is disabled, server can still
|
|
169
|
-
// send his own cache control but client should just ignore it
|
|
170
|
-
// and keep sending cache-control: 'no-store'
|
|
171
|
-
// if not, uncomment the line below to preserve client
|
|
172
|
-
// desire to ignore cache
|
|
173
|
-
// ...(headers["cache-control"] === "no-store" ? { "cache-control": "no-store" } : {}),
|
|
174
|
-
},
|
|
175
|
-
},
|
|
176
|
-
response,
|
|
177
|
-
);
|
|
178
|
-
return composeTwoResponses(intermediateResponse, clientCacheResponse);
|
|
179
|
-
} catch (e) {
|
|
180
|
-
return composeTwoResponses(
|
|
181
|
-
{
|
|
182
|
-
headers: {
|
|
183
|
-
...(cacheControl ? { "cache-control": cacheControl } : {}),
|
|
184
|
-
},
|
|
185
|
-
},
|
|
186
|
-
convertFileSystemErrorToResponseProperties(e) || {},
|
|
187
|
-
);
|
|
188
|
-
}
|
|
197
|
+
return serveFile(`file://${new URL(urlString).pathname}`);
|
|
189
198
|
};
|
|
190
199
|
|
|
191
200
|
const create500Response = (message) => {
|
|
@@ -205,8 +214,8 @@ const getClientCacheResponse = async ({
|
|
|
205
214
|
etagMemory,
|
|
206
215
|
etagMemoryMaxSize,
|
|
207
216
|
mtimeEnabled,
|
|
208
|
-
|
|
209
|
-
|
|
217
|
+
fileStat,
|
|
218
|
+
fileUrl,
|
|
210
219
|
}) => {
|
|
211
220
|
// here you might be tempted to add || headers["cache-control"] === "no-cache"
|
|
212
221
|
// but no-cache means resource can be cache but must be revalidated (yeah naming is strange)
|
|
@@ -225,15 +234,15 @@ const getClientCacheResponse = async ({
|
|
|
225
234
|
headers,
|
|
226
235
|
etagMemory,
|
|
227
236
|
etagMemoryMaxSize,
|
|
228
|
-
|
|
229
|
-
|
|
237
|
+
fileStat,
|
|
238
|
+
fileUrl,
|
|
230
239
|
});
|
|
231
240
|
}
|
|
232
241
|
|
|
233
242
|
if (mtimeEnabled) {
|
|
234
243
|
return getMtimeResponse({
|
|
235
244
|
headers,
|
|
236
|
-
|
|
245
|
+
fileStat,
|
|
237
246
|
});
|
|
238
247
|
}
|
|
239
248
|
|
|
@@ -244,8 +253,8 @@ const getEtagResponse = async ({
|
|
|
244
253
|
headers,
|
|
245
254
|
etagMemory,
|
|
246
255
|
etagMemoryMaxSize,
|
|
247
|
-
|
|
248
|
-
|
|
256
|
+
fileUrl,
|
|
257
|
+
fileStat,
|
|
249
258
|
}) => {
|
|
250
259
|
const [computeEtagTiming, fileContentEtag] = await timeFunction(
|
|
251
260
|
"file service>generate file etag",
|
|
@@ -253,8 +262,8 @@ const getEtagResponse = async ({
|
|
|
253
262
|
computeEtag({
|
|
254
263
|
etagMemory,
|
|
255
264
|
etagMemoryMaxSize,
|
|
256
|
-
|
|
257
|
-
|
|
265
|
+
fileUrl,
|
|
266
|
+
fileStat,
|
|
258
267
|
}),
|
|
259
268
|
);
|
|
260
269
|
|
|
@@ -282,20 +291,20 @@ const ETAG_MEMORY_MAP = new Map();
|
|
|
282
291
|
const computeEtag = async ({
|
|
283
292
|
etagMemory,
|
|
284
293
|
etagMemoryMaxSize,
|
|
285
|
-
|
|
286
|
-
|
|
294
|
+
fileUrl,
|
|
295
|
+
fileStat,
|
|
287
296
|
}) => {
|
|
288
297
|
if (etagMemory) {
|
|
289
|
-
const etagMemoryEntry = ETAG_MEMORY_MAP.get(
|
|
298
|
+
const etagMemoryEntry = ETAG_MEMORY_MAP.get(fileUrl);
|
|
290
299
|
if (
|
|
291
300
|
etagMemoryEntry &&
|
|
292
|
-
fileStatAreTheSame(etagMemoryEntry.
|
|
301
|
+
fileStatAreTheSame(etagMemoryEntry.fileStat, fileStat)
|
|
293
302
|
) {
|
|
294
303
|
return etagMemoryEntry.eTag;
|
|
295
304
|
}
|
|
296
305
|
}
|
|
297
306
|
const fileContentAsBuffer = await new Promise((resolve, reject) => {
|
|
298
|
-
readFile(new URL(
|
|
307
|
+
readFile(new URL(fileUrl), (error, buffer) => {
|
|
299
308
|
if (error) {
|
|
300
309
|
reject(error);
|
|
301
310
|
} else {
|
|
@@ -309,7 +318,7 @@ const computeEtag = async ({
|
|
|
309
318
|
const firstKey = Array.from(ETAG_MEMORY_MAP.keys())[0];
|
|
310
319
|
ETAG_MEMORY_MAP.delete(firstKey);
|
|
311
320
|
}
|
|
312
|
-
ETAG_MEMORY_MAP.set(
|
|
321
|
+
ETAG_MEMORY_MAP.set(fileUrl, { fileStat, eTag });
|
|
313
322
|
}
|
|
314
323
|
return eTag;
|
|
315
324
|
};
|
|
@@ -334,7 +343,7 @@ const fileStatKeysToCompare = [
|
|
|
334
343
|
"blksize",
|
|
335
344
|
];
|
|
336
345
|
|
|
337
|
-
const getMtimeResponse = async ({ headers,
|
|
346
|
+
const getMtimeResponse = async ({ headers, fileStat }) => {
|
|
338
347
|
if ("if-modified-since" in headers) {
|
|
339
348
|
let cachedModificationDate;
|
|
340
349
|
try {
|
|
@@ -346,7 +355,7 @@ const getMtimeResponse = async ({ headers, sourceStat }) => {
|
|
|
346
355
|
};
|
|
347
356
|
}
|
|
348
357
|
|
|
349
|
-
const actualModificationDate = dateToSecondsPrecision(
|
|
358
|
+
const actualModificationDate = dateToSecondsPrecision(fileStat.mtime);
|
|
350
359
|
if (Number(cachedModificationDate) >= Number(actualModificationDate)) {
|
|
351
360
|
return {
|
|
352
361
|
status: 304,
|
|
@@ -357,12 +366,16 @@ const getMtimeResponse = async ({ headers, sourceStat }) => {
|
|
|
357
366
|
return {
|
|
358
367
|
status: 200,
|
|
359
368
|
headers: {
|
|
360
|
-
"last-modified": dateToUTCString(
|
|
369
|
+
"last-modified": dateToUTCString(fileStat.mtime),
|
|
361
370
|
},
|
|
362
371
|
};
|
|
363
372
|
};
|
|
364
373
|
|
|
365
|
-
const getCompressedResponse = async ({
|
|
374
|
+
const getCompressedResponse = async ({ fileUrl, headers }) => {
|
|
375
|
+
const contentType = CONTENT_TYPE.fromUrlExtension(fileUrl);
|
|
376
|
+
if (CONTENT_TYPE.isBinary(contentType)) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
366
379
|
const acceptedCompressionFormat = pickContentEncoding(
|
|
367
380
|
{ headers },
|
|
368
381
|
Object.keys(availableCompressionFormats),
|
|
@@ -371,7 +384,7 @@ const getCompressedResponse = async ({ sourceUrl, headers }) => {
|
|
|
371
384
|
return null;
|
|
372
385
|
}
|
|
373
386
|
|
|
374
|
-
const fileReadableStream = fileUrlToReadableStream(
|
|
387
|
+
const fileReadableStream = fileUrlToReadableStream(fileUrl);
|
|
375
388
|
const body =
|
|
376
389
|
await availableCompressionFormats[acceptedCompressionFormat](
|
|
377
390
|
fileReadableStream,
|
|
@@ -380,7 +393,7 @@ const getCompressedResponse = async ({ sourceUrl, headers }) => {
|
|
|
380
393
|
return {
|
|
381
394
|
status: 200,
|
|
382
395
|
headers: {
|
|
383
|
-
"content-type":
|
|
396
|
+
"content-type": contentType,
|
|
384
397
|
"content-encoding": acceptedCompressionFormat,
|
|
385
398
|
"vary": "accept-encoding",
|
|
386
399
|
},
|
|
@@ -410,14 +423,14 @@ const availableCompressionFormats = {
|
|
|
410
423
|
},
|
|
411
424
|
};
|
|
412
425
|
|
|
413
|
-
const getRawResponse = async ({
|
|
426
|
+
const getRawResponse = async ({ fileUrl, fileStat }) => {
|
|
414
427
|
return {
|
|
415
428
|
status: 200,
|
|
416
429
|
headers: {
|
|
417
|
-
"content-type": CONTENT_TYPE.fromUrlExtension(
|
|
418
|
-
"content-length":
|
|
430
|
+
"content-type": CONTENT_TYPE.fromUrlExtension(fileUrl),
|
|
431
|
+
"content-length": fileStat.size,
|
|
419
432
|
},
|
|
420
|
-
body: fileUrlToReadableStream(
|
|
433
|
+
body: fileUrlToReadableStream(fileUrl),
|
|
421
434
|
};
|
|
422
435
|
};
|
|
423
436
|
|
|
@@ -32,12 +32,26 @@ export const fileSystemPathToUrl = (value) => {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
export const urlToFileSystemPath = (url) => {
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const urlObject = new URL(url);
|
|
36
|
+
let { origin, pathname, hash } = urlObject;
|
|
37
|
+
if (urlObject.protocol === "file:") {
|
|
38
|
+
origin = "file://";
|
|
39
|
+
}
|
|
40
|
+
pathname = pathname
|
|
41
|
+
.split("/")
|
|
42
|
+
.map((part) => {
|
|
43
|
+
return encodeURIComponent(part); // part.replace(/%(?![0-9A-F][0-9A-F])/g, "%25");
|
|
44
|
+
})
|
|
45
|
+
.join("/");
|
|
46
|
+
if (hash) {
|
|
47
|
+
pathname += `%23${encodeURIComponent(hash.slice(1))}`;
|
|
48
|
+
}
|
|
49
|
+
const urlString = `${origin}${pathname}`;
|
|
50
|
+
const fileSystemPath = fileURLToPath(urlString);
|
|
51
|
+
if (fileSystemPath[fileSystemPath.length - 1] === "/") {
|
|
37
52
|
// remove trailing / so that nodejs path becomes predictable otherwise it logs
|
|
38
53
|
// the trailing slash on linux but does not on windows
|
|
39
|
-
|
|
54
|
+
return fileSystemPath.slice(0, -1);
|
|
40
55
|
}
|
|
41
|
-
const fileSystemPath = fileURLToPath(urlString);
|
|
42
56
|
return fileSystemPath;
|
|
43
57
|
};
|
package/src/start_server.js
CHANGED
|
@@ -355,6 +355,14 @@ export const startServer = async ({
|
|
|
355
355
|
nodeResponse.end();
|
|
356
356
|
return;
|
|
357
357
|
}
|
|
358
|
+
try {
|
|
359
|
+
// eslint-disable-next-line no-new
|
|
360
|
+
new URL(nodeRequest.url, "http://example.com");
|
|
361
|
+
} catch {
|
|
362
|
+
nodeResponse.writeHead(400, "Request url is not supported");
|
|
363
|
+
nodeResponse.end();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
358
366
|
|
|
359
367
|
const receiveRequestOperation = Abort.startOperation();
|
|
360
368
|
receiveRequestOperation.addAbortSource((abort) => {
|