@fragno-dev/core 0.0.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.
Files changed (108) hide show
  1. package/.turbo/turbo-build.log +61 -0
  2. package/.turbo/turbo-types$colon$check.log +2 -0
  3. package/dist/api/api.d.ts +2 -0
  4. package/dist/api/api.js +3 -0
  5. package/dist/api-CBDGZiLC.d.ts +278 -0
  6. package/dist/api-CBDGZiLC.d.ts.map +1 -0
  7. package/dist/api-DgHfYjq2.js +54 -0
  8. package/dist/api-DgHfYjq2.js.map +1 -0
  9. package/dist/client/client.d.ts +3 -0
  10. package/dist/client/client.js +6 -0
  11. package/dist/client/client.svelte.d.ts +33 -0
  12. package/dist/client/client.svelte.d.ts.map +1 -0
  13. package/dist/client/client.svelte.js +123 -0
  14. package/dist/client/client.svelte.js.map +1 -0
  15. package/dist/client/react.d.ts +58 -0
  16. package/dist/client/react.d.ts.map +1 -0
  17. package/dist/client/react.js +80 -0
  18. package/dist/client/react.js.map +1 -0
  19. package/dist/client/vanilla.d.ts +61 -0
  20. package/dist/client/vanilla.d.ts.map +1 -0
  21. package/dist/client/vanilla.js +136 -0
  22. package/dist/client/vanilla.js.map +1 -0
  23. package/dist/client/vue.d.ts +39 -0
  24. package/dist/client/vue.d.ts.map +1 -0
  25. package/dist/client/vue.js +108 -0
  26. package/dist/client/vue.js.map +1 -0
  27. package/dist/client-DWjxKDnE.js +703 -0
  28. package/dist/client-DWjxKDnE.js.map +1 -0
  29. package/dist/client-XFdAy-IQ.d.ts +287 -0
  30. package/dist/client-XFdAy-IQ.d.ts.map +1 -0
  31. package/dist/integrations/astro.d.ts +18 -0
  32. package/dist/integrations/astro.d.ts.map +1 -0
  33. package/dist/integrations/astro.js +16 -0
  34. package/dist/integrations/astro.js.map +1 -0
  35. package/dist/integrations/next-js.d.ts +15 -0
  36. package/dist/integrations/next-js.d.ts.map +1 -0
  37. package/dist/integrations/next-js.js +17 -0
  38. package/dist/integrations/next-js.js.map +1 -0
  39. package/dist/integrations/react-ssr.d.ts +19 -0
  40. package/dist/integrations/react-ssr.d.ts.map +1 -0
  41. package/dist/integrations/react-ssr.js +38 -0
  42. package/dist/integrations/react-ssr.js.map +1 -0
  43. package/dist/integrations/svelte-kit.d.ts +21 -0
  44. package/dist/integrations/svelte-kit.d.ts.map +1 -0
  45. package/dist/integrations/svelte-kit.js +18 -0
  46. package/dist/integrations/svelte-kit.js.map +1 -0
  47. package/dist/mod.d.ts +3 -0
  48. package/dist/mod.js +177 -0
  49. package/dist/mod.js.map +1 -0
  50. package/dist/route-Bp6eByhz.js +331 -0
  51. package/dist/route-Bp6eByhz.js.map +1 -0
  52. package/dist/ssr-tJHqcNSw.js +48 -0
  53. package/dist/ssr-tJHqcNSw.js.map +1 -0
  54. package/package.json +127 -0
  55. package/src/api/api.test.ts +140 -0
  56. package/src/api/api.ts +106 -0
  57. package/src/api/error.ts +47 -0
  58. package/src/api/fragment.test.ts +509 -0
  59. package/src/api/fragment.ts +277 -0
  60. package/src/api/internal/path-runtime.test.ts +121 -0
  61. package/src/api/internal/path-type.test.ts +602 -0
  62. package/src/api/internal/path.ts +322 -0
  63. package/src/api/internal/response-stream.ts +118 -0
  64. package/src/api/internal/route.test.ts +56 -0
  65. package/src/api/internal/route.ts +9 -0
  66. package/src/api/request-input-context.test.ts +437 -0
  67. package/src/api/request-input-context.ts +201 -0
  68. package/src/api/request-middleware.test.ts +544 -0
  69. package/src/api/request-middleware.ts +126 -0
  70. package/src/api/request-output-context.test.ts +626 -0
  71. package/src/api/request-output-context.ts +175 -0
  72. package/src/api/route.test.ts +176 -0
  73. package/src/api/route.ts +152 -0
  74. package/src/client/client-builder.test.ts +264 -0
  75. package/src/client/client-error.test.ts +15 -0
  76. package/src/client/client-error.ts +141 -0
  77. package/src/client/client-types.test.ts +493 -0
  78. package/src/client/client.ssr.test.ts +173 -0
  79. package/src/client/client.svelte.test.ts +837 -0
  80. package/src/client/client.svelte.ts +278 -0
  81. package/src/client/client.test.ts +1690 -0
  82. package/src/client/client.ts +1035 -0
  83. package/src/client/component.test.svelte +21 -0
  84. package/src/client/internal/ndjson-streaming.test.ts +457 -0
  85. package/src/client/internal/ndjson-streaming.ts +248 -0
  86. package/src/client/react.test.ts +947 -0
  87. package/src/client/react.ts +241 -0
  88. package/src/client/vanilla.test.ts +867 -0
  89. package/src/client/vanilla.ts +265 -0
  90. package/src/client/vue.test.ts +754 -0
  91. package/src/client/vue.ts +242 -0
  92. package/src/http/http-status.ts +60 -0
  93. package/src/integrations/astro.ts +17 -0
  94. package/src/integrations/next-js.ts +31 -0
  95. package/src/integrations/react-ssr.ts +40 -0
  96. package/src/integrations/svelte-kit.ts +41 -0
  97. package/src/mod.ts +20 -0
  98. package/src/util/async.test.ts +85 -0
  99. package/src/util/async.ts +96 -0
  100. package/src/util/content-type.test.ts +136 -0
  101. package/src/util/content-type.ts +84 -0
  102. package/src/util/nanostores.test.ts +28 -0
  103. package/src/util/nanostores.ts +65 -0
  104. package/src/util/ssr.ts +75 -0
  105. package/src/util/types-util.ts +16 -0
  106. package/tsconfig.json +10 -0
  107. package/tsdown.config.ts +21 -0
  108. package/vitest.config.ts +10 -0
@@ -0,0 +1,277 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import { type FragnoRouteConfig, type HTTPMethod } from "./api";
3
+ import { FragnoApiError } from "./error";
4
+ import { getMountRoute } from "./internal/route";
5
+ import { addRoute, createRouter, findRoute } from "rou3";
6
+ import { RequestInputContext } from "./request-input-context";
7
+ import type { ExtractPathParams } from "./internal/path";
8
+ import { RequestOutputContext } from "./request-output-context";
9
+ import {
10
+ type EmptyObject,
11
+ type AnyFragnoRouteConfig,
12
+ type AnyRouteOrFactory,
13
+ type FlattenRouteFactories,
14
+ resolveRouteFactories,
15
+ } from "./route";
16
+ import {
17
+ RequestMiddlewareInputContext,
18
+ RequestMiddlewareOutputContext,
19
+ type FragnoMiddlewareCallback,
20
+ } from "./request-middleware";
21
+
22
+ export interface FragnoPublicConfig {
23
+ mountRoute?: string;
24
+ }
25
+
26
+ export interface FragnoPublicClientConfig {
27
+ mountRoute?: string;
28
+ baseUrl?: string;
29
+ }
30
+
31
+ export interface FragnoInstantiatedFragment<
32
+ TRoutes extends readonly AnyFragnoRouteConfig[] = [],
33
+ TDeps = EmptyObject,
34
+ TServices extends Record<string, unknown> = Record<string, unknown>,
35
+ > {
36
+ config: FragnoFragmentSharedConfig<TRoutes>;
37
+ deps: TDeps;
38
+ services: TServices;
39
+ handler: (req: Request) => Promise<Response>;
40
+ mountRoute: string;
41
+ withMiddleware: (
42
+ handler: FragnoMiddlewareCallback<TRoutes, TDeps, TServices>,
43
+ ) => FragnoInstantiatedFragment<TRoutes, TDeps, TServices>;
44
+ }
45
+
46
+ export interface FragnoFragmentSharedConfig<
47
+ TRoutes extends readonly FragnoRouteConfig<
48
+ HTTPMethod,
49
+ string,
50
+ StandardSchemaV1 | undefined,
51
+ StandardSchemaV1 | undefined,
52
+ string,
53
+ string
54
+ >[],
55
+ > {
56
+ name: string;
57
+ routes: TRoutes;
58
+ }
59
+
60
+ export type AnyFragnoFragmentSharedConfig = FragnoFragmentSharedConfig<
61
+ readonly AnyFragnoRouteConfig[]
62
+ >;
63
+
64
+ interface FragmentDefinition<
65
+ TConfig,
66
+ TDeps = EmptyObject,
67
+ TServices extends Record<string, unknown> = EmptyObject,
68
+ > {
69
+ name: string;
70
+ dependencies?: (config: TConfig) => TDeps;
71
+ services?: (config: TConfig, deps: TDeps) => TServices;
72
+ }
73
+
74
+ export class FragmentBuilder<
75
+ TConfig,
76
+ TDeps = EmptyObject,
77
+ TServices extends Record<string, unknown> = EmptyObject,
78
+ > {
79
+ #definition: FragmentDefinition<TConfig, TDeps, TServices>;
80
+
81
+ constructor(definition: FragmentDefinition<TConfig, TDeps, TServices>) {
82
+ this.#definition = definition;
83
+ }
84
+
85
+ get definition() {
86
+ return this.#definition;
87
+ }
88
+
89
+ withDependencies<TNewDeps>(
90
+ fn: (config: TConfig) => TNewDeps,
91
+ ): FragmentBuilder<TConfig, TNewDeps, TServices> {
92
+ return new FragmentBuilder<TConfig, TNewDeps, TServices>({
93
+ ...this.#definition,
94
+ dependencies: fn,
95
+ } as FragmentDefinition<TConfig, TNewDeps, TServices>);
96
+ }
97
+
98
+ withServices<TNewServices extends Record<string, unknown>>(
99
+ fn: (config: TConfig, deps: TDeps) => TNewServices,
100
+ ): FragmentBuilder<TConfig, TDeps, TNewServices> {
101
+ return new FragmentBuilder<TConfig, TDeps, TNewServices>({
102
+ ...this.#definition,
103
+ services: fn,
104
+ } as FragmentDefinition<TConfig, TDeps, TNewServices>);
105
+ }
106
+ }
107
+
108
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
109
+ export function defineFragment<TConfig = {}>(name: string): FragmentBuilder<TConfig> {
110
+ return new FragmentBuilder({
111
+ name,
112
+ });
113
+ }
114
+
115
+ export function createFragment<
116
+ TConfig,
117
+ TDeps,
118
+ TServices extends Record<string, unknown>,
119
+ const TRoutesOrFactories extends readonly AnyRouteOrFactory[],
120
+ >(
121
+ fragmentDefinition: FragmentBuilder<TConfig, TDeps, TServices>,
122
+ config: TConfig,
123
+ routesOrFactories: TRoutesOrFactories,
124
+ fragnoConfig: FragnoPublicConfig = {},
125
+ ): FragnoInstantiatedFragment<FlattenRouteFactories<TRoutesOrFactories>, TDeps, TServices> {
126
+ const definition = fragmentDefinition.definition;
127
+
128
+ const dependencies = definition.dependencies ? definition.dependencies(config) : ({} as TDeps);
129
+ const services = definition.services
130
+ ? definition.services(config, dependencies)
131
+ : ({} as TServices);
132
+
133
+ const context = { config, deps: dependencies, services };
134
+ const routes = resolveRouteFactories(context, routesOrFactories);
135
+
136
+ const mountRoute = getMountRoute({
137
+ name: definition.name,
138
+ mountRoute: fragnoConfig.mountRoute,
139
+ });
140
+
141
+ const router =
142
+ createRouter<
143
+ FragnoRouteConfig<
144
+ HTTPMethod,
145
+ string,
146
+ StandardSchemaV1 | undefined,
147
+ StandardSchemaV1 | undefined,
148
+ string,
149
+ string
150
+ >
151
+ >();
152
+
153
+ let middlewareHandler:
154
+ | FragnoMiddlewareCallback<FlattenRouteFactories<TRoutesOrFactories>, TDeps, TServices>
155
+ | undefined;
156
+
157
+ for (const routeConfig of routes) {
158
+ addRoute(router, routeConfig.method.toUpperCase(), routeConfig.path, routeConfig);
159
+ }
160
+
161
+ const fragment: FragnoInstantiatedFragment<
162
+ FlattenRouteFactories<TRoutesOrFactories>,
163
+ TDeps,
164
+ TServices
165
+ > = {
166
+ mountRoute,
167
+ config: {
168
+ name: definition.name,
169
+ routes,
170
+ },
171
+ services,
172
+ deps: dependencies,
173
+ withMiddleware: (handler) => {
174
+ if (middlewareHandler) {
175
+ throw new Error("Middleware already set");
176
+ }
177
+
178
+ middlewareHandler = handler;
179
+
180
+ return fragment;
181
+ },
182
+ handler: async (req: Request) => {
183
+ const url = new URL(req.url);
184
+ const pathname = url.pathname;
185
+
186
+ const matchRoute = pathname.startsWith(mountRoute) ? pathname.slice(mountRoute.length) : null;
187
+
188
+ if (matchRoute === null) {
189
+ return Response.json(
190
+ {
191
+ error:
192
+ `Fragno: Route for '${definition.name}' not found. Is the fragment mounted on the right route? ` +
193
+ `Expecting: '${mountRoute}'.`,
194
+ code: "ROUTE_NOT_FOUND",
195
+ },
196
+ { status: 404 },
197
+ );
198
+ }
199
+
200
+ const route = findRoute(router, req.method, matchRoute);
201
+
202
+ if (!route) {
203
+ return Response.json(
204
+ { error: `Fragno: Route for '${definition.name}' not found`, code: "ROUTE_NOT_FOUND" },
205
+ { status: 404 },
206
+ );
207
+ }
208
+
209
+ const { handler, inputSchema, outputSchema, path } = route.data;
210
+
211
+ const outputContext = new RequestOutputContext(outputSchema);
212
+
213
+ if (middlewareHandler) {
214
+ const middlewareInputContext = new RequestMiddlewareInputContext(routes, {
215
+ method: req.method as HTTPMethod,
216
+ path,
217
+ pathParams: route.params,
218
+ searchParams: new URL(req.url).searchParams,
219
+ body: req.body,
220
+ request: req,
221
+ });
222
+
223
+ const middlewareOutputContext = new RequestMiddlewareOutputContext(dependencies, services);
224
+
225
+ try {
226
+ const middlewareResult = await middlewareHandler(
227
+ middlewareInputContext,
228
+ middlewareOutputContext,
229
+ );
230
+ if (middlewareResult !== undefined) {
231
+ return middlewareResult;
232
+ }
233
+ } catch (error) {
234
+ console.error("Error in middleware", error);
235
+
236
+ if (error instanceof FragnoApiError) {
237
+ // TODO: If a validation error occurs in middleware (when calling `await input.valid()`)
238
+ // the processing is short-circuited and a potential `catch` block around the call
239
+ // to `input.valid()` in the actual handler will not be executed.
240
+ return error.toResponse();
241
+ }
242
+
243
+ return Response.json(
244
+ { error: "Internal server error", code: "INTERNAL_SERVER_ERROR" },
245
+ { status: 500 },
246
+ );
247
+ }
248
+ }
249
+
250
+ const inputContext = await RequestInputContext.fromRequest({
251
+ request: req,
252
+ method: req.method,
253
+ path,
254
+ pathParams: (route.params ?? {}) as ExtractPathParams<typeof path>,
255
+ inputSchema,
256
+ });
257
+
258
+ try {
259
+ const result = await handler(inputContext, outputContext);
260
+ return result;
261
+ } catch (error) {
262
+ console.error("Error in handler", error);
263
+
264
+ if (error instanceof FragnoApiError) {
265
+ return error.toResponse();
266
+ }
267
+
268
+ return Response.json(
269
+ { error: "Internal server error", code: "INTERNAL_SERVER_ERROR" },
270
+ { status: 500 },
271
+ );
272
+ }
273
+ },
274
+ };
275
+
276
+ return fragment;
277
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { buildPath, extractPathParams, matchPathParams } from "./path";
3
+
4
+ describe("extractPathParams (runtime names)", () => {
5
+ test("no params", () => {
6
+ expect(extractPathParams("/static/assets")).toEqual([]);
7
+ expect(extractPathParams("/")).toEqual([]);
8
+ expect(extractPathParams("")).toEqual([]);
9
+ });
10
+
11
+ test("named param", () => {
12
+ expect(extractPathParams("/users/:id")).toEqual(["id"]);
13
+ expect(extractPathParams(":id")).toEqual(["id"]);
14
+ expect(extractPathParams("/:id")).toEqual(["id"]);
15
+ });
16
+
17
+ test("empty name param segment is allowed", () => {
18
+ expect(extractPathParams("/:")).toEqual([""]);
19
+ expect(extractPathParams("/:/x")).toEqual([""]);
20
+ });
21
+
22
+ test("duplicate identifiers are preserved in order", () => {
23
+ expect(extractPathParams("/path/:/x/:/")).toEqual(["", ""]);
24
+ expect(extractPathParams("/path/:var/x/:var")).toEqual(["var", "var"]);
25
+ });
26
+
27
+ test("multiple named params", () => {
28
+ expect(extractPathParams("/users/:id/posts/:postId")).toEqual(["id", "postId"]);
29
+ });
30
+
31
+ test("wildcards", () => {
32
+ expect(extractPathParams("/path/foo/**")).toEqual(["**"]);
33
+ expect(extractPathParams("/path/foo/**:name")).toEqual(["name"]);
34
+ });
35
+
36
+ test("mixed complex", () => {
37
+ expect(extractPathParams("/api/:version/users/:userId/posts/:postId/**:remaining")).toEqual([
38
+ "version",
39
+ "userId",
40
+ "postId",
41
+ "remaining",
42
+ ]);
43
+ });
44
+
45
+ test("consecutive slashes are ignored in names extraction", () => {
46
+ expect(extractPathParams("//path//:name")).toEqual(["name"]);
47
+ });
48
+ });
49
+
50
+ describe("matchPathParams (runtime extraction)", () => {
51
+ test("literal match with named param", () => {
52
+ expect(matchPathParams("/users/:id", "/users/123")).toEqual({ id: "123" });
53
+ });
54
+
55
+ test("mismatch returns empty object", () => {
56
+ expect(matchPathParams("/users/:id", "/posts/123")).toEqual({});
57
+ });
58
+
59
+ test("named wildcard captures remainder", () => {
60
+ expect(matchPathParams("/files/**:rest", "/files/a/b/c")).toEqual({ rest: "a/b/c" });
61
+ expect(matchPathParams("/files/**:rest", "/files")).toEqual({ rest: "" });
62
+ });
63
+
64
+ test("anonymous wildcard captures remainder under ** key", () => {
65
+ expect(matchPathParams("/files/**", "/files/a/b")).toEqual({ "**": "a/b" });
66
+ expect(matchPathParams("/files/**", "/files")).toEqual({ "**": "" });
67
+ });
68
+
69
+ test("mixed named segment and wildcard remainder", () => {
70
+ expect(matchPathParams("/api/:version/**:rest", "/api/v1/users/1")).toEqual({
71
+ version: "v1",
72
+ rest: "users/1",
73
+ });
74
+ });
75
+
76
+ test("trailing slashes are ignored", () => {
77
+ expect(matchPathParams("/users/:id", "/users/123/")).toEqual({ id: "123" });
78
+ });
79
+
80
+ test("pattern longer than path fills empty strings for remaining params", () => {
81
+ // Remaining ":id" becomes empty string
82
+ expect(matchPathParams("/users/:id", "/users")).toEqual({ id: "" });
83
+ // Remaining "**" becomes empty string
84
+ expect(matchPathParams("/files/**", "/files")).toEqual({ "**": "" });
85
+ });
86
+ });
87
+
88
+ describe("buildPath (runtime building)", () => {
89
+ test("builds literal match with named param", () => {
90
+ expect(buildPath("/users/:id", { id: "123" })).toEqual("/users/123");
91
+ });
92
+
93
+ test("encodes named params", () => {
94
+ expect(buildPath("/q/:term", { term: "a b" })).toEqual("/q/a%20b");
95
+ expect(buildPath("/:name", { name: "ä" })).toEqual("/%C3%A4");
96
+ });
97
+
98
+ test("builds with named wildcard remainder", () => {
99
+ expect(buildPath("/files/**:rest", { rest: "a/b/c" })).toEqual("/files/a/b/c");
100
+ });
101
+
102
+ test("builds with anonymous wildcard remainder", () => {
103
+ expect(buildPath("/files/**", { "**": "a/b" })).toEqual("/files/a/b");
104
+ });
105
+
106
+ test("preserves extra slashes from pattern", () => {
107
+ expect(buildPath("//path//:name//", { name: "x" })).toEqual("//path//x//");
108
+ });
109
+
110
+ test("throws on missing named param", () => {
111
+ // @ts-expect-error - intentionally passing missing param to test runtime error
112
+ expect(() => buildPath("/users/:id", {})).toThrowError(/Missing value/);
113
+ });
114
+
115
+ test("throws on missing wildcard values", () => {
116
+ // @ts-expect-error - intentionally passing missing param to test runtime error
117
+ expect(() => buildPath("/files/**", {})).toThrowError(/Missing value/);
118
+ // @ts-expect-error - intentionally passing missing param to test runtime error
119
+ expect(() => buildPath("/files/**:rest", {})).toThrowError(/Missing value/);
120
+ });
121
+ });