vanilla-jet 1.3.2 → 1.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.
@@ -214,8 +214,8 @@ Dipper.prototype.dequeueStyle = function(name, dependencies) {
214
214
  if (obj.styles[name] != undefined) {
215
215
  if (obj.enqueued_styles[name] != undefined) {
216
216
  var item = obj.styles[name];
217
- if (dependencies != undefined) {
218
- _.each(item.require, function(dep) {
217
+ if (dependencies === true && Array.isArray(item.requires)) {
218
+ _.each(item.requires, function(dep) {
219
219
  obj.dequeueStyle(dep);
220
220
  });
221
221
  }
@@ -233,8 +233,8 @@ Dipper.prototype.dequeueScript = function(name, dependencies) {
233
233
  if (obj.scripts[name] != undefined) {
234
234
  if (obj.enqueued_scripts[name] != undefined) {
235
235
  var item = obj.scripts[name];
236
- if (dependencies != undefined) {
237
- _.each(item.require, function(dep) {
236
+ if (dependencies === true && Array.isArray(item.requires)) {
237
+ _.each(item.requires, function(dep) {
238
238
  obj.dequeueScript(dep);
239
239
  });
240
240
  }
@@ -341,7 +341,7 @@ Dipper.prototype.includeAnimations = function() {
341
341
  keys = Object.keys(obj.anims);
342
342
 
343
343
  _.each(keys, function(anim) {
344
- animsString += obj.includeAnim(anim);
344
+ animsString += obj.includeAnimation(anim);
345
345
  });
346
346
  let baseAnimsString = `<script>'${animsString}'</script>`;
347
347
  return baseAnimsString;
@@ -3,18 +3,22 @@ let _ = require('underscore');
3
3
 
4
4
  class Response {
5
5
 
6
- constructor(res) {
6
+ constructor(res, options) {
7
7
 
8
8
  this.res = null;
9
9
  this.body = '';
10
10
  this.status = 200;
11
11
  this.headers = [];
12
12
  this.autoRespond = true;
13
- this.init(res);
13
+ this.options = {};
14
+ this.init(res, options);
14
15
  }
15
16
 
16
- init(res) {
17
+ init(res, options) {
17
18
  this.res = res;
19
+ this.options = Object.assign({
20
+ enable_precompressed_negotiation: false
21
+ }, options || {});
18
22
  }
19
23
 
20
24
  setBody(body) {
@@ -75,20 +79,93 @@ class Response {
75
79
  let obj = this,
76
80
  path = require("path"),
77
81
  fs = require("fs");
78
- template = 'pages/' + template;
79
-
80
- const acceptEncoding = request.acceptEncoding || [];
81
- if (acceptEncoding.includes('gzip')) {
82
- template = template + '.gz';
83
- obj.res.setHeader('Content-Encoding', 'gzip');
84
- } else {
85
- obj.res.setHeader('Content-Type', 'text/html');
82
+ let templatePath = 'pages/' + template;
83
+ let acceptEncoding = request.acceptEncoding || [];
84
+ let allowBrotli = Boolean(obj.options.enable_precompressed_negotiation);
85
+ let baseFilename = path.join(process.cwd(), 'public', templatePath);
86
+ let candidates = [];
87
+
88
+ if (allowBrotli && obj.supportsEncoding(acceptEncoding, 'br')) {
89
+ candidates.push({ filename: baseFilename + '.br', encoding: 'br' });
86
90
  }
87
-
88
- const filename = path.join(process.cwd(), 'public/' + template);
89
- const fileStream = fs.createReadStream(filename);
90
- fileStream.pipe(obj.res);
91
+ if (obj.supportsEncoding(acceptEncoding, 'gzip')) {
92
+ candidates.push({ filename: baseFilename + '.gz', encoding: 'gzip' });
93
+ }
94
+ candidates.push({ filename: baseFilename, encoding: '' });
95
+
96
+ let hasNegotiation = candidates.some((candidate) => candidate.encoding !== '');
97
+ obj.resolveFirstAvailableFile(candidates, (err, selectedFile) => {
98
+ if (err || !selectedFile) {
99
+ return obj.error404();
100
+ }
101
+
102
+ obj.res.setHeader('Content-Type', 'text/html; charset=utf-8');
103
+ if (hasNegotiation) {
104
+ obj.res.setHeader('Vary', 'Accept-Encoding');
105
+ }
106
+ if (selectedFile.encoding) {
107
+ obj.res.setHeader('Content-Encoding', selectedFile.encoding);
108
+ }
109
+
110
+ let fileStream = fs.createReadStream(selectedFile.filename);
111
+ fileStream.on('error', () => {
112
+ obj.error404();
113
+ });
114
+ fileStream.pipe(obj.res);
115
+ });
91
116
  }
117
+
118
+ resolveFirstAvailableFile(candidates, callback) {
119
+ let fs = require("fs");
120
+ let index = 0;
121
+ function resolveCandidate() {
122
+ let currentCandidate = candidates[index];
123
+ if (!currentCandidate) {
124
+ return callback(new Error('File not found'));
125
+ }
126
+
127
+ fs.stat(currentCandidate.filename, (err, stats) => {
128
+ if (!err && stats && stats.isFile()) {
129
+ return callback(null, currentCandidate);
130
+ }
131
+ index = index + 1;
132
+ resolveCandidate();
133
+ });
134
+ }
135
+
136
+ resolveCandidate();
137
+ }
138
+
139
+ supportsEncoding(acceptEncoding, encoding) {
140
+ if (!Array.isArray(acceptEncoding)) {
141
+ return false;
142
+ }
143
+
144
+ let normalizedEncoding = String(encoding).toLowerCase();
145
+ return acceptEncoding.some((entry) => {
146
+ if (entry == null) {
147
+ return false;
148
+ }
149
+ let token = String(entry).toLowerCase();
150
+ let parts = token.split(';');
151
+ if (parts[0] !== normalizedEncoding) {
152
+ return false;
153
+ }
154
+
155
+ let qValue = 1;
156
+ for (let idx = 1; idx < parts.length; idx = idx + 1) {
157
+ let part = parts[idx];
158
+ if (part.startsWith('q=')) {
159
+ let parsedQValue = parseFloat(part.slice(2));
160
+ if (!Number.isNaN(parsedQValue)) {
161
+ qValue = parsedQValue;
162
+ }
163
+ }
164
+ }
165
+
166
+ return qValue > 0;
167
+ });
168
+ }
92
169
  }
93
170
 
94
171
  module.exports = Response;
@@ -16,6 +16,12 @@ class Router {
16
16
  this.defaultRoute = '';
17
17
  this.server = server;
18
18
  this.cwd = process.cwd();
19
+ this.staticBasePath = this.cwd.replace('core/framework', '');
20
+ this.staticMetadataCache = new Map();
21
+ this.staticResolutionCache = new Map();
22
+ this.staticFileWatchers = new Map();
23
+ this.staticMetadataMaxAgeMs = 1000;
24
+ this.staticStreamChunkSize = 128 * 1024;
19
25
  this.mimes = {
20
26
  'png': 'image/png',
21
27
  'webp': 'image/webp',
@@ -30,8 +36,13 @@ class Router {
30
36
  'pdf': 'application/pdf',
31
37
  'json': 'application/json'
32
38
  };
39
+ this.mimeHeaders = Object.keys(this.mimes).reduce((headers, ext) => {
40
+ headers[ext] = { 'Content-Type': this.mimes[ext] };
41
+ return headers;
42
+ }, {});
33
43
  this.compressionMimes = [ 'css', 'js' ];
34
44
  this.compressionFiles = [ 'vanilla.min.js', 'app.min.css' ];
45
+ this.enablePrecompressedNegotiation = Boolean(server?.options?.enable_precompressed_negotiation);
35
46
  }
36
47
 
37
48
  routeToRegExp(route) {
@@ -71,7 +82,7 @@ class Router {
71
82
 
72
83
  let obj = this;
73
84
  let isMatch = false;
74
- let response = new Response(res);
85
+ let response = new Response(res, obj.server.options);
75
86
  let request = new Request(req, {
76
87
  onDataReceived: function () {
77
88
  if (request.path == '') { request.path = obj.defaultRoute; }
@@ -93,45 +104,54 @@ class Router {
93
104
  // -- Check static files
94
105
  if (!handled && !isMatch) {
95
106
 
96
- let ext = path.extname(req.url).replace('.', ''),
107
+ let ext = path.extname(request.path).replace('.', ''),
97
108
  extHandled = false,
98
109
  extHeader = {};
99
110
 
100
111
  if (obj.mimes[ext] != undefined && obj.mimes[ext] != 'undefined') {
101
112
  extHandled = true;
102
- extHeader = { 'Content-Type': obj.mimes[ext] };
113
+ extHeader = obj.mimeHeaders[ext];
103
114
  }
104
115
 
105
116
  if (extHandled) {
106
117
 
107
- let rep = obj.cwd.replace('core/framework', ''),
108
- route = request.path,
109
- filename = path.join(rep, route),
118
+ let route = request.path,
119
+ filename = path.join(obj.staticBasePath, route),
110
120
  filePrivate = obj.isProtectedFile(route);
111
121
 
112
- // -- Check if the file is a gzip file
113
- if (request.acceptEncoding.includes('gzip') &&
114
- obj.compressionMimes.includes(ext) &&
115
- obj.compressionFiles.includes(filename.split('/').pop())
116
- ) {
117
- filename = filename + '.gz';
118
- extHeader['Content-Encoding'] = 'gzip';
122
+ if (filePrivate) {
123
+ return obj.onNotFound(response);
119
124
  }
120
125
 
121
- fs.stat(filename, (err, stats) => {
122
-
123
- if (err || !stats.isFile() || filePrivate) {
126
+ let staticCandidates = obj.getStaticCandidates(request, ext, filename);
127
+ let hasConditionalHeaders = Boolean(req.headers['if-none-match'] || req.headers['if-modified-since']);
128
+ obj.resolveFirstAvailableStaticFile(route, request.acceptEncoding, staticCandidates, hasConditionalHeaders, (err, staticFile) => {
129
+ if (err || !staticFile) {
124
130
  return obj.onNotFound(response);
125
131
  }
126
- const fileStream = fs.createReadStream(filename);
132
+
133
+ let metadata = staticFile.metadata;
134
+ let staticHeaders = obj.buildStaticHeaders(extHeader, staticCandidates, staticFile.contentEncoding, metadata);
135
+
136
+ if (obj.isNotModified(req, metadata)) {
137
+ let notModifiedHeaders = Object.assign({}, staticHeaders);
138
+ delete notModifiedHeaders['Content-Length'];
139
+ res.writeHead(304, notModifiedHeaders);
140
+ return res.end();
141
+ }
142
+
143
+ const fileStream = fs.createReadStream(staticFile.filename, {
144
+ highWaterMark: obj.staticStreamChunkSize
145
+ });
127
146
  fileStream.on('error', (streamErr) => {
147
+ obj.staticMetadataCache.delete(staticFile.filename);
148
+ obj.staticResolutionCache.clear();
128
149
  console.error("Error reading file:", streamErr);
129
150
  res.writeHead(500);
130
151
  res.end('Server Error');
131
152
  });
132
153
 
133
- extHeader['Content-Length'] = stats.size;
134
- res.writeHead(200, extHeader);
154
+ res.writeHead(200, staticHeaders);
135
155
  fileStream.pipe(res);
136
156
  res.on('close', () => {});
137
157
  });
@@ -142,8 +162,225 @@ class Router {
142
162
  isMatch = false;
143
163
  }
144
164
 
165
+ getStaticFileMetadata(filename, forceRefresh, callback) {
166
+ let obj = this;
167
+ forceRefresh = forceRefresh || false;
168
+ let cachedMetadata = obj.staticMetadataCache.get(filename);
169
+ if (cachedMetadata && !forceRefresh) {
170
+ return callback(null, cachedMetadata);
171
+ }
172
+
173
+ if (cachedMetadata && forceRefresh && !obj.shouldRefreshConditionalMetadata(cachedMetadata)) {
174
+ return callback(null, cachedMetadata);
175
+ }
176
+
177
+ fs.stat(filename, (err, stats) => {
178
+ if (err || !stats.isFile()) {
179
+ return callback(err || new Error('File not found'));
180
+ }
181
+
182
+ let metadata = {
183
+ size: stats.size,
184
+ lastModified: stats.mtime.toUTCString(),
185
+ etag: `W/"${stats.size}-${Math.floor(stats.mtimeMs)}"`,
186
+ cachedAt: Date.now()
187
+ };
188
+
189
+ obj.staticMetadataCache.set(filename, metadata);
190
+ obj.watchStaticFile(filename);
191
+ callback(null, metadata);
192
+ });
193
+ }
194
+
195
+ getStaticCandidates(request, ext, filename) {
196
+ let obj = this;
197
+ let candidates = [{ filename: filename, contentEncoding: '' }];
198
+ let isCompressible = obj.compressionMimes.includes(ext) && obj.compressionFiles.includes(path.basename(filename));
199
+ if (!isCompressible) {
200
+ return candidates;
201
+ }
202
+
203
+ let compressedCandidates = [];
204
+ if (obj.enablePrecompressedNegotiation && obj.supportsEncoding(request.acceptEncoding, 'br')) {
205
+ compressedCandidates.push({
206
+ filename: filename + '.br',
207
+ contentEncoding: 'br'
208
+ });
209
+ }
210
+
211
+ if (obj.supportsEncoding(request.acceptEncoding, 'gzip')) {
212
+ compressedCandidates.push({
213
+ filename: filename + '.gz',
214
+ contentEncoding: 'gzip'
215
+ });
216
+ }
217
+
218
+ return compressedCandidates.concat(candidates);
219
+ }
220
+
221
+ resolveFirstAvailableStaticFile(route, acceptEncoding, candidates, forceRefresh, callback) {
222
+ let obj = this;
223
+ let resolutionKey = obj.getStaticResolutionKey(route, acceptEncoding);
224
+ let cachedResolution = obj.staticResolutionCache.get(resolutionKey);
225
+ if (cachedResolution) {
226
+ return obj.getStaticFileMetadata(cachedResolution.filename, forceRefresh, (cachedErr, cachedMetadata) => {
227
+ if (!cachedErr && cachedMetadata) {
228
+ return callback(null, {
229
+ filename: cachedResolution.filename,
230
+ contentEncoding: cachedResolution.contentEncoding,
231
+ metadata: cachedMetadata
232
+ });
233
+ }
234
+ obj.staticResolutionCache.delete(resolutionKey);
235
+ obj.resolveFirstAvailableStaticFile(route, acceptEncoding, candidates, forceRefresh, callback);
236
+ });
237
+ }
238
+
239
+ let index = 0;
240
+ function resolveCandidate() {
241
+ let currentCandidate = candidates[index];
242
+ if (!currentCandidate) {
243
+ return callback(new Error('No static file found'));
244
+ }
245
+
246
+ obj.getStaticFileMetadata(currentCandidate.filename, forceRefresh, (err, metadata) => {
247
+ if (!err && metadata) {
248
+ obj.staticResolutionCache.set(resolutionKey, {
249
+ filename: currentCandidate.filename,
250
+ contentEncoding: currentCandidate.contentEncoding
251
+ });
252
+ return callback(null, {
253
+ filename: currentCandidate.filename,
254
+ contentEncoding: currentCandidate.contentEncoding,
255
+ metadata: metadata
256
+ });
257
+ }
258
+ index = index + 1;
259
+ resolveCandidate();
260
+ });
261
+ }
262
+
263
+ resolveCandidate();
264
+ }
265
+
266
+ getStaticResolutionKey(route, acceptEncoding) {
267
+ let normalizedEncodings = Array.isArray(acceptEncoding) ? acceptEncoding.join(',') : '';
268
+ return `${route}|${normalizedEncodings}`;
269
+ }
270
+
271
+ shouldRefreshConditionalMetadata(metadata) {
272
+ if (!metadata || !metadata.cachedAt) {
273
+ return true;
274
+ }
275
+ return Date.now() - metadata.cachedAt > this.staticMetadataMaxAgeMs;
276
+ }
277
+
278
+ buildStaticHeaders(extHeader, candidates, contentEncoding, metadata) {
279
+ let staticHeaders = Object.assign({}, extHeader);
280
+ if (contentEncoding) {
281
+ staticHeaders['Content-Encoding'] = contentEncoding;
282
+ }
283
+ if (candidates.some((candidate) => candidate.contentEncoding)) {
284
+ staticHeaders['Vary'] = 'Accept-Encoding';
285
+ }
286
+
287
+ staticHeaders['Content-Length'] = metadata.size;
288
+ staticHeaders['ETag'] = metadata.etag;
289
+ staticHeaders['Last-Modified'] = metadata.lastModified;
290
+ // Force revalidation to keep clients fresh without hard reload.
291
+ staticHeaders['Cache-Control'] = 'no-cache, must-revalidate';
292
+ return staticHeaders;
293
+ }
294
+
295
+ supportsEncoding(acceptEncoding, encoding) {
296
+ if (!Array.isArray(acceptEncoding)) {
297
+ return false;
298
+ }
299
+
300
+ let normalizedEncoding = String(encoding).toLowerCase();
301
+ return acceptEncoding.some((entry) => {
302
+ if (entry == null) {
303
+ return false;
304
+ }
305
+ let token = String(entry).toLowerCase();
306
+ let parts = token.split(';');
307
+ if (parts[0] !== normalizedEncoding) {
308
+ return false;
309
+ }
310
+
311
+ let qValue = 1;
312
+ for (let idx = 1; idx < parts.length; idx = idx + 1) {
313
+ let part = parts[idx];
314
+ if (part.startsWith('q=')) {
315
+ let parsedQValue = parseFloat(part.slice(2));
316
+ if (!Number.isNaN(parsedQValue)) {
317
+ qValue = parsedQValue;
318
+ }
319
+ }
320
+ }
321
+
322
+ return qValue > 0;
323
+ });
324
+ }
325
+
326
+ watchStaticFile(filename) {
327
+ let obj = this;
328
+ if (obj.staticFileWatchers.has(filename)) {
329
+ return;
330
+ }
331
+
332
+ try {
333
+ let watcher = fs.watch(filename, (eventType) => {
334
+ obj.staticMetadataCache.delete(filename);
335
+ obj.staticResolutionCache.clear();
336
+ if (eventType === 'rename') {
337
+ let renamedWatcher = obj.staticFileWatchers.get(filename);
338
+ if (renamedWatcher) {
339
+ renamedWatcher.close();
340
+ }
341
+ obj.staticFileWatchers.delete(filename);
342
+ }
343
+ });
344
+
345
+ watcher.on('error', () => {
346
+ obj.staticMetadataCache.delete(filename);
347
+ obj.staticResolutionCache.clear();
348
+ let activeWatcher = obj.staticFileWatchers.get(filename);
349
+ if (activeWatcher) {
350
+ activeWatcher.close();
351
+ }
352
+ obj.staticFileWatchers.delete(filename);
353
+ });
354
+
355
+ obj.staticFileWatchers.set(filename, watcher);
356
+ } catch (err) {
357
+ // If watch cannot be created, keep runtime behavior and continue.
358
+ }
359
+ }
360
+
361
+ isNotModified(req, metadata) {
362
+ let ifNoneMatch = req.headers['if-none-match'];
363
+ if (ifNoneMatch) {
364
+ let etags = ifNoneMatch.split(',').map((etag) => etag.trim());
365
+ if (etags.includes(metadata.etag)) {
366
+ return true;
367
+ }
368
+ }
369
+
370
+ let ifModifiedSince = req.headers['if-modified-since'];
371
+ if (ifModifiedSince) {
372
+ let requestModifiedSince = new Date(ifModifiedSince).getTime();
373
+ let fileModifiedAt = new Date(metadata.lastModified).getTime();
374
+ if (!Number.isNaN(requestModifiedSince) && requestModifiedSince >= fileModifiedAt) {
375
+ return true;
376
+ }
377
+ }
378
+
379
+ return false;
380
+ }
381
+
145
382
  isProtectedFile(route) {
146
- let protectedDirs = ['framework', 'external', 'node_mudules'];
383
+ let protectedDirs = ['framework', 'external', 'node_modules'];
147
384
  let routeParts = route.split('/');
148
385
  if (routeParts[1] != undefined && routeParts.length > 2) {
149
386
  return protectedDirs.includes(routeParts[1]);
@@ -31,7 +31,8 @@ class Server {
31
31
 
32
32
  _.defaults(opts, {
33
33
  https_server: false,
34
- wsServer: false
34
+ wsServer: false,
35
+ enable_precompressed_negotiation: false
35
36
  });
36
37
  obj.options = opts;
37
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanilla-jet",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "description": "VannilaJet framework",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  "build:qa": "gulp build --env qa",
13
13
  "build:staging": "gulp build --env staging",
14
14
  "build:prod": "gulp build --env production",
15
- "test": "npm run test"
15
+ "test": "node -e \"console.log('No automated tests configured yet')\"",
16
+ "benchmark:static": "node ./scripts/benchmark-static.js"
16
17
  },
17
18
  "repository": {
18
19
  "type": "git",