Skip to content

Commit 032fe1a

Browse files
committed
Adding more transforms and preparing for an initial release
1 parent 15b7092 commit 032fe1a

7 files changed

Lines changed: 310 additions & 44 deletions

File tree

.changeset/silver-clocks-sleep.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@solid-primitives/sse": minor
3+
---
4+
5+
Initial release of `@solid-primitives/sse`.
6+
7+
### Primitives
8+
9+
- `makeSSE(url, options?)` — base non-reactive primitive. Creates an `EventSource`, attaches handlers, and returns `[source, cleanup]`. No Solid lifecycle dependency.
10+
- `createSSE(url, options?)` — reactive primitive. Accepts a static or signal URL, closes on owner disposal, and exposes `data`, `error`, `readyState`, `close`, and `reconnect`.
11+
- `makeSSEWorker(target, options?)` — runs the `EventSource` connection inside a Web Worker or SharedWorker, keeping network I/O off the main thread. The reactive API is identical to `createSSE`.
12+
13+
### Built-in transformers
14+
15+
- `json` — parse message data as a single JSON value
16+
- `ndjson` — parse newline-delimited JSON into an array
17+
- `lines` — split message data into a `string[]` by newline
18+
- `number` — parse message data as a number via `Number()`
19+
- `safe(transform, fallback?)` — fault-tolerant wrapper; returns `fallback` instead of throwing on bad input
20+
- `pipe(a, b)` — compose two transforms into one

packages/sse/README.md

