@farm.js/plugin 0.1.0-beta.1 → 0.1.0-beta.100

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 (54) hide show
  1. package/dist/api/index.d.ts +2 -0
  2. package/dist/api/index.d.ts.map +1 -1
  3. package/dist/api/index.js +25 -95
  4. package/dist/dev-static.d.ts +8 -0
  5. package/dist/dev-static.d.ts.map +1 -0
  6. package/dist/dev-static.js +37 -0
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/middleware/index.d.ts.map +1 -1
  11. package/dist/middleware/index.js +28 -53
  12. package/dist/middleware/matching.d.ts +29 -0
  13. package/dist/middleware/matching.d.ts.map +1 -0
  14. package/dist/middleware/matching.js +79 -0
  15. package/dist/rsc/automatic-optimized-boundary.d.ts +11 -0
  16. package/dist/rsc/automatic-optimized-boundary.d.ts.map +1 -0
  17. package/dist/rsc/automatic-optimized-boundary.js +162 -0
  18. package/dist/rsc/compatibility.d.ts +8 -0
  19. package/dist/rsc/compatibility.d.ts.map +1 -0
  20. package/dist/rsc/compatibility.js +46 -0
  21. package/dist/rsc/dev-response.d.ts +3 -0
  22. package/dist/rsc/dev-response.d.ts.map +1 -0
  23. package/dist/rsc/dev-response.js +10 -0
  24. package/dist/rsc/entries/client.d.ts +1 -0
  25. package/dist/rsc/entries/client.d.ts.map +1 -1
  26. package/dist/rsc/entries/client.js +42 -5
  27. package/dist/rsc/entries/error-fallback.d.ts +3 -0
  28. package/dist/rsc/entries/error-fallback.d.ts.map +1 -0
  29. package/dist/rsc/entries/error-fallback.js +12 -0
  30. package/dist/rsc/entries/rsc.d.ts.map +1 -1
  31. package/dist/rsc/entries/rsc.js +187 -189
  32. package/dist/rsc/entries/ssr.d.ts.map +1 -1
  33. package/dist/rsc/entries/ssr.js +44 -74
  34. package/dist/rsc/index.d.ts +15 -2
  35. package/dist/rsc/index.d.ts.map +1 -1
  36. package/dist/rsc/index.js +167 -69
  37. package/dist/rsc/nitro-build.d.ts.map +1 -1
  38. package/dist/rsc/nitro-build.js +28 -8
  39. package/dist/rsc/optimized-boundary.d.ts +10 -1
  40. package/dist/rsc/optimized-boundary.d.ts.map +1 -1
  41. package/dist/rsc/optimized-boundary.js +229 -1
  42. package/dist/rsc/route-action-transform.d.ts +11 -0
  43. package/dist/rsc/route-action-transform.d.ts.map +1 -0
  44. package/dist/rsc/route-action-transform.js +369 -0
  45. package/dist/rsc/server-fn-transform.js +1 -1
  46. package/dist/rsc/test-fixture-dependencies.d.ts +2 -0
  47. package/dist/rsc/test-fixture-dependencies.d.ts.map +1 -0
  48. package/dist/rsc/test-fixture-dependencies.js +23 -0
  49. package/dist/rsc/types.d.ts +9 -0
  50. package/dist/rsc/types.d.ts.map +1 -1
  51. package/dist/rsc/vite-plugin-nitro.d.ts +20 -7
  52. package/dist/rsc/vite-plugin-nitro.d.ts.map +1 -1
  53. package/dist/rsc/vite-plugin-nitro.js +51 -66
  54. package/package.json +15 -8
