@k2b/cloud 0.29.1 → 0.31.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 (136) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/app-roles.ts +14 -0
  4. package/src/_internal/capabilities.ts +113 -5
  5. package/src/_internal/capability-sentences.ts +413 -0
  6. package/src/_internal/define-app.ts +24 -5
  7. package/src/_internal/registry-validation.ts +5 -0
  8. package/src/_internal/registry.ts +50 -6
  9. package/src/_internal/widget-declarations.ts +53 -0
  10. package/src/access/PermissionEditor.tsx +43 -24
  11. package/src/ai/app-skill-store.ts +298 -0
  12. package/src/ai/app-skills.ts +105 -0
  13. package/src/ai/approval-routes.ts +40 -4
  14. package/src/ai/approvals.ts +68 -10
  15. package/src/ai/browser.ts +10 -0
  16. package/src/ai/capabilities.ts +68 -9
  17. package/src/ai/chart-block.ts +380 -0
  18. package/src/ai/chat/action-sentences.ts +49 -0
  19. package/src/ai/chat/blocks.tsx +97 -40
  20. package/src/ai/chat/chart-block.tsx +79 -0
  21. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  22. package/src/ai/chat/message-actions.tsx +3 -1
  23. package/src/ai/chat/message-utils.ts +3 -0
  24. package/src/ai/chat/messages.ts +26 -2
  25. package/src/ai/chat/tool-groups.ts +66 -12
  26. package/src/ai/chat/tool-names.ts +6 -2
  27. package/src/ai/chat/turn-layout.ts +10 -2
  28. package/src/ai/chat/turn-view.tsx +123 -36
  29. package/src/ai/chat/web-tools.tsx +95 -1
  30. package/src/ai/client/controller.ts +6 -3
  31. package/src/ai/code-runtime-tools.ts +109 -61
  32. package/src/ai/code-source-contracts.ts +25 -21
  33. package/src/ai/code-source-tools.ts +3 -1
  34. package/src/ai/default-tool-contracts.ts +198 -0
  35. package/src/ai/default-tools.ts +25 -1
  36. package/src/ai/executor.ts +189 -35
  37. package/src/ai/fetch-file-tool.ts +41 -4
  38. package/src/ai/firecrawl-tools.ts +87 -4
  39. package/src/ai/index.ts +17 -1
  40. package/src/ai/migrate.ts +172 -14
  41. package/src/ai/protocol.ts +22 -1
  42. package/src/ai/routes.ts +11 -0
  43. package/src/ai/runtime-tools.ts +1 -0
  44. package/src/ai/runtime.ts +46 -7
  45. package/src/ai/skill-content.ts +54 -0
  46. package/src/ai/skills-routes.ts +22 -13
  47. package/src/ai/skills.ts +177 -231
  48. package/src/ai/store.ts +66 -1
  49. package/src/ai/tool-audit.ts +11 -1
  50. package/src/ai/tool-sentences.ts +38 -0
  51. package/src/ai/tools.ts +91 -3
  52. package/src/ai/types.ts +47 -1
  53. package/src/ai/web-provenance.ts +103 -0
  54. package/src/api/admin-ai-skills.ts +35 -16
  55. package/src/api/capabilities.ts +3 -2
  56. package/src/api/fanout.ts +128 -0
  57. package/src/api/help.ts +8 -3
  58. package/src/api/mcp.ts +4 -1
  59. package/src/api/search.ts +31 -106
  60. package/src/api/widgets.ts +291 -101
  61. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  62. package/src/browser/CloudResourceSearch.tsx +1 -1
  63. package/src/browser/command-shortcuts.ts +21 -6
  64. package/src/browser/ndjson.ts +34 -0
  65. package/src/browser/reload.ts +46 -0
  66. package/src/browser/search-stream.ts +4 -26
  67. package/src/browser/spotlight-position.ts +4 -1
  68. package/src/browser/widgets.ts +30 -0
  69. package/src/capabilities/executions.ts +3 -0
  70. package/src/capabilities/server.ts +20 -2
  71. package/src/cli/access.ts +48 -14
  72. package/src/cli/admin/ai-skills.ts +59 -39
  73. package/src/cli/commands.ts +20 -0
  74. package/src/cli/index.ts +16 -0
  75. package/src/cli/plugin.ts +30 -0
  76. package/src/contracts/announcement-cookie.ts +62 -0
  77. package/src/contracts/announcements.ts +14 -52
  78. package/src/contracts/app.ts +34 -2
  79. package/src/contracts/capabilities.ts +24 -0
  80. package/src/contracts/pwa-paths.ts +7 -2
  81. package/src/contracts/pwa.ts +2 -7
  82. package/src/contracts/registry.ts +10 -1
  83. package/src/contracts/shared.ts +5 -0
  84. package/src/contracts/widgets.ts +67 -2
  85. package/src/desktop/solid.tsx +6 -7
  86. package/src/index.ts +1 -0
  87. package/src/server/help.ts +8 -1
  88. package/src/server/index.ts +1 -0
  89. package/src/server/widget-request.ts +27 -0
  90. package/src/services/accounts/users.ts +15 -0
  91. package/src/services/approvals/index.ts +225 -0
  92. package/src/services/approvals/migrate.ts +33 -0
  93. package/src/services/help/index.ts +18 -5
  94. package/src/services/help/types.ts +3 -2
  95. package/src/services/identity/constants.ts +2 -0
  96. package/src/services/identity/key-ring.ts +123 -42
  97. package/src/services/identity/runtime-config.ts +13 -2
  98. package/src/shared/app-presentation.ts +11 -0
  99. package/src/shared/email-html.ts +43 -8
  100. package/src/ssr/AdminLayout.tsx +4 -7
  101. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  102. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  103. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  104. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  105. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  106. package/src/ssr/Layout.tsx +38 -14
  107. package/src/ssr/LayoutFooter.tsx +1 -1
  108. package/src/ssr/LayoutHeader.tsx +17 -20
  109. package/src/ssr/LayoutHelp.tsx +1 -1
  110. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  111. package/src/ssr/LayoutRail.tsx +1 -2
  112. package/src/ssr/PageTrail.tsx +23 -0
  113. package/src/ssr/PageTrailRow.island.tsx +20 -0
  114. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  115. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  116. package/src/ssr/PwaRuntime.island.tsx +1 -1
  117. package/src/ssr/app-navigation.ts +5 -7
  118. package/src/ssr/global-search-host.tsx +55 -0
  119. package/src/ssr/help.ts +3 -3
  120. package/src/ssr/islands/index.ts +1 -0
  121. package/src/ssr/layout-runtime.ts +60 -0
  122. package/src/ssr/mobile-menu-history.ts +1 -1
  123. package/src/ssr/page-trail-runtime.ts +313 -0
  124. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  125. package/src/ssr/page-trail.ts +59 -0
  126. package/src/ssr/platform-messages.ts +4 -0
  127. package/src/ssr/rail-context.ts +4 -3
  128. package/src/ssr/runtime.ts +14 -1
  129. package/src/styles/effects.css +21 -4
  130. package/src/styles/global.css +35 -2
  131. package/src/styles/utilities-layout.css +24 -1
  132. package/src/styles/utilities-navigation.css +6 -18
  133. package/src/ai/code-mode-skill.ts +0 -114
  134. package/src/ai/data-analysis-skill.ts +0 -10
  135. package/src/ai/grids-skill.ts +0 -158
  136. package/src/ai/skill-seeds.ts +0 -560
