akanjs 3.1.0-rc.21 → 3.1.0-rc.23
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/client/cookie.ts +5 -0
- package/client/csrLocation.ts +17 -72
- package/client/csrTypes.ts +19 -4
- package/client/route/PageDefinition.ts +103 -10
- package/client/route/RouteDefinition.ts +44 -3
- package/client/route/RouteGuards.ts +64 -0
- package/client/route/index.ts +1 -0
- package/client/route/resolveRouteModule.ts +8 -0
- package/client/route/routeArgs.ts +9 -0
- package/client/router.ts +11 -0
- package/common/authCookie.ts +13 -0
- package/common/authToken.ts +1 -12
- package/common/index.ts +2 -8
- package/common/routeConvention.ts +2 -0
- package/constant/inputParser.ts +1 -1
- package/dictionary/base.dictionary.ts +10 -0
- package/document/documentQuery.ts +21 -0
- package/document/liveRowScope.ts +4 -8
- package/document/queryEvaluator.ts +5 -8
- package/fetch/appScope.ts +54 -0
- package/fetch/client/fetchClient.ts +46 -29
- package/fetch/index.ts +1 -0
- package/package.json +1 -1
- package/server/mcp/McpApps.ts +44 -50
- package/server/mcp/McpPages.ts +93 -19
- package/server/mcp/McpRouter.ts +14 -11
- package/server/ops/migrateJobs.ts +145 -0
- package/server/ops/opsRoute.ts +39 -7
- package/server/ops/opsToken.ts +7 -2
- package/server/routeElementComposer.tsx +33 -6
- package/server/routeTreeBuilder.ts +5 -0
- package/server/rscClient.tsx +20 -0
- package/server/rscPagePrompts.ts +66 -16
- package/server/rscWorker.tsx +33 -11
- package/server/rscWorkerHost.ts +12 -1
- package/server/webRouter.ts +31 -27
- package/service/liveSyncHub.ts +2 -1
- package/service/predefinedAdaptor/sql/QueryCompiler.ts +3 -7
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +14 -2
- package/service/predefinedAdaptor/sql/dialect/postgres.ts +4 -3
- package/service/predefinedAdaptor/sql/dialect/sqlite.ts +2 -2
- package/service/predefinedAdaptor/sql/types.ts +2 -0
- package/service/predefinedAdaptor/sql/values.ts +19 -0
- package/signal/mcp/mcpProtocol.ts +7 -0
- package/signal/mcp/pagePrompt.ts +18 -1
- package/types/client/csrLocation.d.ts +3 -7
- package/types/client/csrTypes.d.ts +22 -3
- package/types/client/route/PageDefinition.d.ts +21 -4
- package/types/client/route/RouteDefinition.d.ts +4 -0
- package/types/client/route/RouteGuards.d.ts +15 -0
- package/types/client/route/index.d.ts +1 -0
- package/types/client/route/resolveRouteModule.d.ts +2 -0
- package/types/client/route/routeArgs.d.ts +5 -0
- package/types/client/route/routeBuilders.d.ts +1 -1
- package/types/client/router.d.ts +3 -0
- package/types/common/authCookie.d.ts +5 -0
- package/types/common/authToken.d.ts +0 -5
- package/types/common/index.d.ts +2 -1
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/documentQuery.d.ts +1 -0
- package/types/fetch/appScope.d.ts +18 -0
- package/types/fetch/client/fetchClient.d.ts +1 -1
- package/types/fetch/index.d.ts +1 -0
- package/types/server/mcp/McpApps.d.ts +14 -8
- package/types/server/mcp/McpPages.d.ts +6 -3
- package/types/server/ops/migrateJobs.d.ts +36 -0
- package/types/server/ops/opsRoute.d.ts +4 -1
- package/types/server/ops/opsToken.d.ts +3 -0
- package/types/server/routeElementComposer.d.ts +10 -1
- package/types/server/rscPagePrompts.d.ts +3 -1
- package/types/server/rscWorkerHost.d.ts +2 -1
- package/types/server/webRouter.d.ts +3 -0
- package/types/service/predefinedAdaptor/sql/QueryCompiler.d.ts +0 -1
- package/types/service/predefinedAdaptor/sql/dialect/postgres.d.ts +1 -1
- package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +1 -1
- package/types/service/predefinedAdaptor/sql/types.d.ts +2 -0
- package/types/service/predefinedAdaptor/sql/values.d.ts +1 -0
- package/types/signal/mcp/mcpProtocol.d.ts +6 -0
- package/types/signal/mcp/pagePrompt.d.ts +16 -0
- package/types/ui/Layout/FrameSlotPortal.d.ts +1 -1
- package/types/ui/System/App.d.ts +17 -0
- package/types/ui/System/index.d.ts +2 -0
- package/types/ui/recipe/badgeRecipe.d.ts +2 -2
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/vendor/@akanjs/native/packages/cli/src/lib/toolchains.d.ts +1 -1
- package/types/webkit/CsrGuards.d.ts +25 -0
- package/types/webkit/CsrRouteTable.d.ts +6 -0
- package/types/webkit/CsrStack.d.ts +6 -0
- package/types/webkit/McpAppBoot.d.ts +32 -0
- package/types/webkit/McpAppBridge.d.ts +10 -1
- package/types/webkit/McpAppFetchBridge.d.ts +3 -1
- package/types/webkit/McpAppView.d.ts +4 -1
- package/types/webkit/PageNotFound.d.ts +5 -0
- package/types/webkit/RenderLayer.d.ts +1 -3
- package/types/webkit/index.d.ts +1 -0
- package/ui/Layout/BottomInset.tsx +2 -11
- package/ui/Layout/FrameSlotPortal.tsx +1 -3
- package/ui/Layout/Navbar.tsx +7 -22
- package/ui/Layout/TopInset.tsx +3 -4
- package/ui/System/App.tsx +43 -0
- package/ui/System/CSR.tsx +1 -10
- package/ui/System/index.tsx +3 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/toolchains.ts +1 -1
- package/webkit/CsrGuards.ts +112 -0
- package/webkit/CsrRouteTable.ts +71 -16
- package/webkit/CsrStack.ts +29 -1
- package/webkit/McpAppBoot.tsx +129 -0
- package/webkit/McpAppBridge.ts +30 -6
- package/webkit/McpAppFetchBridge.ts +6 -1
- package/webkit/McpAppView.ts +39 -28
- package/webkit/PageNotFound.tsx +24 -0
- package/webkit/RenderLayer.tsx +98 -41
- package/webkit/bootCsr.tsx +4 -43
- package/webkit/index.ts +1 -0
- package/webkit/useCsrValues.ts +55 -6
- package/webkit/useLocation.ts +21 -19
- package/types/webkit/EmbeddedLayout.d.ts +0 -10
- package/webkit/EmbeddedLayout.tsx +0 -82
package/common/authToken.ts
CHANGED
|
@@ -1,20 +1,9 @@
|
|
|
1
1
|
import { getEnv } from "akanjs/base";
|
|
2
|
+
import { legacyAuthTokenKey } from "./authCookie";
|
|
2
3
|
import { decodeJwtPayload } from "./jwtDecode";
|
|
3
4
|
|
|
4
|
-
/** The pre-scoping key. Read as a fallback, never written; drop it once deployments have rotated past it. */
|
|
5
|
-
export const legacyAuthTokenKey = "jwt";
|
|
6
|
-
|
|
7
5
|
export const authTokenKey = (): string => `${legacyAuthTokenKey}:${getEnv().appName}`;
|
|
8
6
|
|
|
9
|
-
/** Matches both spellings so a signout can sweep a jar written before and after the key was scoped. */
|
|
10
|
-
export const isAuthTokenKey = (key: string): boolean =>
|
|
11
|
-
key === legacyAuthTokenKey || key.startsWith(`${legacyAuthTokenKey}:`);
|
|
12
|
-
|
|
13
|
-
const authCookiePattern = new RegExp(`(?:^|;\\s*)${legacyAuthTokenKey}(?::[^=\\s;]+)?=`);
|
|
14
|
-
|
|
15
|
-
export const cookieHeaderHasAuthToken = (cookieHeader: string | null | undefined): boolean =>
|
|
16
|
-
!!cookieHeader && authCookiePattern.test(cookieHeader);
|
|
17
|
-
|
|
18
7
|
export const isOwnAuthToken = (jwt: string): boolean => {
|
|
19
8
|
const { appName, environment } = getEnv();
|
|
20
9
|
try {
|
package/common/index.ts
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
export { applyMixins, getAllPropertyDescriptors } from "./applyMixins";
|
|
2
|
-
export {
|
|
3
|
-
|
|
4
|
-
cookieHeaderHasAuthToken,
|
|
5
|
-
isAuthTokenKey,
|
|
6
|
-
isOwnAuthToken,
|
|
7
|
-
legacyAuthTokenKey,
|
|
8
|
-
readAuthToken,
|
|
9
|
-
} from "./authToken";
|
|
2
|
+
export { cookieHeaderHasAuthToken, isAuthTokenKey, legacyAuthTokenKey } from "./authCookie";
|
|
3
|
+
export { authTokenKey, isOwnAuthToken, readAuthToken } from "./authToken";
|
|
10
4
|
export { Backoff, type BackoffPolicy } from "./backoff";
|
|
11
5
|
export { bearerTokenOf } from "./bearerTokenOf";
|
|
12
6
|
export { CodeAgentClient, type CodeAgentTransport } from "./CodeAgentClient";
|
|
@@ -16,6 +16,7 @@ export const PAGE_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
|
|
|
16
16
|
"head",
|
|
17
17
|
"generateHead",
|
|
18
18
|
"Loading",
|
|
19
|
+
"guard",
|
|
19
20
|
]);
|
|
20
21
|
export const LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([...PAGE_ROUTE_EXPORTS, "NotFound", "Error"]);
|
|
21
22
|
export const ROOT_LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
|
|
@@ -27,6 +28,7 @@ export const ROOT_LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
|
|
|
27
28
|
"wsConnect",
|
|
28
29
|
"layoutStyle",
|
|
29
30
|
"viewport",
|
|
31
|
+
"McpAppRoot",
|
|
30
32
|
]);
|
|
31
33
|
/** Root-layout exports that are plain config rather than components, so a PascalCase check cannot allow them. */
|
|
32
34
|
export const RESERVED_ROUTE_CONFIG_EXPORTS: ReadonlySet<string> = new Set(
|
package/constant/inputParser.ts
CHANGED
|
@@ -45,12 +45,12 @@ export class InputParser {
|
|
|
45
45
|
]);
|
|
46
46
|
|
|
47
47
|
static parse(argRef: Cls, arrDepth: number, value: unknown, option: ParseOption) {
|
|
48
|
-
if (PrimitiveRegistry.has(argRef)) return deserialize(argRef, arrDepth, value, option);
|
|
49
48
|
const { key, nullable = false } = option;
|
|
50
49
|
if (value === null || value === undefined) {
|
|
51
50
|
if (nullable) return null;
|
|
52
51
|
throw new InvalidInputError(key, null);
|
|
53
52
|
}
|
|
53
|
+
if (PrimitiveRegistry.has(argRef)) return deserialize(argRef, arrDepth, value, option);
|
|
54
54
|
return InputParser.#walk(value, arrDepth, key, (item, path) => InputParser.#value(argRef, item, path));
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -65,6 +65,14 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
|
|
|
65
65
|
"{endpoint} is closed: the slice's guards name none that allows it.",
|
|
66
66
|
"{endpoint}이(가) 닫혀 있습니다. 슬라이스 guards에 이 동작을 허용하는 guard를 지정해야 합니다.",
|
|
67
67
|
],
|
|
68
|
+
mutationInApp: [
|
|
69
|
+
"The {app} app called {endpoint} while it loads, and an app may load with queries only.",
|
|
70
|
+
"{app} 앱이 불러오는 중에 {endpoint}을(를) 호출했습니다. 앱은 쿼리로만 불러올 수 있습니다.",
|
|
71
|
+
],
|
|
72
|
+
readOnlyDeployment: [
|
|
73
|
+
"This server is read-only, so {endpoint} cannot run here.",
|
|
74
|
+
"읽기 전용 서버라 {endpoint}을(를) 실행할 수 없습니다.",
|
|
75
|
+
],
|
|
68
76
|
})
|
|
69
77
|
.translate({
|
|
70
78
|
somethingWrong: ["Something's wrong!", "문제가 생겼어요!"],
|
|
@@ -92,6 +100,8 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
|
|
|
92
100
|
cancel: ["Cancel", "취소"],
|
|
93
101
|
retry: ["Try again", "다시 시도"],
|
|
94
102
|
pageLoadFailed: ["This page could not be loaded.", "이 화면을 불러오지 못했습니다."],
|
|
103
|
+
pageNotFound: ["This page does not exist.", "없는 화면입니다."],
|
|
104
|
+
goBack: ["Go back", "돌아가기"],
|
|
95
105
|
unauthorized: ["Unauthorized", "권한 없음"],
|
|
96
106
|
agent: ["Agent", "에이전트"],
|
|
97
107
|
agentIntro: ["Ask about this page or tell the agent what to do.", "이 화면에 대해 묻거나 할 일을 지시해 보세요."],
|
|
@@ -144,6 +144,27 @@ export type DocumentQuery<T = any> =
|
|
|
144
144
|
[K in DocumentPath<T>]?: DocumentQueryValue;
|
|
145
145
|
};
|
|
146
146
|
|
|
147
|
+
export const isDocumentQueryNode = (value: unknown): value is DocumentQueryNode => {
|
|
148
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
149
|
+
const node = value as Record<string, unknown>;
|
|
150
|
+
const fits = (...keys: string[]) => Object.keys(node).every((key) => key === "kind" || keys.includes(key));
|
|
151
|
+
switch (node.kind) {
|
|
152
|
+
case "all":
|
|
153
|
+
case "any":
|
|
154
|
+
return Array.isArray(node.queries) && fits("queries");
|
|
155
|
+
case "not":
|
|
156
|
+
return !!node.query && typeof node.query === "object" && fits("query");
|
|
157
|
+
case "op":
|
|
158
|
+
return typeof node.op === "string" && queryOperatorKeys.has(node.op) && fits("op", "value");
|
|
159
|
+
case "raw":
|
|
160
|
+
return typeof node.sql === "string" && Array.isArray(node.params) && fits("sql", "params");
|
|
161
|
+
case "search":
|
|
162
|
+
return typeof node.text === "string" && fits("text", "columns", "prefix", "weights");
|
|
163
|
+
default:
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
147
168
|
export interface SqlFragment {
|
|
148
169
|
sql: string;
|
|
149
170
|
params: unknown[];
|
package/document/liveRowScope.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type DocumentQuery,
|
|
3
|
-
type DocumentQueryNode,
|
|
4
3
|
type DocumentQueryOperator,
|
|
4
|
+
isDocumentQueryNode,
|
|
5
5
|
queryOperatorKeys,
|
|
6
6
|
} from "./documentQuery";
|
|
7
7
|
|
|
@@ -14,7 +14,7 @@ type RemovedAtTest = "isNull" | "isNotNull" | "value";
|
|
|
14
14
|
export class LiveRowScope {
|
|
15
15
|
static conflictOf(query: DocumentQuery | undefined, negated = false): string | null {
|
|
16
16
|
if (!query || typeof query !== "object") return null;
|
|
17
|
-
if (
|
|
17
|
+
if (isDocumentQueryNode(query)) {
|
|
18
18
|
if (query.kind === "all" || query.kind === "any")
|
|
19
19
|
return query.queries.reduce<string | null>(
|
|
20
20
|
(found, sub) => found ?? LiveRowScope.conflictOf(sub, negated),
|
|
@@ -30,7 +30,7 @@ export class LiveRowScope {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
static #fieldConflict(path: string, value: unknown, negated: boolean): string | null {
|
|
33
|
-
if (
|
|
33
|
+
if (isDocumentQueryNode(value) && value.kind !== "op") return LiveRowScope.conflictOf(value, negated);
|
|
34
34
|
if (path !== "removedAt") return null;
|
|
35
35
|
for (const [test, spelled] of LiveRowScope.#testsOf(value)) {
|
|
36
36
|
const named = negated ? `not(removedAt ${spelled})` : `removedAt ${spelled}`;
|
|
@@ -44,7 +44,7 @@ export class LiveRowScope {
|
|
|
44
44
|
static #testsOf(value: unknown): [RemovedAtTest, string][] {
|
|
45
45
|
if (value === undefined) return [];
|
|
46
46
|
if (value === null) return [["isNull", "null"]];
|
|
47
|
-
if (
|
|
47
|
+
if (isDocumentQueryNode(value) && value.kind === "op") {
|
|
48
48
|
const test = LiveRowScope.#testOf(value.op, value.value);
|
|
49
49
|
return test ? [test] : [];
|
|
50
50
|
}
|
|
@@ -72,8 +72,4 @@ export class LiveRowScope {
|
|
|
72
72
|
if ((op === "oneOf" || op === "notOneOf") && !(operand as unknown[] | undefined)?.length) return null;
|
|
73
73
|
return ["value", op];
|
|
74
74
|
}
|
|
75
|
-
|
|
76
|
-
static #isNode(value: unknown): value is DocumentQueryNode {
|
|
77
|
-
return !!value && typeof value === "object" && "kind" in value;
|
|
78
|
-
}
|
|
79
75
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type DocumentQueryNode,
|
|
5
5
|
type DocumentQueryOperator,
|
|
6
6
|
encodeDocumentValue,
|
|
7
|
+
isDocumentQueryNode,
|
|
7
8
|
queryOperatorKeys,
|
|
8
9
|
sanitizeJson,
|
|
9
10
|
} from "./documentQuery";
|
|
@@ -49,7 +50,7 @@ export class DocumentQueryEvaluator {
|
|
|
49
50
|
*/
|
|
50
51
|
static unevaluableReason(query: DocumentQuery | undefined): string | null {
|
|
51
52
|
if (!query || typeof query !== "object") return null;
|
|
52
|
-
if (
|
|
53
|
+
if (isDocumentQueryNode(query)) {
|
|
53
54
|
if (query.kind === "raw") return "q.raw() carries SQL that only the database can run";
|
|
54
55
|
if (query.kind === "search") return "q.search() compiles to a full-text JOIN with no in-memory equivalent";
|
|
55
56
|
if (query.kind === "all" || query.kind === "any")
|
|
@@ -70,7 +71,7 @@ export class DocumentQueryEvaluator {
|
|
|
70
71
|
if (!value || typeof value !== "object") return null;
|
|
71
72
|
if (baseDocumentColumns.has(path)) return null;
|
|
72
73
|
const keyAbsence = `${path}: exists/missing asks whether the key is in the stored JSON, which a document in hand cannot answer — use q.empty()`;
|
|
73
|
-
if (
|
|
74
|
+
if (isDocumentQueryNode(value)) {
|
|
74
75
|
if (value.kind === "op") return value.op === "exists" || value.op === "missing" ? keyAbsence : null;
|
|
75
76
|
return DocumentQueryEvaluator.unevaluableReason(value as DocumentQuery);
|
|
76
77
|
}
|
|
@@ -94,7 +95,7 @@ export class DocumentQueryEvaluator {
|
|
|
94
95
|
|
|
95
96
|
evaluate(query: DocumentQuery | undefined, row: DocumentRowView): boolean {
|
|
96
97
|
if (!query || typeof query !== "object") return true;
|
|
97
|
-
if (
|
|
98
|
+
if (isDocumentQueryNode(query)) return this.#node(query, row);
|
|
98
99
|
const entries = Object.entries(query);
|
|
99
100
|
if (!entries.length) return true;
|
|
100
101
|
return entries.every(([path, value]) => this.#field(path, value, row));
|
|
@@ -118,7 +119,7 @@ export class DocumentQueryEvaluator {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
#field(path: string, value: unknown, row: DocumentRowView): boolean {
|
|
121
|
-
if (
|
|
122
|
+
if (isDocumentQueryNode(value)) {
|
|
122
123
|
if (value.kind !== "op") return this.evaluate({ [path]: value } as DocumentQuery, row);
|
|
123
124
|
return this.#operator(path, value.op, value.value, row);
|
|
124
125
|
}
|
|
@@ -290,8 +291,4 @@ export class DocumentQueryEvaluator {
|
|
|
290
291
|
static #foldAscii(text: string) {
|
|
291
292
|
return text.replace(/[A-Z]/g, (char) => char.toLowerCase());
|
|
292
293
|
}
|
|
293
|
-
|
|
294
|
-
static #isNode(value: unknown): value is DocumentQueryNode {
|
|
295
|
-
return !!value && typeof value === "object" && "kind" in value;
|
|
296
|
-
}
|
|
297
294
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
interface AppScopeStorage {
|
|
2
|
+
run<T>(app: string, load: () => T): T;
|
|
3
|
+
getStore(): string | undefined;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
var __AKAN_APP_SCOPE__: AppScopeStorage | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Marks the run of a page's `.app()` load, which may only query: an MCP view replays those queries from the tool
|
|
12
|
+
* result's snapshot, so a write in the load would run once for the tool call and again when the view draws.
|
|
13
|
+
*/
|
|
14
|
+
export class AppScope {
|
|
15
|
+
static readonly #storage: AppScopeStorage = AppScope.#shared();
|
|
16
|
+
|
|
17
|
+
static run<T>(app: string, load: () => T): T {
|
|
18
|
+
return AppScope.#storage.run(app, load);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The app whose load is running here, if any. */
|
|
22
|
+
static current(): string | undefined {
|
|
23
|
+
return AppScope.#storage.getStore();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static #shared(): AppScopeStorage {
|
|
27
|
+
globalThis.__AKAN_APP_SCOPE__ ??= AppScope.#createStorage();
|
|
28
|
+
return globalThis.__AKAN_APP_SCOPE__;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static #createStorage(): AppScopeStorage {
|
|
32
|
+
if (typeof window === "undefined") {
|
|
33
|
+
try {
|
|
34
|
+
const { AsyncLocalStorage } = require("node:async_hooks") as typeof import("node:async_hooks");
|
|
35
|
+
const als = new AsyncLocalStorage<string>();
|
|
36
|
+
return { run: (app, load) => als.run(app, load), getStore: () => als.getStore() };
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let current: string | undefined;
|
|
41
|
+
return {
|
|
42
|
+
run(app, load) {
|
|
43
|
+
const previous = current;
|
|
44
|
+
current = app;
|
|
45
|
+
try {
|
|
46
|
+
return load();
|
|
47
|
+
} finally {
|
|
48
|
+
current = previous;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
getStore: () => current,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
ServiceSignal,
|
|
21
21
|
} from "akanjs/signal";
|
|
22
22
|
import { agentTurnConstant } from "../agentTurn";
|
|
23
|
+
import { AppScope } from "../appScope";
|
|
23
24
|
import type { ClientSignal, FetchClientType, FetchSignalInput, MergeAllFetchTypes, SliceMeta } from "../fetchType";
|
|
24
25
|
import {
|
|
25
26
|
claimRequestQuery,
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
import type { GetSliceMetaObjFromDatabaseSignals } from "../types";
|
|
31
32
|
import { FetchHandle } from "./fetchHandle";
|
|
32
33
|
import { HttpClient } from "./httpClient";
|
|
33
|
-
import type
|
|
34
|
+
import { type ErrorConstructor, type RestoredError, restoreRemoteError } from "./remoteError";
|
|
34
35
|
import { SliceInitHandle } from "./sliceInitHandle";
|
|
35
36
|
import { expandQueryArgs, normalizeQueryArgs } from "./sliceQueryArgs";
|
|
36
37
|
import { WsClient, type WsClientOptions } from "./wsClient";
|
|
@@ -440,8 +441,8 @@ export class FetchClient {
|
|
|
440
441
|
const payload = claim.owned ? response : FetchClient.#deepCopy(response);
|
|
441
442
|
return parseReturn(payload, { crystalize: option?.crystalize ?? true });
|
|
442
443
|
};
|
|
443
|
-
case "mutation":
|
|
444
|
-
|
|
444
|
+
case "mutation": {
|
|
445
|
+
const send = async (...argData: unknown[]) => {
|
|
445
446
|
const { option, argMap, url } = requestOf(argData);
|
|
446
447
|
const bridge = this.#bridgeOf(option?.origin);
|
|
447
448
|
if (bridge)
|
|
@@ -458,6 +459,12 @@ export class FetchClient {
|
|
|
458
459
|
});
|
|
459
460
|
return parseReturn(response, { crystalize: option?.crystalize ?? true });
|
|
460
461
|
};
|
|
462
|
+
|
|
463
|
+
return (...argData: unknown[]) => {
|
|
464
|
+
this.#refuseInApp(key);
|
|
465
|
+
return send(...argData);
|
|
466
|
+
};
|
|
467
|
+
}
|
|
461
468
|
default:
|
|
462
469
|
throw new Error(`Unsupported endpoint type: ${endpoint.type}`);
|
|
463
470
|
}
|
|
@@ -476,6 +483,7 @@ export class FetchClient {
|
|
|
476
483
|
const parseReturn = this.#makeReturnParser(endpoint.returns);
|
|
477
484
|
const wrappedListeners = new WeakMap<(data: unknown) => void, (data: unknown) => void>();
|
|
478
485
|
return (...argData: unknown[]) => {
|
|
486
|
+
this.#refuseInApp(key);
|
|
479
487
|
|
|
480
488
|
if (FetchClient.bridged) return () => undefined;
|
|
481
489
|
const args = argData.slice(0, roomArgLength);
|
|
@@ -507,6 +515,7 @@ export class FetchClient {
|
|
|
507
515
|
const msgArgLength = msgArgs.length;
|
|
508
516
|
const serializerMap = this.#makeArgSerializer(endpoint.args);
|
|
509
517
|
return (...argData: unknown[]) => {
|
|
518
|
+
this.#refuseInApp(key);
|
|
510
519
|
if (FetchClient.bridged) return;
|
|
511
520
|
const args = argData.slice(0, msgArgLength);
|
|
512
521
|
const fetchPolicy = argData[msgArgLength] as FetchPolicy | undefined;
|
|
@@ -536,6 +545,12 @@ export class FetchClient {
|
|
|
536
545
|
}
|
|
537
546
|
}
|
|
538
547
|
|
|
548
|
+
#refuseInApp(endpoint: string) {
|
|
549
|
+
const app = AppScope.current();
|
|
550
|
+
if (app)
|
|
551
|
+
throw restoreRemoteError({ error: "base.error.mutationInApp", data: { app, endpoint } }, 400, this.ErrorCls);
|
|
552
|
+
}
|
|
553
|
+
|
|
539
554
|
#bridgeOf(origin?: string) {
|
|
540
555
|
return origin || this.#foreign ? null : FetchClient.bridge;
|
|
541
556
|
}
|
|
@@ -707,32 +722,34 @@ export class FetchClient {
|
|
|
707
722
|
);
|
|
708
723
|
}
|
|
709
724
|
|
|
710
|
-
this.#setHandlerFactory(
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
725
|
+
this.#setHandlerFactory(names.addModelFiles, () => {
|
|
726
|
+
const upload = async (fileList: FileList | File[], parentId?: string, option?: FetchPolicy) => {
|
|
727
|
+
|
|
728
|
+
if (this.#bridgeOf())
|
|
729
|
+
throw new Error("File upload is unavailable in an MCP App view: its host relays tool calls, not files.");
|
|
730
|
+
const cap = resolveFileUploadCapability(this.serializedSignal);
|
|
731
|
+
const endpoint = cap ? this.serializedSignal[cap.refName]?.endpoint[cap.endpointKey] : undefined;
|
|
732
|
+
if (!cap || !endpoint)
|
|
733
|
+
throw new Error(
|
|
734
|
+
"File upload is not configured. Mark an upload mutation with { fileUpload: true } (e.g. shared FileEndpoint.addFiles).",
|
|
735
|
+
);
|
|
736
|
+
const { fields, buildMetas } = fileUploadContract;
|
|
737
|
+
const formData = new FormData();
|
|
738
|
+
for (let i = 0; i < fileList.length; i++) formData.append(fields.files, fileList[i]);
|
|
739
|
+
formData.append(fields.metas, JSON.stringify(buildMetas(fileList)));
|
|
740
|
+
formData.append(fields.type, refName);
|
|
741
|
+
if (parentId) formData.append(fields.parentId, parentId);
|
|
742
|
+
const url = FetchClient.makeHttpUrl(cap.endpointKey, endpoint, cap.prefix, new Map());
|
|
743
|
+
return await this.http.post(url, formData, {
|
|
744
|
+
headers: this.#makeRequestHeaders(option),
|
|
745
|
+
baseUrl: this.#baseUrlOf(endpoint),
|
|
746
|
+
});
|
|
747
|
+
};
|
|
748
|
+
return ((fileList: FileList | File[], parentId?: string, option?: FetchPolicy) => {
|
|
749
|
+
this.#refuseInApp(names.addModelFiles);
|
|
750
|
+
return upload(fileList, parentId, option);
|
|
751
|
+
}) as FetchHandler;
|
|
752
|
+
});
|
|
736
753
|
}
|
|
737
754
|
|
|
738
755
|
#makeModelObjFn(refName: string, callerKey: string) {
|
package/fetch/index.ts
CHANGED
package/package.json
CHANGED
package/server/mcp/McpApps.ts
CHANGED
|
@@ -14,23 +14,29 @@ import {
|
|
|
14
14
|
import type { PagePromptEntry, PagePromptRecord, PagePromptRun } from "../../signal/mcp/pagePrompt";
|
|
15
15
|
import { McpAppShell } from "./McpAppShell";
|
|
16
16
|
|
|
17
|
-
/** The
|
|
17
|
+
/** The single-file view each `.app()` boots from, read from the build that serves the web. */
|
|
18
18
|
export interface McpAppShellSource {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
/** `null` when this build has no view for the app. */
|
|
20
|
+
read(app: string): Promise<string | null>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface McpAppsOption {
|
|
24
|
+
/** Every write the view sends is refused before it leaves, as the deployment lists no write to relay it to. */
|
|
25
|
+
readOnly?: boolean;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export class McpApps {
|
|
25
29
|
static readonly scheme = "ui://";
|
|
26
|
-
|
|
27
|
-
static readonly rootSegment = "app";
|
|
28
30
|
static readonly #tokenClaims = new Set(["iss", "aud", "exp", "nbf", "iat", "jti", "client_id", "scope"]);
|
|
31
|
+
|
|
32
|
+
static readonly #appUri = /^ui:\/\/([^/]+)\/apps\/([A-Za-z0-9_-]{1,64})$/;
|
|
29
33
|
|
|
30
34
|
readonly #shell: McpAppShellSource | null;
|
|
35
|
+
readonly #readOnly: boolean;
|
|
31
36
|
|
|
32
|
-
constructor(shell: McpAppShellSource | null) {
|
|
37
|
+
constructor(shell: McpAppShellSource | null, { readOnly = false }: McpAppsOption = {}) {
|
|
33
38
|
this.#shell = shell;
|
|
39
|
+
this.#readOnly = readOnly;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
get rendersUi() {
|
|
@@ -53,18 +59,19 @@ export class McpApps {
|
|
|
53
59
|
]),
|
|
54
60
|
);
|
|
55
61
|
const required = entry.arguments.filter((arg) => arg.required).map((arg) => arg.name);
|
|
62
|
+
const inputSchema = {
|
|
63
|
+
type: "object" as const,
|
|
64
|
+
properties,
|
|
65
|
+
...(required.length ? { required } : {}),
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
};
|
|
68
|
+
const tool = { name: entry.name, description: entry.description, inputSchema };
|
|
69
|
+
|
|
70
|
+
if (entry.kind === "prompt") return { ...tool, annotations: { readOnlyHint: false, openWorldHint: false } };
|
|
56
71
|
return {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
type: "object",
|
|
61
|
-
properties,
|
|
62
|
-
...(required.length ? { required } : {}),
|
|
63
|
-
additionalProperties: false,
|
|
64
|
-
},
|
|
65
|
-
|
|
66
|
-
annotations: { readOnlyHint: false, openWorldHint: false },
|
|
67
|
-
...(this.#shell ? { _meta: this.#toolMetaOf(this.uriOf(this.#basePathOf(entry.pattern))) } : {}),
|
|
72
|
+
...tool,
|
|
73
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
74
|
+
...(this.#shell ? { _meta: this.#toolMetaOf(this.uriOf(entry.name)) } : {}),
|
|
68
75
|
};
|
|
69
76
|
}
|
|
70
77
|
|
|
@@ -74,30 +81,30 @@ export class McpApps {
|
|
|
74
81
|
|
|
75
82
|
resourcesOf(entries: PagePromptEntry[]): McpResource[] {
|
|
76
83
|
if (!this.#shell) return [];
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
return entries
|
|
85
|
+
.filter((entry) => entry.kind === "app")
|
|
86
|
+
.map((entry) => ({
|
|
87
|
+
uri: this.uriOf(entry.name),
|
|
88
|
+
name: entry.name,
|
|
89
|
+
description: entry.description,
|
|
90
|
+
mimeType: MCP_UI_MIME_TYPE,
|
|
91
|
+
}));
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
uriOf(
|
|
88
|
-
|
|
89
|
-
return basePath ? `${root}/${basePath}` : root;
|
|
94
|
+
uriOf(app: string) {
|
|
95
|
+
return `${McpApps.scheme}${getEnv().appName.toLowerCase()}/apps/${app}`;
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
async read(uri: string, origin: string): Promise<McpResourceContents | null> {
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
if (!shell ||
|
|
96
|
-
const html = await shell.read(
|
|
99
|
+
const match = McpApps.#appUri.exec(uri);
|
|
100
|
+
const app = match?.[1]?.toLowerCase() === getEnv().appName.toLowerCase() ? match[2] : undefined;
|
|
101
|
+
if (!this.#shell || !app) return null;
|
|
102
|
+
const html = await this.#shell.read(app);
|
|
97
103
|
return html ? McpAppShell.contentsOf(uri, html, origin) : null;
|
|
98
104
|
}
|
|
99
105
|
|
|
100
106
|
resultOf(
|
|
107
|
+
entry: PagePromptEntry,
|
|
101
108
|
run: Extract<PagePromptRun, { ok: true }>,
|
|
102
109
|
messages: PromptMessage[],
|
|
103
110
|
webOrigin: string,
|
|
@@ -107,14 +114,15 @@ export class McpApps {
|
|
|
107
114
|
const href = `${url.pathname}${url.search}`;
|
|
108
115
|
const content: PromptContent[] = [
|
|
109
116
|
...messages.map((message) => message.content),
|
|
110
|
-
{ type: "text", text: `Open
|
|
117
|
+
{ type: "text", text: `Open the full screen: ${webOrigin}${href}` },
|
|
111
118
|
];
|
|
112
|
-
if (!this.#shell || !forView) return { content, isError: false };
|
|
119
|
+
if (entry.kind === "prompt" || !this.#shell || !forView) return { content, isError: false };
|
|
113
120
|
const _meta = {
|
|
114
121
|
[MCP_APP_META.snapshot]: McpApps.#snapshotOf(run.records),
|
|
115
122
|
[MCP_APP_META.account]: forView.account,
|
|
123
|
+
...(this.#readOnly ? { [MCP_APP_META.readOnly]: true } : {}),
|
|
116
124
|
};
|
|
117
|
-
return { content, structuredContent: { href }, _meta, isError: false };
|
|
125
|
+
return { content, structuredContent: { href, ...run.route }, _meta, isError: false };
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
static accountOf(req: Request): Record<string, unknown> | null {
|
|
@@ -128,20 +136,6 @@ export class McpApps {
|
|
|
128
136
|
}
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
#basePathOf(pattern: string) {
|
|
132
|
-
const [, , segment] = pattern.split("/");
|
|
133
|
-
return segment && this.#shell?.basePaths.includes(segment) ? segment : "";
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
#basePathOfUri(uri: string) {
|
|
137
|
-
const match = /^ui:\/\/([^/]+)\/([^?#]*)$/i.exec(uri);
|
|
138
|
-
if (!match || match[1]?.toLowerCase() !== getEnv().appName.toLowerCase()) return null;
|
|
139
|
-
const [root, basePath, ...rest] = (match[2] ?? "").split("/");
|
|
140
|
-
if (root !== McpApps.rootSegment || rest.length) return null;
|
|
141
|
-
if (!basePath) return "";
|
|
142
|
-
return this.#shell?.basePaths.includes(basePath) ? basePath : null;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
139
|
static #snapshotOf(records: PagePromptRecord[]): McpAppSnapshotRecord[] {
|
|
146
140
|
return records.flatMap(({ key, args, value }) => (value === undefined ? [] : [{ key, args, value }]));
|
|
147
141
|
}
|