@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,419 @@
1
+ // @typokit/core — Hook System Tests
2
+
3
+ import { describe, it, expect } from "@rstest/core";
4
+ import {
5
+ AsyncSeriesHookImpl,
6
+ createBuildPipeline,
7
+ getPipelineTaps,
8
+ BUILD_HOOK_PHASES,
9
+ } from "./hooks.js";
10
+
11
+ import type {
12
+ BuildContext,
13
+ BuildResult,
14
+ GeneratedOutput,
15
+ } from "@typokit/types";
16
+ import type { TypoKitPlugin } from "./plugin.js";
17
+
18
+ // ─── AsyncSeriesHookImpl Tests ──────────────────────────────
19
+
20
+ describe("AsyncSeriesHookImpl", () => {
21
+ it("should execute taps in registration order", async () => {
22
+ const hook = new AsyncSeriesHookImpl<[string]>();
23
+ const order: string[] = [];
24
+
25
+ hook.tap("first", () => {
26
+ order.push("first");
27
+ });
28
+ hook.tap("second", () => {
29
+ order.push("second");
30
+ });
31
+ hook.tap("third", () => {
32
+ order.push("third");
33
+ });
34
+
35
+ await hook.call("test");
36
+
37
+ expect(order).toEqual(["first", "second", "third"]);
38
+ });
39
+
40
+ it("should pass arguments to all taps", async () => {
41
+ const hook = new AsyncSeriesHookImpl<[number, string]>();
42
+ const received: Array<[number, string]> = [];
43
+
44
+ hook.tap("a", (n, s) => {
45
+ received.push([n, s]);
46
+ });
47
+ hook.tap("b", (n, s) => {
48
+ received.push([n, s]);
49
+ });
50
+
51
+ await hook.call(42, "hello");
52
+
53
+ expect(received).toEqual([
54
+ [42, "hello"],
55
+ [42, "hello"],
56
+ ]);
57
+ });
58
+
59
+ it("should handle async taps", async () => {
60
+ const hook = new AsyncSeriesHookImpl<[string]>();
61
+ const order: string[] = [];
62
+
63
+ hook.tap("sync", () => {
64
+ order.push("sync");
65
+ });
66
+ hook.tap("async", async () => {
67
+ await new Promise<void>((resolve) => {
68
+ const g = globalThis as unknown as {
69
+ setTimeout: (fn: () => void, ms: number) => unknown;
70
+ };
71
+ g.setTimeout(resolve, 10);
72
+ });
73
+ order.push("async");
74
+ });
75
+ hook.tap("after", () => {
76
+ order.push("after");
77
+ });
78
+
79
+ await hook.call("test");
80
+
81
+ expect(order).toEqual(["sync", "async", "after"]);
82
+ });
83
+
84
+ it("should work with zero taps", async () => {
85
+ const hook = new AsyncSeriesHookImpl<[string]>();
86
+ // Should not throw
87
+ await hook.call("test");
88
+ });
89
+
90
+ it("should expose taps array for introspection", () => {
91
+ const hook = new AsyncSeriesHookImpl<[string]>();
92
+ hook.tap("myPlugin", () => {});
93
+ hook.tap("otherPlugin", () => {});
94
+
95
+ expect(hook.taps.length).toBe(2);
96
+ expect(hook.taps[0].name).toBe("myPlugin");
97
+ expect(hook.taps[1].name).toBe("otherPlugin");
98
+ });
99
+
100
+ it("should allow multiple taps with the same name", async () => {
101
+ const hook = new AsyncSeriesHookImpl<[string]>();
102
+ const calls: number[] = [];
103
+
104
+ hook.tap("plugin", () => {
105
+ calls.push(1);
106
+ });
107
+ hook.tap("plugin", () => {
108
+ calls.push(2);
109
+ });
110
+
111
+ await hook.call("test");
112
+
113
+ expect(calls).toEqual([1, 2]);
114
+ });
115
+
116
+ it("should propagate errors from taps", async () => {
117
+ const hook = new AsyncSeriesHookImpl<[string]>();
118
+
119
+ hook.tap("good", () => {});
120
+ hook.tap("bad", () => {
121
+ throw new Error("tap failed");
122
+ });
123
+ hook.tap("unreached", () => {});
124
+
125
+ let caught: Error | null = null;
126
+ try {
127
+ await hook.call("test");
128
+ } catch (e) {
129
+ caught = e as Error;
130
+ }
131
+
132
+ expect(caught).not.toBeNull();
133
+ expect(caught?.message).toBe("tap failed");
134
+ });
135
+
136
+ it("should allow taps to mutate shared context objects", async () => {
137
+ const hook = new AsyncSeriesHookImpl<[{ items: string[] }]>();
138
+
139
+ hook.tap("first", (ctx) => {
140
+ ctx.items.push("a");
141
+ });
142
+ hook.tap("second", (ctx) => {
143
+ ctx.items.push("b");
144
+ });
145
+
146
+ const context = { items: [] as string[] };
147
+ await hook.call(context);
148
+
149
+ expect(context.items).toEqual(["a", "b"]);
150
+ });
151
+ });
152
+
153
+ // ─── createBuildPipeline Tests ──────────────────────────────
154
+
155
+ describe("createBuildPipeline", () => {
156
+ it("should create a pipeline with all 6 hook phases", () => {
157
+ const pipeline = createBuildPipeline();
158
+
159
+ expect(pipeline.hooks.beforeTransform).toBeInstanceOf(AsyncSeriesHookImpl);
160
+ expect(pipeline.hooks.afterTypeParse).toBeInstanceOf(AsyncSeriesHookImpl);
161
+ expect(pipeline.hooks.afterValidators).toBeInstanceOf(AsyncSeriesHookImpl);
162
+ expect(pipeline.hooks.afterRouteTable).toBeInstanceOf(AsyncSeriesHookImpl);
163
+ expect(pipeline.hooks.emit).toBeInstanceOf(AsyncSeriesHookImpl);
164
+ expect(pipeline.hooks.done).toBeInstanceOf(AsyncSeriesHookImpl);
165
+ });
166
+
167
+ it("should create independent hooks (not shared instances)", () => {
168
+ const pipeline = createBuildPipeline();
169
+ expect(pipeline.hooks.beforeTransform).not.toBe(pipeline.hooks.done);
170
+ });
171
+
172
+ it("should fire hooks at correct pipeline phases with typed context", async () => {
173
+ const pipeline = createBuildPipeline();
174
+ const phases: string[] = [];
175
+
176
+ const buildCtx: BuildContext = {
177
+ rootDir: "/test",
178
+ outDir: "/test/dist",
179
+ dev: false,
180
+ outputs: [],
181
+ };
182
+
183
+ pipeline.hooks.beforeTransform.tap("test", (ctx) => {
184
+ phases.push("beforeTransform");
185
+ expect(ctx.rootDir).toBe("/test");
186
+ });
187
+
188
+ pipeline.hooks.afterTypeParse.tap("test", (types, ctx) => {
189
+ phases.push("afterTypeParse");
190
+ expect(types).toBeDefined();
191
+ expect(ctx.rootDir).toBe("/test");
192
+ });
193
+
194
+ pipeline.hooks.afterValidators.tap("test", (outputs, ctx) => {
195
+ phases.push("afterValidators");
196
+ expect(Array.isArray(outputs)).toBe(true);
197
+ expect(ctx.rootDir).toBe("/test");
198
+ });
199
+
200
+ pipeline.hooks.afterRouteTable.tap("test", (_table, ctx) => {
201
+ phases.push("afterRouteTable");
202
+ expect(ctx.rootDir).toBe("/test");
203
+ });
204
+
205
+ pipeline.hooks.emit.tap("test", (outputs, ctx) => {
206
+ phases.push("emit");
207
+ expect(Array.isArray(outputs)).toBe(true);
208
+ expect(ctx.rootDir).toBe("/test");
209
+ });
210
+
211
+ const buildResult: BuildResult = {
212
+ success: true,
213
+ outputs: [],
214
+ duration: 100,
215
+ errors: [],
216
+ };
217
+
218
+ pipeline.hooks.done.tap("test", (result) => {
219
+ phases.push("done");
220
+ expect(result.success).toBe(true);
221
+ });
222
+
223
+ // Simulate build pipeline execution order
224
+ await pipeline.hooks.beforeTransform.call(buildCtx);
225
+ await pipeline.hooks.afterTypeParse.call({}, buildCtx);
226
+ await pipeline.hooks.afterValidators.call([], buildCtx);
227
+ await pipeline.hooks.afterRouteTable.call(
228
+ {
229
+ segment: "",
230
+ children: {},
231
+ handlers: {},
232
+ },
233
+ buildCtx,
234
+ );
235
+ await pipeline.hooks.emit.call([], buildCtx);
236
+ await pipeline.hooks.done.call(buildResult);
237
+
238
+ expect(phases).toEqual([
239
+ "beforeTransform",
240
+ "afterTypeParse",
241
+ "afterValidators",
242
+ "afterRouteTable",
243
+ "emit",
244
+ "done",
245
+ ]);
246
+ });
247
+
248
+ it("should allow multiple plugins to tap the same hook", async () => {
249
+ const pipeline = createBuildPipeline();
250
+ const calls: string[] = [];
251
+
252
+ const buildCtx: BuildContext = {
253
+ rootDir: "/test",
254
+ outDir: "/test/dist",
255
+ dev: false,
256
+ outputs: [],
257
+ };
258
+
259
+ // Plugin A
260
+ pipeline.hooks.beforeTransform.tap("pluginA", () => {
261
+ calls.push("A:beforeTransform");
262
+ });
263
+ pipeline.hooks.emit.tap("pluginA", () => {
264
+ calls.push("A:emit");
265
+ });
266
+
267
+ // Plugin B
268
+ pipeline.hooks.beforeTransform.tap("pluginB", () => {
269
+ calls.push("B:beforeTransform");
270
+ });
271
+ pipeline.hooks.emit.tap("pluginB", () => {
272
+ calls.push("B:emit");
273
+ });
274
+
275
+ await pipeline.hooks.beforeTransform.call(buildCtx);
276
+ await pipeline.hooks.emit.call([], buildCtx);
277
+
278
+ expect(calls).toEqual([
279
+ "A:beforeTransform",
280
+ "B:beforeTransform",
281
+ "A:emit",
282
+ "B:emit",
283
+ ]);
284
+ });
285
+
286
+ it("should work with TypoKitPlugin.onBuild() interface", async () => {
287
+ const pipeline = createBuildPipeline();
288
+ const tapped: string[] = [];
289
+
290
+ const plugin: TypoKitPlugin = {
291
+ name: "test-plugin",
292
+ onBuild(p) {
293
+ p.hooks.beforeTransform.tap("test-plugin", () => {
294
+ tapped.push("beforeTransform");
295
+ });
296
+ p.hooks.done.tap("test-plugin", () => {
297
+ tapped.push("done");
298
+ });
299
+ },
300
+ };
301
+
302
+ // Plugins register taps via onBuild
303
+ plugin.onBuild?.(pipeline);
304
+
305
+ const buildCtx: BuildContext = {
306
+ rootDir: "/test",
307
+ outDir: "/test/dist",
308
+ dev: false,
309
+ outputs: [],
310
+ };
311
+
312
+ await pipeline.hooks.beforeTransform.call(buildCtx);
313
+ await pipeline.hooks.done.call({
314
+ success: true,
315
+ outputs: [],
316
+ duration: 50,
317
+ errors: [],
318
+ });
319
+
320
+ expect(tapped).toEqual(["beforeTransform", "done"]);
321
+ });
322
+
323
+ it("should allow emit hook to add outputs", async () => {
324
+ const pipeline = createBuildPipeline();
325
+
326
+ const buildCtx: BuildContext = {
327
+ rootDir: "/test",
328
+ outDir: "/test/dist",
329
+ dev: false,
330
+ outputs: [],
331
+ };
332
+
333
+ const emittedOutputs: GeneratedOutput[] = [];
334
+
335
+ pipeline.hooks.emit.tap("custom-plugin", (outputs) => {
336
+ const newOutput: GeneratedOutput = {
337
+ filePath: "custom/output.ts",
338
+ content: "// generated",
339
+ overwrite: true,
340
+ };
341
+ outputs.push(newOutput);
342
+ emittedOutputs.push(newOutput);
343
+ });
344
+
345
+ const outputs: GeneratedOutput[] = [];
346
+ await pipeline.hooks.emit.call(outputs, buildCtx);
347
+
348
+ expect(outputs.length).toBe(1);
349
+ expect(outputs[0].filePath).toBe("custom/output.ts");
350
+ expect(emittedOutputs.length).toBe(1);
351
+ });
352
+ });
353
+
354
+ // ─── getPipelineTaps Tests ──────────────────────────────────
355
+
356
+ describe("getPipelineTaps", () => {
357
+ it("should return empty array for fresh pipeline", () => {
358
+ const pipeline = createBuildPipeline();
359
+ const taps = getPipelineTaps(pipeline);
360
+ expect(taps).toEqual([]);
361
+ });
362
+
363
+ it("should return all registered taps with hook name and order", () => {
364
+ const pipeline = createBuildPipeline();
365
+
366
+ pipeline.hooks.beforeTransform.tap("pluginA", () => {});
367
+ pipeline.hooks.beforeTransform.tap("pluginB", () => {});
368
+ pipeline.hooks.emit.tap("pluginA", () => {});
369
+ pipeline.hooks.done.tap("pluginC", () => {});
370
+
371
+ const taps = getPipelineTaps(pipeline);
372
+
373
+ expect(taps).toEqual([
374
+ { hookName: "beforeTransform", tapName: "pluginA", order: 0 },
375
+ { hookName: "beforeTransform", tapName: "pluginB", order: 1 },
376
+ { hookName: "emit", tapName: "pluginA", order: 0 },
377
+ { hookName: "done", tapName: "pluginC", order: 0 },
378
+ ]);
379
+ });
380
+
381
+ it("should respect registration order within each hook", () => {
382
+ const pipeline = createBuildPipeline();
383
+
384
+ pipeline.hooks.afterTypeParse.tap("z-plugin", () => {});
385
+ pipeline.hooks.afterTypeParse.tap("a-plugin", () => {});
386
+ pipeline.hooks.afterTypeParse.tap("m-plugin", () => {});
387
+
388
+ const taps = getPipelineTaps(pipeline);
389
+
390
+ expect(taps[0].tapName).toBe("z-plugin");
391
+ expect(taps[0].order).toBe(0);
392
+ expect(taps[1].tapName).toBe("a-plugin");
393
+ expect(taps[1].order).toBe(1);
394
+ expect(taps[2].tapName).toBe("m-plugin");
395
+ expect(taps[2].order).toBe(2);
396
+ });
397
+ });
398
+
399
+ // ─── BUILD_HOOK_PHASES Tests ────────────────────────────────
400
+
401
+ describe("BUILD_HOOK_PHASES", () => {
402
+ it("should list all 6 phases in execution order", () => {
403
+ expect(BUILD_HOOK_PHASES).toEqual([
404
+ "beforeTransform",
405
+ "afterTypeParse",
406
+ "afterValidators",
407
+ "afterRouteTable",
408
+ "emit",
409
+ "done",
410
+ ]);
411
+ });
412
+
413
+ it("should match the keys in BuildPipeline.hooks", () => {
414
+ const pipeline = createBuildPipeline();
415
+ for (const phase of BUILD_HOOK_PHASES) {
416
+ expect(pipeline.hooks[phase]).toBeInstanceOf(AsyncSeriesHookImpl);
417
+ }
418
+ });
419
+ });
package/src/hooks.ts ADDED
@@ -0,0 +1,114 @@
1
+ // @typokit/core — Tapable Hook System Implementation
2
+
3
+ import type {
4
+ BuildContext,
5
+ BuildResult,
6
+ CompiledRouteTable,
7
+ GeneratedOutput,
8
+ SchemaTypeMap,
9
+ } from "@typokit/types";
10
+
11
+ // ─── AsyncSeriesHook Implementation ─────────────────────────
12
+
13
+ /** A tap registration entry */
14
+ export interface TapEntry<T extends unknown[]> {
15
+ name: string;
16
+ fn: (...args: T) => void | Promise<void>;
17
+ }
18
+
19
+ /**
20
+ * Tapable-style async series hook.
21
+ * Hooks execute in registration order; each receives the same args.
22
+ */
23
+ export class AsyncSeriesHookImpl<T extends unknown[]> {
24
+ readonly taps: TapEntry<T>[] = [];
25
+
26
+ /** Register a named tap */
27
+ tap(name: string, fn: (...args: T) => void | Promise<void>): void {
28
+ this.taps.push({ name, fn });
29
+ }
30
+
31
+ /** Execute all taps in series, in registration order */
32
+ async call(...args: T): Promise<void> {
33
+ for (const entry of this.taps) {
34
+ await entry.fn(...args);
35
+ }
36
+ }
37
+ }
38
+
39
+ // ─── Build Pipeline Implementation ──────────────────────────
40
+
41
+ /** Concrete build pipeline with all 6 hook phases */
42
+ export interface BuildPipelineInstance {
43
+ hooks: {
44
+ beforeTransform: AsyncSeriesHookImpl<[BuildContext]>;
45
+ afterTypeParse: AsyncSeriesHookImpl<[SchemaTypeMap, BuildContext]>;
46
+ afterValidators: AsyncSeriesHookImpl<[GeneratedOutput[], BuildContext]>;
47
+ afterRouteTable: AsyncSeriesHookImpl<[CompiledRouteTable, BuildContext]>;
48
+ emit: AsyncSeriesHookImpl<[GeneratedOutput[], BuildContext]>;
49
+ done: AsyncSeriesHookImpl<[BuildResult]>;
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Create a new build pipeline with empty hooks for all 6 phases.
55
+ * Plugins call `onBuild(pipeline)` to tap into specific phases.
56
+ */
57
+ export function createBuildPipeline(): BuildPipelineInstance {
58
+ return {
59
+ hooks: {
60
+ beforeTransform: new AsyncSeriesHookImpl<[BuildContext]>(),
61
+ afterTypeParse: new AsyncSeriesHookImpl<[SchemaTypeMap, BuildContext]>(),
62
+ afterValidators: new AsyncSeriesHookImpl<
63
+ [GeneratedOutput[], BuildContext]
64
+ >(),
65
+ afterRouteTable: new AsyncSeriesHookImpl<
66
+ [CompiledRouteTable, BuildContext]
67
+ >(),
68
+ emit: new AsyncSeriesHookImpl<[GeneratedOutput[], BuildContext]>(),
69
+ done: new AsyncSeriesHookImpl<[BuildResult]>(),
70
+ },
71
+ };
72
+ }
73
+
74
+ /** Hook phase names in execution order */
75
+ export const BUILD_HOOK_PHASES = [
76
+ "beforeTransform",
77
+ "afterTypeParse",
78
+ "afterValidators",
79
+ "afterRouteTable",
80
+ "emit",
81
+ "done",
82
+ ] as const;
83
+
84
+ export type BuildHookPhase = (typeof BUILD_HOOK_PHASES)[number];
85
+
86
+ /** Metadata about a registered tap for introspection */
87
+ export interface TapInfo {
88
+ hookName: string;
89
+ tapName: string;
90
+ order: number;
91
+ }
92
+
93
+ /**
94
+ * Get introspection info about all registered taps in a build pipeline.
95
+ * Used by `typokit inspect build-pipeline --json`.
96
+ */
97
+ export function getPipelineTaps(pipeline: BuildPipelineInstance): TapInfo[] {
98
+ const taps: TapInfo[] = [];
99
+
100
+ for (const phase of BUILD_HOOK_PHASES) {
101
+ const hook = pipeline.hooks[phase];
102
+ // Use type assertion since AsyncSeriesHookImpl always has taps
103
+ const hookImpl = hook as AsyncSeriesHookImpl<unknown[]>;
104
+ for (let i = 0; i < hookImpl.taps.length; i++) {
105
+ taps.push({
106
+ hookName: phase,
107
+ tapName: hookImpl.taps[i].name,
108
+ order: i,
109
+ });
110
+ }
111
+ }
112
+
113
+ return taps;
114
+ }
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ // @typokit/core
2
+ export { type ServerAdapter } from "./adapters/server.js";
3
+ export {
4
+ type DatabaseAdapter,
5
+ type DatabaseState,
6
+ type TableState,
7
+ type ColumnState,
8
+ } from "./adapters/database.js";
9
+ export {
10
+ type AsyncSeriesHook,
11
+ type BuildPipeline,
12
+ type CliCommand,
13
+ type InspectEndpoint,
14
+ type AppInstance,
15
+ type TypoKitPlugin,
16
+ } from "./plugin.js";
17
+ export {
18
+ AsyncSeriesHookImpl,
19
+ createBuildPipeline,
20
+ getPipelineTaps,
21
+ BUILD_HOOK_PHASES,
22
+ type BuildPipelineInstance,
23
+ type TapEntry,
24
+ type TapInfo,
25
+ type BuildHookPhase,
26
+ } from "./hooks.js";
27
+ export {
28
+ type MiddlewareInput,
29
+ type Middleware,
30
+ type MiddlewareEntry,
31
+ defineMiddleware,
32
+ createPlaceholderLogger,
33
+ createRequestContext,
34
+ executeMiddlewareChain,
35
+ } from "./middleware.js";
36
+ export {
37
+ type HandlerInput,
38
+ type HandlerFn,
39
+ type HandlerDefs,
40
+ defineHandlers,
41
+ } from "./handler.js";
42
+ export {
43
+ type RouteGroup,
44
+ type CreateAppOptions,
45
+ type TypoKitApp,
46
+ createApp,
47
+ } from "./app.js";
48
+ export {
49
+ type ErrorMiddlewareOptions,
50
+ createErrorMiddleware,
51
+ } from "./error-middleware.js";