@nexus_js/server 0.9.28 → 0.9.29
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/dist/actions.d.ts +11 -71
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +51 -442
- package/dist/actions.js.map +1 -1
- package/dist/context.d.ts +4 -38
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +3 -13
- package/dist/context.js.map +1 -1
- package/dist/csrf.d.ts +2 -16
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +30 -68
- package/dist/csrf.js.map +1 -1
- package/dist/dev-assets.d.ts +0 -31
- package/dist/dev-assets.d.ts.map +1 -1
- package/dist/dev-assets.js +38 -372
- package/dist/dev-assets.js.map +1 -1
- package/dist/dev-error-html.d.ts.map +1 -1
- package/dist/dev-error-html.js +0 -24
- package/dist/dev-error-html.js.map +1 -1
- package/dist/devradar.d.ts +1 -1
- package/dist/devradar.d.ts.map +1 -1
- package/dist/devradar.js.map +1 -1
- package/dist/index.d.ts +2 -97
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -442
- package/dist/index.js.map +1 -1
- package/dist/load-module.d.ts +0 -6
- package/dist/load-module.d.ts.map +1 -1
- package/dist/load-module.js +53 -40
- package/dist/load-module.js.map +1 -1
- package/dist/navigate.d.ts +5 -0
- package/dist/navigate.d.ts.map +1 -1
- package/dist/navigate.js +1 -0
- package/dist/navigate.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +14 -27
- package/dist/rate-limit.js.map +1 -1
- package/dist/renderer.d.ts +7 -27
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +25 -152
- package/dist/renderer.js.map +1 -1
- package/dist/streaming.d.ts +3 -3
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +13 -33
- package/dist/streaming.js.map +1 -1
- package/package.json +8 -26
- package/dist/build-id.d.ts +0 -14
- package/dist/build-id.d.ts.map +0 -1
- package/dist/build-id.js +0 -40
- package/dist/build-id.js.map +0 -1
- package/dist/dev-assets.test.d.ts +0 -2
- package/dist/dev-assets.test.d.ts.map +0 -1
- package/dist/head-renderer.test.d.ts +0 -2
- package/dist/head-renderer.test.d.ts.map +0 -1
- package/dist/head-renderer.test.js +0 -78
- package/dist/head-renderer.test.js.map +0 -1
- package/dist/legacy-wrapper.d.ts +0 -88
- package/dist/legacy-wrapper.d.ts.map +0 -1
- package/dist/legacy-wrapper.js +0 -104
- package/dist/legacy-wrapper.js.map +0 -1
- package/dist/lib-assets.d.ts +0 -5
- package/dist/lib-assets.d.ts.map +0 -1
- package/dist/lib-assets.js +0 -95
- package/dist/lib-assets.js.map +0 -1
- package/dist/metadata.d.ts +0 -95
- package/dist/metadata.d.ts.map +0 -1
- package/dist/metadata.js +0 -132
- package/dist/metadata.js.map +0 -1
- package/dist/renderer.test.d.ts +0 -2
- package/dist/renderer.test.d.ts.map +0 -1
- package/dist/renderer.test.js +0 -251
- package/dist/renderer.test.js.map +0 -1
- package/dist/tenancy.d.ts +0 -17
- package/dist/tenancy.d.ts.map +0 -1
- package/dist/tenancy.js +0 -132
- package/dist/tenancy.js.map +0 -1
- package/dist/tenancy.test.d.ts +0 -2
- package/dist/tenancy.test.d.ts.map +0 -1
- package/dist/tenancy.test.js +0 -38
- package/dist/tenancy.test.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -4,60 +4,30 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
6
|
import { readFile, stat } from 'node:fs/promises';
|
|
7
|
-
import { join, extname
|
|
8
|
-
import { randomBytes } from 'node:crypto';
|
|
9
|
-
import { Readable } from 'node:stream';
|
|
7
|
+
import { join, extname } from 'node:path';
|
|
10
8
|
import { buildRouteManifest, matchRoute } from '@nexus_js/router';
|
|
11
9
|
import { handleActionRequest } from './actions.js';
|
|
12
10
|
import { handleSSERequestNode, isConnectRequest, topicFromUrl } from '@nexus_js/connect';
|
|
13
|
-
import { buildAggregatedNxStylesheet,
|
|
14
|
-
import { tryServeLibAsset } from './lib-assets.js';
|
|
11
|
+
import { buildAggregatedNxStylesheet, bustAggregatedStylesCache, compileIslandClientBundle, isIslandClientRequest, tryServeRuntimeAsset, } from './dev-assets.js';
|
|
15
12
|
import { devErrorHtmlPage } from './dev-error-html.js';
|
|
16
13
|
import { broadcastDevHotReload, subscribeDevHotClient } from './dev-hot.js';
|
|
17
|
-
import { renderRoute, renderRouteStreaming } from './renderer.js';
|
|
14
|
+
import { renderRoute, renderRouteStreaming, wrapWithDocument } from './renderer.js';
|
|
18
15
|
import { pipeToNodeResponse } from './streaming.js';
|
|
16
|
+
import { findNotFoundBoundary } from './error-boundary.js';
|
|
17
|
+
import { loadRouteModule } from './load-module.js';
|
|
19
18
|
import { handleNavigationRequest } from './navigate.js';
|
|
20
19
|
import { bumpDevReloadGeneration, preloadRegisteredServerActions } from './load-module.js';
|
|
21
20
|
import { createContext, RedirectSignal, NotFoundSignal } from './context.js';
|
|
22
|
-
import { nexusVault
|
|
21
|
+
import { nexusVault } from '@nexus_js/security';
|
|
23
22
|
import { handleDevVaultPost } from './dev-vault.js';
|
|
24
|
-
import { resolveTenant } from './tenancy.js';
|
|
25
23
|
import { refreshShieldAllowlist, isActionBlockedByShield, setShieldLite, } from './shield-runtime.js';
|
|
26
|
-
import { loadAndCacheNexusBuildId } from './build-id.js';
|
|
27
24
|
import { emitDevRadar } from './devradar.js';
|
|
28
25
|
export { STUDIO_DEFAULT_PORT } from './constants.js';
|
|
29
|
-
export { createAction, registerAction, ActionError, getRegisteredActionNames
|
|
30
|
-
export { loadAndCacheNexusBuildId, getExpectedNexusBuildId } from './build-id.js';
|
|
26
|
+
export { createAction, registerAction, ActionError, getRegisteredActionNames } from './actions.js';
|
|
31
27
|
export { createContext } from './context.js';
|
|
32
28
|
export { nexusVault } from '@nexus_js/security';
|
|
33
|
-
export { resolveTenant } from './tenancy.js';
|
|
34
29
|
export { mergeRoutePretext } from './renderer.js';
|
|
35
|
-
export { defineMetadata, escapeHtml } from './metadata.js';
|
|
36
|
-
export { defineHead, useHead, flushHead, renderHeadToString } from '@nexus_js/head';
|
|
37
30
|
export { registerDevRadarSink, emitDevRadar, sanitizeTelemetryValue, newTraceId } from './devradar.js';
|
|
38
|
-
export { wrapExpressMiddleware, wrapExpressHandler } from './legacy-wrapper.js';
|
|
39
|
-
/**
|
|
40
|
-
* Returns true when an Origin header value is a loopback address (localhost,
|
|
41
|
-
* 127.x.x.x, ::1, 0.0.0.0) or the opaque "null" value.
|
|
42
|
-
* Used to protect dev-only endpoints from external network access.
|
|
43
|
-
* "null" is explicitly rejected here (unlike the action handler) because dev
|
|
44
|
-
* endpoints must never be reachable from sandboxed or opaque contexts.
|
|
45
|
-
*/
|
|
46
|
-
function isLoopbackOrigin(origin) {
|
|
47
|
-
if (origin === 'null')
|
|
48
|
-
return false; // opaque origin — never trusted
|
|
49
|
-
try {
|
|
50
|
-
const { hostname } = new URL(origin);
|
|
51
|
-
return (hostname === 'localhost' ||
|
|
52
|
-
hostname === '127.0.0.1' ||
|
|
53
|
-
hostname === '::1' ||
|
|
54
|
-
hostname === '0.0.0.0' ||
|
|
55
|
-
/^127\.\d+\.\d+\.\d+$/.test(hostname));
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
31
|
/** Merge ctx response headers (Set-Cookie, etc.) with redirect Location. */
|
|
62
32
|
function redirectHeadersForWriteHead(err) {
|
|
63
33
|
const setCookies = [];
|
|
@@ -78,8 +48,8 @@ function redirectHeadersForWriteHead(err) {
|
|
|
78
48
|
}
|
|
79
49
|
return out;
|
|
80
50
|
}
|
|
81
|
-
/** Merge Hardened Mode headers
|
|
82
|
-
function mergeHardenedHeaders(headers, hardened, dev
|
|
51
|
+
/** Merge Hardened Mode headers (changelog v0.5) — CSP nonces are a future enhancement. */
|
|
52
|
+
function mergeHardenedHeaders(headers, hardened, dev) {
|
|
83
53
|
const h = {};
|
|
84
54
|
for (const [k, v] of Object.entries(headers)) {
|
|
85
55
|
if (v !== undefined)
|
|
@@ -95,34 +65,6 @@ function mergeHardenedHeaders(headers, hardened, dev, cspNonce, cspOptions) {
|
|
|
95
65
|
if (!dev) {
|
|
96
66
|
h['strict-transport-security'] = 'max-age=31536000; includeSubDomains';
|
|
97
67
|
}
|
|
98
|
-
// Content-Security-Policy with per-request nonce for inline scripts.
|
|
99
|
-
// script-src: 'self' for external scripts, nonce for inline scripts (Nexus-generated).
|
|
100
|
-
// Custom inline scripts in templates get the nonce via ctx.cspNonce.
|
|
101
|
-
// object-src: 'none' blocks Flash / legacy plugin execution.
|
|
102
|
-
// base-uri: 'self' prevents base tag injection (open redirect via <base href>).
|
|
103
|
-
if (cspNonce) {
|
|
104
|
-
const extraStyle = cspOptions?.additionalStyleSrc?.join(' ') ?? '';
|
|
105
|
-
const extraFont = cspOptions?.additionalFontSrc?.join(' ') ?? '';
|
|
106
|
-
const extraScript = cspOptions?.additionalScriptSrc?.join(' ') ?? '';
|
|
107
|
-
const extraConnect = cspOptions?.additionalConnectSrc?.join(' ') ?? '';
|
|
108
|
-
const extraImg = cspOptions?.additionalImgSrc?.join(' ') ?? '';
|
|
109
|
-
const extraFrame = cspOptions?.additionalFrameSrc?.join(' ') ?? '';
|
|
110
|
-
const scriptSrc = dev
|
|
111
|
-
? `'self' 'nonce-${cspNonce}' 'unsafe-eval'${extraScript ? ` ${extraScript}` : ''}`
|
|
112
|
-
: `'self' 'nonce-${cspNonce}'${extraScript ? ` ${extraScript}` : ''}`;
|
|
113
|
-
h['content-security-policy'] =
|
|
114
|
-
`default-src 'self'; ` +
|
|
115
|
-
// default-src does not allow blob: for iframes; leave worker-src unset so it falls back to script-src (CDN workers).
|
|
116
|
-
`frame-src 'self' blob:${extraFrame ? ` ${extraFrame}` : ''}; ` +
|
|
117
|
-
`script-src ${scriptSrc}; ` +
|
|
118
|
-
`style-src 'self' 'unsafe-inline'${extraStyle ? ` ${extraStyle}` : ''}; ` +
|
|
119
|
-
`img-src 'self' data: blob:${extraImg ? ` ${extraImg}` : ''}; ` +
|
|
120
|
-
`font-src 'self'${extraFont ? ` ${extraFont}` : ''}; ` +
|
|
121
|
-
`connect-src 'self'${extraConnect ? ` ${extraConnect}` : ''}; ` +
|
|
122
|
-
`object-src 'none'; ` +
|
|
123
|
-
`base-uri 'self'; ` +
|
|
124
|
-
`form-action 'self'`;
|
|
125
|
-
}
|
|
126
68
|
return h;
|
|
127
69
|
}
|
|
128
70
|
const MIME_TYPES = {
|
|
@@ -148,11 +90,9 @@ export async function createNexusServer(opts) {
|
|
|
148
90
|
const publicDir = join(opts.root, opts.publicDir ?? 'public');
|
|
149
91
|
setShieldLite(opts.security?.shieldLite === true);
|
|
150
92
|
let manifest = await buildRouteManifest(routesDir);
|
|
151
|
-
const nexusBuildId = loadAndCacheNexusBuildId(opts.root);
|
|
152
93
|
const renderOpts = {
|
|
153
94
|
dev,
|
|
154
95
|
appRoot: opts.root,
|
|
155
|
-
...(nexusBuildId ? { buildId: nexusBuildId } : {}),
|
|
156
96
|
...(opts.browserImportMap ? { browserImportMap: opts.browserImportMap } : {}),
|
|
157
97
|
assets: {
|
|
158
98
|
/** ESM entry + chunks served from @nexus_js/runtime/dist via /_nexus/rt/* */
|
|
@@ -161,32 +101,11 @@ export async function createNexusServer(opts) {
|
|
|
161
101
|
islands: new Map(),
|
|
162
102
|
},
|
|
163
103
|
};
|
|
164
|
-
const
|
|
165
|
-
const cspConfig = opts.security?.csp;
|
|
166
|
-
// `sec` without a nonce — used for non-HTML responses (JSON, static files, actions).
|
|
167
|
-
const sec = (h) => mergeHardenedHeaders(h, hardened, dev, undefined, cspConfig);
|
|
104
|
+
const sec = (h) => mergeHardenedHeaders(h, opts.security?.hardened, dev);
|
|
168
105
|
const server = createServer(async (req, res) => {
|
|
169
106
|
const t0 = Date.now();
|
|
170
107
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
171
108
|
const method = req.method ?? 'GET';
|
|
172
|
-
if ((method === 'GET' || method === 'HEAD') && url.pathname === '/_nexus/health') {
|
|
173
|
-
const payload = {
|
|
174
|
-
ok: true,
|
|
175
|
-
ts: Date.now(),
|
|
176
|
-
buildId: nexusBuildId ?? null,
|
|
177
|
-
};
|
|
178
|
-
res.writeHead(200, sec({ 'content-type': 'application/json; charset=utf-8' }));
|
|
179
|
-
if (method === 'HEAD') {
|
|
180
|
-
res.end();
|
|
181
|
-
}
|
|
182
|
-
else {
|
|
183
|
-
res.end(JSON.stringify(payload));
|
|
184
|
-
}
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
// Generate a per-request CSP nonce for HTML responses (hardened mode only).
|
|
188
|
-
// Same nonce is injected into inline <script> tags and the CSP header.
|
|
189
|
-
const cspNonce = hardened ? randomBytes(16).toString('base64url') : undefined;
|
|
190
109
|
// Capture cache strategy before headers are flushed, then call onRequest hook
|
|
191
110
|
let _cacheStrategy;
|
|
192
111
|
let _isAction = false;
|
|
@@ -205,27 +124,12 @@ export async function createNexusServer(opts) {
|
|
|
205
124
|
});
|
|
206
125
|
}
|
|
207
126
|
// ── Dev hot-reload (SSE) — browser listens and calls location.reload() ──
|
|
208
|
-
// Guard dev-only endpoints against external origin access. Browsers on the
|
|
209
|
-
// same machine will send no Origin (direct navigation) or a loopback Origin.
|
|
210
|
-
// An attacker on the network sending a cross-origin request is rejected.
|
|
211
127
|
if (dev && method === 'GET' && url.pathname === '/_nexus/dev/hot') {
|
|
212
|
-
const devOrigin = req.headers['origin'];
|
|
213
|
-
if (devOrigin && !isLoopbackOrigin(devOrigin)) {
|
|
214
|
-
res.writeHead(403, { 'content-type': 'text/plain' });
|
|
215
|
-
res.end('Forbidden: dev endpoint requires loopback origin');
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
128
|
subscribeDevHotClient(req, res);
|
|
219
129
|
return;
|
|
220
130
|
}
|
|
221
131
|
// ── Vault-lite (dev) — hot-reload secrets without restart ───────────────
|
|
222
132
|
if (dev && method === 'POST' && url.pathname === '/_nexus/dev/vault') {
|
|
223
|
-
const devOrigin = req.headers['origin'];
|
|
224
|
-
if (devOrigin && !isLoopbackOrigin(devOrigin)) {
|
|
225
|
-
res.writeHead(403, { 'content-type': 'text/plain' });
|
|
226
|
-
res.end('Forbidden: dev endpoint requires loopback origin');
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
133
|
const request = await incomingMessageToWebRequest(req);
|
|
230
134
|
const response = await handleDevVaultPost(request);
|
|
231
135
|
await webToNodeResponse(response, res, sec);
|
|
@@ -262,39 +166,6 @@ export async function createNexusServer(opts) {
|
|
|
262
166
|
}
|
|
263
167
|
// ── Nexus Connect — SSE (/_nexus/connect/:topic) ────────────────────────
|
|
264
168
|
if (method === 'GET' && isConnectRequest(url)) {
|
|
265
|
-
const origin = typeof req.headers['origin'] === 'string' ? req.headers['origin'] : undefined;
|
|
266
|
-
const host = typeof req.headers['host'] === 'string' ? req.headers['host'] : undefined;
|
|
267
|
-
const corsMode = opts.connect?.corsOrigins ?? (dev ? '*' : 'self');
|
|
268
|
-
let allowOrigin;
|
|
269
|
-
if (corsMode === '*') {
|
|
270
|
-
allowOrigin = '*';
|
|
271
|
-
}
|
|
272
|
-
else if (corsMode === 'self') {
|
|
273
|
-
if (!origin) {
|
|
274
|
-
allowOrigin = '*';
|
|
275
|
-
}
|
|
276
|
-
else {
|
|
277
|
-
try {
|
|
278
|
-
const o = new URL(origin);
|
|
279
|
-
if (host && o.host === host) {
|
|
280
|
-
allowOrigin = origin;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
catch {
|
|
284
|
-
allowOrigin = undefined;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
else if (Array.isArray(corsMode)) {
|
|
289
|
-
if (origin && corsMode.includes(origin)) {
|
|
290
|
-
allowOrigin = origin;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
if (origin && !allowOrigin) {
|
|
294
|
-
res.writeHead(403, sec({ 'content-type': 'text/plain; charset=utf-8' }));
|
|
295
|
-
res.end('Forbidden');
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
169
|
handleSSERequestNode(req, res, topicFromUrl(url));
|
|
299
170
|
return;
|
|
300
171
|
}
|
|
@@ -305,34 +176,12 @@ export async function createNexusServer(opts) {
|
|
|
305
176
|
res.end(rt.body);
|
|
306
177
|
return;
|
|
307
178
|
}
|
|
308
|
-
const libAsset = await tryServeLibAsset(url.pathname, opts.root, dev);
|
|
309
|
-
if (libAsset) {
|
|
310
|
-
res.writeHead(200, {
|
|
311
|
-
'content-type': libAsset.contentType,
|
|
312
|
-
'cache-control': dev ? 'no-store' : 'public, max-age=0, must-revalidate',
|
|
313
|
-
});
|
|
314
|
-
res.end(libAsset.body);
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
179
|
// ── Client island ESM (dynamic import target for <nexus-island>) ───────
|
|
318
180
|
if (isIslandClientRequest(url.pathname) && method === 'GET') {
|
|
319
|
-
|
|
320
|
-
try {
|
|
321
|
-
out = await compileIslandClientBundle(opts.root, url);
|
|
322
|
-
}
|
|
323
|
-
catch (err) {
|
|
324
|
-
// Last-resort catch: bundle compilers should never throw (they
|
|
325
|
-
// wrap their internals), but if they do we must still respond with valid
|
|
326
|
-
// JavaScript so the browser gets a parseable error rather than HTML.
|
|
327
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
328
|
-
out = {
|
|
329
|
-
body: `throw new Error(${JSON.stringify(`[Nexus] Unexpected island error: ${msg}`)});`,
|
|
330
|
-
status: 500,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
181
|
+
const out = await compileIslandClientBundle(opts.root, url);
|
|
333
182
|
res.writeHead(out.status, {
|
|
334
183
|
'content-type': 'application/javascript; charset=utf-8',
|
|
335
|
-
'cache-control': 'no-store',
|
|
184
|
+
'cache-control': dev ? 'no-store' : 'public, max-age=120',
|
|
336
185
|
});
|
|
337
186
|
res.end(out.body);
|
|
338
187
|
return;
|
|
@@ -353,35 +202,13 @@ export async function createNexusServer(opts) {
|
|
|
353
202
|
return;
|
|
354
203
|
}
|
|
355
204
|
// ── Aggregated scoped CSS from all .nx files under src/
|
|
356
|
-
// ── Aggregated scoped CSS from all .nx files under src/
|
|
357
205
|
if (url.pathname === '/_nexus/styles.css' && method === 'GET') {
|
|
358
206
|
try {
|
|
359
207
|
const css = await buildAggregatedNxStylesheet(opts.root);
|
|
360
|
-
|
|
361
|
-
// Conditional GET (If-None-Match) — eliminates FOUC on hard refresh
|
|
362
|
-
// (Cmd+R / Ctrl+F5). The browser caches the stylesheet and on each
|
|
363
|
-
// reload sends If-None-Match with the stored ETag. When the CSS has
|
|
364
|
-
// not changed the server responds 304 instantly (no recompilation
|
|
365
|
-
// needed) and the browser reuses its cached copy, applying styles
|
|
366
|
-
// before the first paint rather than waiting for a full roundtrip.
|
|
367
|
-
//
|
|
368
|
-
// `cache-control: no-cache` (not `no-store`) lets the browser keep
|
|
369
|
-
// the response in its local cache but forces it to revalidate before
|
|
370
|
-
// use. When a file changes, `bustAggregatedStylesCache()` resets the
|
|
371
|
-
// ETag, so the next request returns 200 with the updated CSS.
|
|
372
|
-
if (etag && req.headers['if-none-match'] === etag) {
|
|
373
|
-
res.writeHead(304, { 'cache-control': dev ? 'no-cache' : 'public, max-age=300', etag });
|
|
374
|
-
res.end();
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
const cacheControl = dev ? 'no-cache' : 'public, max-age=300';
|
|
378
|
-
const headers = {
|
|
208
|
+
res.writeHead(200, {
|
|
379
209
|
'content-type': 'text/css; charset=utf-8',
|
|
380
|
-
'cache-control':
|
|
381
|
-
};
|
|
382
|
-
if (etag)
|
|
383
|
-
headers['etag'] = etag;
|
|
384
|
-
res.writeHead(200, headers);
|
|
210
|
+
'cache-control': dev ? 'no-store' : 'public, max-age=300',
|
|
211
|
+
});
|
|
385
212
|
res.end(css);
|
|
386
213
|
}
|
|
387
214
|
catch (err) {
|
|
@@ -391,37 +218,6 @@ export async function createNexusServer(opts) {
|
|
|
391
218
|
}
|
|
392
219
|
return;
|
|
393
220
|
}
|
|
394
|
-
// ── Global CSS (Tailwind / PostCSS / plain CSS) ──────────────────────────
|
|
395
|
-
if (url.pathname === '/_nexus/global.css' && method === 'GET') {
|
|
396
|
-
try {
|
|
397
|
-
const css = await buildGlobalStylesheet(opts.root, opts.cssEntry);
|
|
398
|
-
if (css === null) {
|
|
399
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
400
|
-
res.end('Not found');
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
const etag = getGlobalCssETag();
|
|
404
|
-
if (etag && req.headers['if-none-match'] === etag) {
|
|
405
|
-
res.writeHead(304, { 'cache-control': dev ? 'no-cache' : 'public, max-age=300', etag });
|
|
406
|
-
res.end();
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
const headers = {
|
|
410
|
-
'content-type': 'text/css; charset=utf-8',
|
|
411
|
-
'cache-control': dev ? 'no-cache' : 'public, max-age=300',
|
|
412
|
-
};
|
|
413
|
-
if (etag)
|
|
414
|
-
headers['etag'] = etag;
|
|
415
|
-
res.writeHead(200, headers);
|
|
416
|
-
res.end(css);
|
|
417
|
-
}
|
|
418
|
-
catch (err) {
|
|
419
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
420
|
-
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
421
|
-
res.end(`[Nexus] Failed to build global styles: ${msg}`);
|
|
422
|
-
}
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
221
|
// ── SPA navigation JSON (/_nexus/navigate?path=…) — must run before SSR matchRoute
|
|
426
222
|
if (url.pathname === '/_nexus/navigate' && method === 'GET') {
|
|
427
223
|
const request = nodeToWebRequest(req);
|
|
@@ -429,32 +225,6 @@ export async function createNexusServer(opts) {
|
|
|
429
225
|
await webToNodeResponse(response, res, sec);
|
|
430
226
|
return;
|
|
431
227
|
}
|
|
432
|
-
// ── Custom mounts (GraphQL, webhooks, etc.) ──────────────────────────────
|
|
433
|
-
// Evaluated before static files so handlers can shadow public/ assets.
|
|
434
|
-
for (const mount of opts.mounts ?? []) {
|
|
435
|
-
const allowedMethods = mount.methods
|
|
436
|
-
? mount.methods.map(m => m.toUpperCase())
|
|
437
|
-
: null; // null = allow all
|
|
438
|
-
if (allowedMethods && !allowedMethods.includes(method))
|
|
439
|
-
continue;
|
|
440
|
-
const pathname = url.pathname;
|
|
441
|
-
const matches = pathname === mount.path || pathname.startsWith(mount.path + '/');
|
|
442
|
-
if (!matches)
|
|
443
|
-
continue;
|
|
444
|
-
const request = await incomingMessageToWebRequest(req);
|
|
445
|
-
const ctx = createContext(request, {}, cspNonce ?? '');
|
|
446
|
-
try {
|
|
447
|
-
const response = await mount.handler(request, ctx);
|
|
448
|
-
await webToNodeResponse(response, res, sec);
|
|
449
|
-
}
|
|
450
|
-
catch (err) {
|
|
451
|
-
if (dev)
|
|
452
|
-
console.error(`[Nexus] Mount handler error (${mount.path}):`, err);
|
|
453
|
-
res.writeHead(500, sec({ 'content-type': 'application/json' }));
|
|
454
|
-
res.end(JSON.stringify({ error: 'Internal Server Error', status: 500 }));
|
|
455
|
-
}
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
228
|
// ── Static files ────────────────────────────────────────────────────────
|
|
459
229
|
// Browsers still request /favicon.ico and /apple-touch-icon.png even when the
|
|
460
230
|
// app only ships favicon.svg — avoid noisy 404s by falling back to SVG.
|
|
@@ -478,127 +248,21 @@ export async function createNexusServer(opts) {
|
|
|
478
248
|
}
|
|
479
249
|
const staticResult = await serveStatic(url.pathname, publicDir);
|
|
480
250
|
if (staticResult) {
|
|
481
|
-
|
|
482
|
-
// (Tailwind output, sourcemaps, big SVGs) on every Cmd+R. Without
|
|
483
|
-
// ETag/Last-Modified the browser cannot revalidate, must re-download
|
|
484
|
-
// the full body, and renders the page unstyled while it waits.
|
|
485
|
-
const ifNoneMatch = req.headers['if-none-match'];
|
|
486
|
-
const ifModifiedSince = req.headers['if-modified-since'];
|
|
487
|
-
const notModified = (typeof ifNoneMatch === 'string' && ifNoneMatch === staticResult.etag) ||
|
|
488
|
-
(typeof ifModifiedSince === 'string' && ifModifiedSince === staticResult.lastModified);
|
|
489
|
-
if (notModified) {
|
|
490
|
-
res.writeHead(304, {
|
|
491
|
-
etag: staticResult.etag,
|
|
492
|
-
'last-modified': staticResult.lastModified,
|
|
493
|
-
'cache-control': dev ? 'no-cache' : 'public, max-age=0, must-revalidate',
|
|
494
|
-
});
|
|
495
|
-
res.end();
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
res.writeHead(200, {
|
|
499
|
-
'content-type': staticResult.mime,
|
|
500
|
-
etag: staticResult.etag,
|
|
501
|
-
'last-modified': staticResult.lastModified,
|
|
502
|
-
// `no-cache` (not `no-store`) lets the browser keep a copy and
|
|
503
|
-
// revalidate via If-None-Match — most reloads return 304 instantly.
|
|
504
|
-
'cache-control': dev ? 'no-cache' : 'public, max-age=0, must-revalidate',
|
|
505
|
-
});
|
|
251
|
+
res.writeHead(200, { 'content-type': staticResult.mime });
|
|
506
252
|
res.end(staticResult.content);
|
|
507
253
|
return;
|
|
508
254
|
}
|
|
509
255
|
// ── SSR routing ─────────────────────────────────────────────────────────
|
|
510
256
|
const matched = matchRoute(url.pathname, manifest);
|
|
511
257
|
if (!matched) {
|
|
512
|
-
// ── Fallback proxy to legacy backend ──────────────────────────────────
|
|
513
|
-
if (opts.fallbackProxy) {
|
|
514
|
-
try {
|
|
515
|
-
const targetUrl = new URL(url.pathname + url.search, opts.fallbackProxy);
|
|
516
|
-
let body = null;
|
|
517
|
-
if (method !== 'GET' && method !== 'HEAD') {
|
|
518
|
-
const chunks = [];
|
|
519
|
-
await new Promise((resolve, reject) => {
|
|
520
|
-
req.on('data', (chunk) => chunks.push(chunk));
|
|
521
|
-
req.on('end', () => resolve());
|
|
522
|
-
req.on('error', reject);
|
|
523
|
-
});
|
|
524
|
-
body = chunks.length > 0 ? new Uint8Array(Buffer.concat(chunks)) : null;
|
|
525
|
-
}
|
|
526
|
-
const proxyReq = await fetch(targetUrl.toString(), {
|
|
527
|
-
method,
|
|
528
|
-
headers: Object.fromEntries(Object.entries(req.headers)
|
|
529
|
-
.filter(([k]) => {
|
|
530
|
-
const key = k.toLowerCase();
|
|
531
|
-
if (key === 'host')
|
|
532
|
-
return false;
|
|
533
|
-
if (key === 'connection')
|
|
534
|
-
return false;
|
|
535
|
-
if (key === 'keep-alive')
|
|
536
|
-
return false;
|
|
537
|
-
if (key === 'proxy-authenticate')
|
|
538
|
-
return false;
|
|
539
|
-
if (key === 'proxy-authorization')
|
|
540
|
-
return false;
|
|
541
|
-
if (key === 'te')
|
|
542
|
-
return false;
|
|
543
|
-
if (key === 'trailer')
|
|
544
|
-
return false;
|
|
545
|
-
if (key === 'transfer-encoding')
|
|
546
|
-
return false;
|
|
547
|
-
if (key === 'upgrade')
|
|
548
|
-
return false;
|
|
549
|
-
if (key === 'content-length')
|
|
550
|
-
return false;
|
|
551
|
-
return true;
|
|
552
|
-
})
|
|
553
|
-
.map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : String(v ?? '')])),
|
|
554
|
-
body: body,
|
|
555
|
-
});
|
|
556
|
-
const proxyHeaders = {};
|
|
557
|
-
proxyReq.headers.forEach((value, key) => {
|
|
558
|
-
if (key.toLowerCase() === 'set-cookie') {
|
|
559
|
-
const existing = proxyHeaders[key];
|
|
560
|
-
if (Array.isArray(existing))
|
|
561
|
-
existing.push(value);
|
|
562
|
-
else if (existing)
|
|
563
|
-
proxyHeaders[key] = [existing, value];
|
|
564
|
-
else
|
|
565
|
-
proxyHeaders[key] = value;
|
|
566
|
-
}
|
|
567
|
-
else {
|
|
568
|
-
proxyHeaders[key] = value;
|
|
569
|
-
}
|
|
570
|
-
});
|
|
571
|
-
res.writeHead(proxyReq.status, proxyHeaders);
|
|
572
|
-
if (proxyReq.body) {
|
|
573
|
-
// Safe cast: incoming proxy body from undici/fetch is compatible with Node Readable in this context
|
|
574
|
-
Readable.fromWeb(proxyReq.body).pipe(res);
|
|
575
|
-
}
|
|
576
|
-
else {
|
|
577
|
-
res.end();
|
|
578
|
-
}
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
catch (err) {
|
|
582
|
-
if (dev)
|
|
583
|
-
console.error('[Nexus] Fallback proxy error:', err);
|
|
584
|
-
res.writeHead(502, sec({ 'content-type': 'text/html' }));
|
|
585
|
-
res.end('<h1>502 Bad Gateway</h1><p>Legacy backend unavailable</p>');
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
258
|
res.writeHead(404, sec({ 'content-type': 'text/html' }));
|
|
590
|
-
|
|
259
|
+
const request = nodeToWebRequest(req);
|
|
260
|
+
const ctx = createContext(request, {});
|
|
261
|
+
res.end(await notFoundPage(url.pathname, dev, routesDir, ctx, renderOpts));
|
|
591
262
|
return;
|
|
592
263
|
}
|
|
593
264
|
const request = nodeToWebRequest(req);
|
|
594
|
-
const
|
|
595
|
-
const tenant = tenancyCfg ? resolveTenant(request, tenancyCfg, getVaultSecretsMap()) : null;
|
|
596
|
-
const ctx = createContext(request, matched.params, cspNonce ?? '');
|
|
597
|
-
if (tenant) {
|
|
598
|
-
ctx.locals['tenant'] = tenant;
|
|
599
|
-
ctx.locals['tenantId'] = tenant.id;
|
|
600
|
-
ctx.secrets = getTenantVaultSecretsMap(tenant.id, tenancyCfg?.vaultIsolation ?? 'strict');
|
|
601
|
-
}
|
|
265
|
+
const ctx = createContext(request, matched.params);
|
|
602
266
|
try {
|
|
603
267
|
if (opts.streamingPretext === true && method === 'GET') {
|
|
604
268
|
_cacheStrategy = 'streaming-no-store';
|
|
@@ -606,11 +270,9 @@ export async function createNexusServer(opts) {
|
|
|
606
270
|
await pipeToNodeResponse(streamRes, res, sec);
|
|
607
271
|
return;
|
|
608
272
|
}
|
|
609
|
-
const
|
|
610
|
-
const result = await renderRoute(matched, ctx, requestRenderOpts);
|
|
273
|
+
const result = await renderRoute(matched, ctx, renderOpts);
|
|
611
274
|
_cacheStrategy = result.headers['x-nexus-cache-strategy'];
|
|
612
|
-
|
|
613
|
-
res.writeHead(result.status, htmlHeaders);
|
|
275
|
+
res.writeHead(result.status, sec(result.headers));
|
|
614
276
|
res.end(result.html);
|
|
615
277
|
}
|
|
616
278
|
catch (err) {
|
|
@@ -621,7 +283,7 @@ export async function createNexusServer(opts) {
|
|
|
621
283
|
}
|
|
622
284
|
if (err instanceof NotFoundSignal) {
|
|
623
285
|
res.writeHead(404, sec({ 'content-type': 'text/html' }));
|
|
624
|
-
res.end(notFoundPage(url.pathname, dev));
|
|
286
|
+
res.end(await notFoundPage(url.pathname, dev, routesDir, ctx, renderOpts));
|
|
625
287
|
return;
|
|
626
288
|
}
|
|
627
289
|
if (dev) {
|
|
@@ -640,21 +302,6 @@ export async function createNexusServer(opts) {
|
|
|
640
302
|
listen() {
|
|
641
303
|
return new Promise((resolve, reject) => {
|
|
642
304
|
void (async () => {
|
|
643
|
-
// Fail hard in production when NEXUS_SECRET is absent or is the
|
|
644
|
-
// well-known dev placeholder. A predictable secret lets anyone forge
|
|
645
|
-
// valid CSRF tokens, bypass replay protection, and hijack sessions.
|
|
646
|
-
const envSecret = process.env['NEXUS_SECRET'];
|
|
647
|
-
if (!dev) {
|
|
648
|
-
if (!envSecret || envSecret === 'nexus-dev-secret-change-me') {
|
|
649
|
-
throw new Error('[Nexus Security] NEXUS_SECRET is not set (or is the insecure dev default). ' +
|
|
650
|
-
'Set NEXUS_SECRET to a random 32+ character secret in your production environment ' +
|
|
651
|
-
'before starting the server. The server refuses to start without it.');
|
|
652
|
-
}
|
|
653
|
-
if (envSecret.length < 32) {
|
|
654
|
-
throw new Error(`[Nexus Security] NEXUS_SECRET is too short (${envSecret.length} chars). ` +
|
|
655
|
-
'Use at least 32 random characters (e.g. openssl rand -base64 32).');
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
305
|
try {
|
|
659
306
|
nexusVault.seedFromProcessEnv();
|
|
660
307
|
await preloadRegisteredServerActions(opts.root, dev);
|
|
@@ -663,23 +310,6 @@ export async function createNexusServer(opts) {
|
|
|
663
310
|
catch (err) {
|
|
664
311
|
console.error('[Nexus] Server action preload failed:', err);
|
|
665
312
|
}
|
|
666
|
-
// Pre-warm the aggregated CSS cache in dev mode so that the very
|
|
667
|
-
// first page load (and Cmd+R with "Disable cache" in DevTools) does
|
|
668
|
-
// not stall waiting for CSS compilation. The build runs in the
|
|
669
|
-
// background — listen() resolves immediately while the CSS compiles
|
|
670
|
-
// concurrently. Any errors are swallowed; the first CSS request
|
|
671
|
-
// will fall back to a normal on-demand build.
|
|
672
|
-
if (dev) {
|
|
673
|
-
buildAggregatedNxStylesheet(opts.root).catch(() => { });
|
|
674
|
-
// Pre-warm global CSS and update renderOpts so SSR includes the link.
|
|
675
|
-
buildGlobalStylesheet(opts.root, opts.cssEntry)
|
|
676
|
-
.then((css) => {
|
|
677
|
-
if (css !== null) {
|
|
678
|
-
renderOpts.assets.styles = ['/_nexus/global.css', '/_nexus/styles.css'];
|
|
679
|
-
}
|
|
680
|
-
})
|
|
681
|
-
.catch(() => { });
|
|
682
|
-
}
|
|
683
313
|
server.listen(port, () => resolve());
|
|
684
314
|
})().catch(reject);
|
|
685
315
|
});
|
|
@@ -688,47 +318,10 @@ export async function createNexusServer(opts) {
|
|
|
688
318
|
async reload() {
|
|
689
319
|
bumpDevReloadGeneration();
|
|
690
320
|
bustAggregatedStylesCache();
|
|
691
|
-
bustGlobalStylesCache();
|
|
692
321
|
manifest = await buildRouteManifest(routesDir);
|
|
693
322
|
if (dev) {
|
|
694
323
|
await preloadRegisteredServerActions(opts.root, true);
|
|
695
324
|
refreshShieldAllowlist(opts.root, true);
|
|
696
|
-
// Re-evaluate whether a global CSS entry appeared/disappeared so SSR
|
|
697
|
-
// links stay in sync with the filesystem.
|
|
698
|
-
try {
|
|
699
|
-
const hasGlobal = (await buildGlobalStylesheet(opts.root, opts.cssEntry)) !== null;
|
|
700
|
-
renderOpts.assets.styles = hasGlobal
|
|
701
|
-
? ['/_nexus/global.css', '/_nexus/styles.css']
|
|
702
|
-
: ['/_nexus/styles.css'];
|
|
703
|
-
}
|
|
704
|
-
catch {
|
|
705
|
-
renderOpts.assets.styles = ['/_nexus/styles.css'];
|
|
706
|
-
}
|
|
707
|
-
// Pre-warm the aggregated CSS cache BEFORE telling the browser to reload.
|
|
708
|
-
// Without this, the sequence is:
|
|
709
|
-
// 1. bustAggregatedStylesCache() empties the cache
|
|
710
|
-
// 2. broadcastDevHotReload() triggers location.reload() in the browser
|
|
711
|
-
// 3. Browser fetches HTML (fast) then immediately requests /_nexus/styles.css
|
|
712
|
-
// 4. Cache is empty → server must recompile all .nx files (slow, 50-200ms)
|
|
713
|
-
// 5. HTML paints first → FOUC until CSS arrives
|
|
714
|
-
//
|
|
715
|
-
// By awaiting the CSS build here, the cache is warm before the browser
|
|
716
|
-
// reloads. Step 4 becomes an instant cache-hit → styles apply before
|
|
717
|
-
// first paint, eliminating the flash of unstyled content.
|
|
718
|
-
try {
|
|
719
|
-
await buildAggregatedNxStylesheet(opts.root);
|
|
720
|
-
}
|
|
721
|
-
catch {
|
|
722
|
-
// CSS build failures are non-fatal — the browser will just get
|
|
723
|
-
// whatever partial CSS the next request produces.
|
|
724
|
-
}
|
|
725
|
-
// Also pre-warm global CSS (Tailwind/PostCSS) so SSR includes it.
|
|
726
|
-
try {
|
|
727
|
-
await buildGlobalStylesheet(opts.root, opts.cssEntry);
|
|
728
|
-
}
|
|
729
|
-
catch {
|
|
730
|
-
/* non-fatal */
|
|
731
|
-
}
|
|
732
325
|
broadcastDevHotReload();
|
|
733
326
|
}
|
|
734
327
|
},
|
|
@@ -777,23 +370,14 @@ async function webToNodeResponse(response, res, mergeHeaders) {
|
|
|
777
370
|
res.end(body);
|
|
778
371
|
}
|
|
779
372
|
async function serveStatic(pathname, publicDir) {
|
|
780
|
-
const
|
|
781
|
-
const safePath = resolve(join(root, pathname.replace(/^\/+/, '')));
|
|
782
|
-
// Prevent path-traversal: resolved path must be inside publicDir
|
|
783
|
-
if (safePath !== root && !safePath.startsWith(root + sep))
|
|
784
|
-
return null;
|
|
373
|
+
const safePath = join(publicDir, pathname.replace(/^\/+/, ''));
|
|
785
374
|
try {
|
|
786
375
|
const info = await stat(safePath);
|
|
787
376
|
if (!info.isFile())
|
|
788
377
|
return null;
|
|
789
378
|
const content = await readFile(safePath);
|
|
790
379
|
const mime = MIME_TYPES[extname(safePath)] ?? 'application/octet-stream';
|
|
791
|
-
|
|
792
|
-
// whenever the file is rewritten by an external watcher (e.g. Tailwind
|
|
793
|
-
// CLI, Vite's tw plugin, custom asset pipelines). Quoted per RFC 7232.
|
|
794
|
-
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`;
|
|
795
|
-
const lastModified = info.mtime.toUTCString();
|
|
796
|
-
return { content, mime, etag, lastModified };
|
|
380
|
+
return { content, mime };
|
|
797
381
|
}
|
|
798
382
|
catch {
|
|
799
383
|
return null;
|
|
@@ -802,7 +386,28 @@ async function serveStatic(pathname, publicDir) {
|
|
|
802
386
|
function serverErrorPage(err, dev) {
|
|
803
387
|
return devErrorHtmlPage({ context: '500 — unhandled', err, dev });
|
|
804
388
|
}
|
|
805
|
-
function notFoundPage(pathname, dev) {
|
|
389
|
+
async function notFoundPage(pathname, dev, routesRoot, ctx, renderOpts) {
|
|
390
|
+
const boundary = await findNotFoundBoundary(join(routesRoot, '_'), routesRoot);
|
|
391
|
+
if (boundary) {
|
|
392
|
+
try {
|
|
393
|
+
const mod = await loadRouteModule(boundary, {
|
|
394
|
+
dev,
|
|
395
|
+
appRoot: renderOpts.appRoot,
|
|
396
|
+
pattern: '/not-found',
|
|
397
|
+
});
|
|
398
|
+
if (typeof mod.render === 'function') {
|
|
399
|
+
const result = await mod.render(ctx);
|
|
400
|
+
const html = result.html ?? '';
|
|
401
|
+
if (/^<\s*html[\s>]/i.test(html.trimStart()) || /^<!DOCTYPE/i.test(html.trimStart())) {
|
|
402
|
+
return html;
|
|
403
|
+
}
|
|
404
|
+
return wrapWithDocument(html, renderOpts, [], 0, null);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
console.error('[Nexus] not-found.nx render error:', err);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
806
411
|
return `<!DOCTYPE html><html><body style="font-family:monospace;padding:2rem;background:#0a0a0f;color:#e8e8f0">
|
|
807
412
|
<h1 style="color:#00d4aa">◆ Nexus — 404</h1>
|
|
808
413
|
<p>No route found for <code style="color:#ff3e00">${pathname}</code></p>
|