@k2b/cloud 0.29.1 → 0.30.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 (77) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-roles.ts +14 -0
  3. package/src/_internal/capabilities.ts +111 -3
  4. package/src/_internal/capability-sentences.ts +413 -0
  5. package/src/_internal/define-app.ts +10 -1
  6. package/src/_internal/registry-validation.ts +5 -0
  7. package/src/access/PermissionEditor.tsx +43 -24
  8. package/src/ai/app-skill-store.ts +298 -0
  9. package/src/ai/app-skills.ts +105 -0
  10. package/src/ai/browser.ts +10 -0
  11. package/src/ai/capabilities.ts +68 -9
  12. package/src/ai/chart-block.ts +380 -0
  13. package/src/ai/chat/action-sentences.ts +49 -0
  14. package/src/ai/chat/blocks.tsx +45 -9
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/message-utils.ts +3 -0
  17. package/src/ai/chat/messages.ts +8 -2
  18. package/src/ai/chat/tool-names.ts +2 -0
  19. package/src/ai/chat/turn-layout.ts +2 -2
  20. package/src/ai/chat/turn-view.tsx +29 -11
  21. package/src/ai/code-runtime-tools.ts +59 -44
  22. package/src/ai/default-tool-contracts.ts +198 -0
  23. package/src/ai/default-tools.ts +25 -1
  24. package/src/ai/executor.ts +19 -21
  25. package/src/ai/index.ts +9 -1
  26. package/src/ai/migrate.ts +59 -12
  27. package/src/ai/runtime-tools.ts +1 -0
  28. package/src/ai/skill-content.ts +54 -0
  29. package/src/ai/skills-routes.ts +22 -13
  30. package/src/ai/skills.ts +177 -231
  31. package/src/ai/tool-sentences.ts +38 -0
  32. package/src/ai/types.ts +8 -1
  33. package/src/api/admin-ai-skills.ts +35 -16
  34. package/src/api/fanout.ts +128 -0
  35. package/src/api/help.ts +8 -3
  36. package/src/api/mcp.ts +4 -1
  37. package/src/api/search.ts +31 -106
  38. package/src/api/widgets.ts +259 -100
  39. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  40. package/src/browser/ndjson.ts +34 -0
  41. package/src/browser/reload.ts +46 -0
  42. package/src/browser/search-stream.ts +4 -26
  43. package/src/browser/widgets.ts +28 -0
  44. package/src/capabilities/server.ts +20 -2
  45. package/src/cli/access.ts +48 -14
  46. package/src/cli/admin/ai-skills.ts +59 -39
  47. package/src/contracts/announcement-cookie.ts +62 -0
  48. package/src/contracts/announcements.ts +14 -52
  49. package/src/contracts/capabilities.ts +24 -0
  50. package/src/contracts/pwa-paths.ts +7 -2
  51. package/src/contracts/pwa.ts +2 -7
  52. package/src/contracts/registry.ts +1 -0
  53. package/src/contracts/shared.ts +5 -0
  54. package/src/contracts/widgets.ts +20 -0
  55. package/src/index.ts +1 -0
  56. package/src/server/help.ts +8 -1
  57. package/src/services/accounts/users.ts +15 -0
  58. package/src/services/help/index.ts +18 -5
  59. package/src/services/help/types.ts +3 -2
  60. package/src/services/identity/constants.ts +2 -0
  61. package/src/services/identity/key-ring.ts +123 -42
  62. package/src/services/identity/runtime-config.ts +13 -2
  63. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  64. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  65. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  66. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  67. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  68. package/src/ssr/PwaRuntime.island.tsx +1 -1
  69. package/src/ssr/app-navigation.ts +5 -7
  70. package/src/ssr/global-search-host.tsx +55 -0
  71. package/src/ssr/help.ts +3 -3
  72. package/src/ssr/rail-context.ts +4 -3
  73. package/src/ssr/runtime.ts +14 -1
  74. package/src/ai/code-mode-skill.ts +0 -114
  75. package/src/ai/data-analysis-skill.ts +0 -10
  76. package/src/ai/grids-skill.ts +0 -158
  77. package/src/ai/skill-seeds.ts +0 -560
