@nexus_js/server 0.9.29 → 0.9.30
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 +71 -11
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +442 -51
- package/dist/actions.js.map +1 -1
- package/dist/build-id.d.ts +14 -0
- package/dist/build-id.d.ts.map +1 -0
- package/dist/build-id.js +40 -0
- package/dist/build-id.js.map +1 -0
- package/dist/context.d.ts +38 -4
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +13 -3
- package/dist/context.js.map +1 -1
- package/dist/csrf.d.ts +16 -2
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +68 -30
- package/dist/csrf.js.map +1 -1
- package/dist/dev-assets.d.ts +31 -0
- package/dist/dev-assets.d.ts.map +1 -1
- package/dist/dev-assets.js +372 -38
- package/dist/dev-assets.js.map +1 -1
- package/dist/dev-assets.test.d.ts +2 -0
- package/dist/dev-assets.test.d.ts.map +1 -0
- package/dist/dev-error-html.d.ts.map +1 -1
- package/dist/dev-error-html.js +24 -0
- 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/head-renderer.test.d.ts +2 -0
- package/dist/head-renderer.test.d.ts.map +1 -0
- package/dist/head-renderer.test.js +78 -0
- package/dist/head-renderer.test.js.map +1 -0
- package/dist/index.d.ts +97 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +442 -47
- package/dist/index.js.map +1 -1
- package/dist/legacy-wrapper.d.ts +88 -0
- package/dist/legacy-wrapper.d.ts.map +1 -0
- package/dist/legacy-wrapper.js +104 -0
- package/dist/legacy-wrapper.js.map +1 -0
- package/dist/lib-assets.d.ts +5 -0
- package/dist/lib-assets.d.ts.map +1 -0
- package/dist/lib-assets.js +95 -0
- package/dist/lib-assets.js.map +1 -0
- package/dist/load-module.d.ts +6 -0
- package/dist/load-module.d.ts.map +1 -1
- package/dist/load-module.js +40 -53
- package/dist/load-module.js.map +1 -1
- package/dist/metadata.d.ts +95 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +132 -0
- package/dist/metadata.js.map +1 -0
- package/dist/navigate.d.ts +0 -5
- package/dist/navigate.d.ts.map +1 -1
- package/dist/navigate.js +0 -1
- package/dist/navigate.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +27 -14
- package/dist/rate-limit.js.map +1 -1
- package/dist/renderer.d.ts +27 -7
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +152 -25
- package/dist/renderer.js.map +1 -1
- package/dist/renderer.test.d.ts +2 -0
- package/dist/renderer.test.d.ts.map +1 -0
- package/dist/renderer.test.js +251 -0
- package/dist/renderer.test.js.map +1 -0
- package/dist/streaming.d.ts +3 -3
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +33 -13
- package/dist/streaming.js.map +1 -1
- package/dist/tenancy.d.ts +17 -0
- package/dist/tenancy.d.ts.map +1 -0
- package/dist/tenancy.js +132 -0
- package/dist/tenancy.js.map +1 -0
- package/dist/tenancy.test.d.ts +2 -0
- package/dist/tenancy.test.d.ts.map +1 -0
- package/dist/tenancy.test.js +38 -0
- package/dist/tenancy.test.js.map +1 -0
- package/package.json +26 -8
package/dist/index.js
CHANGED
|
@@ -4,30 +4,60 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
6
|
import { readFile, stat } from 'node:fs/promises';
|
|
7
|
-
import { join, extname } from 'node:path';
|
|
7
|
+
import { join, extname, resolve, sep } from 'node:path';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { Readable } from 'node:stream';
|
|
8
10
|
import { buildRouteManifest, matchRoute } from '@nexus_js/router';
|
|
9
11
|
import { handleActionRequest } from './actions.js';
|
|
10
12
|
import { handleSSERequestNode, isConnectRequest, topicFromUrl } from '@nexus_js/connect';
|
|
11
|
-
import { buildAggregatedNxStylesheet, bustAggregatedStylesCache, compileIslandClientBundle, isIslandClientRequest, tryServeRuntimeAsset, } from './dev-assets.js';
|
|
13
|
+
import { buildAggregatedNxStylesheet, buildGlobalStylesheet, bustAggregatedStylesCache, bustGlobalStylesCache, compileIslandClientBundle, getAggregatedCssETag, getGlobalCssETag, isIslandClientRequest, tryServeRuntimeAsset, } from './dev-assets.js';
|
|
14
|
+
import { tryServeLibAsset } from './dev-assets.js';
|
|
12
15
|
import { devErrorHtmlPage } from './dev-error-html.js';
|
|
13
16
|
import { broadcastDevHotReload, subscribeDevHotClient } from './dev-hot.js';
|
|
14
|
-
import { renderRoute, renderRouteStreaming
|
|
17
|
+
import { renderRoute, renderRouteStreaming } from './renderer.js';
|
|
15
18
|
import { pipeToNodeResponse } from './streaming.js';
|
|
16
|
-
import { findNotFoundBoundary } from './error-boundary.js';
|
|
17
|
-
import { loadRouteModule } from './load-module.js';
|
|
18
19
|
import { handleNavigationRequest } from './navigate.js';
|
|
19
20
|
import { bumpDevReloadGeneration, preloadRegisteredServerActions } from './load-module.js';
|
|
20
21
|
import { createContext, RedirectSignal, NotFoundSignal } from './context.js';
|
|
21
|
-
import { nexusVault } from '@nexus_js/security';
|
|
22
|
+
import { nexusVault, getTenantVaultSecretsMap, getVaultSecretsMap } from '@nexus_js/security';
|
|
22
23
|
import { handleDevVaultPost } from './dev-vault.js';
|
|
24
|
+
import { resolveTenant } from './tenancy.js';
|
|
23
25
|
import { refreshShieldAllowlist, isActionBlockedByShield, setShieldLite, } from './shield-runtime.js';
|
|
26
|
+
import { loadAndCacheNexusBuildId } from './build-id.js';
|
|
24
27
|
import { emitDevRadar } from './devradar.js';
|
|
25
28
|
export { STUDIO_DEFAULT_PORT } from './constants.js';
|
|
26
|
-
export { createAction, registerAction, ActionError, getRegisteredActionNames } from './actions.js';
|
|
29
|
+
export { createAction, registerAction, ActionError, getRegisteredActionNames, isInternalUrl, isSafeUrl, } from './actions.js';
|
|
30
|
+
export { loadAndCacheNexusBuildId, getExpectedNexusBuildId } from './build-id.js';
|
|
27
31
|
export { createContext } from './context.js';
|
|
28
32
|
export { nexusVault } from '@nexus_js/security';
|
|
33
|
+
export { resolveTenant } from './tenancy.js';
|
|
29
34
|
export { mergeRoutePretext } from './renderer.js';
|
|
35
|
+
export { defineMetadata, escapeHtml } from './metadata.js';
|
|
36
|
+
export { defineHead, useHead, flushHead, renderHeadToString } from '@nexus_js/head';
|
|
30
37
|
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
|
+
}
|
|
31
61
|
/** Merge ctx response headers (Set-Cookie, etc.) with redirect Location. */
|
|
32
62
|
function redirectHeadersForWriteHead(err) {
|
|
33
63
|
const setCookies = [];
|
|
@@ -48,8 +78,8 @@ function redirectHeadersForWriteHead(err) {
|
|
|
48
78
|
}
|
|
49
79
|
return out;
|
|
50
80
|
}
|
|
51
|
-
/** Merge Hardened Mode headers
|
|
52
|
-
function mergeHardenedHeaders(headers, hardened, dev) {
|
|
81
|
+
/** Merge Hardened Mode headers — includes per-request CSP nonce when hardened. */
|
|
82
|
+
function mergeHardenedHeaders(headers, hardened, dev, cspNonce, cspOptions) {
|
|
53
83
|
const h = {};
|
|
54
84
|
for (const [k, v] of Object.entries(headers)) {
|
|
55
85
|
if (v !== undefined)
|
|
@@ -65,6 +95,34 @@ function mergeHardenedHeaders(headers, hardened, dev) {
|
|
|
65
95
|
if (!dev) {
|
|
66
96
|
h['strict-transport-security'] = 'max-age=31536000; includeSubDomains';
|
|
67
97
|
}
|
|
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
|
+
}
|
|
68
126
|
return h;
|
|
69
127
|
}
|
|
70
128
|
const MIME_TYPES = {
|
|
@@ -90,9 +148,11 @@ export async function createNexusServer(opts) {
|
|
|
90
148
|
const publicDir = join(opts.root, opts.publicDir ?? 'public');
|
|
91
149
|
setShieldLite(opts.security?.shieldLite === true);
|
|
92
150
|
let manifest = await buildRouteManifest(routesDir);
|
|
151
|
+
const nexusBuildId = loadAndCacheNexusBuildId(opts.root);
|
|
93
152
|
const renderOpts = {
|
|
94
153
|
dev,
|
|
95
154
|
appRoot: opts.root,
|
|
155
|
+
...(nexusBuildId ? { buildId: nexusBuildId } : {}),
|
|
96
156
|
...(opts.browserImportMap ? { browserImportMap: opts.browserImportMap } : {}),
|
|
97
157
|
assets: {
|
|
98
158
|
/** ESM entry + chunks served from @nexus_js/runtime/dist via /_nexus/rt/* */
|
|
@@ -101,11 +161,32 @@ export async function createNexusServer(opts) {
|
|
|
101
161
|
islands: new Map(),
|
|
102
162
|
},
|
|
103
163
|
};
|
|
104
|
-
const
|
|
164
|
+
const hardened = opts.security?.hardened === true;
|
|
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);
|
|
105
168
|
const server = createServer(async (req, res) => {
|
|
106
169
|
const t0 = Date.now();
|
|
107
170
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
108
171
|
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;
|
|
109
190
|
// Capture cache strategy before headers are flushed, then call onRequest hook
|
|
110
191
|
let _cacheStrategy;
|
|
111
192
|
let _isAction = false;
|
|
@@ -124,12 +205,27 @@ export async function createNexusServer(opts) {
|
|
|
124
205
|
});
|
|
125
206
|
}
|
|
126
207
|
// ── 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.
|
|
127
211
|
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
|
+
}
|
|
128
218
|
subscribeDevHotClient(req, res);
|
|
129
219
|
return;
|
|
130
220
|
}
|
|
131
221
|
// ── Vault-lite (dev) — hot-reload secrets without restart ───────────────
|
|
132
222
|
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
|
+
}
|
|
133
229
|
const request = await incomingMessageToWebRequest(req);
|
|
134
230
|
const response = await handleDevVaultPost(request);
|
|
135
231
|
await webToNodeResponse(response, res, sec);
|
|
@@ -166,6 +262,39 @@ export async function createNexusServer(opts) {
|
|
|
166
262
|
}
|
|
167
263
|
// ── Nexus Connect — SSE (/_nexus/connect/:topic) ────────────────────────
|
|
168
264
|
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
|
+
}
|
|
169
298
|
handleSSERequestNode(req, res, topicFromUrl(url));
|
|
170
299
|
return;
|
|
171
300
|
}
|
|
@@ -176,12 +305,34 @@ export async function createNexusServer(opts) {
|
|
|
176
305
|
res.end(rt.body);
|
|
177
306
|
return;
|
|
178
307
|
}
|
|
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
|
+
}
|
|
179
317
|
// ── Client island ESM (dynamic import target for <nexus-island>) ───────
|
|
180
318
|
if (isIslandClientRequest(url.pathname) && method === 'GET') {
|
|
181
|
-
|
|
319
|
+
let out;
|
|
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
|
+
}
|
|
182
333
|
res.writeHead(out.status, {
|
|
183
334
|
'content-type': 'application/javascript; charset=utf-8',
|
|
184
|
-
'cache-control':
|
|
335
|
+
'cache-control': 'no-store',
|
|
185
336
|
});
|
|
186
337
|
res.end(out.body);
|
|
187
338
|
return;
|
|
@@ -202,13 +353,35 @@ export async function createNexusServer(opts) {
|
|
|
202
353
|
return;
|
|
203
354
|
}
|
|
204
355
|
// ── Aggregated scoped CSS from all .nx files under src/
|
|
356
|
+
// ── Aggregated scoped CSS from all .nx files under src/
|
|
205
357
|
if (url.pathname === '/_nexus/styles.css' && method === 'GET') {
|
|
206
358
|
try {
|
|
207
359
|
const css = await buildAggregatedNxStylesheet(opts.root);
|
|
208
|
-
|
|
360
|
+
const etag = getAggregatedCssETag();
|
|
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 = {
|
|
209
379
|
'content-type': 'text/css; charset=utf-8',
|
|
210
|
-
'cache-control':
|
|
211
|
-
}
|
|
380
|
+
'cache-control': cacheControl,
|
|
381
|
+
};
|
|
382
|
+
if (etag)
|
|
383
|
+
headers['etag'] = etag;
|
|
384
|
+
res.writeHead(200, headers);
|
|
212
385
|
res.end(css);
|
|
213
386
|
}
|
|
214
387
|
catch (err) {
|
|
@@ -218,6 +391,37 @@ export async function createNexusServer(opts) {
|
|
|
218
391
|
}
|
|
219
392
|
return;
|
|
220
393
|
}
|
|
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
|
+
}
|
|
221
425
|
// ── SPA navigation JSON (/_nexus/navigate?path=…) — must run before SSR matchRoute
|
|
222
426
|
if (url.pathname === '/_nexus/navigate' && method === 'GET') {
|
|
223
427
|
const request = nodeToWebRequest(req);
|
|
@@ -225,6 +429,32 @@ export async function createNexusServer(opts) {
|
|
|
225
429
|
await webToNodeResponse(response, res, sec);
|
|
226
430
|
return;
|
|
227
431
|
}
|
|
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
|
+
}
|
|
228
458
|
// ── Static files ────────────────────────────────────────────────────────
|
|
229
459
|
// Browsers still request /favicon.ico and /apple-touch-icon.png even when the
|
|
230
460
|
// app only ships favicon.svg — avoid noisy 404s by falling back to SVG.
|
|
@@ -248,21 +478,127 @@ export async function createNexusServer(opts) {
|
|
|
248
478
|
}
|
|
249
479
|
const staticResult = await serveStatic(url.pathname, publicDir);
|
|
250
480
|
if (staticResult) {
|
|
251
|
-
|
|
481
|
+
// Conditional GET — eliminates re-downloading large static assets
|
|
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
|
+
});
|
|
252
506
|
res.end(staticResult.content);
|
|
253
507
|
return;
|
|
254
508
|
}
|
|
255
509
|
// ── SSR routing ─────────────────────────────────────────────────────────
|
|
256
510
|
const matched = matchRoute(url.pathname, manifest);
|
|
257
511
|
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
|
+
}
|
|
258
589
|
res.writeHead(404, sec({ 'content-type': 'text/html' }));
|
|
259
|
-
|
|
260
|
-
const ctx = createContext(request, {});
|
|
261
|
-
res.end(await notFoundPage(url.pathname, dev, routesDir, ctx, renderOpts));
|
|
590
|
+
res.end(notFoundPage(url.pathname, dev));
|
|
262
591
|
return;
|
|
263
592
|
}
|
|
264
593
|
const request = nodeToWebRequest(req);
|
|
265
|
-
const
|
|
594
|
+
const tenancyCfg = opts.tenancy;
|
|
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
|
+
}
|
|
266
602
|
try {
|
|
267
603
|
if (opts.streamingPretext === true && method === 'GET') {
|
|
268
604
|
_cacheStrategy = 'streaming-no-store';
|
|
@@ -270,9 +606,11 @@ export async function createNexusServer(opts) {
|
|
|
270
606
|
await pipeToNodeResponse(streamRes, res, sec);
|
|
271
607
|
return;
|
|
272
608
|
}
|
|
273
|
-
const
|
|
609
|
+
const requestRenderOpts = cspNonce ? { ...renderOpts, cspNonce } : renderOpts;
|
|
610
|
+
const result = await renderRoute(matched, ctx, requestRenderOpts);
|
|
274
611
|
_cacheStrategy = result.headers['x-nexus-cache-strategy'];
|
|
275
|
-
|
|
612
|
+
const htmlHeaders = mergeHardenedHeaders(result.headers, hardened, dev, cspNonce, cspConfig);
|
|
613
|
+
res.writeHead(result.status, htmlHeaders);
|
|
276
614
|
res.end(result.html);
|
|
277
615
|
}
|
|
278
616
|
catch (err) {
|
|
@@ -283,7 +621,7 @@ export async function createNexusServer(opts) {
|
|
|
283
621
|
}
|
|
284
622
|
if (err instanceof NotFoundSignal) {
|
|
285
623
|
res.writeHead(404, sec({ 'content-type': 'text/html' }));
|
|
286
|
-
res.end(
|
|
624
|
+
res.end(notFoundPage(url.pathname, dev));
|
|
287
625
|
return;
|
|
288
626
|
}
|
|
289
627
|
if (dev) {
|
|
@@ -302,6 +640,21 @@ export async function createNexusServer(opts) {
|
|
|
302
640
|
listen() {
|
|
303
641
|
return new Promise((resolve, reject) => {
|
|
304
642
|
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
|
+
}
|
|
305
658
|
try {
|
|
306
659
|
nexusVault.seedFromProcessEnv();
|
|
307
660
|
await preloadRegisteredServerActions(opts.root, dev);
|
|
@@ -310,6 +663,23 @@ export async function createNexusServer(opts) {
|
|
|
310
663
|
catch (err) {
|
|
311
664
|
console.error('[Nexus] Server action preload failed:', err);
|
|
312
665
|
}
|
|
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
|
+
}
|
|
313
683
|
server.listen(port, () => resolve());
|
|
314
684
|
})().catch(reject);
|
|
315
685
|
});
|
|
@@ -318,10 +688,47 @@ export async function createNexusServer(opts) {
|
|
|
318
688
|
async reload() {
|
|
319
689
|
bumpDevReloadGeneration();
|
|
320
690
|
bustAggregatedStylesCache();
|
|
691
|
+
bustGlobalStylesCache();
|
|
321
692
|
manifest = await buildRouteManifest(routesDir);
|
|
322
693
|
if (dev) {
|
|
323
694
|
await preloadRegisteredServerActions(opts.root, true);
|
|
324
695
|
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
|
+
}
|
|
325
732
|
broadcastDevHotReload();
|
|
326
733
|
}
|
|
327
734
|
},
|
|
@@ -370,14 +777,23 @@ async function webToNodeResponse(response, res, mergeHeaders) {
|
|
|
370
777
|
res.end(body);
|
|
371
778
|
}
|
|
372
779
|
async function serveStatic(pathname, publicDir) {
|
|
373
|
-
const
|
|
780
|
+
const root = resolve(publicDir);
|
|
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;
|
|
374
785
|
try {
|
|
375
786
|
const info = await stat(safePath);
|
|
376
787
|
if (!info.isFile())
|
|
377
788
|
return null;
|
|
378
789
|
const content = await readFile(safePath);
|
|
379
790
|
const mime = MIME_TYPES[extname(safePath)] ?? 'application/octet-stream';
|
|
380
|
-
|
|
791
|
+
// ETag derived from size + mtime — fast (no content hash) and changes
|
|
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 };
|
|
381
797
|
}
|
|
382
798
|
catch {
|
|
383
799
|
return null;
|
|
@@ -386,28 +802,7 @@ async function serveStatic(pathname, publicDir) {
|
|
|
386
802
|
function serverErrorPage(err, dev) {
|
|
387
803
|
return devErrorHtmlPage({ context: '500 — unhandled', err, dev });
|
|
388
804
|
}
|
|
389
|
-
|
|
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
|
-
}
|
|
805
|
+
function notFoundPage(pathname, dev) {
|
|
411
806
|
return `<!DOCTYPE html><html><body style="font-family:monospace;padding:2rem;background:#0a0a0f;color:#e8e8f0">
|
|
412
807
|
<h1 style="color:#00d4aa">◆ Nexus — 404</h1>
|
|
413
808
|
<p>No route found for <code style="color:#ff3e00">${pathname}</code></p>
|