feat(v1.1.6-client): @picloud/client TypeScript package

First frontend library (v1.0.0), co-shipped with realtime. Hybrid
model — no direct service access from the browser.

- endpoint<Req,Res>(path).get()/.post() — typed HTTP, auth-token
  injection, structured errors, optional zod/valibot validate adapter.
- subscribe(topic, cb, {token, onTokenExpired}) — streaming-fetch SSE
  with exponential-backoff reconnect, 401 token refresh, Last-Event-ID
  resume.
- auth.login/logout/token over dev-defined endpoints.
- React (useTopic/useEndpoint + PicloudProvider) and Svelte
  (topicStore/endpointStore) subpath exports.

Build: tsup (ESM+CJS+.d.ts); tests: vitest (15); lint: tsc --noEmit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-04 20:19:07 +02:00
parent fcbcc576a2
commit b1dddb9cb9
21 changed files with 4848 additions and 0 deletions

3
clients/typescript/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo

View File

@@ -0,0 +1,111 @@
# @picloud/client
TypeScript client for [PiCloud](../../README.md). Three capabilities, all
**script-mediated** — there is no direct KV / docs / users access from the
browser (the hybrid model, by design):
1. **Typed HTTP** to dev-defined script endpoints.
2. **SSE realtime** subscriptions to externally-subscribable pub/sub topics.
3. **Auth-flow helpers** over your own dev-defined login/logout endpoints.
```ts
import { PicloudClient } from '@picloud/client';
const client = new PicloudClient({
baseURL: 'https://api.example.com',
getAuthToken: () => localStorage.getItem('auth_token')
});
// Typed HTTP
interface CreateUserReq { name: string; email?: string; role: string }
interface CreateUserRes { id: string; name: string; created_at: string }
const user = await client
.endpoint<CreateUserReq, CreateUserRes>('/api/users')
.post({ name: 'alice', role: 'admin' });
// SSE subscription
const unsubscribe = client.subscribe('chat-room-123', (event) => {
console.log('got event:', event.message);
});
unsubscribe();
// Token-gated topic (token obtained from one of YOUR script endpoints,
// which calls `pubsub::subscriber_token`)
client.subscribe('chat-room-123', cb, { token: 'eyJhbGc...' });
// Auth helpers (call dev-defined endpoints under the hood)
await client.auth.login('alice@example.com', 'password');
await client.auth.logout();
const token = client.auth.token;
```
## React
```tsx
import { PicloudProvider, useTopic, useEndpoint } from '@picloud/client/react';
// Wrap your tree once: <PicloudProvider client={client}>…</PicloudProvider>
function ChatRoom({ roomId }: { roomId: string }) {
const messages = useTopic<ChatMessage>(`chat-room-${roomId}`);
return <ul>{messages.map((m, i) => <li key={i}>{m.text}</li>)}</ul>;
}
function UserProfile({ id }: { id: string }) {
const { data, loading, error } = useEndpoint<UserRes>(`/api/users/${id}`).get();
if (loading) return <Spinner />;
if (error) return <ErrorView error={error} />;
return <div>{data?.name}</div>;
}
```
## Svelte
```ts
import { topicStore, endpointStore } from '@picloud/client/svelte';
const messages = topicStore<ChatMessage>(client, `chat-room-${roomId}`);
// $messages is an array that grows as events arrive
const userQuery = endpointStore<UserRes>(client, `/api/users/${id}`).get();
// $userQuery is { data, loading, error }
```
> The Svelte helpers take the `client` explicitly (a store isn't a component,
> so there's no React-style context to read).
## Optional runtime validation (zod / valibot)
No hard dependency — the adapter is the `{ parse(input): T }` shape. A Zod
schema satisfies it directly; wrap Valibot in one line:
```ts
import { z } from 'zod';
const UserSchema = z.object({ id: z.string(), name: z.string() });
const user = await client.endpoint('/api/users/1').get({ validate: UserSchema });
// valibot:
import * as v from 'valibot';
const schema = v.object({ id: v.string() });
const adapter = { parse: (i: unknown) => v.parse(schema, i) };
```
## Transport notes
- SSE is implemented over streaming `fetch` (not native `EventSource`) so the
client can refresh an expired token on a 401, send `Last-Event-ID` on resume,
and apply its own exponential backoff (1s → 2s → 4s … capped at 30s).
- **React Native** has no native `EventSource`, but it also can't stream
`fetch` bodies on all engines — if you target RN, supply a streaming-capable
`fetch` polyfill via the `fetch` option, or use a `react-native-sse`-based
adapter. (Server-side `Last-Event-ID` replay is not implemented in v1.1.6;
the client sends the header so it's ready when the server adds replay.)
## Build / test
```sh
npm install
npm run lint # tsc --noEmit (strict)
npm run test # vitest
npm run build # tsup → dist/ (ESM + CJS + .d.ts)
```

3580
clients/typescript/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
{
"name": "@picloud/client",
"version": "1.0.0",
"description": "TypeScript client for PiCloud — typed HTTP to script endpoints, SSE realtime subscriptions, auth-flow helpers, and React/Svelte hooks.",
"license": "MIT OR Apache-2.0",
"type": "module",
"sideEffects": false,
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./react": {
"types": "./dist/react/index.d.ts",
"import": "./dist/react/index.js",
"require": "./dist/react/index.cjs"
},
"./svelte": {
"types": "./dist/svelte/index.d.ts",
"import": "./dist/svelte/index.js",
"require": "./dist/svelte/index.cjs"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:watch": "vitest",
"lint": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=17",
"svelte": ">=4"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"svelte": {
"optional": true
}
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/react": "^18.3.0",
"jsdom": "^25.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"svelte": "^4.2.0",
"tsup": "^8.3.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}

View File

@@ -0,0 +1,71 @@
import { Endpoint } from './endpoint.js';
import type { AuthTokenProvider } from './types.js';
export interface AuthClientConfig {
baseURL: string;
fetchImpl: typeof fetch;
/** Path of the dev-defined login endpoint (default `/api/auth/login`). */
loginPath?: string;
/** Path of the dev-defined logout endpoint (default `/api/auth/logout`). */
logoutPath?: string;
/** Called whenever the stored token changes (e.g. to persist it). */
onToken?: (token: string | null) => void;
}
interface LoginResponse {
token?: string;
}
/**
* Auth-flow helpers. These call **dev-defined** endpoints under the hood
* (the script layer owns the actual auth); the lib only standardizes the
* dance + in-memory token storage. There is no built-in identity model —
* `login` POSTs credentials and stores whatever `token` comes back.
*/
export class AuthClient {
private current: string | null = null;
constructor(private readonly cfg: AuthClientConfig) {}
/** The current bearer token, or null. */
get token(): string | null {
return this.current;
}
/** Suitable as `PicloudClientOptions.getAuthToken`. */
readonly provider: AuthTokenProvider = () => this.current;
/** POST credentials to the login endpoint; store the returned token. */
async login(email: string, password: string): Promise<string | null> {
const ep = new Endpoint<{ email: string; password: string }, LoginResponse>({
baseURL: this.cfg.baseURL,
path: this.cfg.loginPath ?? '/api/auth/login',
fetchImpl: this.cfg.fetchImpl
});
const res = await ep.post({ email, password });
this.setToken(typeof res?.token === 'string' ? res.token : null);
return this.current;
}
/** POST to the logout endpoint (best-effort) and clear the token. */
async logout(): Promise<void> {
const ep = new Endpoint<undefined, unknown>({
baseURL: this.cfg.baseURL,
path: this.cfg.logoutPath ?? '/api/auth/logout',
// Send the current token so the script can invalidate the session.
getAuthToken: () => this.current,
fetchImpl: this.cfg.fetchImpl
});
try {
await ep.post();
} finally {
this.setToken(null);
}
}
/** Manually set (or clear) the token — e.g. restoring from storage. */
setToken(token: string | null): void {
this.current = token;
this.cfg.onToken?.(token);
}
}

View File

@@ -0,0 +1,61 @@
import { AuthClient } from './auth.js';
import { Endpoint } from './endpoint.js';
import { subscribeTopic } from './subscribe.js';
import type {
PicloudClientOptions,
RealtimeEvent,
SubscribeOptions,
Unsubscribe
} from './types.js';
/**
* The PiCloud frontend client. Three capabilities, all script-mediated
* (the hybrid model — no direct KV/docs/users access from the browser):
*
* - `endpoint<Req, Res>(path)` — typed HTTP to a dev-defined route.
* - `subscribe(topic, cb, opts?)` — SSE realtime subscription.
* - `auth` — login/logout/token helpers over dev-defined endpoints.
*/
export class PicloudClient {
readonly auth: AuthClient;
private readonly baseURL: string;
private readonly fetchImpl: typeof fetch;
private readonly getAuthToken: PicloudClientOptions['getAuthToken'];
constructor(opts: PicloudClientOptions) {
if (!opts.baseURL) throw new Error('PicloudClient: baseURL is required');
this.baseURL = opts.baseURL;
const f = opts.fetch ?? globalThis.fetch;
if (typeof f !== 'function') {
throw new Error('PicloudClient: no fetch available — pass options.fetch');
}
// Bind to avoid "Illegal invocation" when calling a detached global.
this.fetchImpl = f.bind(globalThis);
this.getAuthToken = opts.getAuthToken;
this.auth = new AuthClient({ baseURL: this.baseURL, fetchImpl: this.fetchImpl });
}
/** A typed handle to a dev-defined endpoint. */
endpoint<Req = unknown, Res = unknown>(path: string): Endpoint<Req, Res> {
return new Endpoint<Req, Res>({
baseURL: this.baseURL,
path,
getAuthToken: this.getAuthToken,
fetchImpl: this.fetchImpl
});
}
/** Subscribe to a realtime topic. Returns an unsubscribe function. */
subscribe<T = unknown>(
topic: string,
onMessage: (event: RealtimeEvent<T>) => void,
opts?: SubscribeOptions<T>
): Unsubscribe {
return subscribeTopic<T>(
{ baseURL: this.baseURL, fetchImpl: this.fetchImpl },
topic,
onMessage,
opts
);
}
}

View File

@@ -0,0 +1,106 @@
import { PicloudHttpError, type AuthTokenProvider, type Validator } from './types.js';
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface EndpointConfig {
baseURL: string;
path: string;
getAuthToken?: AuthTokenProvider;
fetchImpl: typeof fetch;
}
export interface RequestOptions<Res> {
/** Extra headers merged over the defaults. */
headers?: Record<string, string>;
/** Optional runtime validation of the parsed response. */
validate?: Validator<Res>;
/** AbortSignal to cancel the request. */
signal?: AbortSignal;
}
/**
* Typed HTTP to a dev-defined script endpoint. Auth header injection +
* structured errors; the request/response types are caller-supplied
* generics (`endpoint<Req, Res>('/path')`). No service access — every
* call hits a route a script binds (the hybrid model).
*/
export class Endpoint<Req = unknown, Res = unknown> {
constructor(private readonly cfg: EndpointConfig) {}
get(opts?: RequestOptions<Res>): Promise<Res> {
return this.send('GET', undefined, opts);
}
post(body?: Req, opts?: RequestOptions<Res>): Promise<Res> {
return this.send('POST', body, opts);
}
put(body?: Req, opts?: RequestOptions<Res>): Promise<Res> {
return this.send('PUT', body, opts);
}
patch(body?: Req, opts?: RequestOptions<Res>): Promise<Res> {
return this.send('PATCH', body, opts);
}
delete(opts?: RequestOptions<Res>): Promise<Res> {
return this.send('DELETE', undefined, opts);
}
private async send(method: Method, body: Req | undefined, opts?: RequestOptions<Res>): Promise<Res> {
const headers: Record<string, string> = {
Accept: 'application/json',
...(opts?.headers ?? {})
};
if (body !== undefined) {
headers['Content-Type'] ??= 'application/json';
}
const token = this.cfg.getAuthToken ? await this.cfg.getAuthToken() : undefined;
if (token) {
headers['Authorization'] ??= `Bearer ${token}`;
}
const url = joinUrl(this.cfg.baseURL, this.cfg.path);
const init: RequestInit = { method, headers };
if (body !== undefined) {
init.body = JSON.stringify(body);
}
if (opts?.signal) {
init.signal = opts.signal;
}
const res = await this.cfg.fetchImpl(url, init);
const parsed = await parseBody(res);
if (!res.ok) {
const message =
(isRecord(parsed) && typeof parsed['error'] === 'string' && parsed['error']) ||
`${method} ${this.cfg.path} failed with ${res.status}`;
throw new PicloudHttpError(res.status, message, parsed);
}
return opts?.validate ? opts.validate.parse(parsed) : (parsed as Res);
}
}
async function parseBody(res: Response): Promise<unknown> {
const text = await res.text();
if (text.length === 0) return null;
const ct = res.headers.get('content-type') ?? '';
if (ct.includes('application/json')) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
return text;
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null;
}
export function joinUrl(base: string, path: string): string {
const b = base.endsWith('/') ? base.slice(0, -1) : base;
const p = path.startsWith('/') ? path : `/${path}`;
return `${b}${p}`;
}

View File

@@ -0,0 +1,14 @@
export { PicloudClient } from './client.js';
export { Endpoint } from './endpoint.js';
export { AuthClient } from './auth.js';
export { subscribeTopic } from './subscribe.js';
export {
PicloudHttpError,
type PicloudClientOptions,
type AuthTokenProvider,
type RealtimeEvent,
type SubscribeOptions,
type Unsubscribe,
type Validator
} from './types.js';
export type { RequestOptions } from './endpoint.js';

View File

@@ -0,0 +1,101 @@
import {
createContext,
createElement,
useContext,
useEffect,
useState,
type ReactNode
} from 'react';
import type { PicloudClient } from '../client.js';
import type { SubscribeOptions } from '../types.js';
const PicloudContext = createContext<PicloudClient | null>(null);
export interface PicloudProviderProps {
client: PicloudClient;
children?: ReactNode;
}
/** Provides a `PicloudClient` to `useTopic` / `useEndpoint`. */
export function PicloudProvider(props: PicloudProviderProps) {
return createElement(PicloudContext.Provider, { value: props.client }, props.children);
}
/** The client from the nearest `PicloudProvider`. Throws if absent. */
export function usePicloud(): PicloudClient {
const client = useContext(PicloudContext);
if (!client) {
throw new Error('usePicloud: wrap your tree in <PicloudProvider client={...}>');
}
return client;
}
/**
* Subscribe to a realtime topic; returns the accumulated messages in
* arrival order. Re-subscribes when `topic` changes; unsubscribes on
* unmount.
*/
export function useTopic<T = unknown>(topic: string, opts?: SubscribeOptions<T>): T[] {
const client = usePicloud();
const [messages, setMessages] = useState<T[]>([]);
useEffect(() => {
setMessages([]);
const unsubscribe = client.subscribe<T>(
topic,
(event) => setMessages((prev) => [...prev, event.message]),
opts
);
return () => unsubscribe();
// `opts` is intentionally excluded: a new object literal each render
// would otherwise resubscribe every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, topic]);
return messages;
}
export interface QueryState<T> {
data: T | null;
loading: boolean;
error: unknown;
}
export interface EndpointHook<Req, Res> {
get: () => QueryState<Res>;
post: (body?: Req) => QueryState<Res>;
}
/**
* Typed endpoint hook. `useEndpoint<Res>(path).get()` fires a GET and
* returns `{ data, loading, error }`, re-running when `path` changes.
* `.post(body)` is the mutation variant (auto-fires once per mount).
*/
export function useEndpoint<Res = unknown, Req = unknown>(path: string): EndpointHook<Req, Res> {
const client = usePicloud();
return {
get: () => useResource<Res>(() => client.endpoint<Req, Res>(path).get(), path, 'GET'),
post: (body?: Req) =>
useResource<Res>(() => client.endpoint<Req, Res>(path).post(body), path, 'POST')
};
}
function useResource<Res>(run: () => Promise<Res>, key: string, method: string): QueryState<Res> {
const [state, setState] = useState<QueryState<Res>>({
data: null,
loading: true,
error: null
});
useEffect(() => {
let active = true;
setState({ data: null, loading: true, error: null });
run()
.then((data) => active && setState({ data, loading: false, error: null }))
.catch((error) => active && setState({ data: null, loading: false, error }));
return () => {
active = false;
};
// `run` is recreated each render; key it on path + method instead.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, method]);
return state;
}

View File

@@ -0,0 +1,194 @@
import { joinUrl } from './endpoint.js';
import type { RealtimeEvent, SubscribeOptions, Unsubscribe } from './types.js';
interface SubscribeConfig {
baseURL: string;
fetchImpl: typeof fetch;
}
/**
* Subscribe to an app pub/sub topic over SSE.
*
* Implemented over streaming `fetch` (not native `EventSource`) so the
* lib can: detect a 401 on (re)connect and refresh the token, send a
* `Last-Event-ID` header on resume, and apply its own exponential
* backoff. See HANDBACK for the rationale. Returns an unsubscribe
* function that aborts the connection and stops reconnecting.
*/
export function subscribeTopic<T = unknown>(
cfg: SubscribeConfig,
topic: string,
onMessage: (event: RealtimeEvent<T>) => void,
opts: SubscribeOptions<T> = {}
): Unsubscribe {
const baseBackoff = opts.baseBackoffMs ?? 1_000;
const maxBackoff = opts.maxBackoffMs ?? 30_000;
let token = opts.token;
let stopped = false;
let attempt = 0;
let lastEventId: string | undefined;
let controller: AbortController | null = null;
let backoffTimer: ReturnType<typeof setTimeout> | null = null;
const stop = () => {
stopped = true;
if (backoffTimer) clearTimeout(backoffTimer);
controller?.abort();
};
const scheduleReconnect = () => {
if (stopped) return;
// Exponential backoff: base, 2x, 4x… capped at maxBackoff.
const delay = Math.min(maxBackoff, baseBackoff * 2 ** attempt);
attempt += 1;
backoffTimer = setTimeout(() => void connect(), delay);
};
const connect = async (): Promise<void> => {
if (stopped) return;
controller = new AbortController();
const url = buildUrl(cfg.baseURL, topic, token);
const headers: Record<string, string> = { Accept: 'text/event-stream' };
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
let res: Response;
try {
res = await cfg.fetchImpl(url, { headers, signal: controller.signal });
} catch (err) {
if (stopped || isAbort(err)) return;
scheduleReconnect();
return;
}
if (res.status === 401) {
// Token expired / rejected — try to refresh, else give up.
const fresh = opts.onTokenExpired ? await opts.onTokenExpired() : null;
if (fresh) {
token = fresh;
attempt = 0; // fresh credential → reconnect immediately
void connect();
} else {
opts.onError?.(new Error('realtime subscribe unauthorized (401)'));
stop();
}
return;
}
if (!res.ok || !res.body) {
if (!stopped) scheduleReconnect();
return;
}
// Connected — reset backoff and stream frames until the body ends.
attempt = 0;
try {
await readStream(res.body, (frame) => {
if (frame.id !== undefined) lastEventId = frame.id;
if (frame.data === undefined) return; // comment / heartbeat
const parsed = parseEvent<T>(frame.data, opts);
if (parsed) onMessage(parsed);
});
} catch (err) {
if (stopped || isAbort(err)) return;
}
// Stream ended (server closed, e.g. topic deleted) → reconnect.
if (!stopped) scheduleReconnect();
};
void connect();
return stop;
}
function buildUrl(baseURL: string, topic: string, token?: string): string {
const url = joinUrl(baseURL, `/realtime/topics/${encodeURIComponent(topic)}`);
// EventSource can't set headers, so the token rides in the query
// string — the same path a raw EventSource would use.
return token ? `${url}?token=${encodeURIComponent(token)}` : url;
}
function parseEvent<T>(data: string, opts: SubscribeOptions<T>): RealtimeEvent<T> | null {
let json: unknown;
try {
json = JSON.parse(data);
} catch {
return null;
}
if (!isRealtimeShape(json)) return null;
const message = opts.validate ? opts.validate.parse(json.message) : (json.message as T);
return { topic: json.topic, message, published_at: json.published_at };
}
function isRealtimeShape(v: unknown): v is RealtimeEvent<unknown> {
return (
typeof v === 'object' &&
v !== null &&
typeof (v as Record<string, unknown>)['topic'] === 'string' &&
typeof (v as Record<string, unknown>)['published_at'] === 'string' &&
'message' in (v as Record<string, unknown>)
);
}
interface SseFrame {
data?: string;
id?: string;
}
/**
* Read an SSE response body, invoking `onFrame` per event. Minimal
* parser: accumulates `data:` lines (joined by `\n`) and `id:` until a
* blank line dispatches the frame. Lines starting with `:` are comments
* (heartbeats) — surfaced as a frame with no `data` so the id can still
* advance.
*/
async function readStream(
body: ReadableStream<Uint8Array>,
onFrame: (frame: SseFrame) => void
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let dataLines: string[] = [];
let id: string | undefined;
let sawComment = false;
const dispatch = () => {
if (dataLines.length > 0) {
onFrame({ data: dataLines.join('\n'), id });
} else if (sawComment) {
onFrame({ id });
}
dataLines = [];
sawComment = false;
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl).replace(/\r$/, '');
buffer = buffer.slice(nl + 1);
if (line === '') {
dispatch();
continue;
}
if (line.startsWith(':')) {
sawComment = true;
continue;
}
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
const rawVal = colon === -1 ? '' : line.slice(colon + 1);
const val = rawVal.startsWith(' ') ? rawVal.slice(1) : rawVal;
if (field === 'data') dataLines.push(val);
else if (field === 'id') id = val;
}
}
// Flush a trailing frame if the stream ended without a blank line.
dispatch();
}
function isAbort(err: unknown): boolean {
return typeof err === 'object' && err !== null && (err as { name?: string }).name === 'AbortError';
}

View File

@@ -0,0 +1,72 @@
import { readable, type Readable } from 'svelte/store';
import type { PicloudClient } from '../client.js';
import type { SubscribeOptions } from '../types.js';
/**
* A Svelte store of realtime messages for a topic. `$messages` is an
* array that grows as events arrive. The SSE connection opens on the
* first subscriber and closes when the last unsubscribes (standard
* `readable` lifecycle).
*
* The client is passed explicitly (Svelte stores aren't components, so
* there's no React-style context to read). See HANDBACK §7.
*/
export function topicStore<T = unknown>(
client: PicloudClient,
topic: string,
opts?: SubscribeOptions<T>
): Readable<T[]> {
return readable<T[]>([], (set) => {
let items: T[] = [];
const unsubscribe = client.subscribe<T>(
topic,
(event) => {
items = [...items, event.message];
set(items);
},
opts
);
return () => unsubscribe();
});
}
export interface QueryState<T> {
data: T | null;
loading: boolean;
error: unknown;
}
export interface EndpointStore<Req, Res> {
get: () => Readable<QueryState<Res>>;
post: (body?: Req) => Readable<QueryState<Res>>;
}
/**
* A Svelte store wrapper over a typed endpoint. `$query` is
* `{ data, loading, error }`. The request fires when the store gains its
* first subscriber.
*/
export function endpointStore<Res = unknown, Req = unknown>(
client: PicloudClient,
path: string
): EndpointStore<Req, Res> {
const run = (exec: () => Promise<Res>): Readable<QueryState<Res>> =>
readable<QueryState<Res>>({ data: null, loading: true, error: null }, (set) => {
let active = true;
exec()
.then((data) => {
if (active) set({ data, loading: false, error: null });
})
.catch((error) => {
if (active) set({ data: null, loading: false, error });
});
return () => {
active = false;
};
});
return {
get: () => run(() => client.endpoint<Req, Res>(path).get()),
post: (body?: Req) => run(() => client.endpoint<Req, Res>(path).post(body))
};
}

View File

@@ -0,0 +1,73 @@
// Shared types for @picloud/client.
/** Returns the current bearer token (or null) before each HTTP request. */
export type AuthTokenProvider = () => string | null | undefined | Promise<string | null | undefined>;
export interface PicloudClientOptions {
/** Base URL of the PiCloud deployment, e.g. `https://api.example.com`. */
baseURL: string;
/**
* Optional: returns the current bearer token, called before each
* request. The client doesn't manage tokens — it just sends them.
*/
getAuthToken?: AuthTokenProvider;
/**
* Optional fetch implementation (defaults to the global `fetch`).
* Injected mainly for tests / non-browser runtimes.
*/
fetch?: typeof fetch;
}
/** A realtime event as delivered over SSE. */
export interface RealtimeEvent<T = unknown> {
topic: string;
message: T;
published_at: string;
}
/**
* Minimal validator shape for the optional runtime-validation adapter.
* A Zod schema satisfies this directly (`schema.parse`); for Valibot,
* wrap it: `{ parse: (i) => v.parse(schema, i) }`. No hard dep on either.
*/
export interface Validator<T> {
parse: (input: unknown) => T;
}
/** Thrown when an endpoint call returns a non-2xx status. */
export class PicloudHttpError extends Error {
readonly status: number;
readonly body: unknown;
constructor(status: number, message: string, body: unknown) {
super(message);
this.name = 'PicloudHttpError';
this.status = status;
this.body = body;
}
}
export interface SubscribeOptions<T = unknown> {
/**
* Subscriber token for `auth_mode = 'token'` topics. Obtained from one
* of your app's script endpoints (which calls
* `pubsub::subscriber_token`). Sent as `?token=` (EventSource-parity).
*/
token?: string;
/**
* Called when a (re)connect is rejected with 401 — typically an
* expired token. Return a fresh token to retry immediately, or
* null/undefined to stop and surface the error.
*/
onTokenExpired?: () => string | null | undefined | Promise<string | null | undefined>;
/** Called on a terminal error (after retries are exhausted or aborted). */
onError?: (err: unknown) => void;
/** Optional runtime validation of each event's `message`. */
validate?: Validator<T>;
/** Max reconnect backoff in ms (default 30_000). */
maxBackoffMs?: number;
/** Base reconnect backoff in ms (default 1_000). */
baseBackoffMs?: number;
}
/** Cancels a realtime subscription. */
export type Unsubscribe = () => void;

View File

@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import { PicloudClient } from '../src/index.js';
import { jsonResponse, lastUrl, type FetchArgs } from './helpers.js';
describe('auth', () => {
it('login POSTs credentials and stores the returned token', async () => {
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
jsonResponse({ token: 'session-abc' })
);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
const token = await client.auth.login('alice@example.com', 'pw');
expect(token).toBe('session-abc');
expect(client.auth.token).toBe('session-abc');
expect(lastUrl(fetchMock)).toBe('https://api.test/api/auth/login');
const init = fetchMock.mock.calls[0]?.[1];
expect(JSON.parse(String(init?.body))).toEqual({
email: 'alice@example.com',
password: 'pw'
});
});
it('logout clears the stored token', async () => {
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) => jsonResponse({}));
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
client.auth.setToken('existing');
await client.auth.logout();
expect(client.auth.token).toBeNull();
});
it('provider returns the current token for getAuthToken wiring', async () => {
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
jsonResponse({ token: 't' })
);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
await client.auth.login('a@b.c', 'pw');
expect(client.auth.provider()).toBe('t');
});
});

View File

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest';
import { PicloudClient, PicloudHttpError } from '../src/index.js';
import { headerOf, jsonResponse, lastInit, lastUrl, type FetchArgs } from './helpers.js';
describe('endpoint', () => {
it('post round-trips a typed request/response', async () => {
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
jsonResponse({ id: '1', name: 'alice', created_at: 'now' }, 201)
);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
interface Req {
name: string;
role: string;
}
interface Res {
id: string;
name: string;
created_at: string;
}
const res = await client.endpoint<Req, Res>('/api/users').post({ name: 'alice', role: 'admin' });
expect(res).toEqual({ id: '1', name: 'alice', created_at: 'now' });
expect(lastUrl(fetchMock)).toBe('https://api.test/api/users');
const init = lastInit(fetchMock);
expect(init.method).toBe('POST');
expect(JSON.parse(String(init.body))).toEqual({ name: 'alice', role: 'admin' });
expect(headerOf(init, 'Content-Type')).toBe('application/json');
});
it('get round-trips', async () => {
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
jsonResponse({ name: 'bob' })
);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
const res = await client.endpoint<unknown, { name: string }>('/api/users/1').get();
expect(res.name).toBe('bob');
expect(lastInit(fetchMock).method).toBe('GET');
});
it('injects the auth token from getAuthToken', async () => {
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) => jsonResponse({ ok: true }));
const client = new PicloudClient({
baseURL: 'https://api.test',
fetch: fetchMock,
getAuthToken: () => 'tok-123'
});
await client.endpoint('/api/me').get();
expect(headerOf(lastInit(fetchMock), 'Authorization')).toBe('Bearer tok-123');
});
it('throws PicloudHttpError with status + body on non-2xx', async () => {
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
jsonResponse({ error: 'bad input' }, 422)
);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
const err = await client
.endpoint('/api/x')
.get()
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(PicloudHttpError);
expect((err as PicloudHttpError).status).toBe(422);
expect((err as PicloudHttpError).message).toBe('bad input');
});
it('applies an optional validator to the response', async () => {
const fetchMock = vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) =>
jsonResponse({ id: 7 })
);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
const validator = {
parse: (input: unknown) => {
const r = input as { id: number };
if (typeof r.id !== 'number') throw new Error('bad');
return r;
}
};
const res = await client.endpoint<unknown, { id: number }>('/api/x').get({ validate: validator });
expect(res.id).toBe(7);
});
});

