htmx-router 2.0.2 → 2.0.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.
@@ -5,6 +5,8 @@ export type Config = {
5
5
  build: () => Promise<RouterModule> | Promise<RouterModule>;
6
6
  render: GenericContext["render"];
7
7
  viteDevServer: ViteDevServer | null;
8
+ poweredBy?: boolean;
9
+ timers?: boolean;
8
10
  };
9
11
  type ServerBindType = "pre" | "post";
10
12
  type ServerBind = (ctx: GenericContext) => Promise<Response | null> | Response | null;
@@ -13,6 +15,8 @@ export declare class HtmxRouterServer {
13
15
  readonly vite: Config["viteDevServer"];
14
16
  readonly render: Config["render"];
15
17
  readonly build: Config["build"];
18
+ readonly poweredBy: boolean;
19
+ readonly timers: boolean;
16
20
  constructor(config: Config);
17
21
  /**
18
22
  * Add a middleware to resolve requests before/after the route tree attempts to resolve
@@ -19,9 +19,13 @@ export class HtmxRouterServer {
19
19
  vite;
20
20
  render;
21
21
  build;
22
+ poweredBy;
23
+ timers;
22
24
  #binding;
23
25
  constructor(config) {
24
26
  this.vite = config.viteDevServer;
27
+ this.poweredBy = config.poweredBy === undefined ? true : config.poweredBy;
28
+ this.timers = config.timers === undefined ? !!config.viteDevServer : config.timers;
25
29
  this.render = config.render;
26
30
  this.build = config.build;
27
31
  this.#binding = {
@@ -49,22 +53,22 @@ export class HtmxRouterServer {
49
53
  }
50
54
  async resolve(request, resolve404 = true) {
51
55
  const url = new URL(request.url);
52
- const ctx = new GenericContext(request, url, this.render);
56
+ const ctx = new GenericContext(request, url, this);
53
57
  { // pre-binding
54
58
  const res = await this.#applyBindings("pre", ctx);
55
59
  if (res)
56
- return res;
60
+ return ctx.finalize(res);
57
61
  }
58
62
  const tree = await this.#getTree();
59
63
  { // route
60
64
  const res = await this.#resolveRoute(ctx, tree);
61
65
  if (res)
62
- return res;
66
+ return ctx.finalize(res);
63
67
  }
64
68
  { // post-binding
65
69
  const res = await this.#applyBindings("post", ctx);
66
70
  if (res)
67
- return res;
71
+ return ctx.finalize(res);
68
72
  }
69
73
  if (resolve404)
70
74
  return await this.error(ctx, undefined);
@@ -79,9 +83,10 @@ export class HtmxRouterServer {
79
83
  if (e === undefined)
80
84
  e = new Response("No Route", MakeStatus("Not Found"));
81
85
  if (ctx instanceof Request)
82
- ctx = new GenericContext(ctx, new URL(ctx.url), this.render);
86
+ ctx = new GenericContext(ctx, new URL(ctx.url), this);
83
87
  const tree = await this.#getTree();
84
- return await tree.unwrap(ctx, e);
88
+ const res = await tree.unwrap(ctx, e);
89
+ return ctx.finalize(res);
85
90
  }
86
91
  /**
87
92
  * Create a closure for use with the classic express.js like servers
@@ -1,17 +1,22 @@
1
+ import type { HtmxRouterServer } from "./request/server.js";
1
2
  import { ParameterShaper } from '../util/parameters.js';
2
3
  import { RouteContext } from "../router.js";
4
+ import { RequestTimer } from "../timer.js";
3
5
  import { Cookies } from '../cookies.js';
4
6
  type Rendered = Response | BodyInit;
5
7
  export declare class GenericContext {
8
+ #private;
6
9
  request: Request;
7
10
  headers: Headers;
8
11
  cookie: Cookies;
9
12
  params: {
10
13
  [key: string]: string;
11
14
  };
15
+ timer: RequestTimer;
12
16
  url: URL;
13
- render: (res: JSX.Element, headers: Headers) => Promise<Rendered> | Rendered;
14
- constructor(request: GenericContext["request"], url: GenericContext["url"], renderer: GenericContext["render"]);
17
+ constructor(request: GenericContext["request"], url: GenericContext["url"], scope: HtmxRouterServer);
18
+ render(res: JSX.Element, headers: Headers): Promise<Rendered> | Rendered;
19
+ finalize(res: Response): Response;
15
20
  shape<T extends ParameterShaper>(shape: T, path: string): RouteContext<T>;
16
21
  }
17
22
  export {};
@@ -1,24 +1,37 @@
1
1
  import { ServerOnlyWarning } from "./util.js";
2
2
  ServerOnlyWarning("internal/router");
3
3
  import { RouteContext } from "../router.js";
4
+ import { RequestTimer } from "../timer.js";
4
5
  import { Cookies } from '../cookies.js';
5
6
  export class GenericContext {
6
7
  request;
7
8
  headers; // response headers
8
9
  cookie;
9
10
  params;
11
+ timer;
10
12
  url;
11
- render;
12
- constructor(request, url, renderer) {
13
+ #server;
14
+ constructor(request, url, scope) {
15
+ this.#server = scope;
13
16
  this.cookie = new Cookies(request.headers.get("cookie"));
14
17
  this.headers = new Headers();
15
18
  this.request = request;
16
19
  this.params = {};
17
20
  this.url = url;
18
- this.render = renderer;
21
+ this.timer = new RequestTimer(scope.timers);
22
+ if (scope.poweredBy)
23
+ this.headers.set("X-Powered-By", "htmx-router");
19
24
  this.headers.set("x-powered-by", "htmx-router");
20
25
  this.headers.set("content-type", "text/html");
21
26
  }
27
+ render(res, headers) {
28
+ this.timer.checkpoint("render");
29
+ return this.#server.render(res, headers);
30
+ }
31
+ finalize(res) {
32
+ this.timer.writeTo(res.headers);
33
+ return res;
34
+ }
22
35
  shape(shape, path) {
23
36
  return new RouteContext(this, this.params, shape, path);
24
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmx-router",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "A lightweight SSR framework with server+client islands",
5
5
  "keywords": [
6
6
  "htmx",
package/response.js CHANGED
@@ -1,55 +1,60 @@
1
1
  export function text(text, init) {
2
- init ||= {};
3
- init.statusText ||= "ok";
4
- init.status ||= 200;
2
+ init = FillResponseInit(200, "Ok", init);
5
3
  const res = new Response(text, init);
6
4
  res.headers.set("Content-Type", "text/plain");
7
5
  res.headers.set("X-Caught", "true");
8
6
  return res;
9
7
  }
10
8
  export function html(text, init) {
11
- init ||= {};
12
- init.statusText ||= "ok";
13
- init.status ||= 200;
9
+ init = FillResponseInit(200, "Ok", init);
14
10
  const res = new Response(text, init);
15
11
  res.headers.set("Content-Type", "text/html; charset=UTF-8");
16
12
  res.headers.set("X-Caught", "true");
17
13
  return res;
18
14
  }
19
15
  export function json(data, init) {
20
- init ||= {};
21
- init.statusText ||= "ok";
22
- init.status ||= 200;
16
+ init = FillResponseInit(200, "Ok", init);
23
17
  const res = new Response(JSON.stringify(data), init);
24
18
  res.headers.set("Content-Type", "application/json");
25
19
  res.headers.set("X-Caught", "true");
26
20
  return res;
27
21
  }
28
22
  export function redirect(url, init) {
29
- init ||= {};
30
- init.statusText ||= "Temporary Redirect";
31
- init.status ||= 307;
23
+ init = FillResponseInit(307, "Temporary Redirect", init);
32
24
  const res = new Response("", init);
33
25
  if (!init?.clientOnly)
34
26
  res.headers.set("Location", url);
35
27
  res.headers.set("HX-Location", url); // use hx-boost if applicable
28
+ res.headers.set("X-Caught", "true");
36
29
  return res;
37
30
  }
38
31
  export function revalidate(init) {
39
- init ||= {};
40
- init.statusText ||= "ok";
41
- init.status ||= 200;
32
+ init = FillResponseInit(200, "Ok", init);
42
33
  const res = new Response("", init);
43
34
  res.headers.set("HX-Location", "");
35
+ res.headers.set("HX-Replace-Url", "");
36
+ res.headers.set("X-Caught", "true");
44
37
  return res;
45
38
  }
46
39
  export function refresh(init) {
47
- init ||= {};
48
- init.statusText ||= "ok";
49
- init.status ||= 200;
40
+ init = FillResponseInit(200, "Ok", init);
50
41
  const res = new Response("", init);
51
42
  if (!init?.clientOnly)
52
43
  res.headers.set("Refresh", "0"); // fallback
53
44
  res.headers.set("HX-Refresh", "true");
45
+ res.headers.set("X-Caught", "true");
54
46
  return res;
55
47
  }
48
+ /**
49
+ * This is to fix issues with deno
50
+ * When you try and change the statusText on a Response object
51
+ */
52
+ function FillResponseInit(status, statusText, init) {
53
+ if (init === undefined)
54
+ init = {};
55
+ if (init.statusText === undefined)
56
+ init.statusText = statusText;
57
+ if (init.status === undefined)
58
+ init.status = status;
59
+ return init;
60
+ }
package/router.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { GenericContext } from "./internal/router.js";
2
+ import type { RequestTimer } from "./timer.js";
2
3
  import { Parameterized, ParameterPrelude, ParameterShaper } from './util/parameters.js';
3
4
  import { RouteModule } from "./index.js";
4
5
  import { Cookies } from './cookies.js';
@@ -13,6 +14,7 @@ export declare class RouteContext<T extends ParameterShaper = {}> {
13
14
  readonly cookie: Cookies;
14
15
  readonly params: Parameterized<T>;
15
16
  readonly url: URL;
17
+ readonly timer: RequestTimer;
16
18
  render: GenericContext["render"];
17
19
  constructor(base: GenericContext | RouteContext, params: ParameterPrelude<T>, shape: T, path: string);
18
20
  }
package/router.js CHANGED
@@ -36,6 +36,7 @@ export class RouteContext {
36
36
  cookie;
37
37
  params;
38
38
  url;
39
+ timer;
39
40
  render;
40
41
  constructor(base, params, shape, path) {
41
42
  this.path = path;
@@ -43,6 +44,7 @@ export class RouteContext {
43
44
  this.headers = base.headers;
44
45
  this.request = base.request;
45
46
  this.render = base.render;
47
+ this.timer = base.timer;
46
48
  this.url = base.url;
47
49
  this.params = {};
48
50
  for (const key in shape) {
package/timer.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Tracks timing checkpoints during request processing and writes performance
3
+ * metrics to HTTP response headers.
4
+ *
5
+ * **HTTP Headers Generated:**
6
+ * - `X-Time-Total`: Total request processing time in milliseconds
7
+ * - `X-Time-{name}`: Duration for each checkpoint in milliseconds
8
+ *
9
+ * **Default Checkpoints:**
10
+ * - `route`: Automatically created when timer is enabled (marks start of request)
11
+ * - `render`: Automatically triggered when JSX gets rendered to a string
12
+ */
13
+ export declare class RequestTimer {
14
+ #private;
15
+ constructor(enabled: boolean);
16
+ /**
17
+ * Creates a timing checkpoint with the given name.
18
+ *
19
+ * Each checkpoint measures the time elapsed since the previous checkpoint.
20
+ * The resulting duration is written to the `X-Time-{name}` HTTP header.
21
+ *
22
+ * **Common Usage Pattern:**
23
+ * ```ts
24
+ * timer.checkpoint("auth"); // -> X-Time-auth: time from auth to fetch
25
+ * timer.checkpoint("fetch"); // -> X-Time-auth: time from fetch to transform
26
+ * timer.checkpoint("transform"); // -> X-Time-fetch: time from transform to render
27
+ * // render checkpoint added automatically during JSX rendering
28
+ * // // -> X-Time-render: time from render to response
29
+ * ```
30
+ *
31
+ * @param name - The name of the checkpoint (used in the HTTP header)
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * export async function loader({ timer }: RouteContext) {
36
+ * timer.checkpoint("auth");
37
+ * await authenticate();
38
+ *
39
+ * timer.checkpoint("fetch");
40
+ * const data = await fetchData();
41
+ *
42
+ * return json(data);
43
+ * }
44
+ * ```
45
+ */
46
+ checkpoint(name: string): void;
47
+ writeTo(headers: Headers): void;
48
+ }
package/timer.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tracks timing checkpoints during request processing and writes performance
3
+ * metrics to HTTP response headers.
4
+ *
5
+ * **HTTP Headers Generated:**
6
+ * - `X-Time-Total`: Total request processing time in milliseconds
7
+ * - `X-Time-{name}`: Duration for each checkpoint in milliseconds
8
+ *
9
+ * **Default Checkpoints:**
10
+ * - `route`: Automatically created when timer is enabled (marks start of request)
11
+ * - `render`: Automatically triggered when JSX gets rendered to a string
12
+ */
13
+ export class RequestTimer {
14
+ #checkpoints;
15
+ constructor(enabled) {
16
+ if (!enabled) {
17
+ this.#checkpoints = null;
18
+ return;
19
+ }
20
+ this.#checkpoints = [];
21
+ this.checkpoint("route");
22
+ }
23
+ /**
24
+ * Creates a timing checkpoint with the given name.
25
+ *
26
+ * Each checkpoint measures the time elapsed since the previous checkpoint.
27
+ * The resulting duration is written to the `X-Time-{name}` HTTP header.
28
+ *
29
+ * **Common Usage Pattern:**
30
+ * ```ts
31
+ * timer.checkpoint("auth"); // -> X-Time-auth: time from auth to fetch
32
+ * timer.checkpoint("fetch"); // -> X-Time-auth: time from fetch to transform
33
+ * timer.checkpoint("transform"); // -> X-Time-fetch: time from transform to render
34
+ * // render checkpoint added automatically during JSX rendering
35
+ * // // -> X-Time-render: time from render to response
36
+ * ```
37
+ *
38
+ * @param name - The name of the checkpoint (used in the HTTP header)
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * export async function loader({ timer }: RouteContext) {
43
+ * timer.checkpoint("auth");
44
+ * await authenticate();
45
+ *
46
+ * timer.checkpoint("fetch");
47
+ * const data = await fetchData();
48
+ *
49
+ * return json(data);
50
+ * }
51
+ * ```
52
+ */
53
+ checkpoint(name) {
54
+ if (this.#checkpoints === null)
55
+ return;
56
+ this.#checkpoints.push(new Checkpoint(name));
57
+ }
58
+ writeTo(headers) {
59
+ if (this.#checkpoints === null)
60
+ return;
61
+ if (this.#checkpoints.length < 1)
62
+ return;
63
+ const end = Date.now();
64
+ const first = this.#checkpoints[0];
65
+ headers.set(`X-Time-Total`, String(end - first.time));
66
+ const limit = this.#checkpoints.length - 1;
67
+ for (let i = 0; i < limit; i++) {
68
+ const c = this.#checkpoints[i];
69
+ const n = this.#checkpoints[i + 1];
70
+ headers.set(`X-Time-${c.name}`, String(n.time - c.time));
71
+ }
72
+ const last = this.#checkpoints[limit];
73
+ headers.set(`X-Time-${last.name}`, String(end - last.time));
74
+ }
75
+ }
76
+ class Checkpoint {
77
+ time;
78
+ name;
79
+ constructor(name) {
80
+ this.time = Date.now();
81
+ this.name = name;
82
+ }
83
+ }