@@ -0,0 +1,38 @@
1
+ import type { CapabilityPresentationCatalog } from "../contracts/capabilities";
2
+
3
+ /**
4
+ * Sentences for Cloud's own Assistant tools that act on something, in the catalog shape every app uses for
5
+ * its Actions and keyed by tool name. The chat words these calls through the same renderer as app Actions;
6
+ * a test checks every placeholder against the tool's input schema. A tool that only reads or does several
7
+ * things, such as `memory` searching, adding, or forgetting, keeps its title with what it acted on instead.
8
+ */
9
+ export const CLOUD_AI_TOOL_PRESENTATION = {
10
+ baseLocale: "en",
11
+ sentences: {
12
+ // Code asks for approvals of what it does inside the run, so a rejection or a stop does not mean the code did not run.
13
+ code_run: { approval: "Run code", done: "Ran code" },
14
+ code_action: { approval: "Run app action {input.action}", done: "Ran app action {input.action}" },
15
+ web_extract: { approval: "Read {input.url}", done: "Read {input.url}", notRun: "{input.url} not read" },
16
+ fetch_file: { approval: "Download {input.url}", done: "Downloaded {input.url}", notRun: "{input.url} not downloaded" },
17
+ write_file: { approval: "Write {input.path}", done: "Wrote {input.path}", notRun: "{input.path} not written" },
18
+ },
19
+ translations: {
20
+ de: {
21
+ actions: {
22
+ code_run: { sentences: { approval: "Code ausführen", done: "Code ausgeführt" } },
23
+ code_action: { sentences: { approval: "App-Aktion {input.action} ausführen", done: "App-Aktion {input.action} ausgeführt" } },
24
+ web_extract: { sentences: { approval: "{input.url} lesen", done: "{input.url} gelesen", notRun: "{input.url} nicht gelesen" } },
25
+ fetch_file: {
26
+ sentences: {
27
+ approval: "{input.url} herunterladen",
28
+ done: "{input.url} heruntergeladen",
29
+ notRun: "{input.url} nicht heruntergeladen",
30
+ },
31
+ },
32
+ write_file: {
33
+ sentences: { approval: "{input.path} schreiben", done: "{input.path} geschrieben", notRun: "{input.path} nicht geschrieben" },
34
+ },
35
+ },
36
+ },
37
+ },
38
+ } as const satisfies CapabilityPresentationCatalog;
package/src/ai/types.ts CHANGED
@@ -12,7 +12,8 @@ import type {
12
12
  } from "@k2b/nessi";
13
13
  import type { Usage } from "@k2b/nessi/ai";
14
14
  import type { z } from "zod";
15
- import type { CapabilityActionReview, CloudResourceRef } from "../contracts/capabilities";
15
+ import type { CapabilitySentenceField } from "../_internal/capability-sentences";
16
+ import type { CapabilityActionReview, CapabilityActionSentences, CloudResourceRef } from "../contracts/capabilities";
16
17
  import type { RequestActor } from "../server";
17
18
  import type { AiTurnBlock } from "./protocol";
18
19
 
@@ -1025,6 +1026,12 @@ export type AiCapabilityToolPresentation = {
1025
1026
  appAccent?: string;
1026
1027
  title: string;
1027
1028
  capabilityKind: "query" | "action";
1029
+ /** Actions: the app's sentences in the locale of the turn, worded for each call by the chat. */
1030
+ sentences?: CapabilityActionSentences;
1031
+ /** Actions: field formats for the sentences and labelled fields for the generic sentence, in schema order. */
1032
+ fields?: CapabilitySentenceField[];
1033
+ /** Actions: Cloud offered the model `approvalReason` for this call, so the chat may show it as the model's reason. */
1034
+ approvalReason?: true;
1028
1035
  };
1029
1036
 
1030
1037
  export type AiToolPresentation = AiCapabilityToolPresentation;
@@ -1,6 +1,6 @@
1
1
  import { Hono, type MiddlewareHandler } from "hono";
2
2
  import { z } from "zod";
3
- import { getBuiltinAiSkillTemplates } from "../ai/skill-seeds";
3
+ import { aiAppSkills } from "../ai/app-skill-store";
4
4
  import { AiSkillInputError, AiSkillLastAdminError, AiSkillRevisionConflictError, aiSkills } from "../ai/skills";
5
5
  import { AuthenticatedPrincipalSchema } from "../contracts/shared";
