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

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

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

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

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

View File

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