iterate 0.2.7 → 0.4.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 +173 -81
- package/dist/api.d.ts +643 -0
- package/dist/api.mjs +0 -0
- package/dist/app-server.d.ts +51 -0
- package/dist/app-server.mjs +481 -0
- package/dist/app-server.mjs.map +1 -0
- package/dist/app-session.d.ts +49 -0
- package/dist/app-session.mjs +235 -0
- package/dist/app-session.mjs.map +1 -0
- package/dist/app.d.ts +29 -0
- package/dist/app.mjs +180 -0
- package/dist/app.mjs.map +1 -0
- package/dist/client/live-state.d.ts +63 -0
- package/dist/client/oauth.d.ts +17 -0
- package/dist/client/react.d.ts +77 -0
- package/dist/client/socket.d.ts +7 -0
- package/dist/client.mjs +156 -0
- package/dist/client.mjs.map +1 -0
- package/dist/expression.d.ts +88 -0
- package/dist/expression.mjs +301 -0
- package/dist/expression.mjs.map +1 -0
- package/dist/lib-BWr-5mFO.mjs +36 -0
- package/dist/lib-BWr-5mFO.mjs.map +1 -0
- package/dist/lib.d.ts +70 -0
- package/dist/lib.mjs +228 -0
- package/dist/lib.mjs.map +1 -0
- package/dist/node.d.ts +15 -0
- package/dist/node.mjs +47 -0
- package/dist/node.mjs.map +1 -0
- package/dist/oauth-scopes.d.ts +32 -0
- package/dist/oauth-scopes.mjs +40 -0
- package/dist/oauth-scopes.mjs.map +1 -0
- package/dist/oauth.mjs +41 -0
- package/dist/oauth.mjs.map +1 -0
- package/dist/principal.d.ts +8 -0
- package/dist/principal.mjs +8 -0
- package/dist/principal.mjs.map +1 -0
- package/dist/project-ingress.d.ts +58 -0
- package/dist/project-ingress.mjs +104 -0
- package/dist/project-ingress.mjs.map +1 -0
- package/dist/react.mjs +285 -0
- package/dist/react.mjs.map +1 -0
- package/dist/sdk/auth.d.ts +25 -0
- package/dist/sdk/index.d.ts +155 -0
- package/dist/sdk/record-pipelined-steps.d.ts +19 -0
- package/dist/sdk.mjs +245 -0
- package/dist/sdk.mjs.map +1 -0
- package/dist/stream/processor.d.ts +383 -0
- package/dist/stream/processor.mjs +605 -0
- package/dist/stream/processor.mjs.map +1 -0
- package/dist/stream/run.d.ts +61 -0
- package/dist/stream/run.mjs +45 -0
- package/dist/stream/run.mjs.map +1 -0
- package/dist/stream/test-support.d.ts +45 -0
- package/dist/stream/test-support.mjs +196 -0
- package/dist/stream/test-support.mjs.map +1 -0
- package/dist/usingCtx-inzbY1Qz.mjs +57 -0
- package/package.json +93 -30
- package/bin/iterate.js +0 -86
- 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 +0 -5
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -8
- package/dist/index.mjs.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
- package/dist/worker.d.mts +0 -33
- package/dist/worker.mjs +0 -18
- package/dist/worker.mjs.map +0 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** The platform's OAuth scopes — what an app may ask for and what a consent grants:
|
|
3
|
+
* - `iterate` — reach the projects the person grants (every app; implied, always granted)
|
|
4
|
+
* - `account` — manage the person's sessions and personal access tokens
|
|
5
|
+
* - `organizations:write` — the person's organizations: list every one they belong to, create new ones
|
|
6
|
+
* Consent is task-based (the shape Cloudflare's own OAuth consent took in August 2026: a client
|
|
7
|
+
* requests a set, the person may deselect the optional ones, the token carries what was granted):
|
|
8
|
+
* `iterate` is required, every other requested scope is optional on the consent page, and an app
|
|
9
|
+
* reads the granted set from `session.info().scopes` rather than assuming its request. */
|
|
10
|
+
export declare const OAuthScope: z.ZodEnum<{
|
|
11
|
+
iterate: "iterate";
|
|
12
|
+
account: "account";
|
|
13
|
+
"organizations:write": "organizations:write";
|
|
14
|
+
}>;
|
|
15
|
+
export type OAuthScope = z.infer<typeof OAuthScope>;
|
|
16
|
+
export declare const OAuthScopes: z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
|
17
|
+
iterate: "iterate";
|
|
18
|
+
account: "account";
|
|
19
|
+
"organizations:write": "organizations:write";
|
|
20
|
+
}>>, z.ZodTransform<string[], ("iterate" | "account" | "organizations:write")[]>>;
|
|
21
|
+
/** A requested scope as the consent page shows it: its name, what it means to the person, and
|
|
22
|
+
* whether they may untick it (`iterate` never). */
|
|
23
|
+
export interface ConsentScope {
|
|
24
|
+
name: OAuthScope;
|
|
25
|
+
title: string;
|
|
26
|
+
note: string;
|
|
27
|
+
required: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** What each scope means to the person — the consent page's copy, sent with `consent.describe` so
|
|
30
|
+
* the page renders what it is given: a scope added to `OAuthScope` is described here or it does
|
|
31
|
+
* not compile. */
|
|
32
|
+
export declare const OAuthScopeDescriptions: Record<OAuthScope, Omit<ConsentScope, "name">>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/oauth-scopes.ts
|
|
3
|
+
/** The platform's OAuth scopes — what an app may ask for and what a consent grants:
|
|
4
|
+
* - `iterate` — reach the projects the person grants (every app; implied, always granted)
|
|
5
|
+
* - `account` — manage the person's sessions and personal access tokens
|
|
6
|
+
* - `organizations:write` — the person's organizations: list every one they belong to, create new ones
|
|
7
|
+
* Consent is task-based (the shape Cloudflare's own OAuth consent took in August 2026: a client
|
|
8
|
+
* requests a set, the person may deselect the optional ones, the token carries what was granted):
|
|
9
|
+
* `iterate` is required, every other requested scope is optional on the consent page, and an app
|
|
10
|
+
* reads the granted set from `session.info().scopes` rather than assuming its request. */
|
|
11
|
+
const OAuthScope = z.enum([
|
|
12
|
+
"iterate",
|
|
13
|
+
"account",
|
|
14
|
+
"organizations:write"
|
|
15
|
+
]);
|
|
16
|
+
const OAuthScopes = z.array(OAuthScope).transform((scopes) => [...new Set(["iterate", ...scopes])]);
|
|
17
|
+
/** What each scope means to the person — the consent page's copy, sent with `consent.describe` so
|
|
18
|
+
* the page renders what it is given: a scope added to `OAuthScope` is described here or it does
|
|
19
|
+
* not compile. */
|
|
20
|
+
const OAuthScopeDescriptions = {
|
|
21
|
+
iterate: {
|
|
22
|
+
title: "Read and make changes in the projects you grant it",
|
|
23
|
+
note: "Required — what the app is for.",
|
|
24
|
+
required: true
|
|
25
|
+
},
|
|
26
|
+
account: {
|
|
27
|
+
title: "See and end your sessions, and mint personal access tokens",
|
|
28
|
+
note: "Optional.",
|
|
29
|
+
required: false
|
|
30
|
+
},
|
|
31
|
+
"organizations:write": {
|
|
32
|
+
title: "See all your organizations and create new ones",
|
|
33
|
+
note: "Optional.",
|
|
34
|
+
required: false
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { OAuthScope, OAuthScopeDescriptions, OAuthScopes };
|
|
39
|
+
|
|
40
|
+
//# sourceMappingURL=oauth-scopes.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth-scopes.mjs","names":[],"sources":["../src/oauth-scopes.ts"],"sourcesContent":["import { z } from \"zod\";\n\n/** The platform's OAuth scopes — what an app may ask for and what a consent grants:\n * - `iterate` — reach the projects the person grants (every app; implied, always granted)\n * - `account` — manage the person's sessions and personal access tokens\n * - `organizations:write` — the person's organizations: list every one they belong to, create new ones\n * Consent is task-based (the shape Cloudflare's own OAuth consent took in August 2026: a client\n * requests a set, the person may deselect the optional ones, the token carries what was granted):\n * `iterate` is required, every other requested scope is optional on the consent page, and an app\n * reads the granted set from `session.info().scopes` rather than assuming its request. */\nexport const OAuthScope = z.enum([\"iterate\", \"account\", \"organizations:write\"]);\nexport type OAuthScope = z.infer<typeof OAuthScope>;\n\nexport const OAuthScopes = z\n .array(OAuthScope)\n .transform((scopes) => [...new Set([\"iterate\", ...scopes])]);\n\n/** A requested scope as the consent page shows it: its name, what it means to the person, and\n * whether they may untick it (`iterate` never). */\nexport interface ConsentScope {\n name: OAuthScope;\n title: string;\n note: string;\n required: boolean;\n}\n\n/** What each scope means to the person — the consent page's copy, sent with `consent.describe` so\n * the page renders what it is given: a scope added to `OAuthScope` is described here or it does\n * not compile. */\nexport const OAuthScopeDescriptions: Record<OAuthScope, Omit<ConsentScope, \"name\">> = {\n iterate: {\n title: \"Read and make changes in the projects you grant it\",\n note: \"Required — what the app is for.\",\n required: true,\n },\n account: {\n title: \"See and end your sessions, and mint personal access tokens\",\n note: \"Optional.\",\n required: false,\n },\n \"organizations:write\": {\n title: \"See all your organizations and create new ones\",\n note: \"Optional.\",\n required: false,\n },\n};\n"],"mappings":";;;;;;;;;;AAUA,MAAa,aAAa,EAAE,KAAK;CAAC;CAAW;CAAW;AAAqB,CAAC;AAG9E,MAAa,cAAc,EACxB,MAAM,UAAU,CAAC,CACjB,WAAW,WAAW,CAAC,GAAG,IAAI,IAAI,CAAC,WAAW,GAAG,MAAM,CAAC,CAAC,CAAC;;;;AAc7D,MAAa,yBAAyE;CACpF,SAAS;EACP,OAAO;EACP,MAAM;EACN,UAAU;CACZ;CACA,SAAS;EACP,OAAO;EACP,MAAM;EACN,UAAU;CACZ;CACA,uBAAuB;EACrB,OAAO;EACP,MAAM;EACN,UAAU;CACZ;AACF"}
|
package/dist/oauth.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { OAuthScopes } from "./oauth-scopes.mjs";
|
|
2
|
+
import * as oauth from "oauth4webapi";
|
|
3
|
+
//#region src/client/oauth.ts
|
|
4
|
+
/** The platform's issuer as oauth4webapi's authorization server: its endpoints under `/oauth2`, and
|
|
5
|
+
* the `iss` it adds to every authorization response (RFC 9207), which `validateAuthResponse` then
|
|
6
|
+
* requires. The same description for an app's browser session and the CLI. */
|
|
7
|
+
function authorizationServer(issuer) {
|
|
8
|
+
return {
|
|
9
|
+
issuer,
|
|
10
|
+
authorization_endpoint: `${issuer}/oauth2/auth`,
|
|
11
|
+
token_endpoint: `${issuer}/oauth2/token`,
|
|
12
|
+
registration_endpoint: `${issuer}/oauth2/register`,
|
|
13
|
+
authorization_response_iss_parameter_supported: true
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** The same code/PKCE parameters for browser login and a console-minted token. */
|
|
17
|
+
async function authorizationCodeRequest(input) {
|
|
18
|
+
const verifier = oauth.generateRandomCodeVerifier();
|
|
19
|
+
const challenge = await oauth.calculatePKCECodeChallenge(verifier);
|
|
20
|
+
const state = oauth.generateRandomState();
|
|
21
|
+
const url = new URL("/oauth2/auth", input.issuer);
|
|
22
|
+
url.search = new URLSearchParams({
|
|
23
|
+
response_type: "code",
|
|
24
|
+
client_id: input.clientId,
|
|
25
|
+
redirect_uri: input.redirectUri,
|
|
26
|
+
scope: OAuthScopes.parse(input.scopes || []).join(" "),
|
|
27
|
+
state,
|
|
28
|
+
code_challenge: challenge,
|
|
29
|
+
code_challenge_method: "S256"
|
|
30
|
+
}).toString();
|
|
31
|
+
for (const resource of input.resources) url.searchParams.append("resource", resource);
|
|
32
|
+
return {
|
|
33
|
+
url,
|
|
34
|
+
state,
|
|
35
|
+
verifier
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { authorizationCodeRequest, authorizationServer };
|
|
40
|
+
|
|
41
|
+
//# sourceMappingURL=oauth.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.mjs","names":[],"sources":["../src/client/oauth.ts"],"sourcesContent":["import * as oauth from \"oauth4webapi\";\nimport { OAuthScopes } from \"../oauth-scopes.ts\";\n\n/** The platform's issuer as oauth4webapi's authorization server: its endpoints under `/oauth2`, and\n * the `iss` it adds to every authorization response (RFC 9207), which `validateAuthResponse` then\n * requires. The same description for an app's browser session and the CLI. */\nexport function authorizationServer(issuer: string): oauth.AuthorizationServer {\n return {\n issuer,\n authorization_endpoint: `${issuer}/oauth2/auth`,\n token_endpoint: `${issuer}/oauth2/token`,\n registration_endpoint: `${issuer}/oauth2/register`,\n authorization_response_iss_parameter_supported: true,\n };\n}\n\n/** The same code/PKCE parameters for browser login and a console-minted token. */\nexport async function authorizationCodeRequest(input: {\n issuer: string;\n clientId: string;\n redirectUri: string;\n resources: string[];\n scopes?: string[];\n}) {\n const verifier = oauth.generateRandomCodeVerifier();\n const challenge = await oauth.calculatePKCECodeChallenge(verifier);\n const state = oauth.generateRandomState();\n const url = new URL(\"/oauth2/auth\", input.issuer);\n url.search = new URLSearchParams({\n response_type: \"code\",\n client_id: input.clientId,\n redirect_uri: input.redirectUri,\n scope: OAuthScopes.parse(input.scopes || []).join(\" \"),\n state,\n code_challenge: challenge,\n code_challenge_method: \"S256\",\n }).toString();\n for (const resource of input.resources) url.searchParams.append(\"resource\", resource);\n return { url, state, verifier };\n}\n"],"mappings":";;;;;;AAMA,SAAgB,oBAAoB,QAA2C;CAC7E,OAAO;EACL;EACA,wBAAwB,GAAG,OAAO;EAClC,gBAAgB,GAAG,OAAO;EAC1B,uBAAuB,GAAG,OAAO;EACjC,gDAAgD;CAClD;AACF;;AAGA,eAAsB,yBAAyB,OAM5C;CACD,MAAM,WAAW,MAAM,2BAA2B;CAClD,MAAM,YAAY,MAAM,MAAM,2BAA2B,QAAQ;CACjE,MAAM,QAAQ,MAAM,oBAAoB;CACxC,MAAM,MAAM,IAAI,IAAI,gBAAgB,MAAM,MAAM;CAChD,IAAI,SAAS,IAAI,gBAAgB;EAC/B,eAAe;EACf,WAAW,MAAM;EACjB,cAAc,MAAM;EACpB,OAAO,YAAY,MAAM,MAAM,UAAU,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG;EACrD;EACA,gBAAgB;EAChB,uBAAuB;CACzB,CAAC,CAAC,CAAC,SAAS;CACZ,KAAK,MAAM,YAAY,MAAM,WAAW,IAAI,aAAa,OAAO,YAAY,QAAQ;CACpF,OAAO;EAAE;EAAK;EAAO;CAAS;AAChC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Who is acting: a stable actor id (the control plane's user id) and, when known, an email. */
|
|
2
|
+
export type Principal = {
|
|
3
|
+
actor: string;
|
|
4
|
+
email?: string;
|
|
5
|
+
};
|
|
6
|
+
/** The header the edge sets on a Request it forwards on a principal's behalf — the ingress after
|
|
7
|
+
* the cookie check, a session's terminal `fetch` — and strips from every inbound Request. */
|
|
8
|
+
export declare const ITX_PRINCIPAL_HEADER = "x-itx-principal";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//#region src/principal.ts
|
|
2
|
+
/** The header the edge sets on a Request it forwards on a principal's behalf — the ingress after
|
|
3
|
+
* the cookie check, a session's terminal `fetch` — and strips from every inbound Request. */
|
|
4
|
+
const ITX_PRINCIPAL_HEADER = "x-itx-principal";
|
|
5
|
+
//#endregion
|
|
6
|
+
export { ITX_PRINCIPAL_HEADER };
|
|
7
|
+
|
|
8
|
+
//# sourceMappingURL=principal.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"principal.mjs","names":[],"sources":["../src/principal.ts"],"sourcesContent":["// principal.ts — WHO user code is acting for, as the platform tells it: the verified `Principal`\n// (`itx.whoami()`, `source.principal` on an event) and the header the platform stamps it in on a\n// Request it forwards to a project's worker (sdk/auth.ts reads it). How the platform admits and\n// carries a caller, and signs its own tokens, stays in the platform.\n\n/** Who is acting: a stable actor id (the control plane's user id) and, when known, an email. */\nexport type Principal = { actor: string; email?: string };\n\n/** The header the edge sets on a Request it forwards on a principal's behalf — the ingress after\n * the cookie check, a session's terminal `fetch` — and strips from every inbound Request. */\nexport const ITX_PRINCIPAL_HEADER = \"x-itx-principal\";\n"],"mappings":";;;AAUA,MAAa,uBAAuB"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** THE ROUTING SLUG a project host named (`notes` for `notes--<project>.<hostname>`), as the project's
|
|
2
|
+
* config worker `fetch` reads it; absent on the apex. Written only by the platform: the edge sets or
|
|
3
|
+
* deletes it on every project-host request, and the context DO deletes it from every other
|
|
4
|
+
* expression fetch, so neither a visitor nor loaded code can pick a routing slug. */
|
|
5
|
+
export declare const ITERATE_ROUTING_SLUG_HEADER = "x-iterate-routing-slug";
|
|
6
|
+
/** How projects are reached over HTTP; null ⇒ no ingress (`/api` and `/mcp` still answer). */
|
|
7
|
+
export type IngressRouting = {
|
|
8
|
+
type: "subdomains";
|
|
9
|
+
hostname: string;
|
|
10
|
+
} | {
|
|
11
|
+
type: "paths";
|
|
12
|
+
} | null;
|
|
13
|
+
/** What a request names: the project (its slug, as written — whether it EXISTS is the directory's
|
|
14
|
+
* answer), the routing slug (null ⇒ the apex; either way the project's config worker answers), and
|
|
15
|
+
* the path prefix the edge strips before the config worker sees the URL ("" under subdomains;
|
|
16
|
+
* "/projects/<project>" or "/projects/<project>/<routingSlug>" under paths). */
|
|
17
|
+
export type ProjectAddress = {
|
|
18
|
+
project: string;
|
|
19
|
+
routingSlug: string | null;
|
|
20
|
+
basePath: string;
|
|
21
|
+
};
|
|
22
|
+
/** The project + routing slug `url` names under `routing`, or null when it names none. Pure. */
|
|
23
|
+
export declare function projectAddressOf(routing: IngressRouting, url: URL, platformOrigin: string): ProjectAddress | null;
|
|
24
|
+
/** A PROJECT WILDCARD — an owned zone served as one project's apex (apps/os
|
|
25
|
+
* `urls.projectWildcard`, `{ hostname: "iterate.com", project: "iterate" }`): the zone's apex and
|
|
26
|
+
* every first-level name under it but the excluded ones, in the apex shape, `routingSlug: null`, so the
|
|
27
|
+
* project's config worker `fetch` answers exactly as it does on `<project>.<hostname>`. Null for
|
|
28
|
+
* anything else. Case and a trailing dot are forgiven. Pure. */
|
|
29
|
+
export declare function projectWildcardHostOf(hostname: string, wildcard: {
|
|
30
|
+
hostname: string;
|
|
31
|
+
project: string;
|
|
32
|
+
excludedHostnames?: string[];
|
|
33
|
+
} | undefined): {
|
|
34
|
+
routingSlug: null;
|
|
35
|
+
project: string;
|
|
36
|
+
} | null;
|
|
37
|
+
/** A PROJECT'S OWN HOSTNAME — `iterate.example.com`, added by the project (apps/os
|
|
38
|
+
* project/custom-hostnames.ts) — is that project's apex, and one label under it names a routing
|
|
39
|
+
* slug: `notes.iterate.example.com` carries `notes`, as `notes--<project>.<hostname>` does. The
|
|
40
|
+
* hostnames a request's host could be a project's own hostname for, most specific first: the host
|
|
41
|
+
* itself (the apex), then its parent with the first label as the routing slug. The caller
|
|
42
|
+
* looks them up in that order; the first a project holds wins. Case and a trailing dot are
|
|
43
|
+
* forgiven. Pure. */
|
|
44
|
+
export declare function customHostnameCandidatesOf(host: string): {
|
|
45
|
+
hostname: string;
|
|
46
|
+
routingSlug: string | null;
|
|
47
|
+
}[];
|
|
48
|
+
/** The URL of `routingSlug` (null ⇒ the apex) in `project` under `routing`, at `path` (default "/",
|
|
49
|
+
* must start with "/"). Null when there is no ingress, or when the result would not parse back to
|
|
50
|
+
* the same address (a bad slug; a `path` that climbs out of its routing slug). subdomains: the
|
|
51
|
+
* protocol and port are `platformOrigin`'s (local dev is `http://localhost:8788`, so
|
|
52
|
+
* `http://<routingSlug>--<project>.localhost:8788/…`); paths:
|
|
53
|
+
* `<platformOrigin>/projects/<project>[/<routingSlug>]<path>`. Pure. */
|
|
54
|
+
export declare function projectUrlOf(routing: IngressRouting, platformOrigin: string, target: {
|
|
55
|
+
project: string;
|
|
56
|
+
routingSlug?: string | null;
|
|
57
|
+
path?: string;
|
|
58
|
+
}): URL | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//#region src/project-ingress.ts
|
|
2
|
+
/** THE ROUTING SLUG a project host named (`notes` for `notes--<project>.<hostname>`), as the project's
|
|
3
|
+
* config worker `fetch` reads it; absent on the apex. Written only by the platform: the edge sets or
|
|
4
|
+
* deletes it on every project-host request, and the context DO deletes it from every other
|
|
5
|
+
* expression fetch, so neither a visitor nor loaded code can pick a routing slug. */
|
|
6
|
+
const ITERATE_ROUTING_SLUG_HEADER = "x-iterate-routing-slug";
|
|
7
|
+
/** A DNS label: lowercase letters and digits, single hyphens inside. */
|
|
8
|
+
const DNS_LABEL = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
|
+
/** A routing slug: a DNS label starting with a letter. */
|
|
10
|
+
const ROUTING_SLUG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
11
|
+
/** The labels `host` has under `hostname` — `site--p.iterate.app` ⇒ `["site--p"]` — lowercased, a
|
|
12
|
+
* trailing dot (a fully-qualified Host, `site--p.base.`) dropped; null when `host` is not under
|
|
13
|
+
* `hostname` at all. */
|
|
14
|
+
function labelsUnder(host, hostname) {
|
|
15
|
+
const name = host.toLowerCase().replace(/\.$/, "");
|
|
16
|
+
const suffix = `.${hostname.toLowerCase()}`;
|
|
17
|
+
return name.endsWith(suffix) ? name.slice(0, -suffix.length).split(".") : null;
|
|
18
|
+
}
|
|
19
|
+
/** The project + routing slug `url` names under `routing`, or null when it names none. Pure. */
|
|
20
|
+
function projectAddressOf(routing, url, platformOrigin) {
|
|
21
|
+
if (!routing) return null;
|
|
22
|
+
if (routing.type === "subdomains") {
|
|
23
|
+
const labels = labelsUnder(url.hostname, routing.hostname);
|
|
24
|
+
if (!labels || labels.length > 2) return null;
|
|
25
|
+
const [first, second] = labels;
|
|
26
|
+
const separator = first.startsWith("xn--") ? -1 : first.indexOf("--");
|
|
27
|
+
const [routingSlug, project] = second !== void 0 ? [first, second] : separator === -1 ? [null, first] : [first.slice(0, separator), first.slice(separator + 2)];
|
|
28
|
+
if (!DNS_LABEL.test(project) || routingSlug !== null && !ROUTING_SLUG.test(routingSlug)) return null;
|
|
29
|
+
return {
|
|
30
|
+
routingSlug,
|
|
31
|
+
project,
|
|
32
|
+
basePath: ""
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (url.origin !== new URL(platformOrigin).origin) return null;
|
|
36
|
+
const [, prefix, project = "", routingSlug] = url.pathname.split("/");
|
|
37
|
+
if (prefix !== "projects" || !DNS_LABEL.test(project)) return null;
|
|
38
|
+
if (routingSlug === void 0 || routingSlug === "") return {
|
|
39
|
+
routingSlug: null,
|
|
40
|
+
project,
|
|
41
|
+
basePath: `/projects/${project}`
|
|
42
|
+
};
|
|
43
|
+
if (!ROUTING_SLUG.test(routingSlug)) return null;
|
|
44
|
+
return {
|
|
45
|
+
routingSlug,
|
|
46
|
+
project,
|
|
47
|
+
basePath: `/projects/${project}/${routingSlug}`
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** A PROJECT WILDCARD — an owned zone served as one project's apex (apps/os
|
|
51
|
+
* `urls.projectWildcard`, `{ hostname: "iterate.com", project: "iterate" }`): the zone's apex and
|
|
52
|
+
* every first-level name under it but the excluded ones, in the apex shape, `routingSlug: null`, so the
|
|
53
|
+
* project's config worker `fetch` answers exactly as it does on `<project>.<hostname>`. Null for
|
|
54
|
+
* anything else. Case and a trailing dot are forgiven. Pure. */
|
|
55
|
+
function projectWildcardHostOf(hostname, wildcard) {
|
|
56
|
+
if (!wildcard) return null;
|
|
57
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, "");
|
|
58
|
+
if (wildcard.excludedHostnames?.includes(normalized)) return null;
|
|
59
|
+
const suffix = `.${wildcard.hostname}`;
|
|
60
|
+
const label = normalized.endsWith(suffix) ? normalized.slice(0, -suffix.length) : null;
|
|
61
|
+
return normalized === wildcard.hostname || label && !label.includes(".") ? {
|
|
62
|
+
routingSlug: null,
|
|
63
|
+
project: wildcard.project
|
|
64
|
+
} : null;
|
|
65
|
+
}
|
|
66
|
+
/** A PROJECT'S OWN HOSTNAME — `iterate.example.com`, added by the project (apps/os
|
|
67
|
+
* project/custom-hostnames.ts) — is that project's apex, and one label under it names a routing
|
|
68
|
+
* slug: `notes.iterate.example.com` carries `notes`, as `notes--<project>.<hostname>` does. The
|
|
69
|
+
* hostnames a request's host could be a project's own hostname for, most specific first: the host
|
|
70
|
+
* itself (the apex), then its parent with the first label as the routing slug. The caller
|
|
71
|
+
* looks them up in that order; the first a project holds wins. Case and a trailing dot are
|
|
72
|
+
* forgiven. Pure. */
|
|
73
|
+
function customHostnameCandidatesOf(host) {
|
|
74
|
+
const hostname = host.toLowerCase().replace(/\.$/, "");
|
|
75
|
+
const dot = hostname.indexOf(".");
|
|
76
|
+
const [routingSlug, parent] = [hostname.slice(0, dot), hostname.slice(dot + 1)];
|
|
77
|
+
return [{
|
|
78
|
+
hostname,
|
|
79
|
+
routingSlug: null
|
|
80
|
+
}, ...dot > 0 && parent.includes(".") && ROUTING_SLUG.test(routingSlug) ? [{
|
|
81
|
+
hostname: parent,
|
|
82
|
+
routingSlug
|
|
83
|
+
}] : []];
|
|
84
|
+
}
|
|
85
|
+
/** The URL of `routingSlug` (null ⇒ the apex) in `project` under `routing`, at `path` (default "/",
|
|
86
|
+
* must start with "/"). Null when there is no ingress, or when the result would not parse back to
|
|
87
|
+
* the same address (a bad slug; a `path` that climbs out of its routing slug). subdomains: the
|
|
88
|
+
* protocol and port are `platformOrigin`'s (local dev is `http://localhost:8788`, so
|
|
89
|
+
* `http://<routingSlug>--<project>.localhost:8788/…`); paths:
|
|
90
|
+
* `<platformOrigin>/projects/<project>[/<routingSlug>]<path>`. Pure. */
|
|
91
|
+
function projectUrlOf(routing, platformOrigin, target) {
|
|
92
|
+
if (!routing) return null;
|
|
93
|
+
const path = target.path || "/";
|
|
94
|
+
if (!path.startsWith("/")) throw new Error(`projectUrlOf: path must start with "/": ${path}`);
|
|
95
|
+
const routingSlug = target.routingSlug || null;
|
|
96
|
+
const origin = new URL(platformOrigin);
|
|
97
|
+
const url = routing.type === "subdomains" ? new URL(path, `${origin.protocol}//${routingSlug ? `${routingSlug}--` : ""}${target.project}.${routing.hostname}${origin.port ? `:${origin.port}` : ""}`) : new URL(`/projects/${target.project}${routingSlug ? `/${routingSlug}` : ""}${path}`, origin.origin);
|
|
98
|
+
const parsed = projectAddressOf(routing, url, platformOrigin);
|
|
99
|
+
return parsed && parsed.project === target.project && parsed.routingSlug === routingSlug ? url : null;
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
export { ITERATE_ROUTING_SLUG_HEADER, customHostnameCandidatesOf, projectAddressOf, projectUrlOf, projectWildcardHostOf };
|
|
103
|
+
|
|
104
|
+
//# sourceMappingURL=project-ingress.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"project-ingress.mjs","names":[],"sources":["../src/project-ingress.ts"],"sourcesContent":["// project-ingress.ts — HOW PROJECTS ARE REACHED OVER HTTP, both directions in ONE pure file. A\n// deployment's `urls.ingressRouting` (apps/os app-config.ts) names the mechanism; `projectAddressOf`\n// parses a request's URL into the project and routing slug it names, `projectUrlOf` composes the URL\n// of a routing slug in a project. The platform's edge parses; the platform, the dash and an app\n// compose — one implementation, table-tested to round-trip (project-ingress.test.ts). No imports.\n//\n// The edge picks the PROJECT only: every host of a project reaches the project's config worker\n// `fetch`, with the routing slug in `x-iterate-routing-slug` (absent on the apex), and the config\n// worker routes on it in plain code.\n//\n// subdomains `<routingSlug>--<project>.<hostname>`, `<routingSlug>.<project>.<hostname>`, the apex\n// `<project>.<hostname>` — every routing slug its own origin, under one wildcard on `hostname`.\n// paths `<platformOrigin>/projects/<project>/<routingSlug>/…`, the apex `<platformOrigin>/projects/<project>/`\n// — one origin (workers.dev has no wildcard), every project under `/projects/` so the\n// platform's own paths (`/api`, `/mcp`, `/login`, …) need no reserved list; the edge\n// sandboxes what a project serves.\n\n/** THE ROUTING SLUG a project host named (`notes` for `notes--<project>.<hostname>`), as the project's\n * config worker `fetch` reads it; absent on the apex. Written only by the platform: the edge sets or\n * deletes it on every project-host request, and the context DO deletes it from every other\n * expression fetch, so neither a visitor nor loaded code can pick a routing slug. */\nexport const ITERATE_ROUTING_SLUG_HEADER = \"x-iterate-routing-slug\";\n\n/** How projects are reached over HTTP; null ⇒ no ingress (`/api` and `/mcp` still answer). */\nexport type IngressRouting = { type: \"subdomains\"; hostname: string } | { type: \"paths\" } | null;\n\n/** What a request names: the project (its slug, as written — whether it EXISTS is the directory's\n * answer), the routing slug (null ⇒ the apex; either way the project's config worker answers), and\n * the path prefix the edge strips before the config worker sees the URL (\"\" under subdomains;\n * \"/projects/<project>\" or \"/projects/<project>/<routingSlug>\" under paths). */\nexport type ProjectAddress = { project: string; routingSlug: string | null; basePath: string };\n\n/** A DNS label: lowercase letters and digits, single hyphens inside. */\nconst DNS_LABEL = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\n/** A routing slug: a DNS label starting with a letter. */\nconst ROUTING_SLUG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;\n\n/** The labels `host` has under `hostname` — `site--p.iterate.app` ⇒ `[\"site--p\"]` — lowercased, a\n * trailing dot (a fully-qualified Host, `site--p.base.`) dropped; null when `host` is not under\n * `hostname` at all. */\nfunction labelsUnder(host: string, hostname: string): string[] | null {\n const name = host.toLowerCase().replace(/\\.$/, \"\");\n const suffix = `.${hostname.toLowerCase()}`;\n return name.endsWith(suffix) ? name.slice(0, -suffix.length).split(\".\") : null;\n}\n\n/** The project + routing slug `url` names under `routing`, or null when it names none. Pure. */\nexport function projectAddressOf(\n routing: IngressRouting,\n url: URL,\n platformOrigin: string,\n): ProjectAddress | null {\n if (!routing) return null;\n if (routing.type === \"subdomains\") {\n const labels = labelsUnder(url.hostname, routing.hostname);\n if (!labels || labels.length > 2) return null; // deeper than `<routingSlug>.<project>` is not a project host\n const [first, second] = labels as [string, string?];\n const separator = first.startsWith(\"xn--\") ? -1 : first.indexOf(\"--\"); // `xn--…` is an IDN label (punycode), never `<routingSlug>--<project>`\n const [routingSlug, project] =\n // oxlint-disable-next-line iterate/simple-truthiness-check -- a PRESENT-but-empty second label (`<routingSlug>..<base>`) is the `<routingSlug>.<project>` shape (rejected below by DNS_LABEL), not the single-label `<project>` shape a truthiness check would route it to\n second !== undefined\n ? [first, second] // `<routingSlug>.<project>`\n : separator === -1\n ? [null, first] // the apex, `<project>`\n : [first.slice(0, separator), first.slice(separator + 2)]; // `<routingSlug>--<project>`\n // oxlint-disable-next-line iterate/simple-truthiness-check -- an empty routing slug (`--<project>.<base>`) must still be rejected by ROUTING_SLUG; truthiness would skip the check and admit it\n if (!DNS_LABEL.test(project) || (routingSlug !== null && !ROUTING_SLUG.test(routingSlug)))\n return null;\n return { routingSlug, project, basePath: \"\" };\n }\n if (url.origin !== new URL(platformOrigin).origin) return null;\n const [, prefix, project = \"\", routingSlug] = url.pathname.split(\"/\");\n if (prefix !== \"projects\" || !DNS_LABEL.test(project)) return null;\n // oxlint-disable-next-line iterate/simple-truthiness-check -- `/projects/<project>` and `/projects/<project>/` are both the apex; a present-but-empty next segment is not a routing slug\n if (routingSlug === undefined || routingSlug === \"\")\n return { routingSlug: null, project, basePath: `/projects/${project}` };\n if (!ROUTING_SLUG.test(routingSlug)) return null;\n return { routingSlug, project, basePath: `/projects/${project}/${routingSlug}` };\n}\n\n/** A PROJECT WILDCARD — an owned zone served as one project's apex (apps/os\n * `urls.projectWildcard`, `{ hostname: \"iterate.com\", project: \"iterate\" }`): the zone's apex and\n * every first-level name under it but the excluded ones, in the apex shape, `routingSlug: null`, so the\n * project's config worker `fetch` answers exactly as it does on `<project>.<hostname>`. Null for\n * anything else. Case and a trailing dot are forgiven. Pure. */\nexport function projectWildcardHostOf(\n hostname: string,\n wildcard: { hostname: string; project: string; excludedHostnames?: string[] } | undefined,\n): { routingSlug: null; project: string } | null {\n if (!wildcard) return null;\n const normalized = hostname.toLowerCase().replace(/\\.$/, \"\");\n if (wildcard.excludedHostnames?.includes(normalized)) return null;\n const suffix = `.${wildcard.hostname}`;\n const label = normalized.endsWith(suffix) ? normalized.slice(0, -suffix.length) : null;\n return normalized === wildcard.hostname || (label && !label.includes(\".\"))\n ? { routingSlug: null, project: wildcard.project }\n : null;\n}\n\n/** A PROJECT'S OWN HOSTNAME — `iterate.example.com`, added by the project (apps/os\n * project/custom-hostnames.ts) — is that project's apex, and one label under it names a routing\n * slug: `notes.iterate.example.com` carries `notes`, as `notes--<project>.<hostname>` does. The\n * hostnames a request's host could be a project's own hostname for, most specific first: the host\n * itself (the apex), then its parent with the first label as the routing slug. The caller\n * looks them up in that order; the first a project holds wins. Case and a trailing dot are\n * forgiven. Pure. */\nexport function customHostnameCandidatesOf(\n host: string,\n): { hostname: string; routingSlug: string | null }[] {\n const hostname = host.toLowerCase().replace(/\\.$/, \"\");\n const dot = hostname.indexOf(\".\");\n const [routingSlug, parent] = [hostname.slice(0, dot), hostname.slice(dot + 1)];\n return [\n { hostname, routingSlug: null },\n ...(dot > 0 && parent.includes(\".\") && ROUTING_SLUG.test(routingSlug)\n ? [{ hostname: parent, routingSlug }]\n : []),\n ];\n}\n\n/** The URL of `routingSlug` (null ⇒ the apex) in `project` under `routing`, at `path` (default \"/\",\n * must start with \"/\"). Null when there is no ingress, or when the result would not parse back to\n * the same address (a bad slug; a `path` that climbs out of its routing slug). subdomains: the\n * protocol and port are `platformOrigin`'s (local dev is `http://localhost:8788`, so\n * `http://<routingSlug>--<project>.localhost:8788/…`); paths:\n * `<platformOrigin>/projects/<project>[/<routingSlug>]<path>`. Pure. */\nexport function projectUrlOf(\n routing: IngressRouting,\n platformOrigin: string,\n target: { project: string; routingSlug?: string | null; path?: string },\n): URL | null {\n if (!routing) return null;\n const path = target.path || \"/\";\n if (!path.startsWith(\"/\")) throw new Error(`projectUrlOf: path must start with \"/\": ${path}`);\n const routingSlug = target.routingSlug || null;\n const origin = new URL(platformOrigin);\n const url =\n routing.type === \"subdomains\"\n ? new URL(\n path,\n `${origin.protocol}//${routingSlug ? `${routingSlug}--` : \"\"}${target.project}.${routing.hostname}${origin.port ? `:${origin.port}` : \"\"}`,\n )\n : new URL(\n `/projects/${target.project}${routingSlug ? `/${routingSlug}` : \"\"}${path}`,\n origin.origin,\n );\n const parsed = projectAddressOf(routing, url, platformOrigin);\n return parsed && parsed.project === target.project && parsed.routingSlug === routingSlug\n ? url\n : null;\n}\n"],"mappings":"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"}
|
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { connectLiveState } from "./client.mjs";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
//#region src/client/react.tsx
|
|
4
|
+
/** @jsxImportSource react */
|
|
5
|
+
/** Subscribe to a producer's live state and render its latest value. Pass a ready `itx` (a capnweb
|
|
6
|
+
* `api.authenticate(credentials).user` or `.projects.get(id)`), the producer's `key`, and a `readSeed`
|
|
7
|
+
* thunk that reads `{rev, state}` (`() => itx.invoke("itx.facets.get('slug').liveSnapshot()")`).
|
|
8
|
+
* Re-subscribes when the session, `key`, or `name` changes; unmount (and every re-subscribe)
|
|
9
|
+
* disposes the previous server-side subscription. */
|
|
10
|
+
function useLiveState(itx, opts) {
|
|
11
|
+
const [store, setStore] = useState();
|
|
12
|
+
const [status, setStatus] = useState("connecting");
|
|
13
|
+
const [error, setError] = useState();
|
|
14
|
+
const readSeedRef = useRef(opts.readSeed);
|
|
15
|
+
readSeedRef.current = opts.readSeed;
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
setStore(void 0);
|
|
18
|
+
setStatus("connecting");
|
|
19
|
+
setError(void 0);
|
|
20
|
+
if (!itx) return;
|
|
21
|
+
const readSeed = readSeedRef.current;
|
|
22
|
+
let disposed = false;
|
|
23
|
+
let dispose;
|
|
24
|
+
const unmounted = new AbortController();
|
|
25
|
+
connectLiveState(itx, {
|
|
26
|
+
key: opts.key,
|
|
27
|
+
name: opts.name,
|
|
28
|
+
readSeed,
|
|
29
|
+
signal: unmounted.signal,
|
|
30
|
+
onResync: (r) => {
|
|
31
|
+
if (disposed) return;
|
|
32
|
+
if (r === "healed") {
|
|
33
|
+
setStatus("live");
|
|
34
|
+
setError(void 0);
|
|
35
|
+
} else {
|
|
36
|
+
setStatus("error");
|
|
37
|
+
setError(r.message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}).then((conn) => {
|
|
41
|
+
dispose = conn.dispose;
|
|
42
|
+
if (disposed) {
|
|
43
|
+
conn.dispose();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
setStore(conn.store);
|
|
47
|
+
setStatus("live");
|
|
48
|
+
}, (e) => {
|
|
49
|
+
if (disposed) return;
|
|
50
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
51
|
+
setStatus("error");
|
|
52
|
+
});
|
|
53
|
+
return () => {
|
|
54
|
+
disposed = true;
|
|
55
|
+
unmounted.abort();
|
|
56
|
+
dispose?.();
|
|
57
|
+
};
|
|
58
|
+
}, [
|
|
59
|
+
itx,
|
|
60
|
+
opts.key,
|
|
61
|
+
opts.name
|
|
62
|
+
]);
|
|
63
|
+
return {
|
|
64
|
+
value: useSyncExternalStore(useCallback((cb) => store ? store.subscribe(cb) : () => {}, [store]), () => store?.get(), () => void 0),
|
|
65
|
+
rev: store?.rev() ?? null,
|
|
66
|
+
status,
|
|
67
|
+
error
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** A wire event (a capnweb proxy value or a plain object) as a `StreamEvent`, or null when
|
|
71
|
+
* it is not a committed row. Structural, not a schema: the transport validated it; this only refuses
|
|
72
|
+
* a shape the view cannot place (no offset, type or time). */
|
|
73
|
+
function toStreamEvent(raw) {
|
|
74
|
+
const value = JSON.parse(JSON.stringify(raw));
|
|
75
|
+
if (!value || typeof value.offset !== "number" || typeof value.type !== "string" || typeof value.createdAt !== "string") return null;
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
/** A named live state before its first seed lands — and before the effect that opens it has run. */
|
|
79
|
+
const LIVE_STATE_CONNECTING = {
|
|
80
|
+
value: void 0,
|
|
81
|
+
rev: null,
|
|
82
|
+
status: "connecting"
|
|
83
|
+
};
|
|
84
|
+
/** THE ITERATE CONTEXT, live — one hook, one stream subscription. THE LOG: subscribe to every
|
|
85
|
+
* committed event (or `consumes`) BEFORE the catch-up read, so nothing lands between the two;
|
|
86
|
+
* pushes and pages both dedupe into one map by offset; `caughtUp` once the read reached the head;
|
|
87
|
+
* `error` when the connect failed. Off that same log, THE PROCESSORS TABLE, re-read whenever the
|
|
88
|
+
* log grows a row-changing event (a subscription configured, halted or resumed — the table is core
|
|
89
|
+
* state, one call away, no push of its own), and WHO IS HERE: the rpc stubs lent right now
|
|
90
|
+
* (`itx.rpcStubs.list()` — physical, re-read at every new head, since presence changes are
|
|
91
|
+
* ephemeral facts) and, from the log, every principal that acted, newest first. And named facets'
|
|
92
|
+
* LIVE STATE, each seeded through `itx.facets.get('<name>').liveSnapshot()` — one entry per name,
|
|
93
|
+
* always. `liveState` OMITTED opens `core` (the core reduce answers under that name) plus every
|
|
94
|
+
* hosted facet in the processors table the hook holds, following the table as it loads and changes;
|
|
95
|
+
* `liveState` GIVEN is exactly the names to open, no implicit `core`. Re-connects when `itx`
|
|
96
|
+
* changes; unmount disposes every server-side subscription. */
|
|
97
|
+
function useIterateContext(itx, opts = {}) {
|
|
98
|
+
const [events, setEvents] = useState(() => /* @__PURE__ */ new Map());
|
|
99
|
+
const [caughtUp, setCaughtUp] = useState(false);
|
|
100
|
+
const [error, setError] = useState();
|
|
101
|
+
const consumesKey = JSON.stringify(opts.consumes || ["*"]);
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
setEvents(/* @__PURE__ */ new Map());
|
|
104
|
+
setCaughtUp(false);
|
|
105
|
+
setError(void 0);
|
|
106
|
+
if (!itx) return;
|
|
107
|
+
let disposed = false;
|
|
108
|
+
const merge = (batch) => setEvents((held) => {
|
|
109
|
+
const next = new Map(held);
|
|
110
|
+
for (const raw of batch) {
|
|
111
|
+
const event = toStreamEvent(raw);
|
|
112
|
+
if (event) next.set(event.offset, event);
|
|
113
|
+
}
|
|
114
|
+
return next;
|
|
115
|
+
});
|
|
116
|
+
let subscription;
|
|
117
|
+
(async () => {
|
|
118
|
+
const handle = await itx.subscribe({
|
|
119
|
+
consumes: JSON.parse(consumesKey),
|
|
120
|
+
target: (batch) => !disposed && merge(batch)
|
|
121
|
+
});
|
|
122
|
+
if (disposed) {
|
|
123
|
+
handle[Symbol.dispose]();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
subscription = handle;
|
|
127
|
+
for (let after = 0;;) {
|
|
128
|
+
const page = await itx.readEvents(after, 500);
|
|
129
|
+
if (disposed) return;
|
|
130
|
+
merge(page.events);
|
|
131
|
+
if (page.atHead || page.scannedThroughOffset <= after) break;
|
|
132
|
+
after = page.scannedThroughOffset;
|
|
133
|
+
}
|
|
134
|
+
setCaughtUp(true);
|
|
135
|
+
})().catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e)));
|
|
136
|
+
return () => {
|
|
137
|
+
disposed = true;
|
|
138
|
+
subscription?.[Symbol.dispose]();
|
|
139
|
+
};
|
|
140
|
+
}, [itx, consumesKey]);
|
|
141
|
+
const sorted = useMemo(() => [...events.values()].sort((a, b) => a.offset - b.offset), [events]);
|
|
142
|
+
const [table, setTable] = useState();
|
|
143
|
+
const [failure, setFailure] = useState();
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!itx) return;
|
|
146
|
+
let disposed = false;
|
|
147
|
+
Promise.resolve(itx.processors.list()).then((list) => {
|
|
148
|
+
if (disposed) return;
|
|
149
|
+
setTable({
|
|
150
|
+
itx,
|
|
151
|
+
rows: list
|
|
152
|
+
});
|
|
153
|
+
setFailure(void 0);
|
|
154
|
+
}, (e) => !disposed && setFailure({
|
|
155
|
+
itx,
|
|
156
|
+
message: e instanceof Error ? e.message : String(e)
|
|
157
|
+
}));
|
|
158
|
+
return () => {
|
|
159
|
+
disposed = true;
|
|
160
|
+
};
|
|
161
|
+
}, [itx, sorted.reduce((last, event) => event.type.startsWith("events.iterate.com/itx/subscription-") ? event.offset : last, 0)]);
|
|
162
|
+
const currentTable = itx && table?.itx === itx ? table : void 0;
|
|
163
|
+
const [census, setCensus] = useState();
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!itx) return;
|
|
166
|
+
let disposed = false;
|
|
167
|
+
Promise.resolve(itx.rpcStubs.list()).then((list) => !disposed && setCensus({
|
|
168
|
+
itx,
|
|
169
|
+
rpcStubs: list
|
|
170
|
+
}), () => void 0);
|
|
171
|
+
return () => {
|
|
172
|
+
disposed = true;
|
|
173
|
+
};
|
|
174
|
+
}, [itx, sorted.at(-1)?.offset ?? 0]);
|
|
175
|
+
const rpcStubs = itx && census?.itx === itx ? census.rpcStubs : [];
|
|
176
|
+
const actors = useMemo(() => {
|
|
177
|
+
const byActor = /* @__PURE__ */ new Map();
|
|
178
|
+
for (const event of sorted) {
|
|
179
|
+
const principal = event.source?.principal;
|
|
180
|
+
if (!principal) continue;
|
|
181
|
+
byActor.set(principal.actor, {
|
|
182
|
+
actor: principal.actor,
|
|
183
|
+
email: principal.email,
|
|
184
|
+
grant: event.source?.grant,
|
|
185
|
+
lastSeenAt: event.createdAt
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return [...byActor.values()].sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
|
|
189
|
+
}, [sorted]);
|
|
190
|
+
const liveStateKey = JSON.stringify(opts.liveState || ["core", ...(currentTable?.rows || []).flatMap((row) => row.hostedFacet ? [row.hostedFacet.name] : [])]);
|
|
191
|
+
const [liveStates, setLiveStates] = useState();
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (!itx) return;
|
|
194
|
+
const names = JSON.parse(liveStateKey);
|
|
195
|
+
if (names.length === 0) return;
|
|
196
|
+
let disposed = false;
|
|
197
|
+
const unmounted = new AbortController();
|
|
198
|
+
const disposers = [];
|
|
199
|
+
const patch = (name, change) => setLiveStates((held) => held && held.itx === itx && held.key === liveStateKey ? {
|
|
200
|
+
...held,
|
|
201
|
+
entries: {
|
|
202
|
+
...held.entries,
|
|
203
|
+
[name]: {
|
|
204
|
+
...held.entries[name],
|
|
205
|
+
...change
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} : held);
|
|
209
|
+
setLiveStates({
|
|
210
|
+
itx,
|
|
211
|
+
key: liveStateKey,
|
|
212
|
+
entries: Object.fromEntries(names.map((name) => [name, LIVE_STATE_CONNECTING]))
|
|
213
|
+
});
|
|
214
|
+
for (const name of names) connectLiveState(itx, {
|
|
215
|
+
key: name,
|
|
216
|
+
readSeed: async () => await itx.invoke(`itx.facets.get('${name}').liveSnapshot()`),
|
|
217
|
+
signal: unmounted.signal,
|
|
218
|
+
onResync: (result) => {
|
|
219
|
+
if (disposed) return;
|
|
220
|
+
if (result === "healed") patch(name, {
|
|
221
|
+
status: "live",
|
|
222
|
+
error: void 0
|
|
223
|
+
});
|
|
224
|
+
else patch(name, {
|
|
225
|
+
status: "error",
|
|
226
|
+
error: result.message
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}).then((connection) => {
|
|
230
|
+
if (disposed) {
|
|
231
|
+
connection.dispose();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
disposers.push(connection.dispose);
|
|
235
|
+
disposers.push(connection.store.subscribe(() => patch(name, {
|
|
236
|
+
value: connection.store.get(),
|
|
237
|
+
rev: connection.store.rev()
|
|
238
|
+
})));
|
|
239
|
+
patch(name, {
|
|
240
|
+
value: connection.store.get(),
|
|
241
|
+
rev: connection.store.rev(),
|
|
242
|
+
status: "live"
|
|
243
|
+
});
|
|
244
|
+
}, (e) => {
|
|
245
|
+
if (disposed) return;
|
|
246
|
+
patch(name, {
|
|
247
|
+
status: "error",
|
|
248
|
+
error: e instanceof Error ? e.message : String(e)
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
return () => {
|
|
252
|
+
disposed = true;
|
|
253
|
+
unmounted.abort();
|
|
254
|
+
for (const dispose of disposers) dispose();
|
|
255
|
+
};
|
|
256
|
+
}, [itx, liveStateKey]);
|
|
257
|
+
const liveState = useMemo(() => {
|
|
258
|
+
const names = JSON.parse(liveStateKey);
|
|
259
|
+
const held = itx && liveStates?.itx === itx && liveStates.key === liveStateKey ? liveStates.entries : {};
|
|
260
|
+
return Object.fromEntries(names.map((name) => [name, held[name] || LIVE_STATE_CONNECTING]));
|
|
261
|
+
}, [
|
|
262
|
+
itx,
|
|
263
|
+
liveStateKey,
|
|
264
|
+
liveStates
|
|
265
|
+
]);
|
|
266
|
+
return {
|
|
267
|
+
events: sorted,
|
|
268
|
+
caughtUp,
|
|
269
|
+
error,
|
|
270
|
+
processors: {
|
|
271
|
+
rows: currentTable?.rows || [],
|
|
272
|
+
loaded: Boolean(currentTable),
|
|
273
|
+
error: itx && failure?.itx === itx ? failure.message : void 0
|
|
274
|
+
},
|
|
275
|
+
presence: {
|
|
276
|
+
actors,
|
|
277
|
+
rpcStubs
|
|
278
|
+
},
|
|
279
|
+
liveState
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
//#endregion
|
|
283
|
+
export { useIterateContext, useLiveState };
|
|
284
|
+
|
|
285
|
+
//# sourceMappingURL=react.mjs.map
|