h3 2.0.1-rc.21 → 2.0.1-rc.23

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.
@@ -4,14 +4,11 @@
4
4
 
5
5
  You can add cross platform WebSocket support to H3 servers using [🔌 CrossWS](https://crossws.h3.dev/).
6
6
 
7
- > [!IMPORTANT]
8
- > Built-in support of WebSockets in h3 version is WIP.
9
-
10
7
  ## Usage
11
8
 
12
9
  WebSocket handlers can be defined using the `defineWebSocketHandler()` utility and registered to any route like event handlers.
13
10
 
14
- You need to register CrossWS as a server plugin in the `serve` function and provide a `resolve` function to resolve the correct hooks from the route.
11
+ You need to register CrossWS as a server plugin in the `serve` function. The plugin resolves the correct hooks from your matched route automatically.
15
12
 
16
13
  ```js
17
14
  import { H3, serve, defineWebSocketHandler } from "h3";
@@ -23,71 +20,114 @@ const app = new H3();
23
20
  app.get("/_ws", defineWebSocketHandler({ message: console.log }));
24
21
 
25
22
  serve(app, {
26
- plugins: [ws({ resolve: async (req) => (await app.fetch(req)).crossws })],
23
+ plugins: [ws()],
27
24
  });
28
25
  ```
29
26
 
27
+ > [!NOTE]
28
+ > Passing a custom `resolve` to `ws()` is only needed to resolve hooks yourself (for example without invoking the app). By default, CrossWS calls the app's `fetch` handler and reads the hooks attached by `defineWebSocketHandler()`.
29
+
30
30
  **Full example:**
31
31
 
32
32
  ```js [websocket.mjs]
33
- import { H3, serve, defineWebSocketHandler } from "h3";
33
+ import { H3, serve, html, defineWebSocketHandler } from "h3";
34
34
  import { plugin as ws } from "crossws/server";
35
35
 
36
36
  export const app = new H3();
37
37
 
38
- const demoURL =
39
- "https://raw.githubusercontent.com/h3js/crossws/refs/heads/main/playground/public/index.html";
40
-
41
- app.get("/", () =>
42
- fetch(demoURL).then(
43
- (res) => new Response(res.body, { headers: { "Content-Type": "text/html" } }),
44
- ),
45
- );
46
-
38
+ // A minimal self-contained WebSocket playground served for plain HTTP requests.
39
+ const playground = html`<!doctype html>
40
+ <title>H3 WebSocket Playground</title>
41
+ <h1>H3 WebSocket Playground</h1>
42
+ <form id="form">
43
+ <input id="input" placeholder="Type a message..." autocomplete="off" autofocus />
44
+ <button type="submit">Send</button>
45
+ </form>
46
+ <div id="log"></div>
47
+ <script type="module">
48
+ const log = (msg) => {
49
+ const line = document.createElement("div");
50
+ line.textContent = msg;
51
+ document.getElementById("log").append(line);
52
+ };
53
+ const url = location.href.replace(/^http/, "ws");
54
+ const ws = new WebSocket(url);
55
+ ws.addEventListener("open", () => log("[open] connected to " + url));
56
+ ws.addEventListener("message", (e) => log("[message] " + e.data));
57
+ ws.addEventListener("close", () => log("[close] disconnected"));
58
+ document.getElementById("form").addEventListener("submit", (e) => {
59
+ e.preventDefault();
60
+ const input = document.getElementById("input");
61
+ ws.send(input.value);
62
+ input.value = "";
63
+ });
64
+ </script>`;
65
+
66
+ // A single route serves both the playground page (plain HTTP) and the
67
+ // WebSocket endpoint (upgrade requests). The page connects back to itself.
47
68
  app.get(
48
- "/_ws",
49
- defineWebSocketHandler({
50
- // upgrade(req) {},
51
- open(peer) {
52
- console.log("[open]", peer);
53
-
54
- // Send welcome to the new client
55
- peer.send("Welcome to the server!");
56
-
57
- // Join new client to the "chat" channel
58
- peer.subscribe("chat");
59
-
60
- // Notify every other connected client
61
- peer.publish("chat", `[system] ${peer} joined!`);
69
+ "/",
70
+ defineWebSocketHandler(
71
+ {
72
+ open(peer) {
73
+ console.log("[open]", peer);
74
+
75
+ // Send welcome to the new client
76
+ peer.send("Welcome to the server!");
77
+
78
+ // Join new client to the "chat" channel
79
+ peer.subscribe("chat");
80
+
81
+ // Notify every other connected client
82
+ peer.publish("chat", `[system] ${peer} joined!`);
83
+ },
84
+
85
+ message(peer, message) {
86
+ console.log("[message]", peer);
87
+
88
+ if (message.text() === "ping") {
89
+ // Reply to the client with a ping response
90
+ peer.send("pong");
91
+ return;
92
+ }
93
+
94
+ // The server re-broadcasts incoming messages to everyone
95
+ peer.publish("chat", `[${peer}] ${message}`);
96
+
97
+ // Echo the message back to the sender
98
+ peer.send(message);
99
+ },
100
+
101
+ close(peer) {
102
+ console.log("[close]", peer);
103
+ peer.publish("chat", `[system] ${peer} has left the chat!`);
104
+ peer.unsubscribe("chat");
105
+ },
62
106
  },
107
+ // Non-upgrade requests get the playground page.
108
+ () => playground,
109
+ ),
110
+ );
63
111
 
64
- message(peer, message) {
65
- console.log("[message]", peer);
112
+ serve(app, {
113
+ plugins: [ws()],
114
+ });
115
+ ```
66
116
 
67
- if (message.text() === "ping") {
68
- // Reply to the client with a ping response
69
- peer.send("pong");
70
- return;
71
- }
117
+ ### Handling HTTP requests
72
118
 
73
- // The server re-broadcasts incoming messages to everyone
74
- peer.publish("chat", `[${peer}] ${message}`);
119
+ By default, a WebSocket route responds with `426 Upgrade Required` to any request that is not a WebSocket upgrade.
75
120
 
76
- // Echo the message back to the sender
77
- peer.send(message);
78
- },
121
+ You can pass an optional HTTP handler as the second argument to `defineWebSocketHandler()` to serve regular (non-upgrade) requests on the same route. WebSocket upgrade requests still go to the hooks.
79
122
 
