@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.
Files changed (117) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/capabilities.ts +230 -75
  7. package/src/_internal/define-app.ts +90 -7
  8. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  9. package/src/_internal/page-responses.ts +20 -4
  10. package/src/_internal/registry-validation.ts +6 -0
  11. package/src/_internal/registry.ts +78 -3
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/static-assets.ts +18 -3
  14. package/src/access/ResourceApiKeys.tsx +30 -30
  15. package/src/ai/capability-execution.ts +5 -0
  16. package/src/ai/chat/blocks.tsx +2 -1
  17. package/src/ai/chat/messages.ts +6 -0
  18. package/src/ai/client/controller.ts +80 -21
  19. package/src/ai/client/transport.ts +47 -3
  20. package/src/ai/code-mode-skill.ts +2 -2
  21. package/src/ai/index.ts +0 -7
  22. package/src/ai/live-events.ts +1 -262
  23. package/src/ai/live.ts +31 -2
  24. package/src/ai/migrate.ts +35 -16
  25. package/src/ai/pdf-render.ts +2 -22
  26. package/src/ai/routes.ts +5 -4
  27. package/src/ai/runtime.ts +1 -6
  28. package/src/ai/solid.ts +0 -5
  29. package/src/ai/stream.ts +89 -65
  30. package/src/api/app-approval.ts +5 -1
  31. package/src/api/help.ts +2 -2
  32. package/src/api/index.ts +3 -0
  33. package/src/api/me.ts +19 -5
  34. package/src/api/pwa-phone.ts +168 -0
  35. package/src/api/pwa.ts +179 -0
  36. package/src/api/search/schemas.ts +34 -0
  37. package/src/api/search.ts +118 -50
  38. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  39. package/src/browser/CloudResourceSearch.tsx +208 -80
  40. package/src/browser/app-session.ts +69 -0
  41. package/src/browser/live-connection.ts +211 -0
  42. package/src/browser/live-websocket.ts +31 -11
  43. package/src/browser/live.ts +2 -0
  44. package/src/browser/resource-search-messages.ts +15 -2
  45. package/src/browser/search-stream.ts +151 -0
  46. package/src/contracts/app.ts +10 -0
  47. package/src/contracts/capabilities.ts +89 -24
  48. package/src/contracts/capability-compatibility.ts +28 -14
  49. package/src/contracts/file-provider.ts +158 -0
  50. package/src/contracts/index.ts +2 -0
  51. package/src/contracts/pwa-paths.ts +6 -0
  52. package/src/contracts/pwa.ts +156 -0
  53. package/src/contracts/registry.ts +7 -0
  54. package/src/contracts/shared.ts +7 -1
  55. package/src/events/index.ts +2 -0
  56. package/src/events/live-engine.ts +830 -0
  57. package/src/events/live-protocol.ts +31 -0
  58. package/src/events/live.ts +298 -0
  59. package/src/server/actor.ts +7 -0
  60. package/src/server/api-client.ts +23 -2
  61. package/src/server/help.ts +3 -3
  62. package/src/server/index.ts +3 -1
  63. package/src/server/middleware/auth.ts +80 -8
  64. package/src/server/middleware/openapi.ts +2 -1
  65. package/src/server/services/access.ts +49 -0
  66. package/src/server/services/index.ts +1 -0
  67. package/src/services/app-approval.ts +6 -25
  68. package/src/services/audit/index.ts +2 -0
  69. package/src/services/branding/app-icon-source.ts +40 -0
  70. package/src/services/branding/app-icons.ts +112 -0
  71. package/src/services/branding/icon-render-worker.ts +69 -0
  72. package/src/services/branding/icon-svg.ts +22 -0
  73. package/src/services/identity/invocation-actor.ts +8 -5
  74. package/src/services/identity/invocation-authority.ts +1 -0
  75. package/src/services/identity/invocation-token.ts +6 -0
  76. package/src/services/index.ts +1 -0
  77. package/src/services/outbox.ts +114 -80
  78. package/src/services/pairing-secret.ts +16 -0
  79. package/src/services/pdf/markdown.ts +22 -2
  80. package/src/services/pwa-devices.ts +640 -0
  81. package/src/services/session/index.ts +212 -57
  82. package/src/services/session/recent.ts +42 -0
  83. package/src/services/session/user.ts +15 -5
  84. package/src/shared/index.ts +1 -0
  85. package/src/{browser → shared}/locale-preference.ts +1 -1
  86. package/src/shared/markdown/formula.ts +122 -26
  87. package/src/shared/markdown/index.ts +27 -23
  88. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  89. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  90. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  91. package/src/ssr/Layout.tsx +10 -12
  92. package/src/ssr/LayoutHeader.tsx +14 -2
  93. package/src/ssr/LayoutHelp.tsx +2 -4
  94. package/src/ssr/LayoutRail.tsx +13 -4
  95. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  96. package/src/ssr/MobileProfileActions.tsx +19 -13
  97. package/src/ssr/PageError.tsx +23 -1
  98. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  99. package/src/ssr/PwaLayout.tsx +70 -0
  100. package/src/ssr/PwaRuntime.island.tsx +105 -0
  101. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  102. package/src/ssr/app-navigation.ts +25 -10
  103. package/src/ssr/index.ts +8 -1
  104. package/src/ssr/layout-context.ts +1 -1
  105. package/src/ssr/preference-controller.ts +2 -2
  106. package/src/ssr/profile-actions.ts +3 -1
  107. package/src/ssr/profile-preferences-messages.ts +2 -0
  108. package/src/ssr/pwa-messages.ts +24 -0
  109. package/src/styles/global.css +6 -0
  110. package/src/styles/resource-search.css +19 -0
  111. package/src/styles/utilities-feedback.css +2 -2
  112. package/scripts/build-pdf-renderer.ts +0 -39
  113. package/src/ai/client/live-connection.ts +0 -141
  114. package/src/ai/live-messages.ts +0 -45
  115. package/src/ai/live-outbox.ts +0 -102
  116. package/src/ai/live-routes.ts +0 -412
  117. 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
