@k2b/cloud 0.22.0 → 0.24.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/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +90 -7
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +20 -4
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/registry.ts +78 -3
- 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/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +80 -21
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/migrate.ts +35 -16
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/runtime.ts +1 -6
- package/src/ai/solid.ts +0 -5
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/help.ts +2 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +168 -0
- package/src/api/pwa.ts +179 -0
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- 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/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +2 -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 +830 -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/help.ts +3 -3
- 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 +114 -80
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +640 -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/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -23
- 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 +70 -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/resource-search.css +19 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- package/src/shared/markdown/extensions/info-blocks.ts +0 -108
|
@@ -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 = () => {
|
|
@@ -12,7 +12,15 @@ export const resourceSearchMessages = i18n.define({
|
|
|
12
12
|
commandHint: "Choose an action, or type > next time.",
|
|
13
13
|
searchFailed: "Search is currently unavailable. Try again.",
|
|
14
14
|
navigationFailed: "Could not open this result. Try again.",
|
|
15
|
-
|
|
15
|
+
stillSearching: ({ apps, count }: { apps: string; count: number }) =>
|
|
16
|
+
count === 1 ? `${apps} is still searching…` : `${apps} are still searching…`,
|
|
17
|
+
moreApps: ({ count }: { count: number }) => `${count} more`,
|
|
18
|
+
appTimedOut: ({ app }: { app: string }) => `${app} did not respond in time`,
|
|
19
|
+
appUnavailable: ({ app }: { app: string }) => `${app} is not available right now`,
|
|
20
|
+
appNoMatches: ({ app, query }: { app: string; query: string }) =>
|
|
21
|
+
query ? `${app}: no matches for “${query}”` : `${app}: no matches`,
|
|
22
|
+
searchDone: ({ count }: { count: number }) =>
|
|
23
|
+
i18n.plural(count, "en", { one: `Search complete, ${count} result`, other: `Search complete, ${count} results` }),
|
|
16
24
|
searchPlaceholder: "Search Cloud…",
|
|
17
25
|
scopedSearchPlaceholder: "Search…",
|
|
18
26
|
searchCloudResources: "Search Cloud resources",
|
|
@@ -54,7 +62,12 @@ export const resourceSearchMessages = i18n.define({
|
|
|
54
62
|
commandHint: "Wähle eine Aktion – beim nächsten Mal geht auch >.",
|
|
55
63
|
searchFailed: "Suche gerade nicht erreichbar. Versuche es erneut.",
|
|
56
64
|
navigationFailed: "Der Treffer konnte nicht geöffnet werden. Versuche es erneut.",
|
|
57
|
-
|
|
65
|
+
stillSearching: ({ apps, count }) => (count === 1 ? `${apps} sucht noch …` : `${apps} suchen noch …`),
|
|
66
|
+
moreApps: ({ count }) => `${count} weitere`,
|
|
67
|
+
appTimedOut: ({ app }) => `${app} hat nicht rechtzeitig geantwortet`,
|
|
68
|
+
appUnavailable: ({ app }) => `${app} ist gerade nicht erreichbar`,
|
|
69
|
+
appNoMatches: ({ app, query }) => (query ? `${app}: keine Treffer für „${query}“` : `${app}: keine Treffer`),
|
|
70
|
+
searchDone: ({ count }) => `Suche abgeschlossen, ${count} Treffer`,
|
|
58
71
|
searchPlaceholder: "Cloud durchsuchen…",
|
|
59
72
|
scopedSearchPlaceholder: "Durchsuchen…",
|
|
60
73
|
searchCloudResources: "Cloud-Inhalte durchsuchen",
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { SearchApp, SearchItem, SearchProviderStatus, SearchResponse, SearchStreamLine } from "../api/search/schemas";
|
|
2
|
+
import { LOCALE_HEADER } from "../shared/locale";
|
|
3
|
+
|
|
4
|
+
/** Kept local so the browser bundle does not load the server-side search schemas. */
|
|
5
|
+
const STREAM_CONTENT_TYPE = "application/x-ndjson";
|
|
6
|
+
|
|
7
|
+
export type SearchAppStatus = SearchProviderStatus | "searching";
|
|
8
|
+
|
|
9
|
+
/** Rows one provider line added. A retried app adds a block of its own at the end. */
|
|
10
|
+
export type SearchBlock = { appId: string; items: SearchItem[] };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One streamed search as the browser shows it. Blocks only ever grow at the end, so nothing already visible moves;
|
|
14
|
+
* `status` says for every app named in `start` whether it is still searching and how it finished.
|
|
15
|
+
*/
|
|
16
|
+
export type SearchRun = {
|
|
17
|
+
apps: SearchApp[];
|
|
18
|
+
providers: string[];
|
|
19
|
+
status: Record<string, SearchAppStatus>;
|
|
20
|
+
blocks: SearchBlock[];
|
|
21
|
+
unsupportedTags: string[];
|
|
22
|
+
/** The stream has ended; a retried app may still search afterwards. */
|
|
23
|
+
done: boolean;
|
|
24
|
+
/** The request failed before any app was named. */
|
|
25
|
+
failed: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const emptySearchRun = (): SearchRun => ({
|
|
29
|
+
apps: [],
|
|
30
|
+
providers: [],
|
|
31
|
+
status: {},
|
|
32
|
+
blocks: [],
|
|
33
|
+
unsupportedTags: [],
|
|
34
|
+
done: false,
|
|
35
|
+
failed: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const itemKey = (item: SearchItem) => `${item.ref.type}\u0000${item.ref.id}`;
|
|
39
|
+
|
|
40
|
+
export const applySearchLine = (run: SearchRun, line: SearchStreamLine): SearchRun => {
|
|
41
|
+
if (line.type === "start")
|
|
42
|
+
return {
|
|
43
|
+
...run,
|
|
44
|
+
apps: line.apps,
|
|
45
|
+
providers: line.providers,
|
|
46
|
+
status: Object.fromEntries(line.providers.map((appId) => [appId, "searching" as const])),
|
|
47
|
+
unsupportedTags: line.unsupportedTags ?? [],
|
|
48
|
+
};
|
|
49
|
+
if (line.type === "done") return { ...run, done: true };
|
|
50
|
+
// Only an app that is searching takes a line, which bounds the lines a run accepts.
|
|
51
|
+
if (run.status[line.provider] !== "searching") return run;
|
|
52
|
+
const shown = new Set(run.blocks.flatMap((block) => block.items.map(itemKey)));
|
|
53
|
+
const items = line.results.filter((item) => !shown.has(itemKey(item)));
|
|
54
|
+
return {
|
|
55
|
+
...run,
|
|
56
|
+
status: { ...run.status, [line.provider]: line.status },
|
|
57
|
+
blocks: items.length ? [...run.blocks, { appId: line.provider, items }] : run.blocks,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The request failed or the stream broke off. Once apps were named, the rows that arrived stay and every app still
|
|
63
|
+
* searching has failed on its own, with its own retry; before that, the search failed as a whole.
|
|
64
|
+
*/
|
|
65
|
+
export const breakSearchRun = (run: SearchRun): SearchRun =>
|
|
66
|
+
run.providers.length
|
|
67
|
+
? {
|
|
68
|
+
...run,
|
|
69
|
+
done: true,
|
|
70
|
+
status: Object.fromEntries(Object.entries(run.status).map(([appId, status]) => [appId, status === "searching" ? "error" : status])),
|
|
71
|
+
}
|
|
72
|
+
: { ...run, failed: true };
|
|
73
|
+
|
|
74
|
+
/** Marks an app as searching again before its retry starts. */
|
|
75
|
+
export const retrySearchApp = (run: SearchRun, appId: string): SearchRun => ({
|
|
76
|
+
...run,
|
|
77
|
+
status: { ...run.status, [appId]: "searching" },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const searchingApps = (run: SearchRun) => run.providers.filter((appId) => run.status[appId] === "searching");
|
|
81
|
+
export const failedSearchApps = (run: SearchRun) =>
|
|
82
|
+
run.providers.flatMap((appId) => {
|
|
83
|
+
const status = run.status[appId];
|
|
84
|
+
return status === "timeout" || status === "error" ? [{ appId, status }] : [];
|
|
85
|
+
});
|
|
86
|
+
/** Every app has answered: only now may an empty run say that nothing was found. */
|
|
87
|
+
export const searchFinished = (run: SearchRun) => (run.done || run.failed) && searchingApps(run).length === 0;
|
|
88
|
+
|
|
89
|
+
/** An older Core answers with merged JSON; it becomes the same lines, without per-app timing. */
|
|
90
|
+
const linesFromResponse = (body: SearchResponse): SearchStreamLine[] => {
|
|
91
|
+
const failed = body.failedApps ?? [];
|
|
92
|
+
const found = [...new Set(body.items.map((item) => item.appId))];
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
type: "start",
|
|
96
|
+
query: body.query,
|
|
97
|
+
apps: body.apps,
|
|
98
|
+
providers: [...new Set([...found, ...failed])],
|
|
99
|
+
...(body.unsupportedTags ? { unsupportedTags: body.unsupportedTags } : {}),
|
|
100
|
+
},
|
|
101
|
+
...found.map((appId): SearchStreamLine => {
|
|
102
|
+
const results = body.items.filter((item) => item.appId === appId);
|
|
103
|
+
return { type: "provider", provider: appId, status: failed.includes(appId) ? "error" : "ok", results, ms: 0 };
|
|
104
|
+
}),
|
|
105
|
+
...failed
|
|
106
|
+
.filter((appId) => !found.includes(appId))
|
|
107
|
+
.map((appId): SearchStreamLine => ({ type: "provider", provider: appId, status: "error", results: [], ms: 0 })),
|
|
108
|
+
{ type: "done", status: failed.length ? "partial" : "complete", count: body.items.length },
|
|
109
|
+
];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Runs one search against `/api/search` and hands over each line as it arrives. Aborting `signal` ends the request,
|
|
114
|
+
* and with it every provider the server still waits for. Rejects when the request fails or the stream breaks off.
|
|
115
|
+
*/
|
|
116
|
+
export const streamCloudResourceSearch = async (
|
|
117
|
+
url: string,
|
|
118
|
+
options: { signal: AbortSignal; locale: string; onLine: (line: SearchStreamLine) => void },
|
|
119
|
+
): Promise<void> => {
|
|
120
|
+
const response = await fetch(url, {
|
|
121
|
+
signal: options.signal,
|
|
122
|
+
headers: { accept: STREAM_CONTENT_TYPE, [LOCALE_HEADER]: options.locale },
|
|
123
|
+
});
|
|
124
|
+
if (!response.ok) throw new Error(`Search failed with ${response.status}`);
|
|
125
|
+
if (!response.headers.get("content-type")?.startsWith(STREAM_CONTENT_TYPE)) {
|
|
126
|
+
const body: SearchResponse = await response.json();
|
|
127
|
+
for (const line of linesFromResponse(body)) options.onLine(line);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!response.body) throw new Error("Search returned no body");
|
|
131
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
132
|
+
let buffer = "";
|
|
133
|
+
let closed = false;
|
|
134
|
+
while (!closed) {
|
|
135
|
+
const chunk = await reader.read();
|
|
136
|
+
if (chunk.done) break;
|
|
137
|
+
buffer += chunk.value;
|
|
138
|
+
let end = buffer.indexOf("\n");
|
|
139
|
+
while (end >= 0) {
|
|
140
|
+
const text = buffer.slice(0, end).trim();
|
|
141
|
+
buffer = buffer.slice(end + 1);
|
|
142
|
+
if (text) {
|
|
143
|
+
const line: SearchStreamLine = JSON.parse(text);
|
|
144
|
+
options.onLine(line);
|
|
145
|
+
if (line.type === "done") closed = true;
|
|
146
|
+
}
|
|
147
|
+
end = buffer.indexOf("\n");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!closed) throw new Error("Search stream ended early");
|
|
151
|
+
};
|
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`).
|
|
@@ -436,6 +436,9 @@ export type CapabilityCommandManifest = z.infer<typeof CapabilityCommandManifest
|
|
|
436
436
|
|
|
437
437
|
type CapabilityDefinitionCatalog<T> = Readonly<Record<string, T>>;
|
|
438
438
|
|
|
439
|
+
/** Offers files to other apps: local IDs of the list Query, the read Query, and the optional save Action. */
|
|
440
|
+
export type CapabilityFileProviderDeclaration = { list: string; read: string; save?: string };
|
|
441
|
+
|
|
439
442
|
export type CapabilityDefinitions = {
|
|
440
443
|
protocolVersion: typeof CAPABILITY_PROTOCOL_VERSION;
|
|
441
444
|
presentation?: CapabilityPresentationCatalog;
|
|
@@ -443,6 +446,7 @@ export type CapabilityDefinitions = {
|
|
|
443
446
|
queries?: CapabilityDefinitionCatalog<CapabilityQueryDefinition>;
|
|
444
447
|
actions?: CapabilityDefinitionCatalog<CapabilityActionDefinition>;
|
|
445
448
|
commands?: CapabilityDefinitionCatalog<CapabilityCommandDefinition>;
|
|
449
|
+
fileProvider?: CapabilityFileProviderDeclaration;
|
|
446
450
|
};
|
|
447
451
|
|
|
448
452
|
/**
|
|
@@ -504,17 +508,81 @@ export const CapabilityAppIdSchema = z
|
|
|
504
508
|
.max(80)
|
|
505
509
|
.regex(/^[a-z][a-z0-9-]*$/);
|
|
506
510
|
|
|
507
|
-
|
|
508
|
-
|
|
511
|
+
const MAX_MANIFEST_ENTRIES = 200;
|
|
512
|
+
|
|
513
|
+
export const CapabilityFileProviderManifestSchema = z
|
|
514
|
+
.object({ list: CapabilityLocalIdSchema, read: CapabilityLocalIdSchema, save: CapabilityLocalIdSchema.optional() })
|
|
515
|
+
.strict();
|
|
516
|
+
|
|
517
|
+
/** Keeps the sent entries this release reads completely. */
|
|
518
|
+
const readEntries = <T extends z.ZodType>(values: unknown, entry: T): z.output<T>[] | null => {
|
|
519
|
+
// Not a bounded list: leave the value to the manifest schema, which rejects it.
|
|
520
|
+
if (!Array.isArray(values) || values.length > MAX_MANIFEST_ENTRIES) return null;
|
|
521
|
+
return values.flatMap((value) => {
|
|
522
|
+
const parsed = entry.safeParse(value);
|
|
523
|
+
return parsed.success ? [parsed.data] : [];
|
|
524
|
+
});
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Reads what this release understands from a manifest that a newer release may have produced.
|
|
529
|
+
*
|
|
530
|
+
* - Unknown top-level fields are ignored. A newer release may therefore only add something at the top
|
|
531
|
+
* level; anything that restricts an existing entry belongs inside that entry.
|
|
532
|
+
* - An entry with a field or value from a newer release is left out rather than stripped: its unknown
|
|
533
|
+
* part may change how the entry runs or who may use it, so guessing could widen access.
|
|
534
|
+
* - Entries that depend on an entry that is not there follow it: a Query scoped to a missing type is
|
|
535
|
+
* left out, and a type whose reader is missing keeps no reader.
|
|
536
|
+
* - A file provider with a field from a newer release is left out. Whether its operations are still
|
|
537
|
+
* there and match the contract is `fileProviderIssues`' job, which Core and consumers run.
|
|
538
|
+
*
|
|
539
|
+
* Every other entry stays available.
|
|
540
|
+
*/
|
|
541
|
+
const readableManifest = (value: unknown): unknown => {
|
|
542
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
543
|
+
const sent = value as Record<string, unknown>;
|
|
544
|
+
const types = readEntries(sent.types, CapabilityResourceTypeManifestSchema);
|
|
545
|
+
const queries = readEntries(sent.queries, CapabilityQueryManifestSchema);
|
|
546
|
+
const actions = readEntries(sent.actions, CapabilityActionManifestSchema);
|
|
547
|
+
const commands = readEntries(sent.commands, CapabilityCommandManifestSchema);
|
|
548
|
+
if (!types || !queries || !actions || !commands) return value;
|
|
549
|
+
const typeIds = new Set(types.map((type) => type.localId));
|
|
550
|
+
const readableQueries = queries.filter((query) => query.universalSearch?.scopeTypes?.every((type) => typeIds.has(type)) ?? true);
|
|
551
|
+
const queryIds = new Set(readableQueries.map((query) => query.localId));
|
|
552
|
+
const { fileProvider: sentProvider, ...rest } = sent;
|
|
553
|
+
const provider = CapabilityFileProviderManifestSchema.safeParse(sentProvider);
|
|
554
|
+
return {
|
|
555
|
+
...rest,
|
|
556
|
+
...(provider.success ? { fileProvider: provider.data } : {}),
|
|
557
|
+
types: types.map((type) => {
|
|
558
|
+
if (!type.reader || queryIds.has(type.reader)) return type;
|
|
559
|
+
const { reader: _missing, ...withoutReader } = type;
|
|
560
|
+
return withoutReader;
|
|
561
|
+
}),
|
|
562
|
+
queries: readableQueries,
|
|
563
|
+
actions,
|
|
564
|
+
commands,
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Reads a manifest from any Cloud release with the same protocol version (see `readableManifest`).
|
|
570
|
+
* Producers stay strict: `app.start()` registers exactly the manifest its release defines.
|
|
571
|
+
*/
|
|
572
|
+
export const CapabilityManifestSchema = z.preprocess(
|
|
573
|
+
readableManifest,
|
|
574
|
+
z.object({
|
|
509
575
|
protocolVersion: z.literal(CAPABILITY_PROTOCOL_VERSION),
|
|
510
576
|
appId: CapabilityAppIdSchema,
|
|
511
577
|
manifestHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
512
|
-
types: z.array(CapabilityResourceTypeManifestSchema).max(
|
|
513
|
-
queries: z.array(CapabilityQueryManifestSchema).max(
|
|
514
|
-
actions: z.array(CapabilityActionManifestSchema).max(
|
|
515
|
-
commands: z.array(CapabilityCommandManifestSchema).max(
|
|
516
|
-
|
|
517
|
-
|
|
578
|
+
types: z.array(CapabilityResourceTypeManifestSchema).max(MAX_MANIFEST_ENTRIES),
|
|
579
|
+
queries: z.array(CapabilityQueryManifestSchema).max(MAX_MANIFEST_ENTRIES),
|
|
580
|
+
actions: z.array(CapabilityActionManifestSchema).max(MAX_MANIFEST_ENTRIES),
|
|
581
|
+
commands: z.array(CapabilityCommandManifestSchema).max(MAX_MANIFEST_ENTRIES),
|
|
582
|
+
// Optional without a default: a manifest without a provider keeps the shape and hash of earlier releases.
|
|
583
|
+
fileProvider: CapabilityFileProviderManifestSchema.optional(),
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
518
586
|
|
|
519
587
|
export type CapabilityResourceTypeManifest = z.infer<typeof CapabilityResourceTypeManifestSchema>;
|
|
520
588
|
export type CapabilityQueryManifest = z.infer<typeof CapabilityQueryManifestSchema>;
|
|
@@ -533,22 +601,19 @@ export const resolveCapabilityResourceReader = (manifest: CapabilityManifest, re
|
|
|
533
601
|
return manifest.queries.find((candidate) => candidate.localId === type.reader) ?? null;
|
|
534
602
|
};
|
|
535
603
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
.strict();
|
|
604
|
+
/** Like the manifest, catalog entries, pages, and their pagination ignore fields that a newer Cloud release added. */
|
|
605
|
+
export const CapabilityCatalogAppSchema = z.object({
|
|
606
|
+
appId: CapabilityAppIdSchema,
|
|
607
|
+
appName: z.string().min(1).max(200),
|
|
608
|
+
appIcon: z.string().min(1).max(120),
|
|
609
|
+
appDescription: z.string().max(1000),
|
|
610
|
+
manifest: CapabilityManifestSchema,
|
|
611
|
+
});
|
|
545
612
|
|
|
546
|
-
export const CapabilityCatalogSchema = z
|
|
547
|
-
.
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
})
|
|
552
|
-
.strict();
|
|
613
|
+
export const CapabilityCatalogSchema = z.object({
|
|
614
|
+
protocolVersion: z.literal(CAPABILITY_PROTOCOL_VERSION),
|
|
615
|
+
apps: z.array(CapabilityCatalogAppSchema).max(25),
|
|
616
|
+
page: z.discriminatedUnion("hasMore", [CapabilityPageSchema.options[0].strip(), CapabilityPageSchema.options[1].strip()]),
|
|
617
|
+
});
|
|
553
618
|
|
|
554
619
|
export type CapabilityCatalog = z.infer<typeof CapabilityCatalogSchema>;
|