iterate 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -76
- package/THIRD_PARTY_NOTICES.md +55 -0
- package/bin/iterate.js +18 -3
- package/dist/api-url-B6404M82.mjs +17 -0
- package/dist/api-url-B6404M82.mjs.map +1 -0
- package/dist/app-ref-BipL0feU.mjs +35 -0
- package/dist/app-ref-BipL0feU.mjs.map +1 -0
- package/dist/app-ref-C1CrgXqX.mjs +7 -0
- package/dist/app-ref-C1CrgXqX.mjs.map +1 -0
- package/dist/app-ref-DYai_om1.mjs +7 -0
- package/dist/app-ref-DYai_om1.mjs.map +1 -0
- package/dist/cli-D0c-pDL_.mjs +1010 -0
- package/dist/cli-D0c-pDL_.mjs.map +1 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.mjs +4 -0
- package/dist/cloudflare-BTm90gQ4.mjs +951 -0
- package/dist/cloudflare-BTm90gQ4.mjs.map +1 -0
- package/dist/contract-s4FW4eES.mjs +309 -0
- package/dist/contract-s4FW4eES.mjs.map +1 -0
- package/dist/document-review/index.d.ts +5 -0
- package/dist/document-review/types.d.ts +107 -0
- package/dist/document-review.mjs +7015 -0
- package/dist/document-review.mjs.map +1 -0
- package/dist/durable-object-processor-durability-CNsTjAJS.mjs +205 -0
- package/dist/durable-object-processor-durability-CNsTjAJS.mjs.map +1 -0
- package/dist/idempotency-DleloJNt.mjs +28 -0
- package/dist/idempotency-DleloJNt.mjs.map +1 -0
- package/dist/index.mjs +1 -1
- package/dist/itx/api-url.d.ts +6 -0
- package/dist/itx/itx-node-client.d.ts +65 -0
- package/dist/itx/itx-session.d.ts +215 -0
- package/dist/itx/owned-rpc-session.d.ts +14 -0
- package/dist/itx/query-client.d.ts +10 -0
- package/dist/itx-api.generated.d.ts +6195 -0
- package/dist/itx-session-sjud8GiT.mjs +534 -0
- package/dist/itx-session-sjud8GiT.mjs.map +1 -0
- package/dist/live-state-BJNqOwFw.mjs +299 -0
- package/dist/live-state-BJNqOwFw.mjs.map +1 -0
- package/dist/next/api.d.ts +479 -0
- package/dist/next/api.mjs +0 -0
- package/dist/next/app-server.d.ts +44 -0
- package/dist/next/app-server.mjs +479 -0
- package/dist/next/app-server.mjs.map +1 -0
- package/dist/next/app-session.d.ts +49 -0
- package/dist/next/app-session.mjs +238 -0
- package/dist/next/app-session.mjs.map +1 -0
- package/dist/next/app.d.ts +29 -0
- package/dist/next/app.mjs +141 -0
- package/dist/next/app.mjs.map +1 -0
- package/dist/next/client/live-state.d.ts +63 -0
- package/dist/next/client/oauth.d.ts +12 -0
- package/dist/next/client/react.d.ts +109 -0
- package/dist/next/client/socket.d.ts +6 -0
- package/dist/next/client.mjs +156 -0
- package/dist/next/client.mjs.map +1 -0
- package/dist/next/expression.d.ts +146 -0
- package/dist/next/expression.mjs +399 -0
- package/dist/next/expression.mjs.map +1 -0
- package/dist/next/lib.d.ts +56 -0
- package/dist/next/lib.mjs +199 -0
- package/dist/next/lib.mjs.map +1 -0
- package/dist/next/oauth-scopes.d.ts +32 -0
- package/dist/next/oauth-scopes.mjs +40 -0
- package/dist/next/oauth-scopes.mjs.map +1 -0
- package/dist/next/oauth.mjs +29 -0
- package/dist/next/oauth.mjs.map +1 -0
- package/dist/next/principal.d.ts +64 -0
- package/dist/next/principal.mjs +98 -0
- package/dist/next/principal.mjs.map +1 -0
- package/dist/next/project-ingress.d.ts +37 -0
- package/dist/next/project-ingress.mjs +75 -0
- package/dist/next/project-ingress.mjs.map +1 -0
- package/dist/next/react.mjs +285 -0
- package/dist/next/react.mjs.map +1 -0
- package/dist/next/sdk/auth.d.ts +5 -0
- package/dist/next/sdk/index.d.ts +112 -0
- package/dist/next/sdk.mjs +139 -0
- package/dist/next/sdk.mjs.map +1 -0
- package/dist/next/stream/processor.d.ts +378 -0
- package/dist/next/stream/processor.mjs +582 -0
- package/dist/next/stream/processor.mjs.map +1 -0
- package/dist/next/stream/run.d.ts +58 -0
- package/dist/next/stream/run.mjs +40 -0
- package/dist/next/stream/run.mjs.map +1 -0
- package/dist/next-node.d.ts +15 -0
- package/dist/next-node.mjs +51 -0
- package/dist/next-node.mjs.map +1 -0
- package/dist/node.d.ts +3 -0
- package/dist/node.mjs +185 -0
- package/dist/node.mjs.map +1 -0
- package/dist/processor-host-capabilities-BMFH3KTM.mjs +56 -0
- package/dist/processor-host-capabilities-BMFH3KTM.mjs.map +1 -0
- package/dist/processors/cloudflare.d.ts +3 -0
- package/dist/processors/durable-object-processor-durability.d.ts +79 -0
- package/dist/processors/event-consumption-metrics.d.ts +82 -0
- package/dist/processors/idempotency.d.ts +13 -0
- package/dist/processors/index.d.ts +12 -0
- package/dist/processors/processor-contracts.d.ts +342 -0
- package/dist/processors/processor-facet.d.ts +186 -0
- package/dist/processors/processor-host-capabilities.d.ts +60 -0
- package/dist/processors/prompt-sections.d.ts +17 -0
- package/dist/processors/rpc-types.d.ts +515 -0
- package/dist/processors/schemas.d.ts +102 -0
- package/dist/processors/stream-handle.d.ts +45 -0
- package/dist/processors/stream-processor-keepalive.d.ts +95 -0
- package/dist/processors/stream-processor-registry.d.ts +233 -0
- package/dist/processors/stream-processor-runner.d.ts +289 -0
- package/dist/processors/stream-processor.d.ts +339 -0
- package/dist/processors/stream-runtime-metrics.d.ts +107 -0
- package/dist/processors/testing.d.ts +302 -0
- package/dist/processors-BoNyeBfQ.mjs +10 -0
- package/dist/processors-BoNyeBfQ.mjs.map +1 -0
- package/dist/processors-cloudflare.mjs +3 -0
- package/dist/processors-testing.mjs +435 -0
- package/dist/processors-testing.mjs.map +1 -0
- package/dist/processors.mjs +52 -0
- package/dist/processors.mjs.map +1 -0
- package/dist/protocol-DnK_f2m6.mjs +251 -0
- package/dist/protocol-DnK_f2m6.mjs.map +1 -0
- package/dist/sdk/capnweb/index.d.ts +2 -0
- package/dist/sdk/capnweb/live-state/compact.d.ts +5 -0
- package/dist/sdk/capnweb/live-state/diff.d.ts +41 -0
- package/dist/sdk/capnweb/live-state/engine.d.ts +44 -0
- package/dist/sdk/capnweb/live-state/index.d.ts +41 -0
- package/dist/sdk/capnweb/live-state/protocol.d.ts +87 -0
- package/dist/sdk/capnweb/live-state/retain.d.ts +23 -0
- package/dist/sdk/capnweb/live-state/store.d.ts +20 -0
- package/dist/sdk/capnweb/live-state/types.d.ts +11 -0
- package/dist/sdk/capnweb/react.d.ts +45 -0
- package/dist/sdk/capnweb/react.mjs +316 -0
- package/dist/sdk/capnweb/react.mjs.map +1 -0
- package/dist/sdk/capnweb.mjs +4 -0
- package/dist/sdk/itx/react.d.ts +191 -0
- package/dist/sdk/itx/react.mjs +383 -0
- package/dist/sdk/itx/react.mjs.map +1 -0
- package/dist/sdk-DMB-IM11.mjs +933 -0
- package/dist/sdk-DMB-IM11.mjs.map +1 -0
- package/dist/sdk.d.ts +339 -0
- package/dist/sdk.mjs +2 -0
- package/dist/serve-itx.d.ts +46 -0
- package/dist/starter-apps/flake-dashboard/app-ref.d.ts +31 -0
- package/dist/starter-apps/flake-dashboard/configured-worker.mjs +1055 -0
- package/dist/starter-apps/flake-dashboard/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/flake-dashboard/contract.d.ts +4839 -0
- package/dist/starter-apps/flake-dashboard/contract.mjs +2 -0
- package/dist/starter-apps/flake-dashboard/index.d.ts +17 -0
- package/dist/starter-apps/flake-dashboard/index.mjs +56 -0
- package/dist/starter-apps/flake-dashboard/index.mjs.map +1 -0
- package/dist/starter-apps/flake-dashboard/worker.d.ts +4607 -0
- package/dist/starter-apps/github-ai-linter/ai-linter.d.ts +8914 -0
- package/dist/starter-apps/github-ai-linter/configured-worker.mjs +17987 -0
- package/dist/starter-apps/github-ai-linter/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/github-ai-linter/contract.d.ts +9193 -0
- package/dist/starter-apps/github-ai-linter/index.d.ts +10 -0
- package/dist/starter-apps/github-ai-linter/index.mjs +36 -0
- package/dist/starter-apps/github-ai-linter/index.mjs.map +1 -0
- package/dist/starter-apps/github-ai-linter/prompt.d.ts +13 -0
- package/dist/starter-apps/github-ai-linter/review-bot.d.ts +808 -0
- package/dist/starter-apps/github-ai-linter/rules.d.ts +34 -0
- package/dist/starter-apps/github-ai-linter/worker-ref.d.ts +19 -0
- package/dist/starter-apps/github-ai-linter/worker.d.ts +19 -0
- package/dist/starter-apps/github-ai-linter/worker.mjs +947 -0
- package/dist/starter-apps/github-ai-linter/worker.mjs.map +1 -0
- package/dist/starter-apps/guestbook/app-ref.d.ts +27 -0
- package/dist/starter-apps/guestbook/client.d.ts +7 -0
- package/dist/starter-apps/guestbook/client.mjs +59 -0
- package/dist/starter-apps/guestbook/configured-worker.mjs +205 -0
- package/dist/starter-apps/guestbook/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/guestbook/index.d.ts +9 -0
- package/dist/starter-apps/guestbook/index.mjs +31 -0
- package/dist/starter-apps/guestbook/index.mjs.map +1 -0
- package/dist/starter-apps/guestbook/processor.d.ts +2267 -0
- package/dist/starter-apps/guestbook/worker.d.ts +26 -0
- package/dist/starter-apps/guestbook/worker.mjs +191 -0
- package/dist/starter-apps/guestbook/worker.mjs.map +1 -0
- package/dist/starter-apps/media/configured-worker.mjs +577 -0
- package/dist/starter-apps/media/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/media/index.mjs +36 -0
- package/dist/starter-apps/media/index.mjs.map +1 -0
- package/dist/starter-apps/media/ref.mjs +20 -0
- package/dist/starter-apps/media/ref.mjs.map +1 -0
- package/dist/starter-apps/media/worker.mjs +579 -0
- package/dist/starter-apps/media/worker.mjs.map +1 -0
- package/dist/starter-apps/notes/configured-worker.mjs +6134 -0
- package/dist/starter-apps/notes/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/notes/index.mjs +23 -0
- package/dist/starter-apps/notes/index.mjs.map +1 -0
- package/dist/starter-apps/notes/ref.mjs +21 -0
- package/dist/starter-apps/notes/ref.mjs.map +1 -0
- package/dist/starter-apps/notes/worker.mjs +427 -0
- package/dist/starter-apps/notes/worker.mjs.map +1 -0
- package/dist/starter-apps/todo/client.mjs +59 -0
- package/dist/starter-apps/todo/configured-worker.mjs +2864 -0
- package/dist/starter-apps/todo/configured-worker.mjs.map +1 -0
- package/dist/starter-apps/todo/index.d.ts +8 -0
- package/dist/starter-apps/todo/index.mjs +29 -0
- package/dist/starter-apps/todo/index.mjs.map +1 -0
- package/dist/stream-processor-keepalive-DAQTP6m3.mjs +2082 -0
- package/dist/stream-processor-keepalive-DAQTP6m3.mjs.map +1 -0
- package/dist/usingCtx-inzbY1Qz.mjs +57 -0
- package/dist/usingCtx-mZx5nsAW.mjs +11800 -0
- package/dist/usingCtx-mZx5nsAW.mjs.map +1 -0
- package/dist/worker-ref-DZxPDmb_.mjs +390 -0
- package/dist/worker-ref-DZxPDmb_.mjs.map +1 -0
- package/menubar/Iterate.entitlements +12 -0
- package/menubar/Iterate.swift +914 -0
- package/menubar/IterateIcon.swift +145 -0
- package/menubar/README.md +28 -0
- package/menubar/build-menubar-app.sh +59 -0
- package/package.json +235 -18
- package/dist/cli-DMS4kJph.mjs +0 -868
- package/dist/cli-DMS4kJph.mjs.map +0 -1
- package/dist/config-DtnR7Lv7.mjs +0 -170
- package/dist/config-DtnR7Lv7.mjs.map +0 -1
- package/dist/index.d.mts.map +0 -1
- package/dist/stream-tui/agent-chat-terminal.d.mts +0 -1
- package/dist/stream-tui/agent-chat-terminal.mjs +0 -933
- package/dist/stream-tui/agent-chat-terminal.mjs.map +0 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { isLocalOrigin } from "./lib.mjs";
|
|
2
|
+
import { OAuthScopes } from "./oauth-scopes.mjs";
|
|
3
|
+
import { t as _usingCtx } from "../usingCtx-inzbY1Qz.mjs";
|
|
4
|
+
import { authorizationCodeRequest } from "./oauth.mjs";
|
|
5
|
+
import { DurableObject } from "cloudflare:workers";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { newHttpBatchRpcSession } from "capnweb";
|
|
8
|
+
import * as oauth from "oauth4webapi";
|
|
9
|
+
//#region src/next/app-session.ts
|
|
10
|
+
/** Each app binds this same class. Token exchange and logout use the issuer's
|
|
11
|
+
* public protocol, so separately deployed apps need no platform bindings.
|
|
12
|
+
* The input gate serializes refresh and logout across tabs. */
|
|
13
|
+
var BrowserSession = class extends DurableObject {
|
|
14
|
+
begin(host, next) {
|
|
15
|
+
return this.#serial(async () => {
|
|
16
|
+
if (await this.ctx.storage.get("session")) throw new Error("Browser session already exists");
|
|
17
|
+
const origin = new URL(host.origin);
|
|
18
|
+
const local = isLocalOrigin(host.origin);
|
|
19
|
+
if (origin.protocol !== "https:" && !local) throw new Error("Browser login requires HTTPS");
|
|
20
|
+
let clientId = host.client?.id || `${host.origin}/.auth/client.json`;
|
|
21
|
+
if (local) {
|
|
22
|
+
const response = await fetch(`${host.issuer}/oauth2/register`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
client_name: host.client?.name || origin.host,
|
|
27
|
+
client_uri: host.origin,
|
|
28
|
+
logo_uri: host.client?.logoUri,
|
|
29
|
+
redirect_uris: [`${host.origin}/.auth/callback`],
|
|
30
|
+
token_endpoint_auth_method: "none",
|
|
31
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
32
|
+
response_types: ["code"]
|
|
33
|
+
}),
|
|
34
|
+
signal: AbortSignal.timeout(1e4)
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) throw new Error(`Local client registration failed (${response.status})`);
|
|
37
|
+
clientId = z.object({ client_id: z.string().min(1) }).parse(await response.json()).client_id;
|
|
38
|
+
}
|
|
39
|
+
const { url, state, verifier } = await authorizationCodeRequest({
|
|
40
|
+
issuer: host.issuer,
|
|
41
|
+
clientId,
|
|
42
|
+
redirectUri: `${host.origin}/.auth/callback`,
|
|
43
|
+
resources: [host.resource],
|
|
44
|
+
scopes: host.scopes
|
|
45
|
+
});
|
|
46
|
+
const data = {
|
|
47
|
+
...host,
|
|
48
|
+
next,
|
|
49
|
+
clientId,
|
|
50
|
+
phase: "pending",
|
|
51
|
+
state,
|
|
52
|
+
verifier,
|
|
53
|
+
until: Date.now() + 10 * 6e4
|
|
54
|
+
};
|
|
55
|
+
await this.ctx.storage.put("session", data);
|
|
56
|
+
await this.ctx.storage.setAlarm(data.until);
|
|
57
|
+
return url.href;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** The issuer's redirect back to `/.auth/callback`, its query string. oauth4webapi validates it
|
|
61
|
+
* (the state matches this browser's pending flow, the `iss` matches the issuer, no `error`), then
|
|
62
|
+
* exchanges the code for tokens. */
|
|
63
|
+
complete(callbackQuery) {
|
|
64
|
+
return this.#serial(async () => {
|
|
65
|
+
const data = await this.ctx.storage.get("session");
|
|
66
|
+
if (!data || data.phase !== "pending" || data.until <= Date.now()) return { error: "This sign-in expired or does not match this browser. Start sign-in again." };
|
|
67
|
+
const as = {
|
|
68
|
+
issuer: data.issuer,
|
|
69
|
+
token_endpoint: `${data.issuer}/oauth2/token`,
|
|
70
|
+
authorization_response_iss_parameter_supported: true
|
|
71
|
+
};
|
|
72
|
+
const client = { client_id: data.clientId };
|
|
73
|
+
let callback;
|
|
74
|
+
try {
|
|
75
|
+
callback = oauth.validateAuthResponse(as, client, new URLSearchParams(callbackQuery), data.state);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof oauth.AuthorizationResponseError) {
|
|
78
|
+
await this.#clear();
|
|
79
|
+
return { error: "Authorization was declined." };
|
|
80
|
+
}
|
|
81
|
+
return { error: "This sign-in expired or does not match this browser. Start sign-in again." };
|
|
82
|
+
}
|
|
83
|
+
const started = Date.now();
|
|
84
|
+
let tokens;
|
|
85
|
+
try {
|
|
86
|
+
const response = await oauth.authorizationCodeGrantRequest(as, client, oauth.None(), callback, `${data.origin}/.auth/callback`, data.verifier, this.#tokenOptions(data));
|
|
87
|
+
tokens = await oauth.processAuthorizationCodeResponse(as, client, response);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
await this.#endOnDeadGrant(error);
|
|
90
|
+
return { error: "Sign-in could not complete. Start sign-in again." };
|
|
91
|
+
}
|
|
92
|
+
await this.#activate(data, tokens, started);
|
|
93
|
+
return { next: data.next };
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
bearer() {
|
|
97
|
+
return this.#serial(() => this.#bearer());
|
|
98
|
+
}
|
|
99
|
+
/** THE ISSUER THIS SESSION IS BOUND TO and the resource its tokens are for — written once at
|
|
100
|
+
* `begin`, never steered by a request: the app's `/api` proxy, its login probe and its logout read
|
|
101
|
+
* them from here, so a browser connected to one issuer can only ever spend its credential there.
|
|
102
|
+
* Null when no session was begun. */
|
|
103
|
+
async host() {
|
|
104
|
+
const data = await this.ctx.storage.get("session");
|
|
105
|
+
return data ? {
|
|
106
|
+
issuer: data.issuer,
|
|
107
|
+
resource: data.resource
|
|
108
|
+
} : null;
|
|
109
|
+
}
|
|
110
|
+
async scopes() {
|
|
111
|
+
const data = await this.ctx.storage.get("session");
|
|
112
|
+
return data?.phase === "active" ? data.scopes : [];
|
|
113
|
+
}
|
|
114
|
+
/** Public client metadata chosen by the app before consent; never a browser-supplied claim. */
|
|
115
|
+
async client() {
|
|
116
|
+
const data = await this.ctx.storage.get("session");
|
|
117
|
+
return data?.phase === "active" ? data.client : void 0;
|
|
118
|
+
}
|
|
119
|
+
/** A verified 401 means this local credential no longer grants access. */
|
|
120
|
+
discard() {
|
|
121
|
+
return this.#serial(() => this.#clear());
|
|
122
|
+
}
|
|
123
|
+
end() {
|
|
124
|
+
return this.#serial(async () => {
|
|
125
|
+
const token = await this.#bearer();
|
|
126
|
+
const data = await this.ctx.storage.get("session");
|
|
127
|
+
if (token && data) {
|
|
128
|
+
const probe = await fetch(new Request(data.resource, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
131
|
+
body: "",
|
|
132
|
+
signal: AbortSignal.timeout(1e4)
|
|
133
|
+
}));
|
|
134
|
+
await probe.body?.cancel();
|
|
135
|
+
if (probe.status !== 401) try {
|
|
136
|
+
var _usingCtx$1 = _usingCtx();
|
|
137
|
+
if (!probe.ok) throw new Error(`Sign-out could not reach Iterate (${probe.status}). Try again.`);
|
|
138
|
+
const api = _usingCtx$1.u(newHttpBatchRpcSession(new Request(data.resource, {
|
|
139
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
140
|
+
signal: AbortSignal.timeout(1e4)
|
|
141
|
+
})));
|
|
142
|
+
await _usingCtx$1.u(api.authenticate({ type: "from-server-cookie" })).logout();
|
|
143
|
+
} catch (_) {
|
|
144
|
+
_usingCtx$1.e = _;
|
|
145
|
+
} finally {
|
|
146
|
+
_usingCtx$1.d();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
await this.#clear();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async alarm() {
|
|
153
|
+
await this.#clear();
|
|
154
|
+
}
|
|
155
|
+
async #bearer() {
|
|
156
|
+
const data = await this.ctx.storage.get("session");
|
|
157
|
+
if (!data || data.phase !== "active") return null;
|
|
158
|
+
if (data.until <= Date.now()) {
|
|
159
|
+
await this.#clear();
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (data.expiresAt > Date.now() + 3e4) return data.accessToken;
|
|
163
|
+
const as = {
|
|
164
|
+
issuer: data.issuer,
|
|
165
|
+
token_endpoint: `${data.issuer}/oauth2/token`
|
|
166
|
+
};
|
|
167
|
+
const client = { client_id: data.clientId };
|
|
168
|
+
const started = Date.now();
|
|
169
|
+
let tokens;
|
|
170
|
+
try {
|
|
171
|
+
const response = await oauth.refreshTokenGrantRequest(as, client, oauth.None(), data.refreshToken, this.#tokenOptions(data));
|
|
172
|
+
tokens = await oauth.processRefreshTokenResponse(as, client, response);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
await this.#endOnDeadGrant(error);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return (await this.#activate(data, tokens, started)).accessToken;
|
|
178
|
+
}
|
|
179
|
+
/** The token request's shared options: the audience (RFC 8707 resource), a bounded timeout, and —
|
|
180
|
+
* only for a LOCAL APP (`begin` already admits http there) — oauth4webapi's opt-out of its
|
|
181
|
+
* HTTPS-only default. Keyed on the app's origin, never the issuer's: an issuer a person typed can
|
|
182
|
+
* not talk a deployed app into sending its codes in the clear. */
|
|
183
|
+
#tokenOptions(data) {
|
|
184
|
+
const options = {
|
|
185
|
+
additionalParameters: { resource: data.resource },
|
|
186
|
+
signal: AbortSignal.timeout(1e4)
|
|
187
|
+
};
|
|
188
|
+
if (isLocalOrigin(data.origin)) options[oauth.allowInsecureRequests] = true;
|
|
189
|
+
return options;
|
|
190
|
+
}
|
|
191
|
+
/** Write the active session from a token response. A refresh reuses the prior refresh token when
|
|
192
|
+
* the issuer does not rotate it. */
|
|
193
|
+
async #activate(data, tokens, started) {
|
|
194
|
+
const refreshToken = tokens.refresh_token || (data.phase === "active" ? data.refreshToken : void 0);
|
|
195
|
+
if (!refreshToken) throw new Error("Iterate returned no refresh token.");
|
|
196
|
+
const { origin, issuer, resource, clientId, next } = data;
|
|
197
|
+
const stored = {
|
|
198
|
+
origin,
|
|
199
|
+
issuer,
|
|
200
|
+
resource,
|
|
201
|
+
clientId,
|
|
202
|
+
client: data.client,
|
|
203
|
+
next,
|
|
204
|
+
phase: "active",
|
|
205
|
+
scopes: tokens.scope ? OAuthScopes.parse(tokens.scope.split(" ").filter(Boolean)) : data.scopes,
|
|
206
|
+
accessToken: tokens.access_token,
|
|
207
|
+
refreshToken,
|
|
208
|
+
expiresAt: started + (tokens.expires_in ?? 0) * 1e3,
|
|
209
|
+
until: data.phase === "pending" ? started + 720 * 36e5 : data.until
|
|
210
|
+
};
|
|
211
|
+
await this.ctx.storage.put("session", stored);
|
|
212
|
+
await this.ctx.storage.setAlarm(stored.until);
|
|
213
|
+
return stored;
|
|
214
|
+
}
|
|
215
|
+
/** A dead code or refresh token (`invalid_grant`) ends the session; other failures are transient. */
|
|
216
|
+
async #endOnDeadGrant(error) {
|
|
217
|
+
if (error instanceof oauth.ResponseBodyError && error.error === "invalid_grant") {
|
|
218
|
+
await this.#clear();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
throw error instanceof oauth.ResponseBodyError ? /* @__PURE__ */ new Error(`Iterate token exchange failed (${error.status}). Try again.`) : error;
|
|
222
|
+
}
|
|
223
|
+
/** An operation failure must not reset the DO or fail other tabs' requests. */
|
|
224
|
+
#serial(work) {
|
|
225
|
+
return this.ctx.blockConcurrencyWhile(() => work().then((value) => ({ value }), (error) => ({ error }))).then((result) => {
|
|
226
|
+
if ("error" in result) throw result.error;
|
|
227
|
+
return result.value;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
async #clear() {
|
|
231
|
+
await this.ctx.storage.deleteAll();
|
|
232
|
+
await this.ctx.storage.deleteAlarm();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
//#endregion
|
|
236
|
+
export { BrowserSession };
|
|
237
|
+
|
|
238
|
+
//# sourceMappingURL=app-session.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app-session.mjs","names":["#serial","#clear","#tokenOptions","#endOnDeadGrant","#activate","#bearer"],"sources":["../../src/next/app-session.ts"],"sourcesContent":["import { DurableObject } from \"cloudflare:workers\";\n// eslint-disable-next-line iterate/no-capnweb-http-batch -- One bounded logout request, no live capabilities.\nimport { newHttpBatchRpcSession } from \"capnweb\";\nimport * as oauth from \"oauth4webapi\";\nimport { z } from \"zod\";\nimport { authorizationCodeRequest } from \"./client/oauth.ts\";\nimport { OAuthScopes } from \"./oauth-scopes.ts\";\nimport { isLocalOrigin } from \"./lib.ts\";\nimport type { IterateApi } from \"./api.ts\";\n\nexport type BrowserHost = {\n origin: string;\n issuer: string;\n resource: string;\n scopes: string[];\n client?: { id?: string; name: string; logoUri: string };\n};\ntype Base = BrowserHost & { clientId: string; next: string; until: number };\ntype Pending = Base & { phase: \"pending\"; state: string; verifier: string };\ntype Active = Base & {\n phase: \"active\";\n accessToken: string;\n refreshToken: string;\n expiresAt: number;\n};\ntype StoredSession = Pending | Active;\n\n/** Each app binds this same class. Token exchange and logout use the issuer's\n * public protocol, so separately deployed apps need no platform bindings.\n * The input gate serializes refresh and logout across tabs. */\nexport class BrowserSession extends DurableObject {\n begin(host: BrowserHost, next: string) {\n return this.#serial(async () => {\n if (await this.ctx.storage.get(\"session\")) throw new Error(\"Browser session already exists\");\n const origin = new URL(host.origin);\n const local = isLocalOrigin(host.origin);\n if (origin.protocol !== \"https:\" && !local) throw new Error(\"Browser login requires HTTPS\");\n let clientId = host.client?.id || `${host.origin}/.auth/client.json`;\n if (local) {\n const response = await fetch(`${host.issuer}/oauth2/register`, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n client_name: host.client?.name || origin.host,\n client_uri: host.origin,\n logo_uri: host.client?.logoUri,\n redirect_uris: [`${host.origin}/.auth/callback`],\n token_endpoint_auth_method: \"none\",\n grant_types: [\"authorization_code\", \"refresh_token\"],\n response_types: [\"code\"],\n }),\n signal: AbortSignal.timeout(10_000),\n });\n if (!response.ok) throw new Error(`Local client registration failed (${response.status})`);\n clientId = z\n .object({ client_id: z.string().min(1) })\n .parse(await response.json()).client_id;\n }\n const { url, state, verifier } = await authorizationCodeRequest({\n issuer: host.issuer,\n clientId,\n redirectUri: `${host.origin}/.auth/callback`,\n resources: [host.resource],\n scopes: host.scopes,\n });\n const data: Pending = {\n ...host,\n next,\n clientId,\n phase: \"pending\",\n state,\n verifier,\n until: Date.now() + 10 * 60_000,\n };\n await this.ctx.storage.put(\"session\", data);\n await this.ctx.storage.setAlarm(data.until);\n return url.href;\n });\n }\n\n /** The issuer's redirect back to `/.auth/callback`, its query string. oauth4webapi validates it\n * (the state matches this browser's pending flow, the `iss` matches the issuer, no `error`), then\n * exchanges the code for tokens. */\n complete(callbackQuery: string) {\n return this.#serial(async () => {\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n if (!data || data.phase !== \"pending\" || data.until <= Date.now())\n return {\n error: \"This sign-in expired or does not match this browser. Start sign-in again.\",\n };\n const as: oauth.AuthorizationServer = {\n issuer: data.issuer,\n token_endpoint: `${data.issuer}/oauth2/token`,\n authorization_response_iss_parameter_supported: true,\n };\n const client: oauth.Client = { client_id: data.clientId };\n let callback: URLSearchParams;\n try {\n callback = oauth.validateAuthResponse(\n as,\n client,\n new URLSearchParams(callbackQuery),\n data.state,\n );\n } catch (error) {\n // A provider `error=` (the person declined) ends the flow; a state/iss mismatch leaves the\n // pending flow to be retried from a genuine browser.\n if (error instanceof oauth.AuthorizationResponseError) {\n await this.#clear();\n return { error: \"Authorization was declined.\" };\n }\n return {\n error: \"This sign-in expired or does not match this browser. Start sign-in again.\",\n };\n }\n const started = Date.now();\n let tokens: oauth.TokenEndpointResponse;\n try {\n const response = await oauth.authorizationCodeGrantRequest(\n as,\n client,\n oauth.None(),\n callback,\n `${data.origin}/.auth/callback`,\n data.verifier,\n this.#tokenOptions(data),\n );\n tokens = await oauth.processAuthorizationCodeResponse(as, client, response);\n } catch (error) {\n await this.#endOnDeadGrant(error);\n return { error: \"Sign-in could not complete. Start sign-in again.\" };\n }\n await this.#activate(data, tokens, started);\n return { next: data.next };\n });\n }\n\n bearer() {\n return this.#serial(() => this.#bearer());\n }\n /** THE ISSUER THIS SESSION IS BOUND TO and the resource its tokens are for — written once at\n * `begin`, never steered by a request: the app's `/api` proxy, its login probe and its logout read\n * them from here, so a browser connected to one issuer can only ever spend its credential there.\n * Null when no session was begun. */\n async host(): Promise<{ issuer: string; resource: string } | null> {\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n return data ? { issuer: data.issuer, resource: data.resource } : null;\n }\n async scopes() {\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n return data?.phase === \"active\" ? data.scopes : [];\n }\n /** Public client metadata chosen by the app before consent; never a browser-supplied claim. */\n async client() {\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n return data?.phase === \"active\" ? data.client : undefined;\n }\n /** A verified 401 means this local credential no longer grants access. */\n discard() {\n return this.#serial(() => this.#clear());\n }\n\n end() {\n return this.#serial(async () => {\n const token = await this.#bearer();\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n if (token && data) {\n // Probe classifies a revoked/expired credential without interpreting RPC error text.\n const probe = await fetch(\n new Request(data.resource, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${token}` },\n body: \"\",\n signal: AbortSignal.timeout(10_000),\n }),\n );\n await probe.body?.cancel();\n if (probe.status !== 401) {\n if (!probe.ok)\n throw new Error(`Sign-out could not reach Iterate (${probe.status}). Try again.`);\n // eslint-disable-next-line iterate/no-capnweb-http-batch -- A bounded logout command returns no live capabilities.\n using api = newHttpBatchRpcSession<IterateApi>(\n new Request(data.resource, {\n headers: { Authorization: `Bearer ${token}` },\n signal: AbortSignal.timeout(10_000),\n }),\n );\n // `authenticate(...)` returns a SessionRpcTarget STUB — its own RPC result to dispose (the\n // capnweb README's `using authedApi = api.authenticate(...)`); disposing only `api` leaves\n // it dangling and workerd warns the result was never disposed.\n using session = api.authenticate({ type: \"from-server-cookie\" });\n await session.logout();\n }\n }\n // Network failures preserve the session and are shown to the caller.\n // Successful logout has written the issuer's durable revocation marker.\n await this.#clear();\n });\n }\n\n async alarm() {\n // Pending flows expire in ten minutes; active grants have the same absolute\n // thirty-day lifetime at the issuer. No refresh token survives this bound.\n await this.#clear();\n }\n async #bearer() {\n const data = await this.ctx.storage.get<StoredSession>(\"session\");\n if (!data || data.phase !== \"active\") return null;\n if (data.until <= Date.now()) {\n await this.#clear();\n return null;\n }\n if (data.expiresAt > Date.now() + 30_000) return data.accessToken;\n const as: oauth.AuthorizationServer = {\n issuer: data.issuer,\n token_endpoint: `${data.issuer}/oauth2/token`,\n };\n const client: oauth.Client = { client_id: data.clientId };\n const started = Date.now();\n let tokens: oauth.TokenEndpointResponse;\n try {\n const response = await oauth.refreshTokenGrantRequest(\n as,\n client,\n oauth.None(),\n data.refreshToken,\n this.#tokenOptions(data),\n );\n tokens = await oauth.processRefreshTokenResponse(as, client, response);\n } catch (error) {\n await this.#endOnDeadGrant(error);\n return null;\n }\n return (await this.#activate(data, tokens, started)).accessToken;\n }\n\n /** The token request's shared options: the audience (RFC 8707 resource), a bounded timeout, and —\n * only for a LOCAL APP (`begin` already admits http there) — oauth4webapi's opt-out of its\n * HTTPS-only default. Keyed on the app's origin, never the issuer's: an issuer a person typed can\n * not talk a deployed app into sending its codes in the clear. */\n #tokenOptions(data: StoredSession): oauth.TokenEndpointRequestOptions {\n const options: oauth.TokenEndpointRequestOptions = {\n additionalParameters: { resource: data.resource },\n signal: AbortSignal.timeout(10_000),\n };\n if (isLocalOrigin(data.origin)) options[oauth.allowInsecureRequests] = true;\n return options;\n }\n\n /** Write the active session from a token response. A refresh reuses the prior refresh token when\n * the issuer does not rotate it. */\n async #activate(\n data: StoredSession,\n tokens: oauth.TokenEndpointResponse,\n started: number,\n ): Promise<Active> {\n const refreshToken =\n tokens.refresh_token || (data.phase === \"active\" ? data.refreshToken : undefined);\n if (!refreshToken) throw new Error(\"Iterate returned no refresh token.\");\n const { origin, issuer, resource, clientId, next } = data;\n const stored: Active = {\n origin,\n issuer,\n resource,\n clientId,\n client: data.client,\n next,\n phase: \"active\",\n // An omitted (or empty) `scope` means unchanged (RFC 6749 §5.1) — keep what the grant already\n // holds rather than silently narrow to `iterate`.\n scopes: tokens.scope\n ? OAuthScopes.parse(tokens.scope.split(\" \").filter(Boolean))\n : data.scopes,\n accessToken: tokens.access_token,\n refreshToken,\n expiresAt: started + (tokens.expires_in ?? 0) * 1000,\n until: data.phase === \"pending\" ? started + 30 * 24 * 3600_000 : data.until,\n };\n await this.ctx.storage.put(\"session\", stored);\n await this.ctx.storage.setAlarm(stored.until);\n return stored;\n }\n\n /** A dead code or refresh token (`invalid_grant`) ends the session; other failures are transient. */\n async #endOnDeadGrant(error: unknown): Promise<void> {\n if (error instanceof oauth.ResponseBodyError && error.error === \"invalid_grant\") {\n await this.#clear();\n return;\n }\n throw error instanceof oauth.ResponseBodyError\n ? new Error(`Iterate token exchange failed (${error.status}). Try again.`)\n : error;\n }\n /** An operation failure must not reset the DO or fail other tabs' requests. */\n #serial<T>(work: () => Promise<T>): Promise<T> {\n return this.ctx\n .blockConcurrencyWhile(() =>\n work().then(\n (value) => ({ value }),\n (error: unknown) => ({ error }),\n ),\n )\n .then((result) => {\n if (\"error\" in result) throw result.error;\n return result.value;\n });\n }\n\n async #clear() {\n await this.ctx.storage.deleteAll();\n await this.ctx.storage.deleteAlarm();\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type RpcStub } from "capnweb";
|
|
2
|
+
import type { IterateSessionApi } from "./api.ts";
|
|
3
|
+
/** What `authenticate` resolves to: the session as a capnweb stub (pipelined; disposable) and the
|
|
4
|
+
* bootstrap info it answered with. Declared, so the package's declarations stay serializable. */
|
|
5
|
+
export type AuthenticatedApp = {
|
|
6
|
+
api: RpcStub<IterateSessionApi>;
|
|
7
|
+
info: ReturnType<IterateSessionApi["info"]>;
|
|
8
|
+
/** The page names a project this sign-in does not include — its `/projects/<ref>` is absent from
|
|
9
|
+
* `api.projects.list()`: leave for `/.auth/login`, whose page offers to sign in again (the
|
|
10
|
+
* project ticked at consent, or another account) and returns to this very URL. Never settles,
|
|
11
|
+
* like `authenticate`'s own leave for login. */
|
|
12
|
+
signInFor(project: string): Promise<never>;
|
|
13
|
+
};
|
|
14
|
+
export type IterateClient = {
|
|
15
|
+
authenticate(next?: string): Promise<AuthenticatedApp>;
|
|
16
|
+
};
|
|
17
|
+
/** Create once per app — a TanStack route's client-only `beforeLoad`, or a plain page's entry.
|
|
18
|
+
* Every loader and action of the page shares the returned public RPC session.
|
|
19
|
+
*
|
|
20
|
+
* Connecting tries for a while (client/socket.ts: ≈16 s of attempts) before an error reaches the
|
|
21
|
+
* page — a phone waking up or a flapping tunnel is not a reason to show "connection failed". And
|
|
22
|
+
* the `api` the page holds is a proxy to the CURRENT connection: when the socket closes, the next
|
|
23
|
+
* call opens a fresh one and pipelines onto it, so a dropped connection costs a reconnect, not the
|
|
24
|
+
* page. A reconnect the platform refuses (the session ended elsewhere) rejects that call; the
|
|
25
|
+
* page's retry runs `authenticate` again — a fresh one, the socket's close forgot the last — whose
|
|
26
|
+
* probe sends the browser to log in. */
|
|
27
|
+
export declare function createIterateClient(options?: {
|
|
28
|
+
scopes?: string[];
|
|
29
|
+
}): IterateClient;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { OAuthScopes } from "./oauth-scopes.mjs";
|
|
2
|
+
import { newWebSocketRpcSession } from "capnweb";
|
|
3
|
+
//#region src/next/client/socket.ts
|
|
4
|
+
/** Open a WebSocket, trying again for a while when the connection fails: a phone waking up, a
|
|
5
|
+
* tunnel flapping, a cold edge — the things a first attempt trips over. Resolves with the socket
|
|
6
|
+
* once it is OPEN; rejects with the last failure after the last attempt. The delays are the
|
|
7
|
+
* waits BETWEEN attempts (the first is immediate). */
|
|
8
|
+
const RETRY_DELAYS_MS = [
|
|
9
|
+
250,
|
|
10
|
+
500,
|
|
11
|
+
1e3,
|
|
12
|
+
2e3,
|
|
13
|
+
4e3,
|
|
14
|
+
8e3
|
|
15
|
+
];
|
|
16
|
+
function openSocketWithRetry(url, options = {}) {
|
|
17
|
+
const delays = options.delaysMs || RETRY_DELAYS_MS;
|
|
18
|
+
const Socket = options.WebSocket || WebSocket;
|
|
19
|
+
const sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
20
|
+
const once = () => new Promise((resolve, reject) => {
|
|
21
|
+
const socket = new Socket(url);
|
|
22
|
+
const opened = () => {
|
|
23
|
+
socket.removeEventListener("close", failed);
|
|
24
|
+
resolve(socket);
|
|
25
|
+
};
|
|
26
|
+
const failed = () => {
|
|
27
|
+
socket.removeEventListener("open", opened);
|
|
28
|
+
reject(/* @__PURE__ */ new Error("WebSocket connection failed."));
|
|
29
|
+
};
|
|
30
|
+
socket.addEventListener("open", opened, { once: true });
|
|
31
|
+
socket.addEventListener("close", failed, { once: true });
|
|
32
|
+
});
|
|
33
|
+
return (async () => {
|
|
34
|
+
let attempt = 0;
|
|
35
|
+
for (;;) try {
|
|
36
|
+
return await once();
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (attempt >= delays.length) throw error;
|
|
39
|
+
await sleep(delays[attempt]);
|
|
40
|
+
attempt += 1;
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/next/app.ts
|
|
46
|
+
/** The browser leaves for the issuer's login (a document navigation) and this never settles — no
|
|
47
|
+
* framework in the loop: a TanStack `beforeLoad` awaiting it ends the way a thrown
|
|
48
|
+
* `redirect({ reloadDocument: true })` did, a plain page simply navigates. */
|
|
49
|
+
function leaveForLogin(login) {
|
|
50
|
+
window.location.assign(login);
|
|
51
|
+
return new Promise(() => {});
|
|
52
|
+
}
|
|
53
|
+
function connectionOn(socket) {
|
|
54
|
+
const iterate = newWebSocketRpcSession(socket);
|
|
55
|
+
return {
|
|
56
|
+
api: iterate.authenticate({ type: "from-server-cookie" }),
|
|
57
|
+
socket,
|
|
58
|
+
dispose: () => iterate[Symbol.dispose]()
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Create once per app — a TanStack route's client-only `beforeLoad`, or a plain page's entry.
|
|
62
|
+
* Every loader and action of the page shares the returned public RPC session.
|
|
63
|
+
*
|
|
64
|
+
* Connecting tries for a while (client/socket.ts: ≈16 s of attempts) before an error reaches the
|
|
65
|
+
* page — a phone waking up or a flapping tunnel is not a reason to show "connection failed". And
|
|
66
|
+
* the `api` the page holds is a proxy to the CURRENT connection: when the socket closes, the next
|
|
67
|
+
* call opens a fresh one and pipelines onto it, so a dropped connection costs a reconnect, not the
|
|
68
|
+
* page. A reconnect the platform refuses (the session ended elsewhere) rejects that call; the
|
|
69
|
+
* page's retry runs `authenticate` again — a fresh one, the socket's close forgot the last — whose
|
|
70
|
+
* probe sends the browser to log in. */
|
|
71
|
+
function createIterateClient(options = {}) {
|
|
72
|
+
const scopes = OAuthScopes.parse(options.scopes || []);
|
|
73
|
+
let live = null;
|
|
74
|
+
let connecting;
|
|
75
|
+
const socketUrl = () => {
|
|
76
|
+
const url = new URL("/api", window.location.href);
|
|
77
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
78
|
+
return url;
|
|
79
|
+
};
|
|
80
|
+
/** Adopt a connection as the live one until its socket closes. A close also forgets the settled
|
|
81
|
+
* `authenticate` — the next one probes `/api` again, so a session that ended elsewhere sends the
|
|
82
|
+
* browser to log in instead of a retry that can only fail. */
|
|
83
|
+
function adopt(socket) {
|
|
84
|
+
const connection = connectionOn(socket);
|
|
85
|
+
live = connection;
|
|
86
|
+
const forget = () => {
|
|
87
|
+
if (live !== connection) return;
|
|
88
|
+
live = null;
|
|
89
|
+
connecting = void 0;
|
|
90
|
+
};
|
|
91
|
+
const dispose = () => {
|
|
92
|
+
forget();
|
|
93
|
+
connection.dispose();
|
|
94
|
+
};
|
|
95
|
+
socket.addEventListener("close", () => {
|
|
96
|
+
forget();
|
|
97
|
+
window.removeEventListener("pagehide", dispose);
|
|
98
|
+
}, { once: true });
|
|
99
|
+
window.addEventListener("pagehide", dispose, { once: true });
|
|
100
|
+
return connection;
|
|
101
|
+
}
|
|
102
|
+
const api = new Proxy({}, { get(_target, property) {
|
|
103
|
+
const connection = live || adopt(new WebSocket(socketUrl()));
|
|
104
|
+
return Reflect.get(connection.api, property);
|
|
105
|
+
} });
|
|
106
|
+
const loginUrl = (params) => `/.auth/login?${new URLSearchParams({
|
|
107
|
+
scope: scopes.join(" "),
|
|
108
|
+
...params
|
|
109
|
+
})}`;
|
|
110
|
+
async function connect(next) {
|
|
111
|
+
const login = loginUrl({ next });
|
|
112
|
+
const probe = await fetch("/api", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
body: "",
|
|
115
|
+
signal: AbortSignal.timeout(1e4)
|
|
116
|
+
});
|
|
117
|
+
await probe.body?.cancel();
|
|
118
|
+
if (probe.status === 401) return leaveForLogin(login);
|
|
119
|
+
if (!probe.ok) throw new Error(`iterate is unavailable (${probe.status}). Please retry.`);
|
|
120
|
+
if (!live) adopt(await openSocketWithRetry(socketUrl()));
|
|
121
|
+
return {
|
|
122
|
+
api,
|
|
123
|
+
info: await api.info(),
|
|
124
|
+
signInFor: (project) => leaveForLogin(loginUrl({
|
|
125
|
+
next: window.location.href,
|
|
126
|
+
project
|
|
127
|
+
}))
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return { authenticate(next = "/") {
|
|
131
|
+
connecting ||= connect(next).catch((error) => {
|
|
132
|
+
connecting = void 0;
|
|
133
|
+
throw error;
|
|
134
|
+
});
|
|
135
|
+
return connecting;
|
|
136
|
+
} };
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
export { createIterateClient };
|
|
140
|
+
|
|
141
|
+
//# sourceMappingURL=app.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app.mjs","names":[],"sources":["../../src/next/client/socket.ts","../../src/next/app.ts"],"sourcesContent":["/** Open a WebSocket, trying again for a while when the connection fails: a phone waking up, a\n * tunnel flapping, a cold edge — the things a first attempt trips over. Resolves with the socket\n * once it is OPEN; rejects with the last failure after the last attempt. The delays are the\n * waits BETWEEN attempts (the first is immediate). */\nconst RETRY_DELAYS_MS: readonly number[] = [250, 500, 1_000, 2_000, 4_000, 8_000];\n\nexport function openSocketWithRetry(\n url: string | URL,\n options: {\n delaysMs?: readonly number[];\n /** the constructor to use — a test's fake, `WebSocket` otherwise */\n WebSocket?: typeof WebSocket;\n sleep?: (ms: number) => Promise<void>;\n } = {},\n): Promise<WebSocket> {\n const delays = options.delaysMs || RETRY_DELAYS_MS;\n const Socket = options.WebSocket || WebSocket;\n const sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));\n const once = () =>\n new Promise<WebSocket>((resolve, reject) => {\n const socket = new Socket(url);\n const opened = () => {\n socket.removeEventListener(\"close\", failed);\n resolve(socket);\n };\n const failed = () => {\n socket.removeEventListener(\"open\", opened);\n reject(new Error(\"WebSocket connection failed.\"));\n };\n socket.addEventListener(\"open\", opened, { once: true });\n socket.addEventListener(\"close\", failed, { once: true });\n });\n return (async () => {\n let attempt = 0;\n for (;;) {\n try {\n return await once();\n } catch (error) {\n if (attempt >= delays.length) throw error;\n await sleep(delays[attempt]!);\n attempt += 1;\n }\n }\n })();\n}\n","import { newWebSocketRpcSession, type RpcStub } from \"capnweb\";\nimport type { IterateApi, IterateSessionApi } from \"./api.ts\";\nimport { openSocketWithRetry } from \"./client/socket.ts\";\nimport { OAuthScopes } from \"./oauth-scopes.ts\";\n\n/** What `authenticate` resolves to: the session as a capnweb stub (pipelined; disposable) and the\n * bootstrap info it answered with. Declared, so the package's declarations stay serializable. */\nexport type AuthenticatedApp = {\n api: RpcStub<IterateSessionApi>;\n info: ReturnType<IterateSessionApi[\"info\"]>;\n /** The page names a project this sign-in does not include — its `/projects/<ref>` is absent from\n * `api.projects.list()`: leave for `/.auth/login`, whose page offers to sign in again (the\n * project ticked at consent, or another account) and returns to this very URL. Never settles,\n * like `authenticate`'s own leave for login. */\n signInFor(project: string): Promise<never>;\n};\nexport type IterateClient = { authenticate(next?: string): Promise<AuthenticatedApp> };\n\n/** The browser leaves for the issuer's login (a document navigation) and this never settles — no\n * framework in the loop: a TanStack `beforeLoad` awaiting it ends the way a thrown\n * `redirect({ reloadDocument: true })` did, a plain page simply navigates. */\nfunction leaveForLogin(login: string): Promise<never> {\n window.location.assign(login);\n return new Promise<never>(() => {});\n}\n\n/** One socket to `/api` with the session the OAuth gate resolved from the cookie. The `api` is\n * capnweb's pipelined `authenticate(...)` answer: usable before the socket has even opened, calls\n * queue until it has. */\ntype Connection = { api: RpcStub<IterateSessionApi>; socket: WebSocket; dispose(): void };\nfunction connectionOn(socket: WebSocket): Connection {\n const iterate = newWebSocketRpcSession<IterateApi>(socket);\n const api = iterate.authenticate({\n type: \"from-server-cookie\",\n }) as unknown as RpcStub<IterateSessionApi>;\n return { api, socket, dispose: () => iterate[Symbol.dispose]() };\n}\n\n/** Create once per app — a TanStack route's client-only `beforeLoad`, or a plain page's entry.\n * Every loader and action of the page shares the returned public RPC session.\n *\n * Connecting tries for a while (client/socket.ts: ≈16 s of attempts) before an error reaches the\n * page — a phone waking up or a flapping tunnel is not a reason to show \"connection failed\". And\n * the `api` the page holds is a proxy to the CURRENT connection: when the socket closes, the next\n * call opens a fresh one and pipelines onto it, so a dropped connection costs a reconnect, not the\n * page. A reconnect the platform refuses (the session ended elsewhere) rejects that call; the\n * page's retry runs `authenticate` again — a fresh one, the socket's close forgot the last — whose\n * probe sends the browser to log in. */\nexport function createIterateClient(options: { scopes?: string[] } = {}): IterateClient {\n const scopes = OAuthScopes.parse(options.scopes || []);\n let live: Connection | null = null;\n let connecting: Promise<AuthenticatedApp> | undefined;\n const socketUrl = () => {\n const url = new URL(\"/api\", window.location.href);\n url.protocol = url.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n return url;\n };\n /** Adopt a connection as the live one until its socket closes. A close also forgets the settled\n * `authenticate` — the next one probes `/api` again, so a session that ended elsewhere sends the\n * browser to log in instead of a retry that can only fail. */\n function adopt(socket: WebSocket): Connection {\n const connection = connectionOn(socket);\n live = connection;\n const forget = () => {\n if (live !== connection) return;\n live = null;\n connecting = undefined;\n };\n const dispose = () => {\n forget();\n connection.dispose();\n };\n socket.addEventListener(\n \"close\",\n () => {\n forget();\n window.removeEventListener(\"pagehide\", dispose);\n },\n { once: true },\n );\n window.addEventListener(\"pagehide\", dispose, { once: true });\n return connection;\n }\n // Every property read goes to the live connection — or to a fresh one when the last socket\n // closed (a new WebSocket, no wait: capnweb queues the call until it opens). What comes back is\n // capnweb's own stub for that property — a method stub carries its path, so it is returned as\n // is, never bound or otherwise touched (a capnweb stub answers `.bind` with another stub).\n const api = new Proxy({} as RpcStub<IterateSessionApi>, {\n get(_target, property) {\n const connection = live || adopt(new WebSocket(socketUrl()));\n return Reflect.get(connection.api as object, property);\n },\n });\n const loginUrl = (params: Record<string, string>) =>\n `/.auth/login?${new URLSearchParams({ scope: scopes.join(\" \"), ...params })}`;\n async function connect(next: string): Promise<AuthenticatedApp> {\n const login = loginUrl({ next });\n // HTTP distinguishes 401 from outages; a failed WebSocket is not evidence\n // that the visitor needs to log in. Errors reach the route's error boundary.\n const probe = await fetch(\"/api\", {\n method: \"POST\",\n body: \"\",\n signal: AbortSignal.timeout(10_000),\n });\n await probe.body?.cancel();\n if (probe.status === 401) return leaveForLogin(login);\n if (!probe.ok) throw new Error(`iterate is unavailable (${probe.status}). Please retry.`);\n if (!live) adopt(await openSocketWithRetry(socketUrl()));\n // Consent is task-based: the person may have granted fewer scopes than the app asked for\n // (every scope but `iterate` is optional on the consent page). The granted set is\n // `info.scopes` — an app reads it and offers a step-up link (`/.auth/login?scope=…`) for what\n // it lacks; it is never bounced back to consent for a permission the person declined.\n const info = await api.info();\n return {\n api,\n info,\n signInFor: (project) => leaveForLogin(loginUrl({ next: window.location.href, project })),\n };\n }\n return {\n authenticate(next = \"/\") {\n connecting ||= connect(next).catch((error) => {\n connecting = undefined;\n throw error;\n });\n return connecting;\n },\n };\n}\n"],"mappings":";;;;;;;AAIA,MAAM,kBAAqC;CAAC;CAAK;CAAK;CAAO;CAAO;CAAO;AAAK;AAEhF,SAAgB,oBACd,KACA,UAKI,CAAC,GACe;CACpB,MAAM,SAAS,QAAQ,YAAY;CACnC,MAAM,SAAS,QAAQ,aAAa;CACpC,MAAM,QAAQ,QAAQ,WAAW,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,EAAE,CAAC;CACxF,MAAM,aACJ,IAAI,SAAoB,SAAS,WAAW;EAC1C,MAAM,SAAS,IAAI,OAAO,GAAG;EAC7B,MAAM,eAAe;GACnB,OAAO,oBAAoB,SAAS,MAAM;GAC1C,QAAQ,MAAM;EAChB;EACA,MAAM,eAAe;GACnB,OAAO,oBAAoB,QAAQ,MAAM;GACzC,uBAAO,IAAI,MAAM,8BAA8B,CAAC;EAClD;EACA,OAAO,iBAAiB,QAAQ,QAAQ,EAAE,MAAM,KAAK,CAAC;EACtD,OAAO,iBAAiB,SAAS,QAAQ,EAAE,MAAM,KAAK,CAAC;CACzD,CAAC;CACH,QAAQ,YAAY;EAClB,IAAI,UAAU;EACd,SACE,IAAI;GACF,OAAO,MAAM,KAAK;EACpB,SAAS,OAAO;GACd,IAAI,WAAW,OAAO,QAAQ,MAAM;GACpC,MAAM,MAAM,OAAO,QAAS;GAC5B,WAAW;EACb;CAEJ,EAAA,CAAG;AACL;;;;;;ACvBA,SAAS,cAAc,OAA+B;CACpD,OAAO,SAAS,OAAO,KAAK;CAC5B,OAAO,IAAI,cAAqB,CAAC,CAAC;AACpC;AAMA,SAAS,aAAa,QAA+B;CACnD,MAAM,UAAU,uBAAmC,MAAM;CAIzD,OAAO;EAAE,KAHG,QAAQ,aAAa,EAC/B,MAAM,qBACR,CACW;EAAG;EAAQ,eAAe,QAAQ,OAAO,QAAQ,CAAC;CAAE;AACjE;;;;;;;;;;;AAYA,SAAgB,oBAAoB,UAAiC,CAAC,GAAkB;CACtF,MAAM,SAAS,YAAY,MAAM,QAAQ,UAAU,CAAC,CAAC;CACrD,IAAI,OAA0B;CAC9B,IAAI;CACJ,MAAM,kBAAkB;EACtB,MAAM,MAAM,IAAI,IAAI,QAAQ,OAAO,SAAS,IAAI;EAChD,IAAI,WAAW,IAAI,aAAa,WAAW,SAAS;EACpD,OAAO;CACT;;;;CAIA,SAAS,MAAM,QAA+B;EAC5C,MAAM,aAAa,aAAa,MAAM;EACtC,OAAO;EACP,MAAM,eAAe;GACnB,IAAI,SAAS,YAAY;GACzB,OAAO;GACP,aAAa,KAAA;EACf;EACA,MAAM,gBAAgB;GACpB,OAAO;GACP,WAAW,QAAQ;EACrB;EACA,OAAO,iBACL,eACM;GACJ,OAAO;GACP,OAAO,oBAAoB,YAAY,OAAO;EAChD,GACA,EAAE,MAAM,KAAK,CACf;EACA,OAAO,iBAAiB,YAAY,SAAS,EAAE,MAAM,KAAK,CAAC;EAC3D,OAAO;CACT;CAKA,MAAM,MAAM,IAAI,MAAM,CAAC,GAAiC,EACtD,IAAI,SAAS,UAAU;EACrB,MAAM,aAAa,QAAQ,MAAM,IAAI,UAAU,UAAU,CAAC,CAAC;EAC3D,OAAO,QAAQ,IAAI,WAAW,KAAe,QAAQ;CACvD,EACF,CAAC;CACD,MAAM,YAAY,WAChB,gBAAgB,IAAI,gBAAgB;EAAE,OAAO,OAAO,KAAK,GAAG;EAAG,GAAG;CAAO,CAAC;CAC5E,eAAe,QAAQ,MAAyC;EAC9D,MAAM,QAAQ,SAAS,EAAE,KAAK,CAAC;EAG/B,MAAM,QAAQ,MAAM,MAAM,QAAQ;GAChC,QAAQ;GACR,MAAM;GACN,QAAQ,YAAY,QAAQ,GAAM;EACpC,CAAC;EACD,MAAM,MAAM,MAAM,OAAO;EACzB,IAAI,MAAM,WAAW,KAAK,OAAO,cAAc,KAAK;EACpD,IAAI,CAAC,MAAM,IAAI,MAAM,IAAI,MAAM,2BAA2B,MAAM,OAAO,iBAAiB;EACxF,IAAI,CAAC,MAAM,MAAM,MAAM,oBAAoB,UAAU,CAAC,CAAC;EAMvD,OAAO;GACL;GACA,MAAA,MAHiB,IAAI,KAAK;GAI1B,YAAY,YAAY,cAAc,SAAS;IAAE,MAAM,OAAO,SAAS;IAAM;GAAQ,CAAC,CAAC;EACzF;CACF;CACA,OAAO,EACL,aAAa,OAAO,KAAK;EACvB,eAAe,QAAQ,IAAI,CAAC,CAAC,OAAO,UAAU;GAC5C,aAAa,KAAA;GACb,MAAM;EACR,CAAC;EACD,OAAO;CACT,EACF;AACF"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type PatchOp } from "../lib.ts";
|
|
2
|
+
/** One live-state delta off the wire — the payload of an `events.iterate.com/live-state/changed`
|
|
3
|
+
* ephemeral event, delivered raw to the subscriber. */
|
|
4
|
+
/** `patch: null` = the change was too large to send — the rev moved, re-seed through the door. */
|
|
5
|
+
export type LiveStateDelta = {
|
|
6
|
+
key: string;
|
|
7
|
+
from: number;
|
|
8
|
+
to: number;
|
|
9
|
+
patch: PatchOp[] | null;
|
|
10
|
+
};
|
|
11
|
+
/** What the producer's seed door returns: the current revision paired with the current value. */
|
|
12
|
+
export type LiveStateSeed<S> = {
|
|
13
|
+
rev: number;
|
|
14
|
+
state: S;
|
|
15
|
+
};
|
|
16
|
+
export type LiveStateStore<S> = {
|
|
17
|
+
/** The current value, or undefined until the first seed lands. */
|
|
18
|
+
get(): S | undefined;
|
|
19
|
+
/** The held revision, or null before the first seed. */
|
|
20
|
+
rev(): number | null;
|
|
21
|
+
/** Subscribe to changes (for React's useSyncExternalStore, or a test's await-loop). */
|
|
22
|
+
subscribe(listener: () => void): () => void;
|
|
23
|
+
/** Seed (or re-seed) from the door — the first paint, and the heal after a gap. */
|
|
24
|
+
seed(seed: LiveStateSeed<S>): void;
|
|
25
|
+
/** Reduce one delta in; on a revision gap call `resync` and hold the value until a fresh seed. */
|
|
26
|
+
apply(delta: LiveStateDelta, resync: () => void): void;
|
|
27
|
+
};
|
|
28
|
+
export declare function createLiveStateStore<S>(): LiveStateStore<S>;
|
|
29
|
+
/** The slice of an itx session this needs — a capnweb `IterateContextRpcTarget` proxy satisfies it structurally:
|
|
30
|
+
* `subscribe` hands back a DISPOSABLE handle (disposing it removes the subscription server-side). */
|
|
31
|
+
export type LiveStateItx = {
|
|
32
|
+
subscribe(input: {
|
|
33
|
+
name?: string;
|
|
34
|
+
consumes?: string[];
|
|
35
|
+
target: (events: unknown[], range: unknown) => void;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
[Symbol.dispose](): void;
|
|
38
|
+
}>;
|
|
39
|
+
};
|
|
40
|
+
/** A connected live-state subscription: the store rendering it, and the dispose that removes the
|
|
41
|
+
* server-side subscription (the handle's disposer; the session's end does the same). */
|
|
42
|
+
export type LiveStateConnection<S> = {
|
|
43
|
+
store: LiveStateStore<S>;
|
|
44
|
+
/** Unsubscribe on the server and stop reducing deltas. Safe to call more than once. */
|
|
45
|
+
dispose(): Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
/** Subscribe to a producer's live state and reduce it into a store. `door` reads the seed
|
|
48
|
+
* (`itx.invoke("itx.facets.get('slug').liveSnapshot()")` for a processor, or a mini-app's
|
|
49
|
+
* own `state()` method). Subscribe happens BEFORE the first seed, so a delta racing the seed just
|
|
50
|
+
* triggers one door re-read — never a lost update. Gap heals are SINGLE-FLIGHT (a burst of gapped
|
|
51
|
+
* frames triggers one door read, not one per frame); a failed heal is reported through `onResync`
|
|
52
|
+
* and retried by the next delivered delta (its `from` still mismatches, so it re-triggers). */
|
|
53
|
+
export declare function connectLiveState<S>(itx: LiveStateItx, opts: {
|
|
54
|
+
key: string;
|
|
55
|
+
name?: string;
|
|
56
|
+
door: () => Promise<LiveStateSeed<S>>;
|
|
57
|
+
/** Called after each gap heal attempt: "healed" on a fresh seed, the error when the door read
|
|
58
|
+
* failed (the store keeps its last value; the next delta retries). */
|
|
59
|
+
onResync?: (result: "healed" | Error) => void;
|
|
60
|
+
/** Abort while the FIRST seed is still pending (a component unmounting): the row just configured
|
|
61
|
+
* is recalled and the connect rejects — a door that never answers leaves nothing lent. */
|
|
62
|
+
signal?: AbortSignal;
|
|
63
|
+
}): Promise<LiveStateConnection<S>>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The same code/PKCE parameters for browser login and a console-minted token. */
|
|
2
|
+
export declare function authorizationCodeRequest(input: {
|
|
3
|
+
issuer: string;
|
|
4
|
+
clientId: string;
|
|
5
|
+
redirectUri: string;
|
|
6
|
+
resources: string[];
|
|
7
|
+
scopes?: string[];
|
|
8
|
+
}): Promise<{
|
|
9
|
+
url: URL;
|
|
10
|
+
state: string;
|
|
11
|
+
verifier: string;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { type LiveStateItx, type LiveStateSeed } from "./live-state.ts";
|
|
2
|
+
export type LiveStateStatus = "connecting" | "live" | "error";
|
|
3
|
+
/** One live state as a component reads it: the latest value (undefined until the first seed lands),
|
|
4
|
+
* the revision it is at, whether its subscription is connecting, live or failed, and the failure. */
|
|
5
|
+
export type LiveStateResult<S = unknown> = {
|
|
6
|
+
value: S | undefined;
|
|
7
|
+
rev: number | null;
|
|
8
|
+
status: LiveStateStatus;
|
|
9
|
+
error?: string;
|
|
10
|
+
};
|
|
11
|
+
/** Subscribe to a producer's live state and render its latest value. Pass a ready `itx` (a capnweb
|
|
12
|
+
* `api.authenticate(credentials).user` or `.projects.get(id)`), the producer's `key`, and a `door`
|
|
13
|
+
* thunk that reads `{rev, state}` (`() => itx.invoke("itx.facets.get('slug').liveSnapshot()")`).
|
|
14
|
+
* Re-subscribes when the session, `key`, or `name` changes; unmount (and every re-subscribe)
|
|
15
|
+
* disposes the previous server-side subscription. */
|
|
16
|
+
export declare function useLiveState<S>(itx: LiveStateItx | undefined, opts: {
|
|
17
|
+
key: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
door: () => Promise<LiveStateSeed<S>>;
|
|
20
|
+
}): LiveStateResult<S>;
|
|
21
|
+
/** One committed event as the hook hands it out: the itx envelope, structurally. */
|
|
22
|
+
export type IterateContextEvent = {
|
|
23
|
+
offset: number;
|
|
24
|
+
type: string;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
payload?: unknown;
|
|
27
|
+
metadata?: Record<string, unknown>;
|
|
28
|
+
idempotencyKey?: string;
|
|
29
|
+
source?: {
|
|
30
|
+
principal?: {
|
|
31
|
+
actor: string;
|
|
32
|
+
email?: string;
|
|
33
|
+
};
|
|
34
|
+
grant?: string;
|
|
35
|
+
processor?: {
|
|
36
|
+
slug: string;
|
|
37
|
+
version: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/** One row of a context's processors table (`itx.processors.list()`), structurally. */
|
|
42
|
+
export type IterateContextProcessorRow = {
|
|
43
|
+
name: string;
|
|
44
|
+
target: string;
|
|
45
|
+
consumes?: string[];
|
|
46
|
+
configuredAtOffset: number;
|
|
47
|
+
hostedFacet?: {
|
|
48
|
+
name: string;
|
|
49
|
+
className: string;
|
|
50
|
+
cacheKey?: string;
|
|
51
|
+
restarts: number;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
/** One presence: who acted on the context and when last, from the log's stamps. */
|
|
55
|
+
export type IterateContextPresence = {
|
|
56
|
+
actor: string;
|
|
57
|
+
email?: string;
|
|
58
|
+
grant?: string;
|
|
59
|
+
lastSeenAt: string;
|
|
60
|
+
};
|
|
61
|
+
/** The slice of a context handle `useIterateContext` reads — a capnweb `IterateContextApi` stub
|
|
62
|
+
* satisfies it structurally. `rpcStubs` is optional: a handle typed without the census (a project
|
|
63
|
+
* context's client type) still gets the log, the table and the actors. `invoke` seeds a named
|
|
64
|
+
* facet's live state (`itx.facets.get('<name>').liveSnapshot()`, as an expression). */
|
|
65
|
+
export type IterateContextHandle = LiveStateItx & {
|
|
66
|
+
readEvents(afterOffset?: number, limit?: number): Promise<{
|
|
67
|
+
events: unknown[];
|
|
68
|
+
atHead: boolean;
|
|
69
|
+
scannedThroughOffset: number;
|
|
70
|
+
}>;
|
|
71
|
+
processors: {
|
|
72
|
+
list(): Promise<IterateContextProcessorRow[]> | IterateContextProcessorRow[];
|
|
73
|
+
};
|
|
74
|
+
rpcStubs?: {
|
|
75
|
+
list(): Promise<string[]> | string[];
|
|
76
|
+
};
|
|
77
|
+
invoke(call: string): Promise<unknown>;
|
|
78
|
+
};
|
|
79
|
+
/** THE ITERATE CONTEXT, live — one hook, one stream subscription. THE LOG: subscribe to every
|
|
80
|
+
* committed event (or `consumes`) BEFORE the catch-up read, so nothing lands between the two;
|
|
81
|
+
* pushes and pages both dedupe into one map by offset; `caughtUp` once the read reached the head;
|
|
82
|
+
* `error` when the connect failed. Off that same log, THE PROCESSORS TABLE, re-read whenever the
|
|
83
|
+
* log grows a row-changing event (a subscription configured, halted or resumed — the table is core
|
|
84
|
+
* state, one call away, no push of its own), and WHO IS HERE: the rpc stubs lent right now
|
|
85
|
+
* (`itx.rpcStubs.list()` — physical, re-read at every new head, since presence changes are
|
|
86
|
+
* ephemeral facts) and, from the log, every principal that acted, newest first. And named facets'
|
|
87
|
+
* LIVE STATE, each seeded through `itx.facets.get('<name>').liveSnapshot()` — one entry per name,
|
|
88
|
+
* always. `liveState` OMITTED opens `core` (the core reduce answers under that name) plus every
|
|
89
|
+
* hosted facet in the processors table the hook holds, following the table as it loads and changes;
|
|
90
|
+
* `liveState` GIVEN is exactly the names to open, no implicit `core`. Re-connects when `itx`
|
|
91
|
+
* changes; unmount disposes every server-side subscription. */
|
|
92
|
+
export declare function useIterateContext(itx: IterateContextHandle | undefined, opts?: {
|
|
93
|
+
consumes?: string[];
|
|
94
|
+
liveState?: string[];
|
|
95
|
+
}): {
|
|
96
|
+
events: IterateContextEvent[];
|
|
97
|
+
caughtUp: boolean;
|
|
98
|
+
error?: string;
|
|
99
|
+
processors: {
|
|
100
|
+
rows: IterateContextProcessorRow[];
|
|
101
|
+
loaded: boolean;
|
|
102
|
+
error?: string;
|
|
103
|
+
};
|
|
104
|
+
presence: {
|
|
105
|
+
actors: IterateContextPresence[];
|
|
106
|
+
rpcStubs: string[];
|
|
107
|
+
};
|
|
108
|
+
liveState: Record<string, LiveStateResult>;
|
|
109
|
+
};
|