htmx-router 2.1.5 → 2.2.0

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,7 @@ type Render = (jsx: JSX.Element) => string;
5
5
  */
6
6
  export declare class EventSource<JsxEnabled extends boolean = false> {
7
7
  #private;
8
+ readonly _signal: AbortSignal;
8
9
  readonly response: Response;
9
10
  readonly createdAt: number;
10
11
  get updatedAt(): number;
@@ -20,11 +21,15 @@ export declare class EventSource<JsxEnabled extends boolean = false> {
20
21
  * For internal use only
21
22
  * @deprecated
22
23
  */
23
- _keepAlive(): boolean;
24
+ pulse(): boolean;
24
25
  dispatch(type: string, data: JsxEnabled extends true ? (JSX.Element | string) : string): boolean;
25
26
  close(): boolean;
26
27
  }
27
28
  export declare class EventSourceSet<JsxEnabled extends boolean = false> extends Set<EventSource<JsxEnabled>> {
29
+ private onAbort;
30
+ constructor();
31
+ add(stream: EventSource<JsxEnabled>): this;
32
+ delete(stream: EventSource<JsxEnabled>): boolean;
28
33
  /**
29
34
  * Send update to all EventSources, auto closing failed dispatches
30
35
  * @returns number of successful sends
@@ -35,6 +40,11 @@ export declare class EventSourceSet<JsxEnabled extends boolean = false> extends
35
40
  * @returns number of connections closed
36
41
  */
37
42
  cull(): number;
43
+ /**
44
+ * INTERNAL: Send keep-alive to all EventSources
45
+ * @deprecated
46
+ */
47
+ pulse(): number;
38
48
  /**
39
49
  * Close all connections
40
50
  * @returns number of connections closed
@@ -1,5 +1,7 @@
1
1
  var _a;
2
2
  import { ServerOnlyWarning } from "./internal/util.js";
3
+ import { MakeStatus } from './status.js';
4
+ import { Lifecycle } from "./event.js";
3
5
  ServerOnlyWarning("event-source");
4
6
  // global for easy reuse
5
7
  const encoder = new TextEncoder();
@@ -17,8 +19,8 @@ const headers = {
17
19
  * Includes a keep alive empty packet sent every 30sec (because Chrome implodes at 120sec, and can be unreliable at 60sec)
18
20
  */
19
21
  export class EventSource {
22
+ _signal;
20
23
  #controller;
21
- #signal;
22
24
  #state;
23
25
  #render;
24
26
  response;
@@ -38,21 +40,26 @@ export class EventSource {
38
40
  constructor(request, render) {
39
41
  this.#controller = null;
40
42
  this.#state = _a.CONNECTING;
43
+ this._signal = request.signal;
41
44
  this.#render = render;
42
45
  this.withCredentials = request.mode === "cors";
43
46
  this.url = request.url;
44
47
  this.createdAt = Date.now();
45
48
  this.#updatedAt = 0;
49
+ if (Lifecycle.isShuttingDown()) {
50
+ this.response = new Response('Server is shutting down', MakeStatus('Service Unavailable'));
51
+ this.#state = _a.CLOSED;
52
+ return;
53
+ }
46
54
  // immediate prepare for abortion
47
- this.#signal = request.signal;
48
- const cancel = () => { this.close(); this.#signal.removeEventListener("abort", cancel); };
49
- this.#signal.addEventListener('abort', cancel);
55
+ const cancel = () => { this.close(); this._signal.removeEventListener("abort", cancel); };
56
+ this._signal.addEventListener('abort', cancel);
50
57
  const start = (c) => { this.#controller = c; this.#state = _a.OPEN; };
51
58
  const stream = new ReadableStream({ start, cancel }, { highWaterMark: 0 });
52
59
  this.response = new Response(stream, { headers });
53
60
  keepAlive.add(this);
54
61
  }
55
- isAborted() { return this.#signal.aborted; }
62
+ isAborted() { return this._signal.aborted; }
56
63
  #sendBytes(chunk, active) {
57
64
  if (this.#state === _a.CLOSED) {
58
65
  const err = new Error(`Warn: Attempted to send data on closed stream for: ${this.url}`);
@@ -83,7 +90,7 @@ export class EventSource {
83
90
  * For internal use only
84
91
  * @deprecated
85
92
  */
86
- _keepAlive() {
93
+ pulse() {
87
94
  return this.#sendText("\n\n", false);
88
95
  }
89
96
  dispatch(type, data) {
@@ -119,6 +126,19 @@ export class EventSource {
119
126
  }
120
127
  _a = EventSource;
121
128
  export class EventSourceSet extends Set {
129
+ onAbort;
130
+ constructor() {
131
+ super();
132
+ this.onAbort = () => this.cull();
133
+ }
134
+ add(stream) {
135
+ stream._signal.addEventListener('abort', this.onAbort);
136
+ return super.add(stream);
137
+ }
138
+ delete(stream) {
139
+ stream._signal.removeEventListener('abort', this.onAbort);
140
+ return super.delete(stream);
141
+ }
122
142
  /**
123
143
  * Send update to all EventSources, auto closing failed dispatches
124
144
  * @returns number of successful sends
@@ -126,8 +146,8 @@ export class EventSourceSet extends Set {
126
146
  dispatch(type, data) {
127
147
  let count = 0;
128
148
  for (const stream of this) {
129
- if (stream.readyState === 0)
130
- continue; // skip initializing
149
+ if (stream.readyState !== EventSource.OPEN)
150
+ continue; // skip closed
131
151
  const success = stream.dispatch(type, data);
132
152
  if (success)
133
153
  count++;
@@ -143,12 +163,27 @@ export class EventSourceSet extends Set {
143
163
  cull() {
144
164
  const count = this.size;
145
165
  for (const stream of this) {
146
- if (stream.readyState !== 2)
166
+ if (stream.readyState !== EventSource.CLOSED)
147
167
  continue;
148
168
  this.delete(stream);
149
169
  }
150
170
  return count;
151
171
  }
172
+ /**
173
+ * INTERNAL: Send keep-alive to all EventSources
174
+ * @deprecated
175
+ */
176
+ pulse() {
177
+ let count = 0;
178
+ for (const stream of this) {
179
+ if (stream.readyState !== EventSource.OPEN)
180
+ continue; // skip closed
181
+ const success = stream.pulse();
182
+ if (success)
183
+ count++;
184
+ }
185
+ return count;
186
+ }
152
187
  /**
153
188
  * Close all connections
154
189
  * @returns number of connections closed
@@ -171,8 +206,8 @@ export class SharedEventSource {
171
206
  #cache;
172
207
  #rules;
173
208
  constructor(props) {
174
- this.#pool = new EventSourceSet();
175
209
  this.#render = props?.render || undefined;
210
+ this.#pool = new EventSourceSet();
176
211
  this.#cache = {};
177
212
  this.#rules = {};
178
213
  }
@@ -221,18 +256,9 @@ export class SharedEventSource {
221
256
  }
222
257
  // Auto close all SSE streams when shutdown requested
223
258
  // Without this graceful shutdowns will hang indefinitely
224
- const keepAlive = new Set();
225
- const interval = setInterval(() => {
226
- for (const e of keepAlive) {
227
- if (e.readyState === EventSource.CLOSED) {
228
- keepAlive.delete(e);
229
- continue;
230
- }
231
- e._keepAlive();
232
- }
233
- }, 10_000);
234
- function Shutdown() { clearInterval(interval); }
235
- if (process) {
236
- process.on('SIGTERM', Shutdown);
237
- process.on('SIGHUP', Shutdown);
238
- }
259
+ const keepAlive = new EventSourceSet();
260
+ const interval = setInterval(() => { keepAlive.pulse(); }, 10_000);
261
+ // Lifecycle.addEventListener('shutdown', () => {
262
+ // clearInterval(interval);
263
+ // keepAlive.closeAll();
264
+ // });
@@ -0,0 +1,31 @@
1
+ declare global {
2
+ const Deno: {
3
+ build: {
4
+ os: 'windows' | 'linux' | 'darwin' | string;
5
+ };
6
+ addSignalListener: (signal: string, handler: () => void) => void;
7
+ } | undefined;
8
+ }
9
+ export declare class ShutdownEvent extends CustomEvent<{
10
+ signal: string;
11
+ }> {
12
+ constructor(signal: string);
13
+ }
14
+ /**
15
+ * Controller for handling graceful shutdowns across Windows, Linux, and Browsers.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * Lifecycle.addEventListener('shutdown', (e) => {
20
+ * console.log('Stopping because of:', e.detail.signal);
21
+ * server.close();
22
+ * });
23
+ * ```
24
+ */
25
+ declare class LifecycleController extends EventTarget {
26
+ #private;
27
+ constructor();
28
+ isShuttingDown(): boolean;
29
+ }
30
+ export declare const Lifecycle: LifecycleController;
31
+ export {};
package/dist/event.js ADDED
@@ -0,0 +1,52 @@
1
+ export class ShutdownEvent extends CustomEvent {
2
+ constructor(signal) {
3
+ super('shutdown', { detail: { signal } });
4
+ }
5
+ }
6
+ /**
7
+ * Controller for handling graceful shutdowns across Windows, Linux, and Browsers.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * Lifecycle.addEventListener('shutdown', (e) => {
12
+ * console.log('Stopping because of:', e.detail.signal);
13
+ * server.close();
14
+ * });
15
+ * ```
16
+ */
17
+ class LifecycleController extends EventTarget {
18
+ #shuttingDown = false;
19
+ constructor() {
20
+ super();
21
+ this.#bindEvents();
22
+ }
23
+ isShuttingDown() { return this.#shuttingDown; }
24
+ // Bind to whatever environment we are running in
25
+ #bindEvents() {
26
+ const trigger = (sig) => this.#trigger(sig);
27
+ if (globalThis.window && globalThis.window.addEventListener) {
28
+ // BROWSER: Tab closing / Refreshing
29
+ globalThis.window.addEventListener('beforeunload', () => trigger('BEFOREUNLOAD'));
30
+ }
31
+ if (process) {
32
+ process.on('SIGINT', () => trigger('SIGINT'));
33
+ process.on('SIGTERM', () => trigger('SIGTERM'));
34
+ process.on('SIGHUP', () => trigger('SIGHUP'));
35
+ }
36
+ if (Deno) {
37
+ if (Deno.build.os !== "windows") {
38
+ Deno.addSignalListener('SIGTERM', () => trigger('SIGTERM'));
39
+ }
40
+ Deno.addSignalListener("SIGHUP", () => trigger('SIGHUP'));
41
+ Deno.addSignalListener("SIGINT", () => trigger('SIGTERM'));
42
+ }
43
+ }
44
+ #trigger(signal) {
45
+ if (this.#shuttingDown)
46
+ return;
47
+ this.#shuttingDown = true;
48
+ console.log(`\n🛑 Shutdown Signal received: ${signal}`);
49
+ this.dispatchEvent(new ShutdownEvent(signal));
50
+ }
51
+ }
52
+ export const Lifecycle = new LifecycleController();
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ParameterShaper } from "./util/parameters.js";
2
+ import type { GenericContext } from "./internal/router.js";
2
3
  import type { RouteContext } from "./router.js";
3
4
  export type RenderFunction<T extends ParameterShaper = {}> = (ctx: RouteContext<T>) => Promise<Response | JSX.Element | null>;
4
5
  export type CatchFunction<T extends ParameterShaper = {}> = (ctx: RouteContext<T>, err: unknown) => Promise<Response | JSX.Element>;
@@ -15,4 +16,4 @@ export type ClientIslandManifest<T> = {
15
16
  type ClientIsland<T> = T extends (props: infer P) => JSX.Element ? (props: P & {
16
17
  children?: JSX.Element;
17
18
  }) => JSX.Element : T;
18
- export { RouteContext };
19
+ export { RouteContext, GenericContext };
@@ -11,6 +11,8 @@ export function Defer<T extends ParameterShaper>(props: {
11
11
  hx-get={Deferral(props.loader, props.params)}
12
12
  hx-trigger="load"
13
13
  hx-swap="outerHTML transition:true"
14
+ hx-replace-url="false"
15
+ hx-push-url="false"
14
16
  style={{ display: "contents" }}
15
17
  >{props.children ? props.children : ""}</div>
16
18
  }`;
@@ -1,17 +1,24 @@
1
1
  import { ServerOnlyWarning } from "../util.js";
2
2
  ServerOnlyWarning("request/server");
3
3
  import { connectToWeb } from "./compatibility/vite/connectToWeb.js";
4
+ import { RouteResolver } from "../../router.js";
4
5
  import { GenericContext } from "../router.js";
5
6
  import { NodeAdaptor } from "./compatibility/node.js";
6
7
  import { MakeStatus } from "../../status.js";
7
8
  import { redirect } from "../../response.js";
8
9
  function UrlCleaner({ url }) {
9
- const i = url.pathname.lastIndexOf("/");
10
+ let i = url.pathname.lastIndexOf("/");
10
11
  if (i === 0)
11
12
  return null;
12
13
  if (i !== url.pathname.length - 1)
13
14
  return null;
14
- url.pathname = url.pathname.slice(0, -1);
15
+ i--;
16
+ while (url.pathname[i] === '/' && i > 0)
17
+ i--;
18
+ if (i === 0)
19
+ url.pathname = '/';
20
+ else
21
+ url.pathname = url.pathname.slice(0, i);
15
22
  return redirect(url.toString(), { permanent: true });
16
23
  }
17
24
  export class HtmxRouterServer {
@@ -110,7 +117,8 @@ export class HtmxRouterServer {
110
117
  if (ctx instanceof Request)
111
118
  ctx = new GenericContext(ctx, new URL(ctx.url), this);
112
119
  const tree = await this.#getTree();
113
- const res = await tree.unwrap(ctx, e);
120
+ const chain = new RouteResolver(ctx, [], tree);
121
+ const res = await chain.unwind(e, 0);
114
122
  return await this.transform(ctx, res);
115
123
  }
116
124
  /**
@@ -125,10 +133,11 @@ export class HtmxRouterServer {
125
133
  }
126
134
  async #resolveRoute(ctx, tree) {
127
135
  let response;
136
+ const x = ctx.url.pathname.slice(1);
137
+ const fragments = x === "" ? [] : x.split("/");
138
+ const chain = new RouteResolver(ctx, fragments, tree);
128
139
  try {
129
- const x = ctx.url.pathname.slice(1);
130
- const fragments = x === "" ? [] : x.split("/");
131
- const res = await tree.resolve(fragments, ctx);
140
+ const res = await chain.resolve();
132
141
  if (res === null)
133
142
  return null;
134
143
  response = res;
@@ -137,7 +146,7 @@ export class HtmxRouterServer {
137
146
  if (e instanceof Error)
138
147
  this.vite?.ssrFixStacktrace(e);
139
148
  console.error(e);
140
- response = await tree.unwrap(ctx, e);
149
+ response = await chain.unwind(e, 0);
141
150
  }
142
151
  // context merge headers if divergent
143
152
  if (response.headers !== ctx.headers) {
package/dist/response.js CHANGED
@@ -108,17 +108,29 @@ export function AssertETagStale(request, headers, etag, options) {
108
108
  headers.append("Cache-Control", `max-age=${options.revalidate}`);
109
109
  }
110
110
  headers.append("Cache-Control", "must-revalidate");
111
- headers.set("ETag", `"${encodeURIComponent(etag.trim())}"`);
112
- const client = request.headers.get("if-none-match");
113
- if (client !== etag)
111
+ etag = encodeURIComponent(etag.trim()); // safely handle any special characters
112
+ headers.set("ETag", `"${etag}"`);
113
+ const rules = request.headers.get("if-none-match");
114
+ if (!rules || !MatchEtags(rules.trim(), etag))
114
115
  return;
115
- const res = new Response(null, {
116
- status: 304, statusText: "Not Modified",
117
- headers
118
- });
116
+ const res = new Response(null, { headers, status: 304, statusText: "Not Modified" });
119
117
  res.headers.set("X-Caught", "true");
120
118
  throw res;
121
119
  }
120
+ function MatchEtags(header, etag) {
121
+ if (header === "*")
122
+ return true;
123
+ for (const term of header.split(/,\s*/)) {
124
+ let s = term.startsWith('W/') ? 'W/'.length : 0;
125
+ let e = term.endsWith('"') ? term.length - 1 : term.length;
126
+ if (term.startsWith('"', s))
127
+ s++;
128
+ const tag = term.slice(s, e);
129
+ if (etag === tag)
130
+ return true;
131
+ }
132
+ return false;
133
+ }
122
134
  /**
123
135
  * This is to fix issues with deno
124
136
  * When you try and change the statusText on a Response object
package/dist/router.d.ts CHANGED
@@ -18,6 +18,15 @@ export declare class RouteContext<T extends ParameterShaper = {}> {
18
18
  render: GenericContext["render"];
19
19
  constructor(base: GenericContext | RouteContext, params: ParameterPrelude<T>, shape: T, path: string);
20
20
  }
21
+ export declare class RouteResolver {
22
+ private stack;
23
+ private slugs;
24
+ readonly ctx: GenericContext;
25
+ constructor(ctx: GenericContext, fragments: string[], tree: RouteTree);
26
+ push(leaf: RouteLeaf, slug?: string): void;
27
+ resolve(): Promise<Response | null>;
28
+ unwind(e: unknown, offset: number): Promise<Response>;
29
+ }
21
30
  export declare class RouteTree {
22
31
  private nested;
23
32
  private index;
@@ -26,20 +35,11 @@ export declare class RouteTree {
26
35
  private wildCard;
27
36
  constructor();
28
37
  ingest(node: RouteLeaf, path?: string[]): void;
29
- resolve(fragments: string[], ctx: GenericContext): Promise<Response | null>;
30
- private _resolve;
31
- private resolveIndex;
32
- private resolveNext;
33
- private resolveWild;
34
- private resolveSlug;
35
- unwrap(ctx: GenericContext, res: unknown): Promise<Response>;
38
+ _applyChain(out: RouteResolver, fragments: string[], offset?: number): void;
36
39
  }
37
40
  declare class RouteLeaf {
38
- private module;
41
+ readonly module: RouteModule<any>;
39
42
  readonly path: string;
40
43
  constructor(module: RouteModule<any>, path: string);
41
- resolve(ctx: GenericContext): Promise<Response | null>;
42
- error(ctx: GenericContext, e: unknown): Promise<Response>;
43
- private response;
44
44
  }
45
45
  export {};
package/dist/router.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AssertUnreachable, ServerOnlyWarning } from "./internal/util.js";
1
+ import { ServerOnlyWarning } from "./internal/util.js";
2
2
  ServerOnlyWarning("router");
3
3
  import { MakeStatus } from "./status.js";
4
4
  // builtin routes
@@ -59,6 +59,78 @@ export class RouteContext {
59
59
  }
60
60
  }
61
61
  }
62
+ export class RouteResolver {
63
+ // using two seperate arrays to reduce object creation
64
+ // since these values are just pointers
65
+ stack;
66
+ slugs;
67
+ ctx;
68
+ constructor(ctx, fragments, tree) {
69
+ this.stack = [];
70
+ this.slugs = [];
71
+ this.ctx = ctx;
72
+ tree._applyChain(this, fragments, 0);
73
+ }
74
+ push(leaf, slug) {
75
+ this.stack.push(leaf);
76
+ this.slugs.push(slug);
77
+ }
78
+ async resolve() {
79
+ const pull = this.ctx.request.method === "HEAD" || this.ctx.request.method === "GET";
80
+ for (let i = this.stack.length - 1; i >= 0; i--) {
81
+ const node = this.stack[i];
82
+ // Determine the resolving function
83
+ const resolver = pull ? node.module.loader : node.module.action;
84
+ if (!resolver) {
85
+ if (pull)
86
+ continue;
87
+ return this.unwind(new Response("Method not Allowed", MakeStatus("Method Not Allowed", this.ctx.headers)), i);
88
+ }
89
+ // Apply the slug pseudo parameter if necessary
90
+ if (this.slugs[i])
91
+ this.ctx.params['$'] = this.slugs[i];
92
+ try {
93
+ const context = this.ctx.shape(node.module.parameters || {}, node.path);
94
+ const jsx = await resolver(context);
95
+ if (jsx === null)
96
+ continue;
97
+ if (jsx instanceof Response)
98
+ return jsx;
99
+ const res = this.ctx.render(jsx);
100
+ return html(res, { headers: this.ctx.headers });
101
+ }
102
+ catch (e) {
103
+ return await this.unwind(e, i);
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ async unwind(e, offset) {
109
+ for (let i = this.stack.length - 1; i >= 0; i--) {
110
+ const node = this.stack[i];
111
+ if (!node.module.error)
112
+ continue;
113
+ try {
114
+ const jsx = await node.module.error(this.ctx.shape({}, node.path), e);
115
+ let caught;
116
+ if (jsx instanceof Response)
117
+ caught = jsx;
118
+ else
119
+ caught = this.ctx.render(jsx);
120
+ if (caught instanceof Response) {
121
+ caught.headers.set("X-Caught", "true");
122
+ return caught;
123
+ }
124
+ this.ctx.headers.set("X-Caught", "true");
125
+ return html(caught, e instanceof Response ? e : MakeStatus("Internal Server Error", this.ctx.headers));
126
+ }
127
+ catch (next) {
128
+ e = next;
129
+ }
130
+ }
131
+ throw e;
132
+ }
133
+ }
62
134
  export class RouteTree {
63
135
  nested;
64
136
  // Leaf nodes
@@ -105,75 +177,23 @@ export class RouteTree {
105
177
  }
106
178
  next.ingest(node, path.slice(1));
107
179
  }
108
- async resolve(fragments, ctx) {
109
- if (!this.slug)
110
- return await this._resolve(fragments, ctx);
111
- try {
112
- return await this._resolve(fragments, ctx);
180
+ _applyChain(out, fragments, offset = 0) {
181
+ if (this.slug) {
182
+ const slug = fragments.slice(offset).join('/');
183
+ out.push(this.slug, slug);
113
184
  }
114
- catch (e) {
115
- return this.unwrap(ctx, e);
116
- }
117
- }
118
- async _resolve(fragments, ctx) {
119
- let res = await this.resolveIndex(fragments, ctx)
120
- || await this.resolveNext(fragments, ctx)
121
- || await this.resolveWild(fragments, ctx)
122
- || await this.resolveSlug(fragments, ctx);
123
- if (res instanceof Response) {
124
- if (res.ok)
125
- return res;
126
- if (100 <= res.status && res.status <= 399)
127
- return res;
128
- if (res.headers.has("X-Caught"))
129
- return res;
130
- return this.unwrap(ctx, res);
185
+ if (offset === fragments.length) {
186
+ if (this.index)
187
+ out.push(this.index, undefined);
188
+ return;
131
189
  }
132
- return res;
133
- }
134
- async resolveIndex(fragments, ctx) {
135
- if (fragments.length > 0)
136
- return null;
137
- if (!this.index)
138
- return null;
139
- const res = await this.index.resolve(ctx);
140
- if (res instanceof Response)
141
- return res;
142
- if (res === null)
143
- return null;
144
- AssertUnreachable(res);
145
- }
146
- async resolveNext(fragments, ctx) {
147
- if (fragments.length < 1)
148
- return null;
149
- const next = this.nested.get(fragments[0]);
150
- if (!next)
151
- return null;
152
- return await next.resolve(fragments.slice(1), ctx);
153
- }
154
- async resolveWild(fragments, ctx) {
190
+ const keyed = this.nested.get(fragments[offset]);
191
+ if (keyed)
192
+ return keyed._applyChain(out, fragments, offset + 1);
155
193
  if (!this.wild)
156
- return null;
157
- if (fragments.length < 1)
158
- return null;
159
- ctx.params[this.wildCard] = fragments[0];
160
- return this.wild.resolve(fragments.slice(1), ctx);
161
- }
162
- async resolveSlug(fragments, ctx) {
163
- if (!this.slug)
164
- return null;
165
- ctx.params["$"] = fragments.join("/");
166
- const res = await this.slug.resolve(ctx);
167
- if (res instanceof Response)
168
- return res;
169
- if (res === null)
170
- return null;
171
- AssertUnreachable(res);
172
- }
173
- unwrap(ctx, res) {
174
- if (!this.slug)
175
- throw res;
176
- return this.slug.error(ctx, res);
194
+ return;
195
+ out.ctx.params[this.wildCard] = fragments[offset];
196
+ return this.wild._applyChain(out, fragments, offset + 1);
177
197
  }
178
198
  }
179
199
  class RouteLeaf {
@@ -183,51 +203,4 @@ class RouteLeaf {
183
203
  this.module = module;
184
204
  this.path = path;
185
205
  }
186
- async resolve(ctx) {
187
- const jsx = await this.response(ctx);
188
- if (jsx === null)
189
- return null;
190
- if (jsx instanceof Response)
191
- return jsx;
192
- const res = ctx.render(jsx);
193
- return html(res, { headers: ctx.headers });
194
- }
195
- async error(ctx, e) {
196
- if (!this.module.error)
197
- throw e;
198
- const jsx = await this.module.error(ctx.shape({}, this.path), e);
199
- let caught;
200
- if (jsx instanceof Response)
201
- caught = jsx;
202
- else
203
- caught = ctx.render(jsx);
204
- if (caught instanceof Response) {
205
- caught.headers.set("X-Caught", "true");
206
- return caught;
207
- }
208
- ctx.headers.set("X-Caught", "true");
209
- return html(caught, e instanceof Response ? e : MakeStatus("Internal Server Error", ctx.headers));
210
- }
211
- async response(ctx) {
212
- try {
213
- if (!this.module.loader && !this.module.action)
214
- return null;
215
- const context = ctx.shape(this.module.parameters || {}, this.path);
216
- if (ctx.request.method === "HEAD" || ctx.request.method === "GET") {
217
- if (this.module.loader)
218
- return await this.module.loader(context);
219
- else
220
- return null;
221
- }
222
- if (this.module.action)
223
- return await this.module.action(context);
224
- throw new Response("Method not Allowed", MakeStatus("Method Not Allowed", ctx.headers));
225
- }
226
- catch (e) {
227
- if (e instanceof Response && e.headers.has("X-Caught"))
228
- return e;
229
- return await this.error(ctx, e);
230
- }
231
- return null;
232
- }
233
206
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmx-router",
3
- "version": "2.1.5",
3
+ "version": "2.2.0",
4
4
  "description": "A lightweight SSR framework with server+client islands",
5
5
  "keywords": [ "htmx", "router", "client islands", "ssr", "vite" ],
6
6
  "type": "module",
@@ -14,6 +14,7 @@
14
14
  "./css": "./dist/css.js",
15
15
  "./defer": "./dist/defer.js",
16
16
  "./endpoint": "./dist/endpoint.js",
17
+ "./event": "./dist/event.js",
17
18
  "./event-source": "./dist/event-source.js",
18
19
  "./navigate": "./dist/navigate.js",
19
20
  "./response": "./dist/response.js",