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:
3
clients/typescript/.gitignore
vendored
Normal file
3
clients/typescript/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
111
clients/typescript/README.md
Normal file
111
clients/typescript/README.md
Normal 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
3580
clients/typescript/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
clients/typescript/package.json
Normal file
61
clients/typescript/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
71
clients/typescript/src/auth.ts
Normal file
71
clients/typescript/src/auth.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
61
clients/typescript/src/client.ts
Normal file
61
clients/typescript/src/client.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
106
clients/typescript/src/endpoint.ts
Normal file
106
clients/typescript/src/endpoint.ts
Normal 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}`;
|
||||
}
|
||||
14
clients/typescript/src/index.ts
Normal file
14
clients/typescript/src/index.ts
Normal 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';
|
||||
101
clients/typescript/src/react/index.ts
Normal file
101
clients/typescript/src/react/index.ts
Normal 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;
|
||||
}
|
||||
194
clients/typescript/src/subscribe.ts
Normal file
194
clients/typescript/src/subscribe.ts
Normal 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';
|
||||
}
|
||||
72
clients/typescript/src/svelte/index.ts
Normal file
72
clients/typescript/src/svelte/index.ts
Normal 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))
|
||||
};
|
||||
}
|
||||
73
clients/typescript/src/types.ts
Normal file
73
clients/typescript/src/types.ts
Normal 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;
|
||||
41
clients/typescript/tests/auth.test.ts
Normal file
41
clients/typescript/tests/auth.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
82
clients/typescript/tests/endpoint.test.ts
Normal file
82
clients/typescript/tests/endpoint.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
clients/typescript/tests/helpers.ts
Normal file
54
clients/typescript/tests/helpers.ts
Normal 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];
|
||||
}
|
||||
41
clients/typescript/tests/react.test.tsx
Normal file
41
clients/typescript/tests/react.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
99
clients/typescript/tests/subscribe.test.ts
Normal file
99
clients/typescript/tests/subscribe.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
34
clients/typescript/tests/svelte.test.ts
Normal file
34
clients/typescript/tests/svelte.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
clients/typescript/tsconfig.json
Normal file
21
clients/typescript/tsconfig.json
Normal 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"]
|
||||
}
|
||||
18
clients/typescript/tsup.config.ts
Normal file
18
clients/typescript/tsup.config.ts
Normal 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']
|
||||
});
|
||||
11
clients/typescript/vitest.config.ts
Normal file
11
clients/typescript/vitest.config.ts
Normal 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']
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user