@jigs.design/client 0.0.0-stage → 0.1.1

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 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,41 @@
1
- # Temporary Holding Version
1
+ # @jigs.design/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `createPickleClient` is the browser's connection to a Pickle server: the transport and the query cache, on TanStack Query Core. `createViewController` is the framework-free half of the view: it owns the sandbox frame, keeps the tree and routes the program's queries, mutations and actions through the client, so a renderer for any framework builds on it as `PickleView` does. The package 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
+ | `httpTransport`, `Transport` | The default HTTP transport, and the type to implement your own |
19
+ | `TransportError` | What a transport rejects with: `{ code, message }` |
20
+ | `createViewController`, `ViewController` | One view's page side: `start`, `load`, `subscribe`, `store`, `drawnProps`, `call`, `stop`, and the handlers for issues, actions and confirmation |
21
+ | `missingComponents` | The components a program draws that a registry lacks, as issues for `setUnregistered` |
22
+ | `ViewIssue`, `ViewStatus` | What the issue handler receives, and what `getStatus` returns |
23
+ | `ActionCall`, `ActionHandler`, `ConfirmFunction` | What the action and confirmation handlers receive |
24
+
25
+ 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.
26
+
27
+ The controller trusts the program it loaded and nothing the worker says: it checks each read and mutation against the program's procedure table, has the tree store check each op against the program's prop schemas, and enforces the budgets on the page again. `drawnProps` turns a node's props into what a component takes, reading each prop's kind from the schema, never from the value.
28
+
29
+ It re-exports the shared types from `@jigs.design/runtime`.
30
+
31
+ ## Source
32
+
33
+ - `src/client.ts`: the client and its query cache.
34
+ - `src/transport.ts`: the transport contract and the HTTP transport.
35
+ - `src/view/controller.ts`: the frame, its messages, the tree and the calls it makes through the client.
36
+ - `src/view/frame-check.ts`: tells you when a page policy broke the frame's own.
37
+ - `src/view/protocol-reference.test.ts`: renders the frame protocol page of the docs from the types and the limits.
38
+
39
+ ## Docs
40
+
41
+ [Transport](../../apps/docs/docs/running/transport.mdx), [Session](../../apps/docs/docs/running/session.mdx), [Renderers](../../apps/docs/docs/renderers/index.mdx) and [Frame protocol](../../apps/docs/docs/renderers/protocol.mdx).
@@ -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 };
@@ -0,0 +1,5 @@
1
+ import { CallReply, HeadersOption, HttpTransportOptions, Transport, TransportError, httpTransport } from "./transport.js";
2
+ import { ClientOptions, ConfirmFunction, PickleClient, createPickleClient } from "./client.js";
3
+ import { ActionCall, ActionHandler, ViewController, ViewIssue, ViewStatus, createViewController, missingComponents } from "./view/controller.js";
4
+ import { Diagnostic, Invalidation, Message, PickleErrorCode, PickleErrorData, Program, SessionEvent, Span } from "@jigs.design/runtime";
5
+ export { type ActionCall, type ActionHandler, 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, type ViewController, type ViewIssue, type ViewStatus, createPickleClient, createViewController, httpTransport, missingComponents };
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import { TransportError, httpTransport } from "./transport.js";
2
+ import { createPickleClient } from "./client.js";
3
+ import { createViewController, missingComponents } from "./view/controller.js";
4
+ export { TransportError, createPickleClient, createViewController, httpTransport, missingComponents };
@@ -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 };
@@ -0,0 +1,132 @@
1
+ import { ConfirmFunction, PickleClient } from "../client.js";
2
+ import { Program, PropSchema, RuntimeError, TreeNode, TreeStore, WireArg } from "@jigs.design/runtime";
3
+ //#region src/view/controller.d.ts
4
+ /** What the view shows beside the tree: a snapshot, replaced on each change. */
5
+ export interface ViewStatus {
6
+ /** Runtime errors the program threw in its last result. */
7
+ errors: ReadonlyArray<RuntimeError>;
8
+ /** Props and components the view dropped while checking ops. */
9
+ issues: ReadonlyArray<string>;
10
+ /** Set when the sandbox rejected or stopped a program, or cannot run. */
11
+ notice: string | null;
12
+ }
13
+ /**
14
+ * A problem the view found, as `onIssue` receives it: a runtime error the
15
+ * program threw, something the view dropped (a prop or component that failed
16
+ * its schema, a request the program does not declare, a component missing
17
+ * from the registry), or a notice that the sandbox rejected or stopped a
18
+ * program or cannot run.
19
+ */
20
+ export type ViewIssue = {
21
+ kind: "dropped";
22
+ message: string;
23
+ } | {
24
+ kind: "error";
25
+ line: number | null;
26
+ message: string;
27
+ } | {
28
+ kind: "notice";
29
+ message: string;
30
+ };
31
+ /** An action the program called, after the server checked its input. */
32
+ export interface ActionCall {
33
+ input: unknown;
34
+ name: string;
35
+ }
36
+ /**
37
+ * Handles the program's actions. What it returns (awaited and copied with
38
+ * `structuredClone`) is the handle's `data`; what it throws is the handle's
39
+ * `error`.
40
+ */
41
+ export type ActionHandler = (action: ActionCall) => unknown;
42
+ /**
43
+ * The page side of one view, for any renderer: it owns the sandbox frame and
44
+ * the tree, and routes the program's queries, mutations and actions. Call
45
+ * `start`, then `load` a program; `subscribe` fires when the tree or the
46
+ * status changes, and `store.get` reads a node from `ROOT_ID` down. Draw
47
+ * each node's props through `drawnProps`, which gives handlers as functions
48
+ * that call back into the worker. `stop` tears everything down.
49
+ */
50
+ export interface ViewController {
51
+ /**
52
+ * Runs handler `fn` of node `node` in the worker with `args`. The
53
+ * functions `drawnProps` returns call this; use it directly to replay an
54
+ * event from elsewhere.
55
+ */
56
+ call: (node: number, fn: string, args: ReadonlyArray<WireArg>) => void;
57
+ /** Whether the current program lets `type` take children. */
58
+ childrenAllowed: (type: string) => boolean;
59
+ /**
60
+ * The props to draw `node` with: a primitive prop as it is, a list as its
61
+ * rows, and a handler as a function that sends its primitive arguments to
62
+ * the worker. Which is which comes from the program's schema, never from
63
+ * the value. A prop named `autoFocus` is left out unless `allowAutoFocus`,
64
+ * so a program cannot take the focus on a page that has not allowed it.
65
+ */
66
+ drawnProps: (node: TreeNode, options: {
67
+ allowAutoFocus: boolean;
68
+ }) => Record<string, unknown>;
69
+ /**
70
+ * How many times the worker finished answering every handler call of
71
+ * `node`. It changes once the program has seen the node's latest events.
72
+ */
73
+ getSettled: (node: number) => number;
74
+ getStatus: () => ViewStatus;
75
+ /**
76
+ * Runs `program`. When `document` differs from the last one's, the
77
+ * program starts from fresh state and confirmations still waiting on the
78
+ * user are withdrawn; otherwise state it still declares keeps its value,
79
+ * and an equal program, by value, does nothing.
80
+ */
81
+ load: (program: Program, document?: number | string) => void;
82
+ /** The kind of prop `name` of `type` in the current program's schema. */
83
+ propKind: (type: string, name: string) => PropSchema["kind"] | undefined;
84
+ /** Replaces the action handler; `undefined` makes every action fail. */
85
+ setActionHandler: (handler: ActionHandler | undefined) => void;
86
+ /**
87
+ * Replaces what asks the user to confirm a mutation; `undefined` falls
88
+ * back to the client's `confirm`. A question still open when the view
89
+ * stops, loads another document or restarts a hung worker has its
90
+ * `signal` aborted.
91
+ */
92
+ setConfirmHandler: (handler: ConfirmFunction | undefined) => void;
93
+ /** Replaces what each new issue is given to; `undefined` stops reporting. */
94
+ setIssueHandler: (handler: ((issue: ViewIssue) => void) | undefined) => void;
95
+ /**
96
+ * Components the program uses that the registry lacks, as issues to
97
+ * report; `missingComponents` lists them.
98
+ */
99
+ setUnregistered: (issues: ReadonlyArray<string>) => void;
100
+ /**
101
+ * Appends the hidden sandbox frame to the document and listens for its
102
+ * messages. Call it once the renderer is on the page; a second call does
103
+ * nothing until `stop`.
104
+ */
105
+ start: () => void;
106
+ /**
107
+ * Removes the frame, forgets the programs, withdraws open confirmations
108
+ * and empties the tree. Results that arrive afterwards are dropped. The
109
+ * controller can `start` again.
110
+ */
111
+ stop: () => void;
112
+ /** The tree the program drew, checked against its schemas. Read it from `ROOT_ID`. */
113
+ store: TreeStore;
114
+ /** Fires after the tree or the status changed. Returns the unsubscribe. */
115
+ subscribe: (listener: () => void) => () => void;
116
+ }
117
+ /**
118
+ * The components `program` draws that `registered` lacks, as the issues the
119
+ * view reports for them. Give the result to `setUnregistered`.
120
+ */
121
+ export declare function missingComponents(program: Program, registered: ReadonlyArray<string>): Array<string>;
122
+ /**
123
+ * Page side of the sandbox. Owns the frame, sends programs and handler calls
124
+ * to the worker, applies the ops it returns to `store`, keeps exactly the
125
+ * queries the program reads subscribed through the client, and runs the
126
+ * mutations its handlers call. Messages from anything but its own frame are
127
+ * ignored, and the names a result asks for must be the right kind of
128
+ * procedure in the program, since the worker shares its realm with the
129
+ * program and is not trusted either.
130
+ */
131
+ export declare function createViewController(client: PickleClient): ViewController;
132
+ //#endregion
@@ -0,0 +1,539 @@
1
+ import { frameProblem, unprotectedProblem } from "./frame-check.js";
2
+ import { createTreeStore, parseErrorData, parseProgram } from "@jigs.design/runtime";
3
+ //#region src/view/controller.ts
4
+ const WATCHDOG_NOTICE = "Program stopped: it did not answer within a second. The sandbox was restarted.";
5
+ const GAVE_UP_NOTICE = "Program stopped: it did not answer within a second, again after a restart. It will not run until a new program is loaded.";
6
+ const idle = {
7
+ errors: [],
8
+ issues: [],
9
+ notice: null
10
+ };
11
+ const FORMAT_NOTICE = "This program was compiled for another version of the runtime; compile it again.";
12
+ /**
13
+ * How long after the frame loads it may take to say it is ready. Its policy
14
+ * check takes a moment, or up to a second when the policy is gone, and then
15
+ * the frame says `unprotected` itself; silence past this means its script
16
+ * never ran.
17
+ */
18
+ const STARTUP_MS = 3e3;
19
+ const MAX_ERROR_TEXT = 1e3;
20
+ /** The prop a registry component reads to focus itself when it mounts. */
21
+ const AUTO_FOCUS = "autoFocus";
22
+ const toWireArgs = (args) => args.filter((arg) => typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean");
23
+ /**
24
+ * The components `program` draws that `registered` lacks, as the issues the
25
+ * view reports for them. Give the result to `setUnregistered`.
26
+ */
27
+ function missingComponents(program, registered) {
28
+ return Object.keys(program.components).filter((name) => !registered.includes(name)).map((name) => `<${name}> is not in the registry, so it renders nothing`);
29
+ }
30
+ const NO_ACTIONS = {
31
+ error: {
32
+ code: "UNAVAILABLE",
33
+ message: "This page handles no actions"
34
+ },
35
+ status: "error"
36
+ };
37
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
38
+ /** The runtime errors of a result, as text React can draw. The frame is not trusted. */
39
+ function parseErrors(list) {
40
+ const errors = [];
41
+ for (const entry of list) {
42
+ if (errors.length >= 20) break;
43
+ if (isRecord(entry) && typeof entry.message === "string") {
44
+ const line = entry.line;
45
+ errors.push({
46
+ line: typeof line === "number" && Number.isSafeInteger(line) ? line : null,
47
+ message: entry.message.slice(0, MAX_ERROR_TEXT)
48
+ });
49
+ }
50
+ }
51
+ return errors;
52
+ }
53
+ /** A message from the frame with the fields its kind needs. The frame is not trusted. */
54
+ function isFrameMessage(value) {
55
+ if (typeof value !== "object" || value === null || !("type" in value)) return false;
56
+ if (value.type === "ready" || value.type === "unprotected") return true;
57
+ if (!("seq" in value) || typeof value.seq !== "number") return false;
58
+ if (value.type === "result") return "ops" in value && Array.isArray(value.ops) && "errors" in value && Array.isArray(value.errors) && "queries" in value && Array.isArray(value.queries) && "mutations" in value && Array.isArray(value.mutations);
59
+ if (value.type === "failed") return "message" in value && typeof value.message === "string";
60
+ return value.type === "quarantined";
61
+ }
62
+ /**
63
+ * Page side of the sandbox. Owns the frame, sends programs and handler calls
64
+ * to the worker, applies the ops it returns to `store`, keeps exactly the
65
+ * queries the program reads subscribed through the client, and runs the
66
+ * mutations its handlers call. Messages from anything but its own frame are
67
+ * ignored, and the names a result asks for must be the right kind of
68
+ * procedure in the program, since the worker shares its realm with the
69
+ * program and is not trusted either.
70
+ */
71
+ function createViewController(client) {
72
+ const store = createTreeStore();
73
+ const listeners = /* @__PURE__ */ new Set();
74
+ const observers = /* @__PURE__ */ new Map();
75
+ const sent = /* @__PURE__ */ new Map();
76
+ let iframe = null;
77
+ let ready = false;
78
+ let queue = [];
79
+ let seq = 0;
80
+ /** The program the worker accepted and is running. */
81
+ let running = null;
82
+ /** The program the view was last given; the worker may not have accepted it yet. */
83
+ let latest = null;
84
+ let latestKey = null;
85
+ /** The document of the last program sent to the worker, as `load` was told. */
86
+ let latestDocument;
87
+ let lastGood = null;
88
+ const updates = /* @__PURE__ */ new Map();
89
+ /** Times each program hung, against `MAX_HANGS`. */
90
+ let hangs = /* @__PURE__ */ new WeakMap();
91
+ /** Handler calls the worker has not answered yet: seq to node. */
92
+ const calls = /* @__PURE__ */ new Map();
93
+ /** Unanswered calls per node, and how often each node's calls all settled. */
94
+ const openCalls = /* @__PURE__ */ new Map();
95
+ const settled = /* @__PURE__ */ new Map();
96
+ /** What the view itself dropped from results since the last program change. */
97
+ let dropped = [];
98
+ let status = idle;
99
+ /** Bumped on stop and on quarantine, so late results are dropped. */
100
+ let generation = 0;
101
+ let startup = null;
102
+ let actionHandler;
103
+ let confirmHandler;
104
+ /**
105
+ * Aborts the confirmations the program on screen asked for, so a question
106
+ * still showing cannot run a mutation for a program that is gone.
107
+ */
108
+ let confirmationScope = new AbortController();
109
+ const withdrawConfirmations = () => {
110
+ confirmationScope.abort();
111
+ confirmationScope = new AbortController();
112
+ };
113
+ let issueHandler;
114
+ /** Issues already reported that are still present, by key. */
115
+ let announced = /* @__PURE__ */ new Set();
116
+ let unregistered = [];
117
+ let reported = false;
118
+ let warnedClone = false;
119
+ /**
120
+ * Reports each issue that was not present before. One that persists is
121
+ * reported once; one that goes away and comes back, again.
122
+ */
123
+ const announce = () => {
124
+ const present = /* @__PURE__ */ new Set();
125
+ const fresh = [];
126
+ const note = (key, issue) => {
127
+ present.add(key);
128
+ if (!announced.has(key)) fresh.push(issue);
129
+ };
130
+ for (const error of status.errors) note(`error:${error.line ?? ""}:${error.message}`, {
131
+ kind: "error",
132
+ line: error.line,
133
+ message: error.message
134
+ });
135
+ for (const message of [...unregistered, ...status.issues]) note(`dropped:${message}`, {
136
+ kind: "dropped",
137
+ message
138
+ });
139
+ if (status.notice !== null) note(`notice:${status.notice}`, {
140
+ kind: "notice",
141
+ message: status.notice
142
+ });
143
+ announced = present;
144
+ const handler = issueHandler;
145
+ for (const issue of fresh) handler?.(issue);
146
+ };
147
+ const setStatus = (next) => {
148
+ status = next;
149
+ for (const listener of listeners) listener();
150
+ announce();
151
+ };
152
+ const drop = (issue) => {
153
+ if (dropped.length < 20 && !dropped.includes(issue)) dropped = [...dropped, issue];
154
+ };
155
+ const clearStartup = () => {
156
+ if (startup !== null) {
157
+ clearTimeout(startup);
158
+ startup = null;
159
+ }
160
+ };
161
+ /** A frame that cannot run is a setup mistake: say so once, in the view and the console. */
162
+ const reportFrame = (problem) => {
163
+ clearStartup();
164
+ if (reported) return;
165
+ reported = true;
166
+ console.error(`Pickle: ${problem}`);
167
+ setStatus({
168
+ ...status,
169
+ notice: problem
170
+ });
171
+ };
172
+ /** Fetches the frame again to tell a replaced policy from other reasons it stayed silent. */
173
+ const diagnoseFrame = () => {
174
+ startup = null;
175
+ const started = generation;
176
+ fetch(client.frame, { cache: "no-store" }).then((response) => frameProblem(client.frame, response, window.location.origin), () => frameProblem(client.frame, null, window.location.origin)).then((problem) => {
177
+ if (started === generation && !ready) reportFrame(problem);
178
+ });
179
+ };
180
+ /** `value` as the worker may receive it, or null when it cannot be copied (a function, say). */
181
+ const cloneable = (value) => {
182
+ try {
183
+ return structuredClone(value ?? null);
184
+ } catch {
185
+ if (!warnedClone) {
186
+ warnedClone = true;
187
+ console.warn("Pickle: an onAction handler returned a value structuredClone cannot copy (a function, a DOM node, a class with private fields), so the program's handle got null as its data.");
188
+ }
189
+ return null;
190
+ }
191
+ };
192
+ /** Gives a checked action to the page's handler and settles the handle with its outcome. */
193
+ const runAction = async (action) => {
194
+ const handler = actionHandler;
195
+ if (handler === void 0) return NO_ACTIONS;
196
+ try {
197
+ return {
198
+ data: cloneable(await handler(action)),
199
+ status: "success"
200
+ };
201
+ } catch (error) {
202
+ return {
203
+ error: parseErrorData(error) ?? {
204
+ code: "INTERNAL",
205
+ message: error instanceof Error ? error.message : String(error)
206
+ },
207
+ status: "error"
208
+ };
209
+ }
210
+ };
211
+ const post = (message) => {
212
+ if (ready && iframe?.contentWindow) iframe.contentWindow.postMessage(message, "*");
213
+ else queue.push(message);
214
+ };
215
+ const sendUpdate = (next, fresh) => {
216
+ seq++;
217
+ updates.set(seq, next);
218
+ post({
219
+ code: next.code,
220
+ fresh,
221
+ procedures: next.procedures,
222
+ prose: next.prose,
223
+ seq,
224
+ type: "update"
225
+ });
226
+ };
227
+ const sendQuery = (key, result) => {
228
+ const text = JSON.stringify(result);
229
+ if (sent.get(key) === text) return;
230
+ sent.set(key, text);
231
+ if (result.status === "pending") return;
232
+ seq++;
233
+ post({
234
+ key,
235
+ result,
236
+ seq,
237
+ type: "query"
238
+ });
239
+ };
240
+ const dropObservers = () => {
241
+ for (const unsubscribe of observers.values()) unsubscribe();
242
+ observers.clear();
243
+ sent.clear();
244
+ };
245
+ /** The running program's entry for `name`, if it is one of `kinds`. */
246
+ const procedure = (name, kinds) => {
247
+ if (running === null || !Object.hasOwn(running.procedures, name)) return;
248
+ const entry = running.procedures[name];
249
+ return entry !== void 0 && kinds.includes(entry.kind) ? entry : void 0;
250
+ };
251
+ /** The queries a result reads that the program declares, up to `MAX_QUERIES`. */
252
+ const checkedReads = (reads) => {
253
+ const wanted = /* @__PURE__ */ new Map();
254
+ for (const read of reads) {
255
+ if (!isRecord(read) || typeof read.key !== "string" || typeof read.name !== "string" || procedure(read.name, ["query"]) === void 0) {
256
+ drop("The sandbox read a query this program does not declare");
257
+ continue;
258
+ }
259
+ if (!wanted.has(read.key) && wanted.size >= 20) {
260
+ drop(`The sandbox read more than 20 queries`);
261
+ continue;
262
+ }
263
+ wanted.set(read.key, {
264
+ input: read.input,
265
+ key: read.key,
266
+ name: read.name
267
+ });
268
+ }
269
+ return wanted;
270
+ };
271
+ const syncQueries = (wanted) => {
272
+ for (const [key, unsubscribe] of observers) if (!wanted.has(key)) {
273
+ unsubscribe();
274
+ observers.delete(key);
275
+ sent.delete(key);
276
+ }
277
+ for (const [key, read] of wanted) if (!observers.has(key)) observers.set(key, client.observe(read.name, read.input, (result) => {
278
+ sendQuery(key, result);
279
+ }));
280
+ };
281
+ /** Marks the call `answered` done; returns whether it was a handler call. */
282
+ const answer = (answered) => {
283
+ const node = calls.get(answered);
284
+ if (node === void 0) return false;
285
+ calls.delete(answered);
286
+ const left = (openCalls.get(node) ?? 1) - 1;
287
+ if (left > 0) openCalls.set(node, left);
288
+ else {
289
+ openCalls.delete(node);
290
+ settled.set(node, (settled.get(node) ?? 0) + 1);
291
+ }
292
+ return true;
293
+ };
294
+ const forgetCalls = () => {
295
+ calls.clear();
296
+ openCalls.clear();
297
+ settled.clear();
298
+ };
299
+ const runMutations = (mutations, fromHandler) => {
300
+ if (mutations.length > 0 && !fromHandler) {
301
+ drop("The sandbox asked for a mutation outside an event handler");
302
+ return;
303
+ }
304
+ if (mutations.length > 5) drop(`The sandbox asked for more than 5 mutations at once`);
305
+ const started = generation;
306
+ for (const mutation of mutations.slice(0, 5)) {
307
+ const call = isRecord(mutation) && typeof mutation.id === "number" && typeof mutation.name === "string" ? {
308
+ id: mutation.id,
309
+ input: mutation.input,
310
+ name: mutation.name
311
+ } : null;
312
+ const entry = call === null ? void 0 : procedure(call.name, ["action", "mutation"]);
313
+ if (call === null || entry === void 0) {
314
+ drop("The sandbox called a mutation this program does not declare");
315
+ continue;
316
+ }
317
+ const kind = entry.kind === "action" ? "action" : "mutation";
318
+ client.mutate(call.name, call.input, {
319
+ confirm: confirmHandler,
320
+ invalidates: entry.invalidates,
321
+ kind,
322
+ signal: confirmationScope.signal
323
+ }).then((result) => kind === "action" && result.status === "success" && started === generation ? runAction({
324
+ input: result.data,
325
+ name: call.name
326
+ }) : result).then((result) => {
327
+ if (started === generation) {
328
+ seq++;
329
+ post({
330
+ id: call.id,
331
+ result,
332
+ seq,
333
+ type: "mutation"
334
+ });
335
+ }
336
+ });
337
+ }
338
+ };
339
+ const onMessage = (event) => {
340
+ if (iframe === null || event.source !== iframe.contentWindow) return;
341
+ const message = event.data;
342
+ if (!isFrameMessage(message)) return;
343
+ switch (message.type) {
344
+ case "unprotected":
345
+ reportFrame(unprotectedProblem(client.frame));
346
+ return;
347
+ case "ready": {
348
+ clearStartup();
349
+ ready = true;
350
+ const queued = queue;
351
+ queue = [];
352
+ for (const item of queued) post(item);
353
+ return;
354
+ }
355
+ case "result": {
356
+ const update = updates.get(message.seq);
357
+ updates.delete(message.seq);
358
+ if (update !== void 0) {
359
+ running = update;
360
+ lastGood = update;
361
+ dropped = [];
362
+ store.setSchemas(update);
363
+ }
364
+ store.apply(message.ops);
365
+ const fromHandler = answer(message.seq);
366
+ const wanted = checkedReads(message.queries);
367
+ runMutations(message.mutations, fromHandler);
368
+ const newest = update !== void 0 && update === latest && !hangs.has(update);
369
+ setStatus({
370
+ errors: parseErrors(message.errors),
371
+ issues: [...store.getIssues(), ...dropped],
372
+ notice: newest ? null : status.notice
373
+ });
374
+ syncQueries(wanted);
375
+ return;
376
+ }
377
+ case "failed": {
378
+ const failed = updates.get(message.seq);
379
+ updates.delete(message.seq);
380
+ answer(message.seq);
381
+ if (failed !== void 0 && failed === latest) latest = running;
382
+ setStatus({
383
+ ...status,
384
+ notice: `Program failed (${message.message}); showing the last working version.`
385
+ });
386
+ return;
387
+ }
388
+ case "quarantined": {
389
+ const failed = updates.get(message.seq);
390
+ const culprit = failed ?? running ?? latest;
391
+ updates.clear();
392
+ generation++;
393
+ withdrawConfirmations();
394
+ dropObservers();
395
+ forgetCalls();
396
+ store.reset();
397
+ running = null;
398
+ if (culprit !== null) {
399
+ const before = hangs.get(culprit) ?? 0;
400
+ hangs.set(culprit, failed === void 0 ? before + 1 : 2);
401
+ if (before === 0) console.warn(`Pickle: ${WATCHDOG_NOTICE}`);
402
+ }
403
+ const usable = (candidate) => candidate !== null && (hangs.get(candidate) ?? 0) < 2;
404
+ let recovery = null;
405
+ if (usable(latest)) recovery = latest;
406
+ else if (usable(lastGood)) recovery = lastGood;
407
+ setStatus({
408
+ errors: [],
409
+ issues: [],
410
+ notice: recovery === null ? GAVE_UP_NOTICE : WATCHDOG_NOTICE
411
+ });
412
+ if (recovery !== null) sendUpdate(recovery, false);
413
+ return;
414
+ }
415
+ }
416
+ };
417
+ const call = (node, fn, args) => {
418
+ seq++;
419
+ calls.set(seq, node);
420
+ openCalls.set(node, (openCalls.get(node) ?? 0) + 1);
421
+ post({
422
+ args,
423
+ fn,
424
+ seq,
425
+ type: "call"
426
+ });
427
+ };
428
+ const propKind = (type, name) => {
429
+ const component = running !== null && Object.hasOwn(running.components, type) ? running.components[type] : void 0;
430
+ return component !== void 0 && Object.hasOwn(component.props, name) ? component.props[name]?.kind : void 0;
431
+ };
432
+ return {
433
+ call,
434
+ childrenAllowed: (type) => running !== null && Object.hasOwn(running.components, type) && running.components[type]?.children !== "none",
435
+ drawnProps: (node, options) => {
436
+ const drawn = {};
437
+ for (const [name, value] of Object.entries(node.props)) {
438
+ if (name === AUTO_FOCUS && !options.allowAutoFocus) continue;
439
+ const kind = propKind(node.type, name);
440
+ if (kind === "handler") {
441
+ if (typeof value === "object" && "fn" in value) {
442
+ const fn = value.fn;
443
+ drawn[name] = (...args) => {
444
+ call(node.id, fn, toWireArgs(args));
445
+ };
446
+ }
447
+ } else if (kind === "list") {
448
+ if (typeof value === "object" && "rows" in value) drawn[name] = value.rows;
449
+ } else if (kind !== void 0 && typeof value !== "object") drawn[name] = value;
450
+ }
451
+ return drawn;
452
+ },
453
+ getSettled: (node) => settled.get(node) ?? 0,
454
+ getStatus: () => status,
455
+ load: (next, document) => {
456
+ const fresh = document !== latestDocument;
457
+ const key = JSON.stringify(next);
458
+ if (key === latestKey && !fresh) return;
459
+ latestKey = key;
460
+ const checked = parseProgram(next);
461
+ if (checked === null) {
462
+ setStatus({
463
+ ...status,
464
+ notice: FORMAT_NOTICE
465
+ });
466
+ return;
467
+ }
468
+ latest = checked;
469
+ latestDocument = document;
470
+ if (fresh) withdrawConfirmations();
471
+ sendUpdate(checked, fresh);
472
+ },
473
+ propKind,
474
+ setActionHandler: (handler) => {
475
+ actionHandler = handler;
476
+ },
477
+ setConfirmHandler: (handler) => {
478
+ confirmHandler = handler;
479
+ },
480
+ setIssueHandler: (handler) => {
481
+ issueHandler = handler;
482
+ },
483
+ setUnregistered: (issues) => {
484
+ unregistered = issues;
485
+ announce();
486
+ },
487
+ start: () => {
488
+ if (iframe !== null) return;
489
+ const frame = document.createElement("iframe");
490
+ frame.setAttribute("sandbox", "allow-scripts");
491
+ frame.setAttribute("aria-hidden", "true");
492
+ frame.setAttribute("tabindex", "-1");
493
+ frame.style.display = "none";
494
+ frame.src = client.frame;
495
+ frame.addEventListener("load", () => {
496
+ if (iframe === frame && !ready && startup === null) startup = setTimeout(diagnoseFrame, STARTUP_MS);
497
+ });
498
+ iframe = frame;
499
+ window.addEventListener("message", onMessage);
500
+ document.body.append(frame);
501
+ },
502
+ stop: () => {
503
+ generation++;
504
+ window.removeEventListener("message", onMessage);
505
+ iframe?.remove();
506
+ iframe = null;
507
+ ready = false;
508
+ clearStartup();
509
+ reported = false;
510
+ queue = [];
511
+ updates.clear();
512
+ hangs = /* @__PURE__ */ new WeakMap();
513
+ forgetCalls();
514
+ dropped = [];
515
+ lastGood = null;
516
+ running = null;
517
+ latest = null;
518
+ latestKey = null;
519
+ latestDocument = void 0;
520
+ actionHandler = void 0;
521
+ confirmHandler = void 0;
522
+ withdrawConfirmations();
523
+ dropObservers();
524
+ store.reset();
525
+ status = idle;
526
+ },
527
+ store,
528
+ subscribe: (listener) => {
529
+ listeners.add(listener);
530
+ const unsubscribe = store.subscribe(listener);
531
+ return () => {
532
+ listeners.delete(listener);
533
+ unsubscribe();
534
+ };
535
+ }
536
+ };
537
+ }
538
+ //#endregion
539
+ export { MAX_ERROR_TEXT, STARTUP_MS, createViewController, missingComponents };
@@ -0,0 +1,32 @@
1
+ import { frameSecurityPolicy } from "@jigs.design/runtime";
2
+ //#region src/view/frame-check.ts
3
+ const ADVICE = "Exclude that route from your page policy: `contentSecurityPolicy(host, { endpoint }).appliesTo(request.url)` is false for it.";
4
+ /**
5
+ * The issue for a frame that started and found its own policy missing: its
6
+ * scripts ran, so whatever replaced the policy does not block them, but the
7
+ * frame's network lock is gone and it refuses to run programs.
8
+ */
9
+ function unprotectedProblem(frame) {
10
+ return `The sandbox frame's Content-Security-Policy is not in force on ${frame}, so the sandbox will not run. A page policy replaced it, or something stripped it. ${ADVICE}`;
11
+ }
12
+ /**
13
+ * The issue for a frame that never said it was ready. `response` is the
14
+ * frame as the view fetched it again, or `null` when that fetch failed, as
15
+ * it does for an endpoint on another origin that does not list the page's
16
+ * origin, `pageOrigin`, in `allowedOrigins`. A response can carry several
17
+ * policies, which `Headers` joins with commas; the frame's own starts with
18
+ * `frameSecurityPolicy`.
19
+ */
20
+ function frameProblem(frame, response, pageOrigin) {
21
+ if (response === null) {
22
+ if ((URL.canParse(frame, pageOrigin) ? new URL(frame, pageOrigin).origin : pageOrigin) !== pageOrigin) return `The sandbox frame at ${frame} did not start, and its response could not be read to say why. It is on another origin: check that the server's \`allowedOrigins\` lists ${pageOrigin}, which its frame-ancestors and CORS headers both need, and that nothing replaces the Content-Security-Policy it sends.`;
23
+ return `The sandbox frame at ${frame} did not start, and its response could not be read to say why. Check that nothing replaces the Content-Security-Policy it sends.`;
24
+ }
25
+ if (response.status !== 200) return `The sandbox frame at ${frame} answered ${response.status}, so the view cannot start. Check that the route hands GET requests to \`pickle.handler\`.`;
26
+ const policies = (response.headers.get("content-security-policy") ?? "").split(",").map((policy) => policy.trim()).filter((policy) => policy !== "");
27
+ if (!policies.some((policy) => policy.startsWith(frameSecurityPolicy))) return `The page's Content-Security-Policy replaced the sandbox frame's on ${frame}, so the sandbox will not run. ${ADVICE}`;
28
+ if (policies.length > 1) return `A second Content-Security-Policy is added to the sandbox frame's response on ${frame}, and it stops the frame from starting. ${ADVICE}`;
29
+ return `The sandbox frame at ${frame} did not start. Its policy is intact; check the browser console for the frame's own errors.`;
30
+ }
31
+ //#endregion
32
+ export { frameProblem, unprotectedProblem };
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@jigs.design/client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
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.1"
30
+ }
6
31
  }