hadars 0.1.10 → 0.1.11

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/dist/cli.js CHANGED
@@ -956,6 +956,74 @@ var makePrecontentHtmlGetter = (htmlFilePromise) => {
956
956
  return [preHead + headHtml + postHead, postContent];
957
957
  };
958
958
  };
959
+ async function transformStream(data, stream) {
960
+ const writer = stream.writable.getWriter();
961
+ writer.write(data);
962
+ writer.close();
963
+ const chunks = [];
964
+ const reader = stream.readable.getReader();
965
+ while (true) {
966
+ const { done, value } = await reader.read();
967
+ if (done)
968
+ break;
969
+ chunks.push(value);
970
+ }
971
+ const total = chunks.reduce((n, c) => n + c.length, 0);
972
+ const out = new Uint8Array(total);
973
+ let offset = 0;
974
+ for (const c of chunks) {
975
+ out.set(c, offset);
976
+ offset += c.length;
977
+ }
978
+ return out;
979
+ }
980
+ var gzipCompress = (d) => transformStream(d, new globalThis.CompressionStream("gzip"));
981
+ var gzipDecompress = (d) => transformStream(d, new globalThis.DecompressionStream("gzip"));
982
+ function createRenderCache(opts, handler) {
983
+ const store = /* @__PURE__ */ new Map();
984
+ return async (req, ctx) => {
985
+ const hadarsReq = parseRequest(req);
986
+ const cacheOpts = await opts(hadarsReq);
987
+ const key = cacheOpts?.key ?? null;
988
+ if (key != null) {
989
+ const entry = store.get(key);
990
+ if (entry) {
991
+ if (entry.expiresAt == null || Date.now() < entry.expiresAt) {
992
+ const accept = req.headers.get("Accept-Encoding") ?? "";
993
+ if (accept.includes("gzip")) {
994
+ return new Response(entry.body.buffer, { status: entry.status, headers: entry.headers });
995
+ }
996
+ const plain = await gzipDecompress(entry.body);
997
+ const headers = entry.headers.filter(([k]) => k.toLowerCase() !== "content-encoding");
998
+ return new Response(plain.buffer, { status: entry.status, headers });
999
+ }
1000
+ store.delete(key);
1001
+ }
1002
+ }
1003
+ const res = await handler(req, ctx);
1004
+ if (key != null && res) {
1005
+ const ttl = cacheOpts?.ttl;
1006
+ res.clone().arrayBuffer().then(async (buf) => {
1007
+ const body = await gzipCompress(new Uint8Array(buf));
1008
+ const headers = [];
1009
+ res.headers.forEach((v, k) => {
1010
+ if (k.toLowerCase() !== "content-encoding" && k.toLowerCase() !== "content-length") {
1011
+ headers.push([k, v]);
1012
+ }
1013
+ });
1014
+ headers.push(["content-encoding", "gzip"]);
1015
+ store.set(key, {
1016
+ body,
1017
+ status: res.status,
1018
+ headers,
1019
+ expiresAt: ttl != null ? Date.now() + ttl : null
1020
+ });
1021
+ }).catch(() => {
1022
+ });
1023
+ }
1024
+ return res;
1025
+ };
1026
+ }
959
1027
  var SSR_FILENAME = "index.ssr.js";
960
1028
  var __dirname2 = process.cwd();
961
1029
  var getSuffix = (mode) => mode === "development" ? `?v=${Date.now()}` : "";
