inertjs-core 1.0.0-beta.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 +12 -0
- package/src/index.js +2 -0
- package/src/pipeline.js +390 -0
- package/src/scope.js +17 -0
- package/src/server.js +108 -0
- package/src/static.js +50 -0
- package/test/server.test.js +167 -0
- package/test/stress.test.js +48 -0
package/package.json
ADDED
package/src/index.js
ADDED
package/src/pipeline.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { getScope } from './scope.js';
|
|
4
|
+
import { renderToStream } from '../../vector/src/stream.js';
|
|
5
|
+
import { raw } from '../../vector/src/index.js';
|
|
6
|
+
import { getCspHeader, validateCsrfToken } from '../../shield/src/index.js';
|
|
7
|
+
import { serveStatic } from './static.js';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import { optimizeImage } from 'inertjs-optimizer';
|
|
11
|
+
|
|
12
|
+
function parseCookies(cookieStr) {
|
|
13
|
+
if (!cookieStr) return {};
|
|
14
|
+
return cookieStr.split(';').reduce((acc, str) => {
|
|
15
|
+
const [k, v] = str.split('=');
|
|
16
|
+
if (k && v) acc[k.trim()] = v.trim();
|
|
17
|
+
return acc;
|
|
18
|
+
}, {});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const flashCache = new Map();
|
|
22
|
+
|
|
23
|
+
export async function handleRequest(req, res, config, trie) {
|
|
24
|
+
const scope = getScope();
|
|
25
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
26
|
+
|
|
27
|
+
if (req.method === 'GET' && !req.headers['accept']?.includes('application/vnd.inert.pulse+json') && flashCache.has(url.pathname)) {
|
|
28
|
+
const start = performance.now();
|
|
29
|
+
const cachedHtml = flashCache.get(url.pathname);
|
|
30
|
+
// Securely inject the unique nonce for this request
|
|
31
|
+
const safeHtml = cachedHtml.replace(/nonce="[^"]*"/g, `nonce="${scope.nonce}"`);
|
|
32
|
+
const csp = getCspHeader(scope.nonce, config);
|
|
33
|
+
res.writeHead(200, {
|
|
34
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
35
|
+
'Content-Security-Policy': csp,
|
|
36
|
+
'X-Content-Type-Options': 'nosniff',
|
|
37
|
+
'X-Frame-Options': 'DENY',
|
|
38
|
+
'X-Inert-Flash': 'Hit',
|
|
39
|
+
'X-Inert-Flash-Time': `${(performance.now() - start).toFixed(2)}ms`
|
|
40
|
+
});
|
|
41
|
+
res.end(safeHtml);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (url.pathname === '/_inert/dev/stream') {
|
|
46
|
+
res.writeHead(200, {
|
|
47
|
+
'Content-Type': 'text/event-stream',
|
|
48
|
+
'Cache-Control': 'no-cache',
|
|
49
|
+
'Connection': 'keep-alive'
|
|
50
|
+
});
|
|
51
|
+
res.write('data: connected\n\n');
|
|
52
|
+
// Keep connection alive to detect disconnects instantly
|
|
53
|
+
const keepAlive = setInterval(() => res.write(':\n\n'), 15000);
|
|
54
|
+
req.on('close', () => clearInterval(keepAlive));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (url.pathname.startsWith('/_inert/pulse/')) {
|
|
59
|
+
const filename = url.pathname.replace('/_inert/pulse/', '');
|
|
60
|
+
const filePath = path.resolve(process.cwd(), 'packages/pulse/src', filename);
|
|
61
|
+
const served = await serveStatic(req, res, filePath);
|
|
62
|
+
if (!served) {
|
|
63
|
+
res.writeHead(404);
|
|
64
|
+
res.end('Not Found');
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (url.pathname.startsWith('/_inert/image')) {
|
|
70
|
+
const src = url.searchParams.get('src');
|
|
71
|
+
const width = url.searchParams.get('w');
|
|
72
|
+
const height = url.searchParams.get('h');
|
|
73
|
+
const quality = url.searchParams.get('q');
|
|
74
|
+
|
|
75
|
+
if (!src) {
|
|
76
|
+
res.writeHead(400);
|
|
77
|
+
res.end('Missing src parameter');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const filePath = path.join(process.cwd(), src.replace(/^\/+/, ''));
|
|
83
|
+
// Basic security check to prevent directory traversal
|
|
84
|
+
if (!filePath.startsWith(process.cwd())) {
|
|
85
|
+
res.writeHead(403);
|
|
86
|
+
res.end('Forbidden');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const buffer = await fs.readFile(filePath);
|
|
91
|
+
const optimizedBuffer = await optimizeImage(buffer, { width, height, quality });
|
|
92
|
+
|
|
93
|
+
res.writeHead(200, {
|
|
94
|
+
'Content-Type': 'image/webp',
|
|
95
|
+
'Cache-Control': 'public, max-age=31536000, immutable'
|
|
96
|
+
});
|
|
97
|
+
res.end(optimizedBuffer);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error(`[InertJS] Image optimization failed:`, err);
|
|
100
|
+
res.writeHead(500);
|
|
101
|
+
res.end('Error optimizing image');
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (url.pathname.startsWith('/public/')) {
|
|
107
|
+
const filename = url.pathname.replace('/public/', '');
|
|
108
|
+
// Need to detect content type based on extension
|
|
109
|
+
const ext = path.extname(filename).toLowerCase();
|
|
110
|
+
const contentTypes = {
|
|
111
|
+
'.jpg': 'image/jpeg',
|
|
112
|
+
'.jpeg': 'image/jpeg',
|
|
113
|
+
'.png': 'image/png',
|
|
114
|
+
'.svg': 'image/svg+xml',
|
|
115
|
+
'.css': 'text/css',
|
|
116
|
+
'.webp': 'image/webp',
|
|
117
|
+
'.ico': 'image/x-icon'
|
|
118
|
+
};
|
|
119
|
+
const cType = contentTypes[ext] || 'application/octet-stream';
|
|
120
|
+
const filePath = path.resolve(process.cwd(), 'public', filename);
|
|
121
|
+
const served = await serveStatic(req, res, filePath, cType);
|
|
122
|
+
if (!served) {
|
|
123
|
+
res.writeHead(404);
|
|
124
|
+
res.end('Not Found');
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (url.pathname.startsWith('/node_modules/')) {
|
|
130
|
+
const filePath = path.resolve(process.cwd(), url.pathname.slice(1));
|
|
131
|
+
const served = await serveStatic(req, res, filePath);
|
|
132
|
+
if (!served) {
|
|
133
|
+
res.writeHead(404);
|
|
134
|
+
res.end('Not Found');
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
140
|
+
|
|
141
|
+
let match = trie.match(segments);
|
|
142
|
+
let is404 = false;
|
|
143
|
+
|
|
144
|
+
if (!match) {
|
|
145
|
+
match = trie.match(['404']);
|
|
146
|
+
is404 = true;
|
|
147
|
+
if (!match) {
|
|
148
|
+
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
149
|
+
res.end(`<!DOCTYPE html>
|
|
150
|
+
<html lang="en">
|
|
151
|
+
<head>
|
|
152
|
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>404 Not Found - InertJS</title>
|
|
153
|
+
<style>
|
|
154
|
+
body { font-family: system-ui, -apple-system, sans-serif; background-color: #0f172a; color: #f8fafc; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
155
|
+
.container { text-align: center; max-width: 600px; padding: 2rem; }
|
|
156
|
+
h1 { font-size: 5rem; margin: 0; background: linear-gradient(to right, #818cf8, #22d3ee); -webkit-background-clip: text; color: transparent; font-weight: 900; }
|
|
157
|
+
p { color: #94a3b8; font-size: 1.25rem; margin-bottom: 2rem; }
|
|
158
|
+
a { background: #1e293b; color: #f8fafc; text-decoration: none; padding: 0.75rem 2rem; border-radius: 9999px; font-weight: 600; border: 1px solid #334155; transition: all 0.2s; }
|
|
159
|
+
a:hover { background: #4f46e5; border-color: #4f46e5; }
|
|
160
|
+
</style>
|
|
161
|
+
</head>
|
|
162
|
+
<body>
|
|
163
|
+
<div class="container">
|
|
164
|
+
<h1>404</h1>
|
|
165
|
+
<p>The route could not be resolved by the InertJS router.</p>
|
|
166
|
+
<a href="/">Return Home</a>
|
|
167
|
+
</div>
|
|
168
|
+
</body>
|
|
169
|
+
</html>`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const { route, params } = match;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
// 1. Run guard() chain
|
|
178
|
+
if (route.guard) {
|
|
179
|
+
const guardModule = await import(pathToFileURL(route.guard).href);
|
|
180
|
+
if (guardModule.guard) {
|
|
181
|
+
const allowed = await guardModule.guard({ req, params, scope });
|
|
182
|
+
if (!allowed) {
|
|
183
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
184
|
+
res.end('Forbidden');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 2. Wire handling (API endpoint)
|
|
191
|
+
if (route.wire && !route.view) {
|
|
192
|
+
const method = req.method.toUpperCase();
|
|
193
|
+
|
|
194
|
+
// CSRF Protection for mutating methods
|
|
195
|
+
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
|
196
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
197
|
+
const headerToken = req.headers['x-inert-csrf'];
|
|
198
|
+
if (!validateCsrfToken(cookies['__inert_csrf'], headerToken)) {
|
|
199
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
200
|
+
res.end('CSRF Validation Failed');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const wireModule = await import(pathToFileURL(route.wire).href);
|
|
206
|
+
if (wireModule[method]) {
|
|
207
|
+
// Execute the method handler
|
|
208
|
+
const responseData = await wireModule[method]({ req, res, params, scope });
|
|
209
|
+
if (!res.headersSent && responseData) {
|
|
210
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
211
|
+
res.end(JSON.stringify(responseData));
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
res.writeHead(405, { 'Content-Type': 'text/plain' });
|
|
215
|
+
res.end('Method Not Allowed');
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 3. Run flux()
|
|
221
|
+
let data = {};
|
|
222
|
+
if (route.flux) {
|
|
223
|
+
const fluxModule = await import(pathToFileURL(route.flux).href);
|
|
224
|
+
if (fluxModule.flux) {
|
|
225
|
+
data = await fluxModule.flux({ req, params, scope });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const isPulse = req.headers['accept']?.includes('application/vnd.inert.pulse+json');
|
|
230
|
+
|
|
231
|
+
// 4. View rendering
|
|
232
|
+
if (!isPulse) {
|
|
233
|
+
const csp = getCspHeader(scope.nonce, config);
|
|
234
|
+
res.writeHead(is404 ? 404 : 200, {
|
|
235
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
236
|
+
'Content-Security-Policy': csp,
|
|
237
|
+
'X-Content-Type-Options': 'nosniff',
|
|
238
|
+
'X-Frame-Options': 'DENY'
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let viewResult = '';
|
|
243
|
+
let isFlashMode = false;
|
|
244
|
+
if (route.view) {
|
|
245
|
+
const viewModule = await import(pathToFileURL(route.view).href);
|
|
246
|
+
if (viewModule.flash === true) isFlashMode = true;
|
|
247
|
+
if (viewModule.render) {
|
|
248
|
+
viewResult = viewModule.render({ data, params, scope });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 5. Wrap in shells (from inside out) if not a pulse navigation
|
|
253
|
+
let finalResult = viewResult;
|
|
254
|
+
|
|
255
|
+
if (isPulse) {
|
|
256
|
+
const { resolveToString } = await import('../../vector/src/index.js');
|
|
257
|
+
const viewHtml = await resolveToString(viewResult);
|
|
258
|
+
|
|
259
|
+
res.writeHead(200, {
|
|
260
|
+
'Content-Type': 'application/vnd.inert.pulse+json',
|
|
261
|
+
'X-Content-Type-Options': 'nosniff'
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
res.end(JSON.stringify({
|
|
265
|
+
title: data.title || 'InertJS',
|
|
266
|
+
shellIdsToUpdate: [],
|
|
267
|
+
viewHtml,
|
|
268
|
+
data
|
|
269
|
+
}));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (route.shells && route.shells.length > 0) {
|
|
274
|
+
for (let i = route.shells.length - 1; i >= 0; i--) {
|
|
275
|
+
const shellModule = await import(pathToFileURL(route.shells[i]).href);
|
|
276
|
+
if (shellModule.render) {
|
|
277
|
+
// Preserve VecStream and RawString objects, only wrap plain strings
|
|
278
|
+
const childrenArg = (finalResult && (finalResult.type === 'VecStream' || finalResult.value !== undefined))
|
|
279
|
+
? finalResult
|
|
280
|
+
: raw(String(finalResult));
|
|
281
|
+
finalResult = shellModule.render({ children: childrenArg, data, params, scope });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 6. Stream to response
|
|
287
|
+
if (isFlashMode && !isPulse && req.method === 'GET') {
|
|
288
|
+
const { resolveToString } = await import('../../vector/src/index.js');
|
|
289
|
+
const finalHtml = await resolveToString(finalResult);
|
|
290
|
+
// Cache it for the next request
|
|
291
|
+
flashCache.set(url.pathname, finalHtml);
|
|
292
|
+
res.end(finalHtml);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const webStream = renderToStream(finalResult, scope.nonce);
|
|
297
|
+
const nodeStream = Readable.fromWeb(webStream);
|
|
298
|
+
|
|
299
|
+
// Pipe handles backpressure natively
|
|
300
|
+
nodeStream.pipe(res);
|
|
301
|
+
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.error(`[InertJS] Pipeline Error:`, err);
|
|
304
|
+
|
|
305
|
+
// Fallback handling
|
|
306
|
+
if (route && route.fallback) {
|
|
307
|
+
try {
|
|
308
|
+
const fallbackModule = await import(pathToFileURL(route.fallback).href);
|
|
309
|
+
if (fallbackModule.fallback && !res.headersSent) {
|
|
310
|
+
const fallbackRes = await fallbackModule.fallback(err);
|
|
311
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
312
|
+
res.end(fallbackRes);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
} catch (fallbackErr) {
|
|
316
|
+
console.error(`[InertJS] Fallback failed:`, fallbackErr);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!res.headersSent) {
|
|
321
|
+
// Global 500 handler
|
|
322
|
+
const errorMatch = trie.match(['500']);
|
|
323
|
+
if (errorMatch && errorMatch.route.view) {
|
|
324
|
+
try {
|
|
325
|
+
const viewModule = await import(pathToFileURL(errorMatch.route.view).href);
|
|
326
|
+
let finalResult = '';
|
|
327
|
+
if (viewModule.render) {
|
|
328
|
+
finalResult = viewModule.render({ data: { error: err.message, stack: err.stack, title: 'Server Error' }, params: {}, scope });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (errorMatch.route.shells && errorMatch.route.shells.length > 0) {
|
|
332
|
+
for (let i = errorMatch.route.shells.length - 1; i >= 0; i--) {
|
|
333
|
+
const shellModule = await import(pathToFileURL(errorMatch.route.shells[i]).href);
|
|
334
|
+
if (shellModule.render) {
|
|
335
|
+
finalResult = shellModule.render({ children: raw(finalResult instanceof Object && finalResult.value ? finalResult.value : String(finalResult)), data: { error: err.message, stack: err.stack, title: 'Server Error' }, params: {}, scope });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const csp = getCspHeader(scope.nonce, config);
|
|
341
|
+
res.writeHead(500, {
|
|
342
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
343
|
+
'Content-Security-Policy': csp,
|
|
344
|
+
'X-Content-Type-Options': 'nosniff',
|
|
345
|
+
'X-Frame-Options': 'DENY'
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const webStream = renderToStream(finalResult, scope.nonce);
|
|
349
|
+
const nodeStream = Readable.fromWeb(webStream);
|
|
350
|
+
nodeStream.pipe(res);
|
|
351
|
+
return;
|
|
352
|
+
} catch (globalErr) {
|
|
353
|
+
console.error(`[InertJS] Global 500 handler failed:`, globalErr);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
358
|
+
res.end(`<!DOCTYPE html>
|
|
359
|
+
<html lang="en">
|
|
360
|
+
<head>
|
|
361
|
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>500 Internal Error - InertJS</title>
|
|
362
|
+
<style>
|
|
363
|
+
body { font-family: system-ui, -apple-system, sans-serif; background-color: #0f172a; color: #f8fafc; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 2rem; box-sizing: border-box; }
|
|
364
|
+
.container { width: 100%; max-width: 800px; }
|
|
365
|
+
.header { text-align: center; margin-bottom: 2rem; }
|
|
366
|
+
h1 { font-size: 3rem; margin: 0; color: #f87171; font-weight: 800; }
|
|
367
|
+
p { color: #94a3b8; font-size: 1.1rem; }
|
|
368
|
+
.error-box { background: rgba(0,0,0,0.3); border: 1px solid rgba(248,113,113,0.3); border-radius: 0.5rem; overflow: hidden; }
|
|
369
|
+
.error-msg { background: rgba(127,29,29,0.4); padding: 1rem; color: #fca5a5; font-family: monospace; font-weight: bold; border-bottom: 1px solid rgba(248,113,113,0.3); word-wrap: break-word; }
|
|
370
|
+
pre { padding: 1rem; margin: 0; color: #cbd5e1; font-family: monospace; font-size: 0.875rem; overflow-x: auto; }
|
|
371
|
+
</style>
|
|
372
|
+
</head>
|
|
373
|
+
<body>
|
|
374
|
+
<div class="container">
|
|
375
|
+
<div class="header">
|
|
376
|
+
<h1>Internal Server Error</h1>
|
|
377
|
+
<p>The application encountered an unexpected condition.</p>
|
|
378
|
+
</div>
|
|
379
|
+
<div class="error-box">
|
|
380
|
+
<div class="error-msg">${err.message.replace(/</g, '<').replace(/>/g, '>')}</div>
|
|
381
|
+
<pre>${err.stack.replace(/</g, '<').replace(/>/g, '>')}</pre>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
</body>
|
|
385
|
+
</html>`);
|
|
386
|
+
} else {
|
|
387
|
+
res.end(); // just close it
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
package/src/scope.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
export const requestScope = new AsyncLocalStorage();
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gets the current request scope.
|
|
7
|
+
*
|
|
8
|
+
* @returns {object} The RequestScope object
|
|
9
|
+
* @throws {Error} If called outside of a request scope
|
|
10
|
+
*/
|
|
11
|
+
export function getScope() {
|
|
12
|
+
const scope = requestScope.getStore();
|
|
13
|
+
if (!scope) {
|
|
14
|
+
throw new Error('E_INERT_SCOPE: Accessed outside of request scope');
|
|
15
|
+
}
|
|
16
|
+
return scope;
|
|
17
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import http2 from 'node:http2';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { requestScope } from './scope.js';
|
|
5
|
+
import { handleRequest } from './pipeline.js';
|
|
6
|
+
import { RateLimiter } from '../../shield/src/index.js';
|
|
7
|
+
|
|
8
|
+
export class CoreServer {
|
|
9
|
+
/**
|
|
10
|
+
* Initializes the InertJS Core server.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} config Validated config object
|
|
13
|
+
* @param {RouterTrie} trie Loaded router trie
|
|
14
|
+
*/
|
|
15
|
+
constructor(config, trie) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.trie = trie;
|
|
18
|
+
|
|
19
|
+
// Browsers do not support HTTP/2 without TLS (h2c).
|
|
20
|
+
// To support Chrome in local dev, we fall back to HTTP/1.1 for plain text.
|
|
21
|
+
this.server = http.createServer();
|
|
22
|
+
|
|
23
|
+
const reqPerSec = config?.shield?.rateLimit || 100;
|
|
24
|
+
this.rateLimiter = new RateLimiter(reqPerSec);
|
|
25
|
+
|
|
26
|
+
this.connections = new Set();
|
|
27
|
+
|
|
28
|
+
this.server.on('connection', (conn) => {
|
|
29
|
+
this.connections.add(conn);
|
|
30
|
+
conn.once('close', () => this.connections.delete(conn));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
this.server.on('request', this._onRequest.bind(this));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_onRequest(req, res) {
|
|
37
|
+
const ip = req.socket.remoteAddress || '127.0.0.1';
|
|
38
|
+
if (!this.rateLimiter.check(ip)) {
|
|
39
|
+
res.writeHead(429, { 'Content-Type': 'text/plain', 'Retry-After': '1' });
|
|
40
|
+
res.end('Too Many Requests');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const scope = {
|
|
45
|
+
reqId: crypto.randomUUID(),
|
|
46
|
+
nonce: crypto.randomBytes(16).toString('base64'),
|
|
47
|
+
startedAt: performance.now(),
|
|
48
|
+
vaultHandle: null,
|
|
49
|
+
conduitAgent: null,
|
|
50
|
+
lens: null
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Every stage is wrapped in an AsyncLocalStorage RequestScope
|
|
54
|
+
requestScope.run(scope, () => {
|
|
55
|
+
handleRequest(req, res, this.config, this.trie).catch(err => {
|
|
56
|
+
console.error(`[InertJS] Unhandled request error:`, err);
|
|
57
|
+
if (!res.headersSent) {
|
|
58
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
59
|
+
res.end('Internal Server Error');
|
|
60
|
+
} else {
|
|
61
|
+
res.end();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async start() {
|
|
68
|
+
let port = this.config.core.port;
|
|
69
|
+
const host = this.config.core.host;
|
|
70
|
+
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
this.server.on('error', (err) => {
|
|
73
|
+
if (err.code === 'EADDRINUSE') {
|
|
74
|
+
console.warn(`[InertJS] Port ${port} is in use, trying ${port + 1}...`);
|
|
75
|
+
port++;
|
|
76
|
+
this.config.core.port = port; // Update config
|
|
77
|
+
this.server.listen(port, host);
|
|
78
|
+
} else {
|
|
79
|
+
reject(err);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this.server.listen(port, host, () => {
|
|
84
|
+
resolve();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async stop() {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
let timeout;
|
|
92
|
+
if (this.config.core.gracefulShutdownMs) {
|
|
93
|
+
timeout = setTimeout(() => {
|
|
94
|
+
console.warn('[InertJS] Graceful shutdown timeout, forcing close');
|
|
95
|
+
for (const conn of this.connections) {
|
|
96
|
+
conn.destroy();
|
|
97
|
+
}
|
|
98
|
+
}, this.config.core.gracefulShutdownMs);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.server.close((err) => {
|
|
102
|
+
if (timeout) clearTimeout(timeout);
|
|
103
|
+
if (err) reject(err);
|
|
104
|
+
else resolve();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/static.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { minifyAsset } from 'inertjs-optimizer';
|
|
5
|
+
|
|
6
|
+
const ETAG_CACHE = new Map();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Serves a static file with ETag caching and proper cache headers.
|
|
10
|
+
*/
|
|
11
|
+
export async function serveStatic(req, res, filePath, contentType = 'application/javascript') {
|
|
12
|
+
try {
|
|
13
|
+
const stat = await fs.stat(filePath);
|
|
14
|
+
|
|
15
|
+
let cacheEntry = ETAG_CACHE.get(filePath);
|
|
16
|
+
if (!cacheEntry || cacheEntry.mtime !== stat.mtimeMs) {
|
|
17
|
+
let content = await fs.readFile(filePath);
|
|
18
|
+
|
|
19
|
+
if (contentType === 'application/javascript' || contentType === 'text/css') {
|
|
20
|
+
const type = contentType === 'text/css' ? 'css' : 'js';
|
|
21
|
+
const minified = await minifyAsset(content.toString('utf8'), type);
|
|
22
|
+
content = Buffer.from(minified, 'utf8');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const hash = crypto.createHash('sha1').update(content).digest('hex');
|
|
26
|
+
cacheEntry = {
|
|
27
|
+
etag: `"${hash}"`,
|
|
28
|
+
mtime: stat.mtimeMs,
|
|
29
|
+
content
|
|
30
|
+
};
|
|
31
|
+
ETAG_CACHE.set(filePath, cacheEntry);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (req.headers['if-none-match'] === cacheEntry.etag) {
|
|
35
|
+
res.writeHead(304);
|
|
36
|
+
res.end();
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
res.writeHead(200, {
|
|
41
|
+
'Content-Type': contentType,
|
|
42
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
43
|
+
'ETag': cacheEntry.etag
|
|
44
|
+
});
|
|
45
|
+
res.end(cacheEntry.content);
|
|
46
|
+
return true;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import http2 from 'node:http2';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { CoreServer } from '../src/server.js';
|
|
9
|
+
import { RouterTrie } from '../../router/src/trie.js';
|
|
10
|
+
|
|
11
|
+
function fetchH2C(url) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const client = http2.connect(url);
|
|
14
|
+
const u = new URL(url);
|
|
15
|
+
const req = client.request({ ':path': u.pathname });
|
|
16
|
+
|
|
17
|
+
let data = '';
|
|
18
|
+
req.setEncoding('utf8');
|
|
19
|
+
|
|
20
|
+
req.on('response', (headers) => {
|
|
21
|
+
req.on('data', chunk => data += chunk);
|
|
22
|
+
req.on('end', () => {
|
|
23
|
+
client.close();
|
|
24
|
+
resolve({ status: headers[':status'], data, headers });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
req.on('error', err => {
|
|
29
|
+
client.close();
|
|
30
|
+
reject(err);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
req.end();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('Core Kernel - Server Pipeline', async (t) => {
|
|
38
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inert-core-test-'));
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// Scaffold test routes
|
|
42
|
+
await fs.mkdir(path.join(tmpDir, 'basic'), { recursive: true });
|
|
43
|
+
|
|
44
|
+
const vectorPath = pathToFileURL(path.resolve(process.cwd(), 'packages/vector/src/index.js')).href;
|
|
45
|
+
|
|
46
|
+
// Mock view.js
|
|
47
|
+
await fs.writeFile(
|
|
48
|
+
path.join(tmpDir, 'basic', 'view.js'),
|
|
49
|
+
`import { vec } from '${vectorPath}'; export const render = ({ data }) => vec\`<main>\${data.title}</main>\`;`
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Mock shell.js
|
|
53
|
+
await fs.writeFile(
|
|
54
|
+
path.join(tmpDir, 'basic', 'shell.js'),
|
|
55
|
+
`import { vec } from '${vectorPath}'; export const render = ({ children }) => vec\`<html><body>\${children}</body></html>\`;`
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Mock flux.js
|
|
59
|
+
await fs.writeFile(
|
|
60
|
+
path.join(tmpDir, 'basic', 'flux.js'),
|
|
61
|
+
`export const flux = async () => { return { title: 'Hello Flux' }; };`
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Mock guard.js
|
|
65
|
+
await fs.writeFile(
|
|
66
|
+
path.join(tmpDir, 'basic', 'guard.js'),
|
|
67
|
+
`export const guard = async ({ req }) => req.headers['x-allow'] === 'yes';`
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const trie = new RouterTrie();
|
|
71
|
+
|
|
72
|
+
// The manifest stores absolute paths
|
|
73
|
+
const toUrl = p => path.join(tmpDir, p).split(path.sep).join('/');
|
|
74
|
+
|
|
75
|
+
trie.insert(['basic'], {
|
|
76
|
+
view: toUrl('basic/view.js'),
|
|
77
|
+
shell: toUrl('basic/shell.js'), // Wait, it's an array of shells
|
|
78
|
+
shells: [toUrl('basic/shell.js')],
|
|
79
|
+
flux: toUrl('basic/flux.js'),
|
|
80
|
+
guard: toUrl('basic/guard.js'),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const config = {
|
|
84
|
+
core: { port: 0, host: '127.0.0.1', gracefulShutdownMs: 1000 }
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const server = new CoreServer(config, trie);
|
|
88
|
+
await server.start();
|
|
89
|
+
const port = server.server.address().port;
|
|
90
|
+
|
|
91
|
+
await t.test('403 when guard fails', async () => {
|
|
92
|
+
const res = await fetchH2C(`http://127.0.0.1:${port}/basic`);
|
|
93
|
+
assert.strictEqual(res.status, 403);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await t.test('200 pipeline success (guard -> flux -> view -> shell)', async () => {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const client = http2.connect(`http://127.0.0.1:${port}`);
|
|
99
|
+
const req = client.request({ ':path': '/basic', 'x-allow': 'yes' });
|
|
100
|
+
|
|
101
|
+
let data = '';
|
|
102
|
+
req.setEncoding('utf8');
|
|
103
|
+
|
|
104
|
+
req.on('response', (headers) => {
|
|
105
|
+
assert.strictEqual(headers[':status'], 200);
|
|
106
|
+
assert.strictEqual(headers['content-type'], 'text/html; charset=utf-8');
|
|
107
|
+
assert.ok(headers['content-security-policy'].includes('default-src \'none\''));
|
|
108
|
+
|
|
109
|
+
req.on('data', chunk => data += chunk);
|
|
110
|
+
req.on('end', () => {
|
|
111
|
+
client.close();
|
|
112
|
+
// It should render shell wrapping view wrapping flux data
|
|
113
|
+
assert.strictEqual(data, '<html><body><main>Hello Flux</main></body></html>');
|
|
114
|
+
resolve();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
req.on('error', err => {
|
|
119
|
+
client.close();
|
|
120
|
+
reject(err);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
req.end();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await t.test('200 Pulse JSON manifest', async () => {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const client = http2.connect(`http://127.0.0.1:${port}`);
|
|
130
|
+
const req = client.request({
|
|
131
|
+
':path': '/basic',
|
|
132
|
+
'x-allow': 'yes',
|
|
133
|
+
'accept': 'application/vnd.inert.pulse+json'
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
let data = '';
|
|
137
|
+
req.setEncoding('utf8');
|
|
138
|
+
|
|
139
|
+
req.on('response', (headers) => {
|
|
140
|
+
assert.strictEqual(headers[':status'], 200);
|
|
141
|
+
assert.strictEqual(headers['content-type'], 'application/vnd.inert.pulse+json');
|
|
142
|
+
|
|
143
|
+
req.on('data', chunk => data += chunk);
|
|
144
|
+
req.on('end', () => {
|
|
145
|
+
client.close();
|
|
146
|
+
const manifest = JSON.parse(data);
|
|
147
|
+
assert.strictEqual(manifest.title, 'Hello Flux');
|
|
148
|
+
assert.strictEqual(manifest.viewHtml, '<main>Hello Flux</main>');
|
|
149
|
+
assert.deepStrictEqual(manifest.data, { title: 'Hello Flux' });
|
|
150
|
+
resolve();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
req.on('error', err => {
|
|
155
|
+
client.close();
|
|
156
|
+
reject(err);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
req.end();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await server.stop();
|
|
164
|
+
} finally {
|
|
165
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import http2 from 'node:http2';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { CoreServer } from '../src/server.js';
|
|
6
|
+
import { RouterTrie } from '../../router/src/index.js';
|
|
7
|
+
|
|
8
|
+
test('CoreServer Rate Limiter E2E (100 reqs/sec limit)', async (t) => {
|
|
9
|
+
const trie = new RouterTrie();
|
|
10
|
+
trie.insert(['test'], {
|
|
11
|
+
view: 'file://' + process.cwd() + '/packages/core/test/mock/view.js'
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const server = new CoreServer({
|
|
15
|
+
core: { port: 0, host: '127.0.0.1' },
|
|
16
|
+
shield: { rateLimit: 100 }
|
|
17
|
+
}, trie);
|
|
18
|
+
|
|
19
|
+
await server.start();
|
|
20
|
+
const port = server.server.address().port;
|
|
21
|
+
|
|
22
|
+
const client = http2.connect(`http://127.0.0.1:${port}`);
|
|
23
|
+
|
|
24
|
+
let successCount = 0;
|
|
25
|
+
let limitedCount = 0;
|
|
26
|
+
|
|
27
|
+
// Spam 200 requests concurrently
|
|
28
|
+
const reqs = Array.from({ length: 200 }).map(() => {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const req = client.request({ ':path': '/test' });
|
|
31
|
+
req.on('response', (headers) => {
|
|
32
|
+
if (headers[':status'] === 200) successCount++;
|
|
33
|
+
if (headers[':status'] === 429) limitedCount++;
|
|
34
|
+
req.resume(); // consume data
|
|
35
|
+
});
|
|
36
|
+
req.on('end', resolve);
|
|
37
|
+
req.end();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await Promise.all(reqs);
|
|
42
|
+
|
|
43
|
+
assert.ok(successCount > 0 && successCount <= 100, `Expected some success, got ${successCount}`);
|
|
44
|
+
assert.ok(limitedCount >= 100, `Expected at least 100 429s, got ${limitedCount}`);
|
|
45
|
+
|
|
46
|
+
client.close();
|
|
47
|
+
await server.stop();
|
|
48
|
+
});
|