80
- close(peer) {
81
- console.log("[close]", peer);
82
- peer.publish("chat", `[system] ${peer} has left the chat!`);
83
- peer.unsubscribe("chat");
84
- },
85
- }),
123
+ ```js
124
+ app.get(
125
+ "/_ws",
126
+ defineWebSocketHandler(
127
+ { message: (peer, message) => peer.send(message.text()) },
128
+ () => "Send a WebSocket upgrade request to connect.",
129
+ ),
86
130
  );
87
-
88
- serve(app, {
89
- plugins: [ws({ resolve: async (req) => (await app.fetch(req)).crossws })],
90
- });
91
131
  ```
92
132
 
93
133
  ## Server-Sent Events (SSE)
@@ -19,18 +19,28 @@ app.get("/", async (event) => {
19
19
  });
20
20
  ```
21
21
 
22
- ### `readBody(event)`
22
+ ### `readBody(event, options?)`
23
23
 
24
24
  Reads request body and tries to parse using JSON.parse or URLSearchParams.
25
25
 
26
+ By default the body is parsed as JSON (falling back to URL-encoded parsing when the `Content-Type` is `application/x-www-form-urlencoded`). Other body types, such as `multipart/form-data`, must be opted into explicitly via `options.type` and are never auto-detected from the request headers.
27
+
26
28
  **Example:**
27
29
 
28
30
  ```ts
