@jigs.design/client 0.1.0 → 0.1.2
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/README.md +18 -9
- package/dist/client.d.ts +4 -4
- package/dist/client.js +3 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/transport.d.ts +7 -7
- package/dist/transport.js +3 -3
- package/dist/view/controller.d.ts +132 -0
- package/dist/view/controller.js +539 -0
- package/dist/view/frame-check.js +32 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
# @jigs.design/client
|
|
2
2
|
|
|
3
|
-
`
|
|
3
|
+
`createJigsClient` is the browser's connection to a Jigs 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 `JigsView` does. The package has no React and no schema library.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
-
import {
|
|
6
|
+
import { createJigsClient } from "@jigs.design/client";
|
|
7
7
|
|
|
8
|
-
export const client =
|
|
8
|
+
export const client = createJigsClient({ endpoint: "/api/jigs" });
|
|
9
9
|
```
|
|
10
10
|
|
|
11
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
12
|
|
|
13
13
|
## Exports
|
|
14
14
|
|
|
15
|
-
| Export
|
|
16
|
-
|
|
|
17
|
-
| `
|
|
15
|
+
| Export | What it is |
|
|
16
|
+
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
17
|
+
| `createJigsClient` | 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 |
|
|
18
24
|
|
|
19
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.
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
22
28
|
|
|
23
29
|
It re-exports the shared types from `@jigs.design/runtime`.
|
|
24
30
|
|
|
@@ -26,7 +32,10 @@ It re-exports the shared types from `@jigs.design/runtime`.
|
|
|
26
32
|
|
|
27
33
|
- `src/client.ts`: the client and its query cache.
|
|
28
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.
|
|
29
38
|
|
|
30
39
|
## Docs
|
|
31
40
|
|
|
32
|
-
[Transport](../../apps/docs/docs/running/transport.mdx)
|
|
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).
|
package/dist/client.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ interface ConfirmOptions {
|
|
|
28
28
|
/**
|
|
29
29
|
* Asks the user to approve a mutation whose policy is "confirm", in the
|
|
30
30
|
* page's own UI, outside the sandbox. Without it, or when it resolves
|
|
31
|
-
* false or throws, the mutation fails with `DECLINED`. A `
|
|
31
|
+
* false or throws, the mutation fails with `DECLINED`. A `JigsView`'s
|
|
32
32
|
* `onConfirm` takes its place for that view.
|
|
33
33
|
*/
|
|
34
34
|
confirm?: ConfirmFunction;
|
|
@@ -46,7 +46,7 @@ export type ClientOptions = CacheOptions & ConfirmOptions & ({
|
|
|
46
46
|
headers?: never;
|
|
47
47
|
transport: Transport;
|
|
48
48
|
});
|
|
49
|
-
export interface
|
|
49
|
+
export interface JigsClient {
|
|
50
50
|
/** URL of the sandbox frame document. */
|
|
51
51
|
readonly frame: string;
|
|
52
52
|
/** Refetches every query with this name, for every input. */
|
|
@@ -83,10 +83,10 @@ export interface PickleClient {
|
|
|
83
83
|
}) => AsyncIterable<SessionEvent>;
|
|
84
84
|
}
|
|
85
85
|
/**
|
|
86
|
-
* The browser's connection to a
|
|
86
|
+
* The browser's connection to a Jigs server. Create one per app, in its
|
|
87
87
|
* own module: each client has its own query cache, so two clients share no
|
|
88
88
|
* requests and no invalidations.
|
|
89
89
|
*/
|
|
90
|
-
export declare function
|
|
90
|
+
export declare function createJigsClient(options: ClientOptions): JigsClient;
|
|
91
91
|
//#endregion
|
|
92
92
|
export {};
|
package/dist/client.js
CHANGED
|
@@ -23,11 +23,11 @@ function toQueryResult(result) {
|
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* The browser's connection to a
|
|
26
|
+
* The browser's connection to a Jigs server. Create one per app, in its
|
|
27
27
|
* own module: each client has its own query cache, so two clients share no
|
|
28
28
|
* requests and no invalidations.
|
|
29
29
|
*/
|
|
30
|
-
function
|
|
30
|
+
function createJigsClient(options) {
|
|
31
31
|
const transport = options.transport === void 0 ? httpTransport(httpOptions(options)) : options.transport;
|
|
32
32
|
const queries = new QueryClient({ defaultOptions: {
|
|
33
33
|
mutations: { retry: false },
|
|
@@ -156,4 +156,4 @@ function httpOptions(options) {
|
|
|
156
156
|
return http;
|
|
157
157
|
}
|
|
158
158
|
//#endregion
|
|
159
|
-
export {
|
|
159
|
+
export { createJigsClient };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CallReply, HeadersOption, HttpTransportOptions, Transport, TransportError, httpTransport } from "./transport.js";
|
|
2
|
-
import { ClientOptions, ConfirmFunction,
|
|
3
|
-
import {
|
|
4
|
-
|
|
2
|
+
import { ClientOptions, ConfirmFunction, JigsClient, createJigsClient } from "./client.js";
|
|
3
|
+
import { ActionCall, ActionHandler, ViewController, ViewIssue, ViewStatus, createViewController, missingComponents } from "./view/controller.js";
|
|
4
|
+
import { Diagnostic, Invalidation, JigsErrorCode, JigsErrorData, Message, 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 JigsClient, type JigsErrorCode, type JigsErrorData, type Message, type Program, type SessionEvent, type Span, type Transport, TransportError, type ViewController, type ViewIssue, type ViewStatus, createJigsClient, createViewController, httpTransport, missingComponents };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { TransportError, httpTransport } from "./transport.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { createJigsClient } from "./client.js";
|
|
3
|
+
import { createViewController, missingComponents } from "./view/controller.js";
|
|
4
|
+
export { TransportError, createJigsClient, createViewController, httpTransport, missingComponents };
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Invalidation,
|
|
1
|
+
import { Invalidation, JigsErrorCode, JigsErrorData, Message, ProcedureKind, SessionEvent, SessionOptions } from "@jigs.design/runtime";
|
|
2
2
|
//#region src/transport.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* A call's reply: the procedure's data and, for a mutation, the queries the
|
|
@@ -16,7 +16,7 @@ export type CallReply = {
|
|
|
16
16
|
token: string;
|
|
17
17
|
};
|
|
18
18
|
};
|
|
19
|
-
/** How the browser reaches the server. Implement it to host
|
|
19
|
+
/** How the browser reaches the server. Implement it to host Jigs behind another router. */
|
|
20
20
|
export interface Transport {
|
|
21
21
|
/**
|
|
22
22
|
* Resolves with the reply, or rejects with a `TransportError`. `kind` is
|
|
@@ -43,18 +43,18 @@ export interface Transport {
|
|
|
43
43
|
}) => AsyncIterable<SessionEvent>;
|
|
44
44
|
}
|
|
45
45
|
/** A failure with a code the spec can read. */
|
|
46
|
-
export declare class TransportError extends Error implements
|
|
47
|
-
readonly code:
|
|
48
|
-
constructor(data:
|
|
46
|
+
export declare class TransportError extends Error implements JigsErrorData {
|
|
47
|
+
readonly code: JigsErrorCode;
|
|
48
|
+
constructor(data: JigsErrorData);
|
|
49
49
|
}
|
|
50
50
|
export type HeadersOption = HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
51
51
|
export interface HttpTransportOptions {
|
|
52
52
|
credentials?: RequestCredentials;
|
|
53
|
-
/** Path or URL where `
|
|
53
|
+
/** Path or URL where `jigs.handler` is mounted. */
|
|
54
54
|
endpoint: string;
|
|
55
55
|
fetch?: typeof fetch;
|
|
56
56
|
headers?: HeadersOption;
|
|
57
57
|
}
|
|
58
|
-
/** The default transport: `fetch` against `
|
|
58
|
+
/** The default transport: `fetch` against `jigs.handler`. */
|
|
59
59
|
export declare function httpTransport(options: HttpTransportOptions): Transport;
|
|
60
60
|
//#endregion
|
package/dist/transport.js
CHANGED
|
@@ -9,7 +9,7 @@ var TransportError = class extends Error {
|
|
|
9
9
|
this.name = "TransportError";
|
|
10
10
|
}
|
|
11
11
|
};
|
|
12
|
-
const VERSION_HEADER = "
|
|
12
|
+
const VERSION_HEADER = "jigs-version";
|
|
13
13
|
const GATEWAY_STATUSES = /* @__PURE__ */ new Set([
|
|
14
14
|
502,
|
|
15
15
|
503,
|
|
@@ -23,7 +23,7 @@ const network = {
|
|
|
23
23
|
code: "NETWORK",
|
|
24
24
|
message: "Could not reach the server."
|
|
25
25
|
};
|
|
26
|
-
/** The error a reply carries, or `UNAVAILABLE` when the reply is not one of
|
|
26
|
+
/** The error a reply carries, or `UNAVAILABLE` when the reply is not one of Jigs's. */
|
|
27
27
|
async function replyError(response) {
|
|
28
28
|
if (GATEWAY_STATUSES.has(response.status) || !response.headers.has(VERSION_HEADER)) return unavailable;
|
|
29
29
|
try {
|
|
@@ -40,7 +40,7 @@ function headersError(cause) {
|
|
|
40
40
|
message: `The request headers could not be built: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
|
-
/** The default transport: `fetch` against `
|
|
43
|
+
/** The default transport: `fetch` against `jigs.handler`. */
|
|
44
44
|
function httpTransport(options) {
|
|
45
45
|
const fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
46
46
|
const credentials = options.credentials ?? "same-origin";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { ConfirmFunction, JigsClient } 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: JigsClient): 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(`Jigs: ${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("Jigs: 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(`Jigs: ${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 \`jigs.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,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jigs.design/client",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "createJigsClient: the browser's transport and query cache. No React.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/yoamomonstruos/
|
|
8
|
+
"url": "git+https://github.com/yoamomonstruos/jigs.git",
|
|
9
9
|
"directory": "packages/client"
|
|
10
10
|
},
|
|
11
11
|
"type": "module",
|
|
@@ -26,6 +26,6 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@tanstack/query-core": "5.104.4",
|
|
29
|
-
"@jigs.design/runtime": "0.1.
|
|
29
|
+
"@jigs.design/runtime": "0.1.2"
|
|
30
30
|
}
|
|
31
31
|
}
|