@typokit/server-native 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.
package/src/index.ts ADDED
@@ -0,0 +1,450 @@
1
+ // @typokit/server-native — Built-In Server Adapter
2
+ //
3
+ // Zero-dependency native HTTP server that uses the compiled radix tree
4
+ // for O(k) route lookup (k = number of path segments).
5
+
6
+ import type { ServerResponse } from "node:http";
7
+ import type {
8
+ CompiledRoute,
9
+ CompiledRouteTable,
10
+ ErrorResponse,
11
+ HandlerMap,
12
+ HttpMethod,
13
+ MiddlewareChain,
14
+ SerializerMap,
15
+ ServerHandle,
16
+ TypoKitRequest,
17
+ TypoKitResponse,
18
+ ValidatorMap,
19
+ ValidationFieldError,
20
+ } from "@typokit/types";
21
+ import type { ServerAdapter, MiddlewareEntry } from "@typokit/core";
22
+ import { createRequestContext, executeMiddlewareChain } from "@typokit/core";
23
+ import {
24
+ writeResponse as nodeWriteResponse,
25
+ createServer,
26
+ } from "@typokit/platform-node";
27
+
28
+ // ─── Route Lookup ────────────────────────────────────────────
29
+
30
+ interface LookupResult {
31
+ node: CompiledRoute;
32
+ params: Record<string, string>;
33
+ }
34
+
35
+ /** Normalize path: strip trailing slash (keep "/" as-is) */
36
+ function normalizePath(path: string): string {
37
+ if (path.length > 1 && path.endsWith("/")) {
38
+ return path.slice(0, -1);
39
+ }
40
+ return path;
41
+ }
42
+
43
+ /**
44
+ * Traverse the compiled radix tree to find the route node matching the
45
+ * given path segments. Returns the matching node and extracted params,
46
+ * or undefined if no route matches.
47
+ */
48
+ function lookupRoute(
49
+ root: CompiledRoute,
50
+ segments: string[],
51
+ ): LookupResult | undefined {
52
+ let current = root;
53
+ const params: Record<string, string> = {};
54
+
55
+ for (let i = 0; i < segments.length; i++) {
56
+ const seg = segments[i];
57
+
58
+ // 1. Try static child match first (O(1) hash lookup)
59
+ if (current.children?.[seg]) {
60
+ current = current.children[seg];
61
+ continue;
62
+ }
63
+
64
+ // 2. Try parameterized child (:id)
65
+ if (current.paramChild) {
66
+ const paramNode = current.paramChild;
67
+ params[paramNode.paramName] = decodeURIComponent(seg);
68
+ current = paramNode;
69
+ continue;
70
+ }
71
+
72
+ // 3. Try wildcard child (*path) — captures remaining segments
73
+ if (current.wildcardChild) {
74
+ const wildcardNode = current.wildcardChild;
75
+ params[wildcardNode.paramName] = segments
76
+ .slice(i)
77
+ .map(decodeURIComponent)
78
+ .join("/");
79
+ return { node: wildcardNode, params };
80
+ }
81
+
82
+ // No match
83
+ return undefined;
84
+ }
85
+
86
+ return { node: current, params };
87
+ }
88
+
89
+ /** Collect all HTTP methods registered at a given route node */
90
+ function collectMethods(node: CompiledRoute): HttpMethod[] {
91
+ if (!node.handlers) return [];
92
+ return Object.keys(node.handlers) as HttpMethod[];
93
+ }
94
+
95
+ // ─── Request Validation Pipeline ─────────────────────────────
96
+
97
+ /** Build a 400 validation error response matching ErrorResponse schema */
98
+ function validationErrorResponse(
99
+ message: string,
100
+ fields: ValidationFieldError[],
101
+ ): TypoKitResponse {
102
+ const body: ErrorResponse = {
103
+ error: {
104
+ code: "VALIDATION_ERROR",
105
+ message,
106
+ details: { fields },
107
+ },
108
+ };
109
+ return {
110
+ status: 400,
111
+ headers: { "content-type": "application/json" },
112
+ body,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Run the request validation pipeline for params, query, and body.
118
+ * Returns a 400 TypoKitResponse on validation failure, or undefined if all pass.
119
+ */
120
+ function runValidators(
121
+ routeHandler: {
122
+ validators?: { params?: string; query?: string; body?: string };
123
+ },
124
+ validatorMap: ValidatorMap | null,
125
+ params: Record<string, string>,
126
+ query: Record<string, string | string[] | undefined>,
127
+ body: unknown,
128
+ ): TypoKitResponse | undefined {
129
+ if (!validatorMap || !routeHandler.validators) {
130
+ return undefined;
131
+ }
132
+
133
+ const allErrors: ValidationFieldError[] = [];
134
+
135
+ // Validate params
136
+ if (routeHandler.validators.params) {
137
+ const validator = validatorMap[routeHandler.validators.params];
138
+ if (validator) {
139
+ const result = validator(params);
140
+ if (!result.success && result.errors) {
141
+ for (const e of result.errors) {
142
+ allErrors.push({
143
+ path: `params.${e.path}`,
144
+ expected: e.expected,
145
+ actual: e.actual,
146
+ });
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ // Validate query
153
+ if (routeHandler.validators.query) {
154
+ const validator = validatorMap[routeHandler.validators.query];
155
+ if (validator) {
156
+ const result = validator(query);
157
+ if (!result.success && result.errors) {
158
+ for (const e of result.errors) {
159
+ allErrors.push({
160
+ path: `query.${e.path}`,
161
+ expected: e.expected,
162
+ actual: e.actual,
163
+ });
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ // Validate body
170
+ if (routeHandler.validators.body) {
171
+ const validator = validatorMap[routeHandler.validators.body];
172
+ if (validator) {
173
+ const result = validator(body);
174
+ if (!result.success && result.errors) {
175
+ for (const e of result.errors) {
176
+ allErrors.push({
177
+ path: `body.${e.path}`,
178
+ expected: e.expected,
179
+ actual: e.actual,
180
+ });
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ if (allErrors.length > 0) {
187
+ return validationErrorResponse("Request validation failed", allErrors);
188
+ }
189
+
190
+ return undefined;
191
+ }
192
+
193
+ // ─── Response Serialization Pipeline ──────────────────────────
194
+
195
+ /**
196
+ * Serialize the response body using a compiled fast-json-stringify schema
197
+ * if available, otherwise fall back to the default (JSON.stringify via writeResponse).
198
+ * Automatically sets Content-Type to application/json for JSON bodies.
199
+ */
200
+ function serializeResponse(
201
+ response: TypoKitResponse,
202
+ serializerRef: string | undefined,
203
+ serializerMap: SerializerMap | null,
204
+ ): TypoKitResponse {
205
+ // Nothing to serialize for null/undefined/string/Buffer bodies
206
+ if (
207
+ response.body === null ||
208
+ response.body === undefined ||
209
+ typeof response.body === "string"
210
+ ) {
211
+ return response;
212
+ }
213
+
214
+ // Ensure content-type is set for JSON bodies
215
+ const headers = { ...response.headers };
216
+ if (!headers["content-type"]) {
217
+ headers["content-type"] = "application/json";
218
+ }
219
+
220
+ // Try compiled serializer first
221
+ if (serializerRef && serializerMap) {
222
+ const serializer = serializerMap[serializerRef];
223
+ if (serializer) {
224
+ return {
225
+ ...response,
226
+ headers,
227
+ body: serializer(response.body),
228
+ };
229
+ }
230
+ }
231
+
232
+ // Fall back to JSON.stringify
233
+ return {
234
+ ...response,
235
+ headers,
236
+ body: JSON.stringify(response.body),
237
+ };
238
+ }
239
+
240
+ // ─── Native Server Adapter ───────────────────────────────────
241
+
242
+ interface NativeServerState {
243
+ routeTable: CompiledRouteTable | null;
244
+ handlerMap: HandlerMap | null;
245
+ middlewareChain: MiddlewareChain | null;
246
+ validatorMap: ValidatorMap | null;
247
+ serializerMap: SerializerMap | null;
248
+ }
249
+
250
+ /**
251
+ * Create the native server adapter — TypoKit's built-in HTTP server.
252
+ *
253
+ * ```ts
254
+ * import { nativeServer } from "@typokit/server-native";
255
+ * const adapter = nativeServer();
256
+ * adapter.registerRoutes(routeTable, handlerMap, middlewareChain, validatorMap);
257
+ * const handle = await adapter.listen(3000);
258
+ * ```
259
+ */
260
+ export function nativeServer(): ServerAdapter {
261
+ const state: NativeServerState = {
262
+ routeTable: null,
263
+ handlerMap: null,
264
+ middlewareChain: null,
265
+ validatorMap: null,
266
+ serializerMap: null,
267
+ };
268
+
269
+ let nativeServerInstance: ReturnType<typeof createServer> | null = null;
270
+
271
+ /** Handle a single incoming request */
272
+ async function handleRequest(req: TypoKitRequest): Promise<TypoKitResponse> {
273
+ if (!state.routeTable || !state.handlerMap) {
274
+ return {
275
+ status: 500,
276
+ headers: { "content-type": "application/json" },
277
+ body: { error: "Server not configured" },
278
+ };
279
+ }
280
+
281
+ // Normalize trailing slashes
282
+ const path = normalizePath(req.path);
283
+ const segments = path === "/" ? [] : path.slice(1).split("/");
284
+
285
+ const result = lookupRoute(state.routeTable, segments);
286
+
287
+ // 404 — no route matches
288
+ if (!result) {
289
+ return {
290
+ status: 404,
291
+ headers: { "content-type": "application/json" },
292
+ body: {
293
+ error: "Not Found",
294
+ message: `No route matches ${req.method} ${req.path}`,
295
+ },
296
+ };
297
+ }
298
+
299
+ const { node, params } = result;
300
+ const method = req.method;
301
+
302
+ // 405 — route exists but method not allowed
303
+ if (!node.handlers?.[method]) {
304
+ const allowed = collectMethods(node);
305
+ if (allowed.length === 0) {
306
+ return {
307
+ status: 404,
308
+ headers: { "content-type": "application/json" },
309
+ body: {
310
+ error: "Not Found",
311
+ message: `No route matches ${req.method} ${req.path}`,
312
+ },
313
+ };
314
+ }
315
+ return {
316
+ status: 405,
317
+ headers: {
318
+ "content-type": "application/json",
319
+ allow: allowed.join(", "),
320
+ },
321
+ body: {
322
+ error: "Method Not Allowed",
323
+ message: `${method} not allowed. Use: ${allowed.join(", ")}`,
324
+ },
325
+ };
326
+ }
327
+
328
+ const routeHandler = node.handlers[method]!;
329
+
330
+ // ── Request Validation Pipeline ──
331
+ const validationError = runValidators(
332
+ routeHandler,
333
+ state.validatorMap,
334
+ params,
335
+ req.query,
336
+ req.body,
337
+ );
338
+ if (validationError) {
339
+ return validationError;
340
+ }
341
+
342
+ const handlerFn = state.handlerMap[routeHandler.ref];
343
+
344
+ if (!handlerFn) {
345
+ return {
346
+ status: 500,
347
+ headers: { "content-type": "application/json" },
348
+ body: {
349
+ error: "Internal Server Error",
350
+ message: `Handler not found: ${routeHandler.ref}`,
351
+ },
352
+ };
353
+ }
354
+
355
+ // Inject extracted params into the request
356
+ const enrichedReq: TypoKitRequest = { ...req, params, path };
357
+
358
+ // Create request context
359
+ let ctx = createRequestContext();
360
+
361
+ // Execute middleware chain if present
362
+ if (state.middlewareChain && state.middlewareChain.entries.length > 0) {
363
+ const entries: MiddlewareEntry[] = state.middlewareChain.entries.map(
364
+ (e) => ({
365
+ name: e.name,
366
+ middleware: {
367
+ handler: async (input) => {
368
+ const mwReq: TypoKitRequest = {
369
+ method: enrichedReq.method,
370
+ path: enrichedReq.path,
371
+ headers: input.headers,
372
+ body: input.body,
373
+ query: input.query,
374
+ params: input.params,
375
+ };
376
+ const response = await e.handler(mwReq, input.ctx, async () => {
377
+ return { status: 200, headers: {}, body: null };
378
+ });
379
+ return response as unknown as Record<string, unknown>;
380
+ },
381
+ },
382
+ }),
383
+ );
384
+
385
+ ctx = await executeMiddlewareChain(enrichedReq, ctx, entries);
386
+ }
387
+
388
+ // Call the handler
389
+ const response = await handlerFn(enrichedReq, ctx);
390
+
391
+ // ── Response Serialization Pipeline ──
392
+ return serializeResponse(
393
+ response,
394
+ routeHandler.serializer,
395
+ state.serializerMap,
396
+ );
397
+ }
398
+
399
+ const adapter: ServerAdapter = {
400
+ name: "native",
401
+
402
+ registerRoutes(
403
+ routeTable: CompiledRouteTable,
404
+ handlerMap: HandlerMap,
405
+ middlewareChain: MiddlewareChain,
406
+ validatorMap?: ValidatorMap,
407
+ serializerMap?: SerializerMap,
408
+ ): void {
409
+ state.routeTable = routeTable;
410
+ state.handlerMap = handlerMap;
411
+ state.middlewareChain = middlewareChain;
412
+ state.validatorMap = validatorMap ?? null;
413
+ state.serializerMap = serializerMap ?? null;
414
+ },
415
+
416
+ async listen(port: number): Promise<ServerHandle> {
417
+ nativeServerInstance = createServer(handleRequest);
418
+ return nativeServerInstance.listen(port);
419
+ },
420
+
421
+ normalizeRequest(raw: unknown): TypoKitRequest {
422
+ // Synchronous normalization from an already-parsed request object.
423
+ // For internal use; the actual async normalization from IncomingMessage
424
+ // happens inside the server's request handler via platform-node.
425
+ const r = raw as TypoKitRequest;
426
+ return {
427
+ method: r.method,
428
+ path: r.path,
429
+ headers: r.headers ?? {},
430
+ body: r.body,
431
+ query: r.query ?? {},
432
+ params: r.params ?? {},
433
+ };
434
+ },
435
+
436
+ writeResponse(raw: unknown, response: TypoKitResponse): void {
437
+ nodeWriteResponse(raw as ServerResponse, response);
438
+ },
439
+
440
+ getNativeServer(): unknown {
441
+ return nativeServerInstance?.server ?? null;
442
+ },
443
+ };
444
+
445
+ return adapter;
446
+ }
447
+
448
+ // Re-export for convenience
449
+ export { serializeResponse, runValidators, validationErrorResponse };
450
+ export { type ServerAdapter } from "@typokit/core";