filesystem-routing 0.4.0 → 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
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)
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 = [];
@@ -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);
@@ -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",
@@ -161,7 +168,8 @@ export function fileRoutes(options = {}) {
161
168
  // A client environment on the shared router never serializes
162
169
  // handler refs (see load), so their modules must not become its
163
170
  // build entries either.
164
- const routes = options.routers?.[name] || isServerConsumer(name, _config.consumer)
171
+ const serverConsumer = isServerConsumer(name, _config.consumer);
172
+ const routes = options.routers?.[name] || serverConsumer
165
173
  ? await router.getRoutes()
166
174
  : stripHandlerRefs(await router.getRoutes());
167
175
  // Every code-split route module is an entry of its own, so the
@@ -171,7 +179,7 @@ export function fileRoutes(options = {}) {
171
179
  for (const route of routes) {
172
180
  for (const [key, ref] of Object.entries(route)) {
173
181
  if (ref && key.startsWith("$") && !key.startsWith("$$") && !ref.eager) {
174
- input.push(toModuleId(ref));
182
+ input.push(toModuleId(route, ref, serverConsumer));
175
183
  }
176
184
  }
177
185
  }
@@ -204,19 +212,19 @@ export function fileRoutes(options = {}) {
204
212
  const environmentName = this.environment.name;
205
213
  const router = getRouter(environmentName);
206
214
  let routes = (router ? await router.getRoutes() : []) ?? [];
215
+ const serverConsumer = isServerConsumer(environmentName, this.environment.config.consumer);
207
216
  // The shared router serves every environment, so the split is this
208
217
  // adapter's job: client consumers get the manifest without handler
209
218
  // refs. An explicit per-environment router already IS the split —
210
219
  // serve whatever it emits.
211
- if (!options.routers?.[environmentName] &&
212
- !isServerConsumer(environmentName, this.environment.config.consumer)) {
220
+ if (!options.routers?.[environmentName] && !serverConsumer) {
213
221
  routes = stripHandlerRefs(routes);
214
222
  }
215
223
  const serializeEntry = (entry) => JSON.stringify(entry, (key, value) => {
216
224
  if (value === undefined)
217
225
  return undefined;
218
226
  if (key.startsWith("$$")) {
219
- const buildId = toModuleId(value);
227
+ const buildId = toModuleId(entry, value, serverConsumer);
220
228
  const refs = {};
221
229
  for (const pick of value.pick) {
222
230
  refs[pick] = js.addNamedImport(pick, buildId);
@@ -228,7 +236,7 @@ export function fileRoutes(options = {}) {
228
236
  };
229
237
  }
230
238
  else if (key.startsWith("$")) {
231
- const buildId = toModuleId(value);
239
+ const buildId = toModuleId(entry, value, serverConsumer);
232
240
  // With code splitting off — or a ref the convention marked
233
241
  // `eager` — the ref is delivered eagerly: a namespace import
234
242
  // (named imports would fail on the synthetic `$css` pick)
@@ -47,19 +47,22 @@ const REF_TYPES = ` /** A code-split ref: the delivery adapter emits it as a dy
47
47
  /** Extensions TypeScript can resolve through a `typeof import(...)`. */
48
48
  const TYPED_MODULE = /\.([mc]?[jt]sx?)$/;
49
49
  /**
50
- * Types a ref by the module it points at, so a page's `default` really is the
51
- * component and a `route` export really is the config the module declares —
52
- * which is what saves an emission adapter from casting either one. Modules
53
- * 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.
54
53
  */
55
54
  function refType(ref, kind, from) {
56
55
  const name = kind === "eager" ? "FileRouteEagerRef" : "FileRouteLazyRef";
57
56
  if (!TYPED_MODULE.test(ref.src))
58
57
  return name;
58
+ const picks = ref.pick.filter(pick => pick !== "$css").map(pick => JSON.stringify(pick));
59
+ if (!picks.length)
60
+ return name;
59
61
  let specifier = normalizePath(relative(from, ref.src)).replace(TYPED_MODULE, "");
60
62
  if (!specifier.startsWith("."))
61
63
  specifier = `./${specifier}`;
62
- 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}>>>`;
63
66
  }
64
67
  const isLazyRefKey = (key) => key.startsWith("$") && !key.startsWith("$$");
65
68
  const isEagerRefKey = (key) => key.startsWith("$$");
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.4.0",
5
+ "version": "0.4.1",
6
6
  "author": "Ryan Carniato",
7
7
  "homepage": "https://github.com/solidjs/filesystem-routing#readme",
8
8
  "repository": {