Files
PiCloud/clients/typescript/README.md
MechaCat02 efb644efe9 fix(clients/ts): F-T-001 + F-T-002 flat useEndpointGet / useEndpointPost hooks
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>
2026-06-07 20:30:26 +02:00

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):

  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.
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 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:

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

npm install
npm run lint    # tsc --noEmit (strict)
npm run test    # vitest
npm run build   # tsup → dist/ (ESM + CJS + .d.ts)