@jigs.design/client 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +31 -2
- package/dist/client.d.ts +92 -0
- package/dist/client.js +159 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/transport.d.ts +60 -0
- package/dist/transport.js +197 -0
- package/package.json +28 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Bates
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @jigs.design/client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`createPickleClient` is the browser's connection to a Pickle server: the transport and the query cache, on TanStack Query Core. It has no React and no schema library.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createPickleClient } from "@jigs.design/client";
|
|
7
|
+
|
|
8
|
+
export const client = createPickleClient({ endpoint: "/api/pickle" });
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Create one client per app, in its own module. Every view and session that imports it shares one query cache, so two views reading the same query make one request, and a mutation's invalidations reach both. The client retries network failures, aborts a query's request when nothing reads it any more, and refreshes the queries a mutation invalidates.
|
|
12
|
+
|
|
13
|
+
## Exports
|
|
14
|
+
|
|
15
|
+
| Export | What it is |
|
|
16
|
+
| -------------------- | ---------------------------------------------------------------- |
|
|
17
|
+
| `createPickleClient` | The client, with `observe`, `mutate`, `invalidate` and `session` |
|
|
18
|
+
|
|
19
|
+
The view calls `observe` and `mutate`; app code calls `invalidate`. `mutate(name, input, procedure)` takes the procedure's `kind` and `invalidates` from the program, and every call goes out with its `kind`, which the server checks against the host.
|
|
20
|
+
| `httpTransport`, `Transport` | The default HTTP transport, and the type to implement your own |
|
|
21
|
+
| `TransportError` | What a transport rejects with: `{ code, message }` |
|
|
22
|
+
|
|
23
|
+
It re-exports the shared types from `@jigs.design/runtime`.
|
|
24
|
+
|
|
25
|
+
## Source
|
|
26
|
+
|
|
27
|
+
- `src/client.ts`: the client and its query cache.
|
|
28
|
+
- `src/transport.ts`: the transport contract and the HTTP transport.
|
|
29
|
+
|
|
30
|
+
## Docs
|
|
31
|
+
|
|
32
|
+
[Transport](../../apps/docs/docs/running/transport.mdx) and [Session](../../apps/docs/docs/running/session.mdx).
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { HeadersOption, Transport } from "./transport.js";
|
|
2
|
+
import { Message, MutationResult, QueryResult, SessionEvent, SessionOptions } from "@jigs.design/runtime";
|
|
3
|
+
//#region src/client.d.ts
|
|
4
|
+
interface CacheOptions {
|
|
5
|
+
/**
|
|
6
|
+
* How long a query result stays fresh, in milliseconds. A fresh result is
|
|
7
|
+
* reused by a new view, so two blocks or two turns that read the same
|
|
8
|
+
* query share one call; a stale one refetches when a view reads it again.
|
|
9
|
+
* `invalidates` and `invalidate` refetch at any age. Default 60,000: a
|
|
10
|
+
* minute, so a long session does not show hour-old data.
|
|
11
|
+
*/
|
|
12
|
+
staleTime?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* What the page asks the user before a mutation whose policy is "confirm"
|
|
16
|
+
* runs. `message` is the host's text, never the model's; `input` is what
|
|
17
|
+
* the program sent. Resolve true to run the mutation. `signal` aborts when
|
|
18
|
+
* the view that called the mutation stops or loads another document: close
|
|
19
|
+
* the question then, since the mutation has already failed as `DECLINED`.
|
|
20
|
+
*/
|
|
21
|
+
export type ConfirmFunction = (request: {
|
|
22
|
+
input: unknown;
|
|
23
|
+
message: string;
|
|
24
|
+
name: string;
|
|
25
|
+
signal: AbortSignal;
|
|
26
|
+
}) => Promise<boolean>;
|
|
27
|
+
interface ConfirmOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Asks the user to approve a mutation whose policy is "confirm", in the
|
|
30
|
+
* page's own UI, outside the sandbox. Without it, or when it resolves
|
|
31
|
+
* false or throws, the mutation fails with `DECLINED`. A `PickleView`'s
|
|
32
|
+
* `onConfirm` takes its place for that view.
|
|
33
|
+
*/
|
|
34
|
+
confirm?: ConfirmFunction;
|
|
35
|
+
}
|
|
36
|
+
export type ClientOptions = CacheOptions & ConfirmOptions & ({
|
|
37
|
+
credentials?: RequestCredentials;
|
|
38
|
+
endpoint: string;
|
|
39
|
+
fetch?: typeof fetch;
|
|
40
|
+
headers?: HeadersOption;
|
|
41
|
+
transport?: never;
|
|
42
|
+
} | {
|
|
43
|
+
credentials?: never;
|
|
44
|
+
endpoint?: never;
|
|
45
|
+
fetch?: never;
|
|
46
|
+
headers?: never;
|
|
47
|
+
transport: Transport;
|
|
48
|
+
});
|
|
49
|
+
export interface PickleClient {
|
|
50
|
+
/** URL of the sandbox frame document. */
|
|
51
|
+
readonly frame: string;
|
|
52
|
+
/** Refetches every query with this name, for every input. */
|
|
53
|
+
invalidate: (name: string) => Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Runs one mutation or action and, when it succeeds, refetches the
|
|
56
|
+
* queries the server says it refreshed. A reply without that list
|
|
57
|
+
* refetches every input of each query in `invalidates`. `kind` goes to
|
|
58
|
+
* the server, which refuses a call whose kind is not the procedure's.
|
|
59
|
+
* When the server asks for confirmation, `procedure.confirm` (else the
|
|
60
|
+
* client's `confirm`) asks the user, and the call goes again with the
|
|
61
|
+
* server's token only if they approve before `procedure.signal` aborts.
|
|
62
|
+
* The signal only withdraws the question: a call already sent is not
|
|
63
|
+
* cancelled, since the server may have run it.
|
|
64
|
+
*/
|
|
65
|
+
mutate: (name: string, input: unknown, procedure: {
|
|
66
|
+
confirm?: ConfirmFunction | undefined;
|
|
67
|
+
invalidates: ReadonlyArray<string>;
|
|
68
|
+
kind: "action" | "mutation";
|
|
69
|
+
signal?: AbortSignal | undefined;
|
|
70
|
+
}) => Promise<MutationResult>;
|
|
71
|
+
/**
|
|
72
|
+
* Keeps one query subscribed and reports every result. Unsubscribing
|
|
73
|
+
* aborts its request when nothing else reads it. The call goes out with
|
|
74
|
+
* `kind: "query"`.
|
|
75
|
+
*/
|
|
76
|
+
observe: (name: string, input: unknown, listener: (result: QueryResult) => void) => () => void;
|
|
77
|
+
/** One turn, as session events. */
|
|
78
|
+
session: (body: {
|
|
79
|
+
messages: ReadonlyArray<Message>;
|
|
80
|
+
options?: SessionOptions;
|
|
81
|
+
}, options: {
|
|
82
|
+
signal: AbortSignal;
|
|
83
|
+
}) => AsyncIterable<SessionEvent>;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The browser's connection to a Pickle server. Create one per app, in its
|
|
87
|
+
* own module: each client has its own query cache, so two clients share no
|
|
88
|
+
* requests and no invalidations.
|
|
89
|
+
*/
|
|
90
|
+
export declare function createPickleClient(options: ClientOptions): PickleClient;
|
|
91
|
+
//#endregion
|
|
92
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { TransportError, errorDataOf, httpTransport } from "./transport.js";
|
|
2
|
+
import { QueryClient, QueryObserver } from "@tanstack/query-core";
|
|
3
|
+
//#region src/client.ts
|
|
4
|
+
/** How long a query result stays fresh by default: a minute. */
|
|
5
|
+
const DEFAULT_STALE_TIME = 6e4;
|
|
6
|
+
const RETRYABLE = /* @__PURE__ */ new Set(["NETWORK", "UNAVAILABLE"]);
|
|
7
|
+
const MAX_RETRIES = 2;
|
|
8
|
+
function toQueryResult(result) {
|
|
9
|
+
if (result.status === "success") return {
|
|
10
|
+
data: result.data,
|
|
11
|
+
error: null,
|
|
12
|
+
status: "success"
|
|
13
|
+
};
|
|
14
|
+
if (result.status === "error") return {
|
|
15
|
+
data: null,
|
|
16
|
+
error: errorDataOf(result.error),
|
|
17
|
+
status: "error"
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
data: null,
|
|
21
|
+
error: null,
|
|
22
|
+
status: "pending"
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The browser's connection to a Pickle server. Create one per app, in its
|
|
27
|
+
* own module: each client has its own query cache, so two clients share no
|
|
28
|
+
* requests and no invalidations.
|
|
29
|
+
*/
|
|
30
|
+
function createPickleClient(options) {
|
|
31
|
+
const transport = options.transport === void 0 ? httpTransport(httpOptions(options)) : options.transport;
|
|
32
|
+
const queries = new QueryClient({ defaultOptions: {
|
|
33
|
+
mutations: { retry: false },
|
|
34
|
+
queries: {
|
|
35
|
+
gcTime: 3e5,
|
|
36
|
+
refetchOnReconnect: false,
|
|
37
|
+
refetchOnWindowFocus: false,
|
|
38
|
+
retry: (failures, error) => failures < MAX_RETRIES && RETRYABLE.has(errorDataOf(error).code),
|
|
39
|
+
staleTime: options.staleTime ?? DEFAULT_STALE_TIME
|
|
40
|
+
}
|
|
41
|
+
} });
|
|
42
|
+
return {
|
|
43
|
+
frame: transport.frame,
|
|
44
|
+
invalidate: (name) => queries.invalidateQueries({ queryKey: [name] }),
|
|
45
|
+
mutate: async (name, input, procedure) => {
|
|
46
|
+
const send = async (confirmation) => {
|
|
47
|
+
try {
|
|
48
|
+
return await transport.call(confirmation === void 0 ? {
|
|
49
|
+
input,
|
|
50
|
+
kind: procedure.kind,
|
|
51
|
+
name
|
|
52
|
+
} : {
|
|
53
|
+
confirmation,
|
|
54
|
+
input,
|
|
55
|
+
kind: procedure.kind,
|
|
56
|
+
name
|
|
57
|
+
}, { signal: new AbortController().signal });
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return { error: errorDataOf(error) };
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
let reply = await send();
|
|
63
|
+
if ("requiresAction" in reply) {
|
|
64
|
+
const declined = await askToConfirm(procedure.confirm ?? options.confirm, {
|
|
65
|
+
input,
|
|
66
|
+
message: reply.requiresAction.message,
|
|
67
|
+
name,
|
|
68
|
+
signal: procedure.signal ?? new AbortController().signal
|
|
69
|
+
});
|
|
70
|
+
if (declined !== null) return {
|
|
71
|
+
error: declined,
|
|
72
|
+
status: "error"
|
|
73
|
+
};
|
|
74
|
+
reply = await send(reply.requiresAction.token);
|
|
75
|
+
}
|
|
76
|
+
if ("error" in reply) return {
|
|
77
|
+
error: reply.error,
|
|
78
|
+
status: "error"
|
|
79
|
+
};
|
|
80
|
+
if ("requiresAction" in reply) return {
|
|
81
|
+
error: unconfirmed,
|
|
82
|
+
status: "error"
|
|
83
|
+
};
|
|
84
|
+
const targets = reply.invalidate ?? procedure.invalidates.map((target) => ({ name: target }));
|
|
85
|
+
for (const target of targets) queries.invalidateQueries({ queryKey: target.input === void 0 ? [target.name] : [target.name, target.input] });
|
|
86
|
+
return {
|
|
87
|
+
data: reply.data,
|
|
88
|
+
status: "success"
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
observe: (name, input, listener) => {
|
|
92
|
+
const observer = new QueryObserver(queries, {
|
|
93
|
+
queryFn: async (context) => {
|
|
94
|
+
const reply = await transport.call({
|
|
95
|
+
input,
|
|
96
|
+
kind: "query",
|
|
97
|
+
name
|
|
98
|
+
}, { signal: context.signal });
|
|
99
|
+
if ("requiresAction" in reply) throw new TransportError(unconfirmed);
|
|
100
|
+
return reply.data;
|
|
101
|
+
},
|
|
102
|
+
queryKey: [name, input]
|
|
103
|
+
});
|
|
104
|
+
const unsubscribe = observer.subscribe((result) => {
|
|
105
|
+
listener(toQueryResult(result));
|
|
106
|
+
});
|
|
107
|
+
listener(toQueryResult(observer.getCurrentResult()));
|
|
108
|
+
return unsubscribe;
|
|
109
|
+
},
|
|
110
|
+
session: (body, sessionOptions) => transport.session(body, sessionOptions)
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const unconfirmed = {
|
|
114
|
+
code: "FORBIDDEN",
|
|
115
|
+
message: "The server asked for a confirmation the call cannot give."
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Asks the user through `confirm`. Null when they approved before
|
|
119
|
+
* `request.signal` aborted; otherwise the `DECLINED` error the mutation fails
|
|
120
|
+
* with, which says why. An abort settles it even if `confirm` never does.
|
|
121
|
+
*/
|
|
122
|
+
async function askToConfirm(confirm, request) {
|
|
123
|
+
if (confirm === void 0) return {
|
|
124
|
+
code: "DECLINED",
|
|
125
|
+
message: `"${request.name}" needs the user's confirmation, and this page cannot ask for it.`
|
|
126
|
+
};
|
|
127
|
+
const withdrawn = {
|
|
128
|
+
code: "DECLINED",
|
|
129
|
+
message: `The confirmation for "${request.name}" was withdrawn before the user answered.`
|
|
130
|
+
};
|
|
131
|
+
if (request.signal.aborted) return withdrawn;
|
|
132
|
+
const aborted = Promise.withResolvers();
|
|
133
|
+
const onAbort = () => aborted.resolve(false);
|
|
134
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
135
|
+
let answer;
|
|
136
|
+
try {
|
|
137
|
+
answer = await Promise.race([confirm(request), aborted.promise]) === true ? null : {
|
|
138
|
+
code: "DECLINED",
|
|
139
|
+
message: "The user declined."
|
|
140
|
+
};
|
|
141
|
+
} catch (error) {
|
|
142
|
+
answer = {
|
|
143
|
+
code: "DECLINED",
|
|
144
|
+
message: `The confirmation for "${request.name}" failed: ${error instanceof Error ? error.message : String(error)}`
|
|
145
|
+
};
|
|
146
|
+
} finally {
|
|
147
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
148
|
+
}
|
|
149
|
+
return request.signal.aborted ? withdrawn : answer;
|
|
150
|
+
}
|
|
151
|
+
function httpOptions(options) {
|
|
152
|
+
const http = { endpoint: options.endpoint };
|
|
153
|
+
if (options.credentials !== void 0) http.credentials = options.credentials;
|
|
154
|
+
if (options.fetch !== void 0) http.fetch = options.fetch;
|
|
155
|
+
if (options.headers !== void 0) http.headers = options.headers;
|
|
156
|
+
return http;
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
export { createPickleClient };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { CallReply, HeadersOption, HttpTransportOptions, Transport, TransportError, httpTransport } from "./transport.js";
|
|
2
|
+
import { ClientOptions, ConfirmFunction, PickleClient, createPickleClient } from "./client.js";
|
|
3
|
+
import { Diagnostic, Invalidation, Message, PickleErrorCode, PickleErrorData, Program, SessionEvent, Span } from "@jigs.design/runtime";
|
|
4
|
+
export { type CallReply, type ClientOptions, type ConfirmFunction, type Diagnostic, type HeadersOption, type HttpTransportOptions, type Invalidation, type Message, type PickleClient, type PickleErrorCode, type PickleErrorData, type Program, type SessionEvent, type Span, type Transport, TransportError, createPickleClient, httpTransport };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Invalidation, Message, PickleErrorCode, PickleErrorData, ProcedureKind, SessionEvent, SessionOptions } from "@jigs.design/runtime";
|
|
2
|
+
//#region src/transport.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A call's reply: the procedure's data and, for a mutation, the queries the
|
|
5
|
+
* server says it refreshed. Without `invalidate`, the client refreshes every
|
|
6
|
+
* input of each query in the program's `invalidates` list. A mutation whose
|
|
7
|
+
* policy is "confirm" first replies with `requiresAction` instead, and has
|
|
8
|
+
* not run.
|
|
9
|
+
*/
|
|
10
|
+
export type CallReply = {
|
|
11
|
+
data: unknown;
|
|
12
|
+
invalidate?: ReadonlyArray<Invalidation>;
|
|
13
|
+
} | {
|
|
14
|
+
requiresAction: {
|
|
15
|
+
message: string;
|
|
16
|
+
token: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
/** How the browser reaches the server. Implement it to host Pickle behind another router. */
|
|
20
|
+
export interface Transport {
|
|
21
|
+
/**
|
|
22
|
+
* Resolves with the reply, or rejects with a `TransportError`. `kind` is
|
|
23
|
+
* the procedure's kind in the program; send it on, since the server
|
|
24
|
+
* refuses a call without it or with the wrong one.
|
|
25
|
+
*/
|
|
26
|
+
call: (request: {
|
|
27
|
+
/** The token from a `requiresAction` reply, once the user approved. */
|
|
28
|
+
confirmation?: string;
|
|
29
|
+
input: unknown;
|
|
30
|
+
kind: ProcedureKind;
|
|
31
|
+
name: string;
|
|
32
|
+
}, options: {
|
|
33
|
+
signal: AbortSignal;
|
|
34
|
+
}) => Promise<CallReply>;
|
|
35
|
+
/** URL of the sandbox frame document. */
|
|
36
|
+
frame: string;
|
|
37
|
+
/** Reports every failure as one `error` event and then ends; never throws. */
|
|
38
|
+
session: (body: {
|
|
39
|
+
messages: ReadonlyArray<Message>;
|
|
40
|
+
options?: SessionOptions;
|
|
41
|
+
}, options: {
|
|
42
|
+
signal: AbortSignal;
|
|
43
|
+
}) => AsyncIterable<SessionEvent>;
|
|
44
|
+
}
|
|
45
|
+
/** A failure with a code the spec can read. */
|
|
46
|
+
export declare class TransportError extends Error implements PickleErrorData {
|
|
47
|
+
readonly code: PickleErrorCode;
|
|
48
|
+
constructor(data: PickleErrorData);
|
|
49
|
+
}
|
|
50
|
+
export type HeadersOption = HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
51
|
+
export interface HttpTransportOptions {
|
|
52
|
+
credentials?: RequestCredentials;
|
|
53
|
+
/** Path or URL where `pickle.handler` is mounted. */
|
|
54
|
+
endpoint: string;
|
|
55
|
+
fetch?: typeof fetch;
|
|
56
|
+
headers?: HeadersOption;
|
|
57
|
+
}
|
|
58
|
+
/** The default transport: `fetch` against `pickle.handler`. */
|
|
59
|
+
export declare function httpTransport(options: HttpTransportOptions): Transport;
|
|
60
|
+
//#endregion
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { parseErrorData, parseInvalidations, parseSessionEvent } from "@jigs.design/runtime";
|
|
2
|
+
//#region src/transport.ts
|
|
3
|
+
/** A failure with a code the spec can read. */
|
|
4
|
+
var TransportError = class extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(data) {
|
|
7
|
+
super(data.message);
|
|
8
|
+
this.code = data.code;
|
|
9
|
+
this.name = "TransportError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
const VERSION_HEADER = "pickle-version";
|
|
13
|
+
const GATEWAY_STATUSES = /* @__PURE__ */ new Set([
|
|
14
|
+
502,
|
|
15
|
+
503,
|
|
16
|
+
504
|
|
17
|
+
]);
|
|
18
|
+
const unavailable = {
|
|
19
|
+
code: "UNAVAILABLE",
|
|
20
|
+
message: "The server is not available right now."
|
|
21
|
+
};
|
|
22
|
+
const network = {
|
|
23
|
+
code: "NETWORK",
|
|
24
|
+
message: "Could not reach the server."
|
|
25
|
+
};
|
|
26
|
+
/** The error a reply carries, or `UNAVAILABLE` when the reply is not one of Pickle's. */
|
|
27
|
+
async function replyError(response) {
|
|
28
|
+
if (GATEWAY_STATUSES.has(response.status) || !response.headers.has(VERSION_HEADER)) return unavailable;
|
|
29
|
+
try {
|
|
30
|
+
const body = await response.json();
|
|
31
|
+
return (typeof body === "object" && body !== null && "error" in body ? parseErrorData(body.error) : null) ?? unavailable;
|
|
32
|
+
} catch {
|
|
33
|
+
return unavailable;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Why the `headers` function failed, as the call's error. */
|
|
37
|
+
function headersError(cause) {
|
|
38
|
+
return thrownErrorData(cause) ?? {
|
|
39
|
+
code: "UNAUTHORIZED",
|
|
40
|
+
message: `The request headers could not be built: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** The default transport: `fetch` against `pickle.handler`. */
|
|
44
|
+
function httpTransport(options) {
|
|
45
|
+
const fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
46
|
+
const credentials = options.credentials ?? "same-origin";
|
|
47
|
+
/**
|
|
48
|
+
* The request's headers. A `headers` function that throws, such as a
|
|
49
|
+
* token refresh that failed, is not a network failure: it keeps a thrown
|
|
50
|
+
* `{ code, message }` and is otherwise `UNAUTHORIZED`, never retried.
|
|
51
|
+
*/
|
|
52
|
+
const headers = async () => {
|
|
53
|
+
let extra;
|
|
54
|
+
try {
|
|
55
|
+
extra = typeof options.headers === "function" ? await options.headers() : options.headers;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new TransportError(headersError(error));
|
|
58
|
+
}
|
|
59
|
+
const merged = new Headers(extra);
|
|
60
|
+
merged.set("content-type", "application/json");
|
|
61
|
+
return merged;
|
|
62
|
+
};
|
|
63
|
+
const post = (body, init, signal) => fetcher(options.endpoint, {
|
|
64
|
+
body,
|
|
65
|
+
credentials,
|
|
66
|
+
headers: init,
|
|
67
|
+
method: "POST",
|
|
68
|
+
signal
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
async call(request, callOptions) {
|
|
72
|
+
const init = await headers();
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await post(JSON.stringify({
|
|
76
|
+
confirmation: request.confirmation,
|
|
77
|
+
input: request.input,
|
|
78
|
+
kind: request.kind,
|
|
79
|
+
name: request.name,
|
|
80
|
+
type: "call"
|
|
81
|
+
}), init, callOptions.signal);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (callOptions.signal.aborted) throw error;
|
|
84
|
+
throw new TransportError(network);
|
|
85
|
+
}
|
|
86
|
+
if (!response.ok || !response.headers.has(VERSION_HEADER)) throw new TransportError(await replyError(response));
|
|
87
|
+
let body;
|
|
88
|
+
try {
|
|
89
|
+
body = await response.json();
|
|
90
|
+
} catch {
|
|
91
|
+
throw new TransportError(unavailable);
|
|
92
|
+
}
|
|
93
|
+
if (typeof body !== "object" || body === null) throw new TransportError(unavailable);
|
|
94
|
+
if ("requiresAction" in body) {
|
|
95
|
+
const requiresAction = parseRequiresAction(body.requiresAction);
|
|
96
|
+
if (requiresAction === null) throw new TransportError(unavailable);
|
|
97
|
+
return { requiresAction };
|
|
98
|
+
}
|
|
99
|
+
if (!("data" in body)) throw new TransportError(unavailable);
|
|
100
|
+
if (!("invalidate" in body)) return { data: body.data };
|
|
101
|
+
const invalidate = parseInvalidations(body.invalidate);
|
|
102
|
+
if (invalidate === null) throw new TransportError(unavailable);
|
|
103
|
+
return {
|
|
104
|
+
data: body.data,
|
|
105
|
+
invalidate
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
frame: options.endpoint,
|
|
109
|
+
async *session(body, { signal }) {
|
|
110
|
+
let init;
|
|
111
|
+
try {
|
|
112
|
+
init = await headers();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
yield {
|
|
115
|
+
error: errorDataOf(error),
|
|
116
|
+
type: "error"
|
|
117
|
+
};
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
let response;
|
|
121
|
+
try {
|
|
122
|
+
response = await post(JSON.stringify({
|
|
123
|
+
messages: body.messages,
|
|
124
|
+
options: body.options,
|
|
125
|
+
type: "session"
|
|
126
|
+
}), init, signal);
|
|
127
|
+
} catch {
|
|
128
|
+
if (!signal.aborted) yield {
|
|
129
|
+
error: network,
|
|
130
|
+
type: "error"
|
|
131
|
+
};
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!response.ok || !response.headers.has(VERSION_HEADER) || response.body === null) {
|
|
135
|
+
yield {
|
|
136
|
+
error: await replyError(response),
|
|
137
|
+
type: "error"
|
|
138
|
+
};
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
142
|
+
let buffer = "";
|
|
143
|
+
let ended = false;
|
|
144
|
+
try {
|
|
145
|
+
for (;;) {
|
|
146
|
+
const chunk = await reader.read();
|
|
147
|
+
if (chunk.done) {
|
|
148
|
+
if (!ended && !signal.aborted) yield {
|
|
149
|
+
error: network,
|
|
150
|
+
type: "error"
|
|
151
|
+
};
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
buffer += chunk.value;
|
|
155
|
+
let end = buffer.indexOf("\n\n");
|
|
156
|
+
while (end !== -1) {
|
|
157
|
+
const frame = buffer.slice(0, end);
|
|
158
|
+
buffer = buffer.slice(end + 2);
|
|
159
|
+
const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
160
|
+
const event = data === "" ? null : parseSessionEvent(data);
|
|
161
|
+
if (event !== null) {
|
|
162
|
+
ended ||= event.type !== "program";
|
|
163
|
+
yield event;
|
|
164
|
+
}
|
|
165
|
+
end = buffer.indexOf("\n\n");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
if (!signal.aborted) yield {
|
|
170
|
+
error: network,
|
|
171
|
+
type: "error"
|
|
172
|
+
};
|
|
173
|
+
} finally {
|
|
174
|
+
reader.releaseLock();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function parseRequiresAction(value) {
|
|
180
|
+
return typeof value === "object" && value !== null && "message" in value && "token" in value && typeof value.message === "string" && typeof value.token === "string" ? {
|
|
181
|
+
message: value.message,
|
|
182
|
+
token: value.token
|
|
183
|
+
} : null;
|
|
184
|
+
}
|
|
185
|
+
/** The `{ code, message }` of anything a transport rejected with. */
|
|
186
|
+
function errorDataOf(cause) {
|
|
187
|
+
return thrownErrorData(cause) ?? unavailable;
|
|
188
|
+
}
|
|
189
|
+
/** The `{ code, message }` that `cause` carries, or null. */
|
|
190
|
+
function thrownErrorData(cause) {
|
|
191
|
+
return typeof cause === "object" && cause !== null ? parseErrorData({
|
|
192
|
+
code: "code" in cause ? cause.code : void 0,
|
|
193
|
+
message: "message" in cause ? cause.message : void 0
|
|
194
|
+
}) : null;
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
export { TransportError, errorDataOf, httpTransport };
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jigs.design/client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "createPickleClient: the browser's transport and query cache. No React.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/yoamomonstruos/pickle.git",
|
|
9
|
+
"directory": "packages/client"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@tanstack/query-core": "5.104.4",
|
|
29
|
+
"@jigs.design/runtime": "0.1.0"
|
|
30
|
+
}
|
|
6
31
|
}
|