filesystem-routing 0.3.1 → 0.4.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
@@ -165,7 +165,8 @@ pages get:
165
165
 
166
166
  Dispatch ships as fetch-style middleware — `filesystem-routing/api` matches
167
167
  requests against the manifest's handler refs and composes into any
168
- `(request, next)` chain, an SSR handler's middleware option included:
168
+ `(request, next)` or `(event, next)` chain, an SSR handler's middleware option
169
+ (`@solidjs/vite-plugin`'s `start.middleware`) included:
169
170
 
170
171
  ```ts
171
172
  // src/middleware.ts
@@ -175,7 +176,8 @@ import { createAPIHandler } from "filesystem-routing/api";
175
176
  export default [createAPIHandler(routes)];
176
177
  ```
177
178
 
178
- Handlers receive the request event of the surrounding scope (the storage
179
+ Handlers receive the event the chain passes in or, in a `(request, next)`
180
+ chain, the request event of the surrounding scope (the storage
179
181
  `provideRequestEvent` establishes — pass `getEvent` to dispatch outside
180
182
  one), with the matched `params` written onto it. Unmatched requests (no
181
183
  route, or no handler for the method) advance the chain; `HEAD` falls back
@@ -190,6 +192,50 @@ server environment's router only (SolidStart pairs it with `components:
190
192
  false` in SPA mode, so the server manifest routes requests without shipping
191
193
  page modules) — see the per-environment `routers` option below.
192
194
 
195
+ ## Server-function pages
196
+
197
+ With `serverComponents: true`, a page whose default export begins with a
198
+ `"use server"` directive is a server-function page:
199
+
200
+ ```tsx
201
+ // src/routes/stories/[id].tsx
202
+ export const route = { live: true }; // any config the emission adapter reads
203
+
204
+ export default async function Story({ params }) {
205
+ "use server";
206
+ const story = await db.stories.get(params.id);
207
+ return props => <article>{story.title}{props.children}</article>;
208
+ }
209
+ ```
210
+
211
+ The scanner sees the directive (on an inline function declaration or
212
+ expression, or a top-level function the default export names — not through
213
+ a wrapper call or a re-export) and records two neutral facts on the entry:
214
+ `server: true`, and a `$component` ref marked `eager` and picking only
215
+ `default`. The client side of such a module is a stub, so there is no code
216
+ worth a chunk: the Vite adapter delivers the ref eagerly (`{ src, require }`)
217
+ even with code splitting on, and it is not a build input. What a
218
+ server-rendered page *means* — how the stub is called, cached, and mounted —
219
+ is the emission adapter's business; the scanner only states the fact.
220
+
221
+ Adapters that carry code only server pages need gate it on
222
+ `filesystem-routing/flags`:
223
+
224
+ ```ts
225
+ import { serverRoutes } from "filesystem-routing/flags";
226
+ import * as server from "./server.js"; // dropped from builds with no server page
227
+
228
+ if (serverRoutes && entry.server) server.wrap(entry);
229
+ ```
230
+
231
+ The package ships `flags` as a real module whose facts are all `true` — the
232
+ safe answer wherever no plugin hook is in the loop (dev prebundling, a
233
+ hand-built manifest, another bundler). In builds the Vite plugin serves the
234
+ scan's answer instead, folded to a literal, so a gated static import
235
+ tree-shakes out of every app without a server page and is an ordinary
236
+ static import — present at hydration — in every app with one. The option is
237
+ off by default; a manifest built without it is byte-identical to before.
238
+
193
239
  ## Custom conventions
194
240
 
195
241
  Conventions plug in at two independent seams, so a custom scheme picks the
@@ -310,6 +356,7 @@ fileRoutes({
310
356
  | --- | --- |
311
357
  | `httpMethods` | emit `$GET`, `$POST`, … refs for uppercase handler exports; a module with handlers but no default export routes without being a page |
312
358
  | `components` | set `false` to route without emitting `$component` refs, keeping page modules out of that environment's bundle |
359
+ | `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
360
  | `buildInputs` | environments whose build takes every code-split route module as an entry |
314
361
  | `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
362
  | `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";
package/dist/api.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The API dispatch adapter: matches requests against the flat manifest's
3
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.
4
+ * middleware, so API routes compose into any `(request, next)` or
5
+ * `(event, next)` chain — an SSR handler's middleware option, a framework's
6
+ * request pipeline — with no bundler or server involvement.
7
7
  *
8
8
  * Ported from SolidStart's `server/routes.ts` (the radix3 matcher) and the
9
9
  * method dispatch in its `server/handler.ts`.
@@ -69,7 +69,8 @@ export interface APIHandlerOptions {
69
69
  base?: string;
70
70
  /**
71
71
  * How the adapter reaches the request event it hands to handlers. Defaults
72
- * to the core request-event scope (the storage `provideRequestEvent`
72
+ * to the event the chain passes in or, when it passes a bare `Request`, to
73
+ * the core request-event scope (the storage `provideRequestEvent`
73
74
  * establishes), which is where a middleware chain runs — override it when
74
75
  * dispatching outside one, or to hand handlers a richer event.
75
76
  */
@@ -85,12 +86,18 @@ export interface APIHandlerOptions {
85
86
  * export default [createAPIHandler(routes)];
86
87
  * ```
87
88
  *
89
+ * The first argument is the request or a request event carrying it
90
+ * (`event.request`), so the handler fits `(request, next)` and
91
+ * `(event, next)` chains alike; it calls `next()` without arguments.
92
+ *
88
93
  * Dispatch preserves SolidStart's semantics: unmatched requests (no route,
89
94
  * or no handler for the method) advance the chain; `HEAD` falls back to the
90
95
  * `GET` handler; matched params are written to `event.params` before the
91
96
  * handler runs. A handler returning `undefined` declines the request —
92
- * allowed for `GET` only (anything else throws): a page module falls
97
+ * allowed for `GET` and `HEAD` only (anything else throws): a page module falls
93
98
  * through to the chain (its component renders instead), a handler-only
94
99
  * module answers 404.
95
100
  */
96
- export declare function createAPIHandler(routes: readonly FileRouteHandlers[], options?: APIHandlerOptions): (request: Request, next: (request?: Request) => Response | Promise<Response>) => Promise<Response>;
101
+ export declare function createAPIHandler(routes: readonly FileRouteHandlers[], options?: APIHandlerOptions): (input: Request | {
102
+ request: Request;
103
+ }, next: (request?: Request) => Response | Promise<Response>) => Promise<Response>;
package/dist/api.js CHANGED
@@ -62,8 +62,8 @@ const RequestContext = Symbol.for("solid.RequestContext");
62
62
  function getScopedEvent() {
63
63
  const event = globalThis[RequestContext]?.getStore();
64
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), " +
65
+ throw new Error("API route dispatched outside a request-event scope. Pass the request event " +
66
+ "instead of the request, compose the handler inside `provideRequestEvent`, " +
67
67
  "or pass `getEvent`.");
68
68
  }
69
69
  return event;
@@ -85,18 +85,22 @@ function toResponse(result) {
85
85
  * export default [createAPIHandler(routes)];
86
86
  * ```
87
87
  *
88
+ * The first argument is the request or a request event carrying it
89
+ * (`event.request`), so the handler fits `(request, next)` and
90
+ * `(event, next)` chains alike; it calls `next()` without arguments.
91
+ *
88
92
  * Dispatch preserves SolidStart's semantics: unmatched requests (no route,
89
93
  * or no handler for the method) advance the chain; `HEAD` falls back to the
90
94
  * `GET` handler; matched params are written to `event.params` before the
91
95
  * handler runs. A handler returning `undefined` declines the request —
92
- * allowed for `GET` only (anything else throws): a page module falls
96
+ * allowed for `GET` and `HEAD` only (anything else throws): a page module falls
93
97
  * through to the chain (its component renders instead), a handler-only
94
98
  * module answers 404.
95
99
  */
96
100
  export function createAPIHandler(routes, options = {}) {
97
101
  const match = createAPIMatcher(routes);
98
- const getEvent = options.getEvent ?? getScopedEvent;
99
- return async (request, next) => {
102
+ return async (input, next) => {
103
+ const request = "request" in input ? input.request : input;
100
104
  const url = new URL(request.url);
101
105
  const matched = match(stripPathBase(url.pathname, options.base ?? "/"), request.method);
102
106
  if (!matched)
@@ -105,12 +109,12 @@ export function createAPIHandler(routes, options = {}) {
105
109
  const fn = request.method === "HEAD" ? mod.HEAD || mod.GET : mod[request.method];
106
110
  if (typeof fn !== "function")
107
111
  return next();
108
- const event = getEvent();
112
+ const event = options.getEvent?.() ?? ("request" in input ? input : getScopedEvent());
109
113
  event.params = matched.params || {};
110
114
  const result = await fn(event);
111
115
  if (result !== undefined)
112
116
  return toResponse(result);
113
- if (request.method !== "GET") {
117
+ if (request.method !== "GET" && request.method !== "HEAD") {
114
118
  throw new Error(`API handler for ${request.method} "${request.url}" did not return a response.`);
115
119
  }
116
120
  if (!matched.isPage)
@@ -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.d.ts CHANGED
@@ -38,14 +38,21 @@ export declare function cleanPath(src: string, config: FileSystemRouterConfig):
38
38
  */
39
39
  export declare class BaseFileSystemRouter extends EventTarget {
40
40
  routes: RouteManifestEntry[];
41
+ /** Source file that first produced each route path, so incremental changes keep scan order. */
42
+ private sources;
41
43
  config: FileSystemRouterConfig;
44
+ /**
45
+ * Route path each source file produced. A removed file can no longer be
46
+ * passed to `toRoute`, and `toPath` may give another file's route.
47
+ */
48
+ private routePaths;
42
49
  constructor(config: FileSystemRouterConfig);
43
50
  glob(): string;
44
51
  buildRoutes(): Promise<RouteManifestEntry[]>;
45
52
  isRoute(src: string): boolean;
46
53
  toPath(src: string): string | undefined;
47
54
  toRoute(src: string): RouteManifestEntry | undefined;
48
- _addRoute(route: RouteManifestEntry): boolean;
55
+ _addRoute(route: RouteManifestEntry, src?: string): boolean;
49
56
  addRoute(src: string): Promise<void>;
50
57
  reload(route: string, type: "update" | "remove" | "add"): void;
51
58
  updateRoute(src_: string): Promise<void>;
package/dist/router.js CHANGED
@@ -20,7 +20,14 @@ export function cleanPath(src, config) {
20
20
  */
21
21
  export class BaseFileSystemRouter extends EventTarget {
22
22
  routes;
23
+ /** Source file that first produced each route path, so incremental changes keep scan order. */
24
+ sources = new Map();
23
25
  config;
26
+ /**
27
+ * Route path each source file produced. A removed file can no longer be
28
+ * passed to `toRoute`, and `toPath` may give another file's route.
29
+ */
30
+ routePaths = new Map();
24
31
  constructor(config) {
25
32
  super();
26
33
  this.routes = [];
@@ -36,7 +43,7 @@ export class BaseFileSystemRouter extends EventTarget {
36
43
  return posix.join(fg.convertPathToPattern(this.config.dir), "**/*") + suffix;
37
44
  }
38
45
  async buildRoutes() {
39
- for (const src of glob(this.glob())) {
46
+ for (const src of glob(this.glob()).sort()) {
40
47
  await this.addRoute(src);
41
48
  }
42
49
  return this.routes;
@@ -54,12 +61,28 @@ export class BaseFileSystemRouter extends EventTarget {
54
61
  return this.config.toRoute(src, this);
55
62
  throw new Error("Not implemented");
56
63
  }
57
- _addRoute(route) {
64
+ _addRoute(route, src) {
58
65
  const idx = this.routes.findIndex(r => r.path === route.path);
59
- if (idx >= 0)
60
- this.routes.splice(idx, 1);
61
- this.routes.push(route);
62
- return idx >= 0;
66
+ if (idx >= 0) {
67
+ // Replace in place: an edited file keeps its position. The source that
68
+ // first produced the path still decides it, as in a full scan.
69
+ this.routes[idx] = route;
70
+ return true;
71
+ }
72
+ if (src !== undefined)
73
+ this.sources.set(route.path, src);
74
+ // A new file goes where a full scan would put it.
75
+ const at = src === undefined
76
+ ? -1
77
+ : this.routes.findIndex(r => {
78
+ const other = this.sources.get(r.path);
79
+ return other !== undefined && other > src;
80
+ });
81
+ if (at === -1)
82
+ this.routes.push(route);
83
+ else
84
+ this.routes.splice(at, 0, route);
85
+ return false;
63
86
  }
64
87
  async addRoute(src) {
65
88
  src = normalizePath(src);
@@ -67,7 +90,10 @@ export class BaseFileSystemRouter extends EventTarget {
67
90
  try {
68
91
  const route = this.toRoute(src);
69
92
  if (route) {
70
- this._addRoute(route);
93
+ if (this.routePaths.get(src) !== route.path)
94
+ this.removeRoute(src);
95
+ this._addRoute(route, src);
96
+ this.routePaths.set(src, route.path);
71
97
  this.reload(route.path, "add");
72
98
  }
73
99
  }
@@ -90,7 +116,10 @@ export class BaseFileSystemRouter extends EventTarget {
90
116
  try {
91
117
  const route = this.toRoute(src);
92
118
  if (route) {
93
- const updated = this._addRoute(route);
119
+ if (this.routePaths.get(src) !== route.path)
120
+ this.removeRoute(src_);
121
+ const updated = this._addRoute(route, src);
122
+ this.routePaths.set(src, route.path);
94
123
  this.reload(route.path, updated ? "update" : "add");
95
124
  }
96
125
  else {
@@ -104,17 +133,16 @@ export class BaseFileSystemRouter extends EventTarget {
104
133
  }
105
134
  removeRoute(src) {
106
135
  src = normalizePath(src);
107
- if (this.isRoute(src)) {
108
- const path = this.toPath(src);
109
- if (path === undefined) {
110
- return;
111
- }
112
- const idx = this.routes.findIndex(r => r.path === path);
113
- if (idx === -1)
114
- return;
115
- this.routes.splice(idx, 1);
116
- this.reload(path, "remove");
117
- }
136
+ const path = this.routePaths.get(src);
137
+ if (path === undefined)
138
+ return;
139
+ this.routePaths.delete(src);
140
+ const idx = this.routes.findIndex(r => r.path === path);
141
+ if (idx === -1)
142
+ return;
143
+ this.routes.splice(idx, 1);
144
+ this.sources.delete(path);
145
+ this.reload(path, "remove");
118
146
  }
119
147
  on(type, cb) {
120
148
  this.addEventListener(type, cb);
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";
@@ -21,6 +21,15 @@ const HANDLER_REF = /^\$[A-Z]+$/;
21
21
  * own default: the `client` environment is the one client consumer.
22
22
  */
23
23
  const isServerConsumer = (name, consumer) => (consumer ?? (name === "client" ? "client" : "server")) === "server";
24
+ /**
25
+ * The id a route module ref is loaded from: its source plus its picks. On the
26
+ * server a server page's module is loaded whole, because the server-function
27
+ * handler imports it whole too, and a second (picked) instance would register
28
+ * the page's server function twice.
29
+ */
30
+ const toModuleId = (route, ref, serverConsumer) => serverConsumer && route.server === true && ref.src === route.$component?.src
31
+ ? ref.src
32
+ : toPickId(ref.src, ref.pick);
24
33
  /**
25
34
  * The client's view of a shared server manifest: handler refs removed. A
26
35
  * client bundle can never invoke a request handler, but serializing its ref
@@ -106,8 +115,6 @@ export function fileRoutes(options = {}) {
106
115
  mkdirSync(dirname(file), { recursive: true });
107
116
  writeFileSync(file, contents);
108
117
  }
109
- /** The id a route module ref is loaded from: its source plus its picks. */
110
- const toModuleId = (ref) => toPickId(ref.src, ref.pick);
111
118
  return [
112
119
  {
113
120
  name: "filesystem-routing",
@@ -138,6 +145,7 @@ export function fileRoutes(options = {}) {
138
145
  extensions: options.extensions ?? DEFAULT_EXTENSIONS,
139
146
  components: options.components,
140
147
  httpMethods: options.httpMethods,
148
+ serverComponents: options.serverComponents,
141
149
  toPath: options.toPath,
142
150
  toRoute: options.toRoute
143
151
  });
@@ -160,7 +168,8 @@ export function fileRoutes(options = {}) {
160
168
  // A client environment on the shared router never serializes
161
169
  // handler refs (see load), so their modules must not become its
162
170
  // build entries either.
163
- const routes = options.routers?.[name] || isServerConsumer(name, _config.consumer)
171
+ const serverConsumer = isServerConsumer(name, _config.consumer);
172
+ const routes = options.routers?.[name] || serverConsumer
164
173
  ? await router.getRoutes()
165
174
  : stripHandlerRefs(await router.getRoutes());
166
175
  // Every code-split route module is an entry of its own, so the
@@ -169,8 +178,8 @@ export function fileRoutes(options = {}) {
169
178
  const input = [];
170
179
  for (const route of routes) {
171
180
  for (const [key, ref] of Object.entries(route)) {
172
- if (ref && key.startsWith("$") && !key.startsWith("$$")) {
173
- input.push(toModuleId(ref));
181
+ if (ref && key.startsWith("$") && !key.startsWith("$$") && !ref.eager) {
182
+ input.push(toModuleId(route, ref, serverConsumer));
174
183
  }
175
184
  }
176
185
  }
@@ -181,8 +190,20 @@ export function fileRoutes(options = {}) {
181
190
  resolveId(source) {
182
191
  if (source === virtualId)
183
192
  return virtualId;
193
+ if (source === flagsId)
194
+ return resolvedFlagsId;
184
195
  },
185
196
  async load(loadedId) {
197
+ if (loadedId === resolvedFlagsId) {
198
+ // Facts about the scan, folded to literals so an adapter's static
199
+ // imports gated on them tree-shake out of a build that does not
200
+ // need them. In serve, always `true`: dev has no size to protect
201
+ // and the first server page must work without a restart.
202
+ const router = getRouter(this.environment.name);
203
+ const routes = (router && (await router.getRoutes())) ?? [];
204
+ const serverRoutes = this.environment.mode !== "build" || routes.some(route => route.server === true);
205
+ return `export const serverRoutes = ${serverRoutes};`;
206
+ }
186
207
  if (loadedId !== virtualId)
187
208
  return;
188
209
  const root = this.environment.config.root;
@@ -191,19 +212,19 @@ export function fileRoutes(options = {}) {
191
212
  const environmentName = this.environment.name;
192
213
  const router = getRouter(environmentName);
193
214
  let routes = (router ? await router.getRoutes() : []) ?? [];
215
+ const serverConsumer = isServerConsumer(environmentName, this.environment.config.consumer);
194
216
  // The shared router serves every environment, so the split is this
195
217
  // adapter's job: client consumers get the manifest without handler
196
218
  // refs. An explicit per-environment router already IS the split —
197
219
  // serve whatever it emits.
198
- if (!options.routers?.[environmentName] &&
199
- !isServerConsumer(environmentName, this.environment.config.consumer)) {
220
+ if (!options.routers?.[environmentName] && !serverConsumer) {
200
221
  routes = stripHandlerRefs(routes);
201
222
  }
202
223
  const serializeEntry = (entry) => JSON.stringify(entry, (key, value) => {
203
224
  if (value === undefined)
204
225
  return undefined;
205
226
  if (key.startsWith("$$")) {
206
- const buildId = toModuleId(value);
227
+ const buildId = toModuleId(entry, value, serverConsumer);
207
228
  const refs = {};
208
229
  for (const pick of value.pick) {
209
230
  refs[pick] = js.addNamedImport(pick, buildId);
@@ -215,19 +236,20 @@ export function fileRoutes(options = {}) {
215
236
  };
216
237
  }
217
238
  else if (key.startsWith("$")) {
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) {
239
+ const buildId = toModuleId(entry, value, serverConsumer);
240
+ // With code splitting off — or a ref the convention marked
241
+ // `eager` — the ref is delivered eagerly: a namespace import
242
+ // (named imports would fail on the synthetic `$css` pick)
243
+ // behind the `require()` shape eager refs carry, plus the
244
+ // `src` lazy refs already expose.
245
+ if (!codeSplitting || value.eager) {
224
246
  return {
225
- src: relative(root, buildId),
247
+ src: normalizePath(relative(root, buildId)),
226
248
  require: `_$() => (${js.addNamespaceImport(buildId)})$_`
227
249
  };
228
250
  }
229
251
  return {
230
- src: relative(root, buildId),
252
+ src: normalizePath(relative(root, buildId)),
231
253
  build: isBuild ? `_$() => import('${buildId}')$_` : undefined,
232
254
  import: `_$() => import('${buildId}')$_`
233
255
  };
@@ -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;
@@ -45,19 +47,22 @@ const REF_TYPES = ` /** A code-split ref: the delivery adapter emits it as a dy
45
47
  /** Extensions TypeScript can resolve through a `typeof import(...)`. */
46
48
  const TYPED_MODULE = /\.([mc]?[jt]sx?)$/;
47
49
  /**
48
- * Types a ref by the module it points at, so a page's `default` really is the
49
- * component and a `route` export really is the config the module declares —
50
- * which is what saves an emission adapter from casting either one. Modules
51
- * TypeScript cannot resolve (`.md`, `.mdx`) fall back to the generic ref.
50
+ * Types a ref by its picked exports, keeping the module's `default` and
51
+ * `route` types so an emission adapter can read either without a cast.
52
+ * Modules TypeScript cannot resolve (`.md`, `.mdx`) fall back to the generic ref.
52
53
  */
53
54
  function refType(ref, kind, from) {
54
55
  const name = kind === "eager" ? "FileRouteEagerRef" : "FileRouteLazyRef";
55
56
  if (!TYPED_MODULE.test(ref.src))
56
57
  return name;
58
+ const picks = ref.pick.filter(pick => pick !== "$css").map(pick => JSON.stringify(pick));
59
+ if (!picks.length)
60
+ return name;
57
61
  let specifier = normalizePath(relative(from, ref.src)).replace(TYPED_MODULE, "");
58
62
  if (!specifier.startsWith("."))
59
63
  specifier = `./${specifier}`;
60
- return `${name}<typeof import(${JSON.stringify(specifier)})>`;
64
+ const moduleType = `typeof import(${JSON.stringify(specifier)})`;
65
+ return `${name}<Pick<${moduleType}, Extract<${picks.join(" | ")}, keyof ${moduleType}>>>`;
61
66
  }
62
67
  const isLazyRefKey = (key) => key.startsWith("$") && !key.startsWith("$$");
63
68
  const isEagerRefKey = (key) => key.startsWith("$$");
@@ -72,14 +77,18 @@ function entryType(entry, indent, nested, from, codeSplitting) {
72
77
  if (nested)
73
78
  fields.push(`id: ${JSON.stringify(entry.id)}`);
74
79
  fields.push(`page: ${entry.page ? "true" : "false"}`);
80
+ if (entry.server)
81
+ fields.push(`server: true`);
75
82
  // A convention records refs it did not emit as present-but-undefined keys,
76
83
  // so go by the value, not the key.
77
84
  const refKeys = Object.keys(entry).filter(key => key.startsWith("$") && entry[key] !== undefined);
78
85
  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)}`);
86
+ // With code splitting off, or a ref the convention marked `eager`, the
87
+ // delivery adapter materializes lazy refs eagerly, so the declaration
88
+ // describes them as delivered.
89
+ const ref = entry[key];
90
+ const kind = isEagerRefKey(key) || !codeSplitting || ref.eager ? "eager" : "lazy";
91
+ fields.push(`${key}: ${refType(ref, kind, from)}`);
83
92
  }
84
93
  // Reading an absent ref is how adapters branch, so keep the key present.
85
94
  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.1",
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
  }