@alt-stack/server-bun 0.4.5

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/src/server.ts ADDED
@@ -0,0 +1,527 @@
1
+ import type { z } from "zod";
2
+ import type { ZodError } from "zod";
3
+ import type {
4
+ TypedContext,
5
+ InputConfig,
6
+ TelemetryOption,
7
+ } from "@alt-stack/server-core";
8
+ import type { Procedure } from "@alt-stack/server-core";
9
+ import type { Router } from "@alt-stack/server-core";
10
+ import {
11
+ validateInput,
12
+ middlewareMarker,
13
+ middlewareOk,
14
+ resolveTelemetryConfig,
15
+ shouldIgnoreRoute,
16
+ initTelemetry,
17
+ createRequestSpan,
18
+ endSpanWithError,
19
+ setSpanOk,
20
+ withActiveSpan,
21
+ isErr,
22
+ ok as resultOk,
23
+ err as resultErr,
24
+ findHttpStatusForError,
25
+ } from "@alt-stack/server-core";
26
+ import type {
27
+ MiddlewareResult,
28
+ MiddlewareResultSuccess,
29
+ } from "@alt-stack/server-core";
30
+ import { BunRouter } from "./router.ts";
31
+ import type { BunServer, BunBaseContext } from "./types.ts";
32
+
33
+ function normalizePrefix(prefix: string): string {
34
+ const normalized = prefix.startsWith("/") ? prefix : `/${prefix}`;
35
+ return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
36
+ }
37
+
38
+ function normalizePath(prefix: string, path: string): string {
39
+ const normalizedPrefix = normalizePrefix(prefix);
40
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
41
+ const cleanPath =
42
+ normalizedPath.endsWith("/") && normalizedPath !== "/"
43
+ ? normalizedPath.slice(0, -1)
44
+ : normalizedPath;
45
+ if (cleanPath === "/") {
46
+ return normalizedPrefix;
47
+ }
48
+ return `${normalizedPrefix}${cleanPath}`;
49
+ }
50
+
51
+ /**
52
+ * Serialize a ResultError for JSON response.
53
+ * Extracts error properties beyond the base Error fields.
54
+ */
55
+ function serializeError(error: Error & { _tag: string }): object {
56
+ const props: Record<string, unknown> = {};
57
+ for (const key of Object.keys(error)) {
58
+ if (key !== "name" && key !== "message" && key !== "stack") {
59
+ props[key] = (error as any)[key];
60
+ }
61
+ }
62
+
63
+ return {
64
+ error: {
65
+ code: error._tag,
66
+ message: error.message,
67
+ ...props,
68
+ },
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Create a JSON response
74
+ */
75
+ function jsonResponse(data: unknown, status: number = 200): Response {
76
+ return new Response(JSON.stringify(data), {
77
+ status,
78
+ headers: { "Content-Type": "application/json" },
79
+ });
80
+ }
81
+
82
+ export function createServer<
83
+ TContext extends BunBaseContext = BunBaseContext,
84
+ >(
85
+ config: Record<string, Router<TContext> | Router<TContext>[]>,
86
+ options?: {
87
+ createContext?: (
88
+ req: Request,
89
+ server: BunServer,
90
+ ) => Promise<Omit<TContext, "bun" | "span">> | Omit<TContext, "bun" | "span">;
91
+ defaultErrorHandlers?: {
92
+ default400Error: (
93
+ errors: Array<
94
+ [error: ZodError, variant: "body" | "param" | "query", value: unknown]
95
+ >,
96
+ ) => [z.ZodObject<any>, z.infer<z.ZodObject<any>>];
97
+ default500Error: (
98
+ error: unknown,
99
+ ) => [z.ZodObject<any>, z.infer<z.ZodObject<any>>];
100
+ default400ErrorSchema?: z.ZodObject<any>;
101
+ default500ErrorSchema?: z.ZodObject<any>;
102
+ };
103
+ telemetry?: TelemetryOption;
104
+ port?: number;
105
+ hostname?: string;
106
+ },
107
+ ): BunServer {
108
+ const bunRouter = new BunRouter();
109
+ const telemetryConfig = resolveTelemetryConfig(options?.telemetry);
110
+
111
+ // Initialize telemetry if enabled
112
+ if (telemetryConfig.enabled) {
113
+ initTelemetry();
114
+ }
115
+
116
+ // Collect all procedures from all routers
117
+ const procedures: Procedure<
118
+ InputConfig,
119
+ z.ZodTypeAny | undefined,
120
+ Record<number, z.ZodTypeAny> | undefined,
121
+ TContext
122
+ >[] = [];
123
+
124
+ for (const [prefix, routerOrRouters] of Object.entries(config)) {
125
+ const routers = Array.isArray(routerOrRouters)
126
+ ? routerOrRouters
127
+ : [routerOrRouters];
128
+
129
+ for (const router of routers) {
130
+ const routerProcedures = router.getProcedures();
131
+
132
+ for (const procedure of routerProcedures) {
133
+ procedures.push({
134
+ ...procedure,
135
+ path: normalizePath(prefix, procedure.path),
136
+ });
137
+ }
138
+ }
139
+ }
140
+
141
+ // Register all procedures with the router
142
+ for (const procedure of procedures) {
143
+ bunRouter.register(
144
+ procedure.method,
145
+ procedure.path,
146
+ async (req, params, server) => {
147
+ // Create telemetry span if enabled
148
+ const url = new URL(req.url);
149
+ const shouldTrace =
150
+ telemetryConfig.enabled &&
151
+ !shouldIgnoreRoute(procedure.path, telemetryConfig);
152
+ const span = shouldTrace
153
+ ? createRequestSpan(
154
+ procedure.method,
155
+ procedure.path,
156
+ url.pathname,
157
+ telemetryConfig,
158
+ )
159
+ : undefined;
160
+
161
+ return withActiveSpan(span, async () => {
162
+ try {
163
+ // Extract query parameters
164
+ const query: Record<string, unknown> = {};
165
+ for (const [key, value] of url.searchParams) {
166
+ query[key] = value;
167
+ }
168
+
169
+ // Parse body (JSON only)
170
+ const body = await req.json().catch(() => ({}));
171
+
172
+ const inputConfig = procedure.config.input;
173
+ const validatedInput = await validateInput(
174
+ inputConfig,
175
+ params,
176
+ query,
177
+ body,
178
+ );
179
+
180
+ const customContext = options?.createContext
181
+ ? await options.createContext(req, server)
182
+ : ({} as Omit<TContext, "bun" | "span">);
183
+
184
+ type ProcedureContext = TypedContext<
185
+ InputConfig,
186
+ Record<number, z.ZodTypeAny> | undefined,
187
+ TContext
188
+ >;
189
+
190
+ const ctx: ProcedureContext = {
191
+ ...customContext,
192
+ bun: { req, server },
193
+ input: validatedInput,
194
+ span,
195
+ } as ProcedureContext;
196
+
197
+ let currentCtx: ProcedureContext = ctx;
198
+ let middlewareIndex = 0;
199
+
200
+ // Get the flags for which middleware return Result types
201
+ const middlewareWithErrorsFlags = (procedure as any)
202
+ .middlewareWithErrorsFlags as boolean[] | undefined;
203
+
204
+ type MiddlewareRunResult =
205
+ | { ok: true; ctx: ProcedureContext }
206
+ | { ok: false; error: Error & { _tag: string } }
207
+ | { ok: true; response: Response };
208
+
209
+ const runMiddleware = async (): Promise<MiddlewareRunResult> => {
210
+ if (middlewareIndex >= procedure.middleware.length) {
211
+ return { ok: true, ctx: currentCtx };
212
+ }
213
+
214
+ const currentIndex = middlewareIndex;
215
+ const middleware = procedure.middleware[middlewareIndex++];
216
+ if (!middleware) {
217
+ return { ok: true, ctx: currentCtx };
218
+ }
219
+
220
+ // Check if this middleware returns Result types
221
+ const isResultMiddleware =
222
+ middlewareWithErrorsFlags?.[currentIndex] ?? false;
223
+
224
+ if (isResultMiddleware) {
225
+ // Result-based middleware - provide next() that returns Result
226
+ const nextFn = async (opts?: {
227
+ ctx?: Partial<ProcedureContext>;
228
+ }) => {
229
+ if (opts?.ctx) {
230
+ currentCtx = { ...currentCtx, ...opts.ctx } as ProcedureContext;
231
+ }
232
+ const nextResult = await runMiddleware();
233
+
234
+ // Propagate errors from downstream middleware
235
+ if (!nextResult.ok) {
236
+ return resultErr(nextResult.error);
237
+ }
238
+
239
+ // Handle Response objects
240
+ if ("response" in nextResult) {
241
+ return middlewareOk(currentCtx);
242
+ }
243
+
244
+ return middlewareOk(nextResult.ctx);
245
+ };
246
+
247
+ const result = await (middleware as any)({
248
+ ctx: currentCtx,
249
+ next: nextFn,
250
+ });
251
+
252
+ // Result-based middleware returns Result<MiddlewareResultSuccess, Error>
253
+ if (result && typeof result === "object" && "_tag" in result) {
254
+ if (result._tag === "Err") {
255
+ const error = result.error as Error & { _tag: string };
256
+ return { ok: false, error };
257
+ }
258
+
259
+ if (result._tag === "Ok") {
260
+ const value = result.value as MiddlewareResultSuccess<any>;
261
+ if (value && value.marker === middlewareMarker) {
262
+ currentCtx = {
263
+ ...currentCtx,
264
+ ...value.ctx,
265
+ } as ProcedureContext;
266
+ }
267
+ return { ok: true, ctx: currentCtx };
268
+ }
269
+ }
270
+
271
+ return { ok: true, ctx: currentCtx };
272
+ }
273
+
274
+ // Legacy middleware - throws on error, returns MiddlewareResult
275
+ let result: unknown;
276
+ try {
277
+ result = await middleware({
278
+ ctx: currentCtx,
279
+ next: async (
280
+ opts?: { ctx?: Partial<ProcedureContext> },
281
+ ): Promise<MiddlewareResult<Partial<ProcedureContext>>> => {
282
+ // Merge context updates if provided
283
+ if (opts?.ctx) {
284
+ currentCtx = { ...currentCtx, ...opts.ctx } as ProcedureContext;
285
+ }
286
+ const nextResult = await runMiddleware();
287
+
288
+ // Propagate errors from Result-based middleware
289
+ if (!nextResult.ok) {
290
+ throw nextResult.error;
291
+ }
292
+
293
+ // Handle Response objects
294
+ if ("response" in nextResult) {
295
+ return {
296
+ marker: middlewareMarker,
297
+ ok: true as const,
298
+ data: nextResult.response,
299
+ };
300
+ }
301
+
302
+ return {
303
+ marker: middlewareMarker,
304
+ ok: true as const,
305
+ data: nextResult.ctx,
306
+ };
307
+ },
308
+ });
309
+ } catch (thrownError) {
310
+ // Check if this is a propagated middleware error with _tag
311
+ if (
312
+ thrownError &&
313
+ thrownError instanceof Error &&
314
+ typeof (thrownError as any)._tag === "string"
315
+ ) {
316
+ return {
317
+ ok: false,
318
+ error: thrownError as Error & { _tag: string },
319
+ };
320
+ }
321
+ // Re-throw other errors to be handled by outer try-catch
322
+ throw thrownError;
323
+ }
324
+
325
+ // Handle both legacy middleware (returns context/Response) and new middleware (returns MiddlewareResult)
326
+ if (result instanceof Response) {
327
+ return { ok: true, response: result };
328
+ }
329
+
330
+ // Check if middleware returned a Result type (err() call)
331
+ if (result && typeof result === "object" && "_tag" in result) {
332
+ const resultWithTag = result as {
333
+ _tag: string;
334
+ error?: unknown;
335
+ value?: unknown;
336
+ };
337
+ if (resultWithTag._tag === "Err") {
338
+ const error = resultWithTag.error as Error & { _tag: string };
339
+ return { ok: false, error };
340
+ }
341
+
342
+ if (resultWithTag._tag === "Ok") {
343
+ const value = resultWithTag.value as MiddlewareResultSuccess<any>;
344
+ if (value && value.marker === middlewareMarker) {
345
+ currentCtx = {
346
+ ...currentCtx,
347
+ ...value.ctx,
348
+ } as ProcedureContext;
349
+ }
350
+ return { ok: true, ctx: currentCtx };
351
+ }
352
+ }
353
+
354
+ // Check if it's a MiddlewareResult wrapper
355
+ if (
356
+ result &&
357
+ typeof result === "object" &&
358
+ "marker" in result &&
359
+ "ok" in result
360
+ ) {
361
+ const data = (result as any).data;
362
+ if (data instanceof Response) {
363
+ return { ok: true, response: data };
364
+ }
365
+ currentCtx = data as ProcedureContext;
366
+ return { ok: true, ctx: currentCtx };
367
+ }
368
+
369
+ // Legacy middleware - direct context return
370
+ currentCtx = result as ProcedureContext;
371
+ return { ok: true, ctx: currentCtx };
372
+ };
373
+
374
+ const middlewareResult = await runMiddleware();
375
+
376
+ // Handle middleware errors (from Result-based middleware)
377
+ if (!middlewareResult.ok) {
378
+ const error = middlewareResult.error as Error & { _tag: string };
379
+ const statusCode = findHttpStatusForError(
380
+ error._tag,
381
+ procedure.config.errors as any,
382
+ );
383
+ const errorData = serializeError(error);
384
+
385
+ span?.setAttribute("http.response.status_code", statusCode);
386
+ span?.end();
387
+ return jsonResponse(errorData, statusCode);
388
+ }
389
+
390
+ // Handle Response objects from middleware
391
+ if ("response" in middlewareResult) {
392
+ span?.setAttribute(
393
+ "http.response.status_code",
394
+ middlewareResult.response.status,
395
+ );
396
+ setSpanOk(span);
397
+ span?.end();
398
+ return middlewareResult.response;
399
+ }
400
+
401
+ currentCtx = middlewareResult.ctx;
402
+
403
+ const result = await procedure.handler(currentCtx);
404
+
405
+ // Handle Result type - check if it's Ok or Err
406
+ if (isErr(result)) {
407
+ const error = result.error;
408
+ const statusCode = findHttpStatusForError(
409
+ error._tag,
410
+ procedure.config.errors as any,
411
+ );
412
+ const errorData = serializeError(error);
413
+
414
+ span?.setAttribute("http.response.status_code", statusCode);
415
+ span?.end();
416
+ return jsonResponse(errorData, statusCode);
417
+ }
418
+
419
+ // It's an Ok result
420
+ const response = result.value;
421
+
422
+ // If handler returns a Response directly, return it as-is
423
+ if (response instanceof Response) {
424
+ span?.setAttribute("http.response.status_code", response.status);
425
+ setSpanOk(span);
426
+ span?.end();
427
+ return response;
428
+ }
429
+
430
+ if (procedure.config.output) {
431
+ const validated = procedure.config.output.parse(response);
432
+ span?.setAttribute("http.response.status_code", 200);
433
+ setSpanOk(span);
434
+ span?.end();
435
+ return jsonResponse(validated);
436
+ }
437
+
438
+ span?.setAttribute("http.response.status_code", 200);
439
+ setSpanOk(span);
440
+ span?.end();
441
+ return jsonResponse(response);
442
+ } catch (error) {
443
+ endSpanWithError(span, error);
444
+ // Check for validation errors (thrown by internal validateInput)
445
+ if (error instanceof Error && error.name === "ValidationError") {
446
+ const validationError = error as Error & { details?: unknown };
447
+ span?.setAttribute("http.response.status_code", 400);
448
+ span?.end();
449
+ // Use default 400 error handler if available
450
+ if (
451
+ options?.defaultErrorHandlers &&
452
+ validationError.details &&
453
+ typeof validationError.details === "object" &&
454
+ "errors" in validationError.details &&
455
+ Array.isArray(
456
+ (validationError.details as { errors?: unknown }).errors,
457
+ )
458
+ ) {
459
+ const errors = (
460
+ validationError.details as { errors: unknown[] }
461
+ ).errors as Array<
462
+ [ZodError, "body" | "param" | "query", unknown]
463
+ >;
464
+ const [_schema, instance] =
465
+ options.defaultErrorHandlers.default400Error(errors);
466
+ return jsonResponse({ error: instance }, 400);
467
+ }
468
+ // Fallback to default validation error format
469
+ return jsonResponse(
470
+ {
471
+ error: {
472
+ code: "VALIDATION_ERROR",
473
+ message: validationError.message,
474
+ details: validationError.details
475
+ ? Array.isArray(validationError.details)
476
+ ? validationError.details
477
+ : [String(validationError.details)]
478
+ : [],
479
+ },
480
+ },
481
+ 400,
482
+ );
483
+ }
484
+ span?.setAttribute("http.response.status_code", 500);
485
+ span?.end();
486
+ // Use default 500 error handler if available
487
+ if (options?.defaultErrorHandlers) {
488
+ const [_schema, instance] =
489
+ options.defaultErrorHandlers.default500Error(error);
490
+ return jsonResponse({ error: instance }, 500);
491
+ }
492
+ // Fallback to default 500 error format
493
+ return jsonResponse(
494
+ {
495
+ error: {
496
+ code: "INTERNAL_SERVER_ERROR",
497
+ message:
498
+ error instanceof Error
499
+ ? error.message
500
+ : "Internal server error",
501
+ details:
502
+ error instanceof Error && error.stack ? [error.stack] : [],
503
+ },
504
+ },
505
+ 500,
506
+ );
507
+ }
508
+ });
509
+ },
510
+ );
511
+ }
512
+
513
+ // Create and return Bun server
514
+ const server = Bun.serve({
515
+ port: options?.port ?? 3000,
516
+ hostname: options?.hostname ?? "0.0.0.0",
517
+ fetch: async (req, server) => {
518
+ const response = await bunRouter.handle(req, server);
519
+ if (response) {
520
+ return response;
521
+ }
522
+ return jsonResponse({ error: { code: "NOT_FOUND", message: "Not Found" } }, 404);
523
+ },
524
+ });
525
+
526
+ return server;
527
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type { BaseContext } from "@alt-stack/server-core";
3
+ import type { BunBaseContext, BunServer } from "./types.ts";
4
+
5
+ describe("Bun Types", () => {
6
+ describe("BunBaseContext", () => {
7
+ it("should extend BaseContext", () => {
8
+ // Type-level test: BunBaseContext should be assignable to BaseContext
9
+ const ctx: BunBaseContext = {
10
+ bun: { req: new Request("http://localhost"), server: {} as BunServer },
11
+ };
12
+ const _baseCtx: BaseContext = ctx;
13
+ expect(ctx.bun).toBeDefined();
14
+ });
15
+
16
+ it("should have bun property with req and server", () => {
17
+ const ctx: BunBaseContext = {
18
+ bun: { req: new Request("http://localhost"), server: {} as BunServer },
19
+ };
20
+ expect(ctx.bun.req).toBeInstanceOf(Request);
21
+ expect(ctx.bun.server).toBeDefined();
22
+ });
23
+
24
+ it("should allow custom properties alongside bun", () => {
25
+ interface AppContext extends BunBaseContext {
26
+ user: { id: string } | null;
27
+ requestId: string;
28
+ }
29
+
30
+ const ctx: AppContext = {
31
+ bun: { req: new Request("http://localhost"), server: {} as BunServer },
32
+ user: { id: "123" },
33
+ requestId: "req-456",
34
+ };
35
+
36
+ expect(ctx.user?.id).toBe("123");
37
+ expect(ctx.requestId).toBe("req-456");
38
+ });
39
+ });
40
+ });
package/src/types.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { BaseContext } from "@alt-stack/server-core";
2
+ import type { Server } from "bun";
3
+
4
+ /**
5
+ * Bun Server type without WebSocket support.
6
+ */
7
+ export type BunServer = Server<undefined>;
8
+
9
+ /**
10
+ * Bun-specific base context that includes the native Request and Server objects.
11
+ * Extends the framework-agnostic BaseContext from server-core.
12
+ */
13
+ export interface BunBaseContext extends BaseContext {
14
+ bun: {
15
+ req: Request;
16
+ server: BunServer;
17
+ };
18
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../typescript-config/base.json",
4
+ "compilerOptions": {
5
+ "rootDir": "./src",
6
+ "types": ["bun-types"],
7
+ "allowImportingTsExtensions": true,
8
+ "noEmit": true
9
+ },
10
+ "include": ["src/**/*"],
11
+ "exclude": ["node_modules"]
12
+ }