flamefront 0.1.1 → 0.1.2

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/server.ts CHANGED
@@ -9,6 +9,27 @@ import {
9
9
  type RouteDefinition,
10
10
  } from "./index.ts"
11
11
  import { stripFlamefrontProtocolRequest } from "./fragment-protocol.ts"
12
+ import {
13
+ actionErrorResponse,
14
+ actionResultResponse,
15
+ executeRegisteredAction,
16
+ getRegisteredAction,
17
+ isSameOriginActionRequest,
18
+ parseActionArguments,
19
+ } from "./action.ts"
20
+
21
+ export { action } from "./action.ts"
22
+ export type {
23
+ ActionDataWithResponseInit,
24
+ ActionFunction,
25
+ ActionInput,
26
+ ActionOutput,
27
+ ActionValidationError,
28
+ StandardSchema,
29
+ StandardSchemaIssue,
30
+ StandardSchemaV1,
31
+ } from "./action.ts"
32
+ export { data, redirect } from "@octanejs/remix-router"
12
33
 
13
34
  type LoaderPath<ContextOrPath, PathOrContext> =
14
35
  ContextOrPath extends `/${string}`
@@ -32,12 +53,24 @@ export interface LoaderArgs<ContextOrPath = unknown, PathOrContext = unknown> {
32
53
  readonly context: LoaderContext<ContextOrPath, PathOrContext>
33
54
  }
34
55
 
56
+ /** Arguments passed to a page module's HTTP action. */
57
+ export interface ActionArgs<
58
+ ContextOrPath = unknown,
59
+ PathOrContext = unknown,
60
+ > extends LoaderArgs<ContextOrPath, PathOrContext> {}
61
+
35
62
  export type Loader<
36
63
  Data = unknown,
37
64
  Context = unknown,
38
65
  Path extends string = string,
39
66
  > = (args: LoaderArgs<Context, Path>) => Data | Promise<Data>
40
67
 
68
+ export type RouteAction<
69
+ Data = unknown,
70
+ Context = unknown,
71
+ Path extends string = string,
72
+ > = (args: ActionArgs<Context, Path>) => Data | Promise<Data>
73
+
41
74
  export interface RouteModule<
42
75
  Data = unknown,
43
76
  Context = unknown,
@@ -45,10 +78,11 @@ export interface RouteModule<
45
78
  > {
46
79
  readonly default: unknown
47
80
  readonly loader?: Loader<Data, Context, Path>
81
+ readonly action?: RouteAction<unknown, Context, Path>
48
82
  }
49
83
 
50
84
  export type DocumentMode = "shell" | RenderMode
51
- export type RequestPurpose = "data" | "document"
85
+ export type RequestPurpose = "data" | "document" | "action"
52
86
 
53
87
  /** Inputs for constructing one request-scoped value for route work. */
54
88
  export interface RequestContextArgs<
@@ -71,7 +105,10 @@ export type RouteImporter<
71
105
  Data = unknown,
72
106
  Context = unknown,
73
107
  Path extends string = string,
74
- > = (entry: string) => Promise<RouteModule<Data, Context, Path>>
108
+ > = ((entry: string) => Promise<RouteModule<Data, Context, Path>>) & {
109
+ /** Optional eager import hook used by generated server action registries. */
110
+ readonly loadActions?: () => void | Promise<void>
111
+ }
75
112
 
76
113
  export interface RenderedDocument {
77
114
  readonly html: string
@@ -156,6 +193,7 @@ export interface RouteRuntime<
156
193
  options?: RouteLoadOptions<Context>,
157
194
  ) => Promise<LoadedRouteFor<Route, Context> | null>
158
195
  readonly loadRouteData: (request: Request) => Promise<Response>
196
+ readonly loadAction: (request: Request) => Promise<Response>
159
197
  }
160
198
 
161
199
  export interface RouteRuntimeOptions<
@@ -164,6 +202,8 @@ export interface RouteRuntimeOptions<
164
202
  > {
165
203
  readonly app: AppDefinition<Route>
166
204
  readonly importRoute: RouteImporter<unknown, Context>
205
+ /** Eagerly load generated action modules before direct dispatch. */
206
+ readonly loadActions?: () => void | Promise<void>
167
207
  /** Build request context for data requests and document router queries. */
168
208
  readonly requestContext?: RequestContextFactory<Context, Route>
169
209
  }
