@typokit/core 0.1.4

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 (51) hide show
  1. package/dist/adapters/database.d.ts +28 -0
  2. package/dist/adapters/database.d.ts.map +1 -0
  3. package/dist/adapters/database.js +2 -0
  4. package/dist/adapters/database.js.map +1 -0
  5. package/dist/adapters/server.d.ts +35 -0
  6. package/dist/adapters/server.d.ts.map +1 -0
  7. package/dist/adapters/server.js +2 -0
  8. package/dist/adapters/server.js.map +1 -0
  9. package/dist/app.d.ts +36 -0
  10. package/dist/app.d.ts.map +1 -0
  11. package/dist/app.js +55 -0
  12. package/dist/app.js.map +1 -0
  13. package/dist/error-middleware.d.ts +17 -0
  14. package/dist/error-middleware.d.ts.map +1 -0
  15. package/dist/error-middleware.js +138 -0
  16. package/dist/error-middleware.js.map +1 -0
  17. package/dist/handler.d.ts +41 -0
  18. package/dist/handler.d.ts.map +1 -0
  19. package/dist/handler.js +22 -0
  20. package/dist/handler.js.map +1 -0
  21. package/dist/hooks.d.ts +48 -0
  22. package/dist/hooks.d.ts.map +1 -0
  23. package/dist/hooks.js +64 -0
  24. package/dist/hooks.js.map +1 -0
  25. package/dist/index.d.ts +9 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +6 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/middleware.d.ts +35 -0
  30. package/dist/middleware.d.ts.map +1 -0
  31. package/dist/middleware.js +54 -0
  32. package/dist/middleware.js.map +1 -0
  33. package/dist/plugin.d.ts +74 -0
  34. package/dist/plugin.d.ts.map +1 -0
  35. package/dist/plugin.js +3 -0
  36. package/dist/plugin.js.map +1 -0
  37. package/package.json +29 -0
  38. package/src/adapters/database.ts +37 -0
  39. package/src/adapters/server.ts +55 -0
  40. package/src/app.test.ts +438 -0
  41. package/src/app.ts +118 -0
  42. package/src/error-middleware.test.ts +263 -0
  43. package/src/error-middleware.ts +186 -0
  44. package/src/handler.test.ts +346 -0
  45. package/src/handler.ts +64 -0
  46. package/src/hooks.test.ts +419 -0
  47. package/src/hooks.ts +114 -0
  48. package/src/index.ts +51 -0
  49. package/src/middleware.test.ts +253 -0
  50. package/src/middleware.ts +100 -0
  51. package/src/plugin.ts +108 -0