View File

@@ -0,0 +1,54 @@
// Test helpers: build JSON + SSE Response objects and a typed fetch mock.
export function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
});
}
export function emptyResponse(status = 200): Response {
return new Response(null, { status });
}
/** Build a text/event-stream Response from raw SSE frame strings. */
export function sseResponse(frames: string[], status = 200): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const frame of frames) controller.enqueue(encoder.encode(frame));
controller.close();
}
});
return new Response(stream, {
status,
headers: { 'content-type': 'text/event-stream' }
});
}
/** One SSE `data:` event frame for a realtime payload. */
export function dataFrame(topic: string, message: unknown, publishedAt = '2026-06-04T00:00:00Z'): string {
const payload = JSON.stringify({ topic, message, published_at: publishedAt });
return `data: ${payload}\n\n`;
}
export type FetchArgs = [string | URL | Request, RequestInit?];
type MockLike = { mock: { calls: ReadonlyArray<ReadonlyArray<unknown>> } };
export function lastInit(mock: MockLike, i = 0): RequestInit {
const call = mock.mock.calls[i];
if (!call) throw new Error(`no fetch call at index ${i}`);
return (call[1] as RequestInit | undefined) ?? {};
}
export function lastUrl(mock: MockLike, i = 0): string {
const call = mock.mock.calls[i];
if (!call) throw new Error(`no fetch call at index ${i}`);
return String(call[0]);
}
export function headerOf(init: RequestInit, name: string): string | undefined {
const h = init.headers as Record<string, string> | undefined;
return h?.[name];
}