@@ -279,6 +319,71 @@ export function createRouteRuntime<
279
319
  return Response.json(loaded.loaderData ?? null)
280
320
  }
281
321
 
322
+ const loadAction = async (request: Request): Promise<Response> => {
323
+ const sanitizedRequest = stripFlamefrontProtocolRequest(request)
324
+ const url = new URL(sanitizedRequest.url)
325
+
326
+ if (["GET", "HEAD", "OPTIONS"].includes(sanitizedRequest.method)) {
327
+ return new Response("Method not allowed.", { status: 405 })
328
+ }
329
+
330
+ if (!isSameOriginActionRequest(sanitizedRequest)) {
331
+ return new Response("Forbidden.", { status: 403 })
332
+ }
333
+
334
+ const actionId = url.searchParams.get("action")
335
+
336
+ if (actionId) {
337
+ try {
338
+ const args = await parseActionArguments(sanitizedRequest)
339
+
340
+ if (!getRegisteredAction(actionId)) {
341
+ await (options.loadActions ?? options.importRoute.loadActions)?.()
342
+ }
343
+
344
+ return await executeRegisteredAction(actionId, args)
345
+ } catch (error) {
346
+ return actionErrorResponse(error)
347
+ }
348
+ }
349
+
350
+ const match = options.app.match(sanitizedRequest.url)
351
+
352
+ if (!match) {
353
+ return new Response("Not found.", { status: 404 })
354
+ }
355
+
356
+ if (match.data.render === "static") {
357
+ return new Response(
358
+ "Static routes cannot define actions; submit to a server route instead.",
359
+ { status: 405 },
360
+ )
361
+ }
362
+
363
+ const routeModule = await options.importRoute(match.data.entry)
364
+
365
+ if (!routeModule.action) {
366
+ return new Response("Method not allowed.", { status: 405 })
367
+ }
368
+
369
+ try {
370
+ const context = await createRequestContext(
371
+ sanitizedRequest,
372
+ { purpose: "action", mode: match.data.render },
373
+ match,
374
+ )
375
+ const value = await routeModule.action({
376
+ request: sanitizedRequest,
377
+ params: match.params as ActionArgs<Context, string>["params"],
378
+ context: context as LoaderContext<Context, string>,
379
+ })
380
+
381
+ return actionResultResponse(value)
382
+ } catch (error) {
383
+ return actionErrorResponse(error)
384
+ }
385
+ }
386
+
282
387
  return {
283
388
  app: options.app,
284
389
  importRoute: options.importRoute,
@@ -287,6 +392,7 @@ export function createRouteRuntime<
287
392
  createRequestContext(request, contextOptions),
288
393
  loadRoute: loadRouteForRequest,
289
394
  loadRouteData,
395
+ loadAction,
290
396
  }
291
397
  }
292
398
 
package/src/srvx.ts CHANGED
@@ -133,6 +133,10 @@ export function createSrvxServerEntry<
133
133
  return next()
134
134
  }
135
135
 
136
+ if (!["GET", "HEAD", "OPTIONS"].includes(request.method)) {
137
+ return next()
138
+ }
139
+
136
140
  return serveClientFile(
137
141
  staticRequest(request, options.app.routing.basename),
138
142
  next,
@@ -169,6 +173,7 @@ export function createSrvxServerEntry<
169
173
  middleware: [...(options.middleware ?? []), frameworkMiddleware],
170
174
  renderDocument: fetchEntry.renderDocument,
171
175
  loadRouteData: fetchEntry.loadRouteData,
176
+ ...(fetchEntry.loadAction ? { loadAction: fetchEntry.loadAction } : {}),
172
177
  renderFragment: fetchEntry.renderFragment,
173
178
  }
174
179
  }
@@ -12,17 +12,17 @@ function isWithin(directory: string, filePath: string): boolean {
12
12
  )
13
13
  }
14
14
 
