@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 +44 -2
- package/agent/skills/velojs-event-streams/SKILL.md +28 -0
- package/agent/skills/velojs-sockets/SKILL.md +16 -0
- package/agent/skills/velojs-testing/SKILL.md +35 -4
- package/dist/hooks.d.ts +12 -0
- package/dist/hooks.js +22 -22
- package/dist/server.d.ts +15 -0
- package/dist/server.js +42 -37
- package/dist/testing/createTestApp.d.ts +8 -0
- package/dist/testing/index.js +432 -386
- package/dist/testing/types.d.ts +30 -0
- package/package.json +3 -1
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
|
|
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
|
|
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
|
|
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),
|
|
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) ||
|
|
51
|
-
}, r ?? []), { data: o, loading: a, refetch:
|
|
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:
|
|
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
|
-
|
|
80
|
-
const 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,
|
|
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),
|
|
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",
|
|
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
|
|
116
|
-
|
|
117
|
-
for (const _ of
|
|
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", (
|
|
123
|
+
}), f.addEventListener("message", (c) => {
|
|
124
124
|
let _;
|
|
125
|
-
if (typeof
|
|
126
|
-
_ =
|
|
127
|
-
else if (
|
|
128
|
-
_ = new Uint8Array(
|
|
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", (
|
|
133
|
-
t.value = "closed", a.current = null, h?.(
|
|
134
|
-
}), f.addEventListener("error", (
|
|
135
|
-
o.value =
|
|
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 &&
|
|
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
|
|
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
|
|
7
|
-
import { AsyncLocalStorage as
|
|
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
|
|
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
|
|
11
|
+
const P = new $();
|
|
12
12
|
globalThis.__veloServerData = P;
|
|
13
13
|
let g = null;
|
|
14
|
-
function
|
|
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
|
|
27
|
+
function I(e) {
|
|
28
|
+
g === e && (g = null);
|
|
29
|
+
}
|
|
30
|
+
function te(e) {
|
|
28
31
|
_().pendingRoutes.push(e);
|
|
29
32
|
}
|
|
30
|
-
const
|
|
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__ */
|
|
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__=${
|
|
44
|
+
const i = `<script>window.__PAGE_DATA__=${N(s)}<\/script>`;
|
|
42
45
|
return e.html(n.replace("</head>", () => `${i}</head>`));
|
|
43
|
-
},
|
|
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
|
-
},
|
|
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__ */
|
|
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__ */
|
|
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
|
|
113
|
+
const { components: u, data: h, shortCircuit: m } = await G(n, d);
|
|
111
114
|
if (m) {
|
|
112
|
-
const
|
|
113
|
-
return d.req.query("_data") === "1" &&
|
|
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 =
|
|
116
|
-
return
|
|
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
|
|
152
|
-
return u.json({ error:
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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 =
|
|
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
|
-
},
|
|
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
|
|
246
|
-
const o =
|
|
247
|
-
if (j(t, e, o), await O(t, e),
|
|
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
|
-
},
|
|
253
|
-
const { routes: t } = e, s = Number(process.env.PORT) || e.port || 3e3, a = process.env.HOST || e.hostname || void 0, o = await
|
|
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
|
-
|
|
269
|
+
I(l);
|
|
267
270
|
});
|
|
268
271
|
}
|
|
269
272
|
return o;
|
|
270
273
|
};
|
|
271
274
|
export {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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>;
|