@@ -1295,7 +1363,7 @@ var run = async (options) => {
1295
1363
  const getPrecontentHtml = makePrecontentHtmlGetter(
1296
1364
  fs.readFile(pathMod3.join(__dirname2, StaticPath, "out.html"), "utf-8")
1297
1365
  );
1298
- await serve(port, async (req, ctx) => {
1366
+ const runHandler = async (req, ctx) => {
1299
1367
  const request = parseRequest(req);
1300
1368
  if (handler) {
1301
1369
  const res = await handler(request);
@@ -1363,7 +1431,12 @@ var run = async (options) => {
1363
1431
  console.error("[hadars] SSR render error:", err);
1364
1432
  return new Response("Internal Server Error", { status: 500 });
1365
1433
  }
1366
- }, options.websocket);
1434
+ };
1435
+ await serve(
1436
+ port,
1437
+ options.cache ? createRenderCache(options.cache, runHandler) : runHandler,
1438
+ options.websocket
1439
+ );
1367
1440
  };
1368
1441
 
1369
1442
  // cli-lib.ts
package/dist/index.d.ts CHANGED
@@ -92,6 +92,29 @@ interface HadarsOptions {
92
92
  * Has no effect on the SSR bundle or dev mode.
93
93
  */
94
94
  optimization?: Record<string, unknown>;
95
+ /**
96
+ * SSR response cache for `run()` mode. Has no effect in `dev()` mode.
97
+ *
98
+ * Receives the incoming request and should return `{ key, ttl? }` to cache
99
+ * the response, or `null`/`undefined` to skip caching for that request.
100
+ * `ttl` is the time-to-live in milliseconds; omit for entries that never expire.
101
+ * The function may be async.
102
+ *
103
+ * @example
104
+ * // Cache every page by pathname (no per-user personalisation):
105
+ * cache: (req) => ({ key: req.pathname })
106
+ *
107
+ * @example
108
+ * // Cache with a per-route TTL, skip authenticated requests:
109
+ * cache: (req) => req.cookies.session ? null : { key: req.pathname, ttl: 60_000 }
110
+ */
111
+ cache?: (req: HadarsRequest) => {
112
+ key: string;
113
+ ttl?: number;
114
+ } | null | undefined | Promise<{
115
+ key: string;
116
+ ttl?: number;
117
+ } | null | undefined>;
95
118
  }
96
119
  type SwcPluginItem = string | [string, Record<string, unknown>] | {
97
120
  path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -52,8 +52,8 @@
52
52
  "node": ">=18.0.0"
53
53
  },
54
54
  "peerDependencies": {
55
- "react": "^19.0.0",
56
- "react-dom": "^19.0.0",
55
+ "react": "^19.1.1",
56
+ "react-dom": "^19.1.1",
57
57
  "typescript": "^5"
58
58
  },
59
59
  "peerDependenciesMeta": {
@@ -63,28 +63,28 @@
63
63
  },
64
64
  "optionalDependencies": {
65
65
  "@rspack/core": "1.4.9",
66
- "@rspack/dev-server": "^1.1.4",
67
- "@rspack/plugin-react-refresh": "^1.5.0",
68
- "@swc/core": "^1.4.0",
66
+ "@rspack/dev-server": "^1.2.1",
67
+ "@rspack/plugin-react-refresh": "^1.6.1",
68
+ "@swc/core": "^1.15.18",
69
69
  "@types/bun": "latest",
70
- "@types/react-dom": "^19.1.9",
70
+ "@types/react-dom": "^19.2.3",
71
71
  "react-refresh": "^0.17.0",
72
- "typescript": "^5"
72
+ "typescript": "^5.9.3"
73
73
  },
74
74
  "devDependencies": {
75
- "@types/react": "^19.0.0",
75
+ "@types/react": "^19.2.14",
76
76
  "@types/react-dom": "^19.0.0",
77
- "esbuild": "^0.19.0",
78
- "tsup": "^6.6.0"
77
+ "esbuild": "^0.19.12",
78
+ "tsup": "^6.7.0"
79
79
  },
80
80
  "dependencies": {
81
81
  "@mdx-js/loader": "^3.1.1",
82
82
  "@mdx-js/react": "^3.1.1",
83
83
  "@svgr/webpack": "^8.1.0",
84
- "@tailwindcss/postcss": "^4.1.12",
85
- "postcss": "^8.5.6",
86
- "postcss-loader": "^8.2.0",
87
- "tailwindcss": "^4.1.12"
84
+ "@tailwindcss/postcss": "^4.2.1",
85
+ "postcss": "^8.5.8",
86
+ "postcss-loader": "^8.2.1",
87
+ "tailwindcss": "^4.2.1"
88
88
  },
89
89
  "license": "MIT",
90
90
  "repository": {
package/src/build.ts CHANGED
@@ -230,6 +230,96 @@ const makePrecontentHtmlGetter = (htmlFilePromise: Promise<string>) => {
230
230
  };
231
231
  };
232
232
 
233
+ // ── SSR response cache ────────────────────────────────────────────────────────
234
+
235
+ interface CacheEntry {
236
+ /** Gzip-compressed response body — much cheaper to keep in RAM than raw HTML. */
237
+ body: Uint8Array;
238
+ status: number;
239
+ /** Headers with Content-Encoding: gzip already set. */
240
+ headers: [string, string][];
241
+ expiresAt: number | null;
242
+ }
243
+
244
+ type CacheFetchHandler = (req: Request, ctx: any) => Promise<Response | undefined>;
245
+
246
+ async function transformStream(data: Uint8Array, stream: { writable: WritableStream; readable: ReadableStream<Uint8Array> }): Promise<Uint8Array> {
247
+ const writer = stream.writable.getWriter();
248
+ writer.write(data);
249
+ writer.close();
250
+ const chunks: Uint8Array[] = [];
251
+ const reader = stream.readable.getReader();
252
+ while (true) {
253
+ const { done, value } = await reader.read();
254
+ if (done) break;
255
+ chunks.push(value);
256
+ }
257
+ const total = chunks.reduce((n, c) => n + c.length, 0);
258
+ const out = new Uint8Array(total);
259
+ let offset = 0;
260
+ for (const c of chunks) { out.set(c, offset); offset += c.length; }
261
+ return out;
262
+ }
263
+
264
+ const gzipCompress = (d: Uint8Array) => transformStream(d, new (globalThis as any).CompressionStream('gzip'));
265
+ const gzipDecompress = (d: Uint8Array) => transformStream(d, new (globalThis as any).DecompressionStream('gzip'));
266
+
267
+ function createRenderCache(
268
+ opts: NonNullable<HadarsOptions['cache']>,
269
+ handler: CacheFetchHandler,
270
+ ): CacheFetchHandler {
271
+ const store = new Map<string, CacheEntry>();
272
+
273
+ return async (req, ctx) => {
274
+ const hadarsReq = parseRequest(req);
275
+ const cacheOpts = await opts(hadarsReq);
276
+ const key = cacheOpts?.key ?? null;
277
+
278
+ if (key != null) {
279
+ const entry = store.get(key);
280
+ if (entry) {
281
+ if (entry.expiresAt == null || Date.now() < entry.expiresAt) {
282
+ const accept = req.headers.get('Accept-Encoding') ?? '';
283
+ if (accept.includes('gzip')) {
284
+ return new Response(entry.body.buffer as ArrayBuffer, { status: entry.status, headers: entry.headers });
285
+ }
286
+ // Client doesn't support gzip — decompress before serving
287
+ const plain = await gzipDecompress(entry.body);
288
+ const headers = entry.headers.filter(([k]) => k.toLowerCase() !== 'content-encoding');
289
+ return new Response(plain.buffer as ArrayBuffer, { status: entry.status, headers });
290
+ }
291
+ store.delete(key);
292
+ }
293
+ }
294
+
295
+ const res = await handler(req, ctx);
296
+
297
+ // Compress and cache in the background via clone() so the original
298
+ // stream is returned to the client immediately.
299
+ if (key != null && res) {
300
+ const ttl = cacheOpts?.ttl;
301
+ res.clone().arrayBuffer().then(async (buf) => {
302
+ const body = await gzipCompress(new Uint8Array(buf));
303
+ const headers: [string, string][] = [];
304
+ res.headers.forEach((v, k) => {
305
+ if (k.toLowerCase() !== 'content-encoding' && k.toLowerCase() !== 'content-length') {
306
+ headers.push([k, v]);
307
+ }
308
+ });
309
+ headers.push(['content-encoding', 'gzip']);
310
+ store.set(key, {
311
+ body,
312
+ status: res.status,
313
+ headers,
314
+ expiresAt: ttl != null ? Date.now() + ttl : null,
315
+ });
316
+ }).catch(() => { /* ignore read errors on clone */ });
317
+ }
318
+
319
+ return res;
320
+ };
321
+ }
322
+
233
323
  interface HadarsRuntimeOptions extends HadarsOptions {
234
324
  mode: "development" | "production";
235
325
  }
@@ -639,7 +729,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
639
729
  fs.readFile(pathMod.join(__dirname, StaticPath, 'out.html'), 'utf-8')
640
730
  );