15
- function staticRoutePath(
15
+ export function staticRoutePathForPath(
16
16
  clientDirectory: string,
17
- route: RouteDefinition,
17
+ routePath: string,
18
18
  ): string {
19
- if (/[:*]/.test(route.path)) {
19
+ if (/[:*]/.test(routePath)) {
20
20
  throw new Error(
21
- `Cannot prerender parameterized static route ${JSON.stringify(route.path)} without concrete paths.`,
21
+ `Cannot prerender parameterized static route ${JSON.stringify(routePath)} without concrete paths.`,
22
22
  )
23
23
  }
24
24
 
25
- const segments = route.path
25
+ const segments = routePath
26
26
  .split("/")
27
27
  .filter(Boolean)
28
28
  .map((segment) => decodeURIComponent(segment))
@@ -33,7 +33,7 @@ function staticRoutePath(
33
33
  )
34
34
  ) {
35
35
  throw new Error(
36
- `Cannot write unsafe static route path ${JSON.stringify(route.path)}.`,
36
+ `Cannot write unsafe static route path ${JSON.stringify(routePath)}.`,
37
37
  )
38
38
  }
39
39
 
@@ -41,13 +41,20 @@ function staticRoutePath(
41
41
 
42
42
  if (!isWithin(clientDirectory, filePath)) {
43
43
  throw new Error(
44
- `Cannot write static route outside the client build: ${JSON.stringify(route.path)}.`,
44
+ `Cannot write static route outside the client build: ${JSON.stringify(routePath)}.`,
45
45
  )
46
46
  }
47
47
 
48
48
  return filePath
49
49
  }
50
50
 
51
+ function staticRoutePath(
52
+ clientDirectory: string,
53
+ route: RouteDefinition,
54
+ ): string {
55
+ return staticRoutePathForPath(clientDirectory, route.path)
56
+ }
57
+
51
58
  export function staticRouteFile(
52
59
  clientDirectory: string,
53
60
  route: RouteDefinition,
@@ -17,6 +17,7 @@ declare module "virtual:flamefront/server-routes" {
17
17
  import type { RouteModule } from "./server.ts"
18
18
 
19
19
  export function importRoute(entry: string): Promise<RouteModule>
20
+ export function loadActions(): Promise<void>
20
21
  }
21
22
 
22
23
  declare module "virtual:flamefront/server-entry" {
@@ -0,0 +1,29 @@
1
+ import type { PrerenderOptions } from "./prerender.ts"
2
+
3
+ export interface RegisteredFlamefrontOptions {
4
+ readonly prerender?: PrerenderOptions
5
+ readonly markdown?: unknown
6
+ readonly target?: string
7
+ }
8
+
9
+ const byRoot = new Map<string, RegisteredFlamefrontOptions>()
10
+ let pending: RegisteredFlamefrontOptions | undefined
11
+
12
+ export function registerFlamefrontOptions(
13
+ options: RegisteredFlamefrontOptions,
14
+ ): void {
15
+ pending = options
16
+ }
17
+
18
+ export function registerFlamefrontRoot(
19
+ root: string,
20
+ options: RegisteredFlamefrontOptions,
21
+ ): void {
22
+ byRoot.set(root, options)
23
+ }
24
+
25
+ export function getFlamefrontOptions(
26
+ root: string,
27
+ ): RegisteredFlamefrontOptions | undefined {
28
+ return byRoot.get(root) ?? pending
29
+ }
package/src/vite.ts CHANGED
@@ -15,12 +15,22 @@ import type {
15
15
  import { generate, parse, traverse, type Babel } from "./babel.ts"
16
16
  import { expandGlob, globDirectory, setGlobRoot } from "./glob.ts"
17
17
  import { removeExports } from "./remove-exports.ts"
18
+ import {
19
+ findActionExports,
20
+ generateActionProxyModule,
21
+ transformServerActions,
22
+ } from "./action-transform.ts"
18
23
  import { writeRouteImportMap } from "./typegen.ts"
19
24
  import {
20
25
  resolveFlamefrontOutput,
21
26
  type FlamefrontOutputOptions,
22
27
  type ResolvedFlamefrontOutput,
23
28
  } from "./output.ts"
29
+ import type { PrerenderOptions } from "./prerender.ts"
30
+ import {
31
+ registerFlamefrontOptions,
32
+ registerFlamefrontRoot,
33
+ } from "./vite-options.ts"
24
34
 
25
35
  export type {
26
36
  FlamefrontAdapter,
@@ -37,11 +47,13 @@ const resolvedServerRoutesId = `\0${serverRoutesId}`
37
47
 
38
48
  export const serverEntryId = "virtual:flamefront/server-entry"
39
49
  const resolvedServerEntryId = `\0${serverEntryId}`
50
+ const actionProxyId = "\0flamefront/action-proxy"
51
+ const actionProxyPrefix = `${actionProxyId}?module=`
40
52
  const hydrationRouteId = "/@flamefront/hydration-route.tsrx"
41
53
  const resolvedHydrationRoutePrefix = `${hydrationRouteId}?`
42
54
  const markdownRouteId = "/@flamefront/markdown-route.tsrx"
43
55
  const resolvedMarkdownRoutePrefix = `${markdownRouteId}?`
44
- const SERVER_ONLY_ROUTE_EXPORTS = ["loader"] as const
56
+ const SERVER_ONLY_ROUTE_EXPORTS = ["loader", "action"] as const
45
57
  const serverFilePattern = /\.server(?:\.[cm]?[jt]sx?|\.tsrx)$/
46
58
  const serverDirectoryPattern = /\/\.server\//
47
59
 
@@ -50,6 +62,8 @@ export interface FlamefrontOptions extends FlamefrontOutputOptions {
50
62
  readonly routes?: string
51
63
  /** Built-in Markdown and MDX compiler configuration. */
52
64
  readonly markdown?: false | MarkdownOptions
65
+ /** Configure incremental static rendering and its persistent cache. */
66
+ readonly prerender?: PrerenderOptions
53
67
  }
54
68
 
55
69
  export interface MarkdownOptions {
@@ -210,21 +224,22 @@ function lazyRoute(
210
224
  routeDefinition.render === "client"
211
225
  ? `(args) => ${browserLoader}(args, ${JSON.stringify(routing)})`
212
226
  : `(args) => ${browserLoader}(args, ${JSON.stringify(routing)}, ${quote(routeDefinition.render)})`
227
+ const browserActionExpression = `(args) => submitRouteAction(args, ${JSON.stringify(routing)})`
213
228
 
214
229
  if (routeDefinition.render !== "client") {
215
230
  const componentId = browserRouteModuleId(routeDefinition)
216
231
 
217
- return `async () => { if (import.meta.env.SSR) { const [routeModule, componentModule] = await Promise.all([import(${quote(entry)}), import(${quote(componentId)})]); return { Component: createRouteBoundary(componentModule.default, ${JSON.stringify(metadata)}), loader: routeModule.loader }; } const componentModule = await import(${quote(componentId)}); return { Component: createRouteFragmentRoute({ metadata: ${JSON.stringify(metadata)}, routing: ${JSON.stringify(routing)}, policy: ${quote(routeDefinition.render)}, fallbackComponent: componentModule.default }), loader: ${browserLoaderExpression} }; }`
232
+ return `async () => { if (import.meta.env.SSR) { const [routeModule, componentModule] = await Promise.all([import(${quote(entry)}), import(${quote(componentId)})]); return { Component: createRouteBoundary(componentModule.default, ${JSON.stringify(metadata)}), loader: routeModule.loader, action: routeModule.action }; } const componentModule = await import(${quote(componentId)}); return { Component: createRouteFragmentRoute({ metadata: ${JSON.stringify(metadata)}, routing: ${JSON.stringify(routing)}, policy: ${quote(routeDefinition.render)}, fallbackComponent: componentModule.default }), loader: ${browserLoaderExpression}, action: ${browserActionExpression} }; }`
218
233
  }
219
234
 
220
235
  if (routeDefinition.render === "client") {
221
236
  if (routeDefinition.content === "markdown") {
222
237
  const componentId = browserRouteModuleId(routeDefinition)
223
238
 
224
- return `async () => { if (import.meta.env.SSR) return {}; const componentModule = await import(${quote(componentId)}); return { Component: createRouteBoundary(componentModule.default, ${JSON.stringify(metadata)}), loader: ${browserLoaderExpression} }; }`
239
+ return `async () => { if (import.meta.env.SSR) return {}; const componentModule = await import(${quote(componentId)}); return { Component: createRouteBoundary(componentModule.default, ${JSON.stringify(metadata)}), loader: ${browserLoaderExpression}, action: ${browserActionExpression} }; }`
225
240
  }
226
241
 
227
- return `async () => { if (import.meta.env.SSR) return {}; const routeModule = await import(${quote(entry)}); return { Component: createRouteBoundary(routeModule.default, ${JSON.stringify(metadata)}), loader: ${browserLoaderExpression} }; }`
242
+ return `async () => { if (import.meta.env.SSR) return {}; const routeModule = await import(${quote(entry)}); return { Component: createRouteBoundary(routeModule.default, ${JSON.stringify(metadata)}), loader: ${browserLoaderExpression}, action: ${browserActionExpression} }; }`
228
243
  }
229
244
 
230
245
  throw new Error(`Unsupported route render mode ${routeDefinition.render}.`)
@@ -290,6 +305,12 @@ function generateConfigs(
290
305
  return `${indent}{\n${childIndent}id: ${quote(metadata.id)},\n${childIndent}lazy: ${lazyLayout(config.entry, metadata)},\n${childIndent}handle: { flamefront: ${JSON.stringify(metadata)} },\n${childIndent}children: [\n${generateConfigs(config.children, routing, depth + 2, metadata.id, location)}\n${childIndent}],\n${indent}}`
291
306
  }
292
307
 
308
+ if (/\.(md|mdx)$/.test(cleanModuleId(config.entry))) {
309
+ const lazy = lazyRoute(config, routing, metadata)
310
+
311
+ return `${indent}{\n${childIndent}id: ${quote(metadata.id)},\n${childIndent}path: ${quote(config.path)},\n${childIndent}lazy: async () => { const [routeModule, resolved] = await Promise.all([import(${quote(config.entry)}), (${lazy})()]); return { ...resolved, handle: { flamefront: ${JSON.stringify(metadata)}, frontmatter: routeModule.frontmatter ?? {} } }; },\n${indent}}`
312
+ }
313
+
293
314
  return `${indent}{\n${childIndent}id: ${quote(metadata.id)},\n${childIndent}path: ${quote(config.path)},\n${childIndent}lazy: ${lazyRoute(config, routing, metadata)},\n${childIndent}handle: { flamefront: ${JSON.stringify(metadata)} },\n${indent}}`
294
315
  })
295
316
  .join(",\n")
@@ -308,19 +329,31 @@ export function generateRemixRoutes(
308
329
  app.shellHydration,
309
330
  )
310
331
 
311
- return `// Generated by Flamefront.\nimport Shell from ${quote(app.shell)};\nimport { RouterDocument } from 'flamefront/octane/router-document';\nimport { createRouteBoundary, createRouteFragmentRoute } from 'flamefront/fragment';\nimport { loadRouteData, loadRouteFragment } from 'flamefront/remix-router/data';\n\nexport { RouterDocument };\nexport const routing = ${JSON.stringify(app.routing)};\nexport const routeMetadata = ${JSON.stringify(routeMetadata)};\n\nexport const routes = [\n\t{\n\t\tid: ${quote(rootId)},\n\t\tComponent: createRouteBoundary(Shell, ${JSON.stringify(routeMetadata[0])}),\n\t\thandle: { flamefront: ${JSON.stringify(routeMetadata[0])} },\n\t\tchildren: [\n${generateConfigs(app.routeTree, app.routing, 3)}\n\t\t],\n\t},\n];\n\n${generateRoutePreloaders(app.routeTree)}`
332
+ return `// Generated by Flamefront.\nimport Shell from ${quote(app.shell)};\nimport { RouterDocument } from 'flamefront/octane/router-document';\nimport { createRouteBoundary, createRouteFragmentRoute } from 'flamefront/fragment';\nimport { loadRouteData, loadRouteFragment } from 'flamefront/remix-router/data';\nimport { submitRouteAction } from 'flamefront/remix-router/data';\n\nexport { RouterDocument };\nexport const routing = ${JSON.stringify(app.routing)};\nexport const routeMetadata = ${JSON.stringify(routeMetadata)};\n\nexport const routes = [\n\t{\n\t\tid: ${quote(rootId)},\n\t\tComponent: createRouteBoundary(Shell, ${JSON.stringify(routeMetadata[0])}),\n\t\thandle: { flamefront: ${JSON.stringify(routeMetadata[0])} },\n\t\tchildren: [\n${generateConfigs(app.routeTree, app.routing, 3)}\n\t\t],\n\t},\n];\n\n${generateRoutePreloaders(app.routeTree)}`
312
333
  }
313
334
 
314
335
  /** Generate the server-only route-module importer used by loader endpoints. */
315
336
  export function generateServerRoutes(
316
- app: Pick<AppDefinition, "routes">,
337
+ app: Pick<AppDefinition, "routes" | "shell" | "routeTree">,
317
338
  ): string {
318
- const entries = [...new Set(app.routes.map((route) => route.entry))]
319
- const imports = entries
339
+ const entries = new Set<string>([app.shell])
340
+ const collect = (configs: readonly RouteConfig[]) => {
341
+ for (const config of configs) {
342
+ entries.add(config.entry)
343
+
344
+ if ("children" in config) {
345
+ collect(config.children)
346
+ }
347
+ }
348
+ }
349
+
350
+ collect(app.routeTree)
351
+
352
+ const imports = [...entries]
320
353
  .map((entry) => `\t${quote(entry)}: () => import(${quote(entry)})`)
321
354
  .join(",\n")
322
355
 
323
- return `// Generated by Flamefront.\nconst routeModules = {\n${imports}\n};\n\nexport async function importRoute(entry) {\n\tconst importModule = routeModules[entry];\n\tif (!importModule) throw new Error(\`No Vite route module was generated for \${entry}.\`);\n\treturn importModule();\n}\n`
356
+ return `// Generated by Flamefront.\nconst routeModules = {\n${imports}\n};\n\nlet actionModulesPromise;\nexport function loadActions() {\n\tactionModulesPromise ??= Promise.all(Object.values(routeModules).map((load) => load())).then(() => undefined);\n\treturn actionModulesPromise;\n}\n\nexport async function importRoute(entry) {\n\tconst importModule = routeModules[entry];\n\tif (!importModule) throw new Error(\`No Vite route module was generated for \${entry}.\`);\n\treturn importModule();\n}\n\nimportRoute.loadActions = loadActions;\n`
324
357
  }
325
358
 
326
359
  /** Generate the server-entry import selected by `flamefront({...})`. */
@@ -556,7 +589,7 @@ function isPathWithinDirectory(directory: string, candidate: string): boolean {
556
589
  }
557
590
 
558
591
  function isServerEnvironment(
559
- context: PluginContext,
592
+ context: Pick<PluginContext, "environment">,
560
593
  options?: TransformOptions,
561
594
  ): boolean {
562
595
  return (
@@ -684,6 +717,12 @@ export function removeServerRouteExports(source: string, id = "route.js") {
684
717
 
685
718
  export function flamefront(options: FlamefrontOptions = {}) {
686
719
  const output = resolveFlamefrontOutput(options)
720
+
721
+ registerFlamefrontOptions({
722
+ prerender: options.prerender,
723
+ markdown: options.markdown,
724
+ target: options.target,
725
+ })
687
726
  let root = process.cwd()
688
727
  let serverBuild = false
689
728
  let appPromise: Promise<AppDefinition> | undefined
@@ -723,6 +762,11 @@ export function flamefront(options: FlamefrontOptions = {}) {
723
762
  writeRouteImportMap(await loadApp(), { root })
724
763
  const configureRoot = (config: { readonly root: string }) => {
725
764
  root = config.root
765
+ registerFlamefrontRoot(root, {
766
+ prerender: options.prerender,
767
+ markdown: options.markdown,
768
+ target: options.target,
769
+ })
726
770
  }
727
771
 
728
772
  const frameworkModulesPlugin = {
@@ -732,7 +776,44 @@ export function flamefront(options: FlamefrontOptions = {}) {
732
776
  async buildStart() {
733
777
  await generateTypes()
734
778
  },
735
- transform(source: string, id: string) {
779
+ transform(
780
+ this: object,
781
+ source: string,
782
+ id: string,
783
+ transformOptions?: TransformOptions,
784
+ ) {
785
+ const context = this as Pick<PluginContext, "environment">
786
+
787
+ if (
788
+ isServerEnvironment(context, transformOptions) &&
789
+ !cleanModuleId(id).endsWith(".tsrx") &&
790
+ source.includes("action") &&
791
+ /flamefront(?:\/server)?["']/.test(source)
792
+ ) {
793
+ const transformedActions = transformServerActions(source, id)
794
+
795
+ if (transformedActions) {
796
+ return {
797
+ code: transformedActions.code,
798
+ map: transformedActions.map,
799
+ }
800
+ }
801
+ }
802
+
803
+ if (
804
+ !cleanModuleId(id).endsWith(".tsrx") &&
805
+ source.includes("action") &&
806
+ /flamefront(?:\/server)?["']/.test(source)
807
+ ) {
808
+ const clientActions = findActionExports(source, id)
809
+
810
+ if (clientActions.length > 0) {
811
+ throw new Error(
812
+ `Flamefront actions must be declared in a *.server.ts module: ${JSON.stringify(path.relative(root, cleanModuleId(id)) || id)}.`,
813
+ )
814
+ }
815
+ }
816
+
736
817
  if (cleanModuleId(id) !== manifestPath()) {
737
818
  return null
738
819
  }
@@ -836,6 +917,10 @@ export function flamefront(options: FlamefrontOptions = {}) {
836
917
  return id
837
918
  }
838
919
 
920
+ if (id.startsWith(actionProxyPrefix)) {
921
+ return id
922
+ }
923
+
839
924
  if (
840
925
  resolveOptions.scan ||
841
926
  isServerEnvironment(this, resolveOptions) ||
@@ -867,10 +952,26 @@ export function flamefront(options: FlamefrontOptions = {}) {
867
952
  return null
868
953
  }
869
954
 
955
+ let actionSource: string
956
+
957
+ try {
958
+ actionSource = fs.readFileSync(resolvedId, "utf8")
959
+ } catch {
960
+ actionSource = ""
961
+ }
962
+
963
+ if (actionSource) {
964
+ const actions = findActionExports(actionSource, resolvedId)
965
+
966
+ if (actions.length > 0) {
967
+ return `${actionProxyPrefix}${encodeURIComponent(resolvedId)}`
968
+ }
969
+ }
970
+
870
971
  const importerId = cleanModuleId(importer)
871
972
  const importerLabel = path.relative(root, importerId) || importerId
872
973
  const routeHint = (await loadRouteModuleIds()).has(importerId)
873
- ? " Flamefront removes server imports used exclusively by `loader`, but this import is still referenced by client code."
974
+ ? " Flamefront removes server imports used exclusively by `loader` or `action`, but this import is still referenced by client code."
874
975
  : ""
875
976
 
876
977
  throw new Error(
@@ -918,6 +1019,23 @@ export function flamefront(options: FlamefrontOptions = {}) {
918
1019
  return generateMarkdownRoute(entry)
919
1020
  }
920
1021
 
1022
+ if (id.startsWith(actionProxyPrefix)) {
1023
+ const moduleId = new URLSearchParams(
1024
+ id.slice(actionProxyId.length + 1),
1025
+ ).get("module")
1026
+
1027
+ if (!moduleId) {
1028
+ throw new TypeError("Flamefront action proxy is missing its module.")
1029
+ }
1030
+
1031
+ const source = fs.readFileSync(moduleId, "utf8")
1032
+
1033
+ return generateActionProxyModule(
1034
+ findActionExports(source, moduleId),
1035
+ (await loadApp()).routing,
1036
+ )
1037
+ }
1038
+
921
1039
  return null
922
1040
  },
923
1041
  }