hadars 0.1.1

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.
@@ -0,0 +1,140 @@
1
+ import { isBun, isDeno } from './runtime';
2
+
3
+ /**
4
+ * Minimal server context passed to every fetch handler invocation.
5
+ * On Bun, `upgrade` performs a real WebSocket upgrade.
6
+ * On all other runtimes it is a no-op that always returns false.
7
+ */
8
+ export interface ServerContext {
9
+ upgrade(req: Request): boolean;
10
+ }
11
+
12
+ type FetchHandler = (
13
+ req: Request,
14
+ ctx: ServerContext,
15
+ ) => Promise<Response | undefined> | Response | undefined;
16
+
17
+ /** Converts a Node.js Readable stream to a Web ReadableStream<Uint8Array>. */
18
+ function nodeReadableToWebStream(readable: NodeJS.ReadableStream): ReadableStream<Uint8Array> {
19
+ const enc = new TextEncoder();
20
+ return new ReadableStream<Uint8Array>({
21
+ start(controller) {
22
+ readable.on('data', (chunk: Buffer | string) => {
23
+ controller.enqueue(
24
+ typeof chunk === 'string'
25
+ ? enc.encode(chunk)
26
+ : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength),
27
+ );
28
+ });
29
+ readable.on('end', () => controller.close());
30
+ readable.on('error', (err) => controller.error(err));
31
+ },
32
+ cancel() {
33
+ (readable as any).destroy?.();
34
+ },
35
+ });
36
+ }
37
+
38
+ const noopCtx: ServerContext = { upgrade: () => false };
39
+
40
+ /**
41
+ * Starts an HTTP server on the given port using the best available runtime API:
42
+ * - **Bun**: `Bun.serve()` — full WebSocket support via the `websocket` option
43
+ * - **Deno**: `Deno.serve()`
44
+ * - **Node.js**: `node:http` `createServer()` with a Web-Fetch bridge
45
+ *
46
+ * The `fetchHandler` may return `undefined` to signal that the response was
47
+ * handled out-of-band (e.g. a Bun WebSocket upgrade).
48
+ */
49
+ export async function serve(
50
+ port: number,
51
+ fetchHandler: FetchHandler,
52
+ /** Bun WebSocketHandler — ignored on Deno and Node.js. */
53
+ websocket?: unknown,
54
+ ): Promise<void> {
55
+ // ── Bun ────────────────────────────────────────────────────────────────
56
+ if (isBun) {
57
+ (globalThis as any).Bun.serve({
58
+ port,
59
+ websocket,
60
+ async fetch(req: Request, server: any) {
61
+ const ctx: ServerContext = { upgrade: (r) => server.upgrade(r) };
62
+ // Returning undefined from a Bun fetch handler means the
63
+ // request was handled as a WebSocket upgrade.
64
+ return (await fetchHandler(req, ctx)) ?? undefined;
65
+ },
66
+ });
67
+ return;
68
+ }
69
+
70
+ // ── Deno ───────────────────────────────────────────────────────────────
71
+ // Deno 2.x changed the signature to options-first. Use the single-object
72
+ // form { port, handler } which is stable across Deno 1.x and 2.x.
73
+ if (isDeno) {
74
+ (globalThis as any).Deno.serve({
75
+ port,
76
+ handler: async (req: Request) => {
77
+ const res = await fetchHandler(req, noopCtx);
78
+ return res ?? new Response('Not Found', { status: 404 });
79
+ },
80
+ });
81
+ return;
82
+ }
83
+
84
+ // ── Node.js ────────────────────────────────────────────────────────────
85
+ const { createServer } = await import('node:http');
86
+
87
+ const server = createServer(async (nodeReq, nodeRes) => {
88
+ try {
89
+ // Collect body for non-GET/HEAD requests
90
+ const chunks: Buffer[] = [];
91
+ if (!['GET', 'HEAD'].includes(nodeReq.method ?? 'GET')) {
92
+ for await (const chunk of nodeReq) {
93
+ chunks.push(chunk as Buffer);
94
+ }
95
+ }
96
+ const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined;
97
+
98
+ const url = `http://localhost:${port}${nodeReq.url ?? '/'}`;
99
+ const reqInit: RequestInit & { duplex?: string } = {
100
+ method: nodeReq.method ?? 'GET',
101
+ headers: new Headers(nodeReq.headers as Record<string, string>),
102
+ };
103
+ if (body) {
104
+ reqInit.body = body;
105
+ reqInit.duplex = 'half';
106
+ }
107
+ const req = new Request(url, reqInit);
108
+
109
+ const res = await fetchHandler(req, noopCtx);
110
+ const response = res ?? new Response('Not Found', { status: 404 });
111
+
112
+ const headers: Record<string, string> = {};
113
+ response.headers.forEach((v, k) => { headers[k] = v; });
114
+ nodeRes.writeHead(response.status, headers);
115
+
116
+ if (response.body) {
117
+ const reader = response.body.getReader();
118
+ while (true) {
119
+ const { done, value } = await reader.read();
120
+ if (done) break;
121
+ await new Promise<void>((resolve, reject) =>
122
+ nodeRes.write(value, (err) => (err ? reject(err) : resolve())),
123
+ );
124
+ }
125
+ }
126
+ } catch (err) {
127
+ console.error('[hadars] request error', err);
128
+ if (!nodeRes.headersSent) nodeRes.writeHead(500);
129
+ } finally {
130
+ nodeRes.end();
131
+ }
132
+ });
133
+
134
+ await new Promise<void>((resolve, reject) => {
135
+ server.listen(port, () => resolve());
136
+ server.once('error', reject);
137
+ });
138
+ }
139
+
140
+ export { nodeReadableToWebStream };
@@ -0,0 +1,48 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+
3
+ /** MIME type map keyed by lowercase file extension. */
4
+ const MIME: Record<string, string> = {
5
+ html: 'text/html; charset=utf-8',
6
+ htm: 'text/html; charset=utf-8',
7
+ css: 'text/css',
8
+ js: 'application/javascript',
9
+ mjs: 'application/javascript',
10
+ cjs: 'application/javascript',
11
+ json: 'application/json',
12
+ map: 'application/json',
13
+ png: 'image/png',
14
+ jpg: 'image/jpeg',
15
+ jpeg: 'image/jpeg',
16
+ gif: 'image/gif',
17
+ webp: 'image/webp',
18
+ svg: 'image/svg+xml',
19
+ ico: 'image/x-icon',
20
+ woff: 'font/woff',
21
+ woff2: 'font/woff2',
22
+ ttf: 'font/ttf',
23
+ otf: 'font/otf',
24
+ txt: 'text/plain',
25
+ xml: 'application/xml',
26
+ pdf: 'application/pdf',
27
+ };
28
+
29
+ /**
30
+ * Tries to serve a file at the given absolute path.
31
+ * Returns a Response with the correct Content-Type, or null if the file does
32
+ * not exist or cannot be read.
33
+ */
34
+ export async function tryServeFile(filePath: string): Promise<Response | null> {
35
+ try {
36
+ await stat(filePath);
37
+ } catch {
38
+ return null;
39
+ }
40
+ try {
41
+ const data = await readFile(filePath);
42
+ const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
43
+ const contentType = MIME[ext] ?? 'application/octet-stream';
44
+ return new Response(data, { headers: { 'Content-Type': contentType } });
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
@@ -0,0 +1,11 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="NINETY_HEAD">
7
+ </head>
8
+ <body>
9
+ <meta name="NINETY_BODY">
10
+ </body>
11
+ </html>
@@ -0,0 +1,19 @@
1
+ import type { ServerContext } from './serve';
2
+ import type { HadarsOptions, HadarsRequest } from "../types/ninety";
3
+
4
+ type UpgradeHandle = (req: HadarsRequest, ctx: ServerContext) => boolean;
5
+
6
+ export const upgradeHandler = (options: HadarsOptions): UpgradeHandle | null => {
7
+ const { wsPath = '/ws' } = options;
8
+
9
+ if (options.websocket) {
10
+ return (req: HadarsRequest, ctx: ServerContext) => {
11
+ if (req.pathname === wsPath) {
12
+ return ctx.upgrade(req);
13
+ }
14
+ return false;
15
+ };
16
+ }
17
+
18
+ return null;
19
+ };