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