- subscribe: (cursor: string | null) => unknown;
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
- const defaultCloseError = ({ code, reason }: LiveWebSocketClose): LiveWebSocketError | null => {
66
- if (code === 1008) return { code: reason || "access_denied", message: "Live access changed or expired." };
67
- if (code === 1011) return { code: reason || "internal_error", message: "Live updates failed." };
68
- if (code === 1013) return { code: reason || "backpressure", message: "Live updates are overloaded." };
69
- return null;
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
- if (!send(options.subscribe(lastAppliedCursor))) throw new Error("Live WebSocket subscription could not be sent");
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) fatal(closeError, { code: event.code, reason: event.reason });
253
- else scheduleReconnect();
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 = () => {
@@ -0,0 +1,2 @@
1
+ export * from "./live-connection";
2
+ export * from "./live-websocket";
@@ -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
- partialFailure: "Some search sources are unavailable. Results may be incomplete.",
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
- partialFailure: "Einige Suchquellen sind nicht erreichbar. Ergebnisse können unvollständig sein.",
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
+ };
@@ -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
- export const CapabilityManifestSchema = z
508
- .object({
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(200),
513
- queries: z.array(CapabilityQueryManifestSchema).max(200),
514
- actions: z.array(CapabilityActionManifestSchema).max(200),
515
- commands: z.array(CapabilityCommandManifestSchema).max(200),
516
- })
517
- .strict();
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
- export const CapabilityCatalogAppSchema = z
537
- .object({
538
- appId: CapabilityAppIdSchema,
539
- appName: z.string().min(1).max(200),
540
- appIcon: z.string().min(1).max(120),
541
- appDescription: z.string().max(1000),
542
- manifest: CapabilityManifestSchema,
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
- .object({
548
- protocolVersion: z.literal(CAPABILITY_PROTOCOL_VERSION),
549
- apps: z.array(CapabilityCatalogAppSchema).max(25),
550
- page: CapabilityPageSchema,
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>;