@@ -25,6 +25,8 @@ export function generateRscEntry(ctx) {
25
25
  const debugLog = `// Debug disabled`;
26
26
  let code = `
27
27
  import React from 'react';
28
+ import ServerErrorFallback from '/.farm/rsc-entries/error-fallback.tsx';
29
+ import { registerAPIRouteShape } from '@farm.js/core/api/runtime';
28
30
  import {
29
31
  renderToReadableStream,
30
32
  `;
@@ -49,11 +51,24 @@ import {
49
51
  applyProductionMiddlewareHeaders,
50
52
  createProductionMiddlewareRunner,
51
53
  } from '@farm.js/core/middleware';
52
- import { invokeAPIRouteEndpoint, matchAPIRoute } from '@farm.js/core/api/runtime';
54
+ import {
55
+ getAllowedAPIRouteMethods,
56
+ invokeAPIRouteEndpoint,
57
+ isFarmAPIPathname,
58
+ matchAPIRouteAtBasePath,
59
+ } from '@farm.js/core/api/runtime';
53
60
  import { _runWithAfterRequest } from '@farm.js/core/after';
54
- import { _runWithCurrentRequest } from '@farm.js/core/internal/production-runtime';
61
+ import { _runWithAPIRequestRuntime, _runWithCurrentRequest, searchParamsToObject } from '@farm.js/core/internal/production-runtime';
62
+ import { getFarmRedirectError, isFarmNotFoundError } from '@farm.js/core/internal/production-runtime';
63
+ import {
64
+ parseRoutePath,
65
+ matchFarmPageRoute,
66
+ compareRouteSpecificity,
67
+ getRoutePatternSpecificity,
68
+ } from '@farm.js/core/internal/production-runtime';
55
69
 
56
70
  const farmDeploymentId = ${JSON.stringify(ctx.deploymentId)};
71
+ const farmApiBasePath = ${JSON.stringify(ctx.apiBasePath ?? "/api")};
57
72
  `;
58
73
  if (ctx.actionsEnabled) {
59
74
  code += `import {
@@ -76,6 +91,9 @@ function debug(...args) {
76
91
  }
77
92
 
78
93
  function applyActionResponseHeaders(headers, request) {
94
+ // The same page URL serves HTML or Flight depending on Accept.
95
+ const vary = (headers.get('vary') || '').split(',').map(value => value.trim().toLowerCase());
96
+ if (!vary.includes('*') && !vary.includes('accept')) headers.append('vary', 'Accept');
79
97
  headers.set('x-farm-deployment-id', farmDeploymentId);
80
98
  const accept = request.headers.get('accept') || '';
81
99
  if (request.method === 'GET' && !accept.includes('text/x-component')) {
@@ -165,20 +183,30 @@ const routeDefinitionModules = collectRouteModuleEntries([${routeSourceRoots
165
183
  .map((root, index) => `{ name: ${JSON.stringify(root.name)}, base: ${JSON.stringify(root.sourceGlob)}, modules: routeDefinitionModules${index} }`)
166
184
  .join(", ")}]);
167
185
 
168
- const apiRouteMethods = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
186
+ const apiRouteMethods = ['GET', 'HEAD', 'QUERY', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
169
187
  const apiRouteMap = new Map();
188
+ const apiRouteShapes = new Map();
170
189
 
171
- function registerApiEndpoint(routePath, filePath, method, endpoint) {
190
+ function registerApiEndpoint(routePath, filePath, method, endpoint, sourceIndex) {
172
191
  if (!routePath || typeof endpoint !== 'function') return;
192
+ const replacedPath = registerAPIRouteShape(apiRouteShapes, routePath, filePath, sourceIndex);
193
+ if (replacedPath) apiRouteMap.delete(replacedPath);
173
194
  const normalizedMethod = String(method || 'GET').toUpperCase();
174
195
  let route = apiRouteMap.get(routePath);
175
196
  if (!route) {
176
- route = { path: routePath, methods: [], handlers: {}, files: {} };
197
+ route = { path: routePath, methods: [], handlers: {}, files: {}, sources: {} };
177
198
  apiRouteMap.set(routePath, route);
178
199
  }
200
+ if (route.sources[normalizedMethod] === sourceIndex) {
201
+ throw new Error(
202
+ 'Duplicate API route for ' + normalizedMethod + ' ' + routePath + ': ' +
203
+ route.files[normalizedMethod] + ' conflicts with ' + filePath
204
+ );
205
+ }
179
206
  if (!route.methods.includes(normalizedMethod)) route.methods.push(normalizedMethod);
180
207
  route.handlers[normalizedMethod] = endpoint;
181
208
  route.files[normalizedMethod] = filePath;
209
+ route.sources[normalizedMethod] = sourceIndex;
182
210
  }
183
211
 
184
212
  function getProgrammaticApiRoutes(routeModule) {
@@ -206,7 +234,7 @@ function registerApiRouteSources(fileModules, definitionModules, sourceCount) {
206
234
  const routePath = relativePath.replace(/\\/route\\.[tj]sx?$/i, '') || '/api';
207
235
  for (const method of apiRouteMethods) {
208
236
  if (typeof routeModule?.[method] === 'function') {
209
- registerApiEndpoint(routePath, filePath, method, routeModule[method]);
237
+ registerApiEndpoint(routePath, filePath, method, routeModule[method], sourceIndex);
210
238
  }
211
239
  }
212
240
  }
@@ -216,13 +244,13 @@ function registerApiRouteSources(fileModules, definitionModules, sourceCount) {
216
244
  const { filePath, module: routeModule } = entry;
217
245
  for (const endpoint of Object.values(routeModule)) {
218
246
  if (typeof endpoint === 'function' && endpoint.__path) {
219
- registerApiEndpoint(endpoint.__path, filePath, endpoint.__method || 'GET', endpoint);
247
+ registerApiEndpoint(endpoint.__path, filePath, endpoint.__method || 'GET', endpoint, sourceIndex);
220
248
  }
221
249
  }
222
250
 
223
251
  for (const route of getProgrammaticApiRoutes(routeModule)) {
224
252
  for (const [method, endpoint] of Object.entries(route.methods || {})) {
225
- registerApiEndpoint(route.path, filePath, method, endpoint);
253
+ registerApiEndpoint(route.path, filePath, method, endpoint, sourceIndex);
226
254
  }
227
255
  }
228
256
  }
@@ -233,15 +261,19 @@ registerApiRouteSources(apiRouteModules, routeDefinitionModules, ${routeSourceRo
233
261
 
234
262
  async function handleAPIRequest(request) {
235
263
  const url = new URL(request.url);
236
- const match = matchAPIRoute(apiRouteMap, url.pathname);
264
+ const match = matchAPIRouteAtBasePath(apiRouteMap, url.pathname, farmApiBasePath);
237
265
  if (!match) return null;
238
266
 
239
267
  const method = request.method.toUpperCase();
240
- const endpoint = match.route.handlers[method];
268
+ const endpoint = match.route.handlers[method] ??
269
+ (method === 'HEAD' ? match.route.handlers.GET : undefined);
241
270
  if (!endpoint) {
242
271
  return new Response(JSON.stringify({ error: 'Method Not Allowed' }), {
243
272
  status: 405,
244
- headers: { 'Content-Type': 'application/json' },
273
+ headers: {
274
+ 'Allow': getAllowedAPIRouteMethods(match.route).join(', '),
275
+ 'Content-Type': 'application/json',
276
+ },
245
277
  });
246
278
  }
247
279
 
@@ -272,57 +304,6 @@ debug('Discovered errors:', Object.keys(errors));
272
304
  debug('Discovered middlewares:', Object.keys(middlewares));
273
305
  debug('Discovered API routes:', Array.from(apiRouteMap.keys()));
274
306
 
275
- /**
276
- * Convert loading/error file path to route pattern (segment the boundary applies to).
277
- * Inlined to avoid runtime dependency on plugin package; logic is simple and covered by e2e.
278
- */
279
- function boundaryPathToRoute(filePath, globVal, kind) {
280
- const re = kind === 'loading' ? /\\/loading\\.[tj]sx?$/i : /\\/error\\.[tj]sx?$/i;
281
- let route = filePath.replace(globVal, '').replace(re, '').replace(/\\\\/g, '/') || '/';
282
- route = route.replace(/\\[([^\\]]+)\\]/g, ':$1');
283
- return route;
284
- }
285
- function getMatchingLoading(pathname, globVal) {
286
- const normalized = pathname.replace(/\\/$/, '') || '/';
287
- const pathParts = normalized.split('/').filter(Boolean);
288
- let best = null, bestLength = -1;
289
- for (const filePath of Object.keys(loadings)) {
290
- const pattern = boundaryPathToRoute(filePath, globVal, 'loading');
291
- const patternParts = pattern === '/' ? [] : pattern.split('/').filter(Boolean);
292
- if (patternParts.length > pathParts.length) continue;
293
- let matches = true;
294
- for (let i = 0; i < patternParts.length; i++) {
295
- const p = patternParts[i], seg = pathParts[i];
296
- if (!seg || (p.startsWith(':') && p !== ':...') || p === ':...') continue;
297
- if (!p.startsWith(':') && p !== seg) { matches = false; break; }
298
- }
299
- if (matches && patternParts.length > bestLength && loadings[filePath]?.default) {
300
- best = loadings[filePath].default; bestLength = patternParts.length;
301
- }
302
- }
303
- return best;
304
- }
305
- function getMatchingError(pathname, globVal) {
306
- const normalized = pathname.replace(/\\/$/, '') || '/';
307
- const pathParts = normalized.split('/').filter(Boolean);
308
- let best = null, bestLength = -1;
309
- for (const filePath of Object.keys(errors)) {
310
- const pattern = boundaryPathToRoute(filePath, globVal, 'error');
311
- const patternParts = pattern === '/' ? [] : pattern.split('/').filter(Boolean);
312
- if (patternParts.length > pathParts.length) continue;
313
- let matches = true;
314
- for (let i = 0; i < patternParts.length; i++) {
315
- const p = patternParts[i], seg = pathParts[i];
316
- if (!seg || (p.startsWith(':') && p !== ':...') || p === ':...') continue;
317
- if (!p.startsWith(':') && p !== seg) { matches = false; break; }
318
- }
319
- if (matches && patternParts.length > bestLength && errors[filePath]?.default) {
320
- best = errors[filePath].default; bestLength = patternParts.length;
321
- }
322
- }
323
- return best;
324
- }
325
-
326
307
  /**
327
308
  * Convert middleware file path to route path
328
309
  * e.g., '/src/middleware.ts' -> '/'
@@ -345,7 +326,7 @@ async function executeMiddleware(request) {
345
326
  /**
346
327
  * Convert file path to route pattern
347
328
  * e.g., '/src/about/page.tsx' -> '/about'
348
- * e.g., '/src/blog/[slug]/page.tsx' -> '/blog/:slug'
329
+ * e.g., '/blog/[slug]/page.tsx' -> '/blog/[slug]'
349
330
  */
350
331
  function filePathToRoute(filePath) {
351
332
  let route = filePath
@@ -353,9 +334,6 @@ function filePathToRoute(filePath) {
353
334
  .replace(/\\/page\\.[tj]sx?$/, '')
354
335
  .replace(/\\/page$/, '') || '/';
355
336
 
356
- // Convert [param] to :param for matching
357
- route = route.replace(/\\[([^\\]]+)\\]/g, ':$1');
358
-
359
337
  return route;
360
338
  }
361
339
 
@@ -385,52 +363,14 @@ const RouteErrorBoundary = React.Component
385
363
  return children;
386
364
  };
387
365
 
388
- /**
389
- * Match a URL pathname to a route pattern
390
- * Supports dynamic segments like :id and catch-all like *
391
- */
392
- function matchPath(pattern, pathname) {
393
- const patternParts = pattern.split('/').filter(Boolean);
394
- const pathParts = pathname.split('/').filter(Boolean);
395
-
396
- // Special case for root
397
- if (pattern === '/' && pathname === '/') {
398
- return { params: {} };
399
- }
400
-
401
- if (patternParts.length !== pathParts.length) {
402
- // Check for catch-all
403
- const lastPattern = patternParts[patternParts.length - 1];
404
- if (!lastPattern?.startsWith(':...')) {
405
- return null;
406
- }
407
- }
408
-
409
- const params = {};
410
-
411
- for (let i = 0; i < patternParts.length; i++) {
412
- const patternPart = patternParts[i];
413
- const pathPart = pathParts[i];
414
-
415
- if (patternPart.startsWith(':...')) {
416
- // Catch-all segment
417
- const paramName = patternPart.slice(4);
418
- params[paramName] = pathParts.slice(i).join('/');
419
- return { params };
420
- }
421
-
422
- if (patternPart.startsWith(':')) {
423
- // Dynamic segment
424
- const paramName = patternPart.slice(1);
425
- params[paramName] = pathPart;
426
- } else if (patternPart !== pathPart) {
427
- // Static segment mismatch
428
- return null;
429
- }
430
- }
431
-
432
- return { params };
433
- }
366
+ // Parse and rank once per entry initialization, not on every request. Use the
367
+ // same validation, decoding and specificity rules as Farm's ordinary router.
368
+ const pageRoutes = Object.entries(pages).map(([filePath, module]) => ({
369
+ filePath,
370
+ module,
371
+ segments: parseRoutePath(filePath).segments,
372
+ specificity: getRoutePatternSpecificity(filePathToRoute(filePath)),
373
+ })).sort((left, right) => compareRouteSpecificity(left.specificity, right.specificity));
434
374
 
435
375
  /**
436
376
  * Simple file-based router
@@ -439,17 +379,16 @@ function matchPath(pattern, pathname) {
439
379
  function matchRoute(pathname) {
440
380
  const normalized = pathname.replace(/\\/$/, '') || '/';
441
381
 
442
- for (const filePath of Object.keys(pages)) {
443
- const pattern = filePathToRoute(filePath);
444
- const match = matchPath(pattern, normalized);
382
+ for (const { filePath, module, segments } of pageRoutes) {
383
+ const match = matchFarmPageRoute(normalized, segments);
445
384
 
446
- if (match) {
447
- debug('Matched route:', pattern, 'for path:', normalized);
385
+ if (match.matches) {
386
+ debug('Matched route:', filePath, 'for path:', normalized);
448
387
  return {
449
- Page: pages[filePath].default,
388
+ Page: module.default,
450
389
  pattern: filePath,
451
390
  params: match.params,
452
- pageMetadata: pages[filePath].metadata,
391
+ pageMetadata: module.metadata,
453
392
  };
454
393
  }
455
394
  }
@@ -468,14 +407,14 @@ function mergeDocumentMetadata(...sources) {
468
407
  }
469
408
 
470
409
  /**
471
- * Find every applicable layout module from root to the page directory.
472
- * Rendering still uses the nearest layout, while document metadata inherits
473
- * through the complete root -> nested layouts -> page chain.
410
+ * Find modules along the selected page's actual file ancestry, root to leaf.
411
+ * Keep route groups and catch-all folders; matching URL prefixes can pick
412
+ * boundaries from a sibling page that the router did not select.
474
413
  */
475
- function getLayoutModules(pageFilePath) {
414
+ function getRouteModules(pageFilePath, modules, kind) {
476
415
  const tryKeys = (...keys) => {
477
416
  for (const k of keys) {
478
- if (layouts[k]?.default) return layouts[k];
417
+ if (modules[k]?.default) return modules[k];
479
418
  }
480
419
  return null;
481
420
  };
@@ -487,26 +426,41 @@ function getLayoutModules(pageFilePath) {
487
426
  for (let depth = 0; depth <= parts.length; depth++) {
488
427
  const relativeDir = parts.slice(0, depth).join('/');
489
428
  const absoluteDir = relativeDir ? '/' + relativeDir : '';
490
- let matchedLayout = null;
429
+ let matchedModule = null;
491
430
  for (const ext of extensions) {
492
- matchedLayout = tryKeys(
493
- absoluteDir + '/layout.' + ext,
494
- relativeDir ? relativeDir + '/layout.' + ext : 'layout.' + ext,
431
+ matchedModule = tryKeys(
432
+ absoluteDir + '/' + kind + '.' + ext,
433
+ relativeDir ? relativeDir + '/' + kind + '.' + ext : kind + '.' + ext,
495
434
  );
496
- if (matchedLayout) break;
435
+ if (matchedModule) break;
497
436
  }
498
- if (matchedLayout && !matches.includes(matchedLayout)) matches.push(matchedLayout);
437
+ if (matchedModule) matches.push(matchedModule);
499
438
  }
500
439
 
501
440
  return matches;
502
441
  }
503
442
 
443
+ function getLayoutModules(pageFilePath) {
444
+ return getRouteModules(pageFilePath, layouts, 'layout');
445
+ }
446
+
447
+ function getMatchingBoundary(pageFilePath, modules, kind) {
448
+ // Before page selection (for example, middleware failure), only the root
449
+ // boundary applies. Do not guess a route from a partially processed URL.
450
+ const matches = getRouteModules(pageFilePath ?? '/page.tsx', modules, kind);
451
+ return matches.at(-1)?.default ?? null;
452
+ }
453
+
504
454
  /**
505
455
  * Main request handler - entry point for both dev and production (Nitro).
506
456
  * Exported as { fetch: handler } for the RSC/Nitro contract (see vite-plugin-rsc-deploy-example).
507
457
  */
508
458
  async function handleFarmRequest(request) {
509
459
  let url = new URL(request.url);
460
+ let matchedPage = null;
461
+ let errorData = new Map();
462
+ let errorContext = new Map();
463
+ let errorHeaders = new Headers();
510
464
  try {
511
465
  debug('Handling request:', request.method, url.pathname);
512
466
 
@@ -515,9 +469,9 @@ async function handleFarmRequest(request) {
515
469
  return createFarmDeploymentMismatchResponse(deploymentMismatch);
516
470
  }
517
471
 
518
- const initialApiMatch = matchAPIRoute(apiRouteMap, url.pathname);
472
+ const initialApiMatch = matchAPIRouteAtBasePath(apiRouteMap, url.pathname, farmApiBasePath);
519
473
  const isInitialApiRequest = Boolean(initialApiMatch) ||
520
- url.pathname === '/api' || url.pathname.startsWith('/api/');
474
+ isFarmAPIPathname(url.pathname, farmApiBasePath) || isFarmAPIPathname(url.pathname);
521
475
 
522
476
  ${ctx.actionsEnabled
523
477
  ? `if (request.method === 'POST' && !isInitialApiRequest) {
@@ -553,6 +507,9 @@ async function handleFarmRequest(request) {
553
507
  const middlewareData = Object.fromEntries(middlewareResult.data);
554
508
  const middlewareContext = middlewareResult.context;
555
509
  const middlewareHeaders = new Headers(middlewareResult.headers);
510
+ errorData = middlewareResult.data;
511
+ errorContext = middlewareContext;
512
+ errorHeaders = middlewareHeaders;
556
513
  if (middlewareResult.data.size || middlewareContext.size) {
557
514
  middlewareHeaders.set('cache-control', 'private, no-store');
558
515
  }
@@ -568,7 +525,7 @@ async function handleFarmRequest(request) {
568
525
  if (apiResponse) {
569
526
  return applyProductionMiddlewareHeaders(apiResponse, middlewareHeaders);
570
527
  }
571
- if (url.pathname === '/api' || url.pathname.startsWith('/api/')) {
528
+ if (isFarmAPIPathname(url.pathname, farmApiBasePath) || isFarmAPIPathname(url.pathname)) {
572
529
  return applyProductionMiddlewareHeaders(new Response(
573
530
  JSON.stringify({ error: 'API route not found', pathname: url.pathname }),
574
531
  { status: 404, headers: { 'Content-Type': 'application/json' } },
@@ -642,8 +599,11 @@ async function handleFarmRequest(request) {
642
599
  if (request.signal.aborted) {
643
600
  return new Response(null, { status: 499, headers: { 'Cache-Control': 'no-store' } });
644
601
  }
645
- console.error('[Farm.js] Server action failed:', e);
646
- returnValue = { ok: false, data: sanitizeServerActionError(e) };
602
+ const actionError = sanitizeServerActionError(e);
603
+ if (actionError.name === 'ServerActionError') {
604
+ console.error('[Farm.js] Server action failed:', e);
605
+ }
606
+ returnValue = { ok: false, data: actionError };
647
607
  debug('Server action failed:', actionId, e);
648
608
  }
649
609
  } else {
@@ -674,10 +634,13 @@ async function handleFarmRequest(request) {
674
634
  if (request.signal.aborted) {
675
635
  return new Response(null, { status: 499, headers: { 'Cache-Control': 'no-store' } });
676
636
  }
677
- console.error('[Farm.js] Form action failed:', e);
678
- debug('Form action failed:', e);
679
- return new Response('Server function failed', {
680
- status: 500,
637
+ const actionError = sanitizeServerActionError(e);
638
+ if (actionError.name === 'ServerActionError') {
639
+ console.error('[Farm.js] Form action failed:', e);
640
+ }
641
+ debug('Form action failed:', actionError);
642
+ return new Response(actionError.message, {
643
+ status: actionError.name === 'ServerFnFailure' ? actionError.status : 500,
681
644
  headers: {
682
645
  'Cache-Control': 'no-store',
683
646
  'Content-Type': 'text/plain; charset=utf-8',
@@ -693,6 +656,7 @@ async function handleFarmRequest(request) {
693
656
  code += `
694
657
  // Match the URL to a page component
695
658
  const matched = matchRoute(url.pathname);
659
+ matchedPage = matched;
696
660
 
697
661
  if (!matched) {
698
662
  debug('404 - No route found for:', url.pathname);
@@ -701,8 +665,6 @@ async function handleFarmRequest(request) {
701
665
 
702
666
  const { Page, pattern, params, pageMetadata } = matched;
703
667
  const LayoutModules = getLayoutModules(pattern);
704
- const LayoutModule = LayoutModules[LayoutModules.length - 1];
705
- const Layout = LayoutModule?.default || (function PassThrough({ children }) { return children; });
706
668
  const metadata = mergeDocumentMetadata(
707
669
  ...LayoutModules.map((layoutModule) => layoutModule.metadata),
708
670
  pageMetadata,
@@ -713,7 +675,9 @@ async function handleFarmRequest(request) {
713
675
  const globalsCssPath = '/${ctx.srcDir}' + routesPath + '/globals.css';
714
676
 
715
677
  // Parse search params
716
- const searchParams = Object.fromEntries(url.searchParams);
678
+ // Collect repeated keys into arrays, matching the searchParams prop shape
679
+ // every non-RSC environment produces (see core's search-params.ts).
680
+ const searchParams = searchParamsToObject(url.searchParams);
717
681
 
718
682
  // Page props passed to components (includes middleware shared data)
719
683
  const pageProps = { params, searchParams, middlewareData };
@@ -723,23 +687,25 @@ async function handleFarmRequest(request) {
723
687
  // Helper to create elements without JSX
724
688
  const h = React.createElement;
725
689
 
726
- // Render page content - handle async components
690
+ // Render page content - only native async functions need direct awaiting.
691
+ // Synchronous components must retain React's hook dispatcher. Source text
692
+ // containing the word async does not change how a component executes.
727
693
  let pageContent;
728
- if (Page.constructor.name === 'AsyncFunction' || Page.toString().includes('async')) {
694
+ if (Page.constructor.name === 'AsyncFunction') {
729
695
  pageContent = await Page(pageProps);
730
696
  } else {
731
697
  pageContent = h(Page, pageProps);
732
698
  }
733
699
 
734
700
  // Route-level loading boundary: wrap in Suspense so async content shows fallback
735
- const LoadingComponent = getMatchingLoading(url.pathname, glob);
701
+ const LoadingComponent = getMatchingBoundary(pattern, loadings, 'loading');
736
702
  if (LoadingComponent) {
737
703
  const loadingFallback = h(LoadingComponent, { params, path: url.pathname });
738
704
  pageContent = h(React.Suspense, { fallback: loadingFallback }, pageContent);
739
705
  }
740
706
 
741
707
  // Route-level error boundary (Next.js error.tsx): catches render errors in this segment
742
- const ErrorComponent = getMatchingError(url.pathname, glob);
708
+ const ErrorComponent = getMatchingBoundary(pattern, errors, 'error');
743
709
  if (ErrorComponent) {
744
710
  pageContent = h(RouteErrorBoundary, {
745
711
  Fallback: ErrorComponent,
@@ -748,12 +714,16 @@ async function handleFarmRequest(request) {
748
714
  });
749
715
  }
750
716
 
751
- // Render layout
752
- let layoutContent;
753
- if (Layout.constructor.name === 'AsyncFunction' || Layout.toString().includes('async')) {
754
- layoutContent = await Layout({ children: pageContent });
755
- } else {
756
- layoutContent = h(Layout, null, pageContent);
717
+ // Render layouts inside out so every ancestor wraps its descendants.
718
+ let layoutContent = pageContent;
719
+ for (let index = LayoutModules.length - 1; index >= 0; index--) {
720
+ const Layout = LayoutModules[index].default;
721
+ const layoutProps = { params: pageProps.params, children: layoutContent };
722
+ if (Layout.constructor.name === 'AsyncFunction') {
723
+ layoutContent = await Layout(layoutProps);
724
+ } else {
725
+ layoutContent = h(Layout, layoutProps);
726
+ }
757
727
  }
758
728
 
759
729
  // Single wrapper so #root has exactly one child (avoids duplicate block / "two pages" in DOM).
@@ -853,6 +823,9 @@ async function handleFarmRequest(request) {
853
823
  )
854
824
  );
855
825
  } catch (err) {
826
+ const redirect = getFarmRedirectError(err);
827
+ if (redirect) return new Response(null, { status: redirect.status, headers: { location: redirect.url, 'cache-control': 'no-store' } });
828
+ if (isFarmNotFoundError(err)) return new Response('Not Found', { status: 404, headers: { 'cache-control': 'no-store' } });
856
829
  console.error('[RSC] Handler error:', err);
857
830
  if (request.method === 'POST') {
858
831
  if (request.signal.aborted) {
@@ -867,58 +840,83 @@ async function handleFarmRequest(request) {
867
840
  },
868
841
  });
869
842
  }
870
- // If route has error.tsx, render it (Next.js-style: SSR error goes to route error boundary)
843
+ // Render outside the failed page/layout tree, but retain request context.
871
844
  const pathname = url.pathname.replace(/\\/$/, '') || '/';
872
- const ErrorComponent = getMatchingError(pathname, glob);
845
+ const ErrorComponent = getMatchingBoundary(matchedPage?.pattern, errors, 'error');
873
846
  if (ErrorComponent) {
874
847
  try {
875
- const matched = matchRoute(pathname);
876
- const layoutPattern = matched ? matched.pattern : null;
877
- const LayoutModules = matched ? getLayoutModules(layoutPattern) : [];
878
- const Layout = LayoutModules[LayoutModules.length - 1]?.default;
879
- const LayoutComp = Layout || (function PassThrough({ children }) { return children; });
880
- const errParams = matched ? matched.params : {};
881
- const errSearchParams = Object.fromEntries(url.searchParams);
882
- const errorElement = h(ErrorComponent, {
883
- error: err,
884
- reset: () => {},
885
- params: errParams,
886
- path: pathname,
887
- searchParams: errSearchParams,
888
- });
889
- const layoutContent = LayoutComp.constructor.name === 'AsyncFunction' || LayoutComp.toString().includes('async')
890
- ? await LayoutComp({ children: errorElement })
891
- : h(LayoutComp, null, errorElement);
892
- const rootInner = h('div', { 'data-farm-root': 'true' }, layoutContent);
893
- const doc = h('html', null,
894
- h('head', null,
895
- h('meta', { charSet: 'utf-8' }),
896
- h('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1' }),
897
- h('link', { rel: 'icon', href: 'data:,' }),
898
- h('title', null, 'Error'),
899
- h('link', { rel: 'stylesheet', href: globalsCssPath, as: 'style', precedence: 'default' })
900
- ),
901
- h('body', null, h('div', { id: 'root' }, rootInner))
848
+ return await _runWithCurrentRequest(request, () =>
849
+ _runWithMiddlewareData(errorData, () =>
850
+ _runWithMiddlewareContext(errorContext, async () => {
851
+ const h = React.createElement;
852
+ const errSearchParams = searchParamsToObject(url.searchParams);
853
+ const fallbackProps = {
854
+ params: matchedPage?.params || {},
855
+ path: pathname,
856
+ search: url.search,
857
+ searchParams: errSearchParams,
858
+ middlewareData: Object.fromEntries(errorData),
859
+ };
860
+ // Preserve the original failure in logs, not production payloads.
861
+ const message = ${ctx.development === true ? "true" : "false"} && err instanceof Error
862
+ ? err.message : 'Internal Server Error';
863
+ const isClientBoundary = ErrorComponent.$$typeof === Symbol.for('react.client.reference');
864
+ const errorElement = isClientBoundary
865
+ ? h(ServerErrorFallback, { Fallback: ErrorComponent, message, fallbackProps })
866
+ : h(ErrorComponent, { ...fallbackProps, error: new Error(message), reset: () => {} });
867
+ const rootInner = h('div', { 'data-farm-root': 'true' }, errorElement);
868
+ const payload = {
869
+ root: h('html', null,
870
+ h('head', null, h('meta', { charSet: 'utf-8' }), h('title', null, 'Error')),
871
+ h('body', null, h('div', { id: 'root' }, rootInner))),
872
+ rootContent: rootInner,
873
+ metadata: { title: 'Error' },
874
+ };
875
+ const rscStream = renderToReadableStream(payload);
876
+ const headers = new Headers(errorHeaders);
877
+ headers.set('cache-control', 'private, no-store');
878
+ headers.set('x-content-type-options', 'nosniff');
879
+ applyActionResponseHeaders(headers, request);
880
+ if ((request.headers.get('accept') || '').includes('text/x-component')) {
881
+ headers.set('content-type', 'text/x-component');
882
+ return new Response(rscStream, { status: 500, headers });
883
+ }
884
+ let ssr;
885
+ if (typeof import.meta.viteRsc?.loadModule === 'function') {
886
+ ssr = await import.meta.viteRsc.loadModule('ssr', 'index');
887
+ } else if (typeof globalThis.__VITE_RSC_LOAD_SSR__ === 'function') {
888
+ ssr = await globalThis.__VITE_RSC_LOAD_SSR__();
889
+ }
890
+ if (!ssr) {
891
+ void rscStream.cancel().catch(() => {});
892
+ throw new Error('SSR environment unavailable');
893
+ }
894
+ const html = await ssr.renderHTML({ payload, rscStream });
895
+ headers.set('content-type', 'text/html; charset=utf-8');
896
+ return new Response(html, { status: 500, headers });
897
+ })
898
+ )
902
899
  );
903
- const renderToString = (await import('react-dom/server')).renderToString;
904
- const html = '<!DOCTYPE html>' + renderToString(doc);
905
- return new Response(html, { status: 500, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
906
- } catch (e) {
907
- console.error('[RSC] Error boundary render failed:', e);
900
+ } catch (fallbackError) {
901
+ console.error('[RSC] Error boundary render failed:', fallbackError);
908
902
  }
909
903
  }
910
904
  const message = 'Internal Server Error';
911
905
  return new Response(JSON.stringify({ error: true, url: request.url, status: 500, message }), {
912
906
  status: 500,
913
- headers: { 'Content-Type': 'application/json' },
907
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'private, no-store' },
914
908
  });
915
909
  }
916
910
  }
917
911
 
918
912
  async function handler(request, context) {
919
- return _runWithCurrentRequest(request, () =>
913
+ return _runWithAPIRequestRuntime({
914
+ basePath: farmApiBasePath,
915
+ dispatch: async (localRequest) =>
916
+ (await handleAPIRequest(localRequest)) ?? Response.json({ error: 'Not Found' }, { status: 404 }),
917
+ }, () => _runWithCurrentRequest(request, () =>
920
918
  _runWithAfterRequest(request, () => handleFarmRequest(request), context)
921
- );
919
+ ));
922
920
  }
923
921
 
924
922
  export default { fetch: handler };
@@ -1 +1 @@
1
- {"version":3,"file":"ssr.d.ts","sourceRoot":"","sources":["../../../src/rsc/entries/ssr.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,YAAY,GAAG,MAAM,CA6O1D"}
1
+ {"version":3,"file":"ssr.d.ts","sourceRoot":"","sources":["../../../src/rsc/entries/ssr.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,YAAY,GAAG,MAAM,CA+M1D"}