@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 +7 -5
- package/dist/codegen/index.d.ts +9 -0
- package/dist/codegen/index.js +130 -35
- package/dist/codegen/index.js.map +1 -1
- package/dist/contract/index.d.ts +1 -1
- package/dist/event/index.d.ts +1 -2
- package/dist/event/index.js.map +1 -1
- package/dist/job/index.js +13 -12
- package/dist/job/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +10 -5
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +1 -1
- package/dist/ops/index.js.map +1 -1
- package/dist/route/index.d.ts +2 -2
- package/dist/route/index.js.map +1 -1
- package/dist/{router-9L3El7nO.d.ts → router-BqyEARoY.d.ts} +25 -23
- package/dist/server/index.js +12 -5
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
- package/src/codegen/README.md +29 -15
- package/src/nextjs/README.md +13 -10
- package/src/route/README.md +14 -9
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.
|
|
126
|
-
|
|
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.
|
|
795
|
-
`
|
|
796
|
-
`
|
|
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`,
|
package/dist/codegen/index.d.ts
CHANGED
|
@@ -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
|
package/dist/codegen/index.js
CHANGED
|
@@ -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
|
|
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 =
|
|
714
|
+
const existing = into.get(name);
|
|
705
715
|
if (existing) {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
716
|
+
if (origin === "package" && existing.def === routeDef) {
|
|
717
|
+
return void 0;
|
|
718
|
+
}
|
|
719
|
+
throw duplicateNameError(origin, name, existing, where);
|
|
709
720
|
}
|
|
710
|
-
|
|
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
|
|
713
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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,
|
|
724
|
-
|
|
761
|
+
for (const [index, nested] of (router._packageRouters ?? []).entries()) {
|
|
762
|
+
collectPackageRoutes(nested, [...trail, `packages[${index}]`], mount, collection);
|
|
725
763
|
}
|
|
726
764
|
}
|
|
727
|
-
function
|
|
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],
|
|
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],
|
|
792
|
+
addRoute(name, entry, [...trail, name], collection.app, "app");
|
|
740
793
|
}
|
|
741
794
|
for (const [index, packageRouter] of (router._packageRouters ?? []).entries()) {
|
|
742
|
-
|
|
795
|
+
mountPackageRouter(packageRouter, `${trail.join(".")}.packages[${index}]`, collection);
|
|
743
796
|
}
|
|
744
797
|
}
|
|
745
|
-
function assertNoPackageCollision(
|
|
746
|
-
for (const [name,
|
|
747
|
-
const
|
|
748
|
-
if (!
|
|
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}" (${
|
|
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
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
|
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
|
|
799
|
-
lines.push(
|
|
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
|
|
945
|
+
const collection = collectRouteMap(router);
|
|
946
|
+
const total = collection.app.size + collection.packages.size;
|
|
857
947
|
if (debug) {
|
|
858
|
-
genLogger.info(`Found ${
|
|
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(
|
|
866
|
-
genLogger.info(
|
|
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
|
}
|