@spfn/core 0.3.0-beta.12 → 0.3.0-beta.14

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
@@ -501,13 +501,24 @@ var NO_INPUTS = 18003;
501
501
  function targetPath(target, base) {
502
502
  return resolve(base, target.endsWith("/*") ? target.slice(0, -2) : target);
503
503
  }
504
+ var UNREADABLE_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM"]);
505
+ function isUnreadable(error) {
506
+ return UNREADABLE_CODES.has(error?.code ?? "");
507
+ }
504
508
  function targetResolves(target, base) {
505
509
  const path = targetPath(target, base);
506
510
  if (!target.endsWith("/*")) {
507
511
  return existsSync(path);
508
512
  }
509
- const stats = statSync(path, { throwIfNoEntry: false });
510
- return stats?.isDirectory() === true && readdirSync(path).length > 0;
513
+ try {
514
+ const stats = statSync(path, { throwIfNoEntry: false });
515
+ return stats?.isDirectory() === true && readdirSync(path).length > 0;
516
+ } catch (error) {
517
+ if (!isUnreadable(error)) {
518
+ throw error;
519
+ }
520
+ return false;
521
+ }
511
522
  }
512
523
  function chooseTarget(pattern, targets, base) {
513
524
  const present = targets.filter((target2) => targetResolves(target2, base));
@@ -677,46 +688,99 @@ function loadRouter(cwd, absoluteRouterPath) {
677
688
  );
678
689
  }
679
690
  var HTTP_METHODS = { GET: true, POST: true, PUT: true, PATCH: true, DELETE: true };
680
- 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) {
681
702
  const where = trail.join(".");
682
703
  if (!routeDef.method || !routeDef.path) {
683
704
  genLogger.warn(
684
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.`
685
706
  );
686
- return;
707
+ return void 0;
687
708
  }
688
709
  if (!Object.hasOwn(HTTP_METHODS, routeDef.method)) {
689
710
  throw new RouteMapGeneratorError(
690
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.`
691
712
  );
692
713
  }
693
- const existing = found.get(name);
714
+ const existing = into.get(name);
694
715
  if (existing) {
695
- throw new RouteMapGeneratorError(
696
- `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.`
697
- );
716
+ if (origin === "package" && existing.def === routeDef) {
717
+ return void 0;
718
+ }
719
+ throw duplicateNameError(origin, name, existing, where);
698
720
  }
699
- 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;
700
729
  }
701
- function collectPackageNames(router, trail, names) {
702
- 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)) {
703
736
  return;
704
737
  }
738
+ collection.visited.set(router, trail.join("."));
705
739
  for (const [name, entry] of Object.entries(router.routes)) {
706
740
  if (isRouter(entry)) {
707
- collectPackageNames(entry, [...trail, name], names);
708
- } else if (isRouteDef(entry) && !names.has(name)) {
709
- 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;
710
759
  }
711
760
  }
712
- for (const [index, packageRouter] of (router._packageRouters ?? []).entries()) {
713
- collectPackageNames(packageRouter, [...trail, `packages[${index}]`], names);
761
+ for (const [index, nested] of (router._packageRouters ?? []).entries()) {
762
+ collectPackageRoutes(nested, [...trail, `packages[${index}]`], mount, collection);
714
763
  }
715
764
  }
716
- 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) {
717
781
  for (const [name, entry] of Object.entries(router.routes)) {
718
782
  if (isRouter(entry)) {
719
- collectRoutes(entry, [...trail, name], found, packageNames);
783
+ collectRoutes(entry, [...trail, name], collection);
720
784
  continue;
721
785
  }
722
786
  if (!isRouteDef(entry)) {
@@ -725,29 +789,33 @@ function collectRoutes(router, trail, found, packageNames) {
725
789
  );
726
790
  continue;
727
791
  }
728
- addRoute(name, entry, [...trail, name], found);
792
+ addRoute(name, entry, [...trail, name], collection.app, "app");
729
793
  }
730
794
  for (const [index, packageRouter] of (router._packageRouters ?? []).entries()) {
731
- collectPackageNames(packageRouter, [...trail, `packages[${index}]`], packageNames);
795
+ mountPackageRouter(packageRouter, `${trail.join(".")}.packages[${index}]`, collection);
732
796
  }
733
797
  }
734
- function assertNoPackageCollision(found, packageNames) {
735
- for (const [name, where] of packageNames) {
736
- const route = found.get(name);
737
- if (!route) {
798
+ function assertNoPackageCollision(collection) {
799
+ for (const [name, route] of collection.packages) {
800
+ const appRoute = collection.app.get(name);
801
+ if (!appRoute) {
738
802
  continue;
739
803
  }
740
804
  throw new RouteMapGeneratorError(
741
- `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.`
742
806
  );
743
807
  }
744
808
  }
745
809
  function collectRouteMap(router) {
746
- const found = /* @__PURE__ */ new Map();
747
- const packageNames = /* @__PURE__ */ new Map();
748
- collectRoutes(router, ["router"], found, packageNames);
749
- assertNoPackageCollision(found, packageNames);
750
- 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;
751
819
  }
752
820
  function toObjectKey(name) {
753
821
  if (name === "__proto__") {
@@ -762,7 +830,29 @@ function toStringLiteral(value) {
762
830
  }
763
831
  return JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
764
832
  }
765
- 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) {
766
856
  const lines = [
767
857
  "/**",
768
858
  " * Route Map (Auto-generated)",
@@ -772,6 +862,7 @@ function generateRouteMapContent(routes, nodeEnv) {
772
862
  ` * Generated with NODE_ENV=${nodeEnv}. A route registered only under one environment is`,
773
863
  " * named here only when the generator ran under it, so this line changing is that",
774
864
  " * difference rather than an edit.",
865
+ ...packageHeaderLines(collection),
775
866
  " */",
776
867
  "",
777
868
  "import type { HttpMethod } from '@spfn/core/route';",
@@ -784,8 +875,14 @@ function generateRouteMapContent(routes, nodeEnv) {
784
875
  "",
785
876
  "export const routeMap: Record<string, RouteInfo> = {"
786
877
  ];
787
- for (const [name, route] of routes) {
788
- 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
+ }
789
886
  }
790
887
  lines.push("};");
791
888
  lines.push("");
@@ -795,6 +892,9 @@ function generateRouteMapContent(routes, nodeEnv) {
795
892
  lines.push("");
796
893
  return lines.join("\n");
797
894
  }
895
+ function entryLine(name, route) {
896
+ return ` ${toObjectKey(name)}: { method: '${route.method}', path: ${toStringLiteral(route.path)} },`;
897
+ }
798
898
  function createRouteMapGenerator(config) {
799
899
  const {
800
900
  routerPath,
@@ -842,17 +942,23 @@ Usage:
842
942
  exportName,
843
943
  subject: "route map"
844
944
  });
845
- const routes = collectRouteMap(router);
945
+ const collection = collectRouteMap(router);
946
+ const total = collection.app.size + collection.packages.size;
846
947
  if (debug) {
847
- 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
+ });
848
952
  }
849
953
  const outputDir = dirname(absoluteOutputPath);
850
954
  if (!existsSync(outputDir)) {
851
955
  mkdirSync(outputDir, { recursive: true });
852
956
  }
853
957
  const nodeEnv = process.env.NODE_ENV ?? "unset";
854
- writeFileSync(absoluteOutputPath, generateRouteMapContent(routes, nodeEnv), "utf-8");
855
- 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
+ );
856
962
  }
857
963
  };
858
964
  }