alepha 0.15.2 → 0.15.4

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 (180) hide show
  1. package/README.md +68 -80
  2. package/dist/api/audits/index.d.ts.map +1 -1
  3. package/dist/api/audits/index.js +8 -0
  4. package/dist/api/audits/index.js.map +1 -1
  5. package/dist/api/files/index.d.ts +170 -170
  6. package/dist/api/files/index.d.ts.map +1 -1
  7. package/dist/api/files/index.js +1 -0
  8. package/dist/api/files/index.js.map +1 -1
  9. package/dist/api/jobs/index.d.ts.map +1 -1
  10. package/dist/api/jobs/index.js +3 -0
  11. package/dist/api/jobs/index.js.map +1 -1
  12. package/dist/api/notifications/index.browser.js +1 -0
  13. package/dist/api/notifications/index.browser.js.map +1 -1
  14. package/dist/api/notifications/index.js +1 -0
  15. package/dist/api/notifications/index.js.map +1 -1
  16. package/dist/api/parameters/index.d.ts +260 -260
  17. package/dist/api/parameters/index.d.ts.map +1 -1
  18. package/dist/api/parameters/index.js +10 -0
  19. package/dist/api/parameters/index.js.map +1 -1
  20. package/dist/api/users/index.d.ts +12 -1
  21. package/dist/api/users/index.d.ts.map +1 -1
  22. package/dist/api/users/index.js +18 -2
  23. package/dist/api/users/index.js.map +1 -1
  24. package/dist/batch/index.d.ts +4 -4
  25. package/dist/bucket/index.d.ts +8 -0
  26. package/dist/bucket/index.d.ts.map +1 -1
  27. package/dist/bucket/index.js +7 -2
  28. package/dist/bucket/index.js.map +1 -1
  29. package/dist/cli/index.d.ts +196 -74
  30. package/dist/cli/index.d.ts.map +1 -1
  31. package/dist/cli/index.js +234 -50
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/command/index.d.ts +10 -0
  34. package/dist/command/index.d.ts.map +1 -1
  35. package/dist/command/index.js +67 -13
  36. package/dist/command/index.js.map +1 -1
  37. package/dist/core/index.browser.js +28 -21
  38. package/dist/core/index.browser.js.map +1 -1
  39. package/dist/core/index.d.ts.map +1 -1
  40. package/dist/core/index.js +28 -21
  41. package/dist/core/index.js.map +1 -1
  42. package/dist/core/index.native.js +28 -21
  43. package/dist/core/index.native.js.map +1 -1
  44. package/dist/email/index.d.ts +21 -13
  45. package/dist/email/index.d.ts.map +1 -1
  46. package/dist/email/index.js +10561 -4
  47. package/dist/email/index.js.map +1 -1
  48. package/dist/lock/core/index.d.ts +6 -1
  49. package/dist/lock/core/index.d.ts.map +1 -1
  50. package/dist/lock/core/index.js +9 -1
  51. package/dist/lock/core/index.js.map +1 -1
  52. package/dist/mcp/index.d.ts +5 -5
  53. package/dist/orm/index.bun.js +32 -16
  54. package/dist/orm/index.bun.js.map +1 -1
  55. package/dist/orm/index.d.ts +4 -1
  56. package/dist/orm/index.d.ts.map +1 -1
  57. package/dist/orm/index.js +34 -22
  58. package/dist/orm/index.js.map +1 -1
  59. package/dist/react/auth/index.browser.js +2 -1
  60. package/dist/react/auth/index.browser.js.map +1 -1
  61. package/dist/react/auth/index.js +2 -1
  62. package/dist/react/auth/index.js.map +1 -1
  63. package/dist/react/core/index.d.ts +3 -3
  64. package/dist/react/router/index.browser.js +9 -15
  65. package/dist/react/router/index.browser.js.map +1 -1
  66. package/dist/react/router/index.d.ts +305 -407
  67. package/dist/react/router/index.d.ts.map +1 -1
  68. package/dist/react/router/index.js +581 -781
  69. package/dist/react/router/index.js.map +1 -1
  70. package/dist/scheduler/index.d.ts +13 -1
  71. package/dist/scheduler/index.d.ts.map +1 -1
  72. package/dist/scheduler/index.js +42 -4
  73. package/dist/scheduler/index.js.map +1 -1
  74. package/dist/security/index.d.ts +42 -42
  75. package/dist/security/index.d.ts.map +1 -1
  76. package/dist/security/index.js +8 -7
  77. package/dist/security/index.js.map +1 -1
  78. package/dist/server/auth/index.d.ts +167 -167
  79. package/dist/server/compress/index.d.ts.map +1 -1
  80. package/dist/server/compress/index.js +1 -0
  81. package/dist/server/compress/index.js.map +1 -1
  82. package/dist/server/health/index.d.ts +17 -17
  83. package/dist/server/links/index.d.ts +39 -39
  84. package/dist/server/links/index.js +1 -1
  85. package/dist/server/links/index.js.map +1 -1
  86. package/dist/server/static/index.js +7 -2
  87. package/dist/server/static/index.js.map +1 -1
  88. package/dist/server/swagger/index.d.ts +8 -0
  89. package/dist/server/swagger/index.d.ts.map +1 -1
  90. package/dist/server/swagger/index.js +7 -2
  91. package/dist/server/swagger/index.js.map +1 -1
  92. package/dist/sms/index.d.ts +8 -0
  93. package/dist/sms/index.d.ts.map +1 -1
  94. package/dist/sms/index.js +7 -2
  95. package/dist/sms/index.js.map +1 -1
  96. package/dist/system/index.browser.js +734 -12
  97. package/dist/system/index.browser.js.map +1 -1
  98. package/dist/system/index.d.ts +8 -0
  99. package/dist/system/index.d.ts.map +1 -1
  100. package/dist/system/index.js +7 -2
  101. package/dist/system/index.js.map +1 -1
  102. package/dist/vite/index.d.ts +3 -2
  103. package/dist/vite/index.d.ts.map +1 -1
  104. package/dist/vite/index.js +42 -8
  105. package/dist/vite/index.js.map +1 -1
  106. package/dist/websocket/index.d.ts +34 -34
  107. package/dist/websocket/index.d.ts.map +1 -1
  108. package/package.json +9 -4
  109. package/src/api/audits/controllers/AdminAuditController.ts +8 -0
  110. package/src/api/files/controllers/AdminFileStatsController.ts +1 -0
  111. package/src/api/jobs/controllers/AdminJobController.ts +3 -0
  112. package/src/api/logs/TODO.md +13 -10
  113. package/src/api/notifications/controllers/AdminNotificationController.ts +1 -0
  114. package/src/api/parameters/controllers/AdminConfigController.ts +10 -0
  115. package/src/api/users/controllers/AdminIdentityController.ts +3 -0
  116. package/src/api/users/controllers/AdminSessionController.ts +3 -0
  117. package/src/api/users/controllers/AdminUserController.ts +5 -0
  118. package/src/cli/apps/AlephaPackageBuilderCli.ts +9 -0
  119. package/src/cli/atoms/buildOptions.ts +99 -9
  120. package/src/cli/commands/build.ts +150 -32
  121. package/src/cli/commands/db.ts +5 -7
  122. package/src/cli/commands/init.spec.ts +50 -6
  123. package/src/cli/commands/init.ts +28 -5
  124. package/src/cli/providers/ViteDevServerProvider.ts +31 -9
  125. package/src/cli/services/AlephaCliUtils.ts +16 -0
  126. package/src/cli/services/PackageManagerUtils.ts +2 -0
  127. package/src/cli/services/ProjectScaffolder.spec.ts +97 -0
  128. package/src/cli/services/ProjectScaffolder.ts +28 -6
  129. package/src/cli/templates/agentMd.ts +6 -1
  130. package/src/cli/templates/apiAppSecurityTs.ts +11 -0
  131. package/src/cli/templates/apiIndexTs.ts +18 -4
  132. package/src/cli/templates/webAppRouterTs.ts +25 -1
  133. package/src/cli/templates/webHelloComponentTsx.ts +15 -5
  134. package/src/command/helpers/Runner.spec.ts +135 -0
  135. package/src/command/helpers/Runner.ts +4 -1
  136. package/src/command/providers/CliProvider.spec.ts +325 -0
  137. package/src/command/providers/CliProvider.ts +117 -7
  138. package/src/core/Alepha.ts +32 -25
  139. package/src/email/index.workerd.ts +36 -0
  140. package/src/email/providers/WorkermailerEmailProvider.ts +221 -0
  141. package/src/lock/core/primitives/$lock.ts +13 -1
  142. package/src/orm/index.bun.ts +1 -1
  143. package/src/orm/index.ts +2 -6
  144. package/src/orm/providers/drivers/BunSqliteProvider.ts +4 -1
  145. package/src/orm/providers/drivers/CloudflareD1Provider.ts +57 -30
  146. package/src/orm/providers/drivers/DatabaseProvider.ts +9 -1
  147. package/src/orm/providers/drivers/NodeSqliteProvider.ts +4 -1
  148. package/src/react/auth/services/ReactAuth.ts +3 -1
  149. package/src/react/router/atoms/ssrManifestAtom.ts +7 -0
  150. package/src/react/router/hooks/useActive.ts +1 -1
  151. package/src/react/router/hooks/useRouter.ts +1 -1
  152. package/src/react/router/index.ts +4 -0
  153. package/src/react/router/primitives/$page.browser.spec.tsx +24 -24
  154. package/src/react/router/primitives/$page.spec.tsx +0 -32
  155. package/src/react/router/primitives/$page.ts +6 -14
  156. package/src/react/router/providers/ReactBrowserProvider.ts +6 -3
  157. package/src/react/router/providers/ReactPageProvider.ts +1 -1
  158. package/src/react/router/providers/ReactPreloadProvider.spec.ts +142 -0
  159. package/src/react/router/providers/ReactPreloadProvider.ts +85 -0
  160. package/src/react/router/providers/ReactServerProvider.ts +21 -82
  161. package/src/react/router/providers/ReactServerTemplateProvider.spec.ts +210 -0
  162. package/src/react/router/providers/ReactServerTemplateProvider.ts +228 -665
  163. package/src/react/router/providers/SSRManifestProvider.ts +7 -0
  164. package/src/react/router/services/ReactRouter.ts +13 -13
  165. package/src/scheduler/index.workerd.ts +43 -0
  166. package/src/scheduler/providers/CronProvider.ts +53 -6
  167. package/src/scheduler/providers/WorkerdCronProvider.ts +102 -0
  168. package/src/security/__tests__/ServerSecurityProvider.spec.ts +77 -0
  169. package/src/security/providers/ServerSecurityProvider.ts +30 -22
  170. package/src/server/compress/providers/ServerCompressProvider.ts +6 -0
  171. package/src/server/core/providers/NodeHttpServerProvider.spec.ts +9 -3
  172. package/src/server/links/providers/ServerLinksProvider.spec.ts +332 -0
  173. package/src/server/links/providers/ServerLinksProvider.ts +1 -1
  174. package/src/system/index.browser.ts +25 -0
  175. package/src/system/index.workerd.ts +1 -0
  176. package/src/system/providers/FileSystemProvider.ts +8 -0
  177. package/src/system/providers/NodeFileSystemProvider.ts +11 -2
  178. package/src/vite/tasks/buildServer.ts +2 -12
  179. package/src/vite/tasks/generateCloudflare.ts +47 -8
  180. package/src/vite/tasks/generateDocker.ts +4 -0
@@ -34,7 +34,7 @@ describe("$page browser tests", () => {
34
34
  const router = alepha.inject(ReactRouter);
35
35
 
36
36
  await act(async () => {
37
- await router.go("/");
37
+ await router.push("/");
38
38
  });
39
39
 
40
40
  await waitFor(() => {
@@ -64,7 +64,7 @@ describe("$page browser tests", () => {
64
64
 
65
65
  // Navigate to home
66
66
  await act(async () => {
67
- await router.go("/");
67
+ await router.push("/");
68
68
  });
69
69
 
70
70
  await waitFor(() => {
@@ -73,7 +73,7 @@ describe("$page browser tests", () => {
73
73
 
74
74
  // Navigate to about
75
75
  await act(async () => {
76
- await router.go("/about");
76
+ await router.push("/about");
77
77
  });
78
78
 
79
79
  await waitFor(() => {
@@ -119,7 +119,7 @@ describe("$page browser tests", () => {
119
119
  const router = alepha.inject(ReactRouter);
120
120
 
121
121
  await act(async () => {
122
- await router.go("/user/123");
122
+ await router.push("/user/123");
123
123
  });
124
124
 
125
125
  await waitFor(() => {
@@ -153,7 +153,7 @@ describe("$page browser tests", () => {
153
153
  const router = alepha.inject(ReactRouter);
154
154
 
155
155
  await act(async () => {
156
- await router.go("/async");
156
+ await router.push("/async");
157
157
  });
158
158
 
159
159
  await waitFor(
@@ -204,7 +204,7 @@ describe("$page browser tests", () => {
204
204
 
205
205
  // Try to access protected - should redirect to login
206
206
  await act(async () => {
207
- await router.go("/protected");
207
+ await router.push("/protected");
208
208
  });
209
209
 
210
210
  await waitFor(() => {
@@ -215,7 +215,7 @@ describe("$page browser tests", () => {
215
215
  isAuthenticated = true;
216
216
 
217
217
  await act(async () => {
218
- await router.go("/protected");
218
+ await router.push("/protected");
219
219
  });
220
220
 
221
221
  await waitFor(() => {
@@ -248,7 +248,7 @@ describe("$page browser tests", () => {
248
248
  const router = alepha.inject(ReactRouter);
249
249
 
250
250
  await act(async () => {
251
- await router.go("/error");
251
+ await router.push("/error");
252
252
  });
253
253
 
254
254
  await waitFor(() => {
@@ -294,7 +294,7 @@ describe("$page browser tests", () => {
294
294
 
295
295
  // Navigate to home - should show layout and home
296
296
  await act(async () => {
297
- await router.go("/");
297
+ await router.push("/");
298
298
  });
299
299
 
300
300
  await waitFor(() => {
@@ -306,7 +306,7 @@ describe("$page browser tests", () => {
306
306
 
307
307
  // Navigate to about - layout should persist
308
308
  await act(async () => {
309
- await router.go("/about");
309
+ await router.push("/about");
310
310
  });
311
311
 
312
312
  await waitFor(() => {
@@ -348,7 +348,7 @@ describe("$page browser tests", () => {
348
348
  const router = alepha.inject(ReactRouter);
349
349
 
350
350
  await act(async () => {
351
- await router.go("/page");
351
+ await router.push("/page");
352
352
  });
353
353
 
354
354
  await waitFor(() => {
@@ -404,7 +404,7 @@ describe("$page browser tests", () => {
404
404
  const router = alepha.inject(ReactRouter);
405
405
 
406
406
  await act(async () => {
407
- await router.go("/section/page");
407
+ await router.push("/section/page");
408
408
  });
409
409
 
410
410
  await waitFor(() => {
@@ -441,7 +441,7 @@ describe("$page browser tests", () => {
441
441
 
442
442
  // Navigate to home
443
443
  await act(async () => {
444
- await router.go("/");
444
+ await router.push("/");
445
445
  });
446
446
 
447
447
  await waitFor(() => {
@@ -452,7 +452,7 @@ describe("$page browser tests", () => {
452
452
 
453
453
  // Navigate away from home to about
454
454
  await act(async () => {
455
- await router.go("/about");
455
+ await router.push("/about");
456
456
  });
457
457
 
458
458
  await waitFor(() => {
@@ -490,7 +490,7 @@ describe("$page browser tests", () => {
490
490
 
491
491
  // Navigate to home first
492
492
  await act(async () => {
493
- await router.go("/");
493
+ await router.push("/");
494
494
  });
495
495
 
496
496
  await waitFor(() => {
@@ -503,7 +503,7 @@ describe("$page browser tests", () => {
503
503
 
504
504
  // Navigate to about - onEnter should be called
505
505
  await act(async () => {
506
- await router.go("/about");
506
+ await router.push("/about");
507
507
  });
508
508
 
509
509
  await waitFor(() => {
@@ -533,7 +533,7 @@ describe("$page browser tests", () => {
533
533
 
534
534
  // Navigate to home - onEnter should be called
535
535
  await act(async () => {
536
- await router.go("/");
536
+ await router.push("/");
537
537
  });
538
538
 
539
539
  await waitFor(() => {
@@ -582,7 +582,7 @@ describe("$page browser tests", () => {
582
582
 
583
583
  // Navigate to child1
584
584
  await act(async () => {
585
- await router.go("/child1");
585
+ await router.push("/child1");
586
586
  });
587
587
 
588
588
  await waitFor(() => {
@@ -595,7 +595,7 @@ describe("$page browser tests", () => {
595
595
 
596
596
  // Navigate to child2 - parent onEnter should NOT be called again
597
597
  await act(async () => {
598
- await router.go("/child2");
598
+ await router.push("/child2");
599
599
  });
600
600
 
601
601
  await waitFor(() => {
@@ -633,7 +633,7 @@ describe("$page browser tests", () => {
633
633
  const router = alepha.inject(ReactRouter);
634
634
 
635
635
  await act(async () => {
636
- await router.go("/user/abc123");
636
+ await router.push("/user/abc123");
637
637
  });
638
638
 
639
639
  await waitFor(() => {
@@ -678,7 +678,7 @@ describe("$page browser tests", () => {
678
678
  const router = alepha.inject(ReactRouter);
679
679
 
680
680
  await act(async () => {
681
- await router.go("/search?q=typescript&page=2");
681
+ await router.push("/search?q=typescript&page=2");
682
682
  });
683
683
 
684
684
  await waitFor(() => {
@@ -728,7 +728,7 @@ describe("$page browser tests", () => {
728
728
 
729
729
  // Navigate with query params
730
730
  await act(async () => {
731
- await router.go("/search?q=alepha&page=5");
731
+ await router.push("/search?q=alepha&page=5");
732
732
  });
733
733
 
734
734
  await waitFor(() => {
@@ -783,7 +783,7 @@ describe("$page browser tests", () => {
783
783
  const router = alepha.inject(ReactRouter);
784
784
 
785
785
  await act(async () => {
786
- await router.go("/users/john/posts?sort=popular&limit=20");
786
+ await router.push("/users/john/posts?sort=popular&limit=20");
787
787
  });
788
788
 
789
789
  await waitFor(() => {
@@ -835,7 +835,7 @@ describe("$page browser tests", () => {
835
835
 
836
836
  // Navigate without query params - should use defaults
837
837
  await act(async () => {
838
- await router.go("/search");
838
+ await router.push("/search");
839
839
  });
840
840
 
841
841
  await waitFor(() => {
@@ -521,38 +521,6 @@ describe("$page primitive tests", () => {
521
521
  expect(functionRendered.html).toBe("Function animation");
522
522
  });
523
523
 
524
- test("$page - match method (not implemented)", ({ expect }) => {
525
- class App {
526
- page = $page({
527
- path: "/test",
528
- component: () => "test",
529
- });
530
- }
531
-
532
- const app = alepha.inject(App);
533
-
534
- expect(app.page.match("/test")).toBe(false);
535
- expect(app.page.match("/other")).toBe(false);
536
- });
537
-
538
- test("$page - pathname method", ({ expect }) => {
539
- class App {
540
- withPath = $page({
541
- path: "/test/:id",
542
- component: () => "test",
543
- });
544
-
545
- withoutPath = $page({
546
- component: () => "test",
547
- });
548
- }
549
-
550
- const app = alepha.inject(App);
551
-
552
- expect(app.withPath.pathname({})).toBe("/test/:id");
553
- expect(app.withoutPath.pathname({})).toBe("");
554
- });
555
-
556
524
  test("$page - complex schema with nested objects", async ({ expect }) => {
557
525
  class App {
558
526
  complex = $page({
@@ -368,10 +368,7 @@ export interface PagePrimitiveOptions<
368
368
  [PAGE_PRELOAD_KEY]?: string;
369
369
  }
370
370
 
371
- export type ErrorHandler = (
372
- error: Error,
373
- state: ReactRouterState,
374
- ) => ReactNode | Redirection | undefined;
371
+ // ---------------------------------------------------------------------------------------------------------------------
375
372
 
376
373
  export class PagePrimitive<
377
374
  TConfig extends PageConfigSchema = PageConfigSchema,
@@ -413,22 +410,17 @@ export class PagePrimitive<
413
410
  }> {
414
411
  return this.reactPageService.fetch(this.options.path || "", options);
415
412
  }
416
-
417
- public match(url: string): boolean {
418
- // TODO: Implement a way to match the URL against the pathname
419
- return false;
420
- }
421
-
422
- public pathname(config: any) {
423
- // TODO: Implement a way to generate the pathname based on the config
424
- return this.options.path || "";
425
- }
426
413
  }
427
414
 
428
415
  $page[KIND] = PagePrimitive;
429
416
 
430
417
  // ---------------------------------------------------------------------------------------------------------------------
431
418
 
419
+ export type ErrorHandler = (
420
+ error: Error,
421
+ state: ReactRouterState,
422
+ ) => ReactNode | Redirection | undefined;
423
+
432
424
  export interface PageConfigSchema {
433
425
  query?: TSchema;
434
426
  params?: TSchema;
@@ -12,14 +12,14 @@ import { DateTimeProvider } from "alepha/datetime";
12
12
  import { $logger } from "alepha/logger";
13
13
  import { BrowserHeadProvider } from "alepha/react/head";
14
14
  import { LinkProvider } from "alepha/server/links";
15
- import type { RouterGoOptions } from "../services/ReactRouter.ts";
15
+ import type { RouterPushOptions } from "../services/ReactRouter.ts";
16
16
  import { ReactBrowserRouterProvider } from "./ReactBrowserRouterProvider.ts";
17
17
  import type {
18
18
  PreviousLayerData,
19
19
  ReactRouterState,
20
20
  } from "./ReactPageProvider.ts";
21
21
 
22
- export type { RouterGoOptions } from "../services/ReactRouter.ts";
22
+ export type { RouterPushOptions } from "../services/ReactRouter.ts";
23
23
 
24
24
  /**
25
25
  * React browser renderer configuration atom
@@ -158,7 +158,10 @@ export class ReactBrowserProvider {
158
158
  await this.render({ previous });
159
159
  }
160
160
 
161
- public async go(url: string, options: RouterGoOptions = {}): Promise<void> {
161
+ public async push(
162
+ url: string,
163
+ options: RouterPushOptions = {},
164
+ ): Promise<void> {
162
165
  this.log.trace(`Going to ${url}`, {
163
166
  url,
164
167
  options,
@@ -101,7 +101,7 @@ export class ReactPageProvider {
101
101
  ) {
102
102
  const page = this.page(name);
103
103
  if (!page) {
104
- throw new Error(`Page ${name} not found`);
104
+ throw new AlephaError(`Page ${name} not found`);
105
105
  }
106
106
 
107
107
  let url = page.path ?? "";
@@ -0,0 +1,142 @@
1
+ import { Alepha } from "alepha";
2
+ import { HttpClient, ServerProvider } from "alepha/server";
3
+ import { describe, it } from "vitest";
4
+ import { ssrManifestAtom } from "../atoms/ssrManifestAtom.ts";
5
+ import { $page } from "../index.ts";
6
+
7
+ describe("ReactPreloadProvider", () => {
8
+ class App {
9
+ home = $page({
10
+ path: "/",
11
+ component: () => "Hello World",
12
+ });
13
+ }
14
+
15
+ it("should add Link header with entry assets to HTML responses", async ({
16
+ expect,
17
+ }) => {
18
+ const alepha = Alepha.create({
19
+ env: { LOG_LEVEL: "error", SERVER_PORT: 0 },
20
+ }).with(App);
21
+
22
+ // Set up mock SSR manifest with entry assets
23
+ alepha.store.set(ssrManifestAtom, {
24
+ client: {
25
+ "src/entry.tsx": {
26
+ file: "assets/entry.abc123.js",
27
+ isEntry: true,
28
+ css: ["assets/style.def456.css"],
29
+ },
30
+ },
31
+ });
32
+
33
+ await alepha.start();
34
+
35
+ const server = alepha.inject(ServerProvider);
36
+ const http = alepha.inject(HttpClient);
37
+
38
+ const response = await http.fetch(`${server.hostname}/`);
39
+
40
+ // Check that the Link header is present
41
+ const linkHeader = response.headers.get("link");
42
+ expect(linkHeader).toBeTruthy();
43
+ expect(linkHeader).toContain(
44
+ "</assets/style.def456.css>; rel=preload; as=style",
45
+ );
46
+ expect(linkHeader).toContain(
47
+ "</assets/entry.abc123.js>; rel=modulepreload",
48
+ );
49
+
50
+ await alepha.stop();
51
+ });
52
+
53
+ it("should not add Link header when no SSR manifest is available", async ({
54
+ expect,
55
+ }) => {
56
+ const alepha = Alepha.create({
57
+ env: { LOG_LEVEL: "error", SERVER_PORT: 0 },
58
+ }).with(App);
59
+
60
+ // No SSR manifest set
61
+
62
+ await alepha.start();
63
+
64
+ const server = alepha.inject(ServerProvider);
65
+ const http = alepha.inject(HttpClient);
66
+
67
+ const response = await http.fetch(`${server.hostname}/`);
68
+
69
+ // Link header should not be present (or empty)
70
+ const linkHeader = response.headers.get("link");
71
+ expect(linkHeader).toBeNull();
72
+
73
+ await alepha.stop();
74
+ });
75
+
76
+ it("should handle base path in manifest", async ({ expect }) => {
77
+ const alepha = Alepha.create({
78
+ env: { LOG_LEVEL: "error", SERVER_PORT: 0 },
79
+ }).with(App);
80
+
81
+ // Set up mock SSR manifest with base path
82
+ alepha.store.set(ssrManifestAtom, {
83
+ base: "/app",
84
+ client: {
85
+ "src/entry.tsx": {
86
+ file: "assets/entry.abc123.js",
87
+ isEntry: true,
88
+ css: ["assets/style.def456.css"],
89
+ },
90
+ },
91
+ });
92
+
93
+ await alepha.start();
94
+
95
+ const server = alepha.inject(ServerProvider);
96
+ const http = alepha.inject(HttpClient);
97
+
98
+ const response = await http.fetch(`${server.hostname}/`);
99
+
100
+ // Check that the Link header includes base path
101
+ const linkHeader = response.headers.get("link");
102
+ expect(linkHeader).toBeTruthy();
103
+ expect(linkHeader).toContain(
104
+ "</app/assets/style.def456.css>; rel=preload; as=style",
105
+ );
106
+ expect(linkHeader).toContain(
107
+ "</app/assets/entry.abc123.js>; rel=modulepreload",
108
+ );
109
+
110
+ await alepha.stop();
111
+ });
112
+
113
+ it("should handle entry with only JS (no CSS)", async ({ expect }) => {
114
+ const alepha = Alepha.create({
115
+ env: { LOG_LEVEL: "error", SERVER_PORT: 0 },
116
+ }).with(App);
117
+
118
+ // Set up mock SSR manifest with only JS entry
119
+ alepha.store.set(ssrManifestAtom, {
120
+ client: {
121
+ "src/entry.tsx": {
122
+ file: "assets/entry.abc123.js",
123
+ isEntry: true,
124
+ },
125
+ },
126
+ });
127
+
128
+ await alepha.start();
129
+
130
+ const server = alepha.inject(ServerProvider);
131
+ const http = alepha.inject(HttpClient);
132
+
133
+ const response = await http.fetch(`${server.hostname}/`);
134
+
135
+ // Check that the Link header contains only JS modulepreload
136
+ const linkHeader = response.headers.get("link");
137
+ expect(linkHeader).toBeTruthy();
138
+ expect(linkHeader).toBe("</assets/entry.abc123.js>; rel=modulepreload");
139
+
140
+ await alepha.stop();
141
+ });
142
+ });
@@ -0,0 +1,85 @@
1
+ import { $hook, $inject, Alepha } from "alepha";
2
+ import { SSRManifestProvider } from "./SSRManifestProvider.ts";
3
+
4
+ /**
5
+ * Adds HTTP Link headers for preloading entry assets.
6
+ *
7
+ * Benefits:
8
+ * - Early Hints (103): Servers can send preload hints before the full response
9
+ * - CDN optimization: Many CDNs use Link headers to optimize asset delivery
10
+ * - Browser prefetching: Browsers can start fetching resources earlier
11
+ *
12
+ * The Link header is computed once at first request and cached for reuse.
13
+ */
14
+ export class ReactPreloadProvider {
15
+ protected readonly alepha = $inject(Alepha);
16
+ protected readonly ssrManifest = $inject(SSRManifestProvider);
17
+
18
+ /**
19
+ * Cached Link header value - computed once, reused for all requests.
20
+ */
21
+ protected cachedLinkHeader: string | null | undefined;
22
+
23
+ /**
24
+ * Build the Link header string from entry assets.
25
+ *
26
+ * Format: <url>; rel=preload; as=type, <url>; rel=modulepreload
27
+ *
28
+ * @returns Link header string or null if no assets
29
+ */
30
+ protected buildLinkHeader(): string | null {
31
+ const assets = this.ssrManifest.getEntryAssets();
32
+ if (!assets) return null;
33
+
34
+ const links: string[] = [];
35
+
36
+ // CSS - preload as style
37
+ for (const css of assets.css) {
38
+ links.push(`<${css}>; rel=preload; as=style`);
39
+ }
40
+
41
+ // JS - modulepreload for ES modules
42
+ if (assets.js) {
43
+ links.push(`<${assets.js}>; rel=modulepreload`);
44
+ }
45
+
46
+ return links.length > 0 ? links.join(", ") : null;
47
+ }
48
+
49
+ /**
50
+ * Get the cached Link header, computing it on first access.
51
+ */
52
+ protected getLinkHeader(): string | null {
53
+ if (this.cachedLinkHeader === undefined) {
54
+ this.cachedLinkHeader = this.buildLinkHeader();
55
+ }
56
+ return this.cachedLinkHeader;
57
+ }
58
+
59
+ /**
60
+ * Add Link header to HTML responses for asset preloading.
61
+ */
62
+ protected readonly onResponse = $hook({
63
+ on: "server:onResponse",
64
+ priority: "first",
65
+ handler: ({ response }) => {
66
+ // Only add to HTML responses (SSR pages)
67
+ const contentType = response.headers["content-type"];
68
+ if (!contentType || !contentType.includes("text/html")) {
69
+ return;
70
+ }
71
+
72
+ const linkHeader = this.getLinkHeader();
73
+ if (!linkHeader) {
74
+ return;
75
+ }
76
+
77
+ // Append to existing Link header if present
78
+ if (response.headers.link) {
79
+ response.headers.link = `${response.headers.link}, ${linkHeader}`;
80
+ } else {
81
+ response.headers.link = linkHeader;
82
+ }
83
+ },
84
+ });
85
+ }