View File

@@ -0,0 +1,41 @@
import { act, renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { PicloudClient, RealtimeEvent, Unsubscribe } from '../src/index.js';
import { PicloudProvider, useTopic } from '../src/react/index.js';
type Cb = (e: RealtimeEvent<unknown>) => void;
function fakeClient() {
const unsubscribe = vi.fn();
let captured: Cb | null = null;
const subscribe = vi.fn(
(_topic: string, cb: Cb): Unsubscribe => {
captured = cb;
return unsubscribe as unknown as Unsubscribe;
}
);
const client = { subscribe } as unknown as PicloudClient;
return { client, subscribe, unsubscribe, emit: (e: RealtimeEvent<unknown>) => captured?.(e) };
}
describe('react useTopic', () => {
it('subscribes on mount, accumulates messages, unsubscribes on unmount', () => {
const { client, subscribe, unsubscribe, emit } = fakeClient();
const wrapper = ({ children }: { children: ReactNode }) =>
PicloudProvider({ client, children });
const { result, unmount } = renderHook(() => useTopic<{ n: number }>('chat'), { wrapper });
expect(subscribe).toHaveBeenCalledTimes(1);
expect(result.current).toEqual([]);
act(() => emit({ topic: 'chat', message: { n: 1 }, published_at: 't' }));
act(() => emit({ topic: 'chat', message: { n: 2 }, published_at: 't' }));
expect(result.current).toEqual([{ n: 1 }, { n: 2 }]);
unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest';
import { PicloudClient, type RealtimeEvent } from '../src/index.js';
import { dataFrame, emptyResponse, lastUrl, sseResponse, type FetchArgs } from './helpers.js';
/** A fetch mock that plays through a queue of response factories. */
function queuedFetch(responders: Array<() => Promise<Response>>) {
let i = 0;
return vi.fn(async (_u: FetchArgs[0], _i?: FetchArgs[1]) => {
const idx = Math.min(i, responders.length - 1);
i += 1;
const r = responders[idx];
if (!r) throw new Error('no responder');
return r();
});
}
describe('subscribe', () => {
it('connects to the SSE endpoint and delivers events', async () => {
const fetchMock = queuedFetch([async () => sseResponse([dataFrame('chat', { hi: 1 })])]);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
const received: Array<RealtimeEvent<{ hi: number }>> = [];
const unsubscribe = client.subscribe<{ hi: number }>('chat', (e) => received.push(e));
await vi.waitFor(() => expect(received.length).toBe(1));
unsubscribe();
expect(received[0]?.topic).toBe('chat');
expect(received[0]?.message).toEqual({ hi: 1 });
expect(lastUrl(fetchMock)).toBe('https://api.test/realtime/topics/chat');
});
it('passes a token via the query string', async () => {
const fetchMock = queuedFetch([async () => sseResponse([dataFrame('chat', 1)])]);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
const unsubscribe = client.subscribe('chat', () => {}, { token: 'abc.def' });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
unsubscribe();
expect(lastUrl(fetchMock)).toBe('https://api.test/realtime/topics/chat?token=abc.def');
});
it('reconnects with backoff after an initial connection failure', async () => {
const fetchMock = queuedFetch([
async () => {
throw new Error('network down');
},
async () => sseResponse([dataFrame('chat', { ok: true })])
]);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
const received: unknown[] = [];
const unsubscribe = client.subscribe('chat', (e) => received.push(e.message), {
baseBackoffMs: 5,
maxBackoffMs: 20
});
await vi.waitFor(() => expect(received.length).toBeGreaterThanOrEqual(1), { timeout: 1000 });
unsubscribe();
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(received[0]).toEqual({ ok: true });
});
it('refreshes the token after a 401 and reconnects', async () => {
const fetchMock = queuedFetch([
async () => emptyResponse(401),
async () => sseResponse([dataFrame('chat', { v: 2 })])
]);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
const onTokenExpired = vi.fn(() => 'fresh-token');
const received: unknown[] = [];
const unsubscribe = client.subscribe('chat', (e) => received.push(e.message), {
token: 'stale',
onTokenExpired,
baseBackoffMs: 5
});
await vi.waitFor(() => expect(received.length).toBeGreaterThanOrEqual(1), { timeout: 1000 });
unsubscribe();
expect(onTokenExpired).toHaveBeenCalled();
// Second connect carries the refreshed token.
expect(lastUrl(fetchMock, 1)).toContain('token=fresh-token');
expect(received[0]).toEqual({ v: 2 });
});
it('stops and reports when a 401 cannot be refreshed', async () => {
const fetchMock = queuedFetch([async () => emptyResponse(401)]);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
const onError = vi.fn();
const unsubscribe = client.subscribe('chat', () => {}, {
onTokenExpired: () => null,
onError
});
await vi.waitFor(() => expect(onError).toHaveBeenCalled());
unsubscribe();
});
});

View File

@@ -0,0 +1,34 @@
import { get } from 'svelte/store';
import { describe, expect, it, vi } from 'vitest';
import type { PicloudClient, RealtimeEvent, Unsubscribe } from '../src/index.js';
import { topicStore } from '../src/svelte/index.js';
type Cb = (e: RealtimeEvent<unknown>) => void;
describe('svelte topicStore', () => {
it('subscribes on first subscriber and unsubscribes on last', () => {
const unsubscribe = vi.fn();
const holder: { cb: Cb | null } = { cb: null };
const subscribe = vi.fn((_topic: string, cb: Cb): Unsubscribe => {
holder.cb = cb;
return unsubscribe as unknown as Unsubscribe;
});
const client = { subscribe } as unknown as PicloudClient;
const store = topicStore<{ x: number }>(client, 'chat');
// No SSE connection until someone subscribes (readable lifecycle).
expect(subscribe).not.toHaveBeenCalled();
let value: { x: number }[] = [];
const stop = store.subscribe((v) => (value = v));
expect(subscribe).toHaveBeenCalledTimes(1);
holder.cb?.({ topic: 'chat', message: { x: 1 }, published_at: 't' });
expect(value).toEqual([{ x: 1 }]);
expect(get(store)).toEqual([{ x: 1 }]);
stop();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"noEmit": true,
"types": []
},
"include": ["src", "tests"]
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'tsup';
// Dual ESM + CJS emit with .d.ts for the main entry and the two
// framework subpath exports. React and Svelte are peer deps — kept
// external so the lib never bundles a framework copy.
export default defineConfig({
entry: {
index: 'src/index.ts',
'react/index': 'src/react/index.ts',
'svelte/index': 'src/svelte/index.ts'
},
format: ['esm', 'cjs'],
dts: true,
clean: true,
sourcemap: true,
treeshake: true,
external: ['react', 'svelte', 'svelte/store']
});

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// jsdom so the React/Svelte hook tests have a DOM; the core
// endpoint/subscribe/auth tests are environment-agnostic.
environment: 'jsdom',
globals: true,
include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx']
}
});