@spfn/core 0.3.0-beta.13 → 0.3.0-beta.15

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
@@ -122,8 +122,9 @@ the route map the RPC proxy needs — and nothing else.
122
122
  5. **Mount the RPC proxy** in Next.js as the `app/api/rpc/[routeName]/route.ts`
123
123
  catch-all. The browser only ever sends `GET` (no body) or `POST` (body or formData)
124
124
  to `/api/rpc/{routeName}`; the proxy looks up `routeMap[routeName]`, substitutes
125
- `:params`, and forwards with the real method. Package route maps (`authRouteMap`,
126
- `eventRouteMap`) merge into the same map.
125
+ `:params`, and forwards with the real method. The generated map already carries the
126
+ routes of the packages the app router mounts with `.packages()`; `eventRouteMap` is
127
+ merged by hand, being a constant rather than a mounted router.
127
128
  6. **Call it** through `createApi<AppRouter>()`. The client is a `Proxy` over the
128
129
  `AppRouter` type — typed in and out, with no runtime cost for the types. Errors
129
130
  arrive as `ApiError`, or as the original error class when that class is registered in
@@ -791,9 +792,10 @@ way so the endpoint never reveals that someone else's record exists.
791
792
 
792
793
  Other SPFN packages build on `@spfn/core`:
793
794
 
794
- - [`@spfn/auth`](../auth/README.md) — sessions, social login and RBAC. Exports
795
- `authRouteMap` and registers proxy interceptors automatically; merge its route map into
796
- `createRpcProxy`.
795
+ - [`@spfn/auth`](../auth/README.md) — sessions, social login and RBAC. Mount `authRouter`
796
+ with `.packages()` and its routes are in the app's generated map; the package registers
797
+ proxy interceptors automatically. (`authRouteMap` is still exported, and merging it is
798
+ now a no-op.)
797
799
  - [`@spfn/mcp`](../mcp/README.md) — exposes your operations as MCP tools, so an agent can
798
800
  run them instead of you building an admin dashboard.
799
801
  - `@spfn/i18n`, `@spfn/storage`, `@spfn/notification`, `@spfn/cms`, `@spfn/monitor`,
@@ -302,6 +302,15 @@ interface GenerationStats {
302
302
  * 404s. Walking the loaded router registers exactly what `registerRoutes`
303
303
  * registers, whatever the source looks like.
304
304
  *
305
+ * The map carries the routes of every package router the app mounts with
306
+ * `.packages()`, not only the app's own. A package ships a client that addresses
307
+ * its routes by name — `authApi.login`, `monitorApi.getStats` — and the app's
308
+ * `createRpcProxy` resolves a name in the single map it was constructed with, so
309
+ * a package route in nobody's map is a call that resolves nowhere. The app
310
+ * therefore merges nothing by hand: `{ ...routeMap, ...authRouteMap }` is a
311
+ * no-op today and `{ ...routeMap }` is the whole of it. (`eventRouteMap` is the
312
+ * exception that stays — it is a hand-written constant, not a mounted router.)
313
+ *
305
314
  * @example
306
315
  * ```typescript
307
316
  * // .spfnrc.ts
@@ -688,46 +688,99 @@ function loadRouter(cwd, absoluteRouterPath) {
688
688
  );
689
689
  }
690
690
  var HTTP_METHODS = { GET: true, POST: true, PUT: true, PATCH: true, DELETE: true };
691
- function addRoute(name, routeDef, trail, found) {
691
+ function duplicateNameError(origin, name, existing, where) {
692
+ if (origin === "app") {
693
+ return new RouteMapGeneratorError(
694
+ `Two routes are both named "${name}" (${existing.trail} and ${where}). The RPC client addresses a route by its name alone, and a nested route registers under its own key, so a name must be unique across the whole router.`
695
+ );
696
+ }
697
+ return new RouteMapGeneratorError(
698
+ `Two package routers both register a route named "${name}" (${existing.trail} and ${where}). Both are written into this app's route map, which holds one entry per name, so one of the two packages would have its client call the other package's path. Nothing of this app's can be renamed to fix it: drop one of the two mounts, or raise the name with the package that took it second.`
699
+ );
700
+ }
701
+ function addRoute(name, routeDef, trail, into, origin) {
692
702
  const where = trail.join(".");
693
703
  if (!routeDef.method || !routeDef.path) {
694
704
  genLogger.warn(
695
705
  `Route "${where}" has no method or path and is left out of the map. The RPC client resolves a name to a method and a path, so both are required. A route reaches this state by being registered before .handler() was called \u2014 the server skips it too.`
696
706
  );
697
- return;
707
+ return void 0;
698
708
  }
699
709
  if (!Object.hasOwn(HTTP_METHODS, routeDef.method)) {
700
710
  throw new RouteMapGeneratorError(
701
711
  `Route "${where}" declares the method "${routeDef.method}", which is not an HttpMethod (${Object.keys(HTTP_METHODS).join(", ")}). The generated map is typed HttpMethod, so naming it would write a file that does not compile.`
702
712
  );
703
713
  }
704
- const existing = found.get(name);
714
+ const existing = into.get(name);
705
715
  if (existing) {
706
- throw new RouteMapGeneratorError(
707
- `Two routes are both named "${name}" (${existing.trail} and ${where}). The RPC client addresses a route by its name alone, and a nested route registers under its own key, so a name must be unique across the whole router.`
708
- );
716
+ if (origin === "package" && existing.def === routeDef) {
717
+ return void 0;
718
+ }
719
+ throw duplicateNameError(origin, name, existing, where);
709
720
  }
710
- found.set(name, { method: routeDef.method, path: routeDef.path, trail: where });
721
+ const collected = {
722
+ method: routeDef.method,
723
+ path: routeDef.path,
724
+ trail: where,
725
+ def: routeDef
726
+ };
727
+ into.set(name, collected);
728
+ return collected;
711
729
  }
