alepha 0.22.0 → 0.23.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/dist/api/jobs/index.d.ts +20 -20
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/keys/index.d.ts +6 -6
- package/dist/api/users/index.d.ts +43 -9
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +24 -3
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +13 -13
- package/dist/cli/core/index.d.ts +46 -40
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +51 -101
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/i18n/index.d.ts +12 -5
- package/dist/cli/i18n/index.d.ts.map +1 -1
- package/dist/cli/i18n/index.js +45 -11
- package/dist/cli/i18n/index.js.map +1 -1
- package/dist/cli/platform-lib/index.d.ts +32 -6
- package/dist/cli/platform-lib/index.d.ts.map +1 -1
- package/dist/cli/platform-lib/index.js +82 -19
- package/dist/cli/platform-lib/index.js.map +1 -1
- package/dist/command/index.d.ts +1 -1
- package/dist/mcp/index.d.ts +9 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +23 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/react/form/index.d.ts +0 -1
- package/dist/react/form/index.d.ts.map +1 -1
- package/dist/react/form/index.js +16 -15
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/i18n/index.d.ts +43 -0
- package/dist/react/i18n/index.d.ts.map +1 -1
- package/dist/react/i18n/index.js +114 -10
- package/dist/react/i18n/index.js.map +1 -1
- package/dist/react/router/index.browser.js +128 -5
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +108 -1
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +184 -6
- package/dist/react/router/index.js.map +1 -1
- package/dist/react/sitemap/index.browser.js +35 -0
- package/dist/react/sitemap/index.browser.js.map +1 -0
- package/dist/react/sitemap/index.d.ts +92 -0
- package/dist/react/sitemap/index.d.ts.map +1 -0
- package/dist/react/sitemap/index.js +131 -0
- package/dist/react/sitemap/index.js.map +1 -0
- package/dist/server/auth/index.d.ts +105 -1
- package/dist/server/auth/index.d.ts.map +1 -1
- package/dist/server/auth/index.js +1604 -7
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/cookies/index.d.ts +15 -0
- package/dist/server/cookies/index.d.ts.map +1 -1
- package/dist/server/cookies/index.js +22 -3
- package/dist/server/cookies/index.js.map +1 -1
- package/dist/server/core/index.d.ts +18 -0
- package/dist/server/core/index.d.ts.map +1 -1
- package/dist/server/core/index.js +25 -0
- package/dist/server/core/index.js.map +1 -1
- package/package.json +16 -3
- package/src/api/users/controllers/RealmController.ts +1 -0
- package/src/api/users/primitives/$realm.ts +26 -0
- package/src/api/users/providers/RealmProvider.ts +15 -0
- package/src/api/users/schemas/realmConfigSchema.ts +14 -0
- package/src/cli/core/atoms/buildOptions.ts +0 -12
- package/src/cli/core/commands/build.ts +0 -10
- package/src/cli/core/index.ts +0 -3
- package/src/cli/core/tasks/BuildCloudflareTask.ts +37 -17
- package/src/cli/core/tasks/BuildPrerenderTask.ts +44 -7
- package/src/cli/i18n/__tests__/I18nCheckService.spec.ts +48 -0
- package/src/cli/i18n/services/I18nCheckService.ts +65 -11
- package/src/cli/platform-lib/adapters/CloudflareAdapter.ts +128 -36
- package/src/mcp/__tests__/McpServerProvider.spec.ts +71 -0
- package/src/mcp/providers/McpServerProvider.ts +55 -0
- package/src/react/form/__tests__/FormModel-submit-loading.spec.ts +71 -0
- package/src/react/form/__tests__/form-submitting-reactive.browser.spec.tsx +96 -0
- package/src/react/form/services/FormModel.ts +57 -39
- package/src/react/i18n/__tests__/I18nProvider.spec.ts +89 -0
- package/src/react/i18n/__tests__/locale-routing.spec.ts +107 -0
- package/src/react/i18n/providers/I18nProvider.ts +171 -12
- package/src/react/router/__tests__/RouterLocaleProvider.spec.ts +127 -0
- package/src/react/router/index.browser.ts +4 -0
- package/src/react/router/index.shared.ts +1 -0
- package/src/react/router/index.ts +9 -0
- package/src/react/router/providers/ReactBrowserRouterProvider.ts +15 -1
- package/src/react/router/providers/ReactPageProvider.ts +12 -1
- package/src/react/router/providers/ReactServerProvider.ts +92 -1
- package/src/react/router/providers/RootComponentsProvider.ts +13 -0
- package/src/react/router/providers/RouterLocaleProvider.ts +125 -0
- package/src/react/router/providers/__tests__/RootComponentsProvider.spec.ts +15 -0
- package/src/react/router/providers/__tests__/rootComponents.ssr.browser.spec.tsx +67 -0
- package/src/react/sitemap/__tests__/$sitemap.spec.ts +131 -0
- package/src/react/sitemap/index.browser.ts +21 -0
- package/src/react/sitemap/index.ts +25 -0
- package/src/react/sitemap/primitives/$sitemap.browser.ts +26 -0
- package/src/react/sitemap/primitives/$sitemap.ts +196 -0
- package/src/server/auth/__tests__/appleClientSecret.spec.ts +34 -0
- package/src/server/auth/__tests__/authFederationClient.spec.ts +40 -0
- package/src/server/auth/__tests__/federationAssertion.spec.ts +146 -0
- package/src/server/auth/__tests__/federationRedirectReplay.spec.ts +44 -0
- package/src/server/auth/helpers/appleClientSecret.ts +24 -0
- package/src/server/auth/helpers/federationAssertion.ts +74 -0
- package/src/server/auth/helpers/jtiReplayGuard.ts +41 -0
- package/src/server/auth/helpers/safeRedirectPath.ts +19 -0
- package/src/server/auth/index.ts +4 -0
- package/src/server/auth/primitives/$authFederationBroker.ts +273 -0
- package/src/server/auth/primitives/$authFederationClient.ts +89 -0
- package/src/server/auth/providers/ServerAuthProvider.ts +18 -4
- package/src/server/cookies/__tests__/ServerCookiesProvider.spec.ts +70 -0
- package/src/server/cookies/providers/ServerCookiesProvider.ts +23 -3
- package/src/server/core/interfaces/ServerRequest.ts +8 -0
- package/src/server/core/primitives/$route.ts +27 -0
- package/src/cli/core/tasks/BuildSitemapTask.ts +0 -130
|
@@ -1016,8 +1016,10 @@ export class CloudflareAdapter extends PlatformAdapter {
|
|
|
1016
1016
|
});
|
|
1017
1017
|
}
|
|
1018
1018
|
|
|
1019
|
-
// 5. Delete R2 bucket
|
|
1020
|
-
//
|
|
1019
|
+
// 5. Delete R2 bucket. An empty bucket is removed by the REST DELETE
|
|
1020
|
+
// directly; only a non-empty one needs an S3 wipe first. Crucially the
|
|
1021
|
+
// wipe is NOT a precondition of the delete — a wipe that can't run (no
|
|
1022
|
+
// creds) must never strand an otherwise-deletable bucket.
|
|
1021
1023
|
const needsBucket = ctx.resources.hasBucket;
|
|
1022
1024
|
if (needsBucket) {
|
|
1023
1025
|
const name = ctx.naming.r2();
|
|
@@ -1025,14 +1027,10 @@ export class CloudflareAdapter extends PlatformAdapter {
|
|
|
1025
1027
|
name: `delete r2 ${name}`,
|
|
1026
1028
|
handler: async () => {
|
|
1027
1029
|
try {
|
|
1028
|
-
await this.
|
|
1029
|
-
await this.api.deleteR2(name);
|
|
1030
|
+
await this.deleteR2Bucket(name, ctx);
|
|
1030
1031
|
} catch (error: any) {
|
|
1031
1032
|
const msg = String(error.message || "");
|
|
1032
|
-
if (
|
|
1033
|
-
msg.includes("does not exist") ||
|
|
1034
|
-
msg.includes("NoSuchBucket")
|
|
1035
|
-
) {
|
|
1033
|
+
if (this.isMissingBucketError(msg)) {
|
|
1036
1034
|
this.log.debug(`Bucket ${name} not found — skipping.`);
|
|
1037
1035
|
} else {
|
|
1038
1036
|
this.log.warn(`Failed to delete r2 ${name}: ${msg}`);
|
|
@@ -1128,15 +1126,86 @@ export class CloudflareAdapter extends PlatformAdapter {
|
|
|
1128
1126
|
await this.api.createR2(name);
|
|
1129
1127
|
}
|
|
1130
1128
|
|
|
1129
|
+
/** Whether a Cloudflare error message indicates the bucket is already gone. */
|
|
1130
|
+
protected isMissingBucketError(msg: string): boolean {
|
|
1131
|
+
return (
|
|
1132
|
+
msg.includes("does not exist") ||
|
|
1133
|
+
msg.includes("NoSuchBucket") ||
|
|
1134
|
+
msg.includes("bucket not found")
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Resolve S3 credentials for wiping an R2 bucket over the S3 protocol.
|
|
1140
|
+
*
|
|
1141
|
+
* Prefers the account's R2 S3 credentials from the environment
|
|
1142
|
+
* (`S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY`) — these are already
|
|
1143
|
+
* provisioned for the deploy (artifact registry) and are account-scoped,
|
|
1144
|
+
* so they can empty any bucket without minting anything. Returns `null`
|
|
1145
|
+
* when not configured, letting the caller fall back to token minting.
|
|
1146
|
+
*/
|
|
1147
|
+
protected resolveR2Credentials(): {
|
|
1148
|
+
accessKeyId: string;
|
|
1149
|
+
secretAccessKey: string;
|
|
1150
|
+
} | null {
|
|
1151
|
+
const accessKeyId = process.env.S3_ACCESS_KEY_ID;
|
|
1152
|
+
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
|
|
1153
|
+
if (accessKeyId && secretAccessKey) {
|
|
1154
|
+
return { accessKeyId, secretAccessKey };
|
|
1155
|
+
}
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Delete an R2 bucket, emptying it first only when necessary.
|
|
1161
|
+
*
|
|
1162
|
+
* Cloudflare's REST `DELETE /r2/buckets/:name` succeeds on an empty bucket
|
|
1163
|
+
* but rejects a non-empty one. So we attempt the delete directly (the
|
|
1164
|
+
* common teardown case — no objects, no creds needed), and only on failure
|
|
1165
|
+
* empty the bucket over the S3 protocol and retry. A missing bucket is a
|
|
1166
|
+
* no-op, so teardown is idempotent.
|
|
1167
|
+
*/
|
|
1168
|
+
protected async deleteR2Bucket(
|
|
1169
|
+
name: string,
|
|
1170
|
+
ctx: PlatformContext,
|
|
1171
|
+
): Promise<void> {
|
|
1172
|
+
try {
|
|
1173
|
+
await this.api.deleteR2(name);
|
|
1174
|
+
return;
|
|
1175
|
+
} catch (error: any) {
|
|
1176
|
+
const msg = String(error.message || "");
|
|
1177
|
+
if (this.isMissingBucketError(msg)) {
|
|
1178
|
+
return; // already gone
|
|
1179
|
+
}
|
|
1180
|
+
// Most often the bucket is non-empty — empty it then retry once.
|
|
1181
|
+
this.log.debug(
|
|
1182
|
+
`Direct delete of r2 ${name} failed (${msg}); emptying then retrying.`,
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
await this.wipeR2Bucket(name, ctx);
|
|
1187
|
+
|
|
1188
|
+
try {
|
|
1189
|
+
await this.api.deleteR2(name);
|
|
1190
|
+
} catch (error: any) {
|
|
1191
|
+
const msg = String(error.message || "");
|
|
1192
|
+
if (this.isMissingBucketError(msg)) {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
throw error;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1131
1199
|
/**
|
|
1132
1200
|
* Empty an R2 bucket via the S3-compatible API.
|
|
1133
1201
|
*
|
|
1134
|
-
* Cloudflare's REST
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1202
|
+
* Cloudflare's REST API has no object-level endpoints — objects must be
|
|
1203
|
+
* listed and deleted over the S3 protocol. We use the account's R2 S3
|
|
1204
|
+
* credentials (`S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY`) when present;
|
|
1205
|
+
* otherwise we fall back to minting a short-lived bucket-scoped token via
|
|
1206
|
+
* the CF API (requires a user-scoped `CLOUDFLARE_API_TOKEN`) and revoke it
|
|
1207
|
+
* after. When neither is available the wipe is skipped with a warning —
|
|
1208
|
+
* the caller still attempts the delete, which succeeds for empty buckets.
|
|
1140
1209
|
*
|
|
1141
1210
|
* Also aborts any pending multipart uploads — those count as bucket
|
|
1142
1211
|
* contents from R2's perspective and would otherwise block the delete.
|
|
@@ -1145,20 +1214,40 @@ export class CloudflareAdapter extends PlatformAdapter {
|
|
|
1145
1214
|
bucketName: string,
|
|
1146
1215
|
ctx: PlatformContext,
|
|
1147
1216
|
): Promise<void> {
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1217
|
+
let creds = this.resolveR2Credentials();
|
|
1218
|
+
let mintedTokenId: string | undefined;
|
|
1219
|
+
|
|
1220
|
+
if (!creds) {
|
|
1221
|
+
// No env S3 creds — try minting a bucket-scoped token. This needs a
|
|
1222
|
+
// user-scoped `CLOUDFLARE_API_TOKEN`; an account-scoped one (or the
|
|
1223
|
+
// wrangler OAuth bearer) can't mint, so we skip rather than throw and
|
|
1224
|
+
// let the caller's delete attempt proceed (fine for empty buckets).
|
|
1225
|
+
if (!process.env.CLOUDFLARE_API_TOKEN) {
|
|
1226
|
+
this.log.warn(
|
|
1227
|
+
`Skipping R2 wipe for ${bucketName}: no S3 credentials ` +
|
|
1228
|
+
`(S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY) and no ` +
|
|
1229
|
+
`CLOUDFLARE_API_TOKEN to mint a bucket-scoped token. A non-empty ` +
|
|
1230
|
+
`bucket must be emptied manually in the Cloudflare dashboard.`,
|
|
1231
|
+
);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
try {
|
|
1235
|
+
const tokenName = `alepha-teardown-${bucketName}-${Date.now()}`;
|
|
1236
|
+
const token = await this.api.createR2Token(tokenName, bucketName);
|
|
1237
|
+
mintedTokenId = token.id;
|
|
1238
|
+
creds = {
|
|
1239
|
+
accessKeyId: token.accessKeyId,
|
|
1240
|
+
secretAccessKey: token.secretAccessKey,
|
|
1241
|
+
};
|
|
1242
|
+
} catch (error: any) {
|
|
1243
|
+
this.log.warn(
|
|
1244
|
+
`Skipping R2 wipe for ${bucketName}: could not mint an R2 token ` +
|
|
1245
|
+
`(${String(error.message || "")}). Set S3_ACCESS_KEY_ID / ` +
|
|
1246
|
+
`S3_SECRET_ACCESS_KEY for reliable teardown.`,
|
|
1247
|
+
);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1159
1250
|
}
|
|
1160
|
-
const tokenName = `alepha-teardown-${bucketName}-${Date.now()}`;
|
|
1161
|
-
const token = await this.api.createR2Token(tokenName, bucketName);
|
|
1162
1251
|
|
|
1163
1252
|
try {
|
|
1164
1253
|
const accountId = await this.api.resolveAccountId();
|
|
@@ -1168,8 +1257,8 @@ export class CloudflareAdapter extends PlatformAdapter {
|
|
|
1168
1257
|
: `${accountId}.r2.cloudflarestorage.com`;
|
|
1169
1258
|
|
|
1170
1259
|
const client = new S3mini({
|
|
1171
|
-
accessKeyId:
|
|
1172
|
-
secretAccessKey:
|
|
1260
|
+
accessKeyId: creds.accessKeyId,
|
|
1261
|
+
secretAccessKey: creds.secretAccessKey,
|
|
1173
1262
|
region: "auto",
|
|
1174
1263
|
endpoint: `https://${host}/${bucketName}`,
|
|
1175
1264
|
});
|
|
@@ -1226,13 +1315,16 @@ export class CloudflareAdapter extends PlatformAdapter {
|
|
|
1226
1315
|
this.log.info(`Emptied ${total} object(s) from bucket ${bucketName}.`);
|
|
1227
1316
|
}
|
|
1228
1317
|
} finally {
|
|
1229
|
-
//
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1318
|
+
// Revoke only a token we minted here — env S3 creds are long-lived and
|
|
1319
|
+
// must not be deleted. Always revoke, even if the wipe failed mid-way.
|
|
1320
|
+
if (mintedTokenId) {
|
|
1321
|
+
try {
|
|
1322
|
+
await this.api.deleteR2Token(mintedTokenId);
|
|
1323
|
+
} catch (error: any) {
|
|
1324
|
+
this.log.warn(
|
|
1325
|
+
`Failed to revoke ephemeral R2 token ${mintedTokenId}: ${String(error.message || "")}`,
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1236
1328
|
}
|
|
1237
1329
|
}
|
|
1238
1330
|
}
|
|
@@ -230,6 +230,77 @@ describe("McpServerProvider", () => {
|
|
|
230
230
|
expect(result.content[0].text).toBe("42");
|
|
231
231
|
});
|
|
232
232
|
|
|
233
|
+
test("passes raw MCP content (e.g. image) through verbatim when the tool has no output schema", async () => {
|
|
234
|
+
const alepha = Alepha.create();
|
|
235
|
+
|
|
236
|
+
class ImageTools {
|
|
237
|
+
screenshot = $tool({
|
|
238
|
+
description: "Return a tiny image",
|
|
239
|
+
// No `result` schema — handler returns raw MCP content blocks.
|
|
240
|
+
handler: async () => ({
|
|
241
|
+
content: [
|
|
242
|
+
{
|
|
243
|
+
type: "image",
|
|
244
|
+
data: "aGVsbG8=",
|
|
245
|
+
mimeType: "image/png",
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
}),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
alepha.with(AlephaMcp).with(ImageTools);
|
|
253
|
+
await alepha.start();
|
|
254
|
+
|
|
255
|
+
const provider = alepha.inject(McpServerProvider);
|
|
256
|
+
const response = await provider.handleMessage({
|
|
257
|
+
jsonrpc: "2.0",
|
|
258
|
+
id: 1,
|
|
259
|
+
method: "tools/call",
|
|
260
|
+
params: { name: "screenshot", arguments: {} },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const result = response?.result as {
|
|
264
|
+
content: Array<{ type: string; data?: string; mimeType?: string }>;
|
|
265
|
+
structuredContent?: unknown;
|
|
266
|
+
};
|
|
267
|
+
expect(result.content).toHaveLength(1);
|
|
268
|
+
expect(result.content[0].type).toBe("image");
|
|
269
|
+
expect(result.content[0].data).toBe("aGVsbG8=");
|
|
270
|
+
expect(result.content[0].mimeType).toBe("image/png");
|
|
271
|
+
// Raw content is NOT double-wrapped into a JSON text block.
|
|
272
|
+
expect(result.structuredContent).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("a plain object result (no content array) still serializes to a JSON text block", async () => {
|
|
276
|
+
const alepha = Alepha.create();
|
|
277
|
+
|
|
278
|
+
class PlainTools {
|
|
279
|
+
info = $tool({
|
|
280
|
+
description: "Return a plain object",
|
|
281
|
+
// No `result` schema, but the object is not raw MCP content.
|
|
282
|
+
handler: async () => ({ hello: "world" }),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
alepha.with(AlephaMcp).with(PlainTools);
|
|
287
|
+
await alepha.start();
|
|
288
|
+
|
|
289
|
+
const provider = alepha.inject(McpServerProvider);
|
|
290
|
+
const response = await provider.handleMessage({
|
|
291
|
+
jsonrpc: "2.0",
|
|
292
|
+
id: 1,
|
|
293
|
+
method: "tools/call",
|
|
294
|
+
params: { name: "info", arguments: {} },
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const result = response?.result as {
|
|
298
|
+
content: Array<{ type: string; text: string }>;
|
|
299
|
+
};
|
|
300
|
+
expect(result.content[0].type).toBe("text");
|
|
301
|
+
expect(JSON.parse(result.content[0].text)).toEqual({ hello: "world" });
|
|
302
|
+
});
|
|
303
|
+
|
|
233
304
|
test("should return error for unknown tool", async () => {
|
|
234
305
|
const alepha = Alepha.create();
|
|
235
306
|
alepha.with(AlephaMcp);
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
JsonRpcRequest,
|
|
20
20
|
JsonRpcResponse,
|
|
21
21
|
McpCapabilities,
|
|
22
|
+
McpContent,
|
|
22
23
|
McpContext,
|
|
23
24
|
McpInitializeResult,
|
|
24
25
|
McpPromptDescriptor,
|
|
@@ -309,6 +310,20 @@ export class McpServerProvider {
|
|
|
309
310
|
try {
|
|
310
311
|
const result = await tool.execute(args, context);
|
|
311
312
|
|
|
313
|
+
// A tool WITHOUT an output schema may return raw MCP content blocks
|
|
314
|
+
// (e.g. an `image` block) instead of JSON — used for binary payloads
|
|
315
|
+
// like attachment previews. Recognized by the CallToolResult shape
|
|
316
|
+
// (`{ content: McpContent[] }`); passed through verbatim. Tools that
|
|
317
|
+
// declare an output schema always go through the structured path
|
|
318
|
+
// below, so a JSON result that happens to carry a `content` array is
|
|
319
|
+
// never mistaken for raw content.
|
|
320
|
+
if (!tool.hasOutputSchema()) {
|
|
321
|
+
const raw = this.asRawToolContent(result);
|
|
322
|
+
if (raw) {
|
|
323
|
+
return raw;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
312
327
|
const callResult: McpToolCallResult = {
|
|
313
328
|
content: [
|
|
314
329
|
{
|
|
@@ -364,6 +379,46 @@ export class McpServerProvider {
|
|
|
364
379
|
}
|
|
365
380
|
}
|
|
366
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Recognize a tool handler's return value as a pre-built MCP tool result —
|
|
384
|
+
* i.e. it already carries a `content` array of content blocks (text, image,
|
|
385
|
+
* audio, resource, resource_link). Returns the normalized
|
|
386
|
+
* {@link McpToolCallResult} when matched, or `undefined` to fall back to the
|
|
387
|
+
* default JSON/text encoding. Only ever consulted for tools that did NOT
|
|
388
|
+
* declare an output schema (see {@link handleToolCall}).
|
|
389
|
+
*/
|
|
390
|
+
protected asRawToolContent(result: unknown): McpToolCallResult | undefined {
|
|
391
|
+
if (!result || typeof result !== "object") {
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
const candidate = result as {
|
|
395
|
+
content?: unknown;
|
|
396
|
+
isError?: unknown;
|
|
397
|
+
_meta?: unknown;
|
|
398
|
+
};
|
|
399
|
+
if (!Array.isArray(candidate.content) || candidate.content.length === 0) {
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
const allBlocks = candidate.content.every(
|
|
403
|
+
(block): block is McpContent =>
|
|
404
|
+
!!block &&
|
|
405
|
+
typeof block === "object" &&
|
|
406
|
+
typeof (block as { type?: unknown }).type === "string",
|
|
407
|
+
);
|
|
408
|
+
if (!allBlocks) {
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
content: candidate.content as McpContent[],
|
|
413
|
+
isError:
|
|
414
|
+
typeof candidate.isError === "boolean" ? candidate.isError : undefined,
|
|
415
|
+
_meta:
|
|
416
|
+
candidate._meta && typeof candidate._meta === "object"
|
|
417
|
+
? (candidate._meta as Record<string, unknown>)
|
|
418
|
+
: undefined,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
367
422
|
protected handleResourcesList(): { resources: McpResourceDescriptor[] } {
|
|
368
423
|
return {
|
|
369
424
|
resources: Array.from(this.resources.values()).map((r) =>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Alepha, t } from "alepha";
|
|
2
|
+
import { AlephaLogger } from "alepha/logger";
|
|
3
|
+
import { describe, it } from "vitest";
|
|
4
|
+
import { FormModel } from "../services/FormModel.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Repro for: a form whose submit fails TypeBox validation (missing field)
|
|
8
|
+
* leaves the submit button stuck in its loading state forever.
|
|
9
|
+
*
|
|
10
|
+
* The button's loading is driven by the `form:submit:begin` / `form:submit:end`
|
|
11
|
+
* event pair (see useFormState). So the invariant that matters is: every
|
|
12
|
+
* `form:submit:begin` is followed by a `form:submit:end`, even when validation
|
|
13
|
+
* throws AND even when a downstream error listener (e.g. the action-error
|
|
14
|
+
* toaster) itself throws.
|
|
15
|
+
*/
|
|
16
|
+
describe("FormModel.submit loading pairing", () => {
|
|
17
|
+
const makeForm = (alepha: Alepha) =>
|
|
18
|
+
alepha.inject(FormModel as any, {
|
|
19
|
+
lifetime: "transient",
|
|
20
|
+
args: [
|
|
21
|
+
"f1",
|
|
22
|
+
{
|
|
23
|
+
id: "f1",
|
|
24
|
+
schema: t.object({ email: t.text(), password: t.text() }),
|
|
25
|
+
handler: () => {},
|
|
26
|
+
initialValues: {}, // both required fields missing → decode throws
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
}) as FormModel<any>;
|
|
30
|
+
|
|
31
|
+
it("emits form:submit:end on a validation error (intrinsic)", async ({
|
|
32
|
+
expect,
|
|
33
|
+
}) => {
|
|
34
|
+
const alepha = Alepha.create().with(AlephaLogger);
|
|
35
|
+
await alepha.start();
|
|
36
|
+
const form = makeForm(alepha);
|
|
37
|
+
let begun = false;
|
|
38
|
+
let ended = false;
|
|
39
|
+
alepha.events.on("form:submit:begin", () => {
|
|
40
|
+
begun = true;
|
|
41
|
+
});
|
|
42
|
+
alepha.events.on("form:submit:end", () => {
|
|
43
|
+
ended = true;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await form.submit().catch(() => {});
|
|
47
|
+
|
|
48
|
+
expect(begun).toBe(true);
|
|
49
|
+
expect(ended).toBe(true); // pairing must hold
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("emits form:submit:end even when a react:action:error listener throws", async ({
|
|
53
|
+
expect,
|
|
54
|
+
}) => {
|
|
55
|
+
const alepha = Alepha.create().with(AlephaLogger);
|
|
56
|
+
await alepha.start();
|
|
57
|
+
const form = makeForm(alepha);
|
|
58
|
+
let ended = false;
|
|
59
|
+
alepha.events.on("form:submit:end", () => {
|
|
60
|
+
ended = true;
|
|
61
|
+
});
|
|
62
|
+
// Simulates a globally-mounted listener (e.g. ActionErrorToaster) throwing.
|
|
63
|
+
alepha.events.on("react:action:error", () => {
|
|
64
|
+
throw new Error("toaster boom");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await form.submit().catch(() => {});
|
|
68
|
+
|
|
69
|
+
expect(ended).toBe(true); // currently FAILS: end is skipped → button stuck
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { fireEvent, render, waitFor } from "@testing-library/react";
|
|
2
|
+
import { Alepha, t } from "alepha";
|
|
3
|
+
import { AlephaLogger } from "alepha/logger";
|
|
4
|
+
import { AlephaContext } from "alepha/react";
|
|
5
|
+
import { describe, it } from "vitest";
|
|
6
|
+
import { useForm, useFormState } from "../index.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Submit buttons bind their loading state to `useFormState(form, ["loading"])`
|
|
10
|
+
* (the reactive form-state API; the non-reactive `form.submitting` getter was
|
|
11
|
+
* removed). This loading must:
|
|
12
|
+
* - turn ON while an async submit is in flight, and
|
|
13
|
+
* - turn OFF again afterwards — including after a TypeBox validation error.
|
|
14
|
+
*
|
|
15
|
+
* The OFF guarantee relies on `FormModel.submit` always emitting
|
|
16
|
+
* `form:submit:end` (see FormModel-submit-loading.spec.ts).
|
|
17
|
+
*/
|
|
18
|
+
describe("useFormState loading drives the submit button", () => {
|
|
19
|
+
const mount = (alepha: Alepha, ui: React.ReactNode) =>
|
|
20
|
+
render(
|
|
21
|
+
<AlephaContext.Provider value={alepha}>{ui}</AlephaContext.Provider>,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
it("turns loading on during an async submit and off after", async ({
|
|
25
|
+
expect,
|
|
26
|
+
}) => {
|
|
27
|
+
const alepha = Alepha.create().with(AlephaLogger);
|
|
28
|
+
let release!: () => void;
|
|
29
|
+
|
|
30
|
+
const Form = () => {
|
|
31
|
+
const form = useForm({
|
|
32
|
+
id: "async",
|
|
33
|
+
schema: t.object({ name: t.string({ minLength: 1 }) }),
|
|
34
|
+
initialValues: { name: "ok" },
|
|
35
|
+
handler: () => new Promise<void>((r) => (release = r)),
|
|
36
|
+
});
|
|
37
|
+
const { loading } = useFormState(form, ["loading"]);
|
|
38
|
+
return (
|
|
39
|
+
<form {...form.props} data-testid="form">
|
|
40
|
+
<button
|
|
41
|
+
type="submit"
|
|
42
|
+
data-testid="submit"
|
|
43
|
+
data-loading={loading ? "true" : "false"}
|
|
44
|
+
>
|
|
45
|
+
go
|
|
46
|
+
</button>
|
|
47
|
+
</form>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const { getByTestId } = mount(alepha, <Form />);
|
|
52
|
+
fireEvent.submit(getByTestId("form"));
|
|
53
|
+
|
|
54
|
+
await waitFor(() =>
|
|
55
|
+
expect(getByTestId("submit").getAttribute("data-loading")).toBe("true"),
|
|
56
|
+
);
|
|
57
|
+
release();
|
|
58
|
+
await waitFor(() =>
|
|
59
|
+
expect(getByTestId("submit").getAttribute("data-loading")).toBe("false"),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("clears loading after a TypeBox validation error", async ({ expect }) => {
|
|
64
|
+
const alepha = Alepha.create().with(AlephaLogger);
|
|
65
|
+
|
|
66
|
+
const Form = () => {
|
|
67
|
+
const form = useForm({
|
|
68
|
+
id: "login",
|
|
69
|
+
schema: t.object({
|
|
70
|
+
identifier: t.string({ minLength: 1 }),
|
|
71
|
+
password: t.string({ minLength: 6 }),
|
|
72
|
+
}),
|
|
73
|
+
handler: async () => {},
|
|
74
|
+
});
|
|
75
|
+
const { loading } = useFormState(form, ["loading"]);
|
|
76
|
+
return (
|
|
77
|
+
<form {...form.props} data-testid="form">
|
|
78
|
+
<button
|
|
79
|
+
type="submit"
|
|
80
|
+
data-testid="submit"
|
|
81
|
+
data-loading={loading ? "true" : "false"}
|
|
82
|
+
>
|
|
83
|
+
Sign in
|
|
84
|
+
</button>
|
|
85
|
+
</form>
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const { getByTestId } = mount(alepha, <Form />);
|
|
90
|
+
fireEvent.submit(getByTestId("form")); // empty → validation error
|
|
91
|
+
|
|
92
|
+
await waitFor(() =>
|
|
93
|
+
expect(getByTestId("submit").getAttribute("data-loading")).toBe("false"),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -27,10 +27,6 @@ export class FormModel<T extends TObject> {
|
|
|
27
27
|
|
|
28
28
|
public input: SchemaToInput<T>;
|
|
29
29
|
|
|
30
|
-
public get submitting(): boolean {
|
|
31
|
-
return this.submitInProgress;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
30
|
constructor(
|
|
35
31
|
public readonly id: string,
|
|
36
32
|
public readonly options: FormCtrlOptions<T>,
|
|
@@ -185,14 +181,22 @@ export class FormModel<T extends TObject> {
|
|
|
185
181
|
return;
|
|
186
182
|
}
|
|
187
183
|
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
184
|
+
// Lifecycle events are best-effort NOTIFICATIONS (loading spinners, toasts,
|
|
185
|
+
// analytics). A misbehaving listener must never break form state or reject
|
|
186
|
+
// submit() — hence `{ catch: true }` on every emit below. Without it, a
|
|
187
|
+
// throwing `react:action:error`/`form:submit:error` listener would skip the
|
|
188
|
+
// `form:submit:end` "loading off" signal and leave submit buttons stuck in
|
|
189
|
+
// their loading state forever.
|
|
190
|
+
await this.alepha.events.emit(
|
|
191
|
+
"react:action:begin",
|
|
192
|
+
{ type: "form", id: this.id },
|
|
193
|
+
{ catch: true },
|
|
194
|
+
);
|
|
195
|
+
await this.alepha.events.emit(
|
|
196
|
+
"form:submit:begin",
|
|
197
|
+
{ id: this.id },
|
|
198
|
+
{ catch: true },
|
|
199
|
+
);
|
|
196
200
|
|
|
197
201
|
this.submitInProgress = true;
|
|
198
202
|
|
|
@@ -210,39 +214,53 @@ export class FormModel<T extends TObject> {
|
|
|
210
214
|
|
|
211
215
|
await options.handler(values as any);
|
|
212
216
|
|
|
213
|
-
await this.alepha.events.emit(
|
|
214
|
-
|
|
215
|
-
id: this.id,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
217
|
+
await this.alepha.events.emit(
|
|
218
|
+
"react:action:success",
|
|
219
|
+
{ type: "form", id: this.id },
|
|
220
|
+
{ catch: true },
|
|
221
|
+
);
|
|
222
|
+
await this.alepha.events.emit(
|
|
223
|
+
"form:submit:success",
|
|
224
|
+
{ id: this.id, values },
|
|
225
|
+
{ catch: true },
|
|
226
|
+
);
|
|
221
227
|
} catch (error) {
|
|
222
228
|
this.log.error("Form submission error:", error);
|
|
223
229
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
await this.alepha.events.emit(
|
|
232
|
-
|
|
233
|
-
id: this.id,
|
|
234
|
-
|
|
230
|
+
// A throwing onError callback must not abort the lifecycle either.
|
|
231
|
+
try {
|
|
232
|
+
options.onError?.(error as Error);
|
|
233
|
+
} catch (handlerError) {
|
|
234
|
+
this.log.error("Form onError handler threw:", handlerError);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await this.alepha.events.emit(
|
|
238
|
+
"react:action:error",
|
|
239
|
+
{ type: "form", id: this.id, error: error as Error },
|
|
240
|
+
{ catch: true },
|
|
241
|
+
);
|
|
242
|
+
await this.alepha.events.emit(
|
|
243
|
+
"form:submit:error",
|
|
244
|
+
{ error: error as Error, id: this.id },
|
|
245
|
+
{ catch: true },
|
|
246
|
+
);
|
|
235
247
|
} finally {
|
|
236
248
|
this.submitInProgress = false;
|
|
237
|
-
}
|
|
238
249
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
250
|
+
// The "loading off" signals live in `finally` so they ALWAYS fire,
|
|
251
|
+
// even if something above threw — guaranteeing the begin/end pairing
|
|
252
|
+
// that drives submit-button loading state.
|
|
253
|
+
await this.alepha.events.emit(
|
|
254
|
+
"react:action:end",
|
|
255
|
+
{ type: "form", id: this.id },
|
|
256
|
+
{ catch: true },
|
|
257
|
+
);
|
|
258
|
+
await this.alepha.events.emit(
|
|
259
|
+
"form:submit:end",
|
|
260
|
+
{ id: this.id },
|
|
261
|
+
{ catch: true },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
246
264
|
};
|
|
247
265
|
|
|
248
266
|
/**
|