@jsenv/server 15.3.2 → 15.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "15.3.2",
3
+ "version": "15.4.0",
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": "2.0.2"
40
+ "eventsource": "3.0.2",
41
+ "undici": "7.2.3"
41
42
  }
42
43
  }
@@ -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 sourceUrl = `file://${new URL(urlString).pathname}`;
96
- try {
97
- const [readStatTiming, sourceStat] = await timeFunction(
98
- "file service>read file stat",
99
- () => statSync(new URL(sourceUrl)),
100
- );
101
- if (sourceStat.isDirectory()) {
102
- if (canReadDirectory) {
103
- return serveDirectory(urlString, {
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
- canReadDirectory,
106
- rootDirectoryUrl,
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
- // 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(
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
- clientCacheResponse,
176
+ response,
143
177
  );
144
- }
145
-
146
- let response;
147
- if (compressionEnabled && sourceStat.size >= compressionSizeThreshold) {
148
- const compressedResponse = await getCompressedResponse({
149
- headers,
150
- sourceUrl,
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
- if (!response) {
157
- response = await getRawResponse({
158
- sourceStat,
159
- sourceUrl,
160
- });
161
- }
195
+ };
162
196
 
163
- const intermediateResponse = composeTwoResponses(
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
- sourceStat,
209
- sourceUrl,
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
- sourceStat,
229
- sourceUrl,
237
+ fileStat,
238
+ fileUrl,
230
239
  });
231
240
  }
232
241
 
233
242
  if (mtimeEnabled) {
234
243
  return getMtimeResponse({
235
244
  headers,
236
- sourceStat,
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
- sourceUrl,
248
- sourceStat,
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
- sourceUrl,
257
- sourceStat,
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
- sourceUrl,
286
- sourceStat,
294
+ fileUrl,
295
+ fileStat,
287
296
  }) => {
288
297
  if (etagMemory) {
289
- const etagMemoryEntry = ETAG_MEMORY_MAP.get(sourceUrl);
298
+ const etagMemoryEntry = ETAG_MEMORY_MAP.get(fileUrl);
290
299
  if (
291
300
  etagMemoryEntry &&
292
- fileStatAreTheSame(etagMemoryEntry.sourceStat, sourceStat)
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(sourceUrl), (error, buffer) => {
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(sourceUrl, { sourceStat, eTag });
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, sourceStat }) => {
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(sourceStat.mtime);
358
+ const actualModificationDate = dateToSecondsPrecision(fileStat.mtime);
350
359
  if (Number(cachedModificationDate) >= Number(actualModificationDate)) {
351
360
  return {
352
361
  status: 304,
@@ -357,12 +366,12 @@ const getMtimeResponse = async ({ headers, sourceStat }) => {
357
366
  return {
358
367
  status: 200,
359
368
  headers: {
360
- "last-modified": dateToUTCString(sourceStat.mtime),
369
+ "last-modified": dateToUTCString(fileStat.mtime),
361
370
  },
362
371
  };
363
372
  };
364
373
 
365
- const getCompressedResponse = async ({ sourceUrl, headers }) => {
374
+ const getCompressedResponse = async ({ fileUrl, headers }) => {
366
375
  const acceptedCompressionFormat = pickContentEncoding(
367
376
  { headers },
368
377
  Object.keys(availableCompressionFormats),
@@ -371,7 +380,7 @@ const getCompressedResponse = async ({ sourceUrl, headers }) => {
371
380
  return null;
372
381
  }
373
382
 
374
- const fileReadableStream = fileUrlToReadableStream(sourceUrl);
383
+ const fileReadableStream = fileUrlToReadableStream(fileUrl);
375
384
  const body =
376
385
  await availableCompressionFormats[acceptedCompressionFormat](
377
386
  fileReadableStream,
@@ -380,7 +389,7 @@ const getCompressedResponse = async ({ sourceUrl, headers }) => {
380
389
  return {
381
390
  status: 200,
382
391
  headers: {
383
- "content-type": CONTENT_TYPE.fromUrlExtension(sourceUrl),
392
+ "content-type": CONTENT_TYPE.fromUrlExtension(fileUrl),
384
393
  "content-encoding": acceptedCompressionFormat,
385
394
  "vary": "accept-encoding",
386
395
  },
@@ -410,14 +419,14 @@ const availableCompressionFormats = {
410
419
  },
411
420
  };
412
421
 
413
- const getRawResponse = async ({ sourceUrl, sourceStat }) => {
422
+ const getRawResponse = async ({ fileUrl, fileStat }) => {
414
423
  return {
415
424
  status: 200,
416
425
  headers: {
417
- "content-type": CONTENT_TYPE.fromUrlExtension(sourceUrl),
418
- "content-length": sourceStat.size,
426
+ "content-type": CONTENT_TYPE.fromUrlExtension(fileUrl),
427
+ "content-length": fileStat.size,
419
428
  },
420
- body: fileUrlToReadableStream(sourceUrl),
429
+ body: fileUrlToReadableStream(fileUrl),
421
430
  };
422
431
  };
423
432
 
@@ -151,7 +151,7 @@ const writeHead = (
151
151
  if (statusText === undefined) {
152
152
  statusText = statusTextFromStatus(status);
153
153
  } else {
154
- statusText = statusText.replace(/\\n/g, "\n");
154
+ statusText = statusText.replace(/\n/g, "");
155
155
  }
156
156
  if (responseIsServerHttp2Stream) {
157
157
  nodeHeaders = {
@@ -32,12 +32,26 @@ export const fileSystemPathToUrl = (value) => {
32
32
  };
33
33
 
34
34
  export const urlToFileSystemPath = (url) => {
35
- let urlString = String(url);
36
- if (urlString[urlString.length - 1] === "/") {
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
- urlString = urlString.slice(0, -1);
54
+ return fileSystemPath.slice(0, -1);
40
55
  }
41
- const fileSystemPath = fileURLToPath(urlString);
42
56
  return fileSystemPath;
43
57
  };
@@ -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) => {