@mauroandre/velojs 0.0.52 → 0.0.53

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
@@ -1320,7 +1320,7 @@ Hooks (`useParams`, `useQuery`, `usePathname`, `Loader`, `useLoader`) access thi
1320
1320
 
1321
1321
  ## Testing
1322
1322
 
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.
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 browser, no fragile mocks of framework internals — and a real TCP port when an external actor has to reach the app.
1324
1324
 
1325
1325
  ```typescript
1326
1326
  import { createTestApp } from "@mauroandre/velojs/testing";
@@ -1332,6 +1332,7 @@ const app = await createTestApp({
1332
1332
  routes,
1333
1333
  bootstrap: async () => { await connect(process.env.MONGO_URI!); },
1334
1334
  getSessionCookie: async ({ user }) => ({ session: await sign(user) }),
1335
+ // port: 0, // optional — also serve this app over real TCP (see below)
1335
1336
  });
1336
1337
 
1337
1338
  // HTTP
@@ -1358,6 +1359,7 @@ await app.close();
1358
1359
  | API | Purpose |
1359
1360
  |-----|---------|
1360
1361
  | `createTestApp(options)` | Build isolated app with bootstrap + auth callback |
1362
+ | `app.port` / `app.url` | Real port and base URL of the TCP listener — `undefined` in the default in-memory mode |
1361
1363
  | `app.get/post/put/patch/delete` | HTTP requests (cookies, headers, query, JSON/FormData body) |
1362
1364
  | `app.action(fn, opts)` | Invoke `action_*` by function reference |
1363
1365
  | `app.loader(fn, opts)` | Invoke `loader` and unwrap response data |
@@ -1368,6 +1370,26 @@ await app.close();
1368
1370
  | `app.reset()` | Clear stream buffers/listeners between tests |
1369
1371
  | `app.close()` | Tear down everything (zero open handles guaranteed) |
1370
1372
 
1373
+ ### External actors over TCP
1374
+
1375
+ 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:
1376
+
1377
+ ```typescript
1378
+ const app = await createTestApp({ routes, port: 0 }); // 0 → free port
1379
+
1380
+ // Hand the URL to the external client (worker, container, another test process)
1381
+ await provisioning.startWorker({ notifyUrl: `${app.url}/_action/Jobs/notify` });
1382
+
1383
+ // The worker POSTs over real HTTP; the in-memory subscription sees the event —
1384
+ // same app, same stream registry, no glue and no second instance
1385
+ const sub = await app.subscribe(stream_progress, { channel: jobId });
1386
+ const event = await sub.next({ timeoutMs: 5000 });
1387
+
1388
+ await app.close(); // closes the listener, dropping in-flight SSE/WebSocket connections
1389
+ ```
1390
+
1391
+ 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.
1392
+
1371
1393
  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
1394
 
1373
1395
  ---
@@ -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/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>;