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.
Files changed (111) hide show
  1. package/dist/api/jobs/index.d.ts +20 -20
  2. package/dist/api/jobs/index.d.ts.map +1 -1
  3. package/dist/api/keys/index.d.ts +6 -6
  4. package/dist/api/users/index.d.ts +43 -9
  5. package/dist/api/users/index.d.ts.map +1 -1
  6. package/dist/api/users/index.js +24 -3
  7. package/dist/api/users/index.js.map +1 -1
  8. package/dist/api/verifications/index.d.ts +13 -13
  9. package/dist/cli/core/index.d.ts +46 -40
  10. package/dist/cli/core/index.d.ts.map +1 -1
  11. package/dist/cli/core/index.js +51 -101
  12. package/dist/cli/core/index.js.map +1 -1
  13. package/dist/cli/i18n/index.d.ts +12 -5
  14. package/dist/cli/i18n/index.d.ts.map +1 -1
  15. package/dist/cli/i18n/index.js +45 -11
  16. package/dist/cli/i18n/index.js.map +1 -1
  17. package/dist/cli/platform-lib/index.d.ts +32 -6
  18. package/dist/cli/platform-lib/index.d.ts.map +1 -1
  19. package/dist/cli/platform-lib/index.js +82 -19
  20. package/dist/cli/platform-lib/index.js.map +1 -1
  21. package/dist/command/index.d.ts +1 -1
  22. package/dist/mcp/index.d.ts +9 -0
  23. package/dist/mcp/index.d.ts.map +1 -1
  24. package/dist/mcp/index.js +23 -0
  25. package/dist/mcp/index.js.map +1 -1
  26. package/dist/react/form/index.d.ts +0 -1
  27. package/dist/react/form/index.d.ts.map +1 -1
  28. package/dist/react/form/index.js +16 -15
  29. package/dist/react/form/index.js.map +1 -1
  30. package/dist/react/i18n/index.d.ts +43 -0
  31. package/dist/react/i18n/index.d.ts.map +1 -1
  32. package/dist/react/i18n/index.js +114 -10
  33. package/dist/react/i18n/index.js.map +1 -1
  34. package/dist/react/router/index.browser.js +128 -5
  35. package/dist/react/router/index.browser.js.map +1 -1
  36. package/dist/react/router/index.d.ts +108 -1
  37. package/dist/react/router/index.d.ts.map +1 -1
  38. package/dist/react/router/index.js +184 -6
  39. package/dist/react/router/index.js.map +1 -1
  40. package/dist/react/sitemap/index.browser.js +35 -0
  41. package/dist/react/sitemap/index.browser.js.map +1 -0
  42. package/dist/react/sitemap/index.d.ts +92 -0
  43. package/dist/react/sitemap/index.d.ts.map +1 -0
  44. package/dist/react/sitemap/index.js +131 -0
  45. package/dist/react/sitemap/index.js.map +1 -0
  46. package/dist/server/auth/index.d.ts +105 -1
  47. package/dist/server/auth/index.d.ts.map +1 -1
  48. package/dist/server/auth/index.js +1604 -7
  49. package/dist/server/auth/index.js.map +1 -1
  50. package/dist/server/cookies/index.d.ts +15 -0
  51. package/dist/server/cookies/index.d.ts.map +1 -1
  52. package/dist/server/cookies/index.js +22 -3
  53. package/dist/server/cookies/index.js.map +1 -1
  54. package/dist/server/core/index.d.ts +18 -0
  55. package/dist/server/core/index.d.ts.map +1 -1
  56. package/dist/server/core/index.js +25 -0
  57. package/dist/server/core/index.js.map +1 -1
  58. package/package.json +16 -3
  59. package/src/api/users/controllers/RealmController.ts +1 -0
  60. package/src/api/users/primitives/$realm.ts +26 -0
  61. package/src/api/users/providers/RealmProvider.ts +15 -0
  62. package/src/api/users/schemas/realmConfigSchema.ts +14 -0
  63. package/src/cli/core/atoms/buildOptions.ts +0 -12
  64. package/src/cli/core/commands/build.ts +0 -10
  65. package/src/cli/core/index.ts +0 -3
  66. package/src/cli/core/tasks/BuildCloudflareTask.ts +37 -17
  67. package/src/cli/core/tasks/BuildPrerenderTask.ts +44 -7
  68. package/src/cli/i18n/__tests__/I18nCheckService.spec.ts +48 -0
  69. package/src/cli/i18n/services/I18nCheckService.ts +65 -11
  70. package/src/cli/platform-lib/adapters/CloudflareAdapter.ts +128 -36
  71. package/src/mcp/__tests__/McpServerProvider.spec.ts +71 -0
  72. package/src/mcp/providers/McpServerProvider.ts +55 -0
  73. package/src/react/form/__tests__/FormModel-submit-loading.spec.ts +71 -0
  74. package/src/react/form/__tests__/form-submitting-reactive.browser.spec.tsx +96 -0
  75. package/src/react/form/services/FormModel.ts +57 -39
  76. package/src/react/i18n/__tests__/I18nProvider.spec.ts +89 -0
  77. package/src/react/i18n/__tests__/locale-routing.spec.ts +107 -0
  78. package/src/react/i18n/providers/I18nProvider.ts +171 -12
  79. package/src/react/router/__tests__/RouterLocaleProvider.spec.ts +127 -0
  80. package/src/react/router/index.browser.ts +4 -0
  81. package/src/react/router/index.shared.ts +1 -0
  82. package/src/react/router/index.ts +9 -0
  83. package/src/react/router/providers/ReactBrowserRouterProvider.ts +15 -1
  84. package/src/react/router/providers/ReactPageProvider.ts +12 -1
  85. package/src/react/router/providers/ReactServerProvider.ts +92 -1
  86. package/src/react/router/providers/RootComponentsProvider.ts +13 -0
  87. package/src/react/router/providers/RouterLocaleProvider.ts +125 -0
  88. package/src/react/router/providers/__tests__/RootComponentsProvider.spec.ts +15 -0
  89. package/src/react/router/providers/__tests__/rootComponents.ssr.browser.spec.tsx +67 -0
  90. package/src/react/sitemap/__tests__/$sitemap.spec.ts +131 -0
  91. package/src/react/sitemap/index.browser.ts +21 -0
  92. package/src/react/sitemap/index.ts +25 -0
  93. package/src/react/sitemap/primitives/$sitemap.browser.ts +26 -0
  94. package/src/react/sitemap/primitives/$sitemap.ts +196 -0
  95. package/src/server/auth/__tests__/appleClientSecret.spec.ts +34 -0
  96. package/src/server/auth/__tests__/authFederationClient.spec.ts +40 -0
  97. package/src/server/auth/__tests__/federationAssertion.spec.ts +146 -0
  98. package/src/server/auth/__tests__/federationRedirectReplay.spec.ts +44 -0
  99. package/src/server/auth/helpers/appleClientSecret.ts +24 -0
  100. package/src/server/auth/helpers/federationAssertion.ts +74 -0
  101. package/src/server/auth/helpers/jtiReplayGuard.ts +41 -0
  102. package/src/server/auth/helpers/safeRedirectPath.ts +19 -0
  103. package/src/server/auth/index.ts +4 -0
  104. package/src/server/auth/primitives/$authFederationBroker.ts +273 -0
  105. package/src/server/auth/primitives/$authFederationClient.ts +89 -0
  106. package/src/server/auth/providers/ServerAuthProvider.ts +18 -4
  107. package/src/server/cookies/__tests__/ServerCookiesProvider.spec.ts +70 -0
  108. package/src/server/cookies/providers/ServerCookiesProvider.ts +23 -3
  109. package/src/server/core/interfaces/ServerRequest.ts +8 -0
  110. package/src/server/core/primitives/$route.ts +27 -0
  111. 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 (must be emptied first Cloudflare's REST DELETE
1020
- // rejects non-empty buckets with `BucketNotEmpty`)
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.wipeR2Bucket(name, ctx);
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 `DELETE /r2/buckets/:name` rejects non-empty buckets
1135
- * with `BucketNotEmpty`, and the REST API has no object-level endpoints
1136
- * objects must be listed and deleted over the S3 protocol. To avoid
1137
- * making users pre-create R2 access keys, we mint a short-lived
1138
- * bucket-scoped API token using the wrangler bearer token, wipe the
1139
- * bucket with `s3mini`, then revoke the token.
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
- // `createR2Token` calls `POST /accounts/:id/tokens`, which requires
1149
- // `User API Tokens → Edit` — only granted on user-level tokens.
1150
- // A standard account-scoped `CLOUDFLARE_API_TOKEN` 401s on that path,
1151
- // and the wrangler OAuth bearer doesn't carry the scope either. Without
1152
- // either, we can't mint the bucket-scoped S3 creds the wipe needs, so
1153
- // skip and leave the bucket for manual deletion in the dashboard.
1154
- if (!process.env.CLOUDFLARE_API_TOKEN) {
1155
- this.log.warn(
1156
- `Skipping R2 wipe for ${bucketName}: CLOUDFLARE_API_TOKEN not set. Delete the bucket manually in the Cloudflare dashboard.`,
1157
- );
1158
- return;
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: token.accessKeyId,
1172
- secretAccessKey: token.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
- // Always revoke, even if the wipe itself failed mid-way.
1230
- try {
1231
- await this.api.deleteR2Token(token.id);
1232
- } catch (error: any) {
1233
- this.log.warn(
1234
- `Failed to revoke ephemeral R2 token ${token.id}: ${String(error.message || "")}`,
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
- // emit both action and form events
189
- await this.alepha.events.emit("react:action:begin", {
190
- type: "form",
191
- id: this.id,
192
- });
193
- await this.alepha.events.emit("form:submit:begin", {
194
- id: this.id,
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("react:action:success", {
214
- type: "form",
215
- id: this.id,
216
- });
217
- await this.alepha.events.emit("form:submit:success", {
218
- id: this.id,
219
- values,
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
- options.onError?.(error as Error);
225
-
226
- await this.alepha.events.emit("react:action:error", {
227
- type: "form",
228
- id: this.id,
229
- error: error as Error,
230
- });
231
- await this.alepha.events.emit("form:submit:error", {
232
- error: error as Error,
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
- await this.alepha.events.emit("react:action:end", {
240
- type: "form",
241
- id: this.id,
242
- });
243
- await this.alepha.events.emit("form:submit:end", {
244
- id: this.id,
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
  /**