filesystem-routing 0.2.0 → 0.3.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
@@ -241,8 +241,14 @@ The scanner produces flat `RouteManifestEntry` objects:
241
241
 
242
242
  The Vite adapter serializes the manifest into the virtual module, turning
243
243
  `$`-prefixed refs into dynamic imports and `$$`-prefixed refs into static
244
- imports, each tree-shaken down to the picked exports. It serves two views of
245
- the same entries:
244
+ imports, each tree-shaken down to the picked exports. Splitting is not always
245
+ a win — on a small app the per-chunk overhead can leave the split bundle
246
+ larger — so `fileRoutes({ codeSplitting: false })` delivers `$` refs eagerly
247
+ too (`{ src, require }`, statically imported); emission adapters branch on
248
+ the delivered shape, so no second switch exists on their side. With
249
+ `@solidjs/router` this requires the next release (newer than
250
+ `2.0.0-next.14`) — against `next.14` or older, enabling the option fails at
251
+ first render. The module serves two views of the same entries:
246
252
 
247
253
  ```ts
248
254
  import routes, { pageRoutes } from "virtual:file-routes";
@@ -305,6 +311,7 @@ fileRoutes({
305
311
  | `httpMethods` | emit `$GET`, `$POST`, … refs for uppercase handler exports; a module with handlers but no default export routes without being a page |
306
312
  | `components` | set `false` to route without emitting `$component` refs, keeping page modules out of that environment's bundle |
307
313
  | `buildInputs` | environments whose build takes every code-split route module as an entry |
314
+ | `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`) |
308
315
  | `moduleId` | the id the manifest is served from |
309
316
  | `optimizeDepsExclude` | escape hatch for packages that import the virtual module, kept out of dep prebundling (defaults to `[]`) |
310
317
  | `types` | write a declaration typing the manifest as a literal tuple, kept in step with the route directory |
package/dist/analyze.js CHANGED
@@ -1,5 +1,21 @@
1
1
  import fs from "node:fs";
2
+ import { createRequire } from "node:module";
2
3
  import { parseSync } from "oxc-parser";
4
+ let tsrxParseSync;
5
+ function loadTsrxParser(src) {
6
+ if (!tsrxParseSync) {
7
+ try {
8
+ const facade = createRequire(import.meta.url)("@tsrx/oxc/parser");
9
+ tsrxParseSync = facade.parseSync;
10
+ }
11
+ catch (cause) {
12
+ throw new Error(`Cannot analyze the TSRX route module ${src}: analyzing .tsrx exports requires ` +
13
+ `the optional peer dependency @tsrx/oxc. Install it (npm i -D @tsrx/oxc) ` +
14
+ `or keep route modules in .ts/.tsx.`, { cause });
15
+ }
16
+ }
17
+ return tsrxParseSync;
18
+ }
3
19
  /**
4
20
  * Analyze a route module's static exports.
5
21
  *
@@ -8,7 +24,23 @@ import { parseSync } from "oxc-parser";
8
24
  * a compiler that already performs export analysis can provide it instead.
9
25
  */
10
26
  export function analyzeModule(src) {
11
- const result = parseSync(src, fs.readFileSync(src, "utf-8"), { lang: "tsx" });
27
+ const source = fs.readFileSync(src, "utf-8");
28
+ let result;
29
+ if (src.endsWith(".tsrx")) {
30
+ const parse = loadTsrxParser(src);
31
+ // The TSRX facade reports most syntax errors through `result.errors` but
32
+ // throws on some malformed sources; normalize both into the same shape.
33
+ try {
34
+ result = parse(src, source, { lang: "tsrx" });
35
+ }
36
+ catch (cause) {
37
+ const message = cause instanceof Error ? cause.message : String(cause);
38
+ throw new SyntaxError(`Failed to parse ${src}:\n${message}`, { cause });
39
+ }
40
+ }
41
+ else {
42
+ result = parseSync(src, source, { lang: "tsx" });
43
+ }
12
44
  const error = result.errors[0];
13
45
  if (error)
14
46
  throw new SyntaxError(`Failed to parse ${src}:\n${error.codeframe || error.message}`);
package/dist/api.d.ts CHANGED
@@ -39,9 +39,15 @@ export interface FileRouteHandlers {
39
39
  $DELETE?: APIHandlerRef;
40
40
  [key: string]: unknown;
41
41
  }
42
- export interface APIHandlerRef {
42
+ /**
43
+ * A handler module ref as delivered: code-split (`import()`) by default,
44
+ * eager (`require()`) when the delivery adapter runs with code splitting off.
45
+ */
46
+ export type APIHandlerRef = {
43
47
  import(): Promise<Record<string, APIHandler>>;
44
- }
48
+ } | {
49
+ require(): Record<string, APIHandler>;
50
+ };
45
51
  export interface APIMatch {
46
52
  handler: APIHandlerRef;
47
53
  params?: Record<string, string>;
package/dist/api.js CHANGED
@@ -101,7 +101,7 @@ export function createAPIHandler(routes, options = {}) {
101
101
  const matched = match(stripPathBase(url.pathname, options.base ?? "/"), request.method);
102
102
  if (!matched)
103
103
  return next();
104
- const mod = await matched.handler.import();
104
+ const mod = "require" in matched.handler ? matched.handler.require() : await matched.handler.import();
105
105
  const fn = request.method === "HEAD" ? mod.HEAD || mod.GET : mod[request.method];
106
106
  if (typeof fn !== "function")
107
107
  return next();
@@ -16,6 +16,11 @@
16
16
  * under decides how:
17
17
  * - keys prefixed `$` become lazy refs: `{ src, import: () => import(...) }`
18
18
  * - keys prefixed `$$` become eager refs: `{ require: () => ({ ...exports }) }`
19
+ *
20
+ * A delivery adapter configured without code splitting (the Vite adapter's
21
+ * `codeSplitting: false`) materializes `$` refs eagerly too, as
22
+ * `{ src, require }` — the delivered shape, not the key prefix, is the
23
+ * contract consumers branch on.
19
24
  */
20
25
  export interface ModuleRef {
21
26
  /** Absolute path of the source module. */
@@ -5,4 +5,4 @@
5
5
  * not tied to any router. Override it with the plugin's `moduleId` option.
6
6
  */
7
7
  export const moduleId = "virtual:file-routes";
8
- export const DEFAULT_EXTENSIONS = ["js", "jsx", "ts", "tsx"];
8
+ export const DEFAULT_EXTENSIONS = ["js", "jsx", "ts", "tsx", "tsrx"];
@@ -32,6 +32,21 @@ export interface FileRoutesOptions extends Pick<PageFileSystemRouterConfig, "com
32
32
  * framework already configures.
33
33
  */
34
34
  buildInputs?: string | string[];
35
+ /**
36
+ * Deliver `$`-prefixed module refs as code-split dynamic imports, each
37
+ * route its own chunk. The default. Set to `false` to deliver every ref
38
+ * eagerly instead — static imports, `require()`-shaped refs, zero dynamic
39
+ * imports in the output — for the apps where splitting loses: small apps
40
+ * whose per-chunk overhead outweighs the split, and targets that want a
41
+ * single bundle. Adapters need no switch of their own: refs are
42
+ * self-describing (`import()` is code-split, `require()` is eager), and
43
+ * emission adapters branch on the shape they are handed.
44
+ *
45
+ * With `@solidjs/router`, `codeSplitting: false` requires the next release
46
+ * (newer than `2.0.0-next.14`): its adapter must branch on eager refs, and
47
+ * against `next.14` or older, enabling this option fails at first render.
48
+ */
49
+ codeSplitting?: boolean;
35
50
  /**
36
51
  * Packages that import the virtual module and must therefore stay out of
37
52
  * esbuild's dependency prebundling, which cannot resolve it. Emission
@@ -65,5 +80,11 @@ export interface FileRoutesOptions extends Pick<PageFileSystemRouterConfig, "com
65
80
  * flat manifest, and `pageRoutes` is the page entries nested by path with
66
81
  * `(group)` segments stripped, so emission adapters don't each reimplement
67
82
  * the tree.
83
+ *
84
+ * With `httpMethods` on, one router serves both sides of an SSR app: the
85
+ * handler refs it emits reach server-consumer environments only, and client
86
+ * manifests drop them (and handler-only API entries with them) so handler
87
+ * modules never enter a client build. An environment given its own router
88
+ * via `routers` is explicit and serves that router's manifest untouched.
68
89
  */
69
90
  export declare function fileRoutes(options?: FileRoutesOptions): PluginOption[];
@@ -10,6 +10,45 @@ import { serializeTypes } from "./types.js";
10
10
  export { DEFAULT_EXTENSIONS, moduleId };
11
11
  export { sanitizeChunkFileName, toPickId, treeShake } from "./tree-shake.js";
12
12
  export { fileSystemWatcher } from "./fs-watcher.js";
13
+ /**
14
+ * Handler refs are the `$`-prefixed all-uppercase keys the convention emits
15
+ * (`$GET`, `$POST`, ...). They exist for server dispatch only.
16
+ */
17
+ const HANDLER_REF = /^\$[A-Z]+$/;
18
+ /**
19
+ * Whether a Vite environment runs on the server. `configEnvironment` sees
20
+ * `consumer` only when something set it explicitly, so fall back to Vite's
21
+ * own default: the `client` environment is the one client consumer.
22
+ */
23
+ const isServerConsumer = (name, consumer) => (consumer ?? (name === "client" ? "client" : "server")) === "server";
24
+ /**
25
+ * The client's view of a shared server manifest: handler refs removed. A
26
+ * client bundle can never invoke a request handler, but serializing its ref
27
+ * would pull the handler module — and the server-only code it imports —
28
+ * into the client build. Handler-only entries (API routes) disappear
29
+ * entirely; pages that also export handlers keep their page refs.
30
+ */
31
+ function stripHandlerRefs(routes) {
32
+ const stripped = [];
33
+ for (const route of routes) {
34
+ const handlerKeys = Object.keys(route).filter(key => HANDLER_REF.test(key));
35
+ if (!handlerKeys.length) {
36
+ stripped.push(route);
37
+ continue;
38
+ }
39
+ const rest = { ...route };
40
+ for (const key of handlerKeys)
41
+ delete rest[key];
42
+ // Entries carry `$component: undefined` placeholders; only a ref with a
43
+ // value keeps a handler-only entry alive. Otherwise not even the API
44
+ // path string reaches the client bundle.
45
+ if (rest.page ||
46
+ Object.keys(rest).some(key => key.startsWith("$") && rest[key] !== undefined)) {
47
+ stripped.push(rest);
48
+ }
49
+ }
50
+ return stripped;
51
+ }
13
52
  /**
14
53
  * The Vite delivery adapter for `filesystem-routing`.
15
54
  *
@@ -22,9 +61,16 @@ export { fileSystemWatcher } from "./fs-watcher.js";
22
61
  * flat manifest, and `pageRoutes` is the page entries nested by path with
23
62
  * `(group)` segments stripped, so emission adapters don't each reimplement
24
63
  * the tree.
64
+ *
65
+ * With `httpMethods` on, one router serves both sides of an SSR app: the
66
+ * handler refs it emits reach server-consumer environments only, and client
67
+ * manifests drop them (and handler-only API entries with them) so handler
68
+ * modules never enter a client build. An environment given its own router
69
+ * via `routers` is explicit and serves that router's manifest untouched.
25
70
  */
26
71
  export function fileRoutes(options = {}) {
27
72
  const virtualId = options.moduleId ?? moduleId;
73
+ const codeSplitting = options.codeSplitting !== false;
28
74
  const buildInputs = options.buildInputs === undefined
29
75
  ? []
30
76
  : Array.isArray(options.buildInputs)
@@ -47,7 +93,7 @@ export function fileRoutes(options = {}) {
47
93
  if (!file || !router)
48
94
  return;
49
95
  const routes = (await router.getRoutes()) ?? [];
50
- const contents = serializeTypes(virtualId, routes, buildRouteTree(routes.filter(route => route.page)), dirname(file));
96
+ const contents = serializeTypes(virtualId, routes, buildRouteTree(routes.filter(route => route.page)), dirname(file), codeSplitting);
51
97
  // Only touch the file when it actually changes: every environment calls
52
98
  // this, and rewriting it would churn the watcher for no reason.
53
99
  let previous;
@@ -101,16 +147,27 @@ export function fileRoutes(options = {}) {
101
147
  await writeTypes();
102
148
  },
103
149
  async configEnvironment(name, _config, env) {
150
+ // Without code splitting no ref becomes a dynamic import, so no
151
+ // route module needs to be an entry: they are all statically
152
+ // reachable from the virtual module.
153
+ if (!codeSplitting)
154
+ return;
104
155
  if (env.command !== "build" || !buildInputs.includes(name))
105
156
  return;
106
157
  const router = getRouter(name);
107
158
  if (!router)
108
159
  return;
160
+ // A client environment on the shared router never serializes
161
+ // handler refs (see load), so their modules must not become its
162
+ // build entries either.
163
+ const routes = options.routers?.[name] || isServerConsumer(name, _config.consumer)
164
+ ? await router.getRoutes()
165
+ : stripHandlerRefs(await router.getRoutes());
109
166
  // Every code-split route module is an entry of its own, so the
110
167
  // manifest's dynamic imports resolve to real chunks. `$$` refs are
111
168
  // inlined into the manifest and need no entry.
112
169
  const input = [];
113
- for (const route of await router.getRoutes()) {
170
+ for (const route of routes) {
114
171
  for (const [key, ref] of Object.entries(route)) {
115
172
  if (ref && key.startsWith("$") && !key.startsWith("$$")) {
116
173
  input.push(toModuleId(ref));
@@ -131,8 +188,17 @@ export function fileRoutes(options = {}) {
131
188
  const root = this.environment.config.root;
132
189
  const isBuild = this.environment.mode === "build";
133
190
  const js = jsCode();
134
- const router = getRouter(this.environment.name);
135
- const routes = (router ? await router.getRoutes() : []) ?? [];
191
+ const environmentName = this.environment.name;
192
+ const router = getRouter(environmentName);
193
+ let routes = (router ? await router.getRoutes() : []) ?? [];
194
+ // The shared router serves every environment, so the split is this
195
+ // adapter's job: client consumers get the manifest without handler
196
+ // refs. An explicit per-environment router already IS the split —
197
+ // serve whatever it emits.
198
+ if (!options.routers?.[environmentName] &&
199
+ !isServerConsumer(environmentName, this.environment.config.consumer)) {
200
+ routes = stripHandlerRefs(routes);
201
+ }
136
202
  const serializeEntry = (entry) => JSON.stringify(entry, (key, value) => {
137
203
  if (value === undefined)
138
204
  return undefined;
@@ -150,6 +216,16 @@ export function fileRoutes(options = {}) {
150
216
  }
151
217
  else if (key.startsWith("$")) {
152
218
  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) {
224
+ return {
225
+ src: relative(root, buildId),
226
+ require: `_$() => (${js.addNamespaceImport(buildId)})$_`
227
+ };
228
+ }
153
229
  return {
154
230
  src: relative(root, buildId),
155
231
  build: isBuild ? `_$() => import('${buildId}')$_` : undefined,
@@ -192,6 +268,7 @@ export const pageRoutes = ${serializeTree(tree)};
192
268
  }
193
269
  function jsCode() {
194
270
  const imports = new Map();
271
+ const namespaceImports = new Map();
195
272
  let vars = 0;
196
273
  function addNamedImport(name, source) {
197
274
  let names = imports.get(source);
@@ -208,15 +285,25 @@ function jsCode() {
208
285
  names[name] = alias;
209
286
  return alias;
210
287
  }
288
+ function addNamespaceImport(source) {
289
+ const existing = namespaceImports.get(source);
290
+ if (existing)
291
+ return existing;
292
+ const alias = "routeModule" + namespaceImports.size;
293
+ namespaceImports.set(source, alias);
294
+ return alias;
295
+ }
211
296
  const getImportStatements = () => {
212
- return [...imports.entries()]
213
- .map(([source, names]) => `import { ${Object.entries(names)
214
- .map(([name, alias]) => `${name} as ${alias}`)
215
- .join(", ")} } from '${source}';`)
216
- .join("\n");
297
+ return [
298
+ ...[...imports.entries()].map(([source, names]) => `import { ${Object.entries(names)
299
+ .map(([name, alias]) => `${name} as ${alias}`)
300
+ .join(", ")} } from '${source}';`),
301
+ ...[...namespaceImports.entries()].map(([source, alias]) => `import * as ${alias} from '${source}';`)
302
+ ].join("\n");
217
303
  };
218
304
  return {
219
305
  addNamedImport,
306
+ addNamespaceImport,
220
307
  getImportStatements
221
308
  };
222
309
  }
@@ -7,4 +7,6 @@ import type { RouteTreeEntry } from "../tree.ts";
7
7
  */
8
8
  export declare function serializeTypes(moduleId: string, routes: readonly RouteManifestEntry[], pageRoutes: readonly RouteTreeEntry[],
9
9
  /** Directory the declaration is written to; module specifiers are relative to it. */
10
- from: string): string;
10
+ from: string,
11
+ /** Whether lazy refs are delivered code-split (the default) or eagerly. */
12
+ codeSplitting?: boolean): string;
@@ -23,6 +23,8 @@ const REF_TYPES = ` /** A code-split ref: the delivery adapter emits it as a dy
23
23
 
24
24
  /** An eager ref: its picked exports are imported statically. */
