filesystem-routing 0.3.1 → 0.4.0

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/README.md CHANGED
@@ -190,6 +190,50 @@ server environment's router only (SolidStart pairs it with `components:
190
190
  false` in SPA mode, so the server manifest routes requests without shipping
191
191
  page modules) — see the per-environment `routers` option below.
192
192
 
193
+ ## Server-function pages
194
+
195
+ With `serverComponents: true`, a page whose default export begins with a
196
+ `"use server"` directive is a server-function page:
197
+
198
+ ```tsx
199
+ // src/routes/stories/[id].tsx
200
+ export const route = { live: true }; // any config the emission adapter reads
201
+
202
+ export default async function Story({ params }) {
203
+ "use server";
204
+ const story = await db.stories.get(params.id);
205
+ return props => <article>{story.title}{props.children}</article>;
206
+ }
207
+ ```
208
+
209
+ The scanner sees the directive (on an inline function declaration or
210
+ expression, or a top-level function the default export names — not through
211
+ a wrapper call or a re-export) and records two neutral facts on the entry:
212
+ `server: true`, and a `$component` ref marked `eager` and picking only
213
+ `default`. The client side of such a module is a stub, so there is no code
214
+ worth a chunk: the Vite adapter delivers the ref eagerly (`{ src, require }`)
215
+ even with code splitting on, and it is not a build input. What a
216
+ server-rendered page *means* — how the stub is called, cached, and mounted —
217
+ is the emission adapter's business; the scanner only states the fact.
218
+
219
+ Adapters that carry code only server pages need gate it on
220
+ `filesystem-routing/flags`:
221
+
222
+ ```ts
223
+ import { serverRoutes } from "filesystem-routing/flags";
224
+ import * as server from "./server.js"; // dropped from builds with no server page
225
+
226
+ if (serverRoutes && entry.server) server.wrap(entry);
227
+ ```
228
+
229
+ The package ships `flags` as a real module whose facts are all `true` — the
230
+ safe answer wherever no plugin hook is in the loop (dev prebundling, a
231
+ hand-built manifest, another bundler). In builds the Vite plugin serves the
232
+ scan's answer instead, folded to a literal, so a gated static import
233
+ tree-shakes out of every app without a server page and is an ordinary
234
+ static import — present at hydration — in every app with one. The option is
235
+ off by default; a manifest built without it is byte-identical to before.
236
+
193
237
  ## Custom conventions
194
238
 
195
239
  Conventions plug in at two independent seams, so a custom scheme picks the
@@ -310,6 +354,7 @@ fileRoutes({
310
354
  | --- | --- |
311
355
  | `httpMethods` | emit `$GET`, `$POST`, … refs for uppercase handler exports; a module with handlers but no default export routes without being a page |
312
356
  | `components` | set `false` to route without emitting `$component` refs, keeping page modules out of that environment's bundle |
357
+ | `serverComponents` | recognize server-function pages: a `"use server"` default export is flagged `server: true` and delivered eagerly; scan facts are served from `filesystem-routing/flags` (defaults to `false`) |
313
358
  | `buildInputs` | environments whose build takes every code-split route module as an entry |
314
359
  | `codeSplitting` | set `false` to deliver every module ref as an eager static import — no `lazy()` components, zero dynamic imports, one bundle — for small apps where per-chunk overhead outweighs the split (defaults to `true`; with `@solidjs/router` requires the next release, newer than `2.0.0-next.14`) |
315
360
  | `moduleId` | the id the manifest is served from |
package/dist/analyze.d.ts CHANGED
@@ -8,6 +8,17 @@ export type { StaticExportEntry };
8
8
  * a compiler that already performs export analysis can provide it instead.
9
9
  */
10
10
  export declare function analyzeModule(src: string): StaticExportEntry[];
11
+ export interface RouteModuleAnalysis {
12
+ exports: StaticExportEntry[];
13
+ /**
14
+ * The directive prologue of the default export, when it is a function
15
+ * declared in this module (inline, or a top-level binding it names):
16
+ * `"use server"` marks a page whose component runs on the server.
17
+ */
18
+ defaultDirective?: string;
19
+ }
20
+ /** `analyzeModule` plus what the default export's function body declares. */
21
+ export declare function analyzeRouteModule(src: string): RouteModuleAnalysis;
11
22
  export declare function getExportName(entry: StaticExportEntry): string;
12
23
  /**
13
24
  * Returns the export name only when it is backed by a same-named local
package/dist/analyze.js CHANGED
@@ -24,6 +24,10 @@ function loadTsrxParser(src) {
24
24
  * a compiler that already performs export analysis can provide it instead.
25
25
  */
26
26
  export function analyzeModule(src) {
27
+ return analyzeRouteModule(src).exports;
28
+ }
29
+ /** `analyzeModule` plus what the default export's function body declares. */
30
+ export function analyzeRouteModule(src) {
27
31
  const source = fs.readFileSync(src, "utf-8");
28
32
  let result;
29
33
  if (src.endsWith(".tsrx")) {
@@ -44,7 +48,30 @@ export function analyzeModule(src) {
44
48
  const error = result.errors[0];
45
49
  if (error)
46
50
  throw new SyntaxError(`Failed to parse ${src}:\n${error.codeframe || error.message}`);
47
- return result.module.staticExports.flatMap(({ entries }) => entries.filter(entry => !entry.isType && entry.exportName.kind !== "None"));
51
+ return {
52
+ exports: result.module.staticExports.flatMap(({ entries }) => entries.filter(entry => !entry.isType && entry.exportName.kind !== "None")),
53
+ defaultDirective: defaultDirectiveOf(result.program.body)
54
+ };
55
+ }
56
+ function defaultDirectiveOf(body) {
57
+ let fn = body.find(node => node.type === "ExportDefaultDeclaration")?.declaration;
58
+ if (fn?.type === "Identifier") {
59
+ // `export default Story` — follow the name to a top-level function.
60
+ const name = fn.name;
61
+ fn = undefined;
62
+ for (const node of body) {
63
+ if (node.type === "FunctionDeclaration" && node.id?.name === name)
64
+ fn = node;
65
+ else if (node.type === "VariableDeclaration")
66
+ for (const d of node.declarations)
67
+ if (d.id?.name === name)
68
+ fn = d.init;
69
+ }
70
+ }
71
+ if (!fn || !/Function(Declaration|Expression)$/.test(fn.type))
72
+ return;
73
+ const first = fn.body?.body?.[0];
74
+ return first?.type === "ExpressionStatement" ? first.directive : undefined;
48
75
  }
49
76
  export function getExportName(entry) {
50
77
  return entry.exportName.name ?? "default";
@@ -30,6 +30,14 @@ export interface PageFileSystemRouterConfig extends FileSystemRouterConfig {
30
30
  * Pass `true` for the standard set. Defaults to `false`.
31
31
  */
32
32
  httpMethods?: boolean | readonly string[];
33
+ /**
34
+ * Recognize server-function pages: a default export whose body begins with
35
+ * a `"use server"` directive is flagged `server: true` and its `$component`
36
+ * delivered eagerly — the client side of such a module is a stub, not a
37
+ * chunk's worth of component. What a server-rendered page means is the
38
+ * emission adapter's business. Defaults to `false`.
39
+ */
40
+ serverComponents?: boolean;
33
41
  }
34
42
  /**
35
43
  * The module convention proven by SolidStart: a route module is a page when
@@ -1,4 +1,4 @@
1
- import { analyzeModule, getExportName, getLocalExportName } from "./analyze.js";
1
+ import { analyzeRouteModule, getExportName, getLocalExportName } from "./analyze.js";
2
2
  import { BaseFileSystemRouter, cleanPath } from "./router.js";
3
3
  /**
4
4
  * The filename convention proven by SolidStart:
@@ -85,24 +85,31 @@ export class PageFileSystemRouter extends BaseFileSystemRouter {
85
85
  };
86
86
  }
87
87
  const methods = httpMethodsOf(this.config);
88
- const exports = analyzeModule(src);
88
+ const { exports, defaultDirective } = analyzeRouteModule(src);
89
89
  const exportNames = exports.map(getExportName);
90
90
  const localExportNames = exports.map(getLocalExportName).filter(name => name !== undefined);
91
91
  const hasDefault = exportNames.includes("default");
92
92
  const hasRouteConfig = exportNames.includes("route");
93
93
  const handlers = createHandlerRefs(src, exportNames, methods);
94
94
  const hasHandlers = Object.keys(handlers).length > 0;
95
+ // A server-function page (opt-in): the client gets a stub, not a chunk's
96
+ // worth of component, so it is delivered eagerly and flagged for the adapter.
97
+ const server = this.config.serverComponents === true && hasDefault && defaultDirective === "use server";
95
98
  if (hasDefault || hasHandlers) {
96
99
  return {
97
100
  page: hasDefault,
101
+ ...(server ? { server } : {}),
98
102
  $component: components && hasDefault
99
103
  ? {
100
104
  src: src,
101
- pick: [
102
- ...localExportNames.filter(name => name !== "route" && !methods.includes(name)),
103
- "default",
104
- "$css"
105
- ]
105
+ pick: server
106
+ ? ["default"]
107
+ : [
108
+ ...localExportNames.filter(name => name !== "route" && !methods.includes(name)),
109
+ "default",
110
+ "$css"
111
+ ],
112
+ ...(server ? { eager: true } : {})
106
113
  }
107
114
  : undefined,
108
115
  $$route: hasRouteConfig
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Facts about the route scan, for emission adapters to gate static imports
3
+ * on. This file is the fallback: every fact is `true`, so code that reads it
4
+ * without the Vite plugin in the loop (dev prebundling, a hand-built
5
+ * manifest, another bundler) errs toward "present", never toward "broken".
6
+ * `filesystem-routing/vite` replaces this module in builds with the scan's
7
+ * actual answers, folded to literals, so an adapter's
8
+ * `if (serverRoutes) use(server)` drops its import when no route needs it.
9
+ */
10
+ /** `true` when some page's component is a server function (`server: true`). */
11
+ export declare const serverRoutes: boolean;
package/dist/flags.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Facts about the route scan, for emission adapters to gate static imports
3
+ * on. This file is the fallback: every fact is `true`, so code that reads it
4
+ * without the Vite plugin in the loop (dev prebundling, a hand-built
5
+ * manifest, another bundler) errs toward "present", never toward "broken".
6
+ * `filesystem-routing/vite` replaces this module in builds with the scan's
7
+ * actual answers, folded to literals, so an adapter's
8
+ * `if (serverRoutes) use(server)` drops its import when no route needs it.
9
+ */
10
+ /** `true` when some page's component is a server function (`server: true`). */
11
+ export const serverRoutes = true;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { analyzeModule, getExportName, getLocalExportName } from "./analyze.ts";
2
- export type { StaticExportEntry } from "./analyze.ts";
1
+ export { analyzeModule, analyzeRouteModule, getExportName, getLocalExportName } from "./analyze.ts";
2
+ export type { RouteModuleAnalysis, StaticExportEntry } from "./analyze.ts";
3
3
  export type { ModuleRef, RouteManifestEntry } from "./manifest.ts";
4
4
  export { BaseFileSystemRouter, cleanPath, glob, normalizePath, type FileSystemRouterConfig } from "./router.ts";
5
5
  export { HTTP_METHODS, PageFileSystemRouter, routePathFromFile, type PageFileSystemRouterConfig } from "./convention.ts";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { analyzeModule, getExportName, getLocalExportName } from "./analyze.js";
1
+ export { analyzeModule, analyzeRouteModule, getExportName, getLocalExportName } from "./analyze.js";
2
2
  export { BaseFileSystemRouter, cleanPath, glob, normalizePath } from "./router.js";
3
3
  export { HTTP_METHODS, PageFileSystemRouter, routePathFromFile } from "./convention.js";
4
4
  export { FlatFileSystemRouter, flatRoutePathFromFile } from "./flat.js";
@@ -27,6 +27,12 @@ export interface ModuleRef {
27
27
  src: string;
28
28
  /** The named exports (or `"default"`) this ref selects from the module. */
29
29
  pick: string[];
30
+ /**
31
+ * Deliver eagerly even under code splitting. Set by a convention when the
32
+ * module has no client code worth a chunk — a server-function page, whose
33
+ * client side is a stub — so the ref is materialized `{ src, require }`.
34
+ */
35
+ eager?: boolean;
30
36
  }
31
37
  export interface RouteManifestEntry {
32
38
  /**
@@ -38,6 +44,13 @@ export interface RouteManifestEntry {
38
44
  path: string;
39
45
  /** `true` when the module renders a page (has a default export). */
40
46
  page?: boolean;
47
+ /**
48
+ * `true` when the page component is a server function — its default export
49
+ * begins with a `"use server"` directive. The convention delivers such a
50
+ * `$component` eagerly; what a server-rendered page means is the emission
51
+ * adapter's business.
52
+ */
53
+ server?: boolean;
41
54
  /** Lazy ref to the page component module. */
42
55
  $component?: ModuleRef;
43
56
  /** Eager ref to the route config (`route` export), when present. */
package/dist/router.js CHANGED
@@ -36,7 +36,7 @@ export class BaseFileSystemRouter extends EventTarget {
36
36
  return posix.join(fg.convertPathToPattern(this.config.dir), "**/*") + suffix;
37
37
  }
38
38
  async buildRoutes() {
39
- for (const src of glob(this.glob())) {
39
+ for (const src of glob(this.glob()).sort()) {
40
40
  await this.addRoute(src);
41
41
  }
42
42
  return this.routes;
package/dist/tree.js CHANGED
@@ -21,7 +21,7 @@ export function buildRouteTree(entries) {
21
21
  processRoute(parentRoute.children || (parentRoute.children = []), route, id.slice(parentRoute.id.length));
22
22
  }
23
23
  return [...entries]
24
- .sort((a, b) => a.path.length - b.path.length)
24
+ .sort((a, b) => (a.path.length - b.path.length) || a.path.localeCompare(b.path))
25
25
  .reduce((routes, route) => {
26
26
  processRoute(routes, route, route.path);
27
27
  return routes;
@@ -6,3 +6,12 @@
6
6
  */
7
7
  export declare const moduleId = "virtual:file-routes";
8
8
  export declare const DEFAULT_EXTENSIONS: string[];
9
+ /**
10
+ * The module adapters import scan facts from — `filesystem-routing/flags`.
11
+ * The package ships it as a real file whose facts are all `true` (the safe
12
+ * answer wherever no plugin hook is in the loop: dev prebundling, a custom
13
+ * manifest, another bundler); the Vite plugin serves the scan's answer in
14
+ * builds so gated code folds away.
15
+ */
16
+ export declare const flagsId = "filesystem-routing/flags";
17
+ export declare const resolvedFlagsId: string;
@@ -6,3 +6,12 @@
6
6
  */
7
7
  export const moduleId = "virtual:file-routes";
8
8
  export const DEFAULT_EXTENSIONS = ["js", "jsx", "ts", "tsx", "tsrx"];
9
+ /**
10
+ * The module adapters import scan facts from — `filesystem-routing/flags`.
11
+ * The package ships it as a real file whose facts are all `true` (the safe
12
+ * answer wherever no plugin hook is in the loop: dev prebundling, a custom
13
+ * manifest, another bundler); the Vite plugin serves the scan's answer in
14
+ * builds so gated code folds away.
15
+ */
16
+ export const flagsId = "filesystem-routing/flags";
17
+ export const resolvedFlagsId = "\0" + flagsId;
@@ -5,7 +5,7 @@ import { DEFAULT_EXTENSIONS, moduleId } from "./constants.ts";
5
5
  export { DEFAULT_EXTENSIONS, moduleId };
6
6
  export { sanitizeChunkFileName, toPickId, treeShake } from "./tree-shake.ts";
7
7
  export { fileSystemWatcher } from "./fs-watcher.ts";
8
- export interface FileRoutesOptions extends Pick<PageFileSystemRouterConfig, "components" | "httpMethods" | "toPath" | "toRoute"> {
8
+ export interface FileRoutesOptions extends Pick<PageFileSystemRouterConfig, "components" | "httpMethods" | "serverComponents" | "toPath" | "toRoute"> {
9
9
  /** Route directory, relative to the Vite root. Defaults to `src/routes`. */
10
10
  dir?: string;
11
11
  /** File extensions that participate in routing. Defaults to js/jsx/ts/tsx. */
@@ -3,7 +3,7 @@ import { dirname, relative, resolve } from "node:path";
3
3
  import { PageFileSystemRouter } from "../convention.js";
4
4
  import { normalizePath } from "../router.js";
5
5
  import { buildRouteTree } from "../tree.js";
6
- import { DEFAULT_EXTENSIONS, moduleId } from "./constants.js";
6
+ import { DEFAULT_EXTENSIONS, flagsId, moduleId, resolvedFlagsId } from "./constants.js";
7
7
  import { fileSystemWatcher } from "./fs-watcher.js";
8
8
  import { sanitizeChunkFileName, toPickId, treeShake } from "./tree-shake.js";
9
9
  import { serializeTypes } from "./types.js";
@@ -138,6 +138,7 @@ export function fileRoutes(options = {}) {
138
138
  extensions: options.extensions ?? DEFAULT_EXTENSIONS,
139
139
  components: options.components,
140
140
  httpMethods: options.httpMethods,
141
+ serverComponents: options.serverComponents,
141
142
  toPath: options.toPath,
142
143
  toRoute: options.toRoute
143
144
  });
@@ -169,7 +170,7 @@ export function fileRoutes(options = {}) {
169
170
  const input = [];
170
171
  for (const route of routes) {
171
172
  for (const [key, ref] of Object.entries(route)) {
172
- if (ref && key.startsWith("$") && !key.startsWith("$$")) {
173
+ if (ref && key.startsWith("$") && !key.startsWith("$$") && !ref.eager) {
173
174
  input.push(toModuleId(ref));
174
175
  }
175
176
  }
@@ -181,8 +182,20 @@ export function fileRoutes(options = {}) {
181
182
  resolveId(source) {
182
183
  if (source === virtualId)
183
184
  return virtualId;
185
+ if (source === flagsId)
186
+ return resolvedFlagsId;
184
187
  },
185
188
  async load(loadedId) {
189
+ if (loadedId === resolvedFlagsId) {
190
+ // Facts about the scan, folded to literals so an adapter's static
191
+ // imports gated on them tree-shake out of a build that does not
192
+ // need them. In serve, always `true`: dev has no size to protect
193
+ // and the first server page must work without a restart.
194
+ const router = getRouter(this.environment.name);
195
+ const routes = (router && (await router.getRoutes())) ?? [];
196
+ const serverRoutes = this.environment.mode !== "build" || routes.some(route => route.server === true);
197
+ return `export const serverRoutes = ${serverRoutes};`;
198
+ }
186
199
  if (loadedId !== virtualId)
187
200
  return;
188
201
  const root = this.environment.config.root;
@@ -216,18 +229,19 @@ export function fileRoutes(options = {}) {
216
229
  }
217
230
  else if (key.startsWith("$")) {
218
231
  const buildId = toModuleId(value);
219
- // With code splitting off the ref is delivered eagerly: a
220
- // namespace import (named imports would fail on the synthetic
221
- // `$css` pick) behind the `require()` shape eager refs carry,
222
- // plus the `src` lazy refs already expose.
223
- if (!codeSplitting) {
232
+ // With code splitting off — or a ref the convention marked
233
+ // `eager` — the ref is delivered eagerly: a namespace import
234
+ // (named imports would fail on the synthetic `$css` pick)
235
+ // behind the `require()` shape eager refs carry, plus the
236
+ // `src` lazy refs already expose.
237
+ if (!codeSplitting || value.eager) {
224
238
  return {
225
- src: relative(root, buildId),
239
+ src: normalizePath(relative(root, buildId)),
226
240
  require: `_$() => (${js.addNamespaceImport(buildId)})$_`
227
241
  };
228
242
  }
229
243
  return {
230
- src: relative(root, buildId),
244
+ src: normalizePath(relative(root, buildId)),
231
245
  build: isBuild ? `_$() => import('${buildId}')$_` : undefined,
232
246
  import: `_$() => import('${buildId}')$_`
233
247
  };
@@ -32,6 +32,8 @@ const REF_TYPES = ` /** A code-split ref: the delivery adapter emits it as a dy
32
32
  export interface FileRouteEntry {
33
33
  path: string;
34
34
  page?: boolean;
35
+ /** The page component is a server function; its \`$component\` is delivered eagerly. */
36
+ server?: boolean;
35
37
  $component?: FileRouteLazyRef<any> | FileRouteEagerRef<any>;