6
6
  import { type AuthContext, auth, err, fail, ok, respond, v } from "../server";
@@ -18,34 +18,52 @@ const SkillAccessUpdateSchema = z.object({ permission: z.enum(["read", "write",
18
18
  const notFound = (c: Parameters<typeof respond>[0], noun: string) => respond(c, fail(err.notFound(noun)));
19
19
  const conflict = (c: Parameters<typeof respond>[0], error: Error) => respond(c, { ok: false, error: error.message, status: 409 as const });
20
20
 
21
+ const ConfirmedSchema = z.object({ confirmed: z.literal(true) }).strict();
22
+
23
+ const installAppSkill = async (c: Parameters<typeof respond>[0], install: () => Promise<boolean>) => {
24
+ try {
25
+ return (await install()) ? respond(c, ok({ installed: true })) : notFound(c, "App Skill");
26
+ } catch (error) {
27
+ if (error instanceof AiSkillRevisionConflictError) return conflict(c, error);
28
+ if (error instanceof AiSkillInputError) return respond(c, fail(err.badInput(error.message)));
29
+ throw error;
30
+ }
31
+ };
32
+
21
33
  export const createAdminAiSkillsRoutes = (authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin")) =>
22
34
  new Hono<AuthContext>()
23
35
  .use("*", authenticate)
24
36
  .get("/", v("query", ListSkillsSchema), async (c) => {
25
37
  const query = c.req.valid("query");
26
- const [skills, summary] = await Promise.all([aiSkills.admin.list(query), aiSkills.admin.summary({ search: query.search })]);
38
+ const [skills, summary, appSkillIssues] = await Promise.all([
39
+ aiSkills.admin.list(query),
40
+ aiSkills.admin.summary({ search: query.search }),
41
+ aiAppSkills.appSkillIssues(),
42
+ ]);
27
43
  return respond(
28
44
  c,
29
45
  ok({
30
46
  ...skills,
31
47
  items: skills.items.map((skill) => ({ ...skill, id: skill.shortId })),
32
48
  summary,
49
+ appSkillIssues,
33
50
  }),
34
51
  );
35
52
  })
36
- .get("/templates", (c) =>
37
- respond(c, ok({ templates: getBuiltinAiSkillTemplates().map(({ key, version, name }) => ({ templateId: key, version, name })) })),
38
- )
53
+ .get("/:skillId/app-version", async (c) => {
54
+ const skill = await aiSkills.admin.getByShortId(c.req.param("skillId")!);
55
+ if (!skill) return notFound(c, "Skill");
56
+ const version = await aiAppSkills.appVersion(skill.id);
57
+ return version ? respond(c, ok(version)) : notFound(c, "Skill");
58
+ })
39
59
  .post(
40
- "/:skillId/template",
60
+ "/:skillId/reset",
41
61
  v(
42
62
  "json",
43
63
  z
44
64
  .object({
45
- templateId: z.string().min(1),
46
- templateVersion: z.number().int().positive(),
47
65
  expectedRevision: z.number().int().positive(),
48
- mode: z.enum(["associate", "reset"]),
66
+ expectedAppVersion: z.string().regex(/^[a-f0-9]{64}$/),
49
67
  confirmed: z.literal(true),
50
68
  })
51
69
  .strict(),
@@ -54,22 +72,23 @@ export const createAdminAiSkillsRoutes = (authenticate: MiddlewareHandler<AuthCo
54
72
  const skill = await aiSkills.admin.getByShortId(c.req.param("skillId")!);
55
73
  if (!skill) return notFound(c, "Skill");
56
74
  try {
57
- return (await aiSkills.admin.applyTemplate(skill.id, c.req.valid("json")))
75
+ const input = c.req.valid("json");
76
+ return (await aiAppSkills.reset(skill.id, input.expectedRevision, input.expectedAppVersion))
58
77
  ? respond(c, ok({ updated: true }))
59
78
  : notFound(c, "Skill");
60
79
  } catch (error) {
61
80
  if (error instanceof AiSkillRevisionConflictError) return conflict(c, error);
62
81
  if (error instanceof AiSkillInputError) return respond(c, fail(err.badInput(error.message)));
63
- if (
64
- typeof error === "object" &&
65
- error !== null &&
66
- (("code" in error && error.code === "23505") || ("errno" in error && error.errno === "23505"))
67
- )
68
- return conflict(c, new Error("A Skill with this name already exists."));
69
82
  throw error;
70
83
  }
71
84
  },
72
85
  )
86
+ .post("/apps/:appId/skills/:name/restore", v("json", ConfirmedSchema), (c) =>
87
+ installAppSkill(c, () => aiAppSkills.restore(c.req.param("appId")!, c.req.param("name")!)),
88
+ )
89
+ .post("/apps/:appId/skills/:name/adopt", v("json", ConfirmedSchema), (c) =>
90
+ installAppSkill(c, () => aiAppSkills.adopt(c.req.param("appId")!, c.req.param("name")!)),
91
+ )
73
92
  .get("/:skillId/projects", async (c) => {
74
93
  const skill = await aiSkills.admin.getByShortId(c.req.param("skillId")!);
75
94
  if (!skill) return notFound(c, "Skill");
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Core's fan-out to many applications for one browser request, shared by Universal Search and dashboard widgets:
3
+ * bounded concurrency, one NDJSON line per application as soon as it finishes, and cancellation when the browser
4
+ * stops reading.
5
+ */
6
+
7
+ /** Media type a client sends in `Accept` and receives back for a stream of JSON lines. */
8
+ export const NDJSON_CONTENT_TYPE = "application/x-ndjson";
9
+
10
+ export const ndjsonStreamHeaders = {
11
+ "content-type": `${NDJSON_CONTENT_TYPE}; charset=utf-8`,
12
+ // Each line must reach the browser when it is written: no cache, no transformation, no proxy buffering.
13
+ "cache-control": "no-store, no-transform",
14
+ "x-accel-buffering": "no",
15
+ } as const;
16
+
17
+ const encoder = new TextEncoder();
18
+ export const ndjsonLine = (line: unknown) => `${JSON.stringify(line)}\n`;
19
+
20
+ /**
21
+ * Starts `run` for every item with at most `concurrency` running at once, in item order. Each item's promise settles
22
+ * on its own, so a caller can act on fast items while slow ones still run. Aborting `signal` settles every item that
23
+ * has not finished as rejected and starts no further item.
24
+ */
25
+ export const startBounded = <T, R>(
26
+ items: readonly T[],
27
+ concurrency: number,
28
+ run: (item: T, index: number) => Promise<R>,
29
+ signal?: AbortSignal,
30
+ ) => {
31
+ const deferred = items.map(() => {
32
+ let settled = false;
33
+ let resolve!: (result: PromiseSettledResult<R>) => void;
34
+ const promise = new Promise<PromiseSettledResult<R>>((done) => {
35
+ resolve = (result) => {
36
+ if (settled) return;
37
+ settled = true;
38
+ done(result);
39
+ };
40
+ });
41
+ return { promise, resolve };
42
+ });
43
+ const abort = () => {
44
+ const reason = signal?.reason ?? new Error("Fan-out deadline exceeded");
45
+ for (const entry of deferred) entry.resolve({ status: "rejected", reason });
46
+ };
47
+ signal?.addEventListener("abort", abort, { once: true });
48
+ if (signal?.aborted) abort();
49
+ let next = 0;
50
+ void Promise.all(
51
+ Array.from({ length: Math.min(concurrency, items.length) }, async () => {
52
+ while (true) {
53
+ if (signal?.aborted) return;
54
+ const index = next;
55
+ next += 1;
56
+ const item = items[index];
57
+ if (item === undefined) return;
58
+ try {
59
+ deferred[index]?.resolve({
60
+ status: "fulfilled",
61
+ value: await run(item, index),
62
+ });
63
+ } catch (reason) {
64
+ deferred[index]?.resolve({ status: "rejected", reason });
65
+ }
66
+ }
67
+ }),
68
+ ).then(() => signal?.removeEventListener("abort", abort));
69
+ return deferred.map((entry) => entry.promise);
70
+ };
71
+
72
+ /** Waits for work that has already started, but no longer than `signal` allows. */
73
+ export const waitWithin = <T>(value: Promise<T>, signal: AbortSignal): Promise<T> =>
74
+ new Promise<T>((resolve, reject) => {
75
+ const aborted = () => reject(signal.reason);
76
+ // The work has already started; observe its rejection even if the client
77
+ // disconnected before we began waiting.
78
+ value.then(
79
+ (result) => {
80
+ signal.removeEventListener("abort", aborted);
81
+ resolve(result);
82
+ },
83
+ (error) => {
84
+ signal.removeEventListener("abort", aborted);
85
+ reject(error);
86
+ },
87
+ );
88
+ if (signal.aborted) return aborted();
89
+ signal.addEventListener("abort", aborted, { once: true });
90
+ });
91
+
92
+ /**
93
+ * Answers with `first`, then one line per entry of `pending` in the order they finish, then `last(lines)`. A slow
94
+ * entry never holds back the others. When the client stops reading, `onCancel` runs so the caller can stop its work.
95
+ */
96
+ export const ndjsonStream = <Line>(options: {
97
+ first: Line;
98
+ pending: readonly Promise<Line>[];
99
+ last: (lines: Line[]) => Line;
100
+ onCancel: () => void;
101
+ }): Response => {
102
+ let cancelled = false;
103
+ const body = new ReadableStream<Uint8Array>({
104
+ start(controller) {
105
+ const write = (line: Line) => {
106
+ if (!cancelled) controller.enqueue(encoder.encode(ndjsonLine(line)));
107
+ };
108
+ write(options.first);
109
+ const lines: Line[] = [];
110
+ void Promise.all(
111
+ options.pending.map((pending) =>
112
+ pending.then((line) => {
113
+ lines.push(line);
114
+ write(line);
115
+ }),
116
+ ),
117
+ ).then(() => {
118
+ write(options.last(lines));
119
+ if (!cancelled) controller.close();
120
+ });
121
+ },
122
+ cancel() {
123
+ cancelled = true;
124
+ options.onCancel();
125
+ },
126
+ });
127
+ return new Response(body, { headers: ndjsonStreamHeaders });
128
+ };
package/src/api/help.ts CHANGED
@@ -13,21 +13,26 @@ export type HelpRouteDependencies = {
13
13
 
14
14
  export const createHelpRoutes = (dependencies: HelpRouteDependencies = {}) => {
15
15
  const reader = dependencies.help ?? createHelpReader;
16
+ // Help is for signed-in people; the reader then hides apps the viewer may not see, like missing ones.
17
+ // Scoped to Help's paths: Core mounts these routes at its API root, before MCP and the other APIs.
16
18
  return new Hono<AuthContext>()
17
- .use(dependencies.authenticate ?? auth.requireRole("*"))
19
+ .use("/help/v1/*", dependencies.authenticate ?? auth.requireRole("authenticated"))
20
+ .use("/help/v1/*", auth.requireOAuthScope("read", "admin"))
18
21
  .get("/help/v1/:appId/search", async (c) => {
19
22
  const locale = getLocale(c);
20
23
  const query = c.req.query("q")?.trim().slice(0, 200) ?? "";
21
24
  const payload: HelpSearchPayload = {
22
25
  locale,
23
- ids: (await reader(locale).search({ query, appId: c.req.param("appId"), limit: 25 })).map((document) => document.documentId),
26
+ ids: (await reader(locale, c.get("actor")).search({ query, appId: c.req.param("appId"), limit: 25 })).map(
27
+ (document) => document.documentId,
28
+ ),
24
29
  };
25
30
  return c.json(payload);
26
31
  })
27
32
  .get("/help/v1/:appId/documents/:documentId", async (c) => {
28
33
  const locale = getLocale(c);
29
34
  const { t } = helpApiMessages.resolve([locale]);
30
- const document = await reader(locale).read({ appId: c.req.param("appId"), documentId: c.req.param("documentId") });
35
+ const document = await reader(locale, c.get("actor")).read({ appId: c.req.param("appId"), documentId: c.req.param("documentId") });
31
36
  if (!document) return c.json({ code: "HELP_NOT_FOUND", message: t.notFound }, 404);
32
37
  const payload: HelpDocumentPayload = {
33
38
  locale: document.locale,
package/src/api/mcp.ts CHANGED
@@ -481,7 +481,10 @@ const createMcpServer = (
481
481
  resolveLocale(request.headers, operatorDefault),
482
482
  ));
483
483
  const helpReader = async () =>
484
- (dependencies.help ?? ((locale) => createHelpReader(locale, { listApps: registry })))(await resolveHelpLocale());
484
+ (dependencies.help ?? ((locale, viewer) => createHelpReader(locale, viewer, { listApps: registry })))(
485
+ await resolveHelpLocale(),
486
+ authority?.actor,
487
+ );
485
488
  const hasScope = (scope: "read" | "write"): boolean =>
486
489
  oauthScopes === null || oauthScopes.includes(scope) || oauthScopes.includes("admin");
487
490
  const requireScope = (scope: "read" | "write"): void => {
package/src/api/search.ts CHANGED
@@ -18,6 +18,7 @@ import { searchInvocationOperation } from "../services/identity/invocation-opera
18
18
  import { normalizeInvocationRequestId, signInvocationToken } from "../services/identity/invocation-token";
19
19
  import { withActiveIdentitySigner } from "../services/identity/key-ring";
20
20
  import { LOCALE_HEADER } from "../shared/locale";
21
+ import { ndjsonLine, ndjsonStream, ndjsonStreamHeaders, startBounded, waitWithin } from "./fanout";
21
22
  import {
22
23
  SEARCH_STREAM_CONTENT_TYPE,
23
24
  type SearchItem,
@@ -85,53 +86,6 @@ type SearchRouteDependencies = {
85
86
  withActiveSigner?: typeof withActiveIdentitySigner;
86
87
  };
87
88
 
88
- const startBounded = <T, R>(
89
- items: readonly T[],
90
- concurrency: number,
91
- run: (item: T, index: number) => Promise<R>,
92
- signal?: AbortSignal,
93
- ) => {
94
- const deferred = items.map(() => {
95
- let settled = false;
96
- let resolve!: (result: PromiseSettledResult<R>) => void;
97
- const promise = new Promise<PromiseSettledResult<R>>((done) => {
98
- resolve = (result) => {
99
- if (settled) return;
100
- settled = true;
101
- done(result);
102
- };
103
- });
104
- return { promise, resolve };
105
- });
106
- const abort = () => {
107
- const reason = signal?.reason ?? new Error("Search fan-out deadline exceeded");
108
- for (const entry of deferred) entry.resolve({ status: "rejected", reason });
109
- };
110
- signal?.addEventListener("abort", abort, { once: true });
111
- if (signal?.aborted) abort();
112
- let next = 0;
113
- void Promise.all(
114
- Array.from({ length: Math.min(concurrency, items.length) }, async () => {
115
- while (true) {
116
- if (signal?.aborted) return;
117
- const index = next;
118
- next += 1;
119
- const item = items[index];
120
- if (item === undefined) return;
121
- try {
122
- deferred[index]?.resolve({
123
- status: "fulfilled",
124
- value: await run(item, index),
125
- });
126
- } catch (reason) {
127
- deferred[index]?.resolve({ status: "rejected", reason });
128
- }
129
- }
130
- }),
131
- ).then(() => signal?.removeEventListener("abort", abort));
132
- return deferred.map((entry) => entry.promise);
133
- };
134
-
135
89
  /** Highest app-provided priority first, then by title. */
136
90
  const rankItems = (items: SearchItem[]) => items.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.title.localeCompare(b.title));
137
91
 
@@ -139,38 +93,8 @@ const rankItems = (items: SearchItem[]) => items.sort((a, b) => (b.priority ?? 0
139
93
  const acceptsStream = (accept: string | undefined) =>
140
94
  accept?.split(",").some((type) => type.split(";")[0]?.trim().toLowerCase() === SEARCH_STREAM_CONTENT_TYPE) ?? false;
141
95
 
142
- const streamHeaders = {
143
- "content-type": `${SEARCH_STREAM_CONTENT_TYPE}; charset=utf-8`,
144
- // Each line must reach the browser when it is written: no cache, no transformation, no proxy buffering.
145
- "cache-control": "no-store, no-transform",
146
- "x-accel-buffering": "no",
147
- } as const;
148
-
149
- const encoder = new TextEncoder();
150
- const lineText = (line: SearchStreamLine) => `${JSON.stringify(line)}\n`;
151
- const encodeLine = (line: SearchStreamLine) => encoder.encode(lineText(line));
152
-
153
96
  type AppOutcome = { appId: string; status: SearchProviderStatus; items: SearchItem[]; ms: number };
154
97
 
155
- const waitWithin = <T>(value: Promise<T>, signal: AbortSignal): Promise<T> =>
156
- new Promise<T>((resolve, reject) => {
157
- const aborted = () => reject(signal.reason);
158
- // The work has already started; observe its rejection even if the client
159
- // disconnected before we began waiting.
160
- value.then(
161
- (result) => {
162
- signal.removeEventListener("abort", aborted);
163
- resolve(result);
164
- },
165
- (error) => {
166
- signal.removeEventListener("abort", aborted);
167
- reject(error);
168
- },
169
- );
170
- if (signal.aborted) return aborted();
171
- signal.addEventListener("abort", aborted, { once: true });
172
- });
173
-
174
98
  /**
175
99
  * Creates the global search route.
176
100
  * Discovers search providers from the capability registry and fetches results via HTTP,
@@ -271,10 +195,16 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
271
195
  stream
272
196
  ? new Response(
273
197
  [
274
- lineText({ type: "start", query: body.query, apps, providers: [], ...(body.unsupportedTags ? { unsupportedTags } : {}) }),
275
- lineText({ type: "done", status: "complete", count: 0 }),
198
+ ndjsonLine({
199
+ type: "start",
200
+ query: body.query,
201
+ apps,
202
+ providers: [],
203
+ ...(body.unsupportedTags ? { unsupportedTags } : {}),
204
+ } satisfies SearchStreamLine),
205
+ ndjsonLine({ type: "done", status: "complete", count: 0 } satisfies SearchStreamLine),
276
206
  ].join(""),
277
- { headers: streamHeaders },
207
+ { headers: ndjsonStreamHeaders },
278
208
  )
279
209
  : c.json(body);
280
210
 
@@ -462,35 +392,30 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
462
392
  const failure = (outcome: AppOutcome) => outcome.status === "timeout" || outcome.status === "error";
463
393
 
464
394
  if (stream) {
465
- let cancelled = false;
466
- const body = new ReadableStream<Uint8Array>({
467
- start(controller) {
468
- const write = (line: SearchStreamLine) => {
469
- if (!cancelled) controller.enqueue(encodeLine(line));
395
+ // A slow app never holds back the others: each line is written when its app finishes.
396
+ return ndjsonStream<SearchStreamLine>({
397
+ first: { type: "start", query: query.q, apps, providers: appIds, ...(unsupportedTags.length > 0 ? { unsupportedTags } : {}) },
398
+ pending: outcomes.map((pending) =>
399
+ pending.then(
400
+ (outcome): SearchStreamLine => ({
401
+ type: "provider",
402
+ provider: outcome.appId,
403
+ status: outcome.status,
404
+ results: outcome.items,
405
+ ms: outcome.ms,
406
+ }),
407
+ ),
408
+ ),
409
+ last: (lines) => {
410
+ const providerLines = lines.filter((line) => line.type === "provider");
411
+ return {
412
+ type: "done",
413
+ status: providerLines.some((line) => line.status === "timeout" || line.status === "error") ? "partial" : "complete",
414
+ count: providerLines.reduce((count, line) => count + line.results.length, 0),
470
415
  };
471
- write({ type: "start", query: query.q, apps, providers: appIds, ...(unsupportedTags.length > 0 ? { unsupportedTags } : {}) });
472
- let count = 0;
473
- let partial = false;
474
- // A slow app never holds back the others: each line is written when its app finishes.
475
- void Promise.all(
476
- outcomes.map((pending) =>
477
- pending.then((outcome) => {
478
- count += outcome.items.length;
479
- partial ||= failure(outcome);
480
- write({ type: "provider", provider: outcome.appId, status: outcome.status, results: outcome.items, ms: outcome.ms });
481
- }),
482
- ),
483
- ).then(() => {
484
- write({ type: "done", status: partial ? "partial" : "complete", count });
485
- if (!cancelled) controller.close();
486
- });
487
- },
488
- cancel() {
489
- cancelled = true;
490
- streamCancelled.abort();
491
416
  },
417
+ onCancel: () => streamCancelled.abort(),
492
418
  });
493
- return new Response(body, { headers: streamHeaders });
494
419
  }
495
420
 
496
421
  const finished = await Promise.all(outcomes);