25
25
  export interface FileRouteEagerRef<M = Record<string, unknown>> {
26
+ /** The source module path — present when the convention emitted the ref lazy and \`codeSplitting: false\` delivered it eagerly. */
27
+ src?: string;
26
28
  require(): M;
27
29
  }
28
30
 
@@ -30,7 +32,7 @@ const REF_TYPES = ` /** A code-split ref: the delivery adapter emits it as a dy
30
32
  export interface FileRouteEntry {
31
33
  path: string;
32
34
  page?: boolean;
33
- $component?: FileRouteLazyRef<any>;
35
+ $component?: FileRouteLazyRef<any> | FileRouteEagerRef<any>;
34
36
  $$route?: FileRouteEagerRef<any>;
35
37
  [key: string]: unknown;
36
38
  }
@@ -64,7 +66,7 @@ const isEagerRefKey = (key) => key.startsWith("$$");
64
66
  * types intact. Absent refs are typed `?: undefined` rather than omitted, so
65
67
  * an adapter can read `entry.$$route` uniformly across the tuple.
66
68
  */
67
- function entryType(entry, indent, nested, from) {
69
+ function entryType(entry, indent, nested, from, codeSplitting) {
68
70
  const inner = indent + " ";
69
71
  const fields = [`path: ${JSON.stringify(entry.path)}`];
70
72
  if (nested)
@@ -74,7 +76,9 @@ function entryType(entry, indent, nested, from) {
74
76
  // so go by the value, not the key.
75
77
  const refKeys = Object.keys(entry).filter(key => key.startsWith("$") && entry[key] !== undefined);
76
78
  for (const key of refKeys) {
77
- const kind = isEagerRefKey(key) ? "eager" : "lazy";
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";
78
82
  fields.push(`${key}: ${refType(entry[key], kind, from)}`);
79
83
  }
80
84
  // Reading an absent ref is how adapters branch, so keep the key present.
@@ -85,17 +89,17 @@ function entryType(entry, indent, nested, from) {
85
89
  if (nested) {
86
90
  const children = entry.children;
87
91
  fields.push(children?.length
88
- ? `children: ${tupleType(children, inner, true, from)}`
92
+ ? `children: ${tupleType(children, inner, true, from, codeSplitting)}`
89
93
  : `children?: undefined`);
90
94
  }
91
95
  return `{\n${fields.map(field => `${inner}${field};`).join("\n")}\n${indent}}`;
92
96
  }
93
- function tupleType(entries, indent, nested, from) {
97
+ function tupleType(entries, indent, nested, from, codeSplitting) {
94
98
  if (!entries.length)
95
99
  return "readonly []";
96
100
  const inner = indent + " ";
97
101
  const members = entries
98
- .map(entry => `${inner}${entryType(entry, inner, nested, from)}`)
102
+ .map(entry => `${inner}${entryType(entry, inner, nested, from, codeSplitting)}`)
99
103
  .join(",\n");
100
104
  return `readonly [\n${members}\n${indent}]`;
101
105
  }
@@ -106,18 +110,20 @@ function tupleType(entries, indent, nested, from) {
106
110
  */
107
111
  export function serializeTypes(moduleId, routes, pageRoutes,
108
112
  /** Directory the declaration is written to; module specifiers are relative to it. */
109
- from) {
113
+ from,
114
+ /** Whether lazy refs are delivered code-split (the default) or eagerly. */
115
+ codeSplitting = true) {
110
116
  return `${HEADER}
