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:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user