641
731
 
642
- await serve(port, async (req, ctx) => {
732
+ const runHandler: CacheFetchHandler = async (req, ctx) => {
643
733
  const request = parseRequest(req);
644
734
  if (handler) {
645
735
  const res = await handler(request);
@@ -715,5 +805,11 @@ export const run = async (options: HadarsRuntimeOptions) => {
715
805
  console.error('[hadars] SSR render error:', err);
716
806
  return new Response('Internal Server Error', { status: 500 });
717
807
  }
718
- }, options.websocket);
808
+ };
809
+
810
+ await serve(
811
+ port,
812
+ options.cache ? createRenderCache(options.cache, runHandler) : runHandler,
813
+ options.websocket,
814
+ );
719
815
  };
@@ -93,6 +93,24 @@ export interface HadarsOptions {
93
93
  * Has no effect on the SSR bundle or dev mode.
94
94
  */
95
95
  optimization?: Record<string, unknown>;
96
+ /**
97
+ * SSR response cache for `run()` mode. Has no effect in `dev()` mode.
98
+ *
99
+ * Receives the incoming request and should return `{ key, ttl? }` to cache
100
+ * the response, or `null`/`undefined` to skip caching for that request.
101
+ * `ttl` is the time-to-live in milliseconds; omit for entries that never expire.
102
+ * The function may be async.
103
+ *
104
+ * @example
105
+ * // Cache every page by pathname (no per-user personalisation):
106
+ * cache: (req) => ({ key: req.pathname })
107
+ *
108
+ * @example
109
+ * // Cache with a per-route TTL, skip authenticated requests:
110
+ * cache: (req) => req.cookies.session ? null : { key: req.pathname, ttl: 60_000 }
111
+ */
112
+ cache?: (req: HadarsRequest) => { key: string; ttl?: number } | null | undefined
113
+ | Promise<{ key: string; ttl?: number } | null | undefined>;
96
114
  }
97
115
 
98
116