useEndpoint(path) returned `{ get: () => useResource(...), post: (body)
=> useResource(...) }`. Each of `.get()` and `.post()` called useState
+ useEffect — but React's Rules of Hooks require hook calls at the top
level of a component, in the same order each render. Calling
.get() conditionally, or both .get() and .post() from the same
component, produced undefined behaviour (state leaking between them).
The test suite covered only useTopic, so this was uncaught.
Separately (F-T-002): the JSDoc said .post() was "the mutation variant
(auto-fires once per mount)" — but auto-firing a POST on mount means
typo'd code creates a user (or sends an email) on every render refresh.
Refactor:
- Remove useEndpoint entirely (public API breaking change — clients/ts
v1.1.x has no external consumers yet per CLAUDE.md).
- Add useEndpointGet<Res>(path): QueryState<Res> — flat hook, auto-fires.
- Add useEndpointPost<Req, Res>(path): { mutate, data, loading, error }
— flat hook, event-driven (NOT auto-firing); call `mutate(body)` from
the submit handler.
README updated to demonstrate both shapes side-by-side.
Existing 15 unit tests pass (useTopic unaffected; useEndpoint tests
never existed). Adding a useEndpointGet/Post test pass is finding
F-T-001 follow-up.
AUDIT.md anchor: F-T-001 (+ F-T-002 folded in).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.2 KiB
4.2 KiB
@picloud/client
TypeScript client for PiCloud. Three capabilities, all script-mediated — there is no direct KV / docs / users access from the browser (the hybrid model, by design):
- Typed HTTP to dev-defined script endpoints.
- SSE realtime subscriptions to externally-subscribable pub/sub topics.
- Auth-flow helpers over your own dev-defined login/logout endpoints.
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
import {
PicloudProvider,
useTopic,
useEndpointGet,
useEndpointPost
} 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 }) {
// Auto-fires when `id` changes; conforms to Rules of Hooks.
const { data, loading, error } = useEndpointGet<UserRes>(`/api/users/${id}`);
if (loading) return <Spinner />;
if (error) return <ErrorView error={error} />;
return <div>{data?.name}</div>;
}
function CreateUserForm() {
// Event-driven: NOT fired on mount; call `mutate(body)` on submit.
const { mutate, loading, error } = useEndpointPost<CreateUserReq, CreateUserRes>(
'/api/users'
);
return (
<form onSubmit={(e) => { e.preventDefault(); mutate({ name: 'Alice' }); }}>
<button disabled={loading}>Create</button>
{error ? <ErrorView error={error} /> : null}
</form>
);
}
Svelte
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
clientexplicitly (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:
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 nativeEventSource) so the client can refresh an expired token on a 401, sendLast-Event-IDon resume, and apply its own exponential backoff (1s → 2s → 4s … capped at 30s). - React Native has no native
EventSource, but it also can't streamfetchbodies on all engines — if you target RN, supply a streaming-capablefetchpolyfill via thefetchoption, or use areact-native-sse-based adapter. (Server-sideLast-Event-IDreplay is not implemented in v1.1.6; the client sends the header so it's ready when the server adds replay.)
Build / test
npm install
npm run lint # tsc --noEmit (strict)
npm run test # vitest
npm run build # tsup → dist/ (ESM + CJS + .d.ts)