@@ -0,0 +1,438 @@
1
+ // @typokit/core — App Factory Tests
2
+
3
+ import { describe, it, expect } from "@rstest/core";
4
+ import type {
5
+ TypoKitRequest,
6
+ TypoKitResponse,
7
+ RequestContext,
8
+ ErrorResponse,
9
+ ServerHandle,
10
+ } from "@typokit/types";
11
+ import { NotFoundError } from "@typokit/errors";
12
+ import type { ServerAdapter } from "./adapters/server.js";
13
+ import type { TypoKitPlugin, AppInstance } from "./plugin.js";
14
+ import { createApp } from "./app.js";
15
+ import { createErrorMiddleware } from "./error-middleware.js";
16
+
17
+ // ─── Helpers ─────────────────────────────────────────────────
18
+
19
+ function createMockServerAdapter(
20
+ overrides?: Partial<ServerAdapter>,
21
+ ): ServerAdapter {
22
+ return {
23
+ name: "mock-server",
24
+ registerRoutes: () => {},
25
+ listen: async (_port: number): Promise<ServerHandle> => ({
26
+ close: async () => {},
27
+ }),
28
+ normalizeRequest: (_raw: unknown): TypoKitRequest => ({
29
+ method: "GET",
30
+ path: "/",
31
+ headers: {},
32
+ body: undefined,
33
+ query: {},
34
+ params: {},
35
+ }),
36
+ writeResponse: () => {},
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ function createMockPlugin(overrides?: Partial<TypoKitPlugin>): TypoKitPlugin {
42
+ return {
43
+ name: "mock-plugin",
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ // ─── App Creation ────────────────────────────────────────────
49
+
50
+ describe("createApp", () => {
51
+ it("returns an app with listen, getNativeServer, and close", () => {
52
+ const app = createApp({
53
+ server: createMockServerAdapter(),
54
+ routes: [],
55
+ });
56
+
57
+ expect(typeof app.listen).toBe("function");
58
+ expect(typeof app.getNativeServer).toBe("function");
59
+ expect(typeof app.close).toBe("function");
60
+ });
61
+
62
+ it("accepts routes with prefix, handlers, and optional middleware", () => {
63
+ const app = createApp({
64
+ server: createMockServerAdapter(),
65
+ routes: [
66
+ {
67
+ prefix: "/api/users",
68
+ handlers: { "GET /": async () => ({ users: [] }) },
69
+ },
70
+ {
71
+ prefix: "/api/posts",
72
+ handlers: { "POST /": async () => ({ id: 1 }) },
73
+ middleware: [],
74
+ },
75
+ ],
76
+ });
77
+
78
+ expect(typeof app.listen).toBe("function");
79
+ });
80
+
81
+ it("accepts optional middleware, plugins, logging, and telemetry", () => {
82
+ const app = createApp({
83
+ server: createMockServerAdapter(),
84
+ routes: [],
85
+ middleware: [],
86
+ plugins: [],
87
+ logging: { info: () => {} },
88
+ telemetry: { enabled: false },
89
+ });
90
+
91
+ expect(typeof app.listen).toBe("function");
92
+ });
93
+
94
+ it("auto-registers error middleware", () => {
95
+ const app = createApp({
96
+ server: createMockServerAdapter(),
97
+ routes: [],
98
+ });
99
+
100
+ expect(typeof app.errorMiddleware).toBe("function");
101
+ });
102
+ });
103
+
104
+ // ─── Server Delegation ──────────────────────────────────────
105
+
106
+ describe("app.listen()", () => {
107
+ it("delegates to the server adapter", async () => {
108
+ let listenedPort: number | undefined;
109
+ const server = createMockServerAdapter({
110
+ listen: async (port: number) => {
111
+ listenedPort = port;
112
+ return { close: async () => {} };
113
+ },
114
+ });
115
+
116
+ const app = createApp({ server, routes: [] });
117
+ await app.listen(3000);
118
+
119
+ expect(listenedPort).toBe(3000);
120
+ });
121
+
122
+ it("returns a ServerHandle from the adapter", async () => {
123
+ const handle = await createApp({
124
+ server: createMockServerAdapter(),
125
+ routes: [],
126
+ }).listen(3000);
127
+
128
+ expect(typeof handle.close).toBe("function");
129
+ });
130
+ });
131
+
132
+ describe("app.getNativeServer()", () => {
133
+ it("delegates to the server adapter getNativeServer()", () => {
134
+ const nativeInstance = { framework: "test" };
135
+ const server = createMockServerAdapter({
136
+ getNativeServer: () => nativeInstance,
137
+ });
138
+
139
+ const app = createApp({ server, routes: [] });
140
+ expect(app.getNativeServer()).toBe(nativeInstance);
141
+ });
142
+
143
+ it("returns null when adapter has no getNativeServer", () => {
144
+ const server = createMockServerAdapter();
145
+ delete (server as unknown as Record<string, unknown>).getNativeServer;
146
+
147
+ const app = createApp({ server, routes: [] });
148
+ expect(app.getNativeServer()).toBeNull();
149
+ });
150
+ });
151
+
152
+ // ─── Plugin Lifecycle ────────────────────────────────────────
153
+
154
+ describe("plugin lifecycle hooks", () => {
155
+ it("calls onStart hooks before the server starts listening", async () => {
156
+ const callOrder: string[] = [];
157
+
158
+ const server = createMockServerAdapter({
159
+ listen: async (_port: number) => {
160
+ callOrder.push("server.listen");
161
+ return { close: async () => {} };
162
+ },
163
+ });
164
+
165
+ const plugin = createMockPlugin({
166
+ onStart: async (_app: AppInstance) => {
167
+ callOrder.push("plugin.onStart");
168
+ },
169
+ });
170
+
171
+ const app = createApp({ server, routes: [], plugins: [plugin] });
172
+ await app.listen(3000);
173
+
174
+ expect(callOrder[0]).toBe("plugin.onStart");
175
+ expect(callOrder[1]).toBe("server.listen");
176
+ });
177
+
178
+ it("calls onReady hooks after the server is listening", async () => {
179
+ const callOrder: string[] = [];
180
+
181
+ const server = createMockServerAdapter({
182
+ listen: async (_port: number) => {
183
+ callOrder.push("server.listen");
184
+ return { close: async () => {} };
185
+ },
186
+ });
187
+
188
+ const plugin = createMockPlugin({
189
+ onReady: async (_app: AppInstance) => {
190
+ callOrder.push("plugin.onReady");
191
+ },
192
+ });
193
+
194
+ const app = createApp({ server, routes: [], plugins: [plugin] });
195
+ await app.listen(3000);
196
+
197
+ expect(callOrder[0]).toBe("server.listen");
198
+ expect(callOrder[1]).toBe("plugin.onReady");
199
+ });
200
+
201
+ it("calls onStop hooks during app.close()", async () => {
202
+ let stopCalled = false;
203
+
204
+ const plugin = createMockPlugin({
205
+ onStop: async (_app: AppInstance) => {
206
+ stopCalled = true;
207
+ },
208
+ });
209
+
210
+ const app = createApp({
211
+ server: createMockServerAdapter(),
212
+ routes: [],
213
+ plugins: [plugin],
214
+ });
215
+
216
+ await app.listen(3000);
217
+ await app.close();
218
+
219
+ expect(stopCalled).toBe(true);
220
+ });
221
+
222
+ it("calls plugin hooks in correct order: onStart → listen → onReady", async () => {
223
+ const callOrder: string[] = [];
224
+
225
+ const server = createMockServerAdapter({
226
+ listen: async (_port: number) => {
227
+ callOrder.push("listen");
228
+ return { close: async () => {} };
229
+ },
230
+ });
231
+
232
+ const plugin = createMockPlugin({
233
+ onStart: async () => {
234
+ callOrder.push("onStart");
235
+ },
236
+ onReady: async () => {
237
+ callOrder.push("onReady");
238
+ },
239
+ onStop: async () => {
240
+ callOrder.push("onStop");
241
+ },
242
+ });
243
+
244
+ const app = createApp({ server, routes: [], plugins: [plugin] });
245
+ await app.listen(3000);
246
+ await app.close();
247
+
248
+ expect(callOrder).toEqual(["onStart", "listen", "onReady", "onStop"]);
249
+ });
250
+
251
+ it("calls multiple plugin hooks in registration order", async () => {
252
+ const callOrder: string[] = [];
253
+
254
+ const pluginA = createMockPlugin({
255
+ name: "plugin-a",
256
+ onStart: async () => {
257
+ callOrder.push("a.onStart");
258
+ },
259
+ onReady: async () => {
260
+ callOrder.push("a.onReady");
261
+ },
262
+ });
263
+
264
+ const pluginB = createMockPlugin({
265
+ name: "plugin-b",
266
+ onStart: async () => {
267
+ callOrder.push("b.onStart");
268
+ },
269
+ onReady: async () => {
270
+ callOrder.push("b.onReady");
271
+ },
272
+ });
273
+
274
+ const app = createApp({
275
+ server: createMockServerAdapter(),
276
+ routes: [],
277
+ plugins: [pluginA, pluginB],
278
+ });
279
+
280
+ await app.listen(3000);
281
+
282
+ expect(callOrder).toEqual([
283
+ "a.onStart",
284
+ "b.onStart",
285
+ "a.onReady",
286
+ "b.onReady",
287
+ ]);
288
+ });
289
+
290
+ it("passes AppInstance to plugin hooks", async () => {
291
+ let receivedApp: AppInstance | undefined;
292
+
293
+ const plugin = createMockPlugin({
294
+ onStart: async (app: AppInstance) => {
295
+ receivedApp = app;
296
+ },
297
+ });
298
+
299
+ const app = createApp({
300
+ server: createMockServerAdapter(),
301
+ routes: [],
302
+ plugins: [plugin],
303
+ });
304
+
305
+ await app.listen(3000);
306
+
307
+ expect(receivedApp).toBeDefined();
308
+ expect(receivedApp!.name).toBe("mock-server");
309
+ expect(receivedApp!.plugins).toEqual([plugin]);
310
+ expect(receivedApp!.services).toEqual({});
311
+ });
312
+ });
313
+
314
+ // ─── app.close() ─────────────────────────────────────────────
315
+
316
+ describe("app.close()", () => {
317
+ it("closes the server handle", async () => {
318
+ let closed = false;
319
+ const server = createMockServerAdapter({
320
+ listen: async () => ({
321
+ close: async () => {
322
+ closed = true;
323
+ },
324
+ }),
325
+ });
326
+
327
+ const app = createApp({ server, routes: [] });
328
+ await app.listen(3000);
329
+ await app.close();
330
+
331
+ expect(closed).toBe(true);
332
+ });
333
+
334
+ it("calls onStop before closing the server", async () => {
335
+ const callOrder: string[] = [];
336
+
337
+ const server = createMockServerAdapter({
338
+ listen: async () => ({
339
+ close: async () => {
340
+ callOrder.push("server.close");
341
+ },
342
+ }),
343
+ });
344
+
345
+ const plugin = createMockPlugin({
346
+ onStop: async () => {
347
+ callOrder.push("onStop");
348
+ },
349
+ });
350
+
351
+ const app = createApp({ server, routes: [], plugins: [plugin] });
352
+ await app.listen(3000);
353
+ await app.close();
354
+
355
+ expect(callOrder[0]).toBe("onStop");
356
+ expect(callOrder[1]).toBe("server.close");
357
+ });
358
+
359
+ it("is safe to call close() without listen()", async () => {
360
+ const app = createApp({
361
+ server: createMockServerAdapter(),
362
+ routes: [],
363
+ });
364
+
365
+ // Should not throw
366
+ await app.close();
367
+ });
368
+ });
369
+
370
+ // ─── Error Middleware ────────────────────────────────────────
371
+
372
+ describe("createErrorMiddleware", () => {
373
+ const dummyReq: TypoKitRequest = {
374
+ method: "GET",
375
+ path: "/",
376
+ headers: {},
377
+ body: undefined,
378
+ query: {},
379
+ params: {},
380
+ };
381
+
382
+ const dummyCtx: RequestContext = {
383
+ log: {
384
+ trace: () => {},
385
+ debug: () => {},
386
+ info: () => {},
387
+ warn: () => {},
388
+ error: () => {},
389
+ fatal: () => {},
390
+ },
391
+ fail: () => {
392
+ throw new Error("fail");
393
+ },
394
+ services: {},
395
+ requestId: "test-id",
396
+ };
397
+
398
+ it("passes through successful responses", async () => {
399
+ const mw = createErrorMiddleware();
400
+ const response: TypoKitResponse = {
401
+ status: 200,
402
+ headers: {},
403
+ body: { ok: true },
404
+ };
405
+
406
+ const result = await mw(dummyReq, dummyCtx, async () => response);
407
+ expect(result).toBe(response);
408
+ });
409
+
410
+ it("catches AppError and serializes to ErrorResponse", async () => {
411
+ const mw = createErrorMiddleware();
412
+ const error = new NotFoundError("USER_NOT_FOUND", "User not found");
413
+
414
+ const result = await mw(dummyReq, dummyCtx, async () => {
415
+ throw error;
416
+ });
417
+
418
+ expect(result.status).toBe(404);
419
+ expect(result.headers["content-type"]).toBe("application/json");
420
+ const body = result.body as ErrorResponse;
421
+ expect(body.error.code).toBe("USER_NOT_FOUND");
422
+ expect(body.error.message).toBe("User not found");
423
+ });
424
+
425
+ it("converts unknown errors to 500 Internal Server Error", async () => {
426
+ const mw = createErrorMiddleware();
427
+
428
+ const result = await mw(dummyReq, dummyCtx, async () => {
429
+ throw new Error("something broke");
430
+ });
431
+
432
+ expect(result.status).toBe(500);
433
+ expect(result.headers["content-type"]).toBe("application/json");
434
+ const body = result.body as { error: { code: string; message: string } };
435
+ expect(body.error.code).toBe("INTERNAL_SERVER_ERROR");
436
+ expect(body.error.message).toBe("Internal Server Error");
437
+ });
438
+ });
package/src/app.ts ADDED
@@ -0,0 +1,118 @@
1
+ // @typokit/core — App Factory (createApp)
2
+
3
+ import type {
4
+ ServerHandle,
5
+ Logger,
6
+ TypoKitRequest,
7
+ RequestContext,
8
+ TypoKitResponse,
9
+ } from "@typokit/types";
10
+ import type { ServerAdapter } from "./adapters/server.js";
11
+ import type { TypoKitPlugin, AppInstance } from "./plugin.js";
12
+ import type { MiddlewareEntry } from "./middleware.js";
13
+ import { createErrorMiddleware } from "./error-middleware.js";
14
+
15
+ // ─── Route Group ─────────────────────────────────────────────
16
+
17
+ /** A group of route handlers registered under a common prefix */
18
+ export interface RouteGroup {
19
+ prefix: string;
20
+ handlers: Record<string, unknown>;
21
+ middleware?: MiddlewareEntry[];
22
+ }
23
+
24
+ // ─── createApp Options ───────────────────────────────────────
25
+
26
+ /** Options accepted by the createApp() factory function */
27
+ export interface CreateAppOptions {
28
+ server: ServerAdapter;
29
+ middleware?: MiddlewareEntry[];
30
+ routes: RouteGroup[];
31
+ plugins?: TypoKitPlugin[];
32
+ logging?: Partial<Logger>;
33
+ telemetry?: Record<string, unknown>;
34
+ }
35
+
36
+ // ─── App Interface ───────────────────────────────────────────
37
+
38
+ /** The application instance returned by createApp() */
39
+ export interface TypoKitApp {
40
+ /** Start the server on the given port */
41
+ listen(port: number): Promise<ServerHandle>;
42
+ /** Expose the underlying server framework instance */
43
+ getNativeServer(): unknown;
44
+ /** Gracefully shut down the application */
45
+ close(): Promise<void>;
46
+ /** The auto-registered error middleware for use by server adapters */
47
+ errorMiddleware: (
48
+ req: TypoKitRequest,
49
+ ctx: RequestContext,
50
+ next: () => Promise<TypoKitResponse>,
51
+ ) => Promise<TypoKitResponse>;
52
+ }
53
+
54
+ // ─── createApp Factory ──────────────────────────────────────
55
+
56
+ /**
57
+ * Create a TypoKit application from a server adapter, middleware,
58
+ * routes, and plugins.
59
+ */
60
+ export function createApp(options: CreateAppOptions): TypoKitApp {
61
+ const { server, plugins = [] } = options;
62
+
63
+ const appInstance: AppInstance = {
64
+ name: server.name,
65
+ plugins,
66
+ services: {},
67
+ };
68
+
69
+ // Auto-register error middleware for the request pipeline
70
+ const errorMiddleware = createErrorMiddleware();
71
+
72
+ let serverHandle: ServerHandle | null = null;
73
+
74
+ const app: TypoKitApp = {
75
+ errorMiddleware,
76
+
77
+ async listen(port: number): Promise<ServerHandle> {
78
+ // 1. Call plugin onStart hooks
79
+ for (const plugin of plugins) {
80
+ if (plugin.onStart) {
81
+ await plugin.onStart(appInstance);
82
+ }
83
+ }
84
+
85
+ // 2. Delegate to server adapter
86
+ serverHandle = await server.listen(port);
87
+
88
+ // 3. Call plugin onReady hooks
89
+ for (const plugin of plugins) {
90
+ if (plugin.onReady) {
91
+ await plugin.onReady(appInstance);
92
+ }
93
+ }
94
+
95
+ return serverHandle;
96
+ },
97
+
98
+ getNativeServer(): unknown {
99
+ return server.getNativeServer?.() ?? null;
100
+ },
101
+
102
+ async close(): Promise<void> {
103
+ // Call plugin onStop hooks
104
+ for (const plugin of plugins) {
105
+ if (plugin.onStop) {
106
+ await plugin.onStop(appInstance);
107
+ }
108
+ }
109
+
110
+ if (serverHandle) {
111
+ await serverHandle.close();
112
+ serverHandle = null;
113
+ }
114
+ },
115
+ };
116
+
117
+ return app;
118
+ }