@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
package/src/api/pwa.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
2
|
+
import { bodyLimit } from "hono/body-limit";
|
|
3
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
import { describeRoute } from "hono-openapi";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import {
|
|
7
|
+
isPwaShellAvailable,
|
|
8
|
+
PWA_LIMITS,
|
|
9
|
+
PwaDeviceListSchema,
|
|
10
|
+
PwaPairingConfirmSchema,
|
|
11
|
+
PwaPairingStartResultSchema,
|
|
12
|
+
PwaPairingStatusSchema,
|
|
13
|
+
} from "../contracts/pwa";
|
|
14
|
+
import { type AuthContext, auth, jsonResponse, rateLimit, v } from "../server";
|
|
15
|
+
import { logger } from "../services/logging";
|
|
16
|
+
import { PwaError, pwaDevices } from "../services/pwa-devices";
|
|
17
|
+
import * as settings from "../services/settings";
|
|
18
|
+
import { publicCloudOrigin } from "../shared/app-url";
|
|
19
|
+
import { getRuntimeContext } from "../ssr/runtime";
|
|
20
|
+
|
|
21
|
+
export type PwaRouteOptions = {
|
|
22
|
+
service?: typeof pwaDevices;
|
|
23
|
+
/** True while the mobile app (the `pwa` application) is registered. */
|
|
24
|
+
shellAvailable?: (c: Context) => boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const log = logger("pwa");
|
|
28
|
+
|
|
29
|
+
const MESSAGES: Record<PwaError["code"], string> = {
|
|
30
|
+
UNAVAILABLE: "The mobile app is not available right now.",
|
|
31
|
+
REAUTHENTICATE: "Sign in again to pair a phone.",
|
|
32
|
+
FORBIDDEN: "Use Cloud on the web for this.",
|
|
33
|
+
ACCOUNT_BLOCKED: "This account cannot use the mobile app right now.",
|
|
34
|
+
INVALID_REQUEST: "The request is not valid.",
|
|
35
|
+
NOT_FOUND: "Not found.",
|
|
36
|
+
EXPIRED: "This pairing has expired.",
|
|
37
|
+
ALREADY_USED: "This pairing link was already used.",
|
|
38
|
+
WRONG_CODE: "The code does not match.",
|
|
39
|
+
CONFLICT: "The pairing is not at this step.",
|
|
40
|
+
ALREADY_PAIRED: "This app is paired with another account.",
|
|
41
|
+
ACCOUNT_MISMATCH: "This browser is signed in to another account.",
|
|
42
|
+
LIMIT_REACHED: "The limit for this account was reached.",
|
|
43
|
+
UNPAIRED: "This phone is not paired.",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** `{ code, message }` with a safe English message; the UI maps codes to its own catalog. */
|
|
47
|
+
export const pwaErrorResponse = (c: Context, error: PwaError) =>
|
|
48
|
+
c.json(
|
|
49
|
+
{ code: error.code, message: MESSAGES[error.code], ...(error.attemptsLeft !== undefined ? { attemptsLeft: error.attemptsLeft } : {}) },
|
|
50
|
+
error.status,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
export const pwaInvalidRequest = () => ({ code: "INVALID_REQUEST", message: MESSAGES.INVALID_REQUEST });
|
|
54
|
+
|
|
55
|
+
/** Never log bodies, cookies, secrets, keys or codes: only the error name. */
|
|
56
|
+
export const handlePwaError = (error: Error, c: Context) => {
|
|
57
|
+
if (error instanceof PwaError) return pwaErrorResponse(c, error);
|
|
58
|
+
if (error instanceof HTTPException && error.status < 500) return pwaErrorResponse(c, new PwaError("INVALID_REQUEST", 400));
|
|
59
|
+
log.error("Mobile app operation failed", { error: error.name || "UnknownError" });
|
|
60
|
+
return pwaErrorResponse(c, new PwaError("UNAVAILABLE", 503));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Reads the live registry that Core's runtime middleware puts on every request. */
|
|
64
|
+
export const defaultShellAvailable = (c: Context): boolean => isPwaShellAvailable(getRuntimeContext(c).apps);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* No caching or referrers, JSON bodies within the limit, and an `Origin` equal to the
|
|
68
|
+
* operator's canonical address on every non-GET request. Forwarded headers are never
|
|
69
|
+
* trusted: behind the gateway they describe the internal hop.
|
|
70
|
+
*/
|
|
71
|
+
export const pwaTransport = (): MiddlewareHandler[] => [
|
|
72
|
+
async (c, next) => {
|
|
73
|
+
c.header("Cache-Control", "no-store");
|
|
74
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
75
|
+
c.header("Vary", "Origin");
|
|
76
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
77
|
+
const origin = c.req.header("Origin");
|
|
78
|
+
if (!origin || origin !== publicCloudOrigin(await settings.get<string>("app.url"))) {
|
|
79
|
+
return pwaErrorResponse(c, new PwaError("FORBIDDEN", 403));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await next();
|
|
83
|
+
},
|
|
84
|
+
bodyLimit({ maxSize: PWA_LIMITS.bodyBytes, onError: (c) => pwaErrorResponse(c, new PwaError("INVALID_REQUEST", 400)) }),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const IdParamSchema = z.object({ id: z.string().uuid() });
|
|
88
|
+
|
|
89
|
+
/** The web session of a person: never the mobile app's session, an API key or an OAuth token. */
|
|
90
|
+
const webActor = async (c: Context) => {
|
|
91
|
+
if (c.req.header("Authorization")) throw new PwaError("FORBIDDEN", 403);
|
|
92
|
+
const token = auth.session.getWebToken(c);
|
|
93
|
+
// Only the mobile app's session: pairing phones needs the web, a new sign-in would not help.
|
|
94
|
+
if (!token && auth.session.getAppToken(c)) throw new PwaError("FORBIDDEN", 403);
|
|
95
|
+
const session = token ? await auth.session.authenticateRequest(c, token) : null;
|
|
96
|
+
if (!session) throw new PwaError("REAUTHENTICATE", 403);
|
|
97
|
+
if (session.data.kind !== "web") throw new PwaError("FORBIDDEN", 403);
|
|
98
|
+
return { userId: session.user.id, sid: session.data.sid };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The phone this request also comes from (Chrome on a paired Android phone shares the app's cookies). */
|
|
102
|
+
const currentDeviceId = async (c: Context, userId: string): Promise<string | null> => {
|
|
103
|
+
const token = auth.session.getAppToken(c);
|
|
104
|
+
const session = token ? await auth.session.authenticateRequest(c, token) : null;
|
|
105
|
+
return session?.data.kind === "app" && session.user.id === userId ? (session.data.deviceId ?? null) : null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const tags = ["Mobile app"];
|
|
109
|
+
|
|
110
|
+
/** Web side of the mobile app (preview), mounted by Core at `/api/auth/pwa/v1`. */
|
|
111
|
+
export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
|
|
112
|
+
const service = options.service ?? pwaDevices;
|
|
113
|
+
const shellAvailable = options.shellAvailable ?? defaultShellAvailable;
|
|
114
|
+
return new Hono<AuthContext>()
|
|
115
|
+
.onError(handlePwaError)
|
|
116
|
+
.use("*", ...pwaTransport())
|
|
117
|
+
.use("*", rateLimit())
|
|
118
|
+
.post(
|
|
119
|
+
"/pairings",
|
|
120
|
+
describeRoute({
|
|
121
|
+
tags,
|
|
122
|
+
summary: "Start pairing a phone",
|
|
123
|
+
description: "Needs a web sign-in from the last ten minutes. The link secret is returned once.",
|
|
124
|
+
responses: { 201: jsonResponse(PwaPairingStartResultSchema, "Pairing started") },
|
|
125
|
+
}),
|
|
126
|
+
async (c) => {
|
|
127
|
+
const actor = await webActor(c);
|
|
128
|
+
if (!shellAvailable(c)) throw new PwaError("UNAVAILABLE", 503);
|
|
129
|
+
return c.json(await service.startPairing(actor), 201);
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
.get(
|
|
133
|
+
"/pairings/:id",
|
|
134
|
+
describeRoute({
|
|
135
|
+
tags,
|
|
136
|
+
summary: "Read a pairing",
|
|
137
|
+
description: "Only for the web session that started it. Never returns the code.",
|
|
138
|
+
responses: { 200: jsonResponse(PwaPairingStatusSchema, "Pairing state") },
|
|
139
|
+
}),
|
|
140
|
+
v("param", IdParamSchema, pwaInvalidRequest),
|
|
141
|
+
async (c) => c.json(await service.inspectPairing(await webActor(c), c.req.valid("param").id)),
|
|
142
|
+
)
|
|
143
|
+
.post(
|
|
144
|
+
"/pairings/:id/confirm",
|
|
145
|
+
describeRoute({ tags, summary: "Confirm a pairing with the code the phone shows", responses: { 204: { description: "Confirmed" } } }),
|
|
146
|
+
v("param", IdParamSchema, pwaInvalidRequest),
|
|
147
|
+
v("json", PwaPairingConfirmSchema, pwaInvalidRequest),
|
|
148
|
+
async (c) => {
|
|
149
|
+
await service.confirmPairing(await webActor(c), c.req.valid("param").id, c.req.valid("json").code);
|
|
150
|
+
return c.body(null, 204);
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
.post(
|
|
154
|
+
"/pairings/:id/cancel",
|
|
155
|
+
describeRoute({ tags, summary: "Cancel a pairing", responses: { 204: { description: "Cancelled" } } }),
|
|
156
|
+
v("param", IdParamSchema, pwaInvalidRequest),
|
|
157
|
+
async (c) => {
|
|
158
|
+
await service.cancelPairing(await webActor(c), c.req.valid("param").id);
|
|
159
|
+
return c.body(null, 204);
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
.get(
|
|
163
|
+
"/devices",
|
|
164
|
+
describeRoute({ tags, summary: "List paired phones", responses: { 200: jsonResponse(PwaDeviceListSchema, "Active phones") } }),
|
|
165
|
+
async (c) => {
|
|
166
|
+
const actor = await webActor(c);
|
|
167
|
+
return c.json({ items: await service.list(actor, await currentDeviceId(c, actor.userId)) });
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
.delete(
|
|
171
|
+
"/devices/:id",
|
|
172
|
+
describeRoute({ tags, summary: "Remove a paired phone", responses: { 204: { description: "Removed; idempotent" } } }),
|
|
173
|
+
v("param", IdParamSchema, pwaInvalidRequest),
|
|
174
|
+
async (c) => {
|
|
175
|
+
await service.revoke(await webActor(c), c.req.valid("param").id);
|
|
176
|
+
return c.body(null, 204);
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
};
|
|
@@ -85,6 +85,40 @@ export const SearchResponseSchema = z.object({
|
|
|
85
85
|
failedApps: z.array(z.string()).optional(),
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
/** Media type a client sends in `Accept` to receive one NDJSON line per app as soon as it finishes. */
|
|
89
|
+
export const SEARCH_STREAM_CONTENT_TYPE = "application/x-ndjson";
|
|
90
|
+
|
|
91
|
+
export const SearchProviderStatusSchema = z.enum(["ok", "empty", "timeout", "error"]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One line of a streamed search: `start` names the apps that search, one `provider` line follows per app in the
|
|
95
|
+
* order the apps finish, and `done` closes the search. Apps without a line before `done` do not exist: every app
|
|
96
|
+
* named in `start` gets exactly one line.
|
|
97
|
+
*/
|
|
98
|
+
export const SearchStreamLineSchema = z.discriminatedUnion("type", [
|
|
99
|
+
z.object({
|
|
100
|
+
type: z.literal("start"),
|
|
101
|
+
query: z.string(),
|
|
102
|
+
apps: z.array(SearchAppSchema),
|
|
103
|
+
providers: z.array(z.string()),
|
|
104
|
+
unsupportedTags: z.array(z.string()).optional(),
|
|
105
|
+
}),
|
|
106
|
+
z.object({
|
|
107
|
+
type: z.literal("provider"),
|
|
108
|
+
provider: z.string(),
|
|
109
|
+
status: SearchProviderStatusSchema,
|
|
110
|
+
results: z.array(SearchItemSchema),
|
|
111
|
+
ms: z.number().int().nonnegative(),
|
|
112
|
+
}),
|
|
113
|
+
z.object({
|
|
114
|
+
type: z.literal("done"),
|
|
115
|
+
status: z.enum(["complete", "partial"]),
|
|
116
|
+
count: z.number().int().nonnegative(),
|
|
117
|
+
}),
|
|
118
|
+
]);
|
|
119
|
+
|
|
88
120
|
export type SearchApp = z.infer<typeof SearchAppSchema>;
|
|
89
121
|
export type SearchItem = z.infer<typeof SearchItemSchema>;
|
|
90
122
|
export type SearchResponse = z.infer<typeof SearchResponseSchema>;
|
|
123
|
+
export type SearchProviderStatus = z.infer<typeof SearchProviderStatusSchema>;
|
|
124
|
+
export type SearchStreamLine = z.infer<typeof SearchStreamLineSchema>;
|
package/src/api/search.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono, type MiddlewareHandler } from "hono";
|
|
2
|
-
import { describeRoute } from "hono-openapi";
|
|
2
|
+
import { describeRoute, resolver } from "hono-openapi";
|
|
3
3
|
import { readBoundedJson } from "../_internal/bounded-json";
|
|
4
4
|
import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
|
|
5
5
|
import { listCapabilities } from "../_internal/registry";
|
|
@@ -18,14 +18,24 @@ 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 {
|
|
21
|
+
import {
|
|
22
|
+
SEARCH_STREAM_CONTENT_TYPE,
|
|
23
|
+
type SearchItem,
|
|
24
|
+
SearchItemSchema,
|
|
25
|
+
type SearchProviderStatus,
|
|
26
|
+
SearchQuerySchema,
|
|
27
|
+
type SearchResponse,
|
|
28
|
+
SearchResponseSchema,
|
|
29
|
+
type SearchStreamLine,
|
|
30
|
+
SearchStreamLineSchema,
|
|
31
|
+
} from "./search/schemas";
|
|
22
32
|
|
|
23
33
|
const log = logger("search");
|
|
24
34
|
const SearchProviderResultSchema = capabilityResultSchema(UniversalSearchDataSchema);
|
|
25
35
|
|
|
26
36
|
/**
|
|
27
|
-
* Maximum items
|
|
28
|
-
*
|
|
37
|
+
* Maximum items of the merged JSON response. A streamed search has no merged
|
|
38
|
+
* list: each app's line carries at most that app's provider limit.
|
|
29
39
|
*/
|
|
30
40
|
const GLOBAL_RESULT_LIMIT = 30;
|
|
31
41
|
const PROVIDER_CONCURRENCY = 8;
|
|
@@ -122,8 +132,25 @@ const startBounded = <T, R>(
|
|
|
122
132
|
return deferred.map((entry) => entry.promise);
|
|
123
133
|
};
|
|
124
134
|
|
|
125
|
-
|
|
126
|
-
|
|
135
|
+
/** Highest app-provided priority first, then by title. */
|
|
136
|
+
const rankItems = (items: SearchItem[]) => items.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.title.localeCompare(b.title));
|
|
137
|
+
|
|
138
|
+
/** The browser asks for a stream explicitly; every other client keeps the merged JSON response. */
|
|
139
|
+
const acceptsStream = (accept: string | undefined) =>
|
|
140
|
+
accept?.split(",").some((type) => type.split(";")[0]?.trim().toLowerCase() === SEARCH_STREAM_CONTENT_TYPE) ?? false;
|
|
141
|
+
|
|
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
|
+
type AppOutcome = { appId: string; status: SearchProviderStatus; items: SearchItem[]; ms: number };
|
|
127
154
|
|
|
128
155
|
const waitWithin = <T>(value: Promise<T>, signal: AbortSignal): Promise<T> =>
|
|
129
156
|
new Promise<T>((resolve, reject) => {
|
|
@@ -160,7 +187,14 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
160
187
|
description: "Searches across app providers discovered via the service registry with optional tag filters.",
|
|
161
188
|
...requiresAuth,
|
|
162
189
|
responses: {
|
|
163
|
-
200:
|
|
190
|
+
200: {
|
|
191
|
+
description:
|
|
192
|
+
"Merged search results. With `Accept: application/x-ndjson`, one JSON line per app in the order the apps finish instead.",
|
|
193
|
+
content: {
|
|
194
|
+
"application/json": { schema: resolver(SearchResponseSchema) },
|
|
195
|
+
[SEARCH_STREAM_CONTENT_TYPE]: { schema: resolver(SearchStreamLineSchema) },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
164
198
|
400: jsonResponse(ErrorResponseSchema, "Invalid query"),
|
|
165
199
|
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
166
200
|
403: jsonResponse(ErrorResponseSchema, "User-backed actor required"),
|
|
@@ -172,6 +206,7 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
172
206
|
expectUserBackedActor(c);
|
|
173
207
|
|
|
174
208
|
const query = c.req.valid("query");
|
|
209
|
+
const stream = acceptsStream(c.req.header("accept"));
|
|
175
210
|
const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
|
|
176
211
|
const invocationAuthority = invocationAuthorityFromRequest(auth.getAuthority(c));
|
|
177
212
|
let entries: CapabilityRegistryEntry[];
|
|
@@ -231,18 +266,24 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
231
266
|
const active =
|
|
232
267
|
query.tag.length === 0 ? appProviders : appProviders.filter((provider) => provider.tags.some((tag) => query.tag.includes(tag)));
|
|
233
268
|
|
|
269
|
+
// Answers without a provider call: the catalog alone, or only unsupported tags.
|
|
270
|
+
const answerWithoutProviders = (body: SearchResponse) =>
|
|
271
|
+
stream
|
|
272
|
+
? new Response(
|
|
273
|
+
[
|
|
274
|
+
lineText({ type: "start", query: body.query, apps, providers: [], ...(body.unsupportedTags ? { unsupportedTags } : {}) }),
|
|
275
|
+
lineText({ type: "done", status: "complete", count: 0 }),
|
|
276
|
+
].join(""),
|
|
277
|
+
{ headers: streamHeaders },
|
|
278
|
+
)
|
|
279
|
+
: c.json(body);
|
|
280
|
+
|
|
234
281
|
if (query.q.length === 0 && query.tag.length === 0 && !query.app && !scope) {
|
|
235
|
-
return
|
|
282
|
+
return answerWithoutProviders({ query: "", count: 0, items: [], apps });
|
|
236
283
|
}
|
|
237
284
|
|
|
238
285
|
if (query.tag.length > 0 && active.length === 0) {
|
|
239
|
-
return
|
|
240
|
-
query: query.q,
|
|
241
|
-
count: 0,
|
|
242
|
-
items: [],
|
|
243
|
-
apps,
|
|
244
|
-
unsupportedTags,
|
|
245
|
-
});
|
|
286
|
+
return answerWithoutProviders({ query: query.q, count: 0, items: [], apps, unsupportedTags });
|
|
246
287
|
}
|
|
247
288
|
|
|
248
289
|
// Single-provider queries get a larger sample for better local
|
|
@@ -252,7 +293,9 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
252
293
|
const effectiveProviderLimit = active.length === 1 ? Math.min(GLOBAL_RESULT_LIMIT, query.provider_limit * 3) : query.provider_limit;
|
|
253
294
|
// The same request-wide budget covers signing and provider I/O.
|
|
254
295
|
const providerDeadline = AbortSignal.timeout(PROVIDER_TIMEOUT_MS);
|
|
255
|
-
|
|
296
|
+
// A client that stops reading a stream, such as the browser on the next keystroke, ends the search too.
|
|
297
|
+
const streamCancelled = new AbortController();
|
|
298
|
+
const fanoutSignal = AbortSignal.any([c.req.raw.signal, providerDeadline, streamCancelled.signal]);
|
|
256
299
|
// Guard the whole issuance batch once, then reuse the prepared signer for
|
|
257
300
|
// every target. This is one Postgres check per outer search request, not
|
|
258
301
|
// one check per provider.
|
|
@@ -298,7 +341,8 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
298
341
|
// One request-wide budget keeps latency flat as the number of apps grows.
|
|
299
342
|
// Workers that have not started when the deadline expires fail fast on
|
|
300
343
|
// the already-aborted signal instead of opening a fresh timeout window.
|
|
301
|
-
const
|
|
344
|
+
const fanoutStarted = performance.now();
|
|
345
|
+
const settled = startBounded(active, PROVIDER_CONCURRENCY, async (provider, index) => {
|
|
302
346
|
// Scope tags to those this provider declared. Apps no longer need
|
|
303
347
|
// their own gate — the framework guarantees they only see tags
|
|
304
348
|
// they understand.
|
|
@@ -395,47 +439,71 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
395
439
|
return validItems;
|
|
396
440
|
});
|
|
397
441
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
442
|
+
// One outcome per app, when all of its Queries have finished. Multiple
|
|
443
|
+
// focused Queries from one app must not buy that app a larger share.
|
|
444
|
+
const appIds = [...new Set(active.map((provider) => provider.appId))];
|
|
445
|
+
const outcomes = appIds.map(async (appId): Promise<AppOutcome> => {
|
|
446
|
+
const results = await Promise.all(active.flatMap((provider, index) => (provider.appId === appId ? [settled[index]!] : [])));
|
|
447
|
+
const failed = results.filter((result) => result.status === "rejected");
|
|
448
|
+
for (const result of failed) {
|
|
449
|
+
log.warn("Search provider failed", {
|
|
450
|
+
appId,
|
|
451
|
+
tags: query.tag,
|
|
452
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
const items = rankItems(results.flatMap((result) => (result.status === "fulfilled" ? result.value : []))).slice(
|
|
456
|
+
0,
|
|
457
|
+
effectiveProviderLimit,
|
|
458
|
+
);
|
|
459
|
+
const status = failed.length ? (providerDeadline.aborted ? "timeout" : "error") : items.length ? "ok" : "empty";
|
|
460
|
+
return { appId, status, items, ms: Math.round(performance.now() - fanoutStarted) };
|
|
407
461
|
});
|
|
462
|
+
const failure = (outcome: AppOutcome) => outcome.status === "timeout" || outcome.status === "error";
|
|
408
463
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
464
|
+
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));
|
|
470
|
+
};
|
|
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
|
+
},
|
|
492
|
+
});
|
|
493
|
+
return new Response(body, { headers: streamHeaders });
|
|
494
|
+
}
|
|
414
495
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
const appBounded = items.filter((item) => {
|
|
419
|
-
const count = appCounts.get(item.appId) ?? 0;
|
|
420
|
-
if (count >= effectiveProviderLimit) return false;
|
|
421
|
-
appCounts.set(item.appId, count + 1);
|
|
422
|
-
return true;
|
|
423
|
-
});
|
|
424
|
-
const sliced = appBounded.slice(0, GLOBAL_RESULT_LIMIT);
|
|
496
|
+
const finished = await Promise.all(outcomes);
|
|
497
|
+
const items = rankItems(finished.flatMap((outcome) => outcome.items)).slice(0, GLOBAL_RESULT_LIMIT);
|
|
498
|
+
const failedApps = finished.filter(failure).map((outcome) => outcome.appId);
|
|
425
499
|
|
|
426
500
|
return c.json({
|
|
427
501
|
query: query.q,
|
|
428
|
-
count:
|
|
429
|
-
items
|
|
502
|
+
count: items.length,
|
|
503
|
+
items,
|
|
430
504
|
apps,
|
|
431
505
|
...(unsupportedTags.length > 0 ? { unsupportedTags } : {}),
|
|
432
|
-
...(
|
|
433
|
-
? {
|
|
434
|
-
failedApps: [
|
|
435
|
-
...new Set(active.filter((_, index) => settled[index]?.status === "rejected").map((provider) => provider.appId)),
|
|
436
|
-
],
|
|
437
|
-
}
|
|
438
|
-
: {}),
|
|
506
|
+
...(failedApps.length > 0 ? { failedApps } : {}),
|
|
439
507
|
});
|
|
440
508
|
},
|
|
441
509
|
);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createGlobalSearchHost } from "../ssr/GlobalSearchDialog";
|
|
2
|
+
import { openCloudResourcePicker } from "./resource-picker";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Opens the real global search, or with `?picker` the resource picker, for a browser test that answers
|
|
6
|
+
* `/api/search` with invented, line-by-line results. `?app=<id>` narrows either one to one app.
|
|
7
|
+
*/
|
|
8
|
+
const params = new URLSearchParams(location.search);
|
|
9
|
+
const app = params.get("app") ?? undefined;
|
|
10
|
+
if (params.has("picker")) void openCloudResourcePicker({ initialAppId: app });
|
|
11
|
+
else createGlobalSearchHost(() => []).open(app ? { scope: { appId: app, label: params.get("label") ?? app } } : {});
|