@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.
Files changed (80) hide show
  1. package/dist/actions.d.ts +71 -11
  2. package/dist/actions.d.ts.map +1 -1
  3. package/dist/actions.js +442 -51
  4. package/dist/actions.js.map +1 -1
  5. package/dist/build-id.d.ts +14 -0
  6. package/dist/build-id.d.ts.map +1 -0
  7. package/dist/build-id.js +40 -0
  8. package/dist/build-id.js.map +1 -0
  9. package/dist/context.d.ts +38 -4
  10. package/dist/context.d.ts.map +1 -1
  11. package/dist/context.js +13 -3
  12. package/dist/context.js.map +1 -1
  13. package/dist/csrf.d.ts +16 -2
  14. package/dist/csrf.d.ts.map +1 -1
  15. package/dist/csrf.js +68 -30
  16. package/dist/csrf.js.map +1 -1
  17. package/dist/dev-assets.d.ts +31 -0
  18. package/dist/dev-assets.d.ts.map +1 -1
  19. package/dist/dev-assets.js +372 -38
  20. package/dist/dev-assets.js.map +1 -1
  21. package/dist/dev-assets.test.d.ts +2 -0
  22. package/dist/dev-assets.test.d.ts.map +1 -0
  23. package/dist/dev-error-html.d.ts.map +1 -1
  24. package/dist/dev-error-html.js +24 -0
  25. package/dist/dev-error-html.js.map +1 -1
  26. package/dist/devradar.d.ts +1 -1
  27. package/dist/devradar.d.ts.map +1 -1
  28. package/dist/devradar.js.map +1 -1
  29. package/dist/head-renderer.test.d.ts +2 -0
  30. package/dist/head-renderer.test.d.ts.map +1 -0
  31. package/dist/head-renderer.test.js +78 -0
  32. package/dist/head-renderer.test.js.map +1 -0
  33. package/dist/index.d.ts +97 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +442 -47
  36. package/dist/index.js.map +1 -1
  37. package/dist/legacy-wrapper.d.ts +88 -0
  38. package/dist/legacy-wrapper.d.ts.map +1 -0
  39. package/dist/legacy-wrapper.js +104 -0
  40. package/dist/legacy-wrapper.js.map +1 -0
  41. package/dist/lib-assets.d.ts +5 -0
  42. package/dist/lib-assets.d.ts.map +1 -0
  43. package/dist/lib-assets.js +95 -0
  44. package/dist/lib-assets.js.map +1 -0
  45. package/dist/load-module.d.ts +6 -0
  46. package/dist/load-module.d.ts.map +1 -1
  47. package/dist/load-module.js +40 -53
  48. package/dist/load-module.js.map +1 -1
  49. package/dist/metadata.d.ts +95 -0
  50. package/dist/metadata.d.ts.map +1 -0
  51. package/dist/metadata.js +132 -0
  52. package/dist/metadata.js.map +1 -0
  53. package/dist/navigate.d.ts +0 -5
  54. package/dist/navigate.d.ts.map +1 -1
  55. package/dist/navigate.js +0 -1
  56. package/dist/navigate.js.map +1 -1
  57. package/dist/rate-limit.d.ts.map +1 -1
  58. package/dist/rate-limit.js +27 -14
  59. package/dist/rate-limit.js.map +1 -1
  60. package/dist/renderer.d.ts +27 -7
  61. package/dist/renderer.d.ts.map +1 -1
  62. package/dist/renderer.js +152 -25
  63. package/dist/renderer.js.map +1 -1
  64. package/dist/renderer.test.d.ts +2 -0
  65. package/dist/renderer.test.d.ts.map +1 -0
  66. package/dist/renderer.test.js +251 -0
  67. package/dist/renderer.test.js.map +1 -0
  68. package/dist/streaming.d.ts +3 -3
  69. package/dist/streaming.d.ts.map +1 -1
  70. package/dist/streaming.js +33 -13
  71. package/dist/streaming.js.map +1 -1
  72. package/dist/tenancy.d.ts +17 -0
  73. package/dist/tenancy.d.ts.map +1 -0
  74. package/dist/tenancy.js +132 -0
  75. package/dist/tenancy.js.map +1 -0
  76. package/dist/tenancy.test.d.ts +2 -0
  77. package/dist/tenancy.test.d.ts.map +1 -0
  78. package/dist/tenancy.test.js +38 -0
  79. package/dist/tenancy.test.js.map +1 -0
  80. 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, wrapWithDocument } from './renderer.js';
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 (changelog v0.5) CSP nonces are a future enhancement. */
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 sec = (h) => mergeHardenedHeaders(h, opts.security?.hardened, dev);
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
- const out = await compileIslandClientBundle(opts.root, url);
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': dev ? 'no-store' : 'public, max-age=120',
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
- res.writeHead(200, {
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': dev ? 'no-store' : 'public, max-age=300',
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
- res.writeHead(200, { 'content-type': staticResult.mime });
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
- const request = nodeToWebRequest(req);
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 ctx = createContext(request, matched.params);
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 result = await renderRoute(matched, ctx, renderOpts);
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
- res.writeHead(result.status, sec(result.headers));
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(await notFoundPage(url.pathname, dev, routesDir, ctx, renderOpts));
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 safePath = join(publicDir, pathname.replace(/^\/+/, ''));
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
- return { content, mime };
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
- 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
- }
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>