@@ -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");
@@ -613,11 +613,12 @@ const runCapabilityDispatch = async (
613
613
  : schemaValidator(`${operation.schemaHash}:result`, capabilityResultJsonSchema(operation.dataSchema));
614
614
  const parsedResult = resultValidator?.safeParse(upstreamBody.data);
615
615
  const parsedReview = params.review ? CapabilityActionReviewSchema.safeParse(upstreamBody.data) : null;
616
+ // A rememberable Action may leave out its scope for arguments that should ask every time; any other Action has none.
616
617
  const reviewApprovalScopeIsValid =
617
618
  !params.review ||
618
- !("approval" in operation) ||
619
619
  !parsedReview?.success ||
620
- (operation.approval === "rememberable" ? parsedReview.data.approvalScope !== undefined : parsedReview.data.approvalScope === undefined);
620
+ ("approval" in operation && operation.approval === "rememberable") ||
621
+ parsedReview.data.approvalScope === undefined;
621
622
  if (!resultValidator || !parsedResult?.success || !reviewApprovalScopeIsValid) {
622
623
  if (actionWithoutRetrySafety) return settle(outcomeUnknown(), "uncertain");
623
624
  const invalid = errorResponse("INVALID_APP_RESPONSE", messages.outsideResultSchema({ appId: params.appId }), 502);
@@ -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);