@reckona/mreact-router 0.0.72 → 0.0.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/actions.ts CHANGED
@@ -1,10 +1,9 @@
1
- import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
1
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { access, readdir, readFile } from "node:fs/promises";
3
3
  import { dirname, join, relative, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import {
6
- collectFormActionReferenceNames,
7
- collectFormActionReferences,
6
+ collectFormActionExpressionReferences,
8
7
  hasModuleDirective,
9
8
  } from "@reckona/mreact-compiler";
10
9
  import {
@@ -31,6 +30,10 @@ import {
31
30
  readExistingFormCsrfToken,
32
31
  validateFormCsrf,
33
32
  } from "./csrf.js";
33
+ import {
34
+ collectRuntimeInferredServerActions,
35
+ type ServerActionInferenceDiagnostic,
36
+ } from "./server-action-inference.js";
34
37
 
35
38
  function isProductionEnvironment(): boolean {
36
39
  return process.env.NODE_ENV === "production";
@@ -137,6 +140,7 @@ export interface PreparedRouteActions {
137
140
  actionNonce?: string;
138
141
  code: string;
139
142
  csrfToken?: string;
143
+ diagnostics?: ServerActionInferenceDiagnostic[] | undefined;
140
144
  // True when the cookie should be (re)set on the response. False means
141
145
  // the incoming request already carried a valid CSRF cookie that the
142
146
  // render is reusing -- skipping Set-Cookie avoids cookie thrash across
@@ -151,11 +155,24 @@ interface ActionReference {
151
155
  moduleId: string;
152
156
  }
153
157
 
158
+ export interface PreparedFormActionReference {
159
+ end: number;
160
+ expression: string;
161
+ expressionEnd: number;
162
+ expressionStart: number;
163
+ exportName: string;
164
+ inferred: boolean;
165
+ moduleId: string;
166
+ sourceHash: string;
167
+ start: number;
168
+ }
169
+
154
170
  const inferredServerActionReferences = new Map<string, Map<string, Map<string, ActionReference>>>();
155
171
 
156
172
  export async function prepareRouteServerActions(options: {
157
173
  appDir: string;
158
174
  code: string;
175
+ formActionReferences?: readonly PreparedFormActionReference[] | undefined;
159
176
  pageFile: string;
160
177
  request?: Request | undefined;
161
178
  }): Promise<PreparedRouteActions> {
@@ -164,11 +181,20 @@ export async function prepareRouteServerActions(options: {
164
181
  return { code: options.code, hasFormActions: false };
165
182
  }
166
183
 
167
- const references = await collectImportedServerActions(options);
184
+ const inference =
185
+ options.formActionReferences === undefined
186
+ ? await collectImportedServerActions(options)
187
+ : {
188
+ diagnostics: [],
189
+ references: formActionReferenceMap(options.code, options.formActionReferences),
190
+ };
191
+ const { diagnostics, references } = inference;
168
192
 
169
193
  if (references.size === 0) {
170
194
  replaceInferredServerActionReferences(options.appDir, options.pageFile, new Map());
171
- return { code: options.code, hasFormActions: false };
195
+ return diagnostics.length === 0
196
+ ? { code: options.code, hasFormActions: false }
197
+ : { code: options.code, diagnostics, hasFormActions: false };
172
198
  }
173
199
 
174
200
  // Reuse the existing CSRF token when the browser already sent one.
@@ -189,7 +215,9 @@ export async function prepareRouteServerActions(options: {
189
215
 
190
216
  if (lowered === options.code) {
191
217
  replaceInferredServerActionReferences(options.appDir, options.pageFile, new Map());
192
- return { code: options.code, hasFormActions: false };
218
+ return diagnostics.length === 0
219
+ ? { code: options.code, hasFormActions: false }
220
+ : { code: options.code, diagnostics, hasFormActions: false };
193
221
  }
194
222
 
195
223
  replaceInferredServerActionReferences(options.appDir, options.pageFile, references);
@@ -199,12 +227,33 @@ export async function prepareRouteServerActions(options: {
199
227
  code: lowered,
200
228
  csrfToken,
201
229
  csrfTokenIsNew,
230
+ diagnostics,
202
231
  hasFormActions: true,
203
232
  };
204
233
  }
205
234
 
235
+ function formActionReferenceMap(
236
+ code: string,
237
+ references: readonly PreparedFormActionReference[],
238
+ ): Map<string, ActionReference> {
239
+ const sourceHash = formActionSourceHash(code);
240
+
241
+ return new Map(
242
+ references
243
+ .filter((reference) => reference.sourceHash === sourceHash)
244
+ .map((reference) => [
245
+ formActionOccurrenceKey(reference),
246
+ {
247
+ exportName: reference.exportName,
248
+ inferred: reference.inferred,
249
+ moduleId: reference.moduleId,
250
+ },
251
+ ]),
252
+ );
253
+ }
254
+
206
255
  function hasFormActionCandidate(code: string, filename: string): boolean {
207
- return collectFormActionReferenceNames({ code, filename }).length > 0;
256
+ return collectFormActionExpressionReferences({ code, filename }).length > 0;
208
257
  }
209
258
 
210
259
  export async function dispatchServerActionRequest(options: {
@@ -571,10 +620,10 @@ function lowerFormActions(options: {
571
620
  references: Map<string, ActionReference>;
572
621
  }): string {
573
622
  let code = options.code;
574
- const formReferences = collectFormActionReferences({ code: options.code });
623
+ const formReferences = collectFormActionExpressionReferences({ code: options.code });
575
624
 
576
625
  for (const formReference of [...formReferences].reverse()) {
577
- const reference = options.references.get(formReference.name);
626
+ const reference = options.references.get(formActionOccurrenceKey(formReference));
578
627
 
579
628
  if (reference === undefined) {
580
629
  continue;
@@ -586,7 +635,7 @@ function lowerFormActions(options: {
586
635
  csrfToken: options.csrfToken,
587
636
  opening,
588
637
  reference,
589
- referenceName: formReference.name,
638
+ referenceName: formReference.expression,
590
639
  });
591
640
 
592
641
  if (loweredOpening === opening) {
@@ -599,6 +648,26 @@ function lowerFormActions(options: {
599
648
  return code;
600
649
  }
601
650
 
651
+ function formActionOccurrenceKey(reference: {
652
+ end: number;
653
+ expression: string;
654
+ expressionEnd: number;
655
+ expressionStart: number;
656
+ start: number;
657
+ }): string {
658
+ return [
659
+ reference.start,
660
+ reference.end,
661
+ reference.expressionStart,
662
+ reference.expressionEnd,
663
+ reference.expression,
664
+ ].join(":");
665
+ }
666
+
667
+ function formActionSourceHash(code: string): string {
668
+ return createHash("sha256").update(code).digest("base64url");
669
+ }
670
+
602
671
  function lowerFormActionOpening(options: {
603
672
  actionNonce: string;
604
673
  csrfToken: string;
@@ -653,58 +722,19 @@ async function collectImportedServerActions(options: {
653
722
  appDir: string;
654
723
  code: string;
655
724
  pageFile: string;
656
- }): Promise<Map<string, ActionReference>> {
657
- const references = new Map<string, ActionReference>();
658
- const actionNames = new Set(
659
- collectFormActionReferenceNames({ code: options.code, filename: options.pageFile }),
660
- );
661
-
662
- if (actionNames.size === 0) {
663
- return references;
664
- }
665
-
666
- const imports = options.code.matchAll(
667
- /^import\s+\{\s*(?<specifiers>[^}]+)\s*\}\s+from\s+["'](?<source>[^"']+)["'];?/gm,
668
- );
669
-
670
- for (const match of imports) {
671
- const source = match.groups?.source;
672
- const specifiers = match.groups?.specifiers;
673
-
674
- if (source === undefined || specifiers === undefined || !source.startsWith(".")) {
675
- continue;
676
- }
677
-
678
- const file = await resolveSourceFile(dirname(options.pageFile), source);
679
-
680
- if (file === undefined) {
681
- continue;
682
- }
683
-
684
- const moduleId = moduleIdForFile(options.appDir, file);
685
- const inferred = !(await isUseServerFile(file));
686
-
687
- for (const specifier of specifiers.split(",")) {
688
- const [exportName, localName] = specifier.trim().split(/\s+as\s+/);
689
- const imported = exportName?.trim();
690
- const local = localName?.trim() ?? imported;
691
-
692
- if (
693
- imported !== undefined &&
694
- imported.length > 0 &&
695
- local !== undefined &&
696
- actionNames.has(local)
697
- ) {
698
- references.set(local, {
699
- exportName: imported,
700
- inferred,
701
- moduleId,
702
- });
703
- }
704
- }
705
- }
706
-
707
- return references;
725
+ }): Promise<{
726
+ diagnostics: ServerActionInferenceDiagnostic[];
727
+ references: Map<string, ActionReference>;
728
+ }> {
729
+ return await collectRuntimeInferredServerActions({
730
+ appDir: options.appDir,
731
+ code: options.code,
732
+ fileSystem: {
733
+ isUseServerFile,
734
+ resolveSourceFile,
735
+ },
736
+ pageFile: options.pageFile,
737
+ });
708
738
  }
709
739
 
710
740
  function isInferredServerActionReference(options: {
package/src/build.ts CHANGED
@@ -4,7 +4,6 @@ import { copyFile, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "nod
4
4
  import { builtinModules } from "node:module";
5
5
  import { dirname, join, relative, sep } from "node:path";
6
6
  import {
7
- collectFormActionReferenceNames,
8
7
  collectStaticImportReferences,
9
8
  collectTopLevelValueExportNames,
10
9
  formatDiagnostic,
@@ -56,6 +55,7 @@ import {
56
55
  routeClosureMayUseAwaitBoundary,
57
56
  stripRouteBuildExports,
58
57
  stripRouteClientOnlyExports,
58
+ stripRouteClientSource,
59
59
  stripRouteLoaderOnlyExports,
60
60
  stripRouteMetadataOnlyExports,
61
61
  stripRouteRequestOnlyExports,
@@ -67,6 +67,7 @@ import {
67
67
  import { collectRouteCssFiles } from "./route-styles.js";
68
68
  import { existingRouteShellCandidates } from "./route-shells.js";
69
69
  import { sourceModuleCandidates } from "./source-modules.js";
70
+ import { collectBuildInferredServerActions } from "./server-action-inference.js";
70
71
  import { workspacePackageFile } from "./workspace-packages.js";
71
72
  import type { PluginOption, UserConfig } from "vite";
72
73
 
@@ -112,6 +113,7 @@ export interface BuiltServerManifest {
112
113
  prerenderedRoutes?: Record<string, BuiltPrerenderedRoute>;
113
114
  publicAssetBaseUrl?: string;
114
115
  routesDir?: string;
116
+ routeServerActionReferences?: Record<string, BuiltServerActionExpressionReference[]>;
115
117
  serverActionManifest?: BuiltServerActionReference[];
116
118
  serverModuleFiles?: Record<string, string>;
117
119
  serverModuleRenderFiles?: Record<string, string>;
@@ -126,6 +128,18 @@ export interface BuiltServerActionReference {
126
128
  moduleId: string;
127
129
  }
128
130
 
131
+ export interface BuiltServerActionExpressionReference {
132
+ end: number;
133
+ exportName: string;
134
+ expression: string;
135
+ expressionEnd: number;
136
+ expressionStart: number;
137
+ inferred: boolean;
138
+ moduleId: string;
139
+ sourceHash: string;
140
+ start: number;
141
+ }
142
+
129
143
  export interface BuiltServerModuleArtifact {
130
144
  analysis?: BuiltRouteSourceAnalysisSummary;
131
145
  loader?: BuiltServerModuleOutput;
@@ -200,7 +214,7 @@ export async function buildApp(options: BuildAppOptions): Promise<BuildAppResult
200
214
  ...new Set([...(await collectPublicAssetPaths(project.publicDir)), ...appConventionPublicAssets]),
201
215
  ].sort();
202
216
 
203
- const serverActionManifest = collectBuildServerActionManifest({
217
+ const serverActionManifest = await collectBuildServerActionManifest({
204
218
  files,
205
219
  projectRoot: project.projectRoot,
206
220
  routes,
@@ -274,6 +288,11 @@ export async function buildApp(options: BuildAppOptions): Promise<BuildAppResult
274
288
  });
275
289
  }
276
290
 
291
+ const routeServerActionReferences = Object.fromEntries(
292
+ [...serverActionManifest.routeReferences.entries()].sort(([left], [right]) =>
293
+ left.localeCompare(right),
294
+ ),
295
+ );
277
296
  const serverManifest = {
278
297
  allowedSourceDirs: project.allowedSourceDirs.map((directory) =>
279
298
  relative(project.projectRoot, directory),
@@ -287,7 +306,12 @@ export async function buildApp(options: BuildAppOptions): Promise<BuildAppResult
287
306
  ...(project.publicAssetBaseUrl === undefined
288
307
  ? {}
289
308
  : { publicAssetBaseUrl: project.publicAssetBaseUrl }),
290
- ...(serverActionManifest.length === 0 ? {} : { serverActionManifest }),
309
+ ...(Object.keys(routeServerActionReferences).length === 0
310
+ ? {}
311
+ : { routeServerActionReferences }),
312
+ ...(serverActionManifest.allowedActions.length === 0
313
+ ? {}
314
+ : { serverActionManifest: serverActionManifest.allowedActions }),
291
315
  ...(Object.keys(serverModuleArtifacts.files).length === 0
292
316
  ? {}
293
317
  : { serverModuleFiles: serverModuleArtifacts.files }),
@@ -588,13 +612,17 @@ function runtimePackageNameForSpecifier(specifier: string): string {
588
612
  return scope !== undefined && name !== undefined ? `${scope}/${name}` : specifier;
589
613
  }
590
614
 
591
- function collectBuildServerActionManifest(options: {
615
+ async function collectBuildServerActionManifest(options: {
592
616
  files: Record<string, string>;
593
617
  projectRoot: string;
594
618
  routes: readonly AppRoute[];
595
619
  routesDir: string;
596
- }): BuiltServerActionReference[] {
620
+ }): Promise<{
621
+ allowedActions: BuiltServerActionReference[];
622
+ routeReferences: Map<string, BuiltServerActionExpressionReference[]>;
623
+ }> {
597
624
  const entries = new Map<string, BuiltServerActionReference>();
625
+ const routeReferences = new Map<string, BuiltServerActionExpressionReference[]>();
598
626
  const relativeRoutesDir = relative(options.projectRoot, options.routesDir);
599
627
  const routeSourceFiles = new Set(
600
628
  options.routes
@@ -616,26 +644,41 @@ function collectBuildServerActionManifest(options: {
616
644
  }
617
645
 
618
646
  if (routeSourceFiles.has(file)) {
619
- for (const reference of collectBuildInferredServerActionReferences({
647
+ const inference = await collectBuildInferredServerActionReferences({
620
648
  file,
621
649
  files: options.files,
622
650
  relativeRoutesDir,
623
651
  source: code,
624
- })) {
652
+ });
653
+
654
+ for (const diagnostic of inference.diagnostics) {
655
+ console.warn(formatServerActionInferenceDiagnostic(diagnostic));
656
+ }
657
+
658
+ routeReferences.set(file, inference.references);
659
+
660
+ for (const reference of inference.references) {
625
661
  const key = `${reference.moduleId}#${reference.exportName}`;
626
662
 
627
663
  if (!entries.has(key)) {
628
- entries.set(key, reference);
664
+ entries.set(key, {
665
+ exportName: reference.exportName,
666
+ inferred: reference.inferred,
667
+ moduleId: reference.moduleId,
668
+ });
629
669
  }
630
670
  }
631
671
  }
632
672
  }
633
673
 
634
- return [...entries.values()].sort((left, right) =>
635
- left.moduleId === right.moduleId
636
- ? left.exportName.localeCompare(right.exportName)
637
- : left.moduleId.localeCompare(right.moduleId),
638
- );
674
+ return {
675
+ allowedActions: [...entries.values()].sort((left, right) =>
676
+ left.moduleId === right.moduleId
677
+ ? left.exportName.localeCompare(right.exportName)
678
+ : left.moduleId.localeCompare(right.moduleId),
679
+ ),
680
+ routeReferences,
681
+ };
639
682
  }
640
683
 
641
684
  function collectBuildInferredServerActionReferences(options: {
@@ -643,54 +686,25 @@ function collectBuildInferredServerActionReferences(options: {
643
686
  files: Record<string, string>;
644
687
  relativeRoutesDir: string;
645
688
  source: string;
646
- }): BuiltServerActionReference[] {
647
- const actionNames = collectFormActionNames(options.source);
648
-
649
- if (actionNames.size === 0) {
650
- return [];
651
- }
652
-
653
- const references: BuiltServerActionReference[] = [];
654
-
655
- for (const match of options.source.matchAll(
656
- /^import\s+\{\s*(?<specifiers>[^}]+)\s*\}\s+from\s+["'](?<source>[^"']+)["'];?/gm,
657
- )) {
658
- const source = match.groups?.source;
659
- const specifiers = match.groups?.specifiers;
660
-
661
- if (source === undefined || specifiers === undefined || !source.startsWith(".")) {
662
- continue;
663
- }
664
-
665
- const localFile = resolveBuildLocalSourceImport(options.files, options.file, source);
666
-
667
- if (localFile === undefined) {
668
- continue;
669
- }
670
-
671
- const moduleId = moduleIdForBuildFile(localFile, options.relativeRoutesDir);
672
-
673
- for (const specifier of specifiers.split(",")) {
674
- const [exportName, localName] = specifier.trim().split(/\s+as\s+/);
675
- const imported = exportName?.trim();
676
- const local = localName?.trim() ?? imported;
677
-
678
- if (
679
- imported !== undefined &&
680
- imported.length > 0 &&
681
- local !== undefined &&
682
- actionNames.has(local)
683
- ) {
684
- references.push({ moduleId, exportName: imported, inferred: true });
685
- }
686
- }
687
- }
688
-
689
- return references;
689
+ }): Promise<{
690
+ diagnostics: { code: string; message: string }[];
691
+ references: BuiltServerActionExpressionReference[];
692
+ }> {
693
+ return collectBuildInferredServerActions({
694
+ file: options.file,
695
+ files: options.files,
696
+ relativeRoutesDir: options.relativeRoutesDir,
697
+ resolveSourceImport: (importer, source) =>
698
+ resolveBuildLocalSourceImport(options.files, importer, source),
699
+ source: options.source,
700
+ });
690
701
  }
691
702
 
692
- function collectFormActionNames(code: string): Set<string> {
693
- return new Set(collectFormActionReferenceNames({ code }));
703
+ function formatServerActionInferenceDiagnostic(diagnostic: {
704
+ code: string;
705
+ message: string;
706
+ }): string {
707
+ return `${diagnostic.code}: ${diagnostic.message}`;
694
708
  }
695
709
 
696
710
  function isSourceModuleFile(file: string): boolean {
@@ -1038,7 +1052,10 @@ async function buildServerModuleArtifacts(options: {
1038
1052
  const clientInference = await inferClientRouteModule({
1039
1053
  ...(route === undefined ? {} : { appDir: options.project.routesDir }),
1040
1054
  cache: options.clientRouteInferenceCache,
1041
- code: stripRouteClientOnlyExports(source),
1055
+ code:
1056
+ route === undefined
1057
+ ? stripRouteClientOnlyExports(source)
1058
+ : stripRouteClientSource({ code: source, filename: route.file }),
1042
1059
  filename: join(options.projectRoot, file),
1043
1060
  ...(route === undefined ? {} : { routePath: route.path }),
1044
1061
  vitePlugins: options.vitePlugins,
@@ -2467,7 +2484,7 @@ async function writeClientRouteBundles(options: {
2467
2484
  vitePlugins: options.vitePlugins,
2468
2485
  });
2469
2486
  const source = await readFile(route.file, "utf8");
2470
- const clientSource = stripRouteClientOnlyExports(source);
2487
+ const clientSource = stripRouteClientSource({ code: source, filename: route.file });
2471
2488
  const navigation = detectNavigationRuntimeHint(source);
2472
2489
  const references = await collectClientRouteReferences({
2473
2490
  appDir: options.appDir,
package/src/client.ts CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  } from "./bundle-pipeline.js";
30
30
  import type { AppRoute } from "./routes.js";
31
31
  import { existingRouteShellCandidates } from "./route-shells.js";
32
- import { stripRouteClientOnlyExports } from "./route-source.js";
32
+ import { stripRouteClientSource } from "./route-source.js";
33
33
  import { hasJsxSyntax } from "./source-jsx.js";
34
34
  import { sourceModuleCandidates } from "./source-modules.js";
35
35
  import { escapeHtmlQuotedAttribute as escapeHtmlAttribute } from "@reckona/mreact-shared/html-escape";
@@ -147,7 +147,7 @@ export async function routeToClientManifestEntry(
147
147
 
148
148
  const code = await readFile(route.file, "utf8");
149
149
  const inference = await inferClientRouteModule({
150
- code: stripRouteClientOnlyExports(code),
150
+ code: stripRouteClientSource({ code, filename: route.file }),
151
151
  filename: route.file,
152
152
  routePath: route.path,
153
153
  });
@@ -311,9 +311,10 @@ export async function collectClientRouteReferences(options: {
311
311
  });
312
312
 
313
313
  for (const referenceFile of inference.clientReferenceSourceFiles) {
314
- const code = stripRouteClientOnlyExports(
315
- await readClientRouteSource({ cache, filename: referenceFile, sourceTransform }),
316
- );
314
+ const code = stripRouteClientSource({
315
+ code: await readClientRouteSource({ cache, filename: referenceFile, sourceTransform }),
316
+ filename: referenceFile,
317
+ });
317
318
  await addSource({ code, filename: referenceFile });
318
319
  }
319
320
  };
@@ -327,9 +328,10 @@ export async function collectClientRouteReferences(options: {
327
328
 
328
329
  if (options.appDir !== undefined) {
329
330
  for (const shell of await clientShellFilesForPage(options.appDir, options.filename)) {
330
- const code = stripRouteClientOnlyExports(
331
- await readClientRouteSource({ cache, filename: shell, sourceTransform }),
332
- );
331
+ const code = stripRouteClientSource({
332
+ code: await readClientRouteSource({ cache, filename: shell, sourceTransform }),
333
+ filename: shell,
334
+ });
333
335
  const moduleContext = await compilerModuleContextForSource({
334
336
  cache,
335
337
  code,
@@ -419,13 +421,14 @@ async function inferClientRouteShellModules(options: {
419
421
  }): Promise<ClientRouteInferenceResult[]> {
420
422
  return Promise.all(
421
423
  (await clientShellFilesForPage(options.appDir, options.filename)).map(async (shell) => {
422
- const code = stripRouteClientOnlyExports(
423
- await readClientRouteSource({
424
+ const code = stripRouteClientSource({
425
+ code: await readClientRouteSource({
424
426
  cache: options.cache,
425
427
  filename: shell,
426
428
  sourceTransform: options.sourceTransform,
427
429
  }),
428
- );
430
+ filename: shell,
431
+ });
429
432
  return await inferClientRouteModuleSource({
430
433
  cache: options.cache,
431
434
  code,
package/src/index.ts CHANGED
@@ -157,6 +157,7 @@ export {
157
157
  export type {
158
158
  AppRouterAllowedServerAction,
159
159
  AppRouterServerActionOptions,
160
+ PreparedFormActionReference,
160
161
  } from "./actions.js";
161
162
  /**
162
163
  * @deprecated Import session helpers and types from `@reckona/mreact-auth` instead.
package/src/render.ts CHANGED
@@ -45,6 +45,7 @@ import type { AppRoute, RouteMatcher } from "./routes.js";
45
45
  import { appFileConventionContentType, type AppFileConvention } from "./file-conventions.js";
46
46
  import {
47
47
  type AppRouterServerActionOptions,
48
+ type PreparedFormActionReference,
48
49
  dispatchServerActionRequest,
49
50
  prepareRouteServerActions,
50
51
  } from "./actions.js";
@@ -148,6 +149,10 @@ export interface RenderAppRequestOptions {
148
149
  serverModuleCacheVersion?: string | undefined;
149
150
  serverSourceFiles?: ReadonlyMap<string, string> | undefined;
150
151
  serverActions?: AppRouterServerActionOptions | undefined;
152
+ serverActionReferencesByFile?: ReadonlyMap<
153
+ string,
154
+ readonly PreparedFormActionReference[]
155
+ > | undefined;
151
156
  skipMiddleware?: boolean | undefined;
152
157
  preload?: AppRouterRenderPreload | undefined;
153
158
  vitePlugins?: readonly PluginOption[] | undefined;
@@ -904,9 +909,13 @@ async function renderAppRequestInternal(options: RenderAppRequestOptions): Promi
904
909
  const preparedActions = await prepareRouteServerActions({
905
910
  appDir: options.appDir,
906
911
  code: originalCode,
912
+ formActionReferences: options.serverActionReferencesByFile?.get(matched.route.file),
907
913
  pageFile: matched.route.file,
908
914
  request: options.request,
909
915
  });
916
+ for (const diagnostic of preparedActions.diagnostics ?? []) {
917
+ console.warn(`${diagnostic.code}: ${diagnostic.message}`);
918
+ }
910
919
  finishRenderTimingPhase(timing, phaseStartedAt, "serverActionsMs");
911
920
  const code = preparedActions.code;
912
921
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
@@ -1,5 +1,6 @@
1
1
  import { dirname, isAbsolute, join } from "node:path";
2
2
  import {
3
+ collectFormActionExpressionReferences,
3
4
  collectTopLevelValueExportNames,
4
5
  collectJsxComponentRootNames,
5
6
  collectStaticImportReferences,
@@ -49,6 +50,30 @@ export function stripRouteClientOnlyExports(code: string): string {
49
50
  }));
50
51
  }
51
52
 
53
+ export function stripRouteClientSource(input: {
54
+ code: string;
55
+ filename?: string | undefined;
56
+ }): string {
57
+ return stripRouteClientFormActionExpressions({
58
+ code: stripRouteClientOnlyExports(input.code),
59
+ filename: input.filename,
60
+ });
61
+ }
62
+
63
+ export function stripRouteClientFormActionExpressions(input: {
64
+ code: string;
65
+ filename?: string | undefined;
66
+ }): string {
67
+ const references = collectFormActionExpressionReferences(input);
68
+ let code = input.code;
69
+
70
+ for (const reference of [...references].reverse()) {
71
+ code = `${code.slice(0, reference.expressionStart)}undefined${code.slice(reference.expressionEnd)}`;
72
+ }
73
+
74
+ return code;
75
+ }
76
+
52
77
  export function stripRouteBuildExports(code: string): string {
53
78
  return stripRouteClientOnlyExports(code);
54
79
  }
package/src/serve.ts CHANGED
@@ -50,6 +50,20 @@ interface BuiltRuntime {
50
50
  prerenderedRoutes: Map<string, BuiltPrerenderedRoute>;
51
51
  routeMatcher: RouteMatcher;
52
52
  routes: readonly AppRoute[];
53
+ serverActionReferencesByFile: ReadonlyMap<
54
+ string,
55
+ readonly {
56
+ end: number;
57
+ expression: string;
58
+ expressionEnd: number;
59
+ expressionStart: number;
60
+ moduleId: string;
61
+ exportName: string;
62
+ inferred: boolean;
63
+ sourceHash: string;
64
+ start: number;
65
+ }[]
66
+ >;
53
67
  serverActionManifest?: readonly { moduleId: string; exportName: string; inferred?: boolean }[] | undefined;
54
68
  serverModuleArtifactLoads: Map<string, Promise<void>>;
55
69
  serverModuleFiles: ReadonlyMap<string, string>;
@@ -701,6 +715,12 @@ async function materializeBuiltRuntime(options: {
701
715
  const serverSourceFiles = new Map(
702
716
  Object.entries(serverManifest.files).map(([file, source]) => [join(appDir, file), source]),
703
717
  );
718
+ const serverActionReferencesByFile = new Map(
719
+ Object.entries(serverManifest.routeServerActionReferences ?? {}).map(([file, references]) => [
720
+ join(appDir, file),
721
+ references,
722
+ ]),
723
+ );
704
724
  const routeMatcher = createRouteMatcher(routes);
705
725
  const clientScripts = new Map(
706
726
  clientManifest.routes.flatMap((route) =>
@@ -752,6 +772,7 @@ async function materializeBuiltRuntime(options: {
752
772
  prerenderedRoutes,
753
773
  routeMatcher,
754
774
  routes,
775
+ serverActionReferencesByFile,
755
776
  ...(serverManifest.serverActionManifest === undefined
756
777
  ? {}
757
778
  : { serverActionManifest: serverManifest.serverActionManifest }),
@@ -1070,6 +1091,7 @@ function builtRenderAppRequestOptions(
1070
1091
  serverModules: options.runtime.serverModules,
1071
1092
  serverModuleCacheVersion: options.runtime.serverModuleCacheVersion,
1072
1093
  serverSourceFiles: options.runtime.serverSourceFiles,
1094
+ serverActionReferencesByFile: options.runtime.serverActionReferencesByFile,
1073
1095
  serverActions: mergeBuiltServerActionOptions(
1074
1096
  options.serverActions,
1075
1097
  options.runtime.serverActionManifest,