111
117
 
112
118
  declare module ${JSON.stringify(moduleId)} {
113
119
  ${REF_TYPES}
114
120
 
115
121
  /** The flat route manifest, in scan order. */
116
- const routes: ${tupleType(routes, " ", false, from)};
122
+ const routes: ${tupleType(routes, " ", false, from, codeSplitting)};
117
123
  export default routes;
118
124
 
119
125
  /** The page entries, nested by path with grouping segments stripped. */
120
- export const pageRoutes: ${tupleType(pageRoutes, " ", true, from)};
126
+ export const pageRoutes: ${tupleType(pageRoutes, " ", true, from, codeSplitting)};
121
127
  }
122
128
  `;
123
129
  }
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.2.0",
5
+ "version": "0.3.0",
6
6
  "author": "Ryan Carniato",
7
7
  "homepage": "https://github.com/solidjs/filesystem-routing#readme",
8
8
  "repository": {
@@ -63,15 +63,20 @@
63
63
  "radix3": "^1.1.2"
64
64
  },
65
65
  "peerDependencies": {
66
+ "@tsrx/oxc": ">=0.9.0",
66
67
  "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
67
68
  },
68
69
  "peerDependenciesMeta": {
70
+ "@tsrx/oxc": {
71
+ "optional": true
72
+ },
69
73
  "vite": {
70
74
  "optional": true
71
75
  }
72
76
  },
73
77
  "devDependencies": {
74
78
  "@changesets/cli": "^2.31.1",
79
+ "@tsrx/oxc": "^0.9.0",
75
80
  "@types/node": "^22.10.0",
76
81
  "prettier": "^3.4.1",
77
82
  "typescript": "^5.7.2",
package/types.d.ts CHANGED
@@ -22,6 +22,8 @@ declare module "virtual:file-routes" {
22
22
 
23
23
  /** An eager ref: its picked exports are imported statically. */
24
24
  export interface FileRouteEagerRef {
25
+ /** The source module path — present when the convention emitted the ref lazy and `codeSplitting: false` delivered it eagerly. */
26
+ src?: string;
25
27
  require(): Record<string, unknown>;
26
28
  }
27
29
 
@@ -34,8 +36,8 @@ declare module "virtual:file-routes" {
34
36
  path: string;
35
37
  /** `true` when the module renders a page. */
36
38
  page?: boolean;
37
- /** The page component module. */
38
- $component?: FileRouteLazyRef;
39
+ /** The page component module: code-split by default, an eager ref under `codeSplitting: false`. */
40
+ $component?: FileRouteLazyRef | FileRouteEagerRef;
39
41
  /** The route config (`route` export), when the module has one. */
40
42
  $$route?: FileRouteEagerRef;
41
43
  /** Refs added by convention extensions, e.g. `$GET` handlers. */