@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.
- package/package.json +4 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +113 -5
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +24 -5
- package/src/_internal/registry-validation.ts +5 -0
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +97 -40
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +26 -2
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +6 -2
- package/src/ai/chat/turn-layout.ts +10 -2
- package/src/ai/chat/turn-view.tsx +123 -36
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +109 -61
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +189 -35
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +17 -1
- package/src/ai/migrate.ts +172 -14
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +47 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/capabilities.ts +3 -2
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +291 -101
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +30 -0
- package/src/capabilities/executions.ts +3 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/app.ts +34 -2
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +10 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +67 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/accounts/users.ts +15 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- package/src/ai/skill-seeds.ts +0 -560
package/src/api/widgets.ts
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
|
-
import { Hono, type MiddlewareHandler } from "hono";
|
|
1
|
+
import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
2
2
|
import { readBoundedJson } from "../_internal/bounded-json";
|
|
3
|
-
import { listWidgets } from "../_internal/registry";
|
|
4
|
-
import {
|
|
3
|
+
import { type DashboardWidget, listWidgets } from "../_internal/registry";
|
|
4
|
+
import {
|
|
5
|
+
type DashboardWidgetSize,
|
|
6
|
+
fitWidgetSize,
|
|
7
|
+
isDashboardWidgetSize,
|
|
8
|
+
WIDGET_MAX_RESPONSE_BYTES,
|
|
9
|
+
type WidgetResponse,
|
|
10
|
+
WidgetResponseSchema,
|
|
11
|
+
type WidgetStreamLine,
|
|
12
|
+
widgetKey,
|
|
13
|
+
} from "../contracts/widgets";
|
|
5
14
|
import { type AuthContext, auth, preferredLocale, rejectReservedWorkloadCredential } from "../server";
|
|
15
|
+
import { WIDGET_SIZE_PARAM } from "../server/widget-request";
|
|
16
|
+
import { CLOUD_INVOCATION_TOKEN_TTL_SECONDS } from "../services/identity/constants";
|
|
6
17
|
import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
|
|
7
18
|
import { widgetInvocationOperation } from "../services/identity/invocation-operations";
|
|
8
19
|
import { normalizeInvocationRequestId, signInvocationToken } from "../services/identity/invocation-token";
|
|
9
20
|
import { withActiveIdentitySigner } from "../services/identity/key-ring";
|
|
10
21
|
import { logger } from "../services/logging";
|
|
11
22
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
23
|
+
import { ndjsonStream, startBounded, waitWithin } from "./fanout";
|
|
12
24
|
|
|
13
25
|
type WidgetRouteDependencies = {
|
|
14
26
|
listWidgets?: typeof listWidgets;
|
|
@@ -19,7 +31,19 @@ type WidgetRouteDependencies = {
|
|
|
19
31
|
timeoutMs?: number;
|
|
20
32
|
};
|
|
21
33
|
|
|
22
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Each widget's own budget, counted from the moment Core starts it: the same interactive budget Universal Search
|
|
36
|
+
* gives its providers. Widgets stream in as they finish, so a slow one delays only itself.
|
|
37
|
+
*/
|
|
38
|
+
export const WIDGET_TIMEOUT_MS = 8_000;
|
|
39
|
+
/** Widgets Core asks at the same time for one dashboard, as many as Universal Search asks providers. */
|
|
40
|
+
export const WIDGET_CONCURRENCY = 8;
|
|
41
|
+
/**
|
|
42
|
+
* A stream signs every invocation up front; no widget may still start after those invocations expire. Widgets that
|
|
43
|
+
* have not answered by then end as `timeout`.
|
|
44
|
+
*/
|
|
45
|
+
const WIDGET_STREAM_DEADLINE_MS = CLOUD_INVOCATION_TOKEN_TTL_SECONDS * 1_000;
|
|
46
|
+
|
|
23
47
|
const log = logger("widgets");
|
|
24
48
|
type WidgetRejectionReason =
|
|
25
49
|
| "upstream_timeout"
|
|
@@ -30,114 +54,280 @@ type WidgetRejectionReason =
|
|
|
30
54
|
| "deadline_exceeded"
|
|
31
55
|
| "request_cancelled"
|
|
32
56
|
| "operation_failed";
|
|
57
|
+
type WidgetPhase = "registry" | "signing" | "provider" | "response";
|
|
33
58
|
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
},
|
|
47
|
-
(error) => {
|
|
48
|
-
signal.removeEventListener("abort", aborted);
|
|
49
|
-
reject(error);
|
|
50
|
-
},
|
|
51
|
-
);
|
|
59
|
+
const logRejection = (
|
|
60
|
+
widget: { appId: string; widgetId: string },
|
|
61
|
+
phase: WidgetPhase,
|
|
62
|
+
reason: WidgetRejectionReason,
|
|
63
|
+
upstreamStatus?: number,
|
|
64
|
+
) =>
|
|
65
|
+
log.warn("Widget proxy rejected response", {
|
|
66
|
+
appId: widget.appId.slice(0, 80),
|
|
67
|
+
widgetId: widget.widgetId.slice(0, 100),
|
|
68
|
+
phase,
|
|
69
|
+
reason,
|
|
70
|
+
...(upstreamStatus === undefined ? {} : { upstreamStatus }),
|
|
52
71
|
});
|
|
53
72
|
|
|
73
|
+
type ProviderOutcome =
|
|
74
|
+
| { status: "ok"; widget: WidgetResponse }
|
|
75
|
+
| { status: "empty" | "forbidden" }
|
|
76
|
+
| { status: "timeout" | "error"; phase: WidgetPhase; reason: WidgetRejectionReason; upstreamStatus?: number };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Calls one provider with its exact invocation and validates the answer. Never throws: every failure becomes a
|
|
80
|
+
* bounded outcome that the caller logs and reports.
|
|
81
|
+
*/
|
|
82
|
+
const callProvider = async (options: {
|
|
83
|
+
c: Context<AuthContext>;
|
|
84
|
+
widget: DashboardWidget;
|
|
85
|
+
/** What the browser asked for, fitted to the widget's declared sizes before it reaches the app. */
|
|
86
|
+
size: unknown;
|
|
87
|
+
token: string;
|
|
88
|
+
requestId: string | undefined;
|
|
89
|
+
signal: AbortSignal;
|
|
90
|
+
timeout: AbortSignal;
|
|
91
|
+
fetch: NonNullable<WidgetRouteDependencies["fetch"]>;
|
|
92
|
+
}): Promise<ProviderOutcome> => {
|
|
93
|
+
const { c, widget, signal, timeout } = options;
|
|
94
|
+
let phase: WidgetPhase = "provider";
|
|
95
|
+
try {
|
|
96
|
+
const headers = new Headers({ authorization: `Bearer ${options.token}` });
|
|
97
|
+
if (options.requestId) headers.set("x-request-id", options.requestId);
|
|
98
|
+
for (const name of ["traceparent", "tracestate"] as const) {
|
|
99
|
+
const value = c.req.header(name);
|
|
100
|
+
if (value) headers.set(name, value);
|
|
101
|
+
}
|
|
102
|
+
const locale = preferredLocale(c.req.raw.headers);
|
|
103
|
+
if (locale) headers.set(LOCALE_HEADER, locale);
|
|
104
|
+
headers.set("x-cloud-invocation-operation", widgetInvocationOperation(widget.widgetId));
|
|
105
|
+
|
|
106
|
+
const targetUrl = new URL(`/api/_internal/widgets/v1/${encodeURIComponent(widget.widgetId)}`, widget.url);
|
|
107
|
+
targetUrl.searchParams.set(WIDGET_SIZE_PARAM, fitWidgetSize(options.size, widget));
|
|
108
|
+
signal.throwIfAborted();
|
|
109
|
+
const response = await options.fetch(targetUrl, { headers, signal });
|
|
110
|
+
if (response.status === 204 || response.status === 403) {
|
|
111
|
+
await response.body?.cancel();
|
|
112
|
+
return { status: response.status === 204 ? "empty" : "forbidden" };
|
|
113
|
+
}
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
await response.body?.cancel();
|
|
116
|
+
return response.status === 504
|
|
117
|
+
? { status: "timeout", phase, reason: "upstream_timeout", upstreamStatus: response.status }
|
|
118
|
+
: { status: "error", phase, reason: "upstream_status", upstreamStatus: response.status };
|
|
119
|
+
}
|
|
120
|
+
phase = "response";
|
|
121
|
+
const body = await readBoundedJson(response, WIDGET_MAX_RESPONSE_BYTES);
|
|
122
|
+
signal.throwIfAborted();
|
|
123
|
+
if (!body.ok) return { status: "error", phase, reason: body.reason };
|
|
124
|
+
const parsed = WidgetResponseSchema.safeParse(body.data);
|
|
125
|
+
if (!parsed.success) return { status: "error", phase, reason: "invalid_schema" };
|
|
126
|
+
return { status: "ok", widget: parsed.data };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const isTimeout = timeout.aborted || (error instanceof Error && error.name === "TimeoutError");
|
|
129
|
+
return isTimeout
|
|
130
|
+
? { status: "timeout", phase, reason: "deadline_exceeded" }
|
|
131
|
+
: { status: "error", phase, reason: signal.aborted ? "request_cancelled" : "operation_failed" };
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `<appId>/<widgetId>` with an optional `@small`, `@medium`, or `@large`, as the key and the size it asks for. The
|
|
137
|
+
* suffix is a size whenever the key before it is declared; `defineApp()` rejects `@` in widget IDs, so only an app
|
|
138
|
+
* built before sizes existed can declare an ID that ends in such a suffix, and it is still asked by that whole ID.
|
|
139
|
+
*/
|
|
140
|
+
const parseRequestedWidget = (value: string, declared: ReadonlySet<string>): [string, DashboardWidgetSize | undefined] => {
|
|
141
|
+
const at = value.lastIndexOf("@");
|
|
142
|
+
const size = at < 0 ? undefined : value.slice(at + 1);
|
|
143
|
+
return isDashboardWidgetSize(size) && declared.has(value.slice(0, at)) ? [value.slice(0, at), size] : [value, undefined];
|
|
144
|
+
};
|
|
145
|
+
|
|
54
146
|
export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) => {
|
|
55
147
|
const registry = dependencies.listWidgets ?? listWidgets;
|
|
56
148
|
const fetchWidget = dependencies.fetch ?? globalThis.fetch;
|
|
149
|
+
const timeoutMs = dependencies.timeoutMs ?? WIDGET_TIMEOUT_MS;
|
|
150
|
+
const withActiveSigner = dependencies.withActiveSigner ?? withActiveIdentitySigner;
|
|
151
|
+
const signInvocation = dependencies.signInvocation ?? signInvocationToken;
|
|
57
152
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
153
|
+
const sign = (
|
|
154
|
+
c: Context<AuthContext>,
|
|
155
|
+
widget: DashboardWidget,
|
|
156
|
+
requestId: string | undefined,
|
|
157
|
+
signer: Parameters<Parameters<typeof withActiveIdentitySigner>[1]>[0],
|
|
158
|
+
) =>
|
|
159
|
+
signInvocation({
|
|
160
|
+
targetAppId: widget.appId,
|
|
161
|
+
callingAppId: "core",
|
|
162
|
+
operation: widgetInvocationOperation(widget.widgetId),
|
|
163
|
+
schemaHash: null,
|
|
164
|
+
authority: invocationAuthorityFromRequest(auth.getAuthority(c)),
|
|
165
|
+
requestId,
|
|
166
|
+
signer,
|
|
167
|
+
issuer: signer.issuer,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
new Hono<AuthContext>()
|
|
172
|
+
.use(dependencies.authenticate ?? auth.requireRole("authenticated"))
|
|
173
|
+
.use(rejectReservedWorkloadCredential)
|
|
174
|
+
.use(auth.requireOAuthScope("read", "admin"))
|
|
175
|
+
/**
|
|
176
|
+
* Every requested widget (`?widget=<appId>/<widgetId>`, repeatable, optionally with `@<size>`; all declared
|
|
177
|
+
* widgets without one) as an NDJSON stream: one line per widget as soon as it answers, each within its own budget.
|
|
178
|
+
*/
|
|
179
|
+
.get("/widgets/v1", async (c) => {
|
|
180
|
+
const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
|
|
181
|
+
const setup = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)]);
|
|
182
|
+
let widgets: DashboardWidget[];
|
|
183
|
+
let requested = new Map<string, DashboardWidgetSize | undefined>();
|
|
184
|
+
try {
|
|
185
|
+
const keys = new Set<string>();
|
|
186
|
+
const declared = await waitWithin(registry(), setup);
|
|
187
|
+
const declaredKeys = new Set(declared.map((widget) => widgetKey(widget.appId, widget.widgetId)));
|
|
188
|
+
requested = new Map((c.req.queries("widget") ?? []).map((value) => parseRequestedWidget(value, declaredKeys)));
|
|
189
|
+
widgets = declared.filter((widget) => {
|
|
190
|
+
const key = widgetKey(widget.appId, widget.widgetId);
|
|
191
|
+
if (keys.has(key) || (requested.size > 0 && !requested.has(key))) return false;
|
|
192
|
+
keys.add(key);
|
|
193
|
+
return true;
|
|
194
|
+
});
|
|
195
|
+
} catch {
|
|
196
|
+
log.warn("Widget registry unavailable", { reason: setup.aborted ? "deadline_exceeded" : "operation_failed" });
|
|
197
|
+
return c.json({ message: "Widget registry is currently unavailable" }, 503);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const streamDeadline = AbortSignal.timeout(WIDGET_STREAM_DEADLINE_MS);
|
|
201
|
+
// A client that stops reading, such as a browser leaving the dashboard, ends every widget it still waits for.
|
|
202
|
+
const streamCancelled = new AbortController();
|
|
203
|
+
const fanoutSignal = AbortSignal.any([c.req.raw.signal, streamDeadline, streamCancelled.signal]);
|
|
204
|
+
// One signing guard for the whole dashboard, not one Postgres check per widget.
|
|
205
|
+
let tokens: PromiseSettledResult<Awaited<ReturnType<typeof signInvocationToken>>>[] = [];
|
|
206
|
+
try {
|
|
207
|
+
if (widgets.length > 0) {
|
|
208
|
+
const signing = AbortSignal.any([fanoutSignal, AbortSignal.timeout(timeoutMs)]);
|
|
209
|
+
tokens = await waitWithin(
|
|
210
|
+
withActiveSigner(
|
|
211
|
+
"invocation",
|
|
212
|
+
(signer) =>
|
|
213
|
+
Promise.all(startBounded(widgets, WIDGET_CONCURRENCY, async (widget) => sign(c, widget, requestId, signer), signing)),
|
|
214
|
+
{ signal: signing, timeoutMs },
|
|
215
|
+
),
|
|
216
|
+
signing,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
log.warn("Widget invocation authority unavailable", { reason: fanoutSignal.aborted ? "deadline_exceeded" : "operation_failed" });
|
|
221
|
+
return c.json({ message: "Widget invocation authority is currently unavailable" }, 503);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const fanoutStarted = performance.now();
|
|
225
|
+
const settled = startBounded(
|
|
226
|
+
widgets,
|
|
227
|
+
WIDGET_CONCURRENCY,
|
|
228
|
+
async (widget, index): Promise<WidgetStreamLine> => {
|
|
229
|
+
const key = widgetKey(widget.appId, widget.widgetId);
|
|
230
|
+
const started = performance.now();
|
|
231
|
+
const elapsed = () => Math.round(performance.now() - started);
|
|
232
|
+
const token = tokens[index];
|
|
233
|
+
if (token?.status !== "fulfilled") {
|
|
234
|
+
logRejection(widget, "signing", "operation_failed");
|
|
235
|
+
return { type: "widget", key, status: "error", ms: elapsed() };
|
|
236
|
+
}
|
|
237
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
238
|
+
const outcome = await callProvider({
|
|
239
|
+
c,
|
|
240
|
+
widget,
|
|
241
|
+
token: token.value.token,
|
|
242
|
+
requestId,
|
|
243
|
+
signal: AbortSignal.any([fanoutSignal, timeout]),
|
|
244
|
+
timeout,
|
|
245
|
+
fetch: fetchWidget,
|
|
246
|
+
size: requested.get(key),
|
|
247
|
+
});
|
|
248
|
+
if (outcome.status === "ok") return { type: "widget", key, status: "ok", widget: outcome.widget, ms: elapsed() };
|
|
249
|
+
// A widget the stream itself stopped has already been settled, and is logged once, below.
|
|
250
|
+
if ("reason" in outcome && !fanoutSignal.aborted) logRejection(widget, outcome.phase, outcome.reason, outcome.upstreamStatus);
|
|
251
|
+
return { type: "widget", key, status: outcome.status, ms: elapsed() };
|
|
252
|
+
},
|
|
253
|
+
fanoutSignal,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return ndjsonStream<WidgetStreamLine>({
|
|
257
|
+
first: { type: "start", widgets: widgets.map((widget) => widgetKey(widget.appId, widget.widgetId)) },
|
|
258
|
+
pending: settled.map((pending, index) =>
|
|
259
|
+
pending.then((result): WidgetStreamLine => {
|
|
260
|
+
if (result.status === "fulfilled") return result.value;
|
|
261
|
+
// Only the stream's own deadline or the browser leaving settles a widget this way. Leaving is not a
|
|
262
|
+
// failure of the widget, so only the deadline is logged.
|
|
263
|
+
const widget = widgets[index]!;
|
|
264
|
+
const status = streamDeadline.aborted ? "timeout" : "error";
|
|
265
|
+
if (streamDeadline.aborted) logRejection(widget, "provider", "deadline_exceeded");
|
|
266
|
+
return {
|
|
267
|
+
type: "widget",
|
|
268
|
+
key: widgetKey(widget.appId, widget.widgetId),
|
|
269
|
+
status,
|
|
270
|
+
ms: Math.round(performance.now() - fanoutStarted),
|
|
271
|
+
};
|
|
272
|
+
}),
|
|
273
|
+
),
|
|
274
|
+
last: (lines) => ({
|
|
275
|
+
type: "done",
|
|
276
|
+
status: lines.some((line) => line.type === "widget" && (line.status === "timeout" || line.status === "error"))
|
|
277
|
+
? "partial"
|
|
278
|
+
: "complete",
|
|
279
|
+
}),
|
|
280
|
+
onCancel: () => streamCancelled.abort(),
|
|
76
281
|
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
282
|
+
})
|
|
283
|
+
.get("/widgets/v1/:appId/:widgetId", async (c) => {
|
|
284
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
285
|
+
const signal = AbortSignal.any([c.req.raw.signal, timeout]);
|
|
286
|
+
const appId = c.req.param("appId");
|
|
287
|
+
const widgetId = c.req.param("widgetId");
|
|
288
|
+
const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
|
|
289
|
+
let phase: WidgetPhase = "registry";
|
|
290
|
+
try {
|
|
291
|
+
const widget = (await waitWithin(registry(), signal)).find((entry) => entry.appId === appId && entry.widgetId === widgetId);
|
|
292
|
+
if (!widget) return c.json({ message: "Widget not found" }, 404);
|
|
293
|
+
|
|
294
|
+
phase = "signing";
|
|
85
295
|
const signed = await waitWithin(
|
|
86
|
-
(
|
|
87
|
-
"invocation",
|
|
88
|
-
(signer) =>
|
|
89
|
-
(dependencies.signInvocation ?? signInvocationToken)({
|
|
90
|
-
targetAppId: widget.appId,
|
|
91
|
-
callingAppId: "core",
|
|
92
|
-
operation: widgetInvocationOperation(widget.widgetId),
|
|
93
|
-
schemaHash: null,
|
|
94
|
-
authority: invocationAuthorityFromRequest(auth.getAuthority(c)),
|
|
95
|
-
requestId,
|
|
96
|
-
signer,
|
|
97
|
-
issuer: signer.issuer,
|
|
98
|
-
}),
|
|
99
|
-
{ signal, timeoutMs: dependencies.timeoutMs ?? WIDGET_PROXY_TIMEOUT_MS },
|
|
100
|
-
),
|
|
296
|
+
withActiveSigner("invocation", (signer) => sign(c, widget, requestId, signer), { signal, timeoutMs }),
|
|
101
297
|
signal,
|
|
102
298
|
);
|
|
103
|
-
return new Headers({ authorization: `Bearer ${signed.token}` });
|
|
104
|
-
})();
|
|
105
299
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
300
|
+
phase = "provider";
|
|
301
|
+
const outcome = await callProvider({
|
|
302
|
+
c,
|
|
303
|
+
widget,
|
|
304
|
+
size: c.req.query(WIDGET_SIZE_PARAM),
|
|
305
|
+
token: signed.token,
|
|
306
|
+
requestId,
|
|
307
|
+
signal,
|
|
308
|
+
timeout,
|
|
309
|
+
fetch: fetchWidget,
|
|
310
|
+
});
|
|
311
|
+
if (outcome.status === "ok") return Response.json(outcome.widget, { headers: { "content-type": "application/json" } });
|
|
312
|
+
if (!("reason" in outcome))
|
|
313
|
+
return new Response(null, { status: outcome.status === "empty" ? 204 : 403, headers: { "content-type": "application/json" } });
|
|
314
|
+
logRejection(widget, outcome.phase, outcome.reason, outcome.upstreamStatus);
|
|
315
|
+
if (outcome.status === "timeout") return Response.json({ message: "Widget deadline exceeded" }, { status: 504 });
|
|
316
|
+
const message =
|
|
317
|
+
outcome.reason === "invalid_schema"
|
|
318
|
+
? "Widget returned an invalid response"
|
|
319
|
+
: outcome.reason === "too_large" || outcome.reason === "invalid_json"
|
|
320
|
+
? "Widget returned invalid or oversized JSON"
|
|
321
|
+
: "Widget is unavailable";
|
|
322
|
+
return Response.json({ message }, { status: 502 });
|
|
323
|
+
} catch (error) {
|
|
324
|
+
const isTimeout = timeout.aborted || (error instanceof Error && error.name === "TimeoutError");
|
|
325
|
+
const reason: WidgetRejectionReason = isTimeout ? "deadline_exceeded" : signal.aborted ? "request_cancelled" : "operation_failed";
|
|
326
|
+
logRejection({ appId, widgetId }, phase, reason);
|
|
327
|
+
return isTimeout
|
|
328
|
+
? Response.json({ message: "Widget deadline exceeded" }, { status: 504 })
|
|
329
|
+
: Response.json({ message: "Widget is unavailable" }, { status: 502 });
|
|
128
330
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
signal.throwIfAborted();
|
|
132
|
-
if (!body.ok) return reject(body.reason, "Widget returned invalid or oversized JSON", 502);
|
|
133
|
-
const parsed = WidgetResponseSchema.safeParse(body.data);
|
|
134
|
-
if (!parsed.success) return reject("invalid_schema", "Widget returned an invalid response", 502);
|
|
135
|
-
return Response.json(parsed.data, { headers: { "content-type": "application/json" } });
|
|
136
|
-
} catch (error) {
|
|
137
|
-
const isTimeout = timeout.aborted || (error instanceof Error && error.name === "TimeoutError");
|
|
138
|
-
return isTimeout
|
|
139
|
-
? reject("deadline_exceeded", "Widget deadline exceeded", 504)
|
|
140
|
-
: reject(signal.aborted ? "request_cancelled" : "operation_failed", "Widget is unavailable", 502);
|
|
141
|
-
}
|
|
142
|
-
});
|
|
331
|
+
})
|
|
332
|
+
);
|
|
143
333
|
};
|
|
@@ -824,7 +824,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
824
824
|
focusInput();
|
|
825
825
|
}}
|
|
826
826
|
>
|
|
827
|
-
|
|
827
|
+
<i class="ti ti-arrow-left" aria-hidden="true" /> {t().back}
|
|
828
828
|
</Button>
|
|
829
829
|
<div class="cloud-resource-search__preview-app">
|
|
830
830
|
<i class={item().icon ?? item().appIcon} aria-hidden="true" />
|
|
@@ -31,12 +31,21 @@ export const shortcutLabel = (shortcut: string): string => {
|
|
|
31
31
|
if (!combination) return "";
|
|
32
32
|
const mac = isMac();
|
|
33
33
|
const names = { ctrl: mac ? "⌃" : "Ctrl", alt: mac ? "⌥" : "Alt", shift: mac ? "⇧" : "Shift", meta: mac ? "⌘" : "Meta" };
|
|
34
|
-
|
|
34
|
+
const arrows: Record<string, string> = { arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→" };
|
|
35
|
+
const key = arrows[combination.key] ?? combination.key.toUpperCase();
|
|
36
|
+
return [...modifiers.filter((name) => combination[name]).map((name) => names[name]), key].join(mac ? "" : "+");
|
|
35
37
|
};
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
const ARROWS = new Set(["arrowup", "arrowdown", "arrowleft", "arrowright"]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One owner in the Cloud layout; app islands only publish commands. A command
|
|
43
|
+
* on an arrow key comes last: the focused element sees the key first, and a
|
|
44
|
+
* widget that moves with arrow keys, such as a tree that reorders on Alt+↑ or
|
|
45
|
+
* an open list, keeps it by handling it.
|
|
46
|
+
*/
|
|
38
47
|
export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => void): (() => void) => {
|
|
39
|
-
const listener = (event: KeyboardEvent) => {
|
|
48
|
+
const listener = (event: KeyboardEvent, arrows: boolean) => {
|
|
40
49
|
if (event.defaultPrevented || event.repeat || event.isComposing || (!isMac() && event.getModifierState("AltGraph"))) return;
|
|
41
50
|
const target = event.target;
|
|
42
51
|
const editing =
|
|
@@ -47,7 +56,7 @@ export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => vo
|
|
|
47
56
|
if (!command.shortcut) return false;
|
|
48
57
|
if (command.id !== "cloud.search" && dialogOpen) return false;
|
|
49
58
|
const combo = parse(command.shortcut);
|
|
50
|
-
if (!combo || (editing && !combo.ctrl && !combo.meta)) return false;
|
|
59
|
+
if (!combo || ARROWS.has(combo.key) !== arrows || (editing && !combo.ctrl && !combo.meta)) return false;
|
|
51
60
|
// Shift+/ reports '?' on common keyboard layouts.
|
|
52
61
|
const key = event.key === "?" && combo.key === "/" ? "/" : event.key.toLowerCase();
|
|
53
62
|
const physicalLetter =
|
|
@@ -69,8 +78,14 @@ export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => vo
|
|
|
69
78
|
run(matches[0]!);
|
|
70
79
|
};
|
|
71
80
|
// Registered app combinations must win over editor bindings such as Mod+Shift+K.
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
const first = (event: KeyboardEvent) => listener(event, false);
|
|
82
|
+
const last = (event: KeyboardEvent) => listener(event, true);
|
|
83
|
+
window.addEventListener("keydown", first, true);
|
|
84
|
+
window.addEventListener("keydown", last);
|
|
85
|
+
return () => {
|
|
86
|
+
window.removeEventListener("keydown", first, true);
|
|
87
|
+
window.removeEventListener("keydown", last);
|
|
88
|
+
};
|
|
74
89
|
};
|
|
75
90
|
|
|
76
91
|
export { COMMANDS_CHANGED };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Media type Core streams per-app results in; kept local so browser bundles do not load server schemas. */
|
|
2
|
+
export const NDJSON_CONTENT_TYPE = "application/x-ndjson";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hands over each line of an NDJSON response as it arrives, until a line of type `done`. Rejects when the stream ends
|
|
6
|
+
* before that line, so the caller can tell a finished stream from one that broke off.
|
|
7
|
+
*/
|
|
8
|
+
export const readNdjsonLines = async <Line extends { type: string }>(
|
|
9
|
+
response: Response,
|
|
10
|
+
onLine: (line: Line) => void,
|
|
11
|
+
name: string,
|
|
12
|
+
): Promise<void> => {
|
|
13
|
+
if (!response.body) throw new Error(`${name} returned no body`);
|
|
14
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
15
|
+
let buffer = "";
|
|
16
|
+
let closed = false;
|
|
17
|
+
while (!closed) {
|
|
18
|
+
const chunk = await reader.read();
|
|
19
|
+
if (chunk.done) break;
|
|
20
|
+
buffer += chunk.value;
|
|
21
|
+
let end = buffer.indexOf("\n");
|
|
22
|
+
while (end >= 0) {
|
|
23
|
+
const text = buffer.slice(0, end).trim();
|
|
24
|
+
buffer = buffer.slice(end + 1);
|
|
25
|
+
if (text) {
|
|
26
|
+
const line: Line = JSON.parse(text);
|
|
27
|
+
onLine(line);
|
|
28
|
+
if (line.type === "done") closed = true;
|
|
29
|
+
}
|
|
30
|
+
end = buffer.indexOf("\n");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!closed) throw new Error(`${name} stream ended early`);
|
|
34
|
+
};
|
package/src/browser/reload.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
import { type ToastHandle, toast } from "@k2b/ui";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Default window in which a second automatic reload for the same key is
|
|
3
6
|
* suppressed. A page load, hydration, and the first live subscription finish
|
|
@@ -32,3 +35,46 @@ export const reloadOnce = (key: string, options: { windowMs?: number } = {}): bo
|
|
|
32
35
|
window.location.reload();
|
|
33
36
|
return true;
|
|
34
37
|
};
|
|
38
|
+
|
|
39
|
+
const reloadMessages = i18n.define({
|
|
40
|
+
baseLocale: "en",
|
|
41
|
+
messages: {
|
|
42
|
+
en: {
|
|
43
|
+
codeUnavailable: "Part of this page could not load. Reload the page and try again.",
|
|
44
|
+
reload: "Reload",
|
|
45
|
+
},
|
|
46
|
+
de: {
|
|
47
|
+
codeUnavailable: "Ein Teil dieser Seite konnte nicht geladen werden. Lade die Seite neu und versuche es erneut.",
|
|
48
|
+
reload: "Neu laden",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** One notice at a time: every failed load in the tab has the same remedy. */
|
|
54
|
+
let codeUnavailable: ToastHandle | undefined;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Loads code that one action needs when the action runs, for example
|
|
58
|
+
* `importOnDemand(() => import("./SettingsDialog"))`, so the page does not
|
|
59
|
+
* download it up front.
|
|
60
|
+
*
|
|
61
|
+
* Resolves `undefined` when the code cannot load. A release replaces the
|
|
62
|
+
* page's code files, so a page that was open before it cannot load code it
|
|
63
|
+
* has not loaded yet; a dropped connection fails the same way. The helper
|
|
64
|
+
* then shows a toast that offers a reload, and the caller only ends its
|
|
65
|
+
* action.
|
|
66
|
+
*/
|
|
67
|
+
export const importOnDemand = async <T>(load: () => Promise<T>): Promise<T | undefined> => {
|
|
68
|
+
try {
|
|
69
|
+
return await load();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.warn("[cloud] Could not load code on demand", error);
|
|
72
|
+
const t = reloadMessages.resolve([document.documentElement.lang || "en"]).t;
|
|
73
|
+
codeUnavailable?.dismiss();
|
|
74
|
+
codeUnavailable = toast.error(t.codeUnavailable, {
|
|
75
|
+
duration: 0,
|
|
76
|
+
action: { label: t.reload, onClick: () => window.location.reload() },
|
|
77
|
+
});
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { SearchApp, SearchItem, SearchProviderStatus, SearchResponse, SearchStreamLine } from "../api/search/schemas";
|
|
2
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";
|
|
3
|
+
import { NDJSON_CONTENT_TYPE, readNdjsonLines } from "./ndjson";
|
|
6
4
|
|
|
7
5
|
export type SearchAppStatus = SearchProviderStatus | "searching";
|
|
8
6
|
|
|
@@ -119,33 +117,13 @@ export const streamCloudResourceSearch = async (
|
|
|
119
117
|
): Promise<void> => {
|
|
120
118
|
const response = await fetch(url, {
|
|
121
119
|
signal: options.signal,
|
|
122
|
-
headers: { accept:
|
|
120
|
+
headers: { accept: NDJSON_CONTENT_TYPE, [LOCALE_HEADER]: options.locale },
|
|
123
121
|
});
|
|
124
122
|
if (!response.ok) throw new Error(`Search failed with ${response.status}`);
|
|
125
|
-
if (!response.headers.get("content-type")?.startsWith(
|
|
123
|
+
if (!response.headers.get("content-type")?.startsWith(NDJSON_CONTENT_TYPE)) {
|
|
126
124
|
const body: SearchResponse = await response.json();
|
|
127
125
|
for (const line of linesFromResponse(body)) options.onLine(line);
|
|
128
126
|
return;
|
|
129
127
|
}
|
|
130
|
-
|
|
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");
|
|
128
|
+
await readNdjsonLines(response, options.onLine, "Search");
|
|
151
129
|
};
|