@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.
Files changed (80) hide show
  1. package/dist/actions.d.ts +11 -71
  2. package/dist/actions.d.ts.map +1 -1
  3. package/dist/actions.js +51 -442
  4. package/dist/actions.js.map +1 -1
  5. package/dist/context.d.ts +4 -38
  6. package/dist/context.d.ts.map +1 -1
  7. package/dist/context.js +3 -13
  8. package/dist/context.js.map +1 -1
  9. package/dist/csrf.d.ts +2 -16
  10. package/dist/csrf.d.ts.map +1 -1
  11. package/dist/csrf.js +30 -68
  12. package/dist/csrf.js.map +1 -1
  13. package/dist/dev-assets.d.ts +0 -31
  14. package/dist/dev-assets.d.ts.map +1 -1
  15. package/dist/dev-assets.js +38 -372
  16. package/dist/dev-assets.js.map +1 -1
  17. package/dist/dev-error-html.d.ts.map +1 -1
  18. package/dist/dev-error-html.js +0 -24
  19. package/dist/dev-error-html.js.map +1 -1
  20. package/dist/devradar.d.ts +1 -1
  21. package/dist/devradar.d.ts.map +1 -1
  22. package/dist/devradar.js.map +1 -1
  23. package/dist/index.d.ts +2 -97
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +47 -442
  26. package/dist/index.js.map +1 -1
  27. package/dist/load-module.d.ts +0 -6
  28. package/dist/load-module.d.ts.map +1 -1
  29. package/dist/load-module.js +53 -40
  30. package/dist/load-module.js.map +1 -1
  31. package/dist/navigate.d.ts +5 -0
  32. package/dist/navigate.d.ts.map +1 -1
  33. package/dist/navigate.js +1 -0
  34. package/dist/navigate.js.map +1 -1
  35. package/dist/rate-limit.d.ts.map +1 -1
  36. package/dist/rate-limit.js +14 -27
  37. package/dist/rate-limit.js.map +1 -1
  38. package/dist/renderer.d.ts +7 -27
  39. package/dist/renderer.d.ts.map +1 -1
  40. package/dist/renderer.js +25 -152
  41. package/dist/renderer.js.map +1 -1
  42. package/dist/streaming.d.ts +3 -3
  43. package/dist/streaming.d.ts.map +1 -1
  44. package/dist/streaming.js +13 -33
  45. package/dist/streaming.js.map +1 -1
  46. package/package.json +8 -26
  47. package/dist/build-id.d.ts +0 -14
  48. package/dist/build-id.d.ts.map +0 -1
  49. package/dist/build-id.js +0 -40
  50. package/dist/build-id.js.map +0 -1
  51. package/dist/dev-assets.test.d.ts +0 -2
  52. package/dist/dev-assets.test.d.ts.map +0 -1
  53. package/dist/head-renderer.test.d.ts +0 -2
  54. package/dist/head-renderer.test.d.ts.map +0 -1
  55. package/dist/head-renderer.test.js +0 -78
  56. package/dist/head-renderer.test.js.map +0 -1
  57. package/dist/legacy-wrapper.d.ts +0 -88
  58. package/dist/legacy-wrapper.d.ts.map +0 -1
  59. package/dist/legacy-wrapper.js +0 -104
  60. package/dist/legacy-wrapper.js.map +0 -1
  61. package/dist/lib-assets.d.ts +0 -5
  62. package/dist/lib-assets.d.ts.map +0 -1
  63. package/dist/lib-assets.js +0 -95
  64. package/dist/lib-assets.js.map +0 -1
  65. package/dist/metadata.d.ts +0 -95
  66. package/dist/metadata.d.ts.map +0 -1
  67. package/dist/metadata.js +0 -132
  68. package/dist/metadata.js.map +0 -1
  69. package/dist/renderer.test.d.ts +0 -2
  70. package/dist/renderer.test.d.ts.map +0 -1
  71. package/dist/renderer.test.js +0 -251
  72. package/dist/renderer.test.js.map +0 -1
  73. package/dist/tenancy.d.ts +0 -17
  74. package/dist/tenancy.d.ts.map +0 -1
  75. package/dist/tenancy.js +0 -132
  76. package/dist/tenancy.js.map +0 -1
  77. package/dist/tenancy.test.d.ts +0 -2
  78. package/dist/tenancy.test.d.ts.map +0 -1
  79. package/dist/tenancy.test.js +0 -38
  80. 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, resolve, sep } from 'node:path';
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, buildGlobalStylesheet, bustAggregatedStylesCache, bustGlobalStylesCache, compileIslandClientBundle, getAggregatedCssETag, getGlobalCssETag, isIslandClientRequest, tryServeRuntimeAsset, } from './dev-assets.js';
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, getTenantVaultSecretsMap, getVaultSecretsMap } from '@nexus_js/security';
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, isInternalUrl, isSafeUrl, } from './actions.js';
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 includes per-request CSP nonce when hardened. */
82
- function mergeHardenedHeaders(headers, hardened, dev, cspNonce, cspOptions) {
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 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);
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
- 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
- }
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
- 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 = {
208
+ res.writeHead(200, {
379
209
  'content-type': 'text/css; charset=utf-8',
380
- 'cache-control': cacheControl,
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
- // 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
- });
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
- res.end(notFoundPage(url.pathname, dev));
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 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
- }
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 requestRenderOpts = cspNonce ? { ...renderOpts, cspNonce } : renderOpts;
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
- const htmlHeaders = mergeHardenedHeaders(result.headers, hardened, dev, cspNonce, cspConfig);
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 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;
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
- // 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 };
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>