29
- app.get("/", async (event) => {
31
+ app.post("/", async (event) => {
30
32
  const body = await readBody(event);
31
33
  });
32
34
  ```
33
35
 
36
+ **Example:**
37
+
38
+ ```ts
39
+ app.post("/upload", async (event) => {
40
+ const body = await readBody(event, { type: "formData" });
41
+ });
42
+ ```
43
+
34
44
  ### `readValidatedBody(event, validate)`
35
45
 
36
46
  Tries to read the request body via `readBody`, then uses the provided validation schema or function and either throws a validation error or returns the result.
@@ -155,7 +165,7 @@ app.get("/", (event) => {
155
165
 
156
166
  Get the request protocol.
157
167
 
158
- If `x-forwarded-proto` header is set to "https", it will return "https". You can disable this behavior by setting `xForwardedProto` to `false`.
168
+ If `x-forwarded-proto` header is set to "https", it will return "https". If the header contains a comma-separated list of protocols, the first entry is used. You can disable this behavior by setting `xForwardedProto` to `false`.
159
169
 
160
170
  If protocol cannot be determined, it will default to "http".
161
171
 
@@ -187,7 +197,7 @@ app.get("/", (event) => {
187
197
 
188
198
  Get a matched route param by name.
189
199
 
190
- If `decode` option is `true`, it will decode the matched route param using `decodeURI`.
200
+ If `decode` option is `true`, it will decode the matched route param using `decodeURIComponent`.
191
201
 
192
202
  **Example:**
193
203
 
@@ -265,7 +275,7 @@ app.get("/", async (event) => {
265
275
 
266
276
  Get matched route params and validate with validate function.
267
277
 
268
- If `decode` option is `true`, it will decode the matched route params using `decodeURI`.
278
+ If `decode` option is `true`, it will decode the matched route params using `decodeURIComponent`.
269
279
 
270
280
  You can use a simple function to validate the params object or use a Standard-Schema compatible library like `zod` to define a schema.
271
281
 
@@ -107,3 +107,15 @@ Check if the origin is allowed.
107
107
  ### `isPreflightRequest(event)`
108
108
 
109
109
  Check if the incoming request is a CORS preflight request.
110
+
111
+ ## Path
112
+
113
+ ### `resolveDotSegments(path, opts?)`
114
+
115
+ Resolve `.` and `..` segments in a path, without ever escaping above the root `/`. The result is always an absolute path with a single leading `/`, so it can never be protocol-relative (`//host`).
116
+
117
+ Also decodes percent-encoded dot segments at any `%25`-nesting depth (`%2e`, `%252e`, ...) and normalizes `\` to `/`, so encoded or backslash-based traversal (e.g. `%2e%2e/`, `..\..\`) is caught the same way as a literal `../`.
118
+
119
+ `%2f`/`%5c` (encoded path separators) are left untouched by default — see {@link ResolveDotSegmentsOptions.decodeSlashes}.
120
+
121
+ Only `.`/`..` resolution and the decodes above alter the string; every other percent-encoding (`%20`, non-ASCII, `%3A`, and any `%2e` not forming a whole segment) is left intact, so the result stays in the same representation as an un-decoded `event.url.pathname` and matches routes/rules consistently. Interior empty segments are preserved (`/a//b` stays `/a//b`, per WHATWG URL normalization) — only the leading slash is guaranteed single, so a consumer doing exact prefix matching should normalize its allowlist the same way.
@@ -28,4 +28,16 @@ Proxy the incoming request to a target URL.
28
28
 
29
29
  If the `target` starts with `/`, the request is handled internally by the app router via `event.app.fetch()` instead of making an external HTTP request.
30
30
 
31
+ The request body is streamed to the target without buffering. Per the Fetch standard, a request body can only be consumed once, so reading it beforehand (e.g. via `readBody()`, `readFormData()`, or body-reading middleware) locks the stream and proxying fails. If you need to inspect the body and still proxy it, read from a clone and leave the original event untouched.
32
+
31
33
  **Security:** Never pass unsanitized user input as the `target`. Callers are responsible for validating and restricting the target URL (e.g. allowlisting hosts, blocking internal paths, enforcing protocol). Consider using `bodyLimit()` middleware to prevent large request bodies from consuming excessive resources when proxying untrusted input.
34
+
35
+ **Example:**
36
+
37
+ ```ts
38
+ app.all("/proxy", async (event) => {
39
+ const body = await event.req.clone().json(); // read from the clone
40
+ // ...inspect body...
41
+ return proxyRequest(event, "/target"); // original stream still intact
42
+ });
43
+ ```
@@ -63,10 +63,49 @@ You can return a new Response from the handler to replace the original response.
63
63
 
64
64
  Define WebSocket hooks.
65
65
 
66
- ### `defineWebSocketHandler()`
66
+ **Example:**
67
+
68
+ ```ts
69
+ const hooks = defineWebSocket({
70
+ open: (peer) => peer.send("Welcome!"),
71
+ message: (peer, message) => peer.send(message.text()),
72
+ close: (peer) => console.log("closed", peer),
73
+ });
74
+ ```
75
+
76
+ ### `defineWebSocketHandler(http?)`
67
77
 
68
78
  Define WebSocket event handler.
69
79
 
80
+ By default, non-upgrade (plain HTTP) requests receive a `426 Upgrade Required` response. Pass an `http` handler to serve those requests instead, allowing the same route to handle both WebSocket upgrades and regular HTTP requests. WebSocket upgrade requests always go to `hooks`.
81
+
82
+ Note: the `http` handler only handles non-upgrade requests. To reject or customize the upgrade handshake itself, use the crossws `upgrade` hook instead.
83
+
84
+ **Example:**
85
+
86
+ ```ts
87
+ // WebSocket-only route (non-upgrade requests get `426 Upgrade Required`)
88
+ app.get(
89
+ "/_ws",
90
+ defineWebSocketHandler({
91
+ message: (peer, message) => peer.send(message.text()),
92
+ }),
93
+ );
94
+ ```
95
+
96
+ **Example:**
97
+
98
+ ```ts
99
+ // Handle both WebSocket upgrades and plain HTTP on the same route
100
+ app.get(
101
+ "/_ws",
102
+ defineWebSocketHandler(
103
+ { message: (peer, message) => peer.send(message.text()) },
104
+ () => "Send a WebSocket upgrade request to connect.",
105
+ ),
106
+ );
107
+ ```
108
+
70
109
  ## Adapters
71
110
 
72
111
  ### `defineNodeHandler(handler)`
@@ -40,3 +40,9 @@ Laravel-style routing system for H3 and Express.js. Clean route definitions, mid
40
40
  `unjwt` is a collection of low-level JWT utilities (JWS, JWE, JWK) built on the Web Crypto API, with zero runtime dependencies. It includes a dedicated H3 v2 adapter for header and cookie-based session management with support for encrypted (JWE) and signed (JWS) tokens.
41
41
 
42
42
  <read-more></read-more>
43
+
44
+ ## `Arkstack`
45
+
46
+ [Arkstack](https://arkstack.toneflix.net) is a runtime-agnostic TypeScript backend framework for building structured, production-ready server applications with first-class support for H3.
47
+
48
+ <read-more></read-more>
@@ -29,7 +29,7 @@ app.use(async (event) => {
29
29
  ```
30
30
 
31
31
  > [!WARNING]
32
- > You must provide a password to encrypt the session.
32
+ > You must provide a password to encrypt the session. The examples below use a hardcoded value so they stay readable, but in a real app load it from an environment variable such as `process.env.SESSION_PASSWORD`, keep it a strong secret of at least 32 characters, and never commit it to source control.
33
33
 
34
34
  This will initialize a session and return an header `Set-Cookie` with a cookie named `h3` and an encrypted content.
35
35
 
@@ -128,3 +128,36 @@ app.use(async (event) => {
128
128
  return session.data;
129
129
  });
130
130
  ```
131
+
132
+ Every option is optional except `password`. The `name` option is worth calling out: it sets the cookie used to store the session and defaults to `h3`. H3 also reads the session from a request header derived from `name`, which it normalizes to lowercase as `x-${name.toLowerCase()}-session`, so the default name `h3` produces the `x-h3-session` header seen earlier. A mixed-case `name` like `MyApp` still resolves to a lowercase `x-myapp-session` header, while the cookie keeps the original casing. That default is why the earlier examples set a cookie named `h3`.
133
+
134
+ > [!NOTE]
135
+ > The `secure: true` option tells the browser to only store and send the cookie over HTTPS. When developing locally over plain HTTP, compliant browsers (notably Safari and iOS, and Chrome on some local domains) silently drop the cookie, so the session will not persist. Set `cookie: { secure: false }` during local development to work around this.
136
+
137
+ ## Use Multiple Sessions
138
+
139
+ Because each session is stored under its own `name`, you can run several independent sessions on the same request. They live in separate cookies and never overwrite each other, which is useful for keeping unrelated concerns apart, such as a long-lived auth session and a short-lived flash message:
140
+
141
+ ```js
142
+ import { useSession } from "h3";
143
+
144
+ app.use(async (event) => {
145
+ const auth = await useSession(event, {
146
+ name: "auth",
147
+ password: "80d42cfb-1cd2-462c-8f17-e3237d9027e9",
148
+ });
149
+
150
+ const flash = await useSession(event, {
151
+ name: "flash",
152
+ password: "80d42cfb-1cd2-462c-8f17-e3237d9027e9",
153
+ });
154
+
155
+ await flash.update({ message: "Saved!" });
156
+
157
+ // `auth` and `flash` are backed by different cookies, so they stay separate
158
+ return { user: auth.data.user, flash: flash.data.message };
159
+ });
160
+ ```
161
+
162
+ > [!NOTE]
163
+ > Give each session a distinct `name`. Two sessions that share a name share the same cookie, so the last write wins.
@@ -582,6 +582,8 @@ declare class HTTPError<DataT = unknown> extends Error implements ErrorBody<Data
582
582
  get statusMessage(): string | undefined;
583
583
  toJSON(): Omit<ErrorBody, "body"> & ErrorBody["body"];
584
584
  }
585
+ type H3Plugin = (h3: H3) => void;
586
+ declare function definePlugin<T = unknown>(def: (h3: H3, options: T) => void): undefined extends T ? (options?: T) => H3Plugin : (options: T) => H3Plugin;
585
587
  interface RouterContext {
586
588
  root: any;
587
589
  static: Record<string, any>;
@@ -600,6 +602,14 @@ interface H3Config {
600
602
  * When enabled, H3 console errors for unhandled exceptions will not be displayed.
601
603
  */
602
604
  silent?: boolean;
605
+ /**
606
+ * By default H3 rejects requests with a malformed percent-encoded URL path
607
+ * (e.g. `/foo%`, `/%ZZ`) with a `400 Bad Request` before routing.
608
+ *
609
+ * When enabled, such requests are allowed through with the raw, undecoded
610
+ * pathname instead. Your handlers are then responsible for handling it safely.
611
+ */
612
+ allowMalformedURL?: boolean;
603
613
  plugins?: H3Plugin[];
604
614
  onRequest?: (event: H3Event) => MaybePromise<void>;
605
615
  onResponse?: (response: Response, event: H3Event) => MaybePromise<void>;
@@ -619,8 +629,6 @@ interface H3Route {
619
629
  meta?: H3RouteMeta;
620
630
  handler: EventHandler;
621
631
  }
622
- type H3Plugin = (h3: H3$1) => void;
623
- declare function definePlugin<T = unknown>(def: (h3: H3$1, options: T) => void): undefined extends T ? (options?: T) => H3Plugin : (options: T) => H3Plugin;
624
632
  type RouteOptions = {
625
633
  middleware?: Middleware[];
626
634
  meta?: H3RouteMeta;
@@ -815,6 +823,7 @@ declare class H3EventResponse {
815
823
  get errHeaders(): Headers;
816
824
  }
817
825
  declare class H3Core implements H3Core$1 {
826
+ static "~h3": boolean;
818
827
  readonly config: H3CoreConfig;
819
828
  "~middleware": Middleware[];
820
829
  "~routes": H3Route[];
@@ -830,4 +839,4 @@ declare const H3: {
830
839
  new (config?: H3Config): H3$1;
831
840
  };
832
841
  type H3 = H3$1;
833
- export { FetchHandler$1 as A, SessionConfig as B, DynamicEventHandler as C, EventHandlerRequest as D, EventHandlerObject as E, Middleware as F, sealSession as G, SessionManager as H, TypedServerRequest as I, useSession as J, unsealSession as K, MaybePromise as L, HTTPHandler as M, InferEventInput as N, EventHandlerResponse as O, LazyEventHandler as P, TypedRequest as R, HTTPError as S, EventHandlerFetch as T, clearSession as U, SessionData as V, getSession as W, CookieSerializeOptions as Y, RouterContext as _, H3EventContext as a, ErrorDetails as b, H3CoreConfig as c, H3RouteMeta as d, HTTPMethod as f, RouteOptions as g, PreparedResponse as h, HTTPEvent as i, FetchableObject as j, EventHandlerWithFetch as k, H3Plugin as l, MiddlewareOptions as m, H3Core as n, H3$1 as o, MatchedRoute as p, updateSession as q, H3Event as r, H3Config as s, H3 as t, H3Route as u, definePlugin as v, EventHandler as w, ErrorInput as x, ErrorBody as y, Session as z };
842
+ export { type CookieSerializeOptions, DynamicEventHandler, ErrorBody, ErrorDetails, ErrorInput, EventHandler, EventHandlerFetch, EventHandlerObject, EventHandlerRequest, EventHandlerResponse, EventHandlerWithFetch, FetchHandler$1 as FetchHandler, FetchableObject, H3, H3$1, H3Config, H3Core, H3CoreConfig, H3Event, H3EventContext, H3Plugin, H3Route, H3RouteMeta, HTTPError, HTTPEvent, HTTPHandler, HTTPMethod, InferEventInput, LazyEventHandler, MatchedRoute, MaybePromise, Middleware, MiddlewareOptions, PreparedResponse, RouteOptions, RouterContext, Session, SessionConfig, SessionData, SessionManager, type TypedRequest, TypedServerRequest, clearSession, definePlugin, getSession, sealSession, unsealSession, updateSession, useSession };