Lines changed: 10 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Primitives for [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web
1414
- [`makeSSE`](#makesse) — Base non-reactive primitive. Creates an `EventSource` and returns a cleanup function. No Solid lifecycle.
1515
- [`createSSE`](#createsse) — Reactive primitive. Accepts a reactive URL, integrates with Solid's owner lifecycle, and returns signals for `data`, `error`, and `readyState`.
1616
- [`makeSSEWorker`](./WORKERS.md) — Runs the SSE connection inside a Web Worker or SharedWorker. See [WORKERS.md](./WORKERS.md).
17+
- [Built-in transformers](./TRANSFORMS.md)`json`, `ndjson`, `lines`, `number`, `safe`, `pipe`. See [TRANSFORMS.md](./TRANSFORMS.md).
1718

1819
## Installation
1920

@@ -156,48 +157,16 @@ SSEReadyState.CLOSED // 2
156157

157158
## Built-in transformers
158159

159-
Three ready-made `transform` functions are exported for the most common SSE data formats.
160+
Ready-made `transform` functions for the most common SSE data formats. See [TRANSFORMS.md](./TRANSFORMS.md) for full documentation and examples.
160161

161-
### `json`
162-
163-
Parse the message data as a single JSON value. Equivalent to `JSON.parse` but named for consistency with the other transformers.
164-
165-
```ts
166-
import { createSSE, json } from "@solid-primitives/sse";
167-
168-
const { data } = createSSE<{ status: string; ts: number }>(url, { transform: json });
169-
// data() === { status: "ok", ts: 1718000000 }
170-
```
171-
172-
### `ndjson`
173-
174-
Parse the message data as [newline-delimited JSON](https://ndjson.org/) (NDJSON / JSON Lines). Each non-empty line is parsed as a separate JSON value and the transformer returns an array.
175-
176-
Use this when the server batches multiple objects into one SSE event:
177-
178-
```
179-
data: {"id":1,"type":"tick"}
180-
data: {"id":2,"type":"tick"}
181-
182-
```
183-
184-
```ts
185-
import { createSSE, ndjson } from "@solid-primitives/sse";
186-
187-
const { data } = createSSE<TickEvent[]>(url, { transform: ndjson });
188-
// data() === [{ id: 1, type: "tick" }, { id: 2, type: "tick" }]
189-
```
190-
191-
### `lines`
192-
193-
Split the message data into individual lines, returning a `string[]`. Empty lines are filtered out. Useful for multi-line text events that are not JSON.
194-
195-
```ts
196-
import { createSSE, lines } from "@solid-primitives/sse";
197-
198-
const { data } = createSSE<string[]>(url, { transform: lines });
199-
// data() === ["line one", "line two"]
200-
```
162+
| Transformer | Description |
163+
|---|---|
164+
| [`json`](./TRANSFORMS.md#json) | Parse data as a single JSON value |
165+
| [`ndjson`](./TRANSFORMS.md#ndjson) | Parse newline-delimited JSON into an array |
166+
| [`lines`](./TRANSFORMS.md#lines) | Split data into a `string[]` by newline |
167+
| [`number`](./TRANSFORMS.md#number) | Parse data as a number via `Number()` |
168+
| [`safe(transform, fallback?)`](./TRANSFORMS.md#safetransform-fallback) | Fault-tolerant wrapper — returns `fallback` instead of throwing |
169+
| [`pipe(a, b)`](./TRANSFORMS.md#pipea-b) | Compose two transforms into one |
201170

202171
## Integration with `@solid-primitives/event-bus`
203172

packages/sse/TRANSFORMS.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Built-in transformers
2+
3+
Ready-made `transform` functions for the most common SSE data formats. Pass one as the `transform` option to `createSSE`:
4+
5+
```ts
6+
import { createSSE, json } from "@solid-primitives/sse";
7+
8+
const { data } = createSSE<{ status: string }>(url, { transform: json });
9+
```
10+
11+
---
12+
13+
## `json`
14+
15+
Parse the message data as a single JSON value. Equivalent to `JSON.parse` but named for consistency with the other transformers.
16+
17+
```ts
18+
import { createSSE, json } from "@solid-primitives/sse";
19+
20+
const { data } = createSSE<{ status: string; ts: number }>(url, { transform: json });
21+
// data() === { status: "ok", ts: 1718000000 }
22+
```
23+
24+
---
25+
26+
## `ndjson`
27+
28+
Parse the message data as [newline-delimited JSON](https://ndjson.org/) (NDJSON / JSON Lines). Each non-empty line is parsed as a separate JSON value and the transformer returns an array.
29+
30+
Use this when the server batches multiple objects into one SSE event:
31+
32+
```
33+
data: {"id":1,"type":"tick"}
34+
data: {"id":2,"type":"tick"}
35+
36+
```
37+
38+
```ts
39+
import { createSSE, ndjson } from "@solid-primitives/sse";
40+
41+
const { data } = createSSE<TickEvent[]>(url, { transform: ndjson });
42+
// data() === [{ id: 1, type: "tick" }, { id: 2, type: "tick" }]
43+
```
44+
45+
---
46+
47+
## `lines`
48+
49+
Split the message data into individual lines, returning a `string[]`. Empty lines are filtered out. Useful for multi-line text events that are not JSON.
50+
51+
```ts
52+
import { createSSE, lines } from "@solid-primitives/sse";
53+
54+
const { data } = createSSE<string[]>(url, { transform: lines });
55+
// data() === ["line one", "line two"]
56+
```
57+
58+
---
59+
60+
## `number`
61+
62+
Parse the message data as a number using `Number()` semantics. Handy for streams that emit counters, progress percentages, sensor readings, or prices.
63+
64+
```ts
65+
import { createSSE, number } from "@solid-primitives/sse";
66+
67+
const { data } = createSSE<number>(url, { transform: number });
68+
// data() === 42
69+
```
70+
71+
Note: follows `Number()` coercion — an empty string becomes `0` and non-numeric strings become `NaN`.
72+
73+
---
74+
75+
## `safe(transform, fallback?)`
76+
77+
Wraps any transform in a `try/catch`. When the inner transform throws, `safe` returns `fallback` instead of propagating the error. This keeps the stream alive across malformed events.
78+
79+
```ts
80+
import { createSSE, json, number, safe } from "@solid-primitives/sse";
81+
82+
// Returns undefined on a bad event instead of throwing
83+
const { data } = createSSE<MyEvent>(url, { transform: safe(json) });
84+
85+
// With an explicit fallback value
86+
const { data } = createSSE<number>(url, { transform: safe(number, 0) });
87+
```
88+
89+
---
90+
91+
## `pipe(a, b)`
92+
93+
Composes two transforms into one: the output of `a` is passed as the input of `b`. Useful for building custom transforms from existing primitives without writing anonymous functions.
94+
95+
```ts
96+
import { createSSE, ndjson, json, safe, pipe } from "@solid-primitives/sse";
97+
98+
// Parse NDJSON then keep only "tick" rows
99+
type RawEvent = { type: string };
100+
const { data } = createSSE<RawEvent[]>(url, {
101+
transform: pipe(ndjson<RawEvent>, rows => rows.filter(r => r.type === "tick")),
102+
});
103+
104+
// Safe JSON with a post-processing step
105+
const { data } = createSSE<string>(url, {
106+
transform: pipe(safe(json<{ label: string }>), ev => ev?.label ?? ""),
107+
});
108+
```

packages/sse/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
"makeSSEWorker",
2323
"json",
2424
"ndjson",
25-
"lines"
25+
"lines",
26+
"number",
27+
"safe",
28+
"pipe"
2629
],
2730
"category": "Network"
2831
},

packages/sse/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ export {
1111
type SSEReturn,
1212
} from "./sse.js";
1313

14-
export { json, ndjson, lines } from "./transform.js";
14+
export { json, ndjson, lines, number, safe, pipe } from "./transform.js";

packages/sse/src/transform.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,65 @@ export const ndjson = <T>(raw: string): T[] =>
5757
* ```
5858
*/
5959
export const lines = (raw: string): string[] => raw.split("\n").filter(line => line !== "");
60+
61+
/**
62+
* Parse SSE message data as a number using `Number()` semantics.
63+
*
64+
* Use this for streams that emit plain numeric values: counters, progress
65+
* percentages, sensor readings, prices, etc.
66+
*
67+
* ```ts
68+
* const { data } = createSSE<number>(url, { transform: number });
69+
* // data() === 42
70+
* ```
71+
*
72+
* Note: follows `Number()` coercion — `""` → `0`, non-numeric strings → `NaN`.
73+
*/
74+
export const number = (raw: string): number => Number(raw);
75+
76+
/**
77+
* Wrap any transform in a `try/catch` so that a malformed event does not
78+
* throw; instead it returns `fallback` (default `undefined`).
79+
*
80+
* ```ts
81+
* // Returns undefined on bad input instead of throwing
82+
* const { data } = createSSE<MyEvent>(url, { transform: safe(json) });
83+
*
84+
* // With an explicit fallback value
85+
* const { data } = createSSE<number>(url, { transform: safe(number, 0) });
86+
* ```
87+
*/
88+
export function safe<T>(transform: (raw: string) => T): (raw: string) => T | undefined;
89+
export function safe<T>(transform: (raw: string) => T, fallback: T): (raw: string) => T;
90+
export function safe<T>(
91+
transform: (raw: string) => T,
92+
fallback?: T,
93+
): (raw: string) => T | undefined {
94+
return (raw: string): T | undefined => {
95+
try {
96+
return transform(raw);
97+
} catch {
98+
return fallback;
99+
}
100+
};
101+
}
102+
103+
/**
104+
* Compose two transforms into one: the output of `a` is passed as the input
105+
* of `b`.
106+
*
107+
* ```ts
108+
* // Parse NDJSON then keep only "tick" events
109+
* const { data } = createSSE<TickEvent[]>(url, {
110+
* transform: pipe(ndjson<RawEvent>, rows => rows.filter(r => r.type === "tick")),
111+
* });
112+
*
113+
* // Safe JSON followed by a post-processing step
114+
* const { data } = createSSE<string>(url, {
115+
* transform: pipe(safe(json<{ label: string }>), ev => ev?.label ?? ""),
116+
* });
117+
* ```
118+
*/
119+
export function pipe<A, B>(a: (raw: string) => A, b: (a: A) => B): (raw: string) => B {
120+
return (raw: string): B => b(a(raw));
121+
}

packages/sse/test/transform.test.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { json, ndjson, lines } from "../src/transform.js";
2+
import { json, ndjson, lines, number, safe, pipe } from "../src/transform.js";
33

44
// ── json ──────────────────────────────────────────────────────────────────────
55

@@ -80,3 +80,107 @@ describe("lines", () => {
8080
expect(lines("")).toEqual([]);
8181
});
8282
});
83+
84+
// ── number ────────────────────────────────────────────────────────────────────
85+
86+
describe("number", () => {
87+
it("parses an integer string", () => {
88+
expect(number("42")).toBe(42);
89+
});
90+
91+
it("parses a float string", () => {
92+
expect(number("3.14")).toBe(3.14);
93+
});
94+
95+
it("parses a negative number", () => {
96+
expect(number("-7")).toBe(-7);
97+
});
98+
99+
it("converts an empty string to 0", () => {
100+
expect(number("")).toBe(0);
101+
});
102+
103+
it("converts a non-numeric string to NaN", () => {
104+
expect(number("not a number")).toBeNaN();
105+
});
106+
});
107+
108+
// ── safe ──────────────────────────────────────────────────────────────────────
109+
110+
describe("safe", () => {
111+
it("returns the transform result when successful", () => {
112+
expect(safe(json)('{"a":1}')).toEqual({ a: 1 });
113+
});
114+
115+
it("returns undefined when the inner transform throws (no fallback)", () => {
116+
expect(safe(json)("bad json")).toBeUndefined();
117+
});
118+
119+
it("returns the fallback when the inner transform throws", () => {
120+
expect(safe(json, null)("bad json")).toBeNull();
121+
});
122+
123+
it("returns a numeric fallback on error", () => {
124+
expect(safe(number, 0)("NaN")).toBe(NaN); // Number("NaN") === NaN, not throwing
125+
// Demonstrate fallback with a throwing transform:
126+
const throwing = (_: string): number => {
127+
throw new Error("fail");
128+
};
129+
expect(safe(throwing, -1)("any")).toBe(-1);
130+
});
131+
132+
it("is composable: safe(ndjson) returns undefined on invalid line", () => {
133+
expect(safe(ndjson)('{"a":1}\nbad')).toBeUndefined();
134+
});
135+
136+
it("overload without fallback infers T | undefined return type", () => {
137+
const result: { a: number } | undefined = safe(json<{ a: number }>)('{"a":1}');
138+
expect(result).toEqual({ a: 1 });
139+
});
140+
141+
it("overload with fallback infers T return type", () => {
142+
const result: { a: number } = safe(json<{ a: number }>, { a: 0 })("bad");
143+
expect(result).toEqual({ a: 0 });
144+
});
145+
});
146+
147+
// ── pipe ──────────────────────────────────────────────────────────────────────
148+
149+
describe("pipe", () => {
150+
it("passes the string through both transforms in order", () => {
151+
const upper = (s: string) => s.toUpperCase();
152+
const exclaim = (s: string) => `${s}!`;
153+
expect(pipe(upper, exclaim)("hello")).toBe("HELLO!");
154+
});
155+
156+
it("composes json with a post-processing step", () => {
157+
const getLabel = pipe(json<{ label: string }>, ev => ev.label);
158+
expect(getLabel('{"label":"tick"}')).toBe("tick");
159+
});
160+
161+
it("composes ndjson with a filter step", () => {
162+
type Row = { type: string };
163+
const ticks = pipe(
164+
ndjson<Row>,
165+
rows => rows.filter(r => r.type === "tick"),
166+
);
167+
expect(ticks('{"type":"tick"}\n{"type":"other"}\n{"type":"tick"}')).toEqual([
168+
{ type: "tick" },
169+
{ type: "tick" },
170+
]);
171+
});
172+
173+
it("composes safe(json) with a fallback mapping", () => {
174+
const getLabel = pipe(safe(json<{ label: string }>), ev => ev?.label ?? "");
175+
expect(getLabel('{"label":"ok"}')).toBe("ok");
176+
expect(getLabel("bad")).toBe("");
177+
});
178+
179+
it("infers the correct return type", () => {
180+
const toLength: (raw: string) => number = pipe(
181+
(s: string) => s.split(","),
182+
arr => arr.length,
183+
);
184+
expect(toLength("a,b,c")).toBe(3);
185+
});
186+
});

0 commit comments

Comments
 (0)