@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/LICENSE +21 -0
- package/package.json +33 -0
- package/src/docs.ts +115 -0
- package/src/index.ts +142 -0
- package/src/router.ts +100 -0
- package/src/server.spec.ts +458 -0
- package/src/server.ts +527 -0
- package/src/types.spec.ts +40 -0
- package/src/types.ts +18 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createServer } from "./server.ts";
|
|
4
|
+
import { Router, router, ok, type BunBaseContext } from "./index.ts";
|
|
5
|
+
|
|
6
|
+
describe("createServer", () => {
|
|
7
|
+
let server: ReturnType<typeof createServer> | null = null;
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
server?.stop();
|
|
11
|
+
server = null;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const getBaseUrl = () => `http://localhost:${server!.port}`;
|
|
15
|
+
|
|
16
|
+
describe("basic routing", () => {
|
|
17
|
+
it("should create a Bun server with GET route", async () => {
|
|
18
|
+
const baseRouter = new Router();
|
|
19
|
+
const testRouter = router({
|
|
20
|
+
"/hello": baseRouter.procedure
|
|
21
|
+
.output(z.object({ message: z.string() }))
|
|
22
|
+
.get(() => ok({ message: "Hello, World!" })),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
26
|
+
const res = await fetch(`${getBaseUrl()}/api/hello`);
|
|
27
|
+
|
|
28
|
+
expect(res.status).toBe(200);
|
|
29
|
+
expect(await res.json()).toEqual({ message: "Hello, World!" });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should create a Bun server with POST route", async () => {
|
|
33
|
+
const baseRouter = new Router();
|
|
34
|
+
const testRouter = router({
|
|
35
|
+
"/greet": baseRouter.procedure
|
|
36
|
+
.input({ body: z.object({ name: z.string() }) })
|
|
37
|
+
.output(z.object({ greeting: z.string() }))
|
|
38
|
+
.post(({ input }) =>
|
|
39
|
+
ok({
|
|
40
|
+
greeting: `Hello, ${input.body.name}!`,
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
46
|
+
const res = await fetch(`${getBaseUrl()}/api/greet`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify({ name: "Alice" }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(res.status).toBe(200);
|
|
53
|
+
expect(await res.json()).toEqual({ greeting: "Hello, Alice!" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should handle PUT requests", async () => {
|
|
57
|
+
const baseRouter = new Router();
|
|
58
|
+
const testRouter = router({
|
|
59
|
+
"/items/{id}": baseRouter.procedure
|
|
60
|
+
.input({
|
|
61
|
+
params: z.object({ id: z.string() }),
|
|
62
|
+
body: z.object({ name: z.string() }),
|
|
63
|
+
})
|
|
64
|
+
.output(z.object({ id: z.string(), name: z.string() }))
|
|
65
|
+
.put(({ input }) =>
|
|
66
|
+
ok({
|
|
67
|
+
id: input.params.id,
|
|
68
|
+
name: input.body.name,
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
74
|
+
const res = await fetch(`${getBaseUrl()}/api/items/456`, {
|
|
75
|
+
method: "PUT",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify({ name: "Updated" }),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(res.status).toBe(200);
|
|
81
|
+
expect(await res.json()).toEqual({ id: "456", name: "Updated" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should handle PATCH requests", async () => {
|
|
85
|
+
const baseRouter = new Router();
|
|
86
|
+
const testRouter = router({
|
|
87
|
+
"/items/{id}": baseRouter.procedure
|
|
88
|
+
.input({
|
|
89
|
+
params: z.object({ id: z.string() }),
|
|
90
|
+
body: z.object({ completed: z.boolean() }),
|
|
91
|
+
})
|
|
92
|
+
.output(z.object({ id: z.string(), completed: z.boolean() }))
|
|
93
|
+
.patch(({ input }) =>
|
|
94
|
+
ok({
|
|
95
|
+
id: input.params.id,
|
|
96
|
+
completed: input.body.completed,
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
102
|
+
const res = await fetch(`${getBaseUrl()}/api/items/789`, {
|
|
103
|
+
method: "PATCH",
|
|
104
|
+
headers: { "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify({ completed: true }),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(res.status).toBe(200);
|
|
109
|
+
expect(await res.json()).toEqual({ id: "789", completed: true });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should handle path parameters", async () => {
|
|
113
|
+
const baseRouter = new Router();
|
|
114
|
+
const testRouter = router({
|
|
115
|
+
"/items/{id}": baseRouter.procedure
|
|
116
|
+
.input({ params: z.object({ id: z.string() }) })
|
|
117
|
+
.output(z.object({ id: z.string() }))
|
|
118
|
+
.get(({ input }) => ok({ id: input.params.id })),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
122
|
+
const res = await fetch(`${getBaseUrl()}/api/items/123`);
|
|
123
|
+
|
|
124
|
+
expect(res.status).toBe(200);
|
|
125
|
+
expect(await res.json()).toEqual({ id: "123" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should handle query parameters", async () => {
|
|
129
|
+
const baseRouter = new Router();
|
|
130
|
+
const testRouter = router({
|
|
131
|
+
"/list": baseRouter.procedure
|
|
132
|
+
.input({
|
|
133
|
+
query: z.object({
|
|
134
|
+
page: z.coerce.number().default(1),
|
|
135
|
+
limit: z.coerce.number().default(10),
|
|
136
|
+
}),
|
|
137
|
+
})
|
|
138
|
+
.output(z.object({ page: z.number(), limit: z.number() }))
|
|
139
|
+
.get(({ input }) =>
|
|
140
|
+
ok({
|
|
141
|
+
page: input.query.page,
|
|
142
|
+
limit: input.query.limit,
|
|
143
|
+
}),
|
|
144
|
+
),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
148
|
+
const res = await fetch(`${getBaseUrl()}/api/list?page=2&limit=20`);
|
|
149
|
+
|
|
150
|
+
expect(res.status).toBe(200);
|
|
151
|
+
expect(await res.json()).toEqual({ page: 2, limit: 20 });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should return 404 for unknown routes", async () => {
|
|
155
|
+
const baseRouter = new Router();
|
|
156
|
+
const testRouter = router({
|
|
157
|
+
"/hello": baseRouter.procedure
|
|
158
|
+
.output(z.object({ message: z.string() }))
|
|
159
|
+
.get(() => ok({ message: "Hello" })),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
163
|
+
const res = await fetch(`${getBaseUrl()}/api/unknown`);
|
|
164
|
+
|
|
165
|
+
expect(res.status).toBe(404);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("custom context", () => {
|
|
170
|
+
it("should provide custom context to handlers", async () => {
|
|
171
|
+
interface AppContext extends BunBaseContext {
|
|
172
|
+
requestId: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const baseRouter = new Router<AppContext>();
|
|
176
|
+
const testRouter = router<AppContext>({
|
|
177
|
+
"/context": baseRouter.procedure
|
|
178
|
+
.output(z.object({ requestId: z.string() }))
|
|
179
|
+
.get(({ ctx }) => ok({ requestId: ctx.requestId })),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
server = createServer<AppContext>(
|
|
183
|
+
{ "/api": testRouter },
|
|
184
|
+
{
|
|
185
|
+
port: 0,
|
|
186
|
+
createContext: () => ({ requestId: "test-123" }),
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const res = await fetch(`${getBaseUrl()}/api/context`);
|
|
191
|
+
|
|
192
|
+
expect(res.status).toBe(200);
|
|
193
|
+
expect(await res.json()).toEqual({ requestId: "test-123" });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("should provide bun context to handlers (properly typed)", async () => {
|
|
197
|
+
const baseRouter = new Router();
|
|
198
|
+
const testRouter = router({
|
|
199
|
+
"/bun-ctx": baseRouter.procedure
|
|
200
|
+
.output(z.object({ url: z.string() }))
|
|
201
|
+
.get(({ ctx }) =>
|
|
202
|
+
ok({
|
|
203
|
+
// ctx.bun is properly typed - no casting needed!
|
|
204
|
+
url: ctx.bun.req.url,
|
|
205
|
+
}),
|
|
206
|
+
),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
210
|
+
const res = await fetch(`${getBaseUrl()}/api/bun-ctx`);
|
|
211
|
+
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
const data = await res.json();
|
|
214
|
+
expect(data.url).toContain("/api/bun-ctx");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("middleware", () => {
|
|
219
|
+
it("should execute procedure-level middleware", async () => {
|
|
220
|
+
interface AppContext extends BunBaseContext {
|
|
221
|
+
user: { id: string } | null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const baseRouter = new Router<AppContext>();
|
|
225
|
+
const testRouter = router<AppContext>({
|
|
226
|
+
"/protected": baseRouter.procedure
|
|
227
|
+
.use(async ({ next }) => {
|
|
228
|
+
// Simulate auth middleware - narrow user to non-null
|
|
229
|
+
return next({ ctx: { user: { id: "user-1" } } });
|
|
230
|
+
})
|
|
231
|
+
.output(z.object({ userId: z.string() }))
|
|
232
|
+
.get(({ ctx }) => ok({ userId: ctx.user!.id })),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
server = createServer<AppContext>(
|
|
236
|
+
{ "/api": testRouter },
|
|
237
|
+
{ port: 0, createContext: () => ({ user: null }) },
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const res = await fetch(`${getBaseUrl()}/api/protected`);
|
|
241
|
+
|
|
242
|
+
expect(res.status).toBe(200);
|
|
243
|
+
expect(await res.json()).toEqual({ userId: "user-1" });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("should allow middleware to throw for early exit", async () => {
|
|
247
|
+
interface AppContext extends BunBaseContext {
|
|
248
|
+
user: { id: string } | null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const baseRouter = new Router<AppContext>();
|
|
252
|
+
const testRouter = router<AppContext>({
|
|
253
|
+
"/protected": baseRouter.procedure
|
|
254
|
+
.use(async ({ ctx, next }) => {
|
|
255
|
+
if (!ctx.user) {
|
|
256
|
+
// Throw an error to trigger 500 response
|
|
257
|
+
throw new Error("Unauthorized");
|
|
258
|
+
}
|
|
259
|
+
return next();
|
|
260
|
+
})
|
|
261
|
+
.output(z.object({ data: z.string() }))
|
|
262
|
+
.get(() => ok({ data: "secret" })),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
server = createServer<AppContext>(
|
|
266
|
+
{ "/api": testRouter },
|
|
267
|
+
{ port: 0, createContext: () => ({ user: null }) },
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const res = await fetch(`${getBaseUrl()}/api/protected`);
|
|
271
|
+
|
|
272
|
+
expect(res.status).toBe(500);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("validation", () => {
|
|
277
|
+
it("should return 400 for invalid body", async () => {
|
|
278
|
+
const baseRouter = new Router();
|
|
279
|
+
const testRouter = router({
|
|
280
|
+
"/validate": baseRouter.procedure
|
|
281
|
+
.input({ body: z.object({ email: z.string().email() }) })
|
|
282
|
+
.output(z.object({ email: z.string() }))
|
|
283
|
+
.post(({ input }) => ok({ email: input.body.email })),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
287
|
+
const res = await fetch(`${getBaseUrl()}/api/validate`, {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers: { "Content-Type": "application/json" },
|
|
290
|
+
body: JSON.stringify({ email: "invalid" }),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(res.status).toBe(400);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("should return 400 for invalid params", async () => {
|
|
297
|
+
const baseRouter = new Router();
|
|
298
|
+
const testRouter = router({
|
|
299
|
+
"/items/{id}": baseRouter.procedure
|
|
300
|
+
.input({ params: z.object({ id: z.string().uuid() }) })
|
|
301
|
+
.output(z.object({ id: z.string() }))
|
|
302
|
+
.get(({ input }) => ok({ id: input.params.id })),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
306
|
+
const res = await fetch(`${getBaseUrl()}/api/items/not-a-uuid`);
|
|
307
|
+
|
|
308
|
+
expect(res.status).toBe(400);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("should return 400 for invalid query", async () => {
|
|
312
|
+
const baseRouter = new Router();
|
|
313
|
+
const testRouter = router({
|
|
314
|
+
"/list": baseRouter.procedure
|
|
315
|
+
.input({ query: z.object({ page: z.coerce.number().min(1) }) })
|
|
316
|
+
.output(z.object({ page: z.number() }))
|
|
317
|
+
.get(({ input }) => ok({ page: input.query.page })),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
321
|
+
const res = await fetch(`${getBaseUrl()}/api/list?page=0`);
|
|
322
|
+
|
|
323
|
+
expect(res.status).toBe(400);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("error handling", () => {
|
|
328
|
+
it("should handle uncaught errors as 500", async () => {
|
|
329
|
+
const baseRouter = new Router();
|
|
330
|
+
const testRouter = router({
|
|
331
|
+
"/crash": baseRouter.procedure
|
|
332
|
+
.output(z.object({ data: z.string() }))
|
|
333
|
+
.get(() => {
|
|
334
|
+
throw new Error("Unexpected error");
|
|
335
|
+
}),
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
server = createServer({ "/api": testRouter }, { port: 0 });
|
|
339
|
+
const res = await fetch(`${getBaseUrl()}/api/crash`);
|
|
340
|
+
|
|
341
|
+
expect(res.status).toBe(500);
|
|
342
|
+
const data = await res.json();
|
|
343
|
+
expect(data.error.code).toBe("INTERNAL_SERVER_ERROR");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("router function integration", () => {
|
|
348
|
+
it("should work with router() function", async () => {
|
|
349
|
+
const baseRouter = new Router();
|
|
350
|
+
const r = router({
|
|
351
|
+
"/hello": baseRouter.procedure
|
|
352
|
+
.output(z.object({ msg: z.string() }))
|
|
353
|
+
.get(() => ok({ msg: "hi" })),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
server = createServer({ "/api": r }, { port: 0 });
|
|
357
|
+
const res = await fetch(`${getBaseUrl()}/api/hello`);
|
|
358
|
+
|
|
359
|
+
expect(res.status).toBe(200);
|
|
360
|
+
expect(await res.json()).toEqual({ msg: "hi" });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("should work with methods object syntax", async () => {
|
|
364
|
+
const baseRouter = new Router();
|
|
365
|
+
const r = router({
|
|
366
|
+
"/resource/{id}": {
|
|
367
|
+
get: baseRouter.procedure
|
|
368
|
+
.input({ params: z.object({ id: z.string() }) })
|
|
369
|
+
.output(z.object({ id: z.string(), action: z.literal("get") }))
|
|
370
|
+
.handler(({ input }) =>
|
|
371
|
+
ok({ id: input.params.id, action: "get" as const }),
|
|
372
|
+
),
|
|
373
|
+
delete: baseRouter.procedure
|
|
374
|
+
.input({ params: z.object({ id: z.string() }) })
|
|
375
|
+
.output(z.object({ id: z.string(), action: z.literal("delete") }))
|
|
376
|
+
.handler(({ input }) =>
|
|
377
|
+
ok({ id: input.params.id, action: "delete" as const }),
|
|
378
|
+
),
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
server = createServer({ "/api": r }, { port: 0 });
|
|
383
|
+
|
|
384
|
+
const getRes = await fetch(`${getBaseUrl()}/api/resource/123`);
|
|
385
|
+
expect(getRes.status).toBe(200);
|
|
386
|
+
expect(await getRes.json()).toEqual({ id: "123", action: "get" });
|
|
387
|
+
|
|
388
|
+
const deleteRes = await fetch(`${getBaseUrl()}/api/resource/456`, {
|
|
389
|
+
method: "DELETE",
|
|
390
|
+
});
|
|
391
|
+
expect(deleteRes.status).toBe(200);
|
|
392
|
+
expect(await deleteRes.json()).toEqual({ id: "456", action: "delete" });
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe("multiple routers", () => {
|
|
397
|
+
it("should merge multiple routers under same prefix", async () => {
|
|
398
|
+
const baseRouter = new Router();
|
|
399
|
+
const usersRouter = router({
|
|
400
|
+
"/users": baseRouter.procedure
|
|
401
|
+
.output(z.array(z.object({ id: z.string() })))
|
|
402
|
+
.get(() => ok([{ id: "1" }, { id: "2" }])),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const postsRouter = router({
|
|
406
|
+
"/posts": baseRouter.procedure
|
|
407
|
+
.output(z.array(z.object({ title: z.string() })))
|
|
408
|
+
.get(() => ok([{ title: "Hello" }])),
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
server = createServer(
|
|
412
|
+
{
|
|
413
|
+
"/api": [usersRouter, postsRouter],
|
|
414
|
+
},
|
|
415
|
+
{ port: 0 },
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
const usersRes = await fetch(`${getBaseUrl()}/api/users`);
|
|
419
|
+
expect(usersRes.status).toBe(200);
|
|
420
|
+
expect(await usersRes.json()).toEqual([{ id: "1" }, { id: "2" }]);
|
|
421
|
+
|
|
422
|
+
const postsRes = await fetch(`${getBaseUrl()}/api/posts`);
|
|
423
|
+
expect(postsRes.status).toBe(200);
|
|
424
|
+
expect(await postsRes.json()).toEqual([{ title: "Hello" }]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("should handle multiple prefixes", async () => {
|
|
428
|
+
const baseRouter = new Router();
|
|
429
|
+
const v1Router = router({
|
|
430
|
+
"/version": baseRouter.procedure
|
|
431
|
+
.output(z.object({ version: z.literal("v1") }))
|
|
432
|
+
.get(() => ok({ version: "v1" as const })),
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const v2Router = router({
|
|
436
|
+
"/version": baseRouter.procedure
|
|
437
|
+
.output(z.object({ version: z.literal("v2") }))
|
|
438
|
+
.get(() => ok({ version: "v2" as const })),
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
server = createServer(
|
|
442
|
+
{
|
|
443
|
+
"/api/v1": v1Router,
|
|
444
|
+
"/api/v2": v2Router,
|
|
445
|
+
},
|
|
446
|
+
{ port: 0 },
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const v1Res = await fetch(`${getBaseUrl()}/api/v1/version`);
|
|
450
|
+
expect(v1Res.status).toBe(200);
|
|
451
|
+
expect(await v1Res.json()).toEqual({ version: "v1" });
|
|
452
|
+
|
|
453
|
+
const v2Res = await fetch(`${getBaseUrl()}/api/v2/version`);
|
|
454
|
+
expect(v2Res.status).toBe(200);
|
|
455
|
+
expect(await v2Res.json()).toEqual({ version: "v2" });
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
});
|