36
38
  $$route?: FileRouteEagerRef<any>;
37
39
  [key: string]: unknown;
@@ -72,14 +74,18 @@ function entryType(entry, indent, nested, from, codeSplitting) {
72
74
  if (nested)
73
75
  fields.push(`id: ${JSON.stringify(entry.id)}`);
74
76
  fields.push(`page: ${entry.page ? "true" : "false"}`);
77
+ if (entry.server)
78
+ fields.push(`server: true`);
75
79
  // A convention records refs it did not emit as present-but-undefined keys,
76
80
  // so go by the value, not the key.
77
81
  const refKeys = Object.keys(entry).filter(key => key.startsWith("$") && entry[key] !== undefined);
78
82
  for (const key of refKeys) {
79
- // With code splitting off, the delivery adapter materializes lazy refs
80
- // eagerly, so the declaration describes them as delivered.
81
- const kind = isEagerRefKey(key) || !codeSplitting ? "eager" : "lazy";
82
- fields.push(`${key}: ${refType(entry[key], kind, from)}`);
83
+ // With code splitting off, or a ref the convention marked `eager`, the
84
+ // delivery adapter materializes lazy refs eagerly, so the declaration
85
+ // describes them as delivered.
86
+ const ref = entry[key];
87
+ const kind = isEagerRefKey(key) || !codeSplitting || ref.eager ? "eager" : "lazy";
88
+ fields.push(`${key}: ${refType(ref, kind, from)}`);
83
89
  }
84
90
  // Reading an absent ref is how adapters branch, so keep the key present.
85
91
  if (!refKeys.some(isLazyRefKey))
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "filesystem-routing",
3
3
  "description": "Router-neutral file-system routing: scans a route directory into a neutral route manifest with pluggable conventions and delivery adapters",
4
4
  "license": "MIT",
5
- "version": "0.3.1",
5
+ "version": "0.4.0",
6
6
  "author": "Ryan Carniato",
7
7
  "homepage": "https://github.com/solidjs/filesystem-routing#readme",
8
8
  "repository": {
@@ -44,6 +44,10 @@
44
44
  "types": "./dist/api.d.ts",
45
45
  "default": "./dist/api.js"
46
46
  },
47
+ "./flags": {
48
+ "types": "./dist/flags.d.ts",
49
+ "default": "./dist/flags.js"
50
+ },
47
51
  "./types": {
48
52
  "types": "./types.d.ts"
49
53
  }