serverstruct 1.0.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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx vitest run:*)",
5
+ "Bash(pnpm test:*)"
6
+ ]
7
+ }
8
+ }
package/OPENAPI.md ADDED
@@ -0,0 +1,286 @@
1
+ # Serverstruct OpenAPI
2
+
3
+ OpenAPI integration for [serverstruct](https://github.com/eriicafes/serverstruct) with [zod-openapi](https://github.com/samchungy/zod-openapi).
4
+
5
+ Define OpenAPI operations alongside your route handlers using Zod schemas. Request parameters and body are validated at runtime, and responses are fully typed.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ npm i zod zod-openapi
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```typescript
16
+ import { application, serve } from "serverstruct";
17
+ import { z } from "zod";
18
+ import {
19
+ createDocument,
20
+ createRouter,
21
+ jsonRequest,
22
+ jsonResponse,
23
+ OpenApiPaths,
24
+ } from "serverstruct/openapi";
25
+
26
+ const app = application((app, box) => {
27
+ const router = createRouter(app, box.get(OpenApiPaths));
28
+
29
+ router.post(
30
+ "/posts",
31
+ {
32
+ operationId: "createPost",
33
+ requestBody: jsonRequest(
34
+ z.object({ title: z.string() }).meta({ description: "New post input" }),
35
+ ),
36
+ responses: {
37
+ 201: jsonResponse(
38
+ z.object({ id: z.string() }).meta({ description: "Created post" }),
39
+ { description: "Post created" },
40
+ ),
41
+ },
42
+ },
43
+ async (event, ctx) => {
44
+ const body = await ctx.body(event);
45
+ // body is typed as { title: string }
46
+
47
+ return ctx.reply(event, 201, { id: "1" });
48
+ },
49
+ );
50
+
51
+ // Serve the OpenAPI document
52
+ const { paths } = box.get(OpenApiPaths);
53
+ app.get("/docs", () =>
54
+ createDocument({
55
+ openapi: "3.1.0",
56
+ info: { title: "My API", version: "1.0.0" },
57
+ paths,
58
+ }),
59
+ );
60
+ });
61
+
62
+ serve(app, { port: 3000 });
63
+ ```
64
+
65
+ ## OpenApiPaths
66
+
67
+ `OpenApiPaths` collects operation definitions for document generation. Register operations by HTTP method and use the returned `RouterContext` for typed request handling.
68
+
69
+ Since `OpenApiPaths` is a class it can be used as a getbox singleton:
70
+
71
+ ```typescript
72
+ const app = application((app, box) => {
73
+ const paths = box.get(OpenApiPaths);
74
+
75
+ const getUser = paths.get("/users/{id}", {
76
+ operationId: "getUser",
77
+ requestParams: {
78
+ path: z
79
+ .object({ id: z.string() })
80
+ .meta({ description: "User path parameters" }),
81
+ },
82
+ responses: {
83
+ 200: {
84
+ description: "User found",
85
+ content: { "application/json": { schema: userSchema } },
86
+ },
87
+ },
88
+ });
89
+
90
+ app.get("/users/:id", async (event) => {
91
+ const params = await getValidatedRouterParams(
92
+ event,
93
+ getUser.schemas.params,
94
+ );
95
+ const query = await getValidatedQuery(event, getUser.schemas.query);
96
+ const body = await readValidatedBody(event, getUser.schemas.body);
97
+ // getUser.schemas.headers and getUser.schemas.cookies are also available
98
+ return getUser.reply(event, 200, { id: params.id, name: "Alice" });
99
+ });
100
+ });
101
+ ```
102
+
103
+ ### Methods
104
+
105
+ - `get(path, operation)` — Register a GET operation
106
+ - `post(path, operation)` — Register a POST operation
107
+ - `put(path, operation)` — Register a PUT operation
108
+ - `delete(path, operation)` — Register a DELETE operation
109
+ - `patch(path, operation)` — Register a PATCH operation
110
+ - `all(path, operation)` — Register for all standard methods
111
+ - `on(methods, path, operation)` — Register for specific methods
112
+
113
+ All methods return a `RouterContext`.
114
+
115
+ ## OpenApiRouter
116
+
117
+ `OpenApiRouter` combines OpenAPI path registration with H3 route registration. It converts H3 path syntax (`:param`) to OpenAPI format (`{param}`) automatically. The handler function receives the `RouterContext` as its second argument.
118
+
119
+ ```typescript
120
+ const app = application((app, box) => {
121
+ const router = createRouter(app, box.get(OpenApiPaths));
122
+
123
+ router.get(
124
+ "/users/:id",
125
+ {
126
+ operationId: "getUser",
127
+ requestParams: {
128
+ path: z
129
+ .object({ id: z.string() })
130
+ .meta({ description: "User path parameters" }),
131
+ },
132
+ responses: {
133
+ 200: jsonResponse(userSchema, {
134
+ description: "User found",
135
+ headers: z
136
+ .object({ "x-request-id": z.string() })
137
+ .meta({ description: "Response headers" }),
138
+ }),
139
+ 404: jsonResponse(errorSchema, { description: "Not found" }),
140
+ },
141
+ },
142
+ async (event, ctx) => {
143
+ const params = await ctx.params(event);
144
+ const user = findUser(params.id);
145
+
146
+ if (!user) {
147
+ return ctx.reply(event, 404, { error: "not found" });
148
+ }
149
+
150
+ return ctx.reply(event, 200, user, {
151
+ "x-request-id": crypto.randomUUID(),
152
+ });
153
+ // data and headers are typed per status code
154
+ },
155
+ );
156
+ });
157
+ ```
158
+
159
+ Methods mirror `OpenApiPaths` but take an additional `handler` and optional `opts` parameter. All methods return `this` for chaining:
160
+
161
+ - `get(path, operation, handler, opts?)`
162
+ - `post(path, operation, handler, opts?)`
163
+ - `put(path, operation, handler, opts?)`
164
+ - `delete(path, operation, handler, opts?)`
165
+ - `patch(path, operation, handler, opts?)`
166
+ - `all(path, operation, handler, opts?)`
167
+ - `on(methods, path, operation, handler, opts?)`
168
+
169
+ ## Path Conversion
170
+
171
+ `OpenApiRouter` automatically converts H3 path syntax to OpenAPI format:
172
+
173
+ | H3 | OpenAPI |
174
+ | -------- | ---------- |
175
+ | `/:name` | `/{name}` |
176
+ | `/*` | `/{param}` |
177
+ | `/**` | `/{path}` |
178
+
179
+ When using `OpenApiPaths` directly, paths should be written in OpenAPI format (`/users/{id}`).
180
+
181
+ ## Helpers
182
+
183
+ ### jsonRequest
184
+
185
+ Build a `requestBody` object with `application/json` content:
186
+
187
+ ```typescript
188
+ {
189
+ operationId: "createPost",
190
+ requestBody: jsonRequest(
191
+ z.object({ title: z.string() }).meta({ description: "New post input" }),
192
+ ),
193
+ }
194
+
195
+ // With additional options
196
+ {
197
+ operationId: "createPost",
198
+ requestBody: jsonRequest(
199
+ z.object({ title: z.string() }).meta({ description: "New post input" }),
200
+ { description: "Create a post", content: { example: { title: "Hello" } } },
201
+ ),
202
+ }
203
+ ```
204
+
205
+ ### jsonResponse
206
+
207
+ Build a response object with `application/json` content and optional headers:
208
+
209
+ ```typescript
210
+ {
211
+ operationId: "getUser",
212
+ responses: {
213
+ 200: jsonResponse(userSchema, { description: "User found" }),
214
+ 404: jsonResponse(errorSchema, { description: "Not found" }),
215
+ },
216
+ }
217
+
218
+ // With headers
219
+ {
220
+ operationId: "getUser",
221
+ responses: {
222
+ 200: jsonResponse(userSchema, {
223
+ description: "User found",
224
+ headers: z.object({ "x-request-id": z.string() }).meta({ description: "Response headers" }),
225
+ }),
226
+ },
227
+ }
228
+ ```
229
+
230
+ ### metadata
231
+
232
+ Zod's `.meta()` should accept `ZodOpenApiMetadata` directly, but if type inference is not working correctly you can use the `metadata` helper to ensure the correct type:
233
+
234
+ ```typescript
235
+ const userSchema = z
236
+ .object({
237
+ id: z.string(),
238
+ name: z.string(),
239
+ })
240
+ .meta(
241
+ metadata({
242
+ description: "A user object",
243
+ example: { id: "1", name: "Alice" },
244
+ }),
245
+ );
246
+ ```
247
+
248
+ ## Generating the Document
249
+
250
+ Use `createDocument` (re-exported from `zod-openapi`) with the accumulated paths:
251
+
252
+ ```typescript
253
+ const app = application((app, box) => {
254
+ const paths = box.get(OpenApiPaths);
255
+
256
+ // ... register routes ...
257
+
258
+ app.get("/docs", () =>
259
+ createDocument({
260
+ openapi: "3.1.0",
261
+ info: { title: "My API", version: "1.0.0" },
262
+ paths: paths.paths,
263
+ }),
264
+ );
265
+ });
266
+ ```
267
+
268
+ ## Scalar API Reference
269
+
270
+ Serve an interactive API documentation UI powered by [Scalar](https://github.com/scalar/scalar).
271
+
272
+ ```sh
273
+ npm i @scalar/core
274
+ ```
275
+
276
+ ```typescript
277
+ import { apiReference } from "serverstruct/openapi/scalar";
278
+
279
+ app.get("/reference", () =>
280
+ apiReference({
281
+ url: "http://localhost:5000/docs",
282
+ }),
283
+ );
284
+ ```
285
+
286
+ The `url` should point to the endpoint serving your OpenAPI document (see [Generating the Document](#generating-the-document) below).
package/OTEL.md ADDED
@@ -0,0 +1,208 @@
1
+ # Serverstruct OpenTelemetry
2
+
3
+ OpenTelemetry distributed tracing integration for [serverstruct](https://github.com/eriicafes/serverstruct).
4
+
5
+ Automatically instrument HTTP requests with OpenTelemetry spans, capturing semantic convention attributes and enabling trace context propagation across microservices.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ npm i @opentelemetry/api @opentelemetry/semantic-conventions @opentelemetry/sdk-node @opentelemetry/resources @opentelemetry/exporter-trace-otlp-http
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```typescript
16
+ import { application, serve } from "serverstruct";
17
+ import { traceMiddleware } from "serverstruct/otel";
18
+ import { NodeSDK } from "@opentelemetry/sdk-node";
19
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
20
+ import { resourceFromAttributes } from "@opentelemetry/resources";
21
+ import {
22
+ ATTR_SERVICE_NAME,
23
+ ATTR_SERVICE_VERSION,
24
+ } from "@opentelemetry/semantic-conventions";
25
+
26
+ // Initialize and start OpenTelemetry SDK
27
+ const sdk = new NodeSDK({
28
+ resource: resourceFromAttributes({
29
+ [ATTR_SERVICE_NAME]: "my-api",
30
+ [ATTR_SERVICE_VERSION]: "1.0.0",
31
+ }),
32
+ traceExporter: new OTLPTraceExporter({
33
+ url: "http://localhost:4318/v1/traces",
34
+ }),
35
+ });
36
+ sdk.start();
37
+
38
+ const app = application((app) => {
39
+ // Add tracing middleware
40
+ app.use(traceMiddleware());
41
+
42
+ app.get("/users/:id", async (event) => {
43
+ // Spans are automatically created for each request
44
+ return { id: event.context.params.id, name: "Alice" };
45
+ });
46
+ });
47
+
48
+ const server = serve(app);
49
+
50
+ // Gracefully shutdown on exit
51
+ process.on("SIGTERM", async () => {
52
+ await server.close();
53
+ await sdk.shutdown();
54
+ process.exit(0);
55
+ });
56
+ ```
57
+
58
+ ## Features
59
+
60
+ The middleware automatically captures the following [OpenTelemetry semantic convention](https://opentelemetry.io/docs/specs/semconv/http/http-spans/) attributes:
61
+
62
+ - `http.request.method` - HTTP method
63
+ - `url.full` - Full request URL
64
+ - `url.path` - URL path
65
+ - `url.query` - Query string (if present)
66
+ - `url.scheme` - URL scheme (http/https)
67
+ - `server.address` - Server host
68
+ - `user_agent.original` - User agent header (if present)
69
+ - `http.response.status_code` - Response status code
70
+
71
+ ### Span Status Mapping
72
+
73
+ Status codes are automatically mapped to span statuses:
74
+
75
+ - 1xx-4xx: `SpanStatusCode.OK`
76
+ - 5xx: `SpanStatusCode.ERROR`
77
+
78
+ ### Exception Recording
79
+
80
+ Exceptions thrown in route handlers are automatically recorded with full stack traces and set the span status to ERROR. The middleware rethrows errors after recording them, so they will still propagate to error handlers.
81
+
82
+ **Middleware Placement**:
83
+
84
+ - Place the tracing middleware **after** error handlers to record exceptions - the trace middleware will catch errors first, record them, then rethrow for error handlers.
85
+ - Place the tracing middleware **before** error handlers to skip exception recording - error handlers will catch errors before they reach the trace middleware.
86
+ - In all cases, span status is still set based on the HTTP response status code (1xx-4xx = OK, 5xx = ERROR).
87
+
88
+ ## Configuration
89
+
90
+ ### Custom Span Names
91
+
92
+ ```typescript
93
+ app.use(
94
+ traceMiddleware({
95
+ spanName: (event) => `${event.req.method} ${event.path}`,
96
+ }),
97
+ );
98
+ ```
99
+
100
+ ### Custom Span Attributes
101
+
102
+ ```typescript
103
+ app.use(
104
+ traceMiddleware({
105
+ spanAttributes: (event) => ({
106
+ "service.name": "my-api",
107
+ "deployment.environment": process.env.NODE_ENV,
108
+ "request.id": event.req.headers.get("x-request-id"),
109
+ }),
110
+ }),
111
+ );
112
+ ```
113
+
114
+ ### Capture Request/Response Headers
115
+
116
+ ```typescript
117
+ app.use(
118
+ traceMiddleware({
119
+ headers: {
120
+ request: ["authorization", "x-api-key"],
121
+ response: ["x-request-id", "x-rate-limit"],
122
+ },
123
+ }),
124
+ );
125
+ ```
126
+
127
+ Headers are captured as:
128
+
129
+ - `http.request.header.<name>` for request headers
130
+ - `http.response.header.<name>` for response headers
131
+
132
+ ### Custom Tracer
133
+
134
+ ```typescript
135
+ import { trace } from "@opentelemetry/api";
136
+
137
+ const tracer = trace.getTracer("my-service", "1.0.0");
138
+
139
+ app.use(traceMiddleware({ tracer }));
140
+ ```
141
+
142
+ ### Trace Context Propagation
143
+
144
+ Enable distributed tracing across microservices by propagating trace context through HTTP headers.
145
+
146
+ ```typescript
147
+ app.use(
148
+ traceMiddleware({
149
+ propagation: {
150
+ // Extract trace context from incoming requests (default: true)
151
+ request: true,
152
+
153
+ // Inject trace context into outgoing responses (default: false)
154
+ response: true,
155
+ },
156
+ }),
157
+ );
158
+ ```
159
+
160
+ ### Custom Propagator
161
+
162
+ Use a custom propagator for trace context extraction/injection:
163
+
164
+ ```typescript
165
+ import { W3CTraceContextPropagator } from "@opentelemetry/core";
166
+
167
+ app.use(
168
+ traceMiddleware({
169
+ propagation: {
170
+ propagator: new W3CTraceContextPropagator(),
171
+ },
172
+ }),
173
+ );
174
+ ```
175
+
176
+ ## Creating Child Spans
177
+
178
+ Create child spans for operations like database queries or external API calls:
179
+
180
+ ```typescript
181
+ import { trace } from "@opentelemetry/api";
182
+
183
+ const tracer = trace.getTracer("my-service");
184
+
185
+ app.get("/users/:id", async (event) => {
186
+ const userId = event.context.params.id;
187
+
188
+ // Create a child span for database operation
189
+ return await tracer.startActiveSpan("db.query", async (span) => {
190
+ try {
191
+ const user = await db.getUser(userId);
192
+ span.setAttributes({
193
+ "db.operation": "SELECT",
194
+ "db.table": "users",
195
+ });
196
+ return user;
197
+ } finally {
198
+ span.end();
199
+ }
200
+ });
201
+ });
202
+ ```
203
+
204
+ ## Learn More
205
+
206
+ - [OpenTelemetry Documentation](https://opentelemetry.io/docs/)
207
+ - [OpenTelemetry JavaScript SDK](https://github.com/open-telemetry/opentelemetry-js)
208
+ - [Semantic Conventions for HTTP](https://opentelemetry.io/docs/specs/semconv/http/http-spans/)