@mauroandre/velojs 0.0.25 → 0.0.27

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
@@ -534,6 +534,47 @@ The client-side transform rewrites the import to fetch `/_action/admin/Layout/lo
534
534
 
535
535
  ---
536
536
 
537
+ ## Endpoints
538
+
539
+ Declarative HTTP endpoints for anything that isn't a page — webhooks, email-verification redirects, OAuth callbacks, health checks.
540
+
541
+ Declare them directly in `routes.tsx`:
542
+
543
+ ```tsx
544
+ import type { AppRoutes, EndpointHandler } from "@mauroandre/velojs";
545
+ import * as Home from "./pages/Home.js";
546
+ import { githubWebhook } from "./webhooks/github.handler.js";
547
+
548
+ export default [
549
+ { path: "/", module: Home },
550
+ { path: "/api/github/webhook", method: "POST", handler: githubWebhook },
551
+ ] satisfies AppRoutes;
552
+ ```
553
+
554
+ The handler gets `{ c, params, query }` and must return a `Response`:
555
+
556
+ ```ts
557
+ import type { EndpointHandler } from "@mauroandre/velojs";
558
+
559
+ export const githubWebhook: EndpointHandler = async ({ c }) => {
560
+ const body = await c.req.json();
561
+ // ...verify signature, process event...
562
+ return c.json({ ok: true });
563
+ };
564
+ ```
565
+
566
+ Endpoints inherit parent `middlewares` and can be nested inside grouping nodes. The Vite plugin strips endpoint objects (and their handler imports) from the client bundle, so server-only code never ships to the browser.
567
+
568
+ Because endpoints live in `routes.tsx`, the testing toolkit sees them automatically:
569
+
570
+ ```ts
571
+ const res = await app.post("/api/github/webhook", { body: {...}, headers: {...} });
572
+ ```
573
+
574
+ Full documentation: [site/docs/06-endpoints.md](./site/docs/06-endpoints.md).
575
+
576
+ ---
577
+
537
578
  ## Event Streams
538
579
 
539
580
  Push real-time updates from server to client via Server-Sent Events (SSE). Live progress, notifications, metrics, log streaming, AI tokens — anything server → client.
@@ -749,6 +790,85 @@ Only affects emits with `{ snapshot: true }`.
749
790
 
750
791
  ---
751
792
 
793
+ ## Sockets
794
+
795
+ Declarative WebSocket handlers, co-located with the page that needs bidirectional communication. Use for interactive terminals, collaborative editing, live cursors — anything where the client also sends messages.
796
+
797
+ Export `socket_<name>` from a page or layout, and VeloJS registers a WebSocket route at `/_socket/{moduleId}/{name}` with middleware inheritance.
798
+
799
+ ### Shortest example
800
+
801
+ ```tsx
802
+ // app/workers/WorkerTerminal.tsx
803
+ import type { SocketHandler } from "@mauroandre/velojs";
804
+ import { parseJson } from "@mauroandre/velojs/sockets";
805
+ import { useSocket } from "@mauroandre/velojs/hooks";
806
+
807
+ export const socket_terminal: SocketHandler = async ({
808
+ incoming, send, keepOpen, abortSignal, c, params,
809
+ }) => {
810
+ const session = await createTerminal(params.workerId);
811
+ session.on("data", (chunk) => send({ type: "data", data: chunk }));
812
+ abortSignal.addEventListener("abort", () => session.destroy());
813
+ keepOpen();
814
+
815
+ for await (const msg of parseJson<{ type: string; data?: string; cols?: number; rows?: number }>(incoming)) {
816
+ if (msg.type === "data" && msg.data) session.write(msg.data);
817
+ if (msg.type === "resize") session.resize(msg.cols!, msg.rows!);
818
+ }
819
+ };
820
+
821
+ export const Component = () => {
822
+ const { send, status } = useSocket(socket_terminal, { channel: workerId });
823
+ // ...
824
+ };
825
+ ```
826
+
827
+ ### Handler args
828
+
829
+ | Arg | Description |
830
+ |---|---|
831
+ | `incoming` | `AsyncIterable<string \| Uint8Array>` — raw frames. Wrap with `parseJson<T>()` for JSON auto-parse. |
832
+ | `send(msg)` | Send a frame. `string` / `Uint8Array` pass through; `object` → `JSON.stringify`. |
833
+ | `close(code?, reason?)` | Server-initiated close. |
834
+ | `keepOpen()` | Required for long-lived handlers. If you return without it, the socket closes. `for await (const m of incoming)` implicitly holds the handler. |
835
+ | `abortSignal` | Fires on disconnect / `app.close()`. Register all cleanup here. |
836
+ | `c`, `params`, `query` | Hono context, URL params, query. Auth via `c.get("user")` from middleware. |
837
+
838
+ ### Middleware inheritance
839
+
840
+ Middleware runs **before** the WebSocket upgrade — an `authMiddleware` that rejects returns an HTTP error and the client never sees an open socket. Same `middlewares` array on parent route nodes as pages/actions/streams.
841
+
842
+ ### Client hook
843
+
844
+ ```ts
845
+ const { send, status, lastMessage, close } = useSocket(socket_terminal, {
846
+ channel: workerId,
847
+ onMessage: (msg) => { /* ... */ },
848
+ });
849
+ ```
850
+
851
+ - `status: Signal<"connecting" | "open" | "closed">` — reactive.
852
+ - **No auto-reconnect** — sockets are usually stateful (pty sessions, collaborative state); blind reconnect loses state silently. Re-mount or toggle `enabled` to reconnect.
853
+
854
+ ### Testing
855
+
856
+ ```ts
857
+ const ws = await app.socket(socket_terminal, {
858
+ user: { id: "alice" },
859
+ params: { workerId: "w42" },
860
+ });
861
+ ws.send({ type: "data", data: "ls\n" });
862
+ const reply = await ws.next({ timeoutMs: 500 });
863
+ await ws.close();
864
+ ```
865
+
866
+ `app.socket()` invokes the handler in-memory and aborts on `app.close()`. **Middleware does NOT run** — pass `user` via options to shortcut `c.get("user")`; test middleware via regular endpoint tests that use the same middleware.
867
+
868
+ Full reference: [site/docs/12-sockets.md](./site/docs/12-sockets.md).
869
+
870
+ ---
871
+
752
872
  ## Hooks
753
873
 
754
874
  All hooks work in both SSR and client (via AsyncLocalStorage on server, wouter/DOM on client).
@@ -1238,7 +1358,7 @@ await app.close();
1238
1358
  | `app.reset()` | Clear stream buffers/listeners between tests |
1239
1359
  | `app.close()` | Tear down everything (zero open handles guaranteed) |
1240
1360
 
1241
- See [Testing docs](https://github.com/mauro-andre/velojs/blob/dev/site/docs/16-testing.md) for the full guide with patterns, isolation, and FAQ.
1361
+ 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.
1242
1362
 
1243
1363
  ---
1244
1364
 
package/dist/client.js CHANGED
@@ -1,19 +1,46 @@
1
- import { jsx as r } from "preact/jsx-runtime";
2
- import { hydrate as l } from "preact";
3
- import { Router as p, Route as s, Switch as h } from "wouter-preact";
4
- const u = (e, o = !1) => e.map((t, n) => {
5
- const c = t.module.Component;
6
- if (!t.children)
7
- return /* @__PURE__ */ r(s, { path: t.path || "", children: /* @__PURE__ */ r(c, {}) }, n);
8
- const i = u(t.children, !!t.path);
9
- return t.isRoot ? /* @__PURE__ */ r(h, { children: i }, n) : t.path ? /* @__PURE__ */ r(s, { path: t.path, nest: !0, children: /* @__PURE__ */ r(c, { children: /* @__PURE__ */ r(h, { children: i }) }) }, n) : /* @__PURE__ */ r(c, { children: /* @__PURE__ */ r(h, { children: i }) }, n);
10
- }), m = ({ routes: e }) => {
11
- const o = u(e);
12
- return /* @__PURE__ */ r(p, { children: o });
13
- }, R = (e) => {
14
- const { routes: o } = e, t = document.querySelector("body");
15
- t && l(/* @__PURE__ */ r(m, { routes: o }), t);
1
+ import { jsx as e } from "preact/jsx-runtime";
2
+ import { hydrate as p } from "preact";
3
+ import { Router as f, Route as l, Switch as s } from "wouter-preact";
4
+ const u = (r, i = !1) => {
5
+ const o = [];
6
+ for (let n = 0; n < r.length; n++) {
7
+ const t = r[n];
8
+ if (!t.module) {
9
+ if (t.children)
10
+ for (const d of u(t.children, i))
11
+ o.push(d);
12
+ continue;
13
+ }
14
+ const c = t.module.Component;
15
+ if (!t.children) {
16
+ o.push(
17
+ /* @__PURE__ */ e(l, { path: t.path || "", children: /* @__PURE__ */ e(c, {}) }, n)
18
+ );
19
+ continue;
20
+ }
21
+ const h = u(t.children, !!t.path);
22
+ if (t.isRoot) {
23
+ o.push(/* @__PURE__ */ e(s, { children: h }, n));
24
+ continue;
25
+ }
26
+ if (!t.path) {
27
+ o.push(
28
+ /* @__PURE__ */ e(c, { children: /* @__PURE__ */ e(s, { children: h }) }, n)
29
+ );
30
+ continue;
31
+ }
32
+ o.push(
33
+ /* @__PURE__ */ e(l, { path: t.path, nest: !0, children: /* @__PURE__ */ e(c, { children: /* @__PURE__ */ e(s, { children: h }) }) }, n)
34
+ );
35
+ }
36
+ return o;
37
+ }, m = ({ routes: r }) => {
38
+ const i = u(r);
39
+ return /* @__PURE__ */ e(f, { children: i });
40
+ }, C = (r) => {
41
+ const { routes: i } = r, o = document.querySelector("body");
42
+ o && p(/* @__PURE__ */ e(m, { routes: i }), o);
16
43
  };
17
44
  export {
18
- R as startClient
45
+ C as startClient
19
46
  };
package/dist/events.d.ts CHANGED
@@ -89,6 +89,45 @@ export type SourceFn<TEvent> = (emit: EmitFn<TEvent>, ctx: {
89
89
  export type PerChannelSourceFn<TEvent> = (channelKey: string, emit: (event: TEvent, options?: EmitOptions) => void, ctx: {
90
90
  abortSignal: AbortSignal;
91
91
  }) => void | Promise<void>;
92
+ /**
93
+ * Arguments passed to a stream `pipe` handler.
94
+ *
95
+ * A `pipe` is a **per-channel producer** (same scoping as `perChannelSource`)
96
+ * with the unified vocabulary used across `stream_*` and `socket_*`:
97
+ * `send / keepOpen / close / abortSignal`. Prefer `pipe` for new code.
98
+ *
99
+ * `c`, `params`, `query` are captured from the **first** subscriber whose
100
+ * connect spawned the pipe on this channel. Subsequent subscribers share the
101
+ * same pipe instance.
102
+ */
103
+ export interface StreamPipeArgs<TEvent> {
104
+ /** Emit an event to all current subscribers of this channel. */
105
+ send: (event: TEvent, options?: EmitOptions) => void;
106
+ /**
107
+ * Mark the pipe as long-running. If `pipe` returns **without** calling
108
+ * `keepOpen()`, the framework closes this channel (cleanup + notify subs).
109
+ * If `keepOpen()` is called, the pipe stays alive until the abort signal
110
+ * fires (last subscriber disconnected, or external `stream.close`).
111
+ */
112
+ keepOpen: () => void;
113
+ /** Close this channel imperatively (fires notify + schedules cleanup). */
114
+ close: () => void;
115
+ /** Fires when the last subscriber of this channel disconnects. */
116
+ abortSignal: AbortSignal;
117
+ /** Hono Context from the first subscriber's request. */
118
+ c: Context;
119
+ /** URL params from the first subscriber's request. */
120
+ params: Record<string, string>;
121
+ /** Query params from the first subscriber's request. */
122
+ query: Record<string, string>;
123
+ /** The channel key this pipe is running for. Empty string for broadcast streams. */
124
+ channel: string;
125
+ }
126
+ /**
127
+ * A `pipe` handler — per-channel producer with unified vocabulary.
128
+ * Mutually exclusive with `source` and `perChannelSource`.
129
+ */
130
+ export type StreamPipeFn<TEvent> = (args: StreamPipeArgs<TEvent>) => void | Promise<void>;
92
131
  /**
93
132
  * Async-capable channel resolver. Returns a channel ID, or `null`/`undefined`
94
133
  * to reject the connection (framework responds 403).
@@ -172,9 +211,21 @@ export interface EventStreamConfig<TEvent, TSnapshot = TEvent> {
172
211
  * external pub/sub subscriptions). The `emit` provided is bound to the current
173
212
  * channel — call it with just the value.
174
213
  *
175
- * Mutually exclusive with `source`.
214
+ * Mutually exclusive with `source` and `pipe`.
176
215
  */
177
216
  perChannelSource?: PerChannelSourceFn<TEvent>;
217
+ /**
218
+ * Per-channel producer with the unified `send / keepOpen / abortSignal`
219
+ * vocabulary shared with `socket_*`. Invoked when the first subscriber of a
220
+ * channel connects. Aborted when the last subscriber of that channel
221
+ * disconnects.
222
+ *
223
+ * Prefer `pipe` for new code. `source` and `perChannelSource` remain for
224
+ * backwards compatibility.
225
+ *
226
+ * Mutually exclusive with `source` and `perChannelSource`.
227
+ */
228
+ pipe?: StreamPipeFn<TEvent>;
178
229
  /**
179
230
  * Time (ms) the per-channel snapshot buffer is retained after `close()`.
180
231
  * Reconnecting clients within this window still see the final state.
@@ -217,8 +268,12 @@ export interface EventStream<TEvent, TSnapshot = TEvent> {
217
268
  readonly __buffers: Map<string, TEvent[]>;
218
269
  /** Internal: assigned URL path (set by standalone or by stream_* discovery) */
219
270
  __path: string | undefined;
220
- /** Internal: source lifecycle hook */
221
- __onConnect(channelKey: string, listener: StreamListener<TEvent>): void;
271
+ /** Internal: source lifecycle hook. `ctx` is used to seed `pipe` when it spawns. */
272
+ __onConnect(channelKey: string, listener: StreamListener<TEvent>, ctx?: {
273
+ c: Context;
274
+ params: Record<string, string>;
275
+ query: Record<string, string>;
276
+ }): void;
222
277
  /** Internal: source lifecycle hook */
223
278
  __onDisconnect(channelKey: string, listener: StreamListener<TEvent>): void;
224
279
  /** @internal Test-only: reset all transient state (buffers, listeners, controllers, timers). */