@k2b/cloud 0.22.0 → 0.23.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/package.json +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/define-app.ts +81 -7
- package/src/_internal/page-responses.ts +19 -3
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/client/controller.ts +17 -3
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/live-routes.ts +2 -2
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +161 -0
- package/src/api/pwa.ts +179 -0
- package/src/browser/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +826 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +150 -30
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pwa-devices.ts +574 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/extensions/info-blocks.ts +18 -47
- package/src/shared/markdown/index.ts +5 -14
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +69 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/global.css +6 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
package/src/api/pwa.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
2
|
+
import { bodyLimit } from "hono/body-limit";
|
|
3
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
import { describeRoute } from "hono-openapi";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import {
|
|
7
|
+
isPwaShellAvailable,
|
|
8
|
+
PWA_LIMITS,
|
|
9
|
+
PwaDeviceListSchema,
|
|
10
|
+
PwaPairingConfirmSchema,
|
|
11
|
+
PwaPairingStartResultSchema,
|
|
12
|
+
PwaPairingStatusSchema,
|
|
13
|
+
} from "../contracts/pwa";
|
|
14
|
+
import { type AuthContext, auth, jsonResponse, rateLimit, v } from "../server";
|
|
15
|
+
import { logger } from "../services/logging";
|
|
16
|
+
import { PwaError, pwaDevices } from "../services/pwa-devices";
|
|
17
|
+
import * as settings from "../services/settings";
|
|
18
|
+
import { publicCloudOrigin } from "../shared/app-url";
|
|
19
|
+
import { getRuntimeContext } from "../ssr/runtime";
|
|
20
|
+
|
|
21
|
+
export type PwaRouteOptions = {
|
|
22
|
+
service?: typeof pwaDevices;
|
|
23
|
+
/** True while the mobile app (the `pwa` application) is registered. */
|
|
24
|
+
shellAvailable?: (c: Context) => boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const log = logger("pwa");
|
|
28
|
+
|
|
29
|
+
const MESSAGES: Record<PwaError["code"], string> = {
|
|
30
|
+
UNAVAILABLE: "The mobile app is not available right now.",
|
|
31
|
+
REAUTHENTICATE: "Sign in again to pair a phone.",
|
|
32
|
+
FORBIDDEN: "Use Cloud on the web for this.",
|
|
33
|
+
ACCOUNT_BLOCKED: "This account cannot use the mobile app right now.",
|
|
34
|
+
INVALID_REQUEST: "The request is not valid.",
|
|
35
|
+
NOT_FOUND: "Not found.",
|
|
36
|
+
EXPIRED: "This pairing has expired.",
|
|
37
|
+
ALREADY_USED: "This pairing link was already used.",
|
|
38
|
+
WRONG_CODE: "The code does not match.",
|
|
39
|
+
CONFLICT: "The pairing is not at this step.",
|
|
40
|
+
ALREADY_PAIRED: "This app is paired with another account.",
|
|
41
|
+
ACCOUNT_MISMATCH: "This browser is signed in to another account.",
|
|
42
|
+
LIMIT_REACHED: "The limit for this account was reached.",
|
|
43
|
+
UNPAIRED: "This phone is not paired.",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** `{ code, message }` with a safe English message; the UI maps codes to its own catalog. */
|
|
47
|
+
export const pwaErrorResponse = (c: Context, error: PwaError) =>
|
|
48
|
+
c.json(
|
|
49
|
+
{ code: error.code, message: MESSAGES[error.code], ...(error.attemptsLeft !== undefined ? { attemptsLeft: error.attemptsLeft } : {}) },
|
|
50
|
+
error.status,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
export const pwaInvalidRequest = () => ({ code: "INVALID_REQUEST", message: MESSAGES.INVALID_REQUEST });
|
|
54
|
+
|
|
55
|
+
/** Never log bodies, cookies, secrets, keys or codes: only the error name. */
|
|
56
|
+
export const handlePwaError = (error: Error, c: Context) => {
|
|
57
|
+
if (error instanceof PwaError) return pwaErrorResponse(c, error);
|
|
58
|
+
if (error instanceof HTTPException && error.status < 500) return pwaErrorResponse(c, new PwaError("INVALID_REQUEST", 400));
|
|
59
|
+
log.error("Mobile app operation failed", { error: error.name || "UnknownError" });
|
|
60
|
+
return pwaErrorResponse(c, new PwaError("UNAVAILABLE", 503));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Reads the live registry that Core's runtime middleware puts on every request. */
|
|
64
|
+
export const defaultShellAvailable = (c: Context): boolean => isPwaShellAvailable(getRuntimeContext(c).apps);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* No caching or referrers, JSON bodies within the limit, and an `Origin` equal to the
|
|
68
|
+
* operator's canonical address on every non-GET request. Forwarded headers are never
|
|
69
|
+
* trusted: behind the gateway they describe the internal hop.
|
|
70
|
+
*/
|
|
71
|
+
export const pwaTransport = (): MiddlewareHandler[] => [
|
|
72
|
+
async (c, next) => {
|
|
73
|
+
c.header("Cache-Control", "no-store");
|
|
74
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
75
|
+
c.header("Vary", "Origin");
|
|
76
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
77
|
+
const origin = c.req.header("Origin");
|
|
78
|
+
if (!origin || origin !== publicCloudOrigin(await settings.get<string>("app.url"))) {
|
|
79
|
+
return pwaErrorResponse(c, new PwaError("FORBIDDEN", 403));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await next();
|
|
83
|
+
},
|
|
84
|
+
bodyLimit({ maxSize: PWA_LIMITS.bodyBytes, onError: (c) => pwaErrorResponse(c, new PwaError("INVALID_REQUEST", 400)) }),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const IdParamSchema = z.object({ id: z.string().uuid() });
|
|
88
|
+
|
|
89
|
+
/** The web session of a person: never the mobile app's session, an API key or an OAuth token. */
|
|
90
|
+
const webActor = async (c: Context) => {
|
|
91
|
+
if (c.req.header("Authorization")) throw new PwaError("FORBIDDEN", 403);
|
|
92
|
+
const token = auth.session.getWebToken(c);
|
|
93
|
+
// Only the mobile app's session: pairing phones needs the web, a new sign-in would not help.
|
|
94
|
+
if (!token && auth.session.getAppToken(c)) throw new PwaError("FORBIDDEN", 403);
|
|
95
|
+
const session = token ? await auth.session.authenticateRequest(c, token) : null;
|
|
96
|
+
if (!session) throw new PwaError("REAUTHENTICATE", 403);
|
|
97
|
+
if (session.data.kind !== "web") throw new PwaError("FORBIDDEN", 403);
|
|
98
|
+
return { userId: session.user.id, sid: session.data.sid };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The phone this request also comes from (Chrome on a paired Android phone shares the app's cookies). */
|
|
102
|
+
const currentDeviceId = async (c: Context, userId: string): Promise<string | null> => {
|
|
103
|
+
const token = auth.session.getAppToken(c);
|
|
104
|
+
const session = token ? await auth.session.authenticateRequest(c, token) : null;
|
|
105
|
+
return session?.data.kind === "app" && session.user.id === userId ? (session.data.deviceId ?? null) : null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const tags = ["Mobile app"];
|
|
109
|
+
|
|
110
|
+
/** Web side of the mobile app (preview), mounted by Core at `/api/auth/pwa/v1`. */
|
|
111
|
+
export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
|
|
112
|
+
const service = options.service ?? pwaDevices;
|
|
113
|
+
const shellAvailable = options.shellAvailable ?? defaultShellAvailable;
|
|
114
|
+
return new Hono<AuthContext>()
|
|
115
|
+
.onError(handlePwaError)
|
|
116
|
+
.use("*", ...pwaTransport())
|
|
117
|
+
.use("*", rateLimit())
|
|
118
|
+
.post(
|
|
119
|
+
"/pairings",
|
|
120
|
+
describeRoute({
|
|
121
|
+
tags,
|
|
122
|
+
summary: "Start pairing a phone",
|
|
123
|
+
description: "Needs a web sign-in from the last ten minutes. The link secret is returned once.",
|
|
124
|
+
responses: { 201: jsonResponse(PwaPairingStartResultSchema, "Pairing started") },
|
|
125
|
+
}),
|
|
126
|
+
async (c) => {
|
|
127
|
+
const actor = await webActor(c);
|
|
128
|
+
if (!shellAvailable(c)) throw new PwaError("UNAVAILABLE", 503);
|
|
129
|
+
return c.json(await service.startPairing(actor), 201);
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
.get(
|
|
133
|
+
"/pairings/:id",
|
|
134
|
+
describeRoute({
|
|
135
|
+
tags,
|
|
136
|
+
summary: "Read a pairing",
|
|
137
|
+
description: "Only for the web session that started it. Never returns the code.",
|
|
138
|
+
responses: { 200: jsonResponse(PwaPairingStatusSchema, "Pairing state") },
|
|
139
|
+
}),
|
|
140
|
+
v("param", IdParamSchema, pwaInvalidRequest),
|
|
141
|
+
async (c) => c.json(await service.inspectPairing(await webActor(c), c.req.valid("param").id)),
|
|
142
|
+
)
|
|
143
|
+
.post(
|
|
144
|
+
"/pairings/:id/confirm",
|
|
145
|
+
describeRoute({ tags, summary: "Confirm a pairing with the code the phone shows", responses: { 204: { description: "Confirmed" } } }),
|
|
146
|
+
v("param", IdParamSchema, pwaInvalidRequest),
|
|
147
|
+
v("json", PwaPairingConfirmSchema, pwaInvalidRequest),
|
|
148
|
+
async (c) => {
|
|
149
|
+
await service.confirmPairing(await webActor(c), c.req.valid("param").id, c.req.valid("json").code);
|
|
150
|
+
return c.body(null, 204);
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
.post(
|
|
154
|
+
"/pairings/:id/cancel",
|
|
155
|
+
describeRoute({ tags, summary: "Cancel a pairing", responses: { 204: { description: "Cancelled" } } }),
|
|
156
|
+
v("param", IdParamSchema, pwaInvalidRequest),
|
|
157
|
+
async (c) => {
|
|
158
|
+
await service.cancelPairing(await webActor(c), c.req.valid("param").id);
|
|
159
|
+
return c.body(null, 204);
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
.get(
|
|
163
|
+
"/devices",
|
|
164
|
+
describeRoute({ tags, summary: "List paired phones", responses: { 200: jsonResponse(PwaDeviceListSchema, "Active phones") } }),
|
|
165
|
+
async (c) => {
|
|
166
|
+
const actor = await webActor(c);
|
|
167
|
+
return c.json({ items: await service.list(actor, await currentDeviceId(c, actor.userId)) });
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
.delete(
|
|
171
|
+
"/devices/:id",
|
|
172
|
+
describeRoute({ tags, summary: "Remove a paired phone", responses: { 204: { description: "Removed; idempotent" } } }),
|
|
173
|
+
v("param", IdParamSchema, pwaInvalidRequest),
|
|
174
|
+
async (c) => {
|
|
175
|
+
await service.revoke(await webActor(c), c.req.valid("param").id);
|
|
176
|
+
return c.body(null, 204);
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { PWA_AUTH_PATH, PWA_SCOPE } from "../contracts/pwa-paths";
|
|
2
|
+
|
|
3
|
+
export type AppSessionRenewal = {
|
|
4
|
+
/** The page has a valid app session now. */
|
|
5
|
+
ok: boolean;
|
|
6
|
+
/** Android shares Chrome's cookies with the app, so the web session there may belong to another account. */
|
|
7
|
+
otherAccount?: { name: string };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Whether this document is a page of the mobile app, whose requests carry the app session. */
|
|
11
|
+
export const insideMobileApp = (): boolean =>
|
|
12
|
+
typeof location !== "undefined" && location.pathname.startsWith(PWA_SCOPE) && !location.pathname.startsWith(`${PWA_AUTH_PATH}/`);
|
|
13
|
+
|
|
14
|
+
const call = async (): Promise<AppSessionRenewal & { renewed: boolean }> => {
|
|
15
|
+
let response: Response;
|
|
16
|
+
try {
|
|
17
|
+
response = await fetch(`${PWA_AUTH_PATH}/session/renew`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
credentials: "same-origin",
|
|
20
|
+
headers: { Accept: "application/json" },
|
|
21
|
+
});
|
|
22
|
+
} catch {
|
|
23
|
+
// Offline or unreachable: the next foreground or request tries again.
|
|
24
|
+
return { ok: false, renewed: false };
|
|
25
|
+
}
|
|
26
|
+
if (response.status === 401) {
|
|
27
|
+
// The phone was removed or its pairing ended. Core has already deleted the device key with this answer, so
|
|
28
|
+
// a reload could only show the generic pairing view; the pairing view for a signed-out phone says why.
|
|
29
|
+
location.replace(`${PWA_SCOPE}?pwa=ended`);
|
|
30
|
+
return { ok: false, renewed: false };
|
|
31
|
+
}
|
|
32
|
+
if (response.status === 403) {
|
|
33
|
+
const body: unknown = await response.json().catch(() => null);
|
|
34
|
+
if (typeof body === "object" && body !== null && "code" in body && body.code === "ACCOUNT_BLOCKED") {
|
|
35
|
+
location.replace(`${PWA_SCOPE}?pwa=blocked`);
|
|
36
|
+
}
|
|
37
|
+
return { ok: false, renewed: false };
|
|
38
|
+
}
|
|
39
|
+
if (!response.ok) return { ok: false, renewed: false };
|
|
40
|
+
// `PwaRenewResultSchema`, read by hand: the typed client on every page must not load the validators.
|
|
41
|
+
const body: unknown = await response.json().catch(() => null);
|
|
42
|
+
if (typeof body !== "object" || body === null || !("renewed" in body) || typeof body.renewed !== "boolean") {
|
|
43
|
+
return { ok: false, renewed: false };
|
|
44
|
+
}
|
|
45
|
+
const other = "otherAccount" in body ? body.otherAccount : undefined;
|
|
46
|
+
const otherAccount =
|
|
47
|
+
typeof other === "object" && other !== null && "name" in other && typeof other.name === "string" ? { name: other.name } : undefined;
|
|
48
|
+
return { ok: true, renewed: body.renewed, otherAccount };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let running: Promise<AppSessionRenewal> | undefined;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Renews the mobile app's session with the phone's device key through Core. One renewal runs at a time in a page,
|
|
55
|
+
* and every caller shares it. An ended pairing or a blocked account leaves for the app's matching Start view.
|
|
56
|
+
*/
|
|
57
|
+
export const renewAppSession = (): Promise<AppSessionRenewal> => {
|
|
58
|
+
running ??= (async () => {
|
|
59
|
+
try {
|
|
60
|
+
const first = await call();
|
|
61
|
+
// After a rotation, a second call presents the new key and retires the old one.
|
|
62
|
+
if (first.renewed) await call();
|
|
63
|
+
return { ok: first.ok, otherAccount: first.otherAccount };
|
|
64
|
+
} finally {
|
|
65
|
+
running = undefined;
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
return running;
|
|
69
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { type LiveServerMessage, LiveServerMessageSchema } from "../events/live-protocol";
|
|
2
|
+
import { createLiveWebSocket, type LiveWebSocket } from "./live-websocket";
|
|
3
|
+
|
|
4
|
+
export type LiveSubscriptionHandlers<T> = {
|
|
5
|
+
/** The cursor the server-rendered state belongs to; `null` starts at the current position. */
|
|
6
|
+
cursor: string | null;
|
|
7
|
+
/** Validates one event's data. A failure reloads the state through `resync`. */
|
|
8
|
+
parse: (data: unknown) => T;
|
|
9
|
+
/** Applies events in order. Idempotent: reconnects and retries can repeat an event. */
|
|
10
|
+
apply: (events: { data: T; cursor: string }[]) => Promise<void>;
|
|
11
|
+
/** Loads the canonical state again; events after it follow. */
|
|
12
|
+
resync: () => Promise<void>;
|
|
13
|
+
/** The subscription ended because its resource is gone or no longer readable. */
|
|
14
|
+
revoked?: (code: "not_found" | "access_denied") => void;
|
|
15
|
+
/** Live updates stopped: the session ended, or `apply` or `resync` kept failing. Never called after `close()`. */
|
|
16
|
+
unavailable: () => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type LiveSubscription = { close: () => void };
|
|
20
|
+
|
|
21
|
+
export type LiveConnection = {
|
|
22
|
+
subscribe: <T>(channel: string, scope: unknown, handlers: LiveSubscriptionHandlers<T>) => LiveSubscription;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Subscriber = {
|
|
26
|
+
frame: () => unknown;
|
|
27
|
+
confirmed: boolean;
|
|
28
|
+
receive: (message: LiveServerMessage) => void;
|
|
29
|
+
end: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type Shared = { socket: LiveWebSocket; subscribers: Map<string, Subscriber>; next: number };
|
|
33
|
+
|
|
34
|
+
const RETRY_DELAYS_MS = [1_000, 3_000, 9_000];
|
|
35
|
+
const MAX_BATCH = 100;
|
|
36
|
+
/** Events that may wait for `apply`; more collapse into one `resync`, which covers them. */
|
|
37
|
+
const MAX_WAITING = 10 * MAX_BATCH;
|
|
38
|
+
|
|
39
|
+
/** One socket per URL and page, shared by every subscription on it. */
|
|
40
|
+
const shared = new Map<string, Shared>();
|
|
41
|
+
|
|
42
|
+
const parseMessage = (raw: string): LiveServerMessage | null => {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = LiveServerMessageSchema.safeParse(JSON.parse(raw));
|
|
45
|
+
return parsed.success ? parsed.data : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const connect = (url: string, activity: "visible" | "always"): Shared => {
|
|
52
|
+
const subscribers = new Map<string, Subscriber>();
|
|
53
|
+
const socket = createLiveWebSocket<LiveServerMessage>({
|
|
54
|
+
url,
|
|
55
|
+
activity,
|
|
56
|
+
parse: parseMessage,
|
|
57
|
+
onOpen: (controls) => {
|
|
58
|
+
for (const subscriber of subscribers.values()) {
|
|
59
|
+
subscriber.confirmed = false;
|
|
60
|
+
controls.send(subscriber.frame());
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
onMessage: (message) => {
|
|
64
|
+
if (message.t === "progress") {
|
|
65
|
+
for (const subscriber of subscribers.values()) if (subscriber.confirmed) subscriber.receive(message);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (message.t !== "error") subscribers.get(message.id)?.receive(message);
|
|
69
|
+
},
|
|
70
|
+
onFatal: () => {
|
|
71
|
+
if (shared.get(url)?.socket === socket) shared.delete(url);
|
|
72
|
+
for (const subscriber of [...subscribers.values()]) subscriber.end();
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
return { socket, subscribers, next: 0 };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type Work<T> = { kind: "events"; events: { data: T; cursor: string }[] } | { kind: "mark" | "resync"; cursor: string };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Subscribes to channels of an application's live socket (`/api/<app>/live`).
|
|
82
|
+
* Each subscription applies its events serially and moves its cursor only after
|
|
83
|
+
* `apply` resolved or at a server mark, so a reconnect resumes exactly there.
|
|
84
|
+
* Only `resync` reloads state; a returning tab replays what it missed.
|
|
85
|
+
*/
|
|
86
|
+
export const liveConnection = (url: string, options: { activity?: "visible" | "always" } = {}): LiveConnection => ({
|
|
87
|
+
subscribe: <T>(channel: string, scope: unknown, handlers: LiveSubscriptionHandlers<T>): LiveSubscription => {
|
|
88
|
+
let connection = shared.get(url);
|
|
89
|
+
if (!connection) {
|
|
90
|
+
connection = connect(url, options.activity ?? "visible");
|
|
91
|
+
shared.set(url, connection);
|
|
92
|
+
}
|
|
93
|
+
const { socket, subscribers } = connection;
|
|
94
|
+
const id = String(++connection.next);
|
|
95
|
+
let cursor = handlers.cursor;
|
|
96
|
+
/** The cursor of a `resync` that has not finished: a reconnect resumes after it. */
|
|
97
|
+
let resyncAt: string | null = null;
|
|
98
|
+
let ended = false;
|
|
99
|
+
let running = false;
|
|
100
|
+
const work: Work<T>[] = [];
|
|
101
|
+
|
|
102
|
+
const attempt = async (run: () => Promise<void>): Promise<boolean> => {
|
|
103
|
+
for (let tries = 0; ; tries++) {
|
|
104
|
+
try {
|
|
105
|
+
await run();
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
const delay = RETRY_DELAYS_MS[tries];
|
|
109
|
+
if (delay === undefined || ended) return false;
|
|
110
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
111
|
+
if (ended) return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const drain = async () => {
|
|
117
|
+
if (running) return;
|
|
118
|
+
running = true;
|
|
119
|
+
try {
|
|
120
|
+
while (work.length > 0 && !ended) {
|
|
121
|
+
const item = work.shift() as Work<T>;
|
|
122
|
+
if (item.kind === "mark") {
|
|
123
|
+
cursor = item.cursor;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const applied = item.kind === "events" ? await attempt(() => handlers.apply(item.events)) : await attempt(handlers.resync);
|
|
127
|
+
if (!applied) {
|
|
128
|
+
// A closed subscription reports nothing: its owner ended it.
|
|
129
|
+
if (ended) return;
|
|
130
|
+
stop();
|
|
131
|
+
handlers.unavailable();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
cursor = item.kind === "events" ? (item.events.at(-1)?.cursor ?? cursor) : item.cursor;
|
|
135
|
+
if (item.kind === "resync" && resyncAt === item.cursor) resyncAt = null;
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
running = false;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Work queued before a resync is covered by the reloaded state. */
|
|
143
|
+
const resync = (at: string) => {
|
|
144
|
+
work.length = 0;
|
|
145
|
+
work.push({ kind: "resync", cursor: at });
|
|
146
|
+
resyncAt = at;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const waiting = () => work.reduce((count, item) => count + (item.kind === "events" ? item.events.length : 0), 0);
|
|
150
|
+
|
|
151
|
+
const subscriber: Subscriber = {
|
|
152
|
+
frame: () => {
|
|
153
|
+
const after = resyncAt ?? cursor;
|
|
154
|
+
return { t: "sub", id, channel, scope, ...(after ? { after } : {}) };
|
|
155
|
+
},
|
|
156
|
+
confirmed: false,
|
|
157
|
+
receive: (message) => {
|
|
158
|
+
if (ended) return;
|
|
159
|
+
if (message.t === "revoked") {
|
|
160
|
+
stop();
|
|
161
|
+
handlers.revoked?.(message.code);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (message.t === "ready" || message.t === "progress") {
|
|
165
|
+
subscriber.confirmed = true;
|
|
166
|
+
work.push({ kind: "mark", cursor: message.cursor });
|
|
167
|
+
} else if (message.t === "resync") {
|
|
168
|
+
subscriber.confirmed = true;
|
|
169
|
+
resync(message.cursor);
|
|
170
|
+
} else if (message.t === "event") {
|
|
171
|
+
let data: T;
|
|
172
|
+
try {
|
|
173
|
+
data = handlers.parse(message.data);
|
|
174
|
+
} catch {
|
|
175
|
+
resync(message.cursor);
|
|
176
|
+
void drain();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const last = work.at(-1);
|
|
180
|
+
if (waiting() >= MAX_WAITING) resync(message.cursor);
|
|
181
|
+
else if (last?.kind === "events" && last.events.length < MAX_BATCH) last.events.push({ data, cursor: message.cursor });
|
|
182
|
+
else work.push({ kind: "events", events: [{ data, cursor: message.cursor }] });
|
|
183
|
+
}
|
|
184
|
+
void drain();
|
|
185
|
+
},
|
|
186
|
+
end: () => {
|
|
187
|
+
if (ended) return;
|
|
188
|
+
stop();
|
|
189
|
+
handlers.unavailable();
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const stop = () => {
|
|
194
|
+
if (ended) return;
|
|
195
|
+
ended = true;
|
|
196
|
+
work.length = 0;
|
|
197
|
+
if (subscribers.get(id) !== subscriber) return;
|
|
198
|
+
subscribers.delete(id);
|
|
199
|
+
socket.send({ t: "unsub", id });
|
|
200
|
+
if (subscribers.size === 0 && shared.get(url) === connection) {
|
|
201
|
+
shared.delete(url);
|
|
202
|
+
socket.dispose();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
subscribers.set(id, subscriber);
|
|
207
|
+
if (subscribers.size === 1) socket.connect();
|
|
208
|
+
else socket.send(subscriber.frame());
|
|
209
|
+
return { close: stop };
|
|
210
|
+
},
|
|
211
|
+
});
|
|
@@ -13,6 +13,13 @@ export type LiveWebSocketClose = {
|
|
|
13
13
|
|
|
14
14
|
export type LiveWebSocketControls = {
|
|
15
15
|
markApplied: (cursor: string | null | undefined) => void;
|
|
16
|
+
/**
|
|
17
|
+
* The cursor the current connection subscribed from: the last applied cursor
|
|
18
|
+
* when the socket opened. A server that confirms exactly this cursor resumes
|
|
19
|
+
* the stream after it, so the page needs no snapshot refresh. Any other
|
|
20
|
+
* cursor means events between the two were skipped.
|
|
21
|
+
*/
|
|
22
|
+
subscribedCursor: () => string | null;
|
|
16
23
|
/** Forget an expired cursor before resubscribing from a fresh snapshot. */
|
|
17
24
|
resetCursor: () => void;
|
|
18
25
|
send: (message: unknown) => boolean;
|
|
@@ -23,7 +30,8 @@ export type LiveWebSocketOptions<TMessage> = {
|
|
|
23
30
|
url: string | (() => string);
|
|
24
31
|
initialCursor?: string | null;
|
|
25
32
|
activity?: LiveWebSocketActivity;
|
|
26
|
-
|
|
33
|
+
/** The subscription sent when a socket opens; omit it when `onOpen` sends the subscriptions. */
|
|
34
|
+
subscribe?: (cursor: string | null) => unknown;
|
|
27
35
|
parse: (raw: string) => TMessage | null;
|
|
28
36
|
onOpen?: (controls: LiveWebSocketControls) => void;
|
|
29
37
|
onMessage: (message: TMessage, controls: LiveWebSocketControls) => void;
|
|
@@ -62,12 +70,13 @@ const DEFAULT_RECONNECT = {
|
|
|
62
70
|
*/
|
|
63
71
|
const CONNECT_TIMEOUT_MS = DEFAULT_RECONNECT.maxDelayMs;
|
|
64
72
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
73
|
+
/**
|
|
74
|
+
* Only a policy close (`1008`) ends live updates by default. Every other close,
|
|
75
|
+
* including `1011` (internal error) and `1013` (try again later), reconnects
|
|
76
|
+
* with backoff; the new subscription checks access again.
|
|
77
|
+
*/
|
|
78
|
+
const defaultCloseError = ({ code, reason }: LiveWebSocketClose): LiveWebSocketError | null =>
|
|
79
|
+
code === 1008 ? { code: reason || "access_denied", message: "Live access changed or expired." } : null;
|
|
71
80
|
|
|
72
81
|
const socketUrl = (raw: string): string => {
|
|
73
82
|
const url = new URL(raw, window.location.origin);
|
|
@@ -96,6 +105,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
|
|
|
96
105
|
let connectStartedAt = 0;
|
|
97
106
|
let reconnectAttempt = 0;
|
|
98
107
|
let lastAppliedCursor = options.initialCursor ?? null;
|
|
108
|
+
let subscribedCursor = lastAppliedCursor;
|
|
99
109
|
let started = false;
|
|
100
110
|
let disposed = false;
|
|
101
111
|
let terminated = false;
|
|
@@ -165,6 +175,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
|
|
|
165
175
|
|
|
166
176
|
const controls: LiveWebSocketControls = {
|
|
167
177
|
markApplied,
|
|
178
|
+
subscribedCursor: () => subscribedCursor,
|
|
168
179
|
resetCursor,
|
|
169
180
|
send,
|
|
170
181
|
terminate: fatal,
|
|
@@ -210,6 +221,9 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
|
|
|
210
221
|
}
|
|
211
222
|
socket = next;
|
|
212
223
|
connectStartedAt = Date.now();
|
|
224
|
+
// When the server first answered. A server can accept a subscription and fail right after, often with an error
|
|
225
|
+
// frame, so the backoff starts over only once the connection stayed up for the longest delay after that.
|
|
226
|
+
let answeredAt: number | null = null;
|
|
213
227
|
connectTimer = setTimeout(() => {
|
|
214
228
|
connectTimer = null;
|
|
215
229
|
if (next.readyState === WebSocket.CONNECTING) abandonAttempt(next);
|
|
@@ -219,7 +233,9 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
|
|
|
219
233
|
if (socket !== next || disposed || terminated) return;
|
|
220
234
|
clearConnectDeadline();
|
|
221
235
|
try {
|
|
222
|
-
|
|
236
|
+
subscribedCursor = lastAppliedCursor;
|
|
237
|
+
if (options.subscribe && !send(options.subscribe(subscribedCursor)))
|
|
238
|
+
throw new Error("Live WebSocket subscription could not be sent");
|
|
223
239
|
setStatus("open");
|
|
224
240
|
options.onOpen?.(controls);
|
|
225
241
|
} catch (error) {
|
|
@@ -232,8 +248,8 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
|
|
|
232
248
|
try {
|
|
233
249
|
const message = options.parse(event.data);
|
|
234
250
|
if (message) {
|
|
251
|
+
answeredAt ??= Date.now();
|
|
235
252
|
options.onMessage(message, controls);
|
|
236
|
-
reconnectAttempt = 0;
|
|
237
253
|
}
|
|
238
254
|
} catch (error) {
|
|
239
255
|
fatal(
|
|
@@ -249,8 +265,12 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
|
|
|
249
265
|
clearConnectDeadline();
|
|
250
266
|
if (disposed || terminated) return;
|
|
251
267
|
const closeError = classifyClose({ code: event.code, reason: event.reason.trim() });
|
|
252
|
-
if (closeError)
|
|
253
|
-
|
|
268
|
+
if (closeError) {
|
|
269
|
+
fatal(closeError, { code: event.code, reason: event.reason });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (answeredAt !== null && Date.now() - answeredAt >= reconnect.maxDelayMs) reconnectAttempt = 0;
|
|
273
|
+
scheduleReconnect();
|
|
254
274
|
};
|
|
255
275
|
|
|
256
276
|
next.onerror = () => {
|
package/src/contracts/app.ts
CHANGED
|
@@ -88,6 +88,14 @@ export type AppCliModule = {
|
|
|
88
88
|
/** `cld` modules keyed by module name, the command `cld <name>`. */
|
|
89
89
|
export type AppCliModules = Readonly<Record<string, AppCliModule>>;
|
|
90
90
|
|
|
91
|
+
/** An application's part of the mobile app (preview). */
|
|
92
|
+
export type AppPwaPart = {
|
|
93
|
+
/** Always `/pwa/<app id>`. */
|
|
94
|
+
href: string;
|
|
95
|
+
/** Coarse visibility in the mobile app, like `nav.requiresRoles`. Routes and services still authorize. */
|
|
96
|
+
requiresRoles?: Role[];
|
|
97
|
+
};
|
|
98
|
+
|
|
91
99
|
export type AppMeta = {
|
|
92
100
|
id: string;
|
|
93
101
|
name: string;
|
|
@@ -118,6 +126,8 @@ export type AppMeta = {
|
|
|
118
126
|
legalLinks?: LegalLink[];
|
|
119
127
|
/** Static search destinations, visible when the app is in the user's navigation catalog. */
|
|
120
128
|
searchLinks?: readonly AppSearchLink[];
|
|
129
|
+
/** The app's pages in the installable mobile app (preview), at `href` = `/pwa/<id>`. */
|
|
130
|
+
pwa?: AppPwaPart;
|
|
121
131
|
/**
|
|
122
132
|
* Dashboard widget endpoints this app exposes. Each entry references an
|
|
123
133
|
* HTTP endpoint that returns a `WidgetResponse` (see `contracts/widgets.ts`).
|
package/src/contracts/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from "./contact-directory";
|
|
|
9
9
|
export * from "./notification-types";
|
|
10
10
|
export * from "./posix";
|
|
11
11
|
export * from "./profile";
|
|
12
|
+
export * from "./pwa";
|
|
12
13
|
// Core app bar administration and shared shortcut values.
|
|
13
14
|
export { RailAccessSchema, type RailAdminEntry, RailAdminInputSchema, RailAdminSchema, type RailAdminState } from "./rail-admin";
|
|
14
15
|
export type { RailShortcut } from "./rail-preferences";
|