712
- function collectPackageNames(router, trail, names) {
713
- if (router._publishesRouteMap === false) {
730
+ function pathPrefix(path) {
731
+ const [, first] = path.split("/");
732
+ return first ? `/${first}` : path;
733
+ }
734
+ function collectPackageRoutes(router, trail, mount, collection) {
735
+ if (router._publishesRouteMap === false || collection.visited.has(router)) {
714
736
  return;
715
737
  }
738
+ collection.visited.set(router, trail.join("."));
716
739
  for (const [name, entry] of Object.entries(router.routes)) {
717
740
  if (isRouter(entry)) {
718
- collectPackageNames(entry, [...trail, name], names);
719
- } else if (isRouteDef(entry) && !names.has(name)) {
720
- names.set(name, [...trail, name].join("."));
741
+ collectPackageRoutes(entry, [...trail, name], mount, collection);
742
+ continue;
743
+ }
744
+ if (!isRouteDef(entry)) {
745
+ genLogger.warn(
746
+ `Package router entry "${[...trail, name].join(".")}" is neither a route nor a router (got ${typeof entry}) and is left out of the map. \`registerRoutes\` skips it too.`
747
+ );
748
+ continue;
749
+ }
750
+ const collected = addRoute(name, entry, [...trail, name], collection.packages, "package");
751
+ if (collected) {
752
+ mount.count += 1;
753
+ mount.prefixes.add(pathPrefix(collected.path));
754
+ continue;
755
+ }
756
+ const already = collection.packages.get(name);
757
+ if (already?.def === entry) {
758
+ mount.sharedWith ??= already.trail;
721
759
  }
722
760
  }
723
- for (const [index, packageRouter] of (router._packageRouters ?? []).entries()) {
724
- collectPackageNames(packageRouter, [...trail, `packages[${index}]`], names);
761
+ for (const [index, nested] of (router._packageRouters ?? []).entries()) {
762
+ collectPackageRoutes(nested, [...trail, `packages[${index}]`], mount, collection);
725
763
  }
726
764
  }
727
- function collectRoutes(router, trail, found, packageNames) {
765
+ function mountPackageRouter(router, label, collection) {
766
+ const mount = { label, count: 0, prefixes: /* @__PURE__ */ new Set() };
767
+ const alreadyAt = collection.visited.get(router);
768
+ if (router._publishesRouteMap === false) {
769
+ mount.skipped = "publishes no route map";
770
+ } else if (alreadyAt) {
771
+ mount.skipped = `already reached through ${alreadyAt}`;
772
+ }
773
+ collectPackageRoutes(router, [label], mount, collection);
774
+ if (mount.count === 0 && !mount.skipped && mount.sharedWith) {
775
+ mount.skipped = `every route it holds was already collected through ${mount.sharedWith}`;
776
+ }
777
+ collection.mounts.push(mount);
778
+ return mount;
779
+ }
780
+ function collectRoutes(router, trail, collection) {
728
781
  for (const [name, entry] of Object.entries(router.routes)) {
729
782
  if (isRouter(entry)) {
730
- collectRoutes(entry, [...trail, name], found, packageNames);
783
+ collectRoutes(entry, [...trail, name], collection);
731
784
  continue;
732
785
  }
733
786
  if (!isRouteDef(entry)) {
@@ -736,29 +789,33 @@ function collectRoutes(router, trail, found, packageNames) {
736
789
  );
737
790
  continue;
738
791
  }
739
- addRoute(name, entry, [...trail, name], found);
792
+ addRoute(name, entry, [...trail, name], collection.app, "app");
740
793
  }
741
794
  for (const [index, packageRouter] of (router._packageRouters ?? []).entries()) {
742
- collectPackageNames(packageRouter, [...trail, `packages[${index}]`], packageNames);
795
+ mountPackageRouter(packageRouter, `${trail.join(".")}.packages[${index}]`, collection);
743
796
  }
744
797
  }
745
- function assertNoPackageCollision(found, packageNames) {
746
- for (const [name, where] of packageNames) {
747
- const route = found.get(name);
748
- if (!route) {
798
+ function assertNoPackageCollision(collection) {
799
+ for (const [name, route] of collection.packages) {
800
+ const appRoute = collection.app.get(name);
801
+ if (!appRoute) {
749
802
  continue;
750
803
  }
751
804
  throw new RouteMapGeneratorError(
752
- `The app route "${name}" (${route.trail}) is also registered by a package router (${where}). The app merges the two maps as { ...routeMap, ...packageRouteMap }, so the package entry wins the name at runtime while the generated types still describe the app's route \u2014 every call would go to the package's path. Rename the app route.`
805
+ `The app route "${name}" (${appRoute.trail}) is also registered by a package router (${route.trail}). Both go into this app's generated route map, which holds one entry per name, so one of the two paths becomes unreachable while the typed client still describes the app's route. Rename the app route.`
753
806
  );
754
807
  }
755
808
  }
756
809
  function collectRouteMap(router) {
757
- const found = /* @__PURE__ */ new Map();
758
- const packageNames = /* @__PURE__ */ new Map();
759
- collectRoutes(router, ["router"], found, packageNames);
760
- assertNoPackageCollision(found, packageNames);
761
- return found;
810
+ const collection = {
811
+ app: /* @__PURE__ */ new Map(),
812
+ packages: /* @__PURE__ */ new Map(),
813
+ mounts: [],
814
+ visited: /* @__PURE__ */ new Map()
815
+ };
816
+ collectRoutes(router, ["router"], collection);
817
+ assertNoPackageCollision(collection);
818
+ return collection;
762
819
  }
763
820
  function toObjectKey(name) {
764
821
  if (name === "__proto__") {
@@ -773,7 +830,29 @@ function toStringLiteral(value) {
773
830
  }
774
831
  return JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
775
832
  }
776
- function generateRouteMapContent(routes, nodeEnv) {
833
+ function describeMount(mount) {
834
+ if (mount.count === 0) {
835
+ return `${mount.label} \u2014 no routes${mount.skipped ? ` (${mount.skipped})` : ""}`;
836
+ }
837
+ const routes = `${mount.count} route${mount.count === 1 ? "" : "s"}`;
838
+ return `${mount.label} \u2014 ${routes} under ${[...mount.prefixes].join(", ")}`;
839
+ }
840
+ function packageHeaderLines(collection) {
841
+ if (collection.packages.size === 0) {
842
+ return [];
843
+ }
844
+ return [
845
+ " *",
846
+ " * The entries under the .packages() line below are not this app's. They belong",
847
+ " * to the package routers it mounts, copied in as they were when this file was",
848
+ " * generated:",
849
+ ...collection.mounts.map((mount) => ` * ${describeMount(mount)}`),
850
+ " *",
851
+ " * A package upgrade reaches them only through a regeneration: `spfn build`",
852
+ " * regenerates before it compiles, a bare `next build` uses what is committed here."
853
+ ];
854
+ }
855
+ function generateRouteMapContent(collection, nodeEnv) {
777
856
  const lines = [
778
857
  "/**",
779
858
  " * Route Map (Auto-generated)",
@@ -783,6 +862,7 @@ function generateRouteMapContent(routes, nodeEnv) {
783
862
  ` * Generated with NODE_ENV=${nodeEnv}. A route registered only under one environment is`,
784
863
  " * named here only when the generator ran under it, so this line changing is that",
785
864
  " * difference rather than an edit.",
865
+ ...packageHeaderLines(collection),
786
866
  " */",
787
867
  "",
788
868
  "import type { HttpMethod } from '@spfn/core/route';",
@@ -795,8 +875,14 @@ function generateRouteMapContent(routes, nodeEnv) {
795
875
  "",
796
876
  "export const routeMap: Record<string, RouteInfo> = {"
797
877
  ];
798
- for (const [name, route] of routes) {
799
- lines.push(` ${toObjectKey(name)}: { method: '${route.method}', path: ${toStringLiteral(route.path)} },`);
878
+ for (const [name, route] of collection.app) {
879
+ lines.push(entryLine(name, route));
880
+ }
881
+ if (collection.packages.size > 0) {
882
+ lines.push(" // From the package routers this app mounts with .packages(), not its own:");
883
+ for (const [name, route] of collection.packages) {
884
+ lines.push(entryLine(name, route));
885
+ }
800
886
  }
801
887
  lines.push("};");
802
888
  lines.push("");
@@ -806,6 +892,9 @@ function generateRouteMapContent(routes, nodeEnv) {
806
892
  lines.push("");
807
893
  return lines.join("\n");
808
894
  }
895
+ function entryLine(name, route) {
896
+ return ` ${toObjectKey(name)}: { method: '${route.method}', path: ${toStringLiteral(route.path)} },`;
897
+ }
809
898
  function createRouteMapGenerator(config) {
810
899
  const {
811
900
  routerPath,
@@ -853,17 +942,23 @@ Usage:
853
942
  exportName,
854
943
  subject: "route map"
855
944
  });
856
- const routes = collectRouteMap(router);
945
+ const collection = collectRouteMap(router);
946
+ const total = collection.app.size + collection.packages.size;
857
947
  if (debug) {
858
- genLogger.info(`Found ${routes.size} routes`, { names: [...routes.keys()] });
948
+ genLogger.info(`Found ${total} routes`, {
949
+ app: [...collection.app.keys()],
950
+ packages: [...collection.packages.keys()]
951
+ });
859
952
  }
860
953
  const outputDir = dirname(absoluteOutputPath);
861
954
  if (!existsSync(outputDir)) {
862
955
  mkdirSync(outputDir, { recursive: true });
863
956
  }
864
957
  const nodeEnv = process.env.NODE_ENV ?? "unset";
865
- writeFileSync(absoluteOutputPath, generateRouteMapContent(routes, nodeEnv), "utf-8");
866
- genLogger.info(`Generated route map: ${relative(cwd, absoluteOutputPath)} (${routes.size} routes)`);
958
+ writeFileSync(absoluteOutputPath, generateRouteMapContent(collection, nodeEnv), "utf-8");
959
+ genLogger.info(
960
+ `Generated route map: ${relative(cwd, absoluteOutputPath)} (${collection.app.size} app routes, ${collection.packages.size} from mounted packages)`
961
+ );
867
962
  }
868
963
  };
869
964
  }