alepha 0.20.3 → 0.20.5

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 (218) hide show
  1. package/dist/api/audits/index.d.ts.map +1 -1
  2. package/dist/api/files/index.d.ts.map +1 -1
  3. package/dist/api/jobs/index.d.ts +14 -14
  4. package/dist/api/jobs/index.d.ts.map +1 -1
  5. package/dist/api/organizations/index.d.ts.map +1 -1
  6. package/dist/api/parameters/index.d.ts +6 -1
  7. package/dist/api/parameters/index.d.ts.map +1 -1
  8. package/dist/api/parameters/index.js +20 -4
  9. package/dist/api/parameters/index.js.map +1 -1
  10. package/dist/api/payments/index.d.ts.map +1 -1
  11. package/dist/api/users/index.browser.js +6 -0
  12. package/dist/api/users/index.browser.js.map +1 -1
  13. package/dist/api/users/index.d.ts +5032 -134
  14. package/dist/api/users/index.d.ts.map +1 -1
  15. package/dist/api/users/index.js +58 -10
  16. package/dist/api/users/index.js.map +1 -1
  17. package/dist/bin/index.js +0 -0
  18. package/dist/bucket/index.d.ts +77 -107
  19. package/dist/bucket/index.d.ts.map +1 -1
  20. package/dist/bucket/index.js +148 -4
  21. package/dist/bucket/index.js.map +1 -1
  22. package/dist/bucket/index.workerd.js +7 -1
  23. package/dist/bucket/index.workerd.js.map +1 -1
  24. package/dist/cache/core/index.d.ts +26 -0
  25. package/dist/cache/core/index.d.ts.map +1 -1
  26. package/dist/cache/core/index.js +11 -1
  27. package/dist/cache/core/index.js.map +1 -1
  28. package/dist/cache/core/index.workerd.js +11 -1
  29. package/dist/cache/core/index.workerd.js.map +1 -1
  30. package/dist/cli/config/index.d.ts +7 -5
  31. package/dist/cli/config/index.d.ts.map +1 -1
  32. package/dist/cli/config/index.js +2 -3
  33. package/dist/cli/config/index.js.map +1 -1
  34. package/dist/cli/core/index.d.ts +419 -12
  35. package/dist/cli/core/index.d.ts.map +1 -1
  36. package/dist/cli/core/index.js +22 -511
  37. package/dist/cli/core/index.js.map +1 -1
  38. package/dist/cli/devtools/index.d.ts +4 -8
  39. package/dist/cli/devtools/index.d.ts.map +1 -1
  40. package/dist/cli/devtools/index.js +13 -15
  41. package/dist/cli/devtools/index.js.map +1 -1
  42. package/dist/cli/platform/index.d.ts +10 -13
  43. package/dist/cli/platform/index.d.ts.map +1 -1
  44. package/dist/cli/platform/index.js +18 -15
  45. package/dist/cli/platform/index.js.map +1 -1
  46. package/dist/cli/vendor/index.d.ts +10 -13
  47. package/dist/cli/vendor/index.d.ts.map +1 -1
  48. package/dist/cli/vendor/index.js +16 -13
  49. package/dist/cli/vendor/index.js.map +1 -1
  50. package/dist/command/index.d.ts +1 -1
  51. package/dist/core/index.browser.js +27 -3
  52. package/dist/core/index.browser.js.map +1 -1
  53. package/dist/core/index.d.ts +6 -3
  54. package/dist/core/index.d.ts.map +1 -1
  55. package/dist/core/index.js +27 -3
  56. package/dist/core/index.js.map +1 -1
  57. package/dist/core/index.native.js +27 -3
  58. package/dist/core/index.native.js.map +1 -1
  59. package/dist/core/index.workerd.js +27 -3
  60. package/dist/core/index.workerd.js.map +1 -1
  61. package/dist/datetime/index.d.ts +69 -10
  62. package/dist/datetime/index.d.ts.map +1 -1
  63. package/dist/datetime/index.js +135 -13
  64. package/dist/datetime/index.js.map +1 -1
  65. package/dist/email/smtp/index.js +10636 -2
  66. package/dist/email/smtp/index.js.map +1 -1
  67. package/dist/fake/index.d.ts +8085 -4
  68. package/dist/fake/index.d.ts.map +1 -1
  69. package/dist/fake/index.js +33554 -3
  70. package/dist/fake/index.js.map +1 -1
  71. package/dist/lock/core/index.d.ts +30 -2
  72. package/dist/lock/core/index.d.ts.map +1 -1
  73. package/dist/lock/core/index.js +35 -12
  74. package/dist/lock/core/index.js.map +1 -1
  75. package/dist/mcp/index.d.ts +238 -31
  76. package/dist/mcp/index.d.ts.map +1 -1
  77. package/dist/mcp/index.js +198 -71
  78. package/dist/mcp/index.js.map +1 -1
  79. package/dist/orm/core/index.browser.js +1 -1
  80. package/dist/orm/core/index.browser.js.map +1 -1
  81. package/dist/orm/core/index.bun.js +4 -3
  82. package/dist/orm/core/index.bun.js.map +1 -1
  83. package/dist/orm/core/index.d.ts +4877 -9
  84. package/dist/orm/core/index.d.ts.map +1 -1
  85. package/dist/orm/core/index.js +4 -3
  86. package/dist/orm/core/index.js.map +1 -1
  87. package/dist/orm/postgres/index.d.ts +608 -1
  88. package/dist/orm/postgres/index.d.ts.map +1 -1
  89. package/dist/react/core/index.d.ts +102 -1
  90. package/dist/react/core/index.d.ts.map +1 -1
  91. package/dist/react/core/index.js +65 -1
  92. package/dist/react/core/index.js.map +1 -1
  93. package/dist/react/form/index.d.ts +6 -0
  94. package/dist/react/form/index.d.ts.map +1 -1
  95. package/dist/react/form/index.js +7 -7
  96. package/dist/react/form/index.js.map +1 -1
  97. package/dist/react/i18n/index.d.ts +7 -1
  98. package/dist/react/i18n/index.d.ts.map +1 -1
  99. package/dist/react/i18n/index.js +6 -0
  100. package/dist/react/i18n/index.js.map +1 -1
  101. package/dist/react/router/index.browser.js +20 -2
  102. package/dist/react/router/index.browser.js.map +1 -1
  103. package/dist/react/router/index.d.ts +36 -4
  104. package/dist/react/router/index.d.ts.map +1 -1
  105. package/dist/react/router/index.js +20 -2
  106. package/dist/react/router/index.js.map +1 -1
  107. package/dist/react/testing/chunk-6Ep1yQYe.js +16 -0
  108. package/dist/react/testing/index.d.ts +411 -1
  109. package/dist/react/testing/index.d.ts.map +1 -1
  110. package/dist/react/testing/index.js +12293 -13
  111. package/dist/react/testing/index.js.map +1 -1
  112. package/dist/react/ui/index.d.ts +195 -1
  113. package/dist/react/ui/index.d.ts.map +1 -1
  114. package/dist/react/ui/index.js +61 -1
  115. package/dist/react/ui/index.js.map +1 -1
  116. package/dist/scheduler/index.d.ts +84 -3
  117. package/dist/scheduler/index.d.ts.map +1 -1
  118. package/dist/scheduler/index.js +390 -1
  119. package/dist/scheduler/index.js.map +1 -1
  120. package/dist/scheduler/index.workerd.js +390 -1
  121. package/dist/scheduler/index.workerd.js.map +1 -1
  122. package/dist/security/index.d.ts +325 -2
  123. package/dist/security/index.d.ts.map +1 -1
  124. package/dist/security/index.js +1361 -2
  125. package/dist/security/index.js.map +1 -1
  126. package/dist/server/auth/index.d.ts +1054 -1
  127. package/dist/server/auth/index.d.ts.map +1 -1
  128. package/dist/server/auth/index.js +1223 -1
  129. package/dist/server/auth/index.js.map +1 -1
  130. package/dist/server/core/index.browser.js +10 -3
  131. package/dist/server/core/index.browser.js.map +1 -1
  132. package/dist/server/core/index.d.ts.map +1 -1
  133. package/dist/server/core/index.js +28 -5
  134. package/dist/server/core/index.js.map +1 -1
  135. package/dist/server/metrics/index.d.ts +514 -1
  136. package/dist/server/metrics/index.d.ts.map +1 -1
  137. package/dist/server/metrics/index.js +4374 -4
  138. package/dist/server/metrics/index.js.map +1 -1
  139. package/dist/server/swagger/index.d.ts.map +1 -1
  140. package/dist/server/swagger/index.js +3 -4
  141. package/dist/server/swagger/index.js.map +1 -1
  142. package/dist/websocket/index.browser.js +11 -5
  143. package/dist/websocket/index.browser.js.map +1 -1
  144. package/dist/websocket/index.d.ts +3 -1
  145. package/dist/websocket/index.d.ts.map +1 -1
  146. package/dist/websocket/index.js +21 -6
  147. package/dist/websocket/index.js.map +1 -1
  148. package/package.json +416 -8
  149. package/src/api/parameters/services/ParameterProvider.ts +21 -4
  150. package/src/api/users/__tests__/SessionService.spec.ts +99 -0
  151. package/src/api/users/__tests__/UserJobs.spec.ts +67 -0
  152. package/src/api/users/atoms/realmAuthSettingsAtom.ts +15 -0
  153. package/src/api/users/entities/sessions.ts +6 -0
  154. package/src/api/users/jobs/UserJobs.ts +44 -17
  155. package/src/api/users/providers/RealmProvider.ts +4 -0
  156. package/src/api/users/services/SessionService.ts +27 -0
  157. package/src/bucket/__tests__/NodeS3BucketProvider.spec.ts +74 -0
  158. package/src/bucket/index.ts +19 -2
  159. package/src/bucket/primitives/$bucket.ts +9 -1
  160. package/src/bucket/providers/CloudflareR2Provider.ts +2 -137
  161. package/src/bucket/providers/NodeS3BucketProvider.ts +218 -0
  162. package/src/cache/core/index.ts +29 -0
  163. package/src/cache/core/primitives/$cache.ts +14 -1
  164. package/src/cli/config/defineConfig.ts +13 -15
  165. package/src/cli/core/__tests__/init.spec.ts +6 -7
  166. package/src/cli/core/services/ProjectScaffolder.ts +18 -14
  167. package/src/cli/core/tasks/BuildCloudflareTask.ts +5 -0
  168. package/src/cli/core/templates/agentMd.ts +2 -10
  169. package/src/cli/core/templates/saasAdminLayoutTsx.ts +3 -3
  170. package/src/cli/devtools/index.ts +12 -26
  171. package/src/cli/platform/index.ts +15 -24
  172. package/src/cli/vendor/atoms/vendorOptions.ts +1 -1
  173. package/src/cli/vendor/index.ts +14 -23
  174. package/src/core/Alepha.ts +11 -1
  175. package/src/core/helpers/ref.ts +18 -0
  176. package/src/core/index.shared.ts +1 -0
  177. package/src/core/providers/SchemaValidator.ts +9 -1
  178. package/src/core/providers/TypeProvider.ts +1 -2
  179. package/src/datetime/REFACTORING.md +118 -0
  180. package/src/datetime/providers/DateTimeProvider.ts +203 -24
  181. package/src/lock/core/index.ts +31 -0
  182. package/src/lock/core/primitives/$lock.ts +14 -1
  183. package/src/mcp/__tests__/jsonrpc.spec.ts +1 -1
  184. package/src/mcp/helpers/jsonrpc.ts +26 -1
  185. package/src/mcp/index.ts +10 -5
  186. package/src/mcp/interfaces/McpTypes.ts +83 -6
  187. package/src/mcp/primitives/$prompt.ts +18 -1
  188. package/src/mcp/primitives/$resource.ts +18 -1
  189. package/src/mcp/primitives/$tool.ts +83 -7
  190. package/src/mcp/providers/McpServerProvider.ts +74 -16
  191. package/src/mcp/transports/StreamableHttpMcpTransport.ts +226 -0
  192. package/src/orm/REFACTORING.md +330 -0
  193. package/src/orm/core/primitives/$transactional.ts +11 -0
  194. package/src/orm/core/schemas/updateSchema.ts +1 -1
  195. package/src/orm/core/services/PgRelationManager.ts +4 -2
  196. package/src/react/core/__tests__/useQuery.browser.spec.tsx +86 -0
  197. package/src/react/core/hooks/useQuery.ts +153 -0
  198. package/src/react/core/index.ts +1 -0
  199. package/src/react/form/services/FormModel.ts +15 -6
  200. package/src/react/form/services/parseField.ts +8 -0
  201. package/src/react/i18n/providers/I18nProvider.ts +8 -2
  202. package/src/react/router/__tests__/$page.spec.tsx +0 -16
  203. package/src/react/router/__tests__/ssr.spec.tsx +339 -0
  204. package/src/react/router/primitives/$page.ts +28 -4
  205. package/src/react/router/providers/ReactPageProvider.ts +27 -9
  206. package/src/react/ui/atoms/uiThemeListAtom.ts +36 -0
  207. package/src/react/ui/index.ts +6 -0
  208. package/src/react/ui/services/SchemaControl.ts +209 -0
  209. package/src/security/primitives/$issuer.ts +6 -3
  210. package/src/server/core/__tests__/ServerRouterProvider-serializationError.spec.ts +75 -0
  211. package/src/server/core/__tests__/ServerRouterProvider-validationError.spec.ts +306 -0
  212. package/src/server/core/errors/ValidationError.ts +13 -1
  213. package/src/server/core/primitives/$action.ts +16 -5
  214. package/src/server/core/providers/ServerRouterProvider.ts +26 -4
  215. package/src/server/swagger/providers/ServerSwaggerProvider.ts +5 -7
  216. package/src/websocket/providers/NodeWebSocketServerProvider.ts +10 -4
  217. package/src/websocket/services/WebSocketClient.ts +11 -5
  218. package/src/mcp/transports/SseMcpTransport.ts +0 -182
@@ -0,0 +1,153 @@
1
+ import type { Async } from "alepha";
2
+ import type { DurationLike } from "alepha/datetime";
3
+ import { type DependencyList, useCallback, useState } from "react";
4
+ import type { ActionContext } from "./useAction.ts";
5
+ import { useAction } from "./useAction.ts";
6
+
7
+ /**
8
+ * Hook for declarative data fetching with automatic execution and refetch.
9
+ *
10
+ * Thin wrapper over {@link useAction}: it pre-applies `runOnInit: true`,
11
+ * exposes the last result as `data`, and provides a stable `refetch()` to
12
+ * re-run the query on demand. For optimistic mutations and side-effects,
13
+ * use {@link useAction} directly — `useQuery` is for the read path.
14
+ *
15
+ * Caching, request deduplication, and AbortSignal cancellation come from
16
+ * `useAction` + `HttpClient`. There is no separate cache layer — pass
17
+ * `localCache` to your `HttpClient.fetch()`/`fetchAction()` call inside
18
+ * the query handler if you want per-call caching.
19
+ *
20
+ * @example Basic
21
+ * ```tsx
22
+ * const client = useInject(HttpClient);
23
+ * const { data, loading, error, refetch } = useQuery({
24
+ * handler: async ({ signal }) => {
25
+ * const res = await client.fetch("/api/users", { request: { signal } });
26
+ * return res.data;
27
+ * },
28
+ * }, []);
29
+ * ```
30
+ *
31
+ * @example Re-fetch when a dep changes
32
+ * ```tsx
33
+ * const { data } = useQuery({
34
+ * handler: async () => api.getUser(userId),
35
+ * }, [userId]);
36
+ * ```
37
+ *
38
+ * @example Polling
39
+ * ```tsx
40
+ * const { data } = useQuery({
41
+ * handler: async () => api.getStatus(),
42
+ * runEvery: [5, "seconds"],
43
+ * }, []);
44
+ * ```
45
+ */
46
+ export function useQuery<Result>(
47
+ options: UseQueryOptions<Result>,
48
+ deps: DependencyList,
49
+ ): UseQueryReturn<Result> {
50
+ const [data, setData] = useState<Result | undefined>(options.initialData);
51
+
52
+ const action = useAction<[], Result>(
53
+ {
54
+ id: options.id,
55
+ handler: options.handler,
56
+ runOnInit: options.enabled !== false,
57
+ runEvery: options.runEvery,
58
+ debounce: options.debounce,
59
+ onError: options.onError,
60
+ onSuccess: async (result) => {
61
+ setData(result);
62
+ if (options.onSuccess) {
63
+ await options.onSuccess(result);
64
+ }
65
+ },
66
+ },
67
+ deps,
68
+ );
69
+
70
+ const refetch = useCallback(() => action.run(), [action.run]);
71
+
72
+ return {
73
+ data,
74
+ loading: action.loading,
75
+ error: action.error,
76
+ refetch,
77
+ cancel: action.cancel,
78
+ };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------------------------------------------------
82
+
83
+ export interface UseQueryOptions<Result> {
84
+ /**
85
+ * Async query handler. Receives an {@link ActionContext} with an
86
+ * AbortSignal that fires on unmount, dependency change, or `cancel()`.
87
+ */
88
+ handler: (context: ActionContext) => Async<Result>;
89
+
90
+ /**
91
+ * Optional identifier (used in lifecycle events for debugging/analytics).
92
+ */
93
+ id?: string;
94
+
95
+ /**
96
+ * If `false`, skip automatic execution on mount and dep change. Use
97
+ * `refetch()` to trigger manually. Defaults to `true`.
98
+ */
99
+ enabled?: boolean;
100
+
101
+ /**
102
+ * Initial value for `data` before the first successful fetch.
103
+ */
104
+ initialData?: Result;
105
+
106
+ /**
107
+ * Re-run periodically. See {@link useAction} `runEvery`.
108
+ */
109
+ runEvery?: DurationLike;
110
+
111
+ /**
112
+ * Debounce delay in milliseconds. See {@link useAction} `debounce`.
113
+ */
114
+ debounce?: number;
115
+
116
+ /**
117
+ * Called on success with the resolved value.
118
+ */
119
+ onSuccess?: (result: Result) => void | Promise<void>;
120
+
121
+ /**
122
+ * Custom error handler. If provided, prevents default error re-throw.
123
+ */
124
+ onError?: (error: Error) => void | Promise<void>;
125
+ }
126
+
127
+ export interface UseQueryReturn<Result> {
128
+ /**
129
+ * The last successful result. `undefined` until the first fetch resolves
130
+ * (or the value of `initialData` if provided).
131
+ */
132
+ data: Result | undefined;
133
+
134
+ /**
135
+ * Loading state — `true` while a fetch is in flight.
136
+ */
137
+ loading: boolean;
138
+
139
+ /**
140
+ * Error from the last failed fetch, if any.
141
+ */
142
+ error?: Error;
143
+
144
+ /**
145
+ * Re-run the query. The previous in-flight request, if any, is aborted.
146
+ */
147
+ refetch: () => Promise<Result | undefined>;
148
+
149
+ /**
150
+ * Abort the in-flight request without scheduling another.
151
+ */
152
+ cancel: () => void;
153
+ }
@@ -13,6 +13,7 @@ export * from "./hooks/useAlepha.ts";
13
13
  export * from "./hooks/useClient.ts";
14
14
  export * from "./hooks/useEvents.ts";
15
15
  export * from "./hooks/useInject.ts";
16
+ export * from "./hooks/useQuery.ts";
16
17
  export * from "./hooks/useStore.ts";
17
18
 
18
19
  // ---------------------------------------------------------------------------------------------------------------------
@@ -140,15 +140,23 @@ export class FormModel<T extends TObject> {
140
140
 
141
141
  public readonly reset = (event?: FormEventLike) => {
142
142
  event?.preventDefault?.();
143
+ // Snapshot all keys that need notification — both keys present
144
+ // before reset (so subscribers learn the cleared value) and keys
145
+ // restored from initialValues. Without the union, fields that were
146
+ // typed but absent from initialValues stay visually stale.
147
+ const keys = new Set<string>([
148
+ ...Object.keys(this.values),
149
+ ...Object.keys(this.initialValues),
150
+ ]);
143
151
  for (const key in this.values) {
144
152
  delete this.values[key];
145
153
  }
146
154
  Object.assign(this.values, { ...this.initialValues });
147
- for (const [key, value] of Object.entries(this.values)) {
155
+ for (const key of keys) {
148
156
  const path = `/${key.replaceAll(".", "/")}`;
149
157
  this.alepha.events.emit(
150
158
  "form:change",
151
- { id: this.id, path, value },
159
+ { id: this.id, path, value: this.values[key] },
152
160
  { catch: true },
153
161
  );
154
162
  }
@@ -356,10 +364,11 @@ export class FormModel<T extends TObject> {
356
364
  name: key,
357
365
  };
358
366
 
359
- if (options.id) {
360
- attr.id = `${options.id}-${key}`;
361
- (attr as any)["data-testid"] = attr.id;
362
- }
367
+ // Use the form's runtime id (always set — comes from `useId()` when
368
+ // no explicit `options.id` was provided). This guarantees stable
369
+ // per-field DOM ids without forcing callers to pass `id`.
370
+ attr.id = `${this.id}-${key}`;
371
+ (attr as any)["data-testid"] = attr.id;
363
372
 
364
373
  if (t.schema.isString(field)) {
365
374
  if (field.maxLength != null) {
@@ -45,6 +45,12 @@ export interface FieldMeta {
45
45
  constraints: FieldConstraints;
46
46
  testId?: string;
47
47
  schema: TSchema;
48
+ /**
49
+ * Raw `$control` value from the schema, untyped here. The UI layer
50
+ * (`alepha/react/ui`) provides the strict {@link SchemaControl} type and
51
+ * a `resolveSchemaControl` helper to evaluate the function form.
52
+ */
53
+ control?: unknown;
48
54
  }
49
55
 
50
56
  export interface ParseFieldOptions {
@@ -77,6 +83,7 @@ export const parseField = (
77
83
  pattern?: string;
78
84
  properties?: unknown;
79
85
  items?: { properties?: unknown };
86
+ $control?: unknown;
80
87
  };
81
88
 
82
89
  const label =
@@ -132,6 +139,7 @@ export const parseField = (
132
139
  | string
133
140
  | undefined,
134
141
  schema: input.schema,
142
+ control: schema.$control,
135
143
  };
136
144
  };
137
145
 
@@ -230,15 +230,21 @@ export class I18nProvider<
230
230
  return value;
231
231
  };
232
232
 
233
+ /**
234
+ * Look up `key` in the registered dictionaries. The `(string & {})` arm
235
+ * keeps autocomplete for the typed dictionary keys while allowing shared
236
+ * library components to pass arbitrary string keys (with a `default`
237
+ * fallback) without casting to `as never`.
238
+ */
233
239
  public readonly tr = (
234
- key: keyof ServiceDictionary<S>[K],
240
+ key: keyof ServiceDictionary<S>[K] | (string & {}),
235
241
  options: {
236
242
  args?: string[];
237
243
  default?: string;
238
244
  } = {},
239
245
  ) => {
240
246
  const translation = this.translate(key as string, options.args || []);
241
- if (translation === key && options.default) {
247
+ if (translation === (key as string) && options.default) {
242
248
  return options.default;
243
249
  }
244
250
  return translation;
@@ -419,22 +419,6 @@ describe("$page primitive tests", () => {
419
419
  expect(await app.staticPage.fetch().then((it) => it.html)).toBe(html);
420
420
  });
421
421
 
422
- test("$page - client-side only rendering", async ({ expect }) => {
423
- class App {
424
- clientOnly = $page({
425
- path: "/client",
426
- client: true,
427
- component: () => "Client only",
428
- });
429
- }
430
-
431
- const app = alepha.inject(App);
432
- await alepha.start();
433
-
434
- const clientRendered = await app.clientOnly.fetch();
435
- expect(clientRendered.html).toBe("");
436
- });
437
-
438
422
  test("$page - server response handler", async ({ expect }) => {
439
423
  const mockHandler = vi.fn();
440
424
 
@@ -0,0 +1,339 @@
1
+ import { Alepha } from "alepha";
2
+ import { beforeEach, describe, test } from "vitest";
3
+ import { $page, NestedView } from "../index.ts";
4
+
5
+ describe("$page ssr option", () => {
6
+ let alepha: Alepha;
7
+
8
+ beforeEach(() => {
9
+ alepha = Alepha.create();
10
+ });
11
+
12
+ test("default (no ssr option) renders server-side", async ({ expect }) => {
13
+ class App {
14
+ home = $page({
15
+ path: "/",
16
+ component: () => "Home content",
17
+ });
18
+ }
19
+
20
+ const app = alepha.inject(App);
21
+ await alepha.start();
22
+
23
+ const rendered = await app.home.render();
24
+ expect(rendered.html).toBe("Home content");
25
+ });
26
+
27
+ test("ssr: true renders server-side", async ({ expect }) => {
28
+ class App {
29
+ home = $page({
30
+ path: "/",
31
+ ssr: true,
32
+ component: () => "Home content",
33
+ });
34
+ }
35
+
36
+ const app = alepha.inject(App);
37
+ await alepha.start();
38
+
39
+ const rendered = await app.home.render();
40
+ expect(rendered.html).toBe("Home content");
41
+ });
42
+
43
+ test("ssr: false skips server rendering on a leaf page", async ({
44
+ expect,
45
+ }) => {
46
+ class App {
47
+ home = $page({
48
+ path: "/",
49
+ ssr: false,
50
+ component: () => "Home content",
51
+ });
52
+ }
53
+
54
+ const app = alepha.inject(App);
55
+ await alepha.start();
56
+
57
+ const rendered = await app.home.render();
58
+ expect(rendered.html).toBe("");
59
+ });
60
+
61
+ test("ssr: false still runs the loader server-side", async ({ expect }) => {
62
+ let loaderCalls = 0;
63
+
64
+ class App {
65
+ home = $page({
66
+ path: "/",
67
+ ssr: false,
68
+ loader: () => {
69
+ loaderCalls += 1;
70
+ return { msg: "loaded" };
71
+ },
72
+ component: ({ msg }: { msg: string }) => msg,
73
+ });
74
+ }
75
+
76
+ const app = alepha.inject(App);
77
+ await alepha.start();
78
+
79
+ const rendered = await app.home.render();
80
+ expect(rendered.html).toBe("");
81
+ expect(loaderCalls).toBe(1);
82
+ });
83
+
84
+ test("parent ssr: false cascades to child without override", async ({
85
+ expect,
86
+ }) => {
87
+ class App {
88
+ parent = $page({
89
+ path: "/parent",
90
+ ssr: false,
91
+ component: () => (
92
+ <>
93
+ Parent
94
+ <NestedView />
95
+ </>
96
+ ),
97
+ });
98
+
99
+ child = $page({
100
+ path: "/child",
101
+ parent: this.parent,
102
+ component: () => "Child",
103
+ });
104
+ }
105
+
106
+ const app = alepha.inject(App);
107
+ await alepha.start();
108
+
109
+ const rendered = await app.child.render();
110
+ expect(rendered.html).toBe("");
111
+ });
112
+
113
+ test("child ssr: true overrides parent ssr: false (both render)", async ({
114
+ expect,
115
+ }) => {
116
+ class App {
117
+ parent = $page({
118
+ path: "/parent",
119
+ ssr: false,
120
+ component: () => (
121
+ <>
122
+ Parent
123
+ <NestedView />
124
+ </>
125
+ ),
126
+ });
127
+
128
+ child = $page({
129
+ path: "/child",
130
+ parent: this.parent,
131
+ ssr: true,
132
+ component: () => "Child",
133
+ });
134
+ }
135
+
136
+ const app = alepha.inject(App);
137
+ await alepha.start();
138
+
139
+ const rendered = await app.child.render();
140
+ expect(rendered.html).toBe("Parent<!-- -->Child");
141
+ });
142
+
143
+ test("child ssr: false overrides parent ssr: true (skip everything)", async ({
144
+ expect,
145
+ }) => {
146
+ class App {
147
+ parent = $page({
148
+ path: "/parent",
149
+ ssr: true,
150
+ component: () => (
151
+ <>
152
+ Parent
153
+ <NestedView />
154
+ </>
155
+ ),
156
+ });
157
+
158
+ child = $page({
159
+ path: "/child",
160
+ parent: this.parent,
161
+ ssr: false,
162
+ component: () => "Child",
163
+ });
164
+ }
165
+
166
+ const app = alepha.inject(App);
167
+ await alepha.start();
168
+
169
+ const rendered = await app.child.render();
170
+ expect(rendered.html).toBe("");
171
+ });
172
+
173
+ test("siblings: one inherits parent ssr: false, the other overrides", async ({
174
+ expect,
175
+ }) => {
176
+ class App {
177
+ parent = $page({
178
+ path: "/parent",
179
+ ssr: false,
180
+ component: () => (
181
+ <>
182
+ Parent
183
+ <NestedView />
184
+ </>
185
+ ),
186
+ });
187
+
188
+ home = $page({
189
+ path: "/home",
190
+ parent: this.parent,
191
+ ssr: true,
192
+ component: () => "Home",
193
+ });
194
+
195
+ about = $page({
196
+ path: "/about",
197
+ parent: this.parent,
198
+ component: () => "About",
199
+ });
200
+ }
201
+
202
+ const app = alepha.inject(App);
203
+ await alepha.start();
204
+
205
+ const home = await app.home.render();
206
+ expect(home.html).toBe("Parent<!-- -->Home");
207
+
208
+ const about = await app.about.render();
209
+ expect(about.html).toBe("");
210
+ });
211
+
212
+ test("3-level chain: nearest explicit ssr wins (leaf decides)", async ({
213
+ expect,
214
+ }) => {
215
+ class App {
216
+ grand = $page({
217
+ path: "/grand",
218
+ ssr: false,
219
+ component: () => (
220
+ <>
221
+ Grand
222
+ <NestedView />
223
+ </>
224
+ ),
225
+ });
226
+
227
+ // no ssr → inherits from grand → false (default for descendants)
228
+ mid = $page({
229
+ path: "/mid",
230
+ parent: this.grand,
231
+ component: () => (
232
+ <>
233
+ Mid
234
+ <NestedView />
235
+ </>
236
+ ),
237
+ });
238
+
239
+ // explicit ssr: true → overrides inherited false
240
+ leaf = $page({
241
+ path: "/leaf",
242
+ parent: this.mid,
243
+ ssr: true,
244
+ component: () => "Leaf",
245
+ });
246
+ }
247
+
248
+ const app = alepha.inject(App);
249
+ await alepha.start();
250
+
251
+ const rendered = await app.leaf.render();
252
+ expect(rendered.html).toBe("Grand<!-- -->Mid<!-- -->Leaf");
253
+ });
254
+
255
+ test("3-level chain: middle ssr: true does not affect leaf without explicit value", async ({
256
+ expect,
257
+ }) => {
258
+ class App {
259
+ grand = $page({
260
+ path: "/grand",
261
+ ssr: false,
262
+ component: () => (
263
+ <>
264
+ Grand
265
+ <NestedView />
266
+ </>
267
+ ),
268
+ });
269
+
270
+ // explicit true overrides grand's false for itself + descendants default
271
+ mid = $page({
272
+ path: "/mid",
273
+ parent: this.grand,
274
+ ssr: true,
275
+ component: () => (
276
+ <>
277
+ Mid
278
+ <NestedView />
279
+ </>
280
+ ),
281
+ });
282
+
283
+ // no ssr → walks up: mid.ssr === true → leaf renders
284
+ leaf = $page({
285
+ path: "/leaf",
286
+ parent: this.mid,
287
+ component: () => "Leaf",
288
+ });
289
+ }
290
+
291
+ const app = alepha.inject(App);
292
+ await alepha.start();
293
+
294
+ const rendered = await app.leaf.render();
295
+ expect(rendered.html).toBe("Grand<!-- -->Mid<!-- -->Leaf");
296
+ });
297
+
298
+ test("parent ssr: false: loaders still run for parent and child", async ({
299
+ expect,
300
+ }) => {
301
+ let parentLoaderCalls = 0;
302
+ let childLoaderCalls = 0;
303
+
304
+ class App {
305
+ parent = $page({
306
+ path: "/parent",
307
+ ssr: false,
308
+ loader: () => {
309
+ parentLoaderCalls += 1;
310
+ return { fromParent: "p" };
311
+ },
312
+ component: () => (
313
+ <>
314
+ Parent
315
+ <NestedView />
316
+ </>
317
+ ),
318
+ });
319
+
320
+ child = $page({
321
+ path: "/child",
322
+ parent: this.parent,
323
+ loader: ({ fromParent }) => {
324
+ childLoaderCalls += 1;
325
+ return { fromChild: `${fromParent}-c` };
326
+ },
327
+ component: ({ fromChild }: { fromChild: string }) => fromChild,
328
+ });
329
+ }
330
+
331
+ const app = alepha.inject(App);
332
+ await alepha.start();
333
+
334
+ const rendered = await app.child.render();
335
+ expect(rendered.html).toBe("");
336
+ expect(parentLoaderCalls).toBe(1);
337
+ expect(childLoaderCalls).toBe(1);
338
+ });
339
+ });
@@ -10,7 +10,6 @@ import {
10
10
  type TSchema,
11
11
  } from "alepha";
12
12
  import { $cache } from "alepha/cache";
13
- import type { ClientOnlyProps } from "alepha/react";
14
13
  import type { Head } from "alepha/react/head";
15
14
  import type { ServerRequest } from "alepha/server";
16
15
  import type { FC, ReactNode } from "react";
@@ -297,10 +296,35 @@ export interface PagePrimitiveOptions<
297
296
  };
298
297
 
299
298
  /**
300
- * If true, force the page to be rendered only on the client-side (browser).
301
- * It uses the `<ClientOnly/>` component to render the page.
299
+ * Enable or disable server-side rendering for this page.
300
+ *
301
+ * - `true` (default): the page component is rendered on the server and
302
+ * hydrated on the client.
303
+ * - `false`: the loader still runs on the server (so data is preloaded and
304
+ * serialized for hydration), but the component is rendered only on the
305
+ * client. The server emits no HTML for this page.
306
+ *
307
+ * **Decided at the leaf, inherited as default by descendants.**
308
+ *
309
+ * The effective value is determined by the matched leaf page: walk up the
310
+ * parent chain and use the nearest explicit `ssr` value. Setting
311
+ * `ssr: false` on a parent therefore acts as the default for its children;
312
+ * a child can override with `ssr: true`.
313
+ *
314
+ * Skipping rendering while keeping the loader is the recommended strategy
315
+ * for CPU-constrained server environments (e.g. Cloudflare Workers) and
316
+ * heavy admin/dashboard views where SSR provides little SEO value.
317
+ *
318
+ * @example
319
+ * ```ts
320
+ * root = $page({ ssr: false }); // default for children
321
+ * home = $page({ parent: root, ssr: true }); // overrides → SSR
322
+ * about = $page({ parent: root }); // inherits → no SSR
323
+ * ```
324
+ *
325
+ * @default true
302
326
  */
303
- client?: boolean | ClientOnlyProps;
327
+ ssr?: boolean;
304
328
 
305
329
  /**
306
330
  * Called before the server response is sent to the client. (server only)