@wenathlan/getry 2.0.164 → 2.0.166

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/server.ts ADDED
@@ -0,0 +1,766 @@
1
+ /**
2
+ * @file server.ts
3
+ * @module gateway/server
4
+ * @description
5
+ * The gateway transport of the ownership wave (wave/ownership.gateway):
6
+ * moved verbatim from devthink/server.ts (the grand-merge interned http
7
+ * transport) and devthink/config.ts (the gateway configuration loader),
8
+ * so the gateway owns its transport — one owner, the family consumes, and
9
+ * devthink re-exports the public faces for the compat consumers.
10
+ *
11
+ * Responsibilities isolated here (three sections, one module):
12
+ * - sse transport — the keepalive constants, the sse/ndjson header
13
+ * builders, the event writers, the heartbeat discard, the frame
14
+ * parser, the chunk builders, the stream tracking state and
15
+ * makestreamresponse (the byte passthrough with masking and
16
+ * tracking) plus trackwrite
17
+ * - gateway routes — the canonical route kinds (GATEWAYROUTES of
18
+ * gateway.ts), the engine handler map, the gateway half of the
19
+ * merged createserver (cors, the /api/config endpoint, the version
20
+ * loop, the console static roots and the spa fallback), consoleroots
21
+ * and main (the transport boot, exported as runserver too)
22
+ * - gateway config loader — the .mjs-first probe chain over the search
23
+ * bases (loadconfig, reloadconfig, getversion, listversions,
24
+ * validateconfig and the private builtindefault/tryimport/
25
+ * searchbases helpers)
26
+ *
27
+ * Architectural constraints:
28
+ * - Verbatim move: every export name and signature preserved — the
29
+ * devthink server keeps answering the family through one re-export
30
+ * shim, so no consumer edits with the move
31
+ * - The devthink composed createserver still answers the whole surface:
32
+ * the os lanes register on the app this module's createserver built
33
+ * (devthink/server.ts owns the composition, this module owns the
34
+ * transport — no os-lane import ever lands here)
35
+ * - The private createtracker / framedatapayload / trackframe helpers
36
+ * moved with makestreamresponse and stay private in the owner; the
37
+ * private builtindefault / tryimport / searchbases helpers moved with
38
+ * loadconfig the same way
39
+ *
40
+ * Version: 2.1.8 — the ownership wave server family extraction
41
+ *
42
+ * Related modules:
43
+ * - gateway.ts — GATEWAYROUTES (the canonical seven route kinds)
44
+ * - engine.ts — createversion (the version loop mounts its handlers)
45
+ * - types.ts — gatewayconfig, gatewaydefinition (the config surface)
46
+ * - utils.ts — corsheaders, ctsse, ctndjson, the safe json family
47
+ * - devthink/server.ts — the compat re-export face + the composed server
48
+ *
49
+ * @author devthink
50
+ * @license MIT
51
+ */
52
+
53
+ import { existsSync, readFileSync } from "node:fs";
54
+ import { basename, dirname, join, resolve } from "node:path";
55
+ import { fileURLToPath } from "node:url";
56
+ import { serve } from "@hono/node-server";
57
+ import { serveStatic } from "@hono/node-server/serve-static";
58
+ import { Hono } from "hono";
59
+ import { cors } from "hono/cors";
60
+ import { createversion } from "@wenathlan/getry/engine";
61
+ import type { gatewayconfig, gatewaydefinition } from "@wenathlan/getry/types";
62
+ import { corsheaders, ctndjson, ctsse, safejsonparse, safestringify } from "@wenathlan/getry/utils";
63
+ import { GATEWAYROUTES } from "./gateway.js";
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // sse transport — the stream pipeline of the gateway http transport
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /** ka ms — keepalive interval milliseconds 200ms */
70
+ export const kams = 200;
71
+
72
+ /** ka suppress ms — suppress keepalive when data written recently */
73
+ export const kasuppressms = 1000;
74
+
75
+ /** sse headers — headers for sse streaming with x accel buffering no */
76
+ export function sseheaders(): Record<string, string> {
77
+ return {
78
+ ...corsheaders,
79
+ "content-type": ctsse,
80
+ "cache-control": "no-cache, no-transform",
81
+ connection: "keep-alive",
82
+ "x-accel-buffering": "no",
83
+ };
84
+ }
85
+
86
+ /** ndjson headers */
87
+ export function ndjsonheaders(): Record<string, string> {
88
+ return {
89
+ ...corsheaders,
90
+ "content-type": ctndjson,
91
+ "cache-control": "no-cache, no-transform",
92
+ connection: "keep-alive",
93
+ };
94
+ }
95
+
96
+ /** writeevent — write a single sse event to a controller */
97
+ export function writeevent(
98
+ controller: ReadableStreamDefaultController,
99
+ event: string | null,
100
+ data: unknown,
101
+ format: "sse" | "ndjson" | "json" | "plain" | "octet" = "sse",
102
+ ): void {
103
+ const datastr = typeof data === "string" ? data : safestringify(data);
104
+ if (format === "ndjson") {
105
+ controller.enqueue(`${datastr}\n`);
106
+ } else if (format === "plain") {
107
+ controller.enqueue(`${datastr}`);
108
+ } else if (format === "sse") {
109
+ const parts: string[] = [];
110
+ if (event) parts.push(`event: ${event}`);
111
+ parts.push(`data: ${datastr}`);
112
+ parts.push("");
113
+ parts.push("");
114
+ controller.enqueue(parts.join("\n"));
115
+ } else {
116
+ controller.enqueue(`${datastr}`);
117
+ }
118
+ }
119
+
120
+ /** writekeepalive — write a keepalive comment invisible to sse and ndjson parsers */
121
+ export function writekeepalive(controller: ReadableStreamDefaultController): void {
122
+ controller.enqueue(": ka\n\n");
123
+ }
124
+
125
+ /** writedone — write the terminal done marker */
126
+ export function writedone(
127
+ controller: ReadableStreamDefaultController,
128
+ format: "sse" | "ndjson" | "json" | "plain" | "octet" = "sse",
129
+ ): void {
130
+ if (format === "sse") {
131
+ controller.enqueue("data: [done]\n\n");
132
+ } else if (format === "ndjson") {
133
+ controller.enqueue('{"done":true}\n');
134
+ }
135
+ }
136
+
137
+ /** safeenqueue — enqueue to controller catching errors */
138
+ export function safeenqueue(controller: ReadableStreamDefaultController, chunk: string): boolean {
139
+ try {
140
+ controller.enqueue(chunk);
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /** safeclose — close a controller catching errors */
148
+ export function safeclose(controller: ReadableStreamDefaultController): void {
149
+ try {
150
+ controller.close();
151
+ } catch {
152
+ // already closed
153
+ }
154
+ }
155
+
156
+ /** maskmodel — pure byte regex mask model name to the target id */
157
+ export function maskmodel(chunk: string, target = "devthink"): string {
158
+ const pattern = /"model"\s*:\s*"[^"]*"/g;
159
+ return chunk.replace(pattern, `"model":"${target}"`);
160
+ }
161
+
162
+ /** discardheartbeat — check if a line is an sse heartbeat comment
163
+ * the empty-data comparison runs on the trimmed line: "data: " and
164
+ * "data:" both trim to "data:" so the untrimmed spelling never matched */
165
+ export function discardheartbeat(line: string): boolean {
166
+ const trimmed = line.trim();
167
+ return trimmed.startsWith(":") || trimmed === "" || trimmed === "event: ping" || trimmed === "data:";
168
+ }
169
+
170
+ /** parsesseframes — split buffer on double newline returns complete frames plus residual */
171
+ export function parsesseframes(buffer: string): { frames: string[]; residual: string } {
172
+ const normalized = buffer.replace(/\r\n/g, "\n");
173
+ const frames: string[] = [];
174
+ let last = 0;
175
+ for (let idx = normalized.indexOf("\n\n", last); idx !== -1; idx = normalized.indexOf("\n\n", last)) {
176
+ frames.push(normalized.slice(last, idx));
177
+ last = idx + 2;
178
+ }
179
+ const residual = normalized.slice(last);
180
+ return { frames, residual };
181
+ }
182
+
183
+ /** extractdata — extract data lines from an sse frame */
184
+ export function extractdata(frame: string): string | null {
185
+ const lines = frame.split("\n");
186
+ const dataLines: string[] = [];
187
+ for (const line of lines) {
188
+ if (line.startsWith("data:")) {
189
+ dataLines.push(line.slice(5).trimStart());
190
+ }
191
+ }
192
+ if (dataLines.length === 0) return null;
193
+ const joined = dataLines.join("\n");
194
+ // the done marker is case-insensitive: upstreams emit both [DONE]
195
+ // (openai) and [done] (misc gateways)
196
+ return isdone(joined) ? null : joined;
197
+ }
198
+
199
+ /** isdone — check if a data string is the done marker
200
+ * case-insensitive: openai emits [DONE] others emit [done] */
201
+ export function isdone(data: string): boolean {
202
+ const trimmed = data.trim().toLowerCase();
203
+ return trimmed === "[done]" || trimmed === "data: [done]" || trimmed === "done";
204
+ }
205
+
206
+ /** makechunk — build a minimal openai compatible chat completion chunk */
207
+ export function makechunk(
208
+ id: string,
209
+ model: string,
210
+ content: string,
211
+ reasoningcontent?: string,
212
+ finishreason?: string | null,
213
+ ): Record<string, unknown> {
214
+ const delta: Record<string, unknown> = {};
215
+ if (content) delta.content = content;
216
+ if (reasoningcontent) delta.reasoning_content = reasoningcontent;
217
+ if (!finishreason) delta.role = "assistant";
218
+ return {
219
+ id,
220
+ object: "chat.completion.chunk",
221
+ created: Math.floor(Date.now() / 1000),
222
+ model,
223
+ choices: [
224
+ {
225
+ index: 0,
226
+ delta,
227
+ ...(finishreason ? { finish_reason: finishreason } : {}),
228
+ },
229
+ ],
230
+ };
231
+ }
232
+
233
+ /** makefinalchunk — build the final chunk with finish reason and optional usage */
234
+ export function makefinalchunk(
235
+ id: string,
236
+ model: string,
237
+ finishreason: string,
238
+ usage?: Record<string, unknown>,
239
+ ): Record<string, unknown> {
240
+ return {
241
+ id,
242
+ object: "chat.completion.chunk",
243
+ created: Math.floor(Date.now() / 1000),
244
+ model,
245
+ choices: [{ index: 0, delta: {}, finish_reason: finishreason }],
246
+ ...(usage ? { usage } : {}),
247
+ };
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // stream tracking — collects content reasoning usage while forwarding
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /** tracked stream data — collected during passthrough for persistence */
255
+ export interface trackedstream {
256
+ content: string;
257
+ reasoning: string;
258
+ totaltokens: number;
259
+ prompttokens: number;
260
+ completiontokens: number;
261
+ frames: number;
262
+ discarded: number;
263
+ }
264
+
265
+ /** createtracker — new empty tracking state */
266
+ function createtracker(): trackedstream {
267
+ return {
268
+ content: "",
269
+ reasoning: "",
270
+ totaltokens: 0,
271
+ prompttokens: 0,
272
+ completiontokens: 0,
273
+ frames: 0,
274
+ discarded: 0,
275
+ };
276
+ }
277
+
278
+ /** framedatapayload — the joined data payload of a frame WITHOUT the done
279
+ * nulling (extractdata maps the done marker to null by contract — the
280
+ * stream loop needs the raw payload to recognize the marker and set its
281
+ * terminated flag before that nulling hides it) */
282
+ function framedatapayload(frame: string): string | null {
283
+ const lines = frame.split("\n").filter((l) => l.startsWith("data:"));
284
+ if (lines.length === 0) return null;
285
+ return lines.map((l) => l.slice(5).trim()).join("\n");
286
+ }
287
+
288
+ /** trackframe — parse a data frame and accumulate tracked fields */
289
+ function trackframe(tracker: trackedstream, data: string): void {
290
+ const parsed = safejsonparse(data);
291
+ if (!parsed || typeof parsed !== "object") return;
292
+ const obj = parsed as Record<string, unknown>;
293
+ // usage
294
+ const usage = obj["usage"] as Record<string, unknown> | undefined;
295
+ if (usage) {
296
+ if (usage["total_tokens"]) tracker.totaltokens = Number(usage["total_tokens"]);
297
+ if (usage["prompt_tokens"]) tracker.prompttokens = Number(usage["prompt_tokens"]);
298
+ if (usage["completion_tokens"]) tracker.completiontokens = Number(usage["completion_tokens"]);
299
+ }
300
+ // choices deltas
301
+ const choices = obj["choices"] as Array<Record<string, unknown>> | undefined;
302
+ if (choices && choices.length > 0) {
303
+ const delta = choices[0]["delta"] as Record<string, unknown> | undefined;
304
+ if (delta) {
305
+ if (typeof delta["content"] === "string") tracker.content += delta["content"];
306
+ if (typeof delta["reasoning_content"] === "string") tracker.reasoning += delta["reasoning_content"];
307
+ }
308
+ const message = choices[0]["message"] as Record<string, unknown> | undefined;
309
+ if (message) {
310
+ if (typeof message["content"] === "string") tracker.content += message["content"];
311
+ if (typeof message["reasoning_content"] === "string") tracker.reasoning += message["reasoning_content"];
312
+ }
313
+ }
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // makestreamresponse — wrap an upstream response into a gateway stream
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /** options for makestreamresponse */
321
+ export interface streamoptions {
322
+ /** mask upstream model ids to this id — null disables masking */
323
+ mask?: string | null;
324
+ /** content type override — defaults from upstream response */
325
+ format?: "sse" | "ndjson" | "json" | "plain" | "octet";
326
+ /** keepalive interval ms — default 200 */
327
+ keepaliveintervalms?: number;
328
+ /** suppress keepalive when written within this ms — default 1000 */
329
+ keepalivesuppressms?: number;
330
+ /** callback with tracked data when the stream finishes */
331
+ onfinal?: (tracked: trackedstream) => void;
332
+ }
333
+
334
+ /** makestreamresponse — wrap an upstream fetch response into a gateway streaming response
335
+ * byte passthrough with sse parsing heartbeat discard masking and tracking */
336
+ export function makestreamresponse(upstream: Response, opts: streamoptions): Response {
337
+ const mask = opts.mask ?? null;
338
+ const kai = opts.keepaliveintervalms ?? kams;
339
+ const kasuppress = opts.keepalivesuppressms ?? kasuppressms;
340
+ const tracker = createtracker();
341
+ const upstreamct = upstream.headers.get("content-type") ?? "";
342
+ const format = opts.format ?? (upstreamct.includes("ndjson") ? "ndjson" : "sse");
343
+
344
+ let closed = false;
345
+ let lastwrite = Date.now();
346
+
347
+ const stream = new ReadableStream({
348
+ async start(controller) {
349
+ // every chunk leaves as bytes: a response body is a byte stream —
350
+ // string chunks throw "received non-uint8array chunk" the moment a
351
+ // consumer reads the body (res.text() res.json()) even though node
352
+ // http write happens to tolerate strings — encode uniformly
353
+ const encoder = new TextEncoder();
354
+ const push = (chunk: string | Uint8Array): boolean => {
355
+ try {
356
+ controller.enqueue(typeof chunk === "string" ? encoder.encode(chunk) : chunk);
357
+ return true;
358
+ } catch {
359
+ return false;
360
+ }
361
+ };
362
+
363
+ const keepalivetimer = setInterval(() => {
364
+ if (closed) return;
365
+ if (Date.now() - lastwrite > kasuppress) {
366
+ push(": ka\n\n");
367
+ }
368
+ }, kai);
369
+
370
+ try {
371
+ const reader = upstream.body?.getReader();
372
+ if (!reader) {
373
+ // a bodyless upstream (new Response(null)) still terminates the
374
+ // downstream stream properly — the early return used to close
375
+ // without the done marker and without the sse terminator
376
+ push("data: [DONE]\n\n");
377
+ closed = true;
378
+ safeclose(controller);
379
+ return;
380
+ }
381
+ const decoder = new TextDecoder();
382
+ let buffer = "";
383
+
384
+ while (true) {
385
+ const { done, value } = await reader.read();
386
+ if (done) break;
387
+ lastwrite = Date.now();
388
+
389
+ if (mask === null && format !== "sse") {
390
+ // pure byte passthrough — raw bytes forwarded unchanged: no
391
+ // decode encode round trip (multibyte sequences split across
392
+ // chunks stay intact)
393
+ push(value);
394
+ continue;
395
+ }
396
+
397
+ // sse mode — parse frames discard heartbeats mask model ids track content
398
+ buffer += decoder.decode(value, { stream: true });
399
+ const { frames, residual } = parsesseframes(buffer);
400
+ buffer = residual;
401
+ for (const frame of frames) {
402
+ if (discardheartbeat(frame)) {
403
+ tracker.discarded += 1;
404
+ continue;
405
+ }
406
+ // the done marker nulls inside extractdata — recognize it from
407
+ // the raw payload first so the terminated flag actually sets
408
+ // (the marker used to fall into the no-data branch, closed never
409
+ // flipped and the loop appended a second done at the end)
410
+ const payload = framedatapayload(frame);
411
+ if (payload !== null && isdone(payload)) {
412
+ push("data: [DONE]\n\n");
413
+ closed = true;
414
+ continue;
415
+ }
416
+ const data = extractdata(frame);
417
+ if (data === null) {
418
+ // frame with no data lines — forward raw
419
+ push(`${frame}\n\n`);
420
+ continue;
421
+ }
422
+ // track content for persistence
423
+ tracker.frames += 1;
424
+ trackframe(tracker, data);
425
+ // mask and forward
426
+ if (mask !== null) {
427
+ const masked = data.replace(/"model"\s*:\s*"[^"]*"/g, `"model":"${mask}"`);
428
+ push(`data: ${masked}\n\n`);
429
+ } else {
430
+ push(`data: ${data}\n\n`);
431
+ }
432
+ }
433
+ }
434
+ // flush residual — through the same done recognition so an
435
+ // unterminated trailing marker terminates instead of duplicating
436
+ if (buffer.trim()) {
437
+ const payload = framedatapayload(buffer);
438
+ if (payload !== null && isdone(payload)) {
439
+ push("data: [DONE]\n\n");
440
+ closed = true;
441
+ } else {
442
+ push(buffer.endsWith("\n\n") ? buffer : `${buffer}\n\n`);
443
+ }
444
+ }
445
+ // ensure done marker present
446
+ if (!closed) {
447
+ push("data: [DONE]\n\n");
448
+ }
449
+ } catch {
450
+ // upstream failed mid-stream — emit done and close
451
+ try {
452
+ push("data: [DONE]\n\n");
453
+ } catch {
454
+ /* closed */
455
+ }
456
+ } finally {
457
+ clearInterval(keepalivetimer);
458
+ closed = true;
459
+ safeclose(controller);
460
+ if (opts.onfinal) {
461
+ try {
462
+ opts.onfinal(tracker);
463
+ } catch {
464
+ /* callback error */
465
+ }
466
+ }
467
+ }
468
+ },
469
+ cancel() {
470
+ closed = true;
471
+ },
472
+ });
473
+
474
+ const headers = format === "ndjson" ? ndjsonheaders() : sseheaders();
475
+ return new Response(stream, { headers });
476
+ }
477
+
478
+ /** trackwrite — update last write timestamp for keepalive suppression */
479
+ export function trackwrite(): number {
480
+ return Date.now();
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // gateway routes — the version loop mounts the engine handlers per version
485
+ // ---------------------------------------------------------------------------
486
+
487
+ /** route resources per version — the canonical 7 */
488
+ const resources = GATEWAYROUTES;
489
+
490
+ /** map resource name to its engine handler */
491
+ function handlermap(
492
+ handlers: ReturnType<typeof createversion>,
493
+ resource: string,
494
+ ): ((req: Request) => Promise<Response>) | null {
495
+ switch (resource) {
496
+ case "keys":
497
+ return handlers.handlekeys;
498
+ case "models":
499
+ return handlers.handlemodels;
500
+ case "chat/completions":
501
+ return handlers.handlechatcompletions;
502
+ case "completions":
503
+ return handlers.handlecompletions;
504
+ case "messages":
505
+ return handlers.handlemessages;
506
+ case "responses":
507
+ return handlers.handleresponses;
508
+ case "embeddings":
509
+ return handlers.handleembeddings;
510
+ default:
511
+ return null;
512
+ }
513
+ }
514
+
515
+ /** createserver — build the hono app from the loaded config (exported at
516
+ * the library surface below, so the battery drives the real routes
517
+ * through app.request without a socket) */
518
+ async function createserver(): Promise<Hono> {
519
+ const app = new Hono();
520
+
521
+ // open cors — all methods all origins
522
+ app.use(
523
+ "*",
524
+ cors({
525
+ origin: "*",
526
+ allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
527
+ allowHeaders: [
528
+ "content-type",
529
+ "authorization",
530
+ "x-token",
531
+ "x-chat-id",
532
+ "x-user-id",
533
+ "x-session-id",
534
+ "x-request-id",
535
+ "x-thinking-level",
536
+ "x-model",
537
+ "x-tools",
538
+ "accept",
539
+ ],
540
+ exposeHeaders: ["x-request-id", "x-session-id"],
541
+ maxAge: 86400,
542
+ }),
543
+ );
544
+
545
+ // load the user config — config.mjs/config.ts or gatewayconfig.ts
546
+ const definition = await loadconfig();
547
+ const problems = validateconfig(definition);
548
+
549
+ // config endpoint — reports the loaded definition shape
550
+ app.get("/api/config", (c) =>
551
+ c.json({
552
+ name: definition.name ?? "gateway",
553
+ description: definition.description ?? "",
554
+ versions: Object.keys(definition.versions),
555
+ problems,
556
+ }),
557
+ );
558
+
559
+ // register routes for every configured version
560
+ for (const [versionid, versionconfig] of Object.entries(definition.versions)) {
561
+ const handlers = createversion(versionconfig);
562
+ // the chat speaks the OpenAI-compatible contract at the origin root —
563
+ // /v1/* rides the exact handlers the legacy /api/{version} prefix always
564
+ // carried, so the registered gateway base of the chat (same origin or a
565
+ // deployed copy of the family) answers one contract everywhere
566
+ for (const prefix of [`/api/${versionid}`, `/${versionid}`]) {
567
+ // info routes — get returns the version descriptor
568
+ for (const resource of resources) {
569
+ app.get(`${prefix}/${resource}`, (c) => handlers.handleinfo(c.req.raw));
570
+ }
571
+
572
+ // action routes — post dispatches to the resource handler
573
+ for (const resource of resources) {
574
+ const handler = handlermap(handlers, resource);
575
+ if (handler) {
576
+ app.post(`${prefix}/${resource}`, (c) => handler(c.req.raw));
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ // static web ui: the host build (cwd/build — a host application or repo dev
583
+ // with its own vite build) wins, then the console that ships inside the
584
+ // package (the bundled http transport lives in the package dist, so a
585
+ // consumer running `npx @wenathlan/gateway serve` with no local build
586
+ // still gets the web console out of the box)
587
+ for (const root of consoleroots()) {
588
+ app.use("*", serveStatic({ root }));
589
+ }
590
+
591
+ // api 404 fallback
592
+ app.notFound((c) => {
593
+ if (c.req.path.startsWith("/api/")) {
594
+ return c.json({ error: "not found", path: c.req.path }, 404);
595
+ }
596
+ // spa fallback: index.html from the first console root that has it
597
+ for (const root of consoleroots()) {
598
+ const indexpath = join(root, "index.html");
599
+ if (existsSync(indexpath)) {
600
+ return c.html(readFileSync(indexpath, "utf8"));
601
+ }
602
+ }
603
+ return c.body(null, 204);
604
+ });
605
+
606
+ return app;
607
+ }
608
+
609
+ /** consoleroots — the static roots in priority order: the local build (the
610
+ * host build — cwd relative), then the package's own build when the http
611
+ * transport itself runs from inside it (the bundled build/http.js sits next
612
+ * to the console the package ships; repo-dev runs http.ts from the root, so
613
+ * the module dir only joins the list when it IS a build directory) */
614
+ function consoleroots(): string[] {
615
+ const roots: string[] = ["build"];
616
+ const moduledir = dirname(fileURLToPath(import.meta.url));
617
+ if (basename(moduledir) === "build" && moduledir !== resolve("build")) {
618
+ roots.push(moduledir);
619
+ }
620
+ return roots;
621
+ }
622
+
623
+ /** main — bootstrap the server on the configured port
624
+ * host and port honor env — never hardcoded per open infrastructure rules */
625
+ async function main(): Promise<void> {
626
+ const app = await createserver();
627
+ const definition = await loadconfig();
628
+ const versioncount = Object.keys(definition.versions).length;
629
+
630
+ // global error guards — never crash the process
631
+ process.on("uncaughtException", (err) => {
632
+ console.error("[server] uncaught exception:", err.message);
633
+ });
634
+ process.on("unhandledRejection", (reason) => {
635
+ console.error("[server] unhandled rejection:", reason);
636
+ });
637
+
638
+ const port = Number(process.env.PORT) || 3001;
639
+ const hostname = process.env.HOST || "0.0.0.0";
640
+ serve({ fetch: app.fetch, port, hostname }, (info: { address: string; port: number }) => {
641
+ console.log(`[server] gateway listening on http://${info.address}:${info.port}`);
642
+ console.log(`[server] config loaded — ${versioncount} versions`);
643
+ });
644
+ }
645
+
646
+ /** export for library usage — users may embed the server */
647
+ export { createserver, main, main as runserver };
648
+
649
+ /* ════════════════════════════════════════════════════════════════════
650
+ Section: the gateway configuration loader (the 1.1.13 embedded-gateway
651
+ definition loader absorbed by the grand merge — the .mjs-first probe
652
+ chain, the version registry and the validator).
653
+ ════════════════════════════════════════════════════════════════════ */
654
+ /**
655
+ * configloader — loads the gateway definition from the user customization layer
656
+ * one file one responsibility — only config loading lives here
657
+ *
658
+ * the library is universal and dry — all logic lives at root
659
+ * the user customization lives in config.mjs — the hardcoded v1 v5
660
+ * definitions that ship as the default example
661
+ *
662
+ * resolution order:
663
+ * 1 config.mjs in cwd — the scaffolded standard location
664
+ * 2 gatewayconfig.ts in cwd — user placed config at project root
665
+ * 3 sol/config.mjs — the legacy theme-folder location (still honored)
666
+ * 4 builtin default — the shipped v1 v5 example configs
667
+ *
668
+ * the cli scaffolds a fresh config.mjs for new users
669
+ * users may create as many versions as they want — v1 through v9 and beyond
670
+ */
671
+
672
+ /** loaded definition cache */
673
+ let loaded: gatewaydefinition | null = null;
674
+
675
+ /** builtin default — empty definition when no user config exists
676
+ * the library is dry — user customization lives in config.mjs
677
+ * the cli scaffolds a fresh config.mjs for new users */
678
+ async function builtindefault(): Promise<gatewaydefinition> {
679
+ return { versions: {} };
680
+ }
681
+
682
+ /** try import from a path — returns null when not found */
683
+ async function tryimport(path: string): Promise<gatewaydefinition | null> {
684
+ try {
685
+ const mod = await import(/* @vite-ignore */ path);
686
+ const candidate = mod.default ?? mod.config ?? mod.gatewaydefinition ?? mod;
687
+ if (candidate && typeof candidate === "object" && "versions" in candidate) {
688
+ return candidate as gatewaydefinition;
689
+ }
690
+ return null;
691
+ } catch {
692
+ return null;
693
+ }
694
+ }
695
+
696
+ /** config search bases — cwd, module dir (source root) and module parent (dist)
697
+ * relative dynamic imports resolve against the importing module url not
698
+ * the process cwd, so the built build/configloader.js must also probe the
699
+ * parent directory where config.mjs lives in a packaged install */
700
+ function searchbases(): string[] {
701
+ const moduledir = dirname(fileURLToPath(import.meta.url));
702
+ const bases = [resolve(process.cwd()), moduledir, resolve(moduledir, "..")];
703
+ return [...new Set(bases)];
704
+ }
705
+
706
+ /** loadconfig — resolve the gateway definition from the standard locations
707
+ * caches the result for the process lifetime — call reloadconfig to refresh */
708
+ export async function loadconfig(): Promise<gatewaydefinition> {
709
+ if (loaded) return loaded;
710
+ // .mjs variants come first in each pair: the extension carries the module
711
+ // type (immune to the nearest package.json type field), so a config
712
+ // scaffolded by the cli loads under plain node in every consumer project
713
+ const names = [
714
+ "config.mjs",
715
+ "config.ts",
716
+ "config.js",
717
+ "sol/config.mjs",
718
+ "sol/config.ts",
719
+ "sol/config.js",
720
+ "gatewayconfig.mjs",
721
+ "gatewayconfig.ts",
722
+ "gatewayconfig.js",
723
+ ];
724
+ for (const base of searchbases()) {
725
+ for (const name of names) {
726
+ const found = await tryimport(`${base}/${name}`);
727
+ if (found) {
728
+ loaded = found;
729
+ return found;
730
+ }
731
+ }
732
+ }
733
+ // builtin fallback
734
+ loaded = await builtindefault();
735
+ return loaded;
736
+ }
737
+
738
+ /** reloadconfig — clear the cache and reload from disk */
739
+ export async function reloadconfig(): Promise<gatewaydefinition> {
740
+ loaded = null;
741
+ return loadconfig();
742
+ }
743
+
744
+ /** getversion — get one version config by id */
745
+ export async function getversion(id: string): Promise<gatewayconfig | null> {
746
+ const def = await loadconfig();
747
+ return def.versions[id] ?? null;
748
+ }
749
+
750
+ /** listversions — get all version ids */
751
+ export async function listversions(): Promise<string[]> {
752
+ const def = await loadconfig();
753
+ return Object.keys(def.versions);
754
+ }
755
+
756
+ /* validateconfig moved to the pure owner (validate.ts — the ownership
757
+ * correction of the gateway step 3 fold): the checks never needed the
758
+ * transport, and the import plus re-export below keeps this server face
759
+ * answering the same symbol (the createserver lane reads it at line ~547,
760
+ * every external consumer resolves the export) while the devthink config
761
+ * bridge resolves the pure module directly — the browser graph never
762
+ * loads the hono stack the validator used to drag along. The contract is
763
+ * verbatim: one definition in, one problem list out, synchronous always. */
764
+ import { validateconfig } from "./validate.js";
765
+ export { validateconfig };
766
+