@mauroandre/velojs 0.0.52 → 0.0.54

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/README.md CHANGED
@@ -731,7 +731,24 @@ const { data, snapshot, closed, error } = useEventStream(stream, {
731
731
  | `closed` | `true` when server closed the stream |
732
732
  | `error` | Parse or connection error, if any |
733
733
 
734
- Lifecycle is automatic: opens on mount, closes on unmount, re-opens fresh when `channel` changes.
734
+ Lifecycle is automatic: opens on mount, closes on unmount, re-opens when `channel` changes.
735
+
736
+ **Reconnect is stale-while-revalidate.** When the connection re-opens — `channel`, `stream` or `enabled` changed — `data` and `snapshot` keep their previous values until the first event of the new connection overwrites them, the same choice the loader store makes (a refresh must not blank the screen). A channel switch therefore never paints an empty state; only `closed` and `error` reset, because a re-open is a fresh attempt. First mount still starts at `null` and fills in when the snapshot arrives. An app that wants to blank the view on a new channel can clear the signals itself when the channel changes.
737
+
738
+ **Channel coming from loader data?** Pass `enabled` so the hook does not connect before the channel exists — the first render of a SPA navigation has `data = null`, and connecting with `channel: undefined` reaches the server without `?channel=`, which a resolver answers with `null` → 403 → a flash of `closed`:
739
+
740
+ ```tsx
741
+ const { data } = useLoader<{ id: string }>();
742
+ const channel = data.value?.id;
743
+
744
+ // no connection until the loader resolves the channel
745
+ const { snapshot, closed } = useEventStream(stream_logs, {
746
+ // spread only when it exists — `exactOptionalPropertyTypes` (the repo's own
747
+ // tsconfig) rejects an explicit `undefined` in an optional property
748
+ ...(channel != null && { channel }),
749
+ enabled: channel != null,
750
+ });
751
+ ```
735
752
 
736
753
  ### `poll` helper
737
754
 
@@ -853,6 +870,9 @@ const { send, status, lastMessage, close } = useSocket(socket_terminal, {
853
870
  ```
854
871
 
855
872
  - `status: Signal<"connecting" | "open" | "closed">` — reactive.
873
+ - `lastMessage: Signal<string | Uint8Array | null>` — last frame received.
874
+ - **Reconnect preserves `lastMessage`** — changing `stub.__path`, `channel` or `enabled` opens a new socket, but the previous frame stays until the first frame of the new one arrives (stale-while-revalidate, same as `useEventStream`); `status` restarts at `"connecting"` and `error` resets. Swapping the stub object with the same `__path` does not reconnect. Clear `lastMessage` yourself in `onOpen` if a channel switch must blank the view.
875
+ - **Channel coming from loader data?** Pass `enabled: channel != null` so the socket only opens once the channel resolves — same reason as `useEventStream`.
856
876
  - **No auto-reconnect** — sockets are usually stateful (pty sessions, collaborative state); blind reconnect loses state silently. Re-mount or toggle `enabled` to reconnect.
857
877
 
858
878
  ### Testing
@@ -1320,7 +1340,7 @@ Hooks (`useParams`, `useQuery`, `usePathname`, `Loader`, `useLoader`) access thi
1320
1340
 
1321
1341
  ## Testing
1322
1342
 
1323
- VeloJS ships a backend testing toolkit at `@mauroandre/velojs/testing`. Spin up the app in memory, fire HTTP requests against the registered handlers, subscribe to event streams. No socket, no browser, no fragile mocks of framework internals.
1343
+ VeloJS ships a backend testing toolkit at `@mauroandre/velojs/testing`. Spin up the app in memory, fire HTTP requests against the registered handlers, subscribe to event streams. No browser, no fragile mocks of framework internals — and a real TCP port when an external actor has to reach the app.
1324
1344
 
1325
1345
  ```typescript
1326
1346
  import { createTestApp } from "@mauroandre/velojs/testing";
@@ -1332,6 +1352,7 @@ const app = await createTestApp({
1332
1352
  routes,
1333
1353
  bootstrap: async () => { await connect(process.env.MONGO_URI!); },
1334
1354
  getSessionCookie: async ({ user }) => ({ session: await sign(user) }),
1355
+ // port: 0, // optional — also serve this app over real TCP (see below)
1335
1356
  });
1336
1357
 
1337
1358
  // HTTP
@@ -1358,6 +1379,7 @@ await app.close();
1358
1379
  | API | Purpose |
1359
1380
  |-----|---------|
1360
1381
  | `createTestApp(options)` | Build isolated app with bootstrap + auth callback |
1382
+ | `app.port` / `app.url` | Real port and base URL of the TCP listener — `undefined` in the default in-memory mode |
1361
1383
  | `app.get/post/put/patch/delete` | HTTP requests (cookies, headers, query, JSON/FormData body) |
1362
1384
  | `app.action(fn, opts)` | Invoke `action_*` by function reference |
1363
1385
  | `app.loader(fn, opts)` | Invoke `loader` and unwrap response data |
@@ -1368,6 +1390,26 @@ await app.close();
1368
1390
  | `app.reset()` | Clear stream buffers/listeners between tests |
1369
1391
  | `app.close()` | Tear down everything (zero open handles guaranteed) |
1370
1392
 
1393
+ ### External actors over TCP
1394
+
1395
+ In-memory requests never leave the process, so an actor that does — a worker in another VM POSTing a notify callback — needs a real socket. Pass `port` and the **same app instance** is served over TCP as well:
1396
+
1397
+ ```typescript
1398
+ const app = await createTestApp({ routes, port: 0 }); // 0 → free port
1399
+
1400
+ // Hand the URL to the external client (worker, container, another test process)
1401
+ await provisioning.startWorker({ notifyUrl: `${app.url}/_action/Jobs/notify` });
1402
+
1403
+ // The worker POSTs over real HTTP; the in-memory subscription sees the event —
1404
+ // same app, same stream registry, no glue and no second instance
1405
+ const sub = await app.subscribe(stream_progress, { channel: jobId });
1406
+ const event = await sub.next({ timeoutMs: 5000 });
1407
+
1408
+ await app.close(); // closes the listener, dropping in-flight SSE/WebSocket connections
1409
+ ```
1410
+
1411
+ Pages, endpoints, actions, `?_data=1` loaders, SSE and WebSocket all answer as in production; `app.close()` also terminates connections still open on the listener. `port` and `hostname` come from the options only — never from `process.env.PORT`/`HOST` (test determinism). Omitted `hostname` follows Node's default bind (all interfaces — the listener is reachable from the local network); `app.url` reports `http://localhost:<port>` unless an explicit hostname was given.
1412
+
1371
1413
  See [Testing docs](https://github.com/mauro-andre/velojs/blob/dev/site/docs/17-testing.md) for the full guide with patterns, isolation, and FAQ.
1372
1414
 
1373
1415
  ---
@@ -389,6 +389,34 @@ useEventStream(stream, {
389
389
 
390
390
  The hook handles `EventSource` lifecycle automatically: opens on mount, closes on unmount, re-opens with a fresh connection when `channel` changes.
391
391
 
392
+ ### Reconnect keeps the last value (stale-while-revalidate)
393
+
394
+ When the connection re-opens — `channel`, `stream` or `enabled` changed — `data` and `snapshot` keep their previous values until the first event of the new connection overwrites them. It is the same choice the loader store makes: a refresh must not blank the screen. Without it, a channel switch (navigating from `/deploys/1` to `/deploys/2` with the stream in a layout that survives the navigation) would paint an empty state for a full round-trip before the new snapshot arrives.
395
+
396
+ `closed` and `error` do reset on a re-open: a reconnect is a fresh attempt, not a closed stream. First mount still starts at `null` and fills in when the snapshot arrives — seed it from loader data if that first frame matters to your UI.
397
+
398
+ If a channel switch must blank the view, clear the signals yourself in an effect keyed on the channel.
399
+
400
+ ### Don't connect before the channel exists
401
+
402
+ When `channel` derives from loader data, the first render of a SPA navigation has it `undefined` (the loader fetch is post-mount). The hook would then connect **without** `?channel=`, and a resolver that returns `null`/`undefined` for a missing channel answers **403** — the client sees `closed = true` and paints a broken state for the milliseconds until the loader resolves and the effect reconnects.
403
+
404
+ Pass `enabled` to gate the connection on the channel:
405
+
406
+ ```tsx
407
+ const { data } = useLoader<{ id: string }>();
408
+ const channel = data.value?.id;
409
+
410
+ const { snapshot, closed } = useEventStream(stream_logs, {
411
+ // spread only when it exists — `exactOptionalPropertyTypes` (the repo's own
412
+ // tsconfig) rejects an explicit `undefined` in an optional property
413
+ ...(channel != null && { channel }),
414
+ enabled: channel != null, // no connection until the loader resolves the id
415
+ });
416
+ ```
417
+
418
+ That flash is a documentation-level concern, not a hook one: the hook cannot distinguish "channel still loading" from "stream has no channel". `enabled` is the switch that expresses the difference.
419
+
392
420
  ## Heartbeat
393
421
 
394
422
  VeloJS sends a `:heartbeat` SSE comment every 20 seconds by default. Without this, idle connections are closed by Cloudflare (~100s), Nginx (~60s), and AWS ALB (~60s) — and the client doesn't even notice.
@@ -206,6 +206,22 @@ const { send, status, lastMessage, error, close } = useSocket(stub, {
206
206
  - `send(msg)` — queues messages sent while `connecting`; drops messages sent while `closed`.
207
207
  - `close(code?, reason?)` — client-initiated disconnect.
208
208
 
209
+ **Reconnect keeps `lastMessage` (stale-while-revalidate).** Changing `stub.__path`, `channel` or `enabled` opens a fresh socket, but the previous frame stays in `lastMessage` until the first frame of the new socket arrives — switching from a worker terminal to another one does not paint a blank view for a round-trip. It is the same choice the loader store and `useEventStream` make. `status` restarts at `"connecting"` and `error` resets, because a reconnect is a fresh attempt; a new stub object with the same `__path` is not a reconnect at all. If a channel switch must blank the view, clear `lastMessage` in `onOpen`.
210
+
211
+ **Channel coming from loader data?** The first render of a SPA navigation has no channel yet (the loader fetch is post-mount), and connecting then reaches the server without `?channel=` — for a guarded socket that is an error, not a connection. Pass `enabled: channel != null` to wait for the loader:
212
+
213
+ ```tsx
214
+ const { data } = useLoader<{ workerId: string }>();
215
+ const channel = data.value?.workerId;
216
+
217
+ const { send, status, lastMessage } = useSocket(socket_terminal, {
218
+ // spread only when it exists — `exactOptionalPropertyTypes` (the repo's own
219
+ // tsconfig) rejects an explicit `undefined` in an optional property
220
+ ...(channel != null && { channel }),
221
+ enabled: channel != null, // no socket until the loader resolves the id
222
+ });
223
+ ```
224
+
209
225
  **No auto-reconnect.** If the server closes the socket (network drop, server restart), `status` goes to `"closed"` and stays there. The caller decides whether/when to reconnect — typically by toggling `enabled` or re-mounting the component. This is intentional: sockets are usually stateful (pty session, collaborative cursor) and a blind reconnect would silently lose state.
210
226
 
211
227
  ## Testing
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: velojs-testing
3
- description: "Writing tests with `createTestApp` from `@mauroandre/velojs/testing`: HTTP requests, actions, loaders, auth, SSE subscriptions, sockets. Use when writing or fixing a test for a VeloJS app."
3
+ description: "Writing tests with `createTestApp` from `@mauroandre/velojs/testing`: HTTP requests, actions, loaders, auth, SSE subscriptions, sockets, and serving the app on a real TCP port for external actors. Use when writing or fixing a test for a VeloJS app."
4
4
  ---
5
5
 
6
6
  # Testing
7
7
 
8
- VeloJS ships a backend testing toolkit at `@mauroandre/velojs/testing`. It spins up your app in memory, fires HTTP requests against the registered handlers, subscribes to event streams, and asserts on the result. No socket. No browser. No fragile mocks of the framework internals.
8
+ VeloJS ships a backend testing toolkit at `@mauroandre/velojs/testing`. It spins up your app in memory, fires HTTP requests against the registered handlers, subscribes to event streams, and asserts on the result. No browser. No fragile mocks of the framework internals. And when an actor outside this process has to reach the app — a worker sending a notify callback — the same app instance is served over a real TCP port.
9
9
 
10
10
  You test the whole stack — middleware, auth, routing, conventions, serialization — through the API your real users hit.
11
11
 
@@ -77,6 +77,7 @@ const app = await createTestApp({
77
77
  routes,
78
78
  bootstrap?: async () => { ... },
79
79
  getSessionCookie?: async ({ user }) => ({ ... }),
80
+ port?: 0,
80
81
  });
81
82
  ```
82
83
 
@@ -85,10 +86,12 @@ const app = await createTestApp({
85
86
  | `routes` | Required. Your app's `routes` array — typically `import routes from "./app/routes.js"` (it is the **default** export of `routes.tsx`) |
86
87
  | `bootstrap` | Runs once before the app is created. Use for DB connections, index creation, anything `app/server.tsx` does at startup. `addRoutes()` and `onServer()` calls here are **scoped to this app only** |
87
88
  | `getSessionCookie` | Maps a user object to cookies the test client will attach. Required for `app.as(user)` and `app.sessionCookies(user)` |
89
+ | `port` | Optional. Serves the same app over real TCP on this port (`0` → free port, see [External actors over TCP](#external-actors-over-tcp)) |
90
+ | `hostname` | Interface to bind when `port` is set (e.g. `"127.0.0.1"`). Omitted → Node's default, all interfaces |
88
91
 
89
92
  ### app.close()
90
93
 
91
- Tears down everything: heartbeats, retention timers, listeners, callbacks. After `close()`, Vitest must report **zero open handles**. Always call it in `afterAll`.
94
+ Tears down everything: heartbeats, retention timers, listeners, callbacks — and the TCP listener when `port` was passed, terminating the SSE/WebSocket connections still open on it. After `close()`, Vitest must report **zero open handles**. Always call it in `afterAll`.
92
95
 
93
96
  ### app.reset()
94
97
 
@@ -159,6 +162,34 @@ await app.get("/api/posts", { query: { tag: ["preact", "ssr"] } });
159
162
  // → /api/posts?tag=preact&tag=ssr
160
163
  ```
161
164
 
165
+ ## External actors over TCP
166
+
167
+ The in-memory client never leaves the process. When the actor under test *does* — a worker in another VM POSTing a notify callback to the control-plane — pass `port` and the **same instance** is served over real TCP:
168
+
169
+ ```typescript
170
+ const app = await createTestApp({ routes, port: 0 }); // 0 → free port
171
+
172
+ // `app.url` / `app.port` are the real, usable address — hand it to the worker
173
+ await provisioning.startWorker({ notifyUrl: `${app.url}/_action/Jobs/notify` });
174
+
175
+ // The worker POSTs over real HTTP. It's the same instance behind app.get and
176
+ // app.subscribe, so a stream watched in-memory sees the event — no glue and no
177
+ // "test watches an empty stream because it hit another instance":
178
+ const sub = await app.subscribe(stream_progress, { channel: jobId });
179
+ const event = await sub.next({ timeoutMs: 5000 });
180
+
181
+ await app.close(); // listener down; in-flight SSE/WS connections dropped
182
+ ```
183
+
184
+ Everything answers as in production — pages, endpoints, `action_*`, `?_data=1` loaders, SSE (`stream_*`) and WebSocket (`socket_*`). `app.close()` refuses new connections and terminates the ones still open, so a suite that keeps the app alive across tests tears down promptly even with a worker mid-SSE.
185
+
186
+ Two rules keep tests deterministic:
187
+
188
+ - **Ports come from the options only.** `port` and `hostname` are never read from `process.env.PORT`/`HOST`.
189
+ - **The bind follows the option.** An explicit `hostname` (e.g. `"127.0.0.1"`) keeps the listener loopback; omitted, Node's default applies — all interfaces, reachable from the local network. `app.url` reports `http://localhost:<port>` unless an explicit hostname was given.
190
+
191
+ Without `port`, nothing changes: the app is purely in-memory, and `app.port`/`app.url` are `undefined`.
192
+
162
193
  ## Auth — getSessionCookie and .as
163
194
 
164
195
  Most authenticated tests look like:
@@ -293,7 +324,7 @@ it("perChannelSource aborts when the last subscriber leaves", async () => {
293
324
 
294
325
  ## Sockets — `.socket(handler)`
295
326
 
296
- `app.socket(handlerOrStubOrPath, opts)` opens an **in-memory** WebSocket session against a `socket_*` handler — no real TCP, no upgrade handshake. The handler runs directly with a mock Context.
327
+ `app.socket(handlerOrStubOrPath, opts)` opens an **in-memory** WebSocket session against a `socket_*` handler — no real TCP, no upgrade handshake. The handler runs directly with a mock Context. (For a real handshake, create the app with `port` and connect a client to `ws://...` — see [External actors over TCP](#external-actors-over-tcp).)
297
328
 
298
329
  ```typescript
299
330
  import { socket_terminal } from "../app/workers/WorkerTerminal.js";
package/dist/hooks.d.ts CHANGED
@@ -166,6 +166,13 @@ export declare function usePathname(): string;
166
166
  *
167
167
  * The connection closes automatically when the component unmounts or when
168
168
  * the channel changes (re-opens with new channel).
169
+ *
170
+ * Stale-while-revalidate: on a re-open (new `channel`/`stream`/`enabled`) the
171
+ * previous `data`/`snapshot` stay in place until the first event of the new
172
+ * connection overwrites them — the same choice the loader store makes. Only
173
+ * `closed`/`error` reset, because a re-open is a fresh attempt. To connect
174
+ * only once a channel derived from loader data has resolved, pass
175
+ * `enabled: channel != null`.
169
176
  */
170
177
  export declare function useEventStream<TEvent, TSnapshot = TEvent>(stream: EventStream<TEvent, TSnapshot>, options?: {
171
178
  channel?: string;
@@ -218,6 +225,11 @@ export interface UseSocketApi {
218
225
  * becomes `"closed"` and stays that way — user flow decides whether to
219
226
  * reconnect (typically by re-mounting or toggling `enabled`).
220
227
  *
228
+ * Stale-while-revalidate: on reconnect (new `stub.__path`/`channel`/`enabled`)
229
+ * `lastMessage` keeps its previous value until the first frame of the new
230
+ * socket arrives; `status`/`error` restart as a fresh attempt
231
+ * (`"connecting"` → `"open"`).
232
+ *
221
233
  * ```tsx
222
234
  * const { send, status, lastMessage } = useSocket(socket_terminal, {
223
235
  * channel: workerId,
package/dist/hooks.js CHANGED
@@ -45,10 +45,10 @@ function B(e, n) {
45
45
  if (!t)
46
46
  return { data: u(null), loading: u(!1), refetch: () => {
47
47
  } };
48
- const o = N(t), a = m(t), c = () => D(t), d = S(!0);
48
+ const o = N(t), a = m(t), l = () => D(t), d = S(!0);
49
49
  return E(() => {
50
- d.current && (d.current = !1, o.value !== null) || c();
51
- }, r ?? []), { data: o, loading: a, refetch: c };
50
+ d.current && (d.current = !1, o.value !== null) || l();
51
+ }, r ?? []), { data: o, loading: a, refetch: l };
52
52
  }
53
53
  function J() {
54
54
  const [, e] = b();
@@ -69,15 +69,15 @@ function x() {
69
69
  return typeof window > "u" ? globalThis.__veloServerData?.getStore?.()?.__pathname ?? "/" : (b(), window.location.pathname);
70
70
  }
71
71
  function G(e, n = {}) {
72
- const t = u(null), r = u(null), o = u(!1), a = u(null), { channel: c, enabled: d = !0 } = n;
72
+ const t = u(null), r = u(null), o = u(!1), a = u(null), { channel: l, enabled: d = !0 } = n;
73
73
  return E(() => {
74
74
  if (typeof window > "u" || !d) return;
75
75
  if (!e.__path) {
76
76
  console.warn("[velojs] useEventStream: stream has no __path");
77
77
  return;
78
78
  }
79
- t.value = null, r.value = null, o.value = !1, a.value = null;
80
- const g = c ? `${e.__path}?channel=${encodeURIComponent(c)}` : e.__path, s = new EventSource(g);
79
+ o.value = !1, a.value = null;
80
+ const g = l ? `${e.__path}?channel=${encodeURIComponent(l)}` : e.__path, s = new EventSource(g);
81
81
  return s.addEventListener("snapshot", (h) => {
82
82
  try {
83
83
  r.value = JSON.parse(h.data);
@@ -98,41 +98,41 @@ function G(e, n = {}) {
98
98
  }), () => {
99
99
  s.close();
100
100
  };
101
- }, [e, c, d]), { data: t, snapshot: r, closed: o, error: a };
101
+ }, [e, l, d]), { data: t, snapshot: r, closed: o, error: a };
102
102
  }
103
103
  function F(e, n = {}) {
104
- const t = u("connecting"), r = u(null), o = u(null), a = S(null), c = S([]), { channel: d, onMessage: g, onOpen: s, onClose: h, onError: y, enabled: L = !0 } = n;
104
+ const t = u("connecting"), r = u(null), o = u(null), a = S(null), l = S([]), { channel: d, onMessage: g, onOpen: s, onClose: h, onError: y, enabled: L = !0 } = n;
105
105
  return E(() => {
106
106
  if (typeof window > "u" || !L) return;
107
107
  if (!e.__path) {
108
108
  console.warn("[velojs] useSocket: stub has no __path");
109
109
  return;
110
110
  }
111
- t.value = "connecting", r.value = null, o.value = null;
111
+ t.value = "connecting", o.value = null;
112
112
  const v = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}`, i = d ? `${v}${e.__path}?channel=${encodeURIComponent(d)}` : `${v}${e.__path}`, f = new WebSocket(i);
113
113
  return f.binaryType = "arraybuffer", a.current = f, f.addEventListener("open", () => {
114
114
  t.value = "open";
115
- const l = c.current;
116
- c.current = [];
117
- for (const _ of l)
115
+ const c = l.current;
116
+ l.current = [];
117
+ for (const _ of c)
118
118
  try {
119
119
  f.send(_);
120
120
  } catch {
121
121
  }
122
122
  s?.();
123
- }), f.addEventListener("message", (l) => {
123
+ }), f.addEventListener("message", (c) => {
124
124
  let _;
125
- if (typeof l.data == "string")
126
- _ = l.data;
127
- else if (l.data instanceof ArrayBuffer)
128
- _ = new Uint8Array(l.data);
125
+ if (typeof c.data == "string")
126
+ _ = c.data;
127
+ else if (c.data instanceof ArrayBuffer)
128
+ _ = new Uint8Array(c.data);
129
129
  else
130
130
  return;
131
131
  r.value = _, g?.(_);
132
- }), f.addEventListener("close", (l) => {
133
- t.value = "closed", a.current = null, h?.(l);
134
- }), f.addEventListener("error", (l) => {
135
- o.value = l, y?.(l);
132
+ }), f.addEventListener("close", (c) => {
133
+ t.value = "closed", a.current = null, h?.(c);
134
+ }), f.addEventListener("error", (c) => {
135
+ o.value = c, y?.(c);
136
136
  }), () => {
137
137
  a.current = null;
138
138
  try {
@@ -149,7 +149,7 @@ function F(e, n = {}) {
149
149
  i.send(v);
150
150
  } catch {
151
151
  }
152
- else i && i.readyState === WebSocket.CONNECTING && c.current.push(v);
152
+ else i && i.readyState === WebSocket.CONNECTING && l.current.push(v);
153
153
  }, close: (p, v) => {
154
154
  const i = a.current;
155
155
  if (i)
package/dist/server.d.ts CHANGED
@@ -4,6 +4,21 @@ import { AsyncLocalStorage } from "node:async_hooks";
4
4
  export declare const serverDataStorage: AsyncLocalStorage<Record<string, unknown>>;
5
5
  type ServerCallback = (server: import("http").Server) => void;
6
6
  export declare function onServer(fn: ServerCallback): void;
7
+ /**
8
+ * Points the global `activeServer` at `server` and drains the callbacks
9
+ * registered in the current context. Exported for the testing toolkit, which
10
+ * serves a TestApp over TCP and must hand the real server to `onServer()`
11
+ * callbacks (same flush as `startServer`).
12
+ */
13
+ export declare function flushServerCallbacks(server: import("http").Server): void;
14
+ /**
15
+ * Drops the global `activeServer` when it still points at `server`. A closed
16
+ * server must stop answering `onServer()`: a late registrant would receive a
17
+ * dead instance (address() === null) instead of queueing for the next one.
18
+ * `startServer` does the same through `server.once("close", …)`; the testing
19
+ * toolkit has no such event to lean on because it closes the server itself.
20
+ */
21
+ export declare function clearActiveServer(server: import("http").Server): void;
7
22
  export interface StartServerOptions {
8
23
  routes: AppRoutes;
9
24
  port?: number;
package/dist/server.js CHANGED
@@ -1,17 +1,17 @@
1
- import { jsx as v } from "preact/jsx-runtime";
1
+ import { jsx as w } from "preact/jsx-runtime";
2
2
  import { Hono as R } from "hono";
3
3
  import { logger as T } from "hono/logger";
4
4
  import { trimTrailingSlash as E } from "hono/trailing-slash";
5
5
  import { render as C } from "preact-render-to-string";
6
- import { Router as $ } from "wouter-preact";
7
- import { AsyncLocalStorage as k } from "node:async_hooks";
6
+ import { Router as A } from "wouter-preact";
7
+ import { AsyncLocalStorage as $ } from "node:async_hooks";
8
8
  import { getAppContext as _ } from "./app-context.js";
9
- import { flushPendingStreamRoutes as A, registerStreamHandler as D } from "./events.js";
9
+ import { flushPendingStreamRoutes as k, registerStreamHandler as D } from "./events.js";
10
10
  import { registerSocketRoutes as O, injectWebSocketServer as S } from "./sockets.js";
11
- const P = new k();
11
+ const P = new $();
12
12
  globalThis.__veloServerData = P;
13
13
  let g = null;
14
- function Z(e) {
14
+ function ee(e) {
15
15
  if (g) {
16
16
  e(g);
17
17
  return;
@@ -24,23 +24,26 @@ function b(e) {
24
24
  for (const s of t.serverCallbacks) s(e);
25
25
  t.serverCallbacks.length = 0;
26
26
  }
27
- function ee(e) {
27
+ function I(e) {
28
+ g === e && (g = null);
29
+ }
30
+ function te(e) {
28
31
  _().pendingRoutes.push(e);
29
32
  }
30
- const I = (e) => JSON.stringify(e).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"), N = (e, t, s, a) => {
33
+ const N = (e) => JSON.stringify(e).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"), H = (e, t, s, a) => {
31
34
  if (a && a !== 200 && e.status(a), e.req.query("_data") === "1") {
32
35
  const c = globalThis.__veloBuildHash || void 0, r = s ? { ...s, __buildHash: c } : { __buildHash: c };
33
36
  return e.json(r);
34
37
  }
35
38
  const o = e.req.path, n = P.run(
36
39
  s ?? {},
37
- () => C(/* @__PURE__ */ v($, { ssrPath: o, children: t }))
40
+ () => C(/* @__PURE__ */ w(A, { ssrPath: o, children: t }))
38
41
  );
39
42
  if (e.header("Cache-Control", "no-cache"), !s)
40
43
  return e.html(n);
41
- const i = `<script>window.__PAGE_DATA__=${I(s)}<\/script>`;
44
+ const i = `<script>window.__PAGE_DATA__=${N(s)}<\/script>`;
42
45
  return e.html(n.replace("</head>", () => `${i}</head>`));
43
- }, H = async (e, t) => {
46
+ }, G = async (e, t) => {
44
47
  const s = t.req.param(), a = t.req.query(), o = { params: s, query: a, c: t }, n = await Promise.all(
45
48
  e.map(async (r) => {
46
49
  if (!r.loader) return null;
@@ -66,16 +69,16 @@ const I = (e) => JSON.stringify(e).replace(/</g, "\\u003c").replace(/>/g, "\\u00
66
69
  data: c,
67
70
  shortCircuit: i
68
71
  };
69
- }, G = (e) => {
72
+ }, x = (e) => {
70
73
  if (e.length === 0) return null;
71
74
  const t = e.filter(Boolean);
72
75
  if (t.length === 0) return null;
73
76
  if (t.length === 1) {
74
77
  const o = t[0];
75
- return /* @__PURE__ */ v(o, {});
78
+ return /* @__PURE__ */ w(o, {});
76
79
  }
77
80
  const s = t[t.length - 1];
78
- return t.slice(0, -1).reduceRight((o, n) => /* @__PURE__ */ v(n, { children: o }), /* @__PURE__ */ v(s, {}));
81
+ return t.slice(0, -1).reduceRight((o, n) => /* @__PURE__ */ w(n, { children: o }), /* @__PURE__ */ w(s, {}));
79
82
  }, y = (e, t, s = [], a = []) => {
80
83
  for (const o of t) {
81
84
  if (o.handler && !o.module) {
@@ -107,13 +110,13 @@ const I = (e) => JSON.stringify(e).replace(/</g, "\\u003c").replace(/>/g, "\\u00
107
110
  continue;
108
111
  }
109
112
  const l = async (d) => {
110
- const { components: u, data: h, shortCircuit: m } = await H(n, d);
113
+ const { components: u, data: h, shortCircuit: m } = await G(n, d);
111
114
  if (m) {
112
- const w = m.headers.get("Location");
113
- return d.req.query("_data") === "1" && w && m.status >= 300 && m.status < 400 ? d.json({ __redirect: w }) : m;
115
+ const v = m.headers.get("Location");
116
+ return d.req.query("_data") === "1" && v && m.status >= 300 && m.status < 400 ? d.json({ __redirect: v }) : m;
114
117
  }
115
- const p = G(u);
116
- return N(d, p, h, r);
118
+ const p = x(u);
119
+ return H(d, p, h, r);
117
120
  };
118
121
  c ? e.notFound(l) : i.length > 0 ? e.on(["GET"], [f], ...i, l) : e.on(["GET"], [f], l);
119
122
  } else
@@ -148,8 +151,8 @@ const I = (e) => JSON.stringify(e).replace(/</g, "\\u003c").replace(/>/g, "\\u00
148
151
  const p = await f(m);
149
152
  return u.json(p ?? { ok: !0 });
150
153
  } catch (p) {
151
- const w = p instanceof Error ? p.message : "Action failed";
152
- return u.json({ error: w }, 500);
154
+ const v = p instanceof Error ? p.message : "Action failed";
155
+ return u.json({ error: v }, 500);
153
156
  }
154
157
  };
155
158
  n.length > 0 ? e.on(
@@ -163,7 +166,7 @@ const I = (e) => JSON.stringify(e).replace(/</g, "\\u003c").replace(/>/g, "\\u00
163
166
  }
164
167
  a.children && q(e, a.children, n);
165
168
  }
166
- }, x = async (e, t, s = []) => {
169
+ }, V = async (e, t, s = []) => {
167
170
  const a = (o, n) => {
168
171
  for (const i of o) {
169
172
  const c = i.module?.metadata?.moduleId, r = [
@@ -191,7 +194,7 @@ const I = (e) => JSON.stringify(e).replace(/</g, "\\u003c").replace(/>/g, "\\u00
191
194
  }
192
195
  };
193
196
  a(t, s);
194
- }, V = (e, t) => t ? t.startsWith("/") ? e + t : e ? e + "/" + t : "/" + t : e || "", W = (e) => {
197
+ }, W = (e, t) => t ? t.startsWith("/") ? e + t : e ? e + "/" + t : "/" + t : e || "", K = (e) => {
195
198
  const t = /* @__PURE__ */ new Set(), s = (a) => {
196
199
  for (const o of a) {
197
200
  if (o.module && !o.children) {
@@ -204,7 +207,7 @@ const I = (e) => JSON.stringify(e).replace(/</g, "\\u003c").replace(/>/g, "\\u00
204
207
  return s(e), t;
205
208
  }, j = (e, t, s, a = "", o = []) => {
206
209
  for (const n of t) {
207
- const i = V(a, n.path), c = [
210
+ const i = W(a, n.path), c = [
208
211
  ...o,
209
212
  ...n.middlewares || []
210
213
  ], r = !!n.handler, f = !!n.method;
@@ -236,21 +239,21 @@ const I = (e) => JSON.stringify(e).replace(/</g, "\\u003c").replace(/>/g, "\\u00
236
239
  c
237
240
  );
238
241
  }
239
- }, K = async (e) => {
242
+ }, L = async (e) => {
240
243
  const t = new R();
241
244
  t.use(E()), process.env.NODE_ENV !== "production" && t.use("*", T());
242
245
  const a = _().pendingRoutes.splice(0);
243
246
  for (const n of a)
244
247
  await n(t);
245
- y(t, e), q(t, e), await x(t, e);
246
- const o = W(e);
247
- if (j(t, e, o), await O(t, e), A(t), process.env.NODE_ENV !== "production" && !g) {
248
+ y(t, e), q(t, e), await V(t, e);
249
+ const o = K(e);
250
+ if (j(t, e, o), await O(t, e), k(t), process.env.NODE_ENV !== "production" && !g) {
248
251
  const n = globalThis.__veloDevServer;
249
252
  n && (b(n), await S(t, n));
250
253
  }
251
254
  return t;
252
- }, te = async (e) => {
253
- const { routes: t } = e, s = Number(process.env.PORT) || e.port || 3e3, a = process.env.HOST || e.hostname || void 0, o = await K(t);
255
+ }, oe = async (e) => {
256
+ const { routes: t } = e, s = Number(process.env.PORT) || e.port || 3e3, a = process.env.HOST || e.hostname || void 0, o = await L(t);
254
257
  if (process.env.NODE_ENV === "production" && !process.env.VELO_STATIC) {
255
258
  const { serve: n } = await import("@hono/node-server"), { serveStatic: i } = await import("@hono/node-server/serve-static"), { join: c } = await import("node:path"), r = c(process.cwd(), "dist/client");
256
259
  (process.env.STATIC_BASE_URL || "").startsWith("http") || o.use("/*", i({ root: r }));
@@ -263,17 +266,19 @@ const I = (e) => JSON.stringify(e).replace(/</g, "\\u003c").replace(/>/g, "\\u00
263
266
  } else
264
267
  console.log(`Server running on port ${s}`);
265
268
  await S(o, l), b(l), l.once("close", () => {
266
- g === l && (g = null);
269
+ I(l);
267
270
  });
268
271
  }
269
272
  return o;
270
273
  };
271
274
  export {
272
- ee as addRoutes,
273
- K as createApp,
274
- K as default,
275
- I as jsonForScript,
276
- Z as onServer,
275
+ te as addRoutes,
276
+ I as clearActiveServer,
277
+ L as createApp,
278
+ L as default,
279
+ b as flushServerCallbacks,
280
+ N as jsonForScript,
281
+ ee as onServer,
277
282
  P as serverDataStorage,
278
- te as startServer
283
+ oe as startServer
279
284
  };
@@ -19,5 +19,13 @@ import type { TestApp, CreateTestAppOptions } from "./types.js";
19
19
  * getSessionCookie: async ({ user }) => ({ session: await sign(user) }),
20
20
  * });
21
21
  * ```
22
+ *
23
+ * Pass `port` to also serve the same app over real TCP — for external actors
24
+ * (a worker in another process) that cannot call `hono.fetch` in-memory:
25
+ *
26
+ * ```typescript
27
+ * const app = await createTestApp({ routes, port: 0 });
28
+ * await handToWorker(`${app.url}/_action/Jobs/notify`);
29
+ * ```
22
30
  */
23
31
  export declare function createTestApp(opts: CreateTestAppOptions): Promise<TestApp>;