serverstruct 1.1.0 → 1.2.0

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
@@ -2,7 +2,12 @@
2
2
 
3
3
  ⚡️ Typesafe and modular servers with [H3](https://github.com/unjs/h3).
4
4
 
5
- Serverstruct provides simple helpers for building modular h3 applications with dependency injection using [getbox](https://github.com/eriicafes/getbox).
5
+ Serverstruct provides simple helpers for building modular H3 applications with dependency injection using [getbox](https://github.com/eriicafes/getbox).
6
+
7
+ ## Integrations
8
+
9
+ - [OpenAPI](./OPENAPI.md) - Typesafe OpenAPI operations with Zod schema validation.
10
+ - [OpenTelemetry](./OTEL.md) - Distributed tracing middleware for HTTP requests.
6
11
 
7
12
  ## Installation
8
13
 
@@ -13,170 +18,151 @@ npm i serverstruct h3 getbox
13
18
  ## Quick Start
14
19
 
15
20
  ```typescript
16
- import { application } from "serverstruct";
21
+ import { application, serve } from "serverstruct";
17
22
 
18
23
  const app = application((app) => {
19
24
  app.get("/", () => "Hello world!");
20
25
  });
21
26
 
22
- app.serve({ port: 3000 });
27
+ serve(app, { port: 3000 });
23
28
  ```
24
29
 
25
- ## Apps
30
+ ## Application
26
31
 
27
- Create modular h3 apps and mount them together:
32
+ `application()` creates an H3 instance and a Box instance for dependency injection.
33
+ You can pass a Box instance to share dependencies across applications (see [Box Instance](#box-instance)).
28
34
 
29
- ```typescript
30
- import { application } from "serverstruct";
35
+ You can mount other apps using `app.mount()`:
31
36
 
32
- // Create a users module
33
- const { app: usersApp } = application((app) => {
34
- const users: User[] = [];
37
+ ```typescript
38
+ import { H3 } from "h3";
39
+ import { application, serve } from "serverstruct";
35
40
 
36
- app.get("/", () => users);
37
- app.post("/", async (event) => {
38
- const body = await readValidatedBody(event, validateUser);
39
- users.push(body);
40
- return body;
41
- });
41
+ // Create a sub application
42
+ const usersApp = application((app) => {
43
+ app.get("/", () => ["Alice", "Bob"]);
42
44
  });
43
45
 
44
- // Compose in main app
46
+ // Create a regular H3 instance
47
+ const docsApp = new H3().get("/", () => "API Documentation");
48
+
49
+ // Mount in main app
45
50
  const app = application((app) => {
46
- app.get("/ping", () => "pong");
51
+ app.get("/", () => "Hello world!");
47
52
  app.mount("/users", usersApp);
53
+ app.mount("/docs", docsApp);
48
54
  });
49
55
 
50
- app.serve({ port: 3000 });
56
+ serve(app, { port: 3000 });
51
57
  ```
52
58
 
53
- You can also create and return a new H3 instance to customize H3 options:
59
+ When an app is mounted, its middlewares and routes are copied to the main app in place with middlewares scoped to the base path.
60
+
61
+ Both `application()` and `controller()` can return a custom H3 instance:
54
62
 
55
63
  ```typescript
56
64
  import { H3 } from "h3";
57
65
 
58
- const app = application(() => {
59
- const customApp = new H3({
60
- onError: (error) => {
61
- console.error(error);
66
+ const customApp = application(() => {
67
+ const app = new H3({
68
+ onError: (error, event) => {
69
+ console.error("Error:", error);
62
70
  },
63
71
  });
64
- customApp.get("/", () => "Hello from custom app!");
65
- return customApp;
66
- });
67
- ```
68
-
69
- ### Accessing the Box Instance
70
-
71
- The `application()` function creates a new Box instance by default and returns it along with `app` and `serve`. You can access the box instance to retrieve dependencies:
72
-
73
- ```typescript
74
- import { constant } from "getbox";
75
-
76
- const Port = constant(5000);
77
-
78
- const { box, serve } = application((app, box) => {
79
- app.get("/", () => "Hello world!");
72
+ app.get("/", () => "Hello from custom app!");
73
+ return app;
80
74
  });
81
-
82
- const port = box.get(Port);
83
- serve({ port });
84
75
  ```
85
76
 
86
77
  ## Controllers
87
78
 
88
- Use `controller()` to create h3 app constructors:
79
+ Controllers are apps that are initialized with a parent Box instance, sharing the same dependency container. Use `controller()` to create H3 app constructors:
89
80
 
90
81
  ```typescript
91
82
  import { application, controller } from "serverstruct";
92
83
 
93
- // Define a controller
94
- const usersController = controller((app) => {
95
- const users: User[] = [];
84
+ class UserStore {
85
+ public users: User[] = [];
86
+
87
+ add(user: User) {
88
+ this.users.push(user);
89
+ return user;
90
+ }
91
+ }
92
+
93
+ // Create a controller
94
+ const usersController = controller((app, box) => {
95
+ const store = box.get(UserStore);
96
96
 
97
- app.get("/", () => users);
97
+ app.get("/", () => store.users);
98
98
  app.post("/", async (event) => {
99
- const body = await readValidatedBody(event, validateUser);
100
- users.push(body);
101
- return body;
99
+ const body = await readBody(event);
100
+ return store.add(body);
102
101
  });
103
102
  });
104
103
 
105
104
  // Use it in your main app
106
105
  const app = application((app, box) => {
107
- app.get("/ping", () => "pong");
106
+ const store = box.get(UserStore);
107
+
108
+ app.get("/count", () => ({
109
+ users: store.users.length,
110
+ }));
108
111
  app.mount("/users", box.new(usersController));
109
112
  });
110
113
 
111
- app.serve({ port: 3000 });
114
+ serve(app, { port: 3000 });
112
115
  ```
113
116
 
114
117
  ## Handlers
115
118
 
116
- Use `handler()` to create h3 handler constructors:
119
+ Use `handler()` to create H3 handler constructors:
117
120
 
118
121
  ```typescript
119
122
  import { application, handler } from "serverstruct";
120
123
 
121
- class UserService {
122
- getUser(id: string) {
123
- return { id, name: "Alice" };
124
- }
125
- }
126
-
127
124
  // Define a handler
128
125
  const getUserHandler = handler((event, box) => {
129
- const userService = box.get(UserService);
126
+ const store = box.get(UserStore);
127
+
130
128
  const id = event.context.params?.id;
131
- return userService.getUser(id);
129
+ return store.users.find((user) => user.id === id);
132
130
  });
133
131
 
134
132
  // Use it in your app
135
133
  const app = application((app, box) => {
136
- app.get("/users/:id", box.get(getUserHandler));
134
+ app.get("/users/:id", box.new(getUserHandler));
137
135
  });
138
136
  ```
139
137
 
140
138
  ### Event Handlers
141
139
 
142
- Use `eventHandler()` to create h3 handlers with additional options like metadata and middleware:
140
+ Use `eventHandler()` to create H3 handler constructors with additional options like meta and middleware:
143
141
 
144
142
  ```typescript
145
143
  import { application, eventHandler } from "serverstruct";
146
144
 
147
- class UserService {
148
- getUser(id: string) {
149
- return { id, name: "Alice" };
150
- }
151
- }
152
-
153
- // Define an event handler with middleware and metadata
145
+ // Define an event handler with additional options
154
146
  const getUserHandler = eventHandler((box) => ({
155
147
  handler(event) {
156
- const userService = box.get(UserService);
148
+ const store = box.get(UserStore);
149
+
157
150
  const id = event.context.params?.id;
158
- return userService.getUser(id);
151
+ return store.users.find((user) => user.id === id);
159
152
  },
160
153
  meta: { auth: true },
161
- middleware: [
162
- (event) => {
163
- const token = event.headers.get("authorization");
164
- if (!token || token !== "secret-token") {
165
- throw new Error("Unauthorized");
166
- }
167
- },
168
- ],
154
+ middleware: [],
169
155
  }));
170
156
 
171
157
  // Use it in your app
172
158
  const app = application((app, box) => {
173
- app.get("/users/:id", box.get(getUserHandler));
159
+ app.get("/users/:id", box.new(getUserHandler));
174
160
  });
175
161
  ```
176
162
 
177
163
  ## Middleware
178
164
 
179
- Use `middleware()` to create h3 middleware constructors:
165
+ Use `middleware()` to create H3 middleware constructors:
180
166
 
181
167
  ```typescript
182
168
  import { application, middleware } from "serverstruct";
@@ -200,75 +186,143 @@ const app = application((app, box) => {
200
186
  });
201
187
  ```
202
188
 
203
- ## Context
189
+ All middlewares defined with `app.use()` are global and execute before the matched handler in the exact order they are defined.
204
190
 
205
- The `context()` function creates a request-scoped, type-safe store for per-request values. Each request gets its own isolated context that is automatically cleaned up when the request completes.
191
+ ## Error Handling
206
192
 
207
- ```typescript
208
- import { application, context } from "serverstruct";
193
+ Error handlers are middleware that catch errors after calling `await next()`.
209
194
 
210
- interface User {
211
- id: string;
212
- name: string;
213
- }
195
+ The last error handler defined executes before earlier ones. The error bubbles through each error handler until a response is returned or the default error response is sent.
214
196
 
215
- // Create a context store
216
- const userContext = context<User>();
197
+ Use H3's `onError` helper to define error handlers:
198
+
199
+ ```typescript
200
+ import { onError } from "h3";
201
+ import { application } from "serverstruct";
217
202
 
218
203
  const app = application((app) => {
219
- // Set context in middleware
220
- app.use((event) => {
221
- const user = { id: "123", name: "Alice" };
222
- userContext.set(event, user);
204
+ app.use(
205
+ onError((error) => {
206
+ console.log("Error:", error);
207
+ }),
208
+ );
209
+ app.get("/", () => {
210
+ throw new Error("Oops");
223
211
  });
212
+ });
213
+ ```
224
214
 
225
- // Access context in handlers
226
- app.get("/profile", (event) => {
227
- const user = userContext.get(event);
228
- return { profile: user };
215
+ When the error handler needs access to the Box, wrap it with `middleware()`:
216
+
217
+ ```typescript
218
+ import { onError } from "h3";
219
+ import { application, middleware } from "serverstruct";
220
+
221
+ const errorHandler = middleware((event, next, box) => {
222
+ return onError((error) => {
223
+ console.log("Error:", error);
224
+ });
225
+ });
226
+
227
+ const app = application((app, box) => {
228
+ app.use(box.get(errorHandler));
229
+ app.get("/", () => {
230
+ throw new Error("Oops");
229
231
  });
230
232
  });
231
233
  ```
232
234
 
233
- ### Context Methods
235
+ ### Not Found Routes
234
236
 
235
- - `set(event, value)` - Store a value for the current request
236
- - `get(event)` - Retrieve the value for the current request (throws if not found)
237
- - `lookup(event)` - Retrieve the value or `undefined` if not found
237
+ To catch not found routes, define a catch-all handler and return the desired error:
238
238
 
239
239
  ```typescript
240
- const requestIdContext = context<string>();
240
+ import { H3Error } from "h3";
241
241
 
242
242
  const app = application((app) => {
243
- app.use((event) => {
244
- requestIdContext.set(event, crypto.randomUUID());
245
- });
243
+ app.get("/", () => "Hello world!");
244
+ app.all("**", () => new H3Error({ status: 404, message: "Not found" }));
245
+ });
246
+ ```
246
247
 
247
- app.get("/", (event) => {
248
- // Safe access - throws if not set
249
- const id = requestIdContext.get(event);
248
+ Mounted apps can define their own not found handlers:
250
249
 
251
- // Optional access - returns undefined if not set
252
- const maybeId = requestIdContext.lookup(event);
250
+ ```typescript
251
+ const usersApp = application((app) => {
252
+ app.get("/", () => ["Alice", "Bob"]);
253
+ app.all(
254
+ "**",
255
+ () => new H3Error({ status: 404, message: "User route not found" }),
256
+ );
257
+ });
253
258
 
254
- return { requestId: id };
255
- });
259
+ const app = application((app) => {
260
+ app.mount("/users", usersApp);
261
+ app.all("**", () => new H3Error({ status: 404, message: "Not found" }));
256
262
  });
257
263
  ```
258
264
 
259
- ## Custom Box Instance
265
+ ## Box Instance
260
266
 
261
- You can also pass your own Box instance to share dependencies across multiple applications or mock dependencies:
267
+ By default, `application()` creates a new Box instance. Pass a Box instance to reuse it:
262
268
 
263
269
  ```typescript
264
270
  import { Box } from "getbox";
271
+ import { application, serve } from "serverstruct";
265
272
 
266
273
  const box = new Box();
267
274
 
268
- // Mock a dependency
269
- Box.mock(box, Database, new Database());
275
+ const usersApp = application((app, box) => {
276
+ const store = box.get(UserStore);
277
+ app.get("/", () => store.users);
278
+ }, box);
270
279
 
271
280
  const app = application((app, box) => {
272
- app.mount("/users", box.new(usersController));
281
+ const store = box.get(UserStore);
282
+ app.get("/count", () => store.users.length);
283
+ app.mount("/users", usersApp);
273
284
  }, box);
285
+
286
+ serve(app, { port: 3000 });
287
+ ```
288
+
289
+ ## Context
290
+
291
+ `context()` creates a request-scoped, type-safe store for per-request values.
292
+
293
+ ```typescript
294
+ import { application, context } from "serverstruct";
295
+
296
+ interface User {
297
+ id: string;
298
+ name: string;
299
+ }
300
+
301
+ // Create a context store
302
+ const userContext = context<User>();
303
+
304
+ const app = application((app) => {
305
+ // Set context in middleware
306
+ app.use((event) => {
307
+ const user = { id: "123", name: "Alice" };
308
+ userContext.set(event, user);
309
+ });
310
+
311
+ // Access context in handlers
312
+ app.get("/profile", (event) => {
313
+ // Optional access - returns undefined if not set
314
+ const maybeUser = userContext.lookup(event);
315
+
316
+ // Safe access - throws if not set
317
+ const user = userContext.get(event);
318
+
319
+ return { profile: user };
320
+ });
321
+ });
274
322
  ```
323
+
324
+ ### Context Methods
325
+
326
+ - `set(event, value)` - Store a value for the current request
327
+ - `get(event)` - Retrieve the value for the current request (throws if not found)
328
+ - `lookup(event)` - Retrieve the value or `undefined` if not found
@@ -0,0 +1,242 @@
1
+ let h3 = require("h3");
2
+ let zod_openapi_api = require("zod-openapi/api");
3
+ let zod_openapi = require("zod-openapi");
4
+
5
+ //#region src/openapi.ts
6
+ function createContext(operation) {
7
+ const paramsSchema = operation.requestParams?.path;
8
+ const querySchema = operation.requestParams?.query;
9
+ const headersSchema = operation.requestParams?.header;
10
+ const bodyRawSchema = operation.requestBody?.content?.["application/json"]?.schema;
11
+ const bodySchema = (0, zod_openapi_api.isAnyZodType)(bodyRawSchema) ? bodyRawSchema : void 0;
12
+ return {
13
+ schemas: {
14
+ params: paramsSchema,
15
+ query: querySchema,
16
+ headers: headersSchema,
17
+ body: bodySchema
18
+ },
19
+ params: (event) => paramsSchema ? (0, h3.getValidatedRouterParams)(event, paramsSchema) : Promise.resolve((0, h3.getRouterParams)(event)),
20
+ query: (event) => querySchema ? (0, h3.getValidatedQuery)(event, querySchema) : Promise.resolve((0, h3.getQuery)(event)),
21
+ body: (event) => bodySchema ? (0, h3.readValidatedBody)(event, bodySchema) : (0, h3.readBody)(event),
22
+ reply: (event, status, data, headers) => {
23
+ event.res.status = status;
24
+ if (headers) for (const [key, value] of Object.entries(headers)) event.res.headers.set(key, String(value));
25
+ return data;
26
+ }
27
+ };
28
+ }
29
+ const HTTP_METHODS = [
30
+ "get",
31
+ "post",
32
+ "put",
33
+ "delete",
34
+ "patch"
35
+ ];
36
+ /**
37
+ * Collects OpenAPI operation definitions for document generation.
38
+ *
39
+ * Register operations by HTTP method and path. The accumulated `paths`
40
+ * object can be passed to `createDocument()` to generate the OpenAPI spec.
41
+ *
42
+ * Each registration returns a typed {@link RouterContext} for use in route handlers.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * // Singleton via getbox DI
47
+ * const paths = box.get(OpenApiPaths);
48
+ *
49
+ * const getPost = paths.get("/posts/{id}", { ... });
50
+ *
51
+ * // Generate OpenAPI document
52
+ * createDocument({ openapi: "3.1.0", info: { ... }, paths: paths.paths });
53
+ * ```
54
+ */
55
+ var OpenApiPaths = class {
56
+ paths = {};
57
+ get(path, operation) {
58
+ return this.register(path, "get", operation);
59
+ }
60
+ post(path, operation) {
61
+ return this.register(path, "post", operation);
62
+ }
63
+ put(path, operation) {
64
+ return this.register(path, "put", operation);
65
+ }
66
+ delete(path, operation) {
67
+ return this.register(path, "delete", operation);
68
+ }
69
+ patch(path, operation) {
70
+ return this.register(path, "patch", operation);
71
+ }
72
+ /** Register an operation for all standard HTTP methods (get, post, put, delete, patch). */
73
+ all(path, operation) {
74
+ return this.on(HTTP_METHODS, path, operation);
75
+ }
76
+ /** Register an operation for specific HTTP methods. */
77
+ on(methods, path, operation) {
78
+ const item = {};
79
+ for (const method of methods) item[method] = operation;
80
+ this.paths[path] = {
81
+ ...this.paths[path],
82
+ ...item
83
+ };
84
+ return createContext(operation);
85
+ }
86
+ register(path, method, operation) {
87
+ this.paths[path] = {
88
+ ...this.paths[path],
89
+ [method]: operation
90
+ };
91
+ return createContext(operation);
92
+ }
93
+ };
94
+ /**
95
+ * Combines OpenAPI path registration with H3 route registration.
96
+ *
97
+ * Each method registers the operation in {@link OpenApiPaths} (converting the
98
+ * H3 path syntax to OpenAPI format) and simultaneously registers the route
99
+ * handler on the H3 app. The handler receives the typed {@link RouterContext}.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * const router = createRouter(app, box.get(OpenApiPaths));
104
+ *
105
+ * router.get("/posts/:id", {
106
+ * operationId: "getPost",
107
+ * requestBody: jsonRequest(inputSchema),
108
+ * responses: {
109
+ * 200: jsonResponse(outputSchema, { description: "Success" }),
110
+ * },
111
+ * }, async (event, ctx) => {
112
+ * const body = await ctx.body(event);
113
+ * return ctx.reply(event, 200, { message: "ok" });
114
+ * });
115
+ * ```
116
+ */
117
+ var OpenApiRouter = class {
118
+ constructor(app, paths) {
119
+ this.app = app;
120
+ this.paths = paths;
121
+ }
122
+ get(path, operation, handler, opts) {
123
+ const ctx = this.paths.get(toOpenApiPath(path), operation);
124
+ this.app.get(path, (event) => handler(event, ctx), opts);
125
+ return this;
126
+ }
127
+ post(path, operation, handler, opts) {
128
+ const ctx = this.paths.post(toOpenApiPath(path), operation);
129
+ this.app.post(path, (event) => handler(event, ctx), opts);
130
+ return this;
131
+ }
132
+ put(path, operation, handler, opts) {
133
+ const ctx = this.paths.put(toOpenApiPath(path), operation);
134
+ this.app.put(path, (event) => handler(event, ctx), opts);
135
+ return this;
136
+ }
137
+ delete(path, operation, handler, opts) {
138
+ const ctx = this.paths.delete(toOpenApiPath(path), operation);
139
+ this.app.delete(path, (event) => handler(event, ctx), opts);
140
+ return this;
141
+ }
142
+ patch(path, operation, handler, opts) {
143
+ const ctx = this.paths.patch(toOpenApiPath(path), operation);
144
+ this.app.patch(path, (event) => handler(event, ctx), opts);
145
+ return this;
146
+ }
147
+ /** Register a route and operation for all standard HTTP methods. */
148
+ all(path, operation, handler, opts) {
149
+ const ctx = this.paths.all(toOpenApiPath(path), operation);
150
+ this.app.all(path, (event) => handler(event, ctx), opts);
151
+ return this;
152
+ }
153
+ /** Register a route and operation for specific HTTP methods. */
154
+ on(methods, path, operation, handler, opts) {
155
+ const ctx = this.paths.on(methods, toOpenApiPath(path), operation);
156
+ for (const method of methods) this.app.on(method, path, (event) => handler(event, ctx), opts);
157
+ return this;
158
+ }
159
+ };
160
+ /** Create an {@link OpenApiRouter} that combines H3 route registration with OpenAPI path collection. */
161
+ function createRouter(app, paths) {
162
+ return new OpenApiRouter(app, paths);
163
+ }
164
+ /** Builder for OpenAPI metadata passed to `.meta()` on Zod schemas. */
165
+ const metadata = (meta) => meta;
166
+ /**
167
+ * Build a typed `requestBody` object with `application/json` content.
168
+ *
169
+ * Additional media type options (e.g. `example`) can be passed via `opts.content`.
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * jsonRequest(inputSchema)
174
+ * jsonRequest(inputSchema, { description: "Create a post", content: { example: { title: "Hello" } } })
175
+ * ```
176
+ */
177
+ function jsonRequest(schema, opts) {
178
+ const { content, ...rest } = opts || {};
179
+ return {
180
+ required: true,
181
+ ...rest,
182
+ content: { "application/json": {
183
+ schema,
184
+ ...content
185
+ } }
186
+ };
187
+ }
188
+ /**
189
+ * Build a typed response object with `application/json` content.
190
+ *
191
+ * Additional media type options (e.g. `example`) can be passed via `opts.content`.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * jsonResponse(outputSchema, { description: "Success" })
196
+ * jsonResponse(outputSchema, {
197
+ * description: "Success",
198
+ * headers: z.object({ "x-request-id": z.string() }).meta({}),
199
+ * })
200
+ * ```
201
+ */
202
+ function jsonResponse(schema, opts) {
203
+ const { content, headers, ...rest } = opts;
204
+ return {
205
+ ...rest,
206
+ headers,
207
+ content: { "application/json": {
208
+ schema,
209
+ ...content
210
+ } }
211
+ };
212
+ }
213
+ /**
214
+ * Convert H3 path syntax to OpenAPI path syntax.
215
+ *
216
+ * - `/:name` → `/{name}`
217
+ * - `/*` → `/{param}`
218
+ * - `/**` → `/{path}`
219
+ */
220
+ function toOpenApiPath(route) {
221
+ if (!route.startsWith("/")) route = "/" + route;
222
+ return route.split("/").map((segment) => {
223
+ if (segment.startsWith(":")) return `{${segment.slice(1)}}`;
224
+ else if (segment === "*") return "{param}";
225
+ else if (segment === "**") return "{path}";
226
+ else return segment;
227
+ }).join("/");
228
+ }
229
+
230
+ //#endregion
231
+ exports.OpenApiPaths = OpenApiPaths;
232
+ exports.OpenApiRouter = OpenApiRouter;
233
+ Object.defineProperty(exports, 'createDocument', {
234
+ enumerable: true,
235
+ get: function () {
236
+ return zod_openapi.createDocument;
237
+ }
238
+ });
239
+ exports.createRouter = createRouter;
240
+ exports.jsonRequest = jsonRequest;
241
+ exports.jsonResponse = jsonResponse;
242
+ exports.metadata = metadata;