filesystem-routing 0.1.1 → 0.2.1

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
@@ -13,8 +13,8 @@ a delivery adapter that materializes the manifest into code.
13
13
  | Scanning, filename convention, neutral route manifest | `filesystem-routing` |
14
14
  | Nesting + `(group)` stripping | `filesystem-routing/tree` |
15
15
  | Vite delivery (virtual module, HMR, code splitting, build inputs) | `filesystem-routing/vite` |
16
- | `RouteDefinition` emission | `@solidjs/router/fs` |
17
- | Request handling for `GET`/`POST` routes, middleware | `@solidjs/start` |
16
+ | Request dispatch for `GET`/`POST` routes, as fetch middleware | `filesystem-routing/api` |
17
+ | `RouteDefinition` emission for Solid Router (code splitting, typed paths) | `@solidjs/router/fs` |
18
18
 
19
19
  Nothing here is Solid-specific: the core is bundler-agnostic — it never imports
20
20
  Vite — the Vite adapter is router-agnostic, and the conventions are the ones
@@ -47,9 +47,11 @@ const Router = createRouter({ routes: fileRoutes(pageRoutes) });
47
47
  export const App = () => <Router>{props => <>{props.children}</>}</Router>;
48
48
  ```
49
49
 
50
- The emission adapter never imports the virtual module itself — the app does,
51
- so the adapter resolves without the plugin, custom `moduleId`s work, and
52
- nothing needs excluding from dependency prebundling.
50
+ The emission adapter ships with the router — `@solidjs/router/fs` for Solid
51
+ Router; other routers mirror the same shape in their own packages. It never
52
+ imports a virtual module itself — the app does, so the adapter resolves
53
+ without the plugin, custom `moduleId`s work, and nothing needs excluding
54
+ from dependency prebundling.
53
55
 
54
56
  Route modules live in `src/routes` (configurable via `fileRoutes({ dir })`).
55
57
  A module is a page when it has a default export, and may export a `route`
@@ -161,25 +163,29 @@ pages get:
161
163
  - The recognized set is `HEAD`/`GET`/`POST`/`PUT`/`DELETE`/`PATCH`/`OPTIONS`;
162
164
  pass an array instead of `true` to change it.
163
165
 
164
- This package stops at the manifest: it discovers handlers, it does not serve
165
- them. Dispatch is a few lines in whatever server consumes the manifest —
166
- match the URL, import the ref, call the export:
166
+ Dispatch ships as fetch-style middleware — `filesystem-routing/api` matches
167
+ requests against the manifest's handler refs and composes into any
168
+ `(request, next)` chain, an SSR handler's middleware option included:
167
169
 
168
170
  ```ts
169
- // any SSR handler or middleware
171
+ // src/middleware.ts
170
172
  import routes from "virtual:file-routes";
173
+ import { createAPIHandler } from "filesystem-routing/api";
171
174
 
172
- async function handleApi(request: Request, entry, params) {
173
- const ref = entry[`$${request.method}`];
174
- if (!ref) return new Response(null, { status: 405 });
175
- const handlers = await ref.import();
176
- // a HEAD alias imports the GET handler
177
- return (handlers[request.method] ?? handlers.GET)({ request, params });
178
- }
175
+ export default [createAPIHandler(routes)];
179
176
  ```
180
177
 
181
- The handler's argument is whatever your server passes — the convention only
182
- dictates the export names. Frameworks typically enable `httpMethods` on the
178
+ Handlers receive the request event of the surrounding scope (the storage
179
+ `provideRequestEvent` establishes — pass `getEvent` to dispatch outside
180
+ one), with the matched `params` written onto it. Unmatched requests (no
181
+ route, or no handler for the method) advance the chain; `HEAD` falls back
182
+ to the `GET` handler; returned strings and JSON values are coerced into
183
+ responses. A `GET` handler may decline by returning `undefined`: a module
184
+ that is also a page falls through to the chain (its component renders
185
+ instead), a handler-only module answers 404. `createAPIMatcher` exposes the
186
+ matching alone for servers with their own dispatch shape.
187
+
188
+ Frameworks typically enable `httpMethods` on the
183
189
  server environment's router only (SolidStart pairs it with `components:
184
190
  false` in SPA mode, so the server manifest routes requests without shipping
185
191
  page modules) — see the per-environment `routers` option below.
@@ -235,8 +241,14 @@ The scanner produces flat `RouteManifestEntry` objects:
235
241
 
236
242
  The Vite adapter serializes the manifest into the virtual module, turning
237
243
  `$`-prefixed refs into dynamic imports and `$$`-prefixed refs into static
238
- imports, each tree-shaken down to the picked exports. It serves two views of
239
- 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:
240
252
 
241
253
  ```ts
242
254
  import routes, { pageRoutes } from "virtual:file-routes";
@@ -247,10 +259,10 @@ with `(group)` segments stripped, so emission adapters don't each reimplement
247
259
  the tree (`buildRouteTree` from `filesystem-routing/tree` is the same
248
260
  function, for consumers holding only a flat manifest). An emission adapter is
249
261
  a function taking manifest entries and returning the router's shape — see
250
- `fileRoutes` in `@solidjs/router/fs` for Solid Router's, a ~30 line adapter
251
- other routers can mirror. Adapters take the manifest as an argument rather
252
- than importing the virtual module, so they work standalone and with custom
253
- module ids.
262
+ `fileRoutes` in `@solidjs/router/fs` for Solid Router's, an
263
+ adapter other routers can mirror. Adapters take the manifest as an argument
264
+ rather than importing the virtual module, so they work standalone and with
265
+ custom module ids.
254
266
 
255
267
  Add `/// <reference types="filesystem-routing/types" />` to type the import.
256
268
 
@@ -299,6 +311,7 @@ fileRoutes({
299
311
  | `httpMethods` | emit `$GET`, `$POST`, … refs for uppercase handler exports; a module with handlers but no default export routes without being a page |
300
312
  | `components` | set `false` to route without emitting `$component` refs, keeping page modules out of that environment's bundle |
301
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`) |
302
315
  | `moduleId` | the id the manifest is served from |
303
316
  | `optimizeDepsExclude` | escape hatch for packages that import the virtual module, kept out of dep prebundling (defaults to `[]`) |
304
317
  | `types` | write a declaration typing the manifest as a literal tuple, kept in step with the route directory |
package/dist/api.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The API dispatch adapter: matches requests against the flat manifest's
3
+ * handler refs (`$GET`, `$POST`, …) and serves them as fetch-style
4
+ * middleware, so API routes compose into any `(request, next)` chain — an
5
+ * SSR handler's middleware option, a framework's request pipeline — with no
6
+ * bundler or server involvement.
7
+ *
8
+ * Ported from SolidStart's `server/routes.ts` (the radix3 matcher) and the
9
+ * method dispatch in its `server/handler.ts`.
10
+ */
11
+ /**
12
+ * The event a handler receives. Structurally the core request event —
13
+ * `getRequestEvent()` inside the handler sees the same object — with the
14
+ * matched route params written onto it before dispatch.
15
+ */
16
+ export interface APIEvent {
17
+ request: Request;
18
+ params?: Record<string, string>;
19
+ [key: string]: unknown;
20
+ }
21
+ /**
22
+ * An API handler: an uppercase method export of a route module. Return a
23
+ * `Response` to answer the request; strings and JSON values are coerced
24
+ * (`new Response(text)` / `Response.json(value)`). A `GET` handler may
25
+ * return `undefined` to decline, falling through to the rest of the chain
26
+ * when the module is also a page.
27
+ */
28
+ export type APIHandler = (event: APIEvent) => unknown;
29
+ /** A manifest entry as the delivery adapter serves it. */
30
+ export interface FileRouteHandlers {
31
+ path: string;
32
+ page?: boolean;
33
+ $component?: unknown;
34
+ $HEAD?: APIHandlerRef;
35
+ $GET?: APIHandlerRef;
36
+ $POST?: APIHandlerRef;
37
+ $PUT?: APIHandlerRef;
38
+ $PATCH?: APIHandlerRef;
39
+ $DELETE?: APIHandlerRef;
40
+ [key: string]: unknown;
41
+ }
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 = {
47
+ import(): Promise<Record<string, APIHandler>>;
48
+ } | {
49
+ require(): Record<string, APIHandler>;
50
+ };
51
+ export interface APIMatch {
52
+ handler: APIHandlerRef;
53
+ params?: Record<string, string>;
54
+ /** `true` when the module also renders a page at this path. */
55
+ isPage: boolean;
56
+ }
57
+ /**
58
+ * Builds a matcher over the flat manifest's handler-carrying entries. Route
59
+ * paths translate from the neutral pattern language to the radix tree —
60
+ * `(group)` segments stripped, `*rest` catch-alls, static segments
61
+ * URL-encoded. Optional parameters are rejected (a radix tree cannot
62
+ * represent them) and duplicate paths are a configuration error.
63
+ */
64
+ export declare function createAPIMatcher(routes: readonly FileRouteHandlers[]): (path: string, method: string) => APIMatch | undefined;
65
+ /** Strips a leading path base: `stripPathBase("/app/x", "/app")` → `/x`. */
66
+ export declare function stripPathBase(path: string, base: string): string;
67
+ export interface APIHandlerOptions {
68
+ /** A path base stripped from the URL before matching. Defaults to `/`. */
69
+ base?: string;
70
+ /**
71
+ * How the adapter reaches the request event it hands to handlers. Defaults
72
+ * to the core request-event scope (the storage `provideRequestEvent`
73
+ * establishes), which is where a middleware chain runs — override it when
74
+ * dispatching outside one, or to hand handlers a richer event.
75
+ */
76
+ getEvent?: () => APIEvent;
77
+ }
78
+ /**
79
+ * Fetch-style middleware serving the manifest's API routes:
80
+ *
81
+ * ```ts
82
+ * import routes from "virtual:file-routes";
83
+ * import { createAPIHandler } from "filesystem-routing/api";
84
+ *
85
+ * export default [createAPIHandler(routes)];
86
+ * ```
87
+ *
88
+ * Dispatch preserves SolidStart's semantics: unmatched requests (no route,
89
+ * or no handler for the method) advance the chain; `HEAD` falls back to the
90
+ * `GET` handler; matched params are written to `event.params` before the
91
+ * handler runs. A handler returning `undefined` declines the request —
92
+ * allowed for `GET` only (anything else throws): a page module falls
93
+ * through to the chain (its component renders instead), a handler-only
94
+ * module answers 404.
95
+ */
96
+ export declare function createAPIHandler(routes: readonly FileRouteHandlers[], options?: APIHandlerOptions): (request: Request, next: (request?: Request) => Response | Promise<Response>) => Promise<Response>;
package/dist/api.js ADDED
@@ -0,0 +1,120 @@
1
+ import { createRouter } from "radix3";
2
+ function containsHTTP(route) {
3
+ return route.$HEAD || route.$GET || route.$POST || route.$PUT || route.$PATCH || route.$DELETE;
4
+ }
5
+ /**
6
+ * Builds a matcher over the flat manifest's handler-carrying entries. Route
7
+ * paths translate from the neutral pattern language to the radix tree —
8
+ * `(group)` segments stripped, `*rest` catch-alls, static segments
9
+ * URL-encoded. Optional parameters are rejected (a radix tree cannot
10
+ * represent them) and duplicate paths are a configuration error.
11
+ */
12
+ export function createAPIMatcher(routes) {
13
+ const router = createRouter({
14
+ routes: routes.reduce((memo, route) => {
15
+ if (!containsHTTP(route))
16
+ return memo;
17
+ const path = route.path
18
+ .replace(/\([^)/]+\)/g, "")
19
+ .replace(/\/+/g, "/")
20
+ .replace(/\*([^/]*)/g, (_, m) => `**:${m}`)
21
+ .split("/")
22
+ .map(s => (s.startsWith(":") || s.startsWith("*") ? s : encodeURIComponent(s)))
23
+ .join("/");
24
+ if (/:[^/]*\?/g.test(path)) {
25
+ throw new Error(`Optional parameters are not supported in API routes: ${path}`);
26
+ }
27
+ if (memo[path]) {
28
+ throw new Error(`Duplicate API routes for "${path}" found at "${memo[path].route.path}" and "${route.path}"`);
29
+ }
30
+ memo[path] = { route };
31
+ return memo;
32
+ }, {})
33
+ });
34
+ return (path, method) => {
35
+ const match = router.lookup(path);
36
+ if (!match || !match.route)
37
+ return;
38
+ const route = match.route;
39
+ // A `GET` handler answers `HEAD` unless the module handles it itself.
40
+ const handler = method === "HEAD" ? route.$HEAD || route.$GET : route[`$${method}`];
41
+ if (handler === undefined)
42
+ return;
43
+ return {
44
+ handler: handler,
45
+ params: match.params,
46
+ isPage: route.page === true && route.$component !== undefined
47
+ };
48
+ };
49
+ }
50
+ /** Strips a leading path base: `stripPathBase("/app/x", "/app")` → `/x`. */
51
+ export function stripPathBase(path, base) {
52
+ if (!base || base === "/")
53
+ return path;
54
+ const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
55
+ if (path === normalizedBase)
56
+ return "/";
57
+ if (path.startsWith(`${normalizedBase}/`))
58
+ return path.slice(normalizedBase.length);
59
+ return path;
60
+ }
61
+ const RequestContext = Symbol.for("solid.RequestContext");
62
+ function getScopedEvent() {
63
+ const event = globalThis[RequestContext]?.getStore();
64
+ if (!event) {
65
+ throw new Error("API route dispatched outside a request-event scope. Compose the handler " +
66
+ "inside `provideRequestEvent` (any `ssr.middleware`-style chain already is), " +
67
+ "or pass `getEvent`.");
68
+ }
69
+ return event;
70
+ }
71
+ function toResponse(result) {
72
+ if (result instanceof Response)
73
+ return result;
74
+ if (typeof result === "string")
75
+ return new Response(result);
76
+ return Response.json(result);
77
+ }
78
+ /**
79
+ * Fetch-style middleware serving the manifest's API routes:
80
+ *
81
+ * ```ts
82
+ * import routes from "virtual:file-routes";
83
+ * import { createAPIHandler } from "filesystem-routing/api";
84
+ *
85
+ * export default [createAPIHandler(routes)];
86
+ * ```
87
+ *
88
+ * Dispatch preserves SolidStart's semantics: unmatched requests (no route,
89
+ * or no handler for the method) advance the chain; `HEAD` falls back to the
90
+ * `GET` handler; matched params are written to `event.params` before the
91
+ * handler runs. A handler returning `undefined` declines the request —
92
+ * allowed for `GET` only (anything else throws): a page module falls
93
+ * through to the chain (its component renders instead), a handler-only
94
+ * module answers 404.
95
+ */
96
+ export function createAPIHandler(routes, options = {}) {
97
+ const match = createAPIMatcher(routes);
98
+ const getEvent = options.getEvent ?? getScopedEvent;
99
+ return async (request, next) => {
100
+ const url = new URL(request.url);
101
+ const matched = match(stripPathBase(url.pathname, options.base ?? "/"), request.method);
102
+ if (!matched)
103
+ return next();
104
+ const mod = "require" in matched.handler ? matched.handler.require() : await matched.handler.import();
105
+ const fn = request.method === "HEAD" ? mod.HEAD || mod.GET : mod[request.method];
106
+ if (typeof fn !== "function")
107
+ return next();
108
+ const event = getEvent();
109
+ event.params = matched.params || {};
110
+ const result = await fn(event);
111
+ if (result !== undefined)
112
+ return toResponse(result);
113
+ if (request.method !== "GET") {
114
+ throw new Error(`API handler for ${request.method} "${request.url}" did not return a response.`);
115
+ }
116
+ if (!matched.isPage)
117
+ return new Response(null, { status: 404 });
118
+ return next();
119
+ };
120
+ }
@@ -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. */
@@ -3,7 +3,7 @@ import { type PageFileSystemRouterConfig } from "../convention.ts";
3
3
  import { BaseFileSystemRouter } from "../router.ts";
4
4
  import { DEFAULT_EXTENSIONS, moduleId } from "./constants.ts";
5
5
  export { DEFAULT_EXTENSIONS, moduleId };
6
- export { treeShake } from "./tree-shake.ts";
6
+ export { sanitizeChunkFileName, toPickId, treeShake } from "./tree-shake.ts";
7
7
  export { fileSystemWatcher } from "./fs-watcher.ts";
8
8
  export interface FileRoutesOptions extends Pick<PageFileSystemRouterConfig, "components" | "httpMethods" | "toPath" | "toRoute"> {
9
9
  /** Route directory, relative to the Vite root. Defaults to `src/routes`. */
@@ -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[];
@@ -5,11 +5,50 @@ import { normalizePath } from "../router.js";
5
5
  import { buildRouteTree } from "../tree.js";
6
6
  import { DEFAULT_EXTENSIONS, moduleId } from "./constants.js";
7
7
  import { fileSystemWatcher } from "./fs-watcher.js";
8
- import { treeShake } from "./tree-shake.js";
8
+ import { sanitizeChunkFileName, toPickId, treeShake } from "./tree-shake.js";
9
9
  import { serializeTypes } from "./types.js";
10
10
  export { DEFAULT_EXTENSIONS, moduleId };
11
- export { treeShake } from "./tree-shake.js";
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;
@@ -61,20 +107,27 @@ export function fileRoutes(options = {}) {
61
107
  writeFileSync(file, contents);
62
108
  }
63
109
  /** The id a route module ref is loaded from: its source plus its picks. */
64
- const toModuleId = (ref) => `${ref.src}?${ref.pick.map(pick => `pick=${pick}`).join("&")}`;
110
+ const toModuleId = (ref) => toPickId(ref.src, ref.pick);
65
111
  return [
66
112
  {
67
113
  name: "filesystem-routing",
68
114
  enforce: "pre",
69
115
  config() {
70
- // Packages importing the virtual module (which only this plugin can
71
- // resolve) must stay out of esbuild prebundling.
72
- if (!options.optimizeDepsExclude?.length)
73
- return;
74
116
  return {
75
- optimizeDeps: {
76
- exclude: options.optimizeDepsExclude
77
- }
117
+ build: {
118
+ rollupOptions: {
119
+ output: {
120
+ // Keeps route chunks named after their file rather than after
121
+ // the `?pick=...` id that addresses them. See toPickId.
122
+ sanitizeFileName: sanitizeChunkFileName
123
+ }
124
+ }
125
+ },
126
+ // Packages importing the virtual module (which only this plugin can
127
+ // resolve) must stay out of esbuild prebundling.
128
+ ...(options.optimizeDepsExclude?.length
129
+ ? { optimizeDeps: { exclude: options.optimizeDepsExclude } }
130
+ : {})
78
131
  };
79
132
  },
80
133
  configResolved(config) {
@@ -94,16 +147,27 @@ export function fileRoutes(options = {}) {
94
147
  await writeTypes();
95
148
  },
96
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;
97
155
  if (env.command !== "build" || !buildInputs.includes(name))
98
156
  return;
99
157
  const router = getRouter(name);
100
158
  if (!router)
101
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());
102
166
  // Every code-split route module is an entry of its own, so the
103
167
  // manifest's dynamic imports resolve to real chunks. `$$` refs are
104
168
  // inlined into the manifest and need no entry.
105
169
  const input = [];
106
- for (const route of await router.getRoutes()) {
170
+ for (const route of routes) {
107
171
  for (const [key, ref] of Object.entries(route)) {
108
172
  if (ref && key.startsWith("$") && !key.startsWith("$$")) {
109
173
  input.push(toModuleId(ref));
@@ -124,8 +188,17 @@ export function fileRoutes(options = {}) {
124
188
  const root = this.environment.config.root;
125
189
  const isBuild = this.environment.mode === "build";
126
190
  const js = jsCode();
127
- const router = getRouter(this.environment.name);
128
- 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
+ }
129
202
  const serializeEntry = (entry) => JSON.stringify(entry, (key, value) => {
130
203
  if (value === undefined)
131
204
  return undefined;
@@ -143,6 +216,16 @@ export function fileRoutes(options = {}) {
143
216
  }
144
217
  else if (key.startsWith("$")) {
145
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
+ }
146
229
  return {
147
230
  src: relative(root, buildId),
148
231
  build: isBuild ? `_$() => import('${buildId}')$_` : undefined,
@@ -185,6 +268,7 @@ export const pageRoutes = ${serializeTree(tree)};
185
268
  }
186
269
  function jsCode() {
187
270
  const imports = new Map();
271
+ const namespaceImports = new Map();
188
272
  let vars = 0;
189
273
  function addNamedImport(name, source) {
190
274
  let names = imports.get(source);
@@ -201,15 +285,25 @@ function jsCode() {
201
285
  names[name] = alias;
202
286
  return alias;
203
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
+ }
204
296
  const getImportStatements = () => {
205
- return [...imports.entries()]
206
- .map(([source, names]) => `import { ${Object.entries(names)
207
- .map(([name, alias]) => `${name} as ${alias}`)
208
- .join(", ")} } from '${source}';`)
209
- .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");
210
303
  };
211
304
  return {
212
305
  addNamedImport,
306
+ addNamespaceImport,
213
307
  getImportStatements
214
308
  };
215
309
  }
@@ -1,2 +1,29 @@
1
1
  import type { Plugin } from "vite";
2
+ /**
3
+ * Builds the module id used to import a subset of a route file's exports.
4
+ *
5
+ * The `pick` list has to live in the query so that the same file can be
6
+ * instantiated once per export subset (the client picks `default`/`$css`,
7
+ * the server picks its HTTP handlers). That query would otherwise leave the
8
+ * id ending in `?pick=GET`, which silently excludes route files from any
9
+ * plugin whose filter is anchored on the file extension -- a very common
10
+ * default, e.g. unplugin-macros' `/\.[cm]?[jt]sx?$/`.
11
+ *
12
+ * The trailing `lang.<ext>` marker is the same convention Vue SFCs use
13
+ * (`?vue&type=script&lang.ts`) and puts a real extension back at the end of
14
+ * the id, so those filters match again.
15
+ *
16
+ * @see https://github.com/solidjs/solid-start/issues/1918
17
+ */
18
+ export declare function toPickId(src: string, pick: string[]): string;
19
+ /**
20
+ * Rollup's default chunk name sanitizer, plus a pass that drops the
21
+ * `?pick=...&lang` tail so route chunks keep the short, stable filenames they
22
+ * had before the `lang` marker was introduced (`index-<hash>.js`, not
23
+ * `index.tsx_pick_default_pick__css_lang-<hash>.js`). Client chunk filenames
24
+ * are public URLs, so this is not purely cosmetic.
25
+ *
26
+ * @see https://github.com/rollup/rollup/blob/master/src/utils/sanitizeFileName.ts
27
+ */
28
+ export declare function sanitizeChunkFileName(name: string): string;
2
29
  export declare function treeShake(): Plugin;
@@ -1,7 +1,52 @@
1
1
  // All credit for this work goes to the amazing Next.js team.
2
2
  // https://github.com/vercel/next.js/blob/canary/packages/next/build/babel/plugins/next-ssg-transform.ts
3
3
  // This is adapted to work with routeData functions. It can be run in two modes, one which preserves the routeData and the Component in the same file, and one which creates a
4
- import { basename } from "node:path";
4
+ import { basename, extname } from "node:path";
5
+ const PICKABLE_EXTENSIONS = ["js", "jsx", "ts", "tsx"];
6
+ /**
7
+ * Builds the module id used to import a subset of a route file's exports.
8
+ *
9
+ * The `pick` list has to live in the query so that the same file can be
10
+ * instantiated once per export subset (the client picks `default`/`$css`,
11
+ * the server picks its HTTP handlers). That query would otherwise leave the
12
+ * id ending in `?pick=GET`, which silently excludes route files from any
13
+ * plugin whose filter is anchored on the file extension -- a very common
14
+ * default, e.g. unplugin-macros' `/\.[cm]?[jt]sx?$/`.
15
+ *
16
+ * The trailing `lang.<ext>` marker is the same convention Vue SFCs use
17
+ * (`?vue&type=script&lang.ts`) and puts a real extension back at the end of
18
+ * the id, so those filters match again.
19
+ *
20
+ * @see https://github.com/solidjs/solid-start/issues/1918
21
+ */
22
+ export function toPickId(src, pick) {
23
+ const query = pick.map(p => `pick=${p}`).join("&");
24
+ const ext = extname(src).slice(1);
25
+ return PICKABLE_EXTENSIONS.includes(ext) ? `${src}?${query}&lang.${ext}` : `${src}?${query}`;
26
+ }
27
+ /**
28
+ * Matches the tail that {@link toPickId} appends, as it appears in a chunk
29
+ * name. Bundlers derive a chunk name from the module id by taking the
30
+ * basename minus its extension, so `index.tsx?pick=default&pick=$css&lang.tsx`
31
+ * arrives here as `index.tsx?pick=default&pick=$css&lang`.
32
+ */
33
+ const PICK_CHUNK_NAME_RE = /\.[cm]?[jt]sx?\?pick=[^?]*&lang$/;
34
+ const INVALID_CHAR_RE = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g;
35
+ const DRIVE_LETTER_RE = /^[a-z]:/i;
36
+ /**
37
+ * Rollup's default chunk name sanitizer, plus a pass that drops the
38
+ * `?pick=...&lang` tail so route chunks keep the short, stable filenames they
39
+ * had before the `lang` marker was introduced (`index-<hash>.js`, not
40
+ * `index.tsx_pick_default_pick__css_lang-<hash>.js`). Client chunk filenames
41
+ * are public URLs, so this is not purely cosmetic.
42
+ *
43
+ * @see https://github.com/rollup/rollup/blob/master/src/utils/sanitizeFileName.ts
44
+ */
45
+ export function sanitizeChunkFileName(name) {
46
+ const stripped = name.replace(PICK_CHUNK_NAME_RE, "");
47
+ const driveLetter = DRIVE_LETTER_RE.exec(stripped)?.[0] ?? "";
48
+ return driveLetter + stripped.slice(driveLetter.length).replace(INVALID_CHAR_RE, "_");
49
+ }
5
50
  function treeShakeTransform({ types: t }) {
6
51
  function getIdentifier(path) {
7
52
  const parentPath = path.parentPath;
@@ -133,6 +178,11 @@ function treeShakeTransform({ types: t }) {
133
178
  if (!state.opts.pick) {
134
179
  return;
135
180
  }
181
+ // Only module-level exports are route exports; `export` inside a
182
+ // TS namespace/module block is unrelated to the pick list.
183
+ if (!exportNamedPath.parentPath.isProgram()) {
184
+ return;
185
+ }
136
186
  const specifiers = exportNamedPath.get("specifiers");
137
187
  if (specifiers.length) {
138
188
  specifiers.forEach(s => {
@@ -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.1.1",
5
+ "version": "0.2.1",
6
6
  "author": "Ryan Carniato",
7
7
  "homepage": "https://github.com/solidjs/filesystem-routing#readme",
8
8
  "repository": {
@@ -40,6 +40,10 @@
40
40
  "types": "./dist/vite/index.d.ts",
41
41
  "default": "./dist/vite/index.js"
42
42
  },
43
+ "./api": {
44
+ "types": "./dist/api.d.ts",
45
+ "default": "./dist/api.js"
46
+ },
43
47
  "./types": {
44
48
  "types": "./types.d.ts"
45
49
  }
@@ -55,7 +59,8 @@
55
59
  "@types/micromatch": "^4.0.10",
56
60
  "fast-glob": "^3.3.3",
57
61
  "micromatch": "^4.0.8",
58
- "oxc-parser": "^0.139.0"
62
+ "oxc-parser": "^0.139.0",
63
+ "radix3": "^1.1.2"
59
64
  },
60
65
  "peerDependencies": {
61
66
  "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
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. */