alepha 0.14.3 → 0.14.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 (114) hide show
  1. package/README.md +1 -1
  2. package/dist/api/audits/index.d.ts +338 -417
  3. package/dist/api/audits/index.d.ts.map +1 -1
  4. package/dist/api/files/index.d.ts +1 -80
  5. package/dist/api/files/index.d.ts.map +1 -1
  6. package/dist/api/jobs/index.d.ts +156 -235
  7. package/dist/api/jobs/index.d.ts.map +1 -1
  8. package/dist/api/notifications/index.d.ts +170 -249
  9. package/dist/api/notifications/index.d.ts.map +1 -1
  10. package/dist/api/parameters/index.d.ts +266 -345
  11. package/dist/api/parameters/index.d.ts.map +1 -1
  12. package/dist/api/users/index.d.ts +755 -834
  13. package/dist/api/users/index.d.ts.map +1 -1
  14. package/dist/api/verifications/index.d.ts +125 -125
  15. package/dist/api/verifications/index.d.ts.map +1 -1
  16. package/dist/cli/index.d.ts +116 -20
  17. package/dist/cli/index.d.ts.map +1 -1
  18. package/dist/cli/index.js +212 -124
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/command/index.d.ts +6 -11
  21. package/dist/command/index.d.ts.map +1 -1
  22. package/dist/command/index.js +2 -2
  23. package/dist/command/index.js.map +1 -1
  24. package/dist/core/index.browser.js +26 -4
  25. package/dist/core/index.browser.js.map +1 -1
  26. package/dist/core/index.d.ts +16 -1
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/core/index.js +26 -4
  29. package/dist/core/index.js.map +1 -1
  30. package/dist/core/index.native.js +26 -4
  31. package/dist/core/index.native.js.map +1 -1
  32. package/dist/logger/index.d.ts +1 -1
  33. package/dist/logger/index.d.ts.map +1 -1
  34. package/dist/logger/index.js +12 -2
  35. package/dist/logger/index.js.map +1 -1
  36. package/dist/mcp/index.d.ts.map +1 -1
  37. package/dist/mcp/index.js +1 -1
  38. package/dist/mcp/index.js.map +1 -1
  39. package/dist/orm/index.d.ts +37 -173
  40. package/dist/orm/index.d.ts.map +1 -1
  41. package/dist/orm/index.js +193 -422
  42. package/dist/orm/index.js.map +1 -1
  43. package/dist/server/auth/index.d.ts +167 -167
  44. package/dist/server/cache/index.d.ts +12 -0
  45. package/dist/server/cache/index.d.ts.map +1 -1
  46. package/dist/server/cache/index.js +55 -2
  47. package/dist/server/cache/index.js.map +1 -1
  48. package/dist/server/compress/index.d.ts +6 -0
  49. package/dist/server/compress/index.d.ts.map +1 -1
  50. package/dist/server/compress/index.js +36 -1
  51. package/dist/server/compress/index.js.map +1 -1
  52. package/dist/server/core/index.browser.js +2 -2
  53. package/dist/server/core/index.browser.js.map +1 -1
  54. package/dist/server/core/index.d.ts +10 -10
  55. package/dist/server/core/index.d.ts.map +1 -1
  56. package/dist/server/core/index.js +6 -3
  57. package/dist/server/core/index.js.map +1 -1
  58. package/dist/server/links/index.d.ts +39 -39
  59. package/dist/server/links/index.d.ts.map +1 -1
  60. package/dist/server/security/index.d.ts +9 -9
  61. package/dist/server/static/index.d.ts.map +1 -1
  62. package/dist/server/static/index.js +4 -0
  63. package/dist/server/static/index.js.map +1 -1
  64. package/dist/server/swagger/index.d.ts.map +1 -1
  65. package/dist/server/swagger/index.js +2 -3
  66. package/dist/server/swagger/index.js.map +1 -1
  67. package/dist/vite/index.d.ts +101 -106
  68. package/dist/vite/index.d.ts.map +1 -1
  69. package/dist/vite/index.js +571 -508
  70. package/dist/vite/index.js.map +1 -1
  71. package/package.json +1 -1
  72. package/src/cli/apps/AlephaCli.ts +0 -2
  73. package/src/cli/atoms/buildOptions.ts +88 -0
  74. package/src/cli/commands/build.ts +32 -69
  75. package/src/cli/commands/db.ts +0 -4
  76. package/src/cli/commands/dev.ts +16 -4
  77. package/src/cli/commands/gen/env.ts +53 -0
  78. package/src/cli/commands/gen/openapi.ts +1 -1
  79. package/src/cli/commands/gen/resource.ts +15 -0
  80. package/src/cli/commands/gen.ts +7 -1
  81. package/src/cli/commands/init.ts +0 -1
  82. package/src/cli/commands/test.ts +0 -1
  83. package/src/cli/commands/verify.ts +1 -1
  84. package/src/cli/defineConfig.ts +49 -7
  85. package/src/cli/index.ts +0 -1
  86. package/src/cli/services/AlephaCliUtils.ts +36 -25
  87. package/src/command/helpers/Runner.spec.ts +2 -2
  88. package/src/command/helpers/Runner.ts +1 -1
  89. package/src/command/primitives/$command.ts +0 -6
  90. package/src/command/providers/CliProvider.ts +1 -3
  91. package/src/core/Alepha.ts +42 -0
  92. package/src/logger/index.ts +15 -3
  93. package/src/mcp/transports/StdioMcpTransport.ts +1 -1
  94. package/src/orm/index.ts +2 -8
  95. package/src/queue/core/providers/WorkerProvider.spec.ts +48 -32
  96. package/src/server/cache/providers/ServerCacheProvider.spec.ts +183 -0
  97. package/src/server/cache/providers/ServerCacheProvider.ts +94 -9
  98. package/src/server/compress/providers/ServerCompressProvider.ts +61 -2
  99. package/src/server/core/helpers/ServerReply.ts +2 -2
  100. package/src/server/core/providers/ServerProvider.ts +11 -1
  101. package/src/server/static/providers/ServerStaticProvider.ts +10 -0
  102. package/src/server/swagger/providers/ServerSwaggerProvider.ts +5 -8
  103. package/src/vite/helpers/importViteReact.ts +13 -0
  104. package/src/vite/index.ts +1 -21
  105. package/src/vite/plugins/viteAlephaDev.ts +16 -1
  106. package/src/vite/plugins/viteAlephaSsrPreload.ts +222 -0
  107. package/src/vite/tasks/buildClient.ts +11 -0
  108. package/src/vite/tasks/buildServer.ts +47 -3
  109. package/src/vite/tasks/devServer.ts +69 -0
  110. package/src/vite/tasks/index.ts +2 -1
  111. package/src/cli/assets/viteConfigTs.ts +0 -14
  112. package/src/cli/commands/run.ts +0 -24
  113. package/src/vite/plugins/viteAlepha.ts +0 -37
  114. package/src/vite/plugins/viteAlephaBuild.ts +0 -281
@@ -165,6 +165,13 @@ export class Alepha {
165
165
  ...state.env,
166
166
  ...process.env,
167
167
  };
168
+
169
+ // remove empty env variables
170
+ for (const key in state.env) {
171
+ if (state.env[key] === "") {
172
+ delete (state.env as any)[key];
173
+ }
174
+ }
168
175
  }
169
176
 
170
177
  // force production mode when building with vite
@@ -908,6 +915,27 @@ export class Alepha {
908
915
  return graph;
909
916
  }
910
917
 
918
+ public dump(): AlephaDump {
919
+ const env: Record<string, AlephaDumpEnvVariable> = {};
920
+ for (const [schema] of this.cacheEnv.entries()) {
921
+ const ref = schema as any;
922
+ for (const [key, value] of Object.entries(ref.properties)) {
923
+ const prop = value as any;
924
+ env[key] = {
925
+ description: prop.description,
926
+ default: prop.default,
927
+ required: ref.required?.includes(key) ?? undefined,
928
+ enum: prop.enum ? ([...prop.enum] as Array<string>) : undefined,
929
+ };
930
+ }
931
+ }
932
+
933
+ return {
934
+ env,
935
+ providers: this.graph(),
936
+ };
937
+ }
938
+
911
939
  public services<T extends object>(base: Service<T>): Array<T> {
912
940
  const list: Array<T> = [];
913
941
  for (const [key, value] of this.registry.entries()) {
@@ -1012,6 +1040,20 @@ export interface Hook<T extends keyof Hooks = any> {
1012
1040
 
1013
1041
  // ---------------------------------------------------------------------------------------------------------------------
1014
1042
 
1043
+ export interface AlephaDump {
1044
+ env: Record<string, AlephaDumpEnvVariable>;
1045
+ providers: Record<string, { from: string[]; as?: string[]; module?: string }>;
1046
+ }
1047
+
1048
+ export interface AlephaDumpEnvVariable {
1049
+ description: string;
1050
+ default?: string;
1051
+ required?: boolean;
1052
+ enum?: Array<string>;
1053
+ }
1054
+
1055
+ // ---------------------------------------------------------------------------------------------------------------------
1056
+
1015
1057
  /**
1016
1058
  * This is how we store services in the Alepha container.
1017
1059
  */
@@ -199,16 +199,28 @@ const envSchema = t.object({
199
199
  * LOG_LEVEL=my.module.name:debug,info # Set debug level for my.module.name and info for all other modules
200
200
  * LOG_LEVEL=alepha:trace, info # Set trace level for all alepha modules and info for all other modules
201
201
  */
202
- LOG_LEVEL: t.optional(t.text({ lowercase: true })),
202
+ LOG_LEVEL: t.optional(
203
+ t.text({
204
+ description: `Application log level on startup.
205
+ Levels are: trace, debug, info, warn, error, silent
206
+ Level can be set for a specific module:
207
+ "my.module.name:debug,info" -> Set debug level for my.module.name and info for all other modules
208
+ "alepha:trace,info" -> Set trace level for all alepha modules and info for all other modules`,
209
+ lowercase: true,
210
+ }),
211
+ ),
203
212
 
204
213
  /**
205
214
  * Built-in log formats.
206
215
  * - "json" - JSON format, useful for structured logging and log aggregation. {@link JsonFormatterProvider}
207
216
  * - "pretty" - Simple text format, human-readable, with colors. {@link PrettyFormatterProvider}
208
- * - "raw" - Raw format, no formatting, just the message. {@link RawFormatterProvider}
217
+ * - "raw" - Raw format, no formatting, just the message. {@link RawFormatterProvider}
209
218
  */
210
219
  LOG_FORMAT: t.optional(
211
- t.enum(["json", "pretty", "raw"], { lowercase: true }),
220
+ t.enum(["json", "pretty", "raw"], {
221
+ description: "Default log format for the application.",
222
+ lowercase: true,
223
+ }),
212
224
  ),
213
225
  });
214
226
 
@@ -121,6 +121,6 @@ export class StdioMcpTransport {
121
121
  */
122
122
  protected send(message: object): void {
123
123
  const json = JSON.stringify(message);
124
- process.stdout.write(json + "\n");
124
+ process.stdout.write(`${json}\n`);
125
125
  }
126
126
  }
package/src/orm/index.ts CHANGED
@@ -5,8 +5,6 @@ import { $entity } from "./primitives/$entity.ts";
5
5
  import { $repository } from "./primitives/$repository.ts";
6
6
  import { $sequence } from "./primitives/$sequence.ts";
7
7
  import { DrizzleKitProvider } from "./providers/DrizzleKitProvider.ts";
8
- import { BunPostgresProvider } from "./providers/drivers/BunPostgresProvider.ts";
9
- import { BunSqliteProvider } from "./providers/drivers/BunSqliteProvider.ts";
10
8
  import { CloudflareD1Provider } from "./providers/drivers/CloudflareD1Provider.ts";
11
9
  import { DatabaseProvider } from "./providers/drivers/DatabaseProvider.ts";
12
10
  import { NodePostgresProvider } from "./providers/drivers/NodePostgresProvider.ts";
@@ -116,8 +114,6 @@ export * from "./primitives/$sequence.ts";
116
114
  export * from "./primitives/$transaction.ts";
117
115
  export * from "./providers/DatabaseTypeProvider.ts";
118
116
  export * from "./providers/DrizzleKitProvider.ts";
119
- export * from "./providers/drivers/BunPostgresProvider.ts";
120
- export * from "./providers/drivers/BunSqliteProvider.ts";
121
117
  export * from "./providers/drivers/CloudflareD1Provider.ts";
122
118
  export * from "./providers/drivers/DatabaseProvider.ts";
123
119
  export * from "./providers/drivers/NodePostgresProvider.ts";
@@ -183,8 +179,6 @@ export const AlephaPostgres = $module({
183
179
  NodePostgresProvider,
184
180
  PglitePostgresProvider,
185
181
  NodeSqliteProvider,
186
- BunPostgresProvider,
187
- BunSqliteProvider,
188
182
  CloudflareD1Provider,
189
183
  SqliteModelBuilder,
190
184
  PostgresModelBuilder,
@@ -233,7 +227,7 @@ export const AlephaPostgres = $module({
233
227
  alepha.with({
234
228
  optional: true,
235
229
  provide: DatabaseProvider,
236
- use: alepha.isBun() ? BunPostgresProvider : NodePostgresProvider,
230
+ use: NodePostgresProvider,
237
231
  });
238
232
  return;
239
233
  }
@@ -241,7 +235,7 @@ export const AlephaPostgres = $module({
241
235
  alepha.with({
242
236
  optional: true,
243
237
  provide: DatabaseProvider,
244
- use: alepha.isBun() ? BunSqliteProvider : NodeSqliteProvider,
238
+ use: NodeSqliteProvider,
245
239
  });
246
240
  },
247
241
  });
@@ -1,4 +1,5 @@
1
1
  import { Alepha, t } from "alepha";
2
+ import { $logger } from "alepha/logger";
2
3
  import { describe, expect, test, vi } from "vitest";
3
4
  import {
4
5
  $consumer,
@@ -13,6 +14,16 @@ const payloadSchema = t.object({
13
14
  count: t.integer(),
14
15
  });
15
16
 
17
+ class TestWorkerProvider extends WorkerProvider {
18
+ public readonly log = $logger();
19
+ public workersRunning = 0;
20
+ public workerIntervals: Record<number, number> = {};
21
+ public abortController = new AbortController();
22
+ public waitForNextMessage(n: number): Promise<void> {
23
+ return super.waitForNextMessage(n);
24
+ }
25
+ }
26
+
16
27
  describe("WorkerProvider", () => {
17
28
  const createTestApp = async (
18
29
  options: {
@@ -29,6 +40,11 @@ describe("WorkerProvider", () => {
29
40
  },
30
41
  });
31
42
 
43
+ app.with({
44
+ provide: WorkerProvider,
45
+ use: TestWorkerProvider,
46
+ });
47
+
32
48
  app.with({
33
49
  provide: QueueProvider,
34
50
  use: MemoryQueueProvider,
@@ -52,16 +68,16 @@ describe("WorkerProvider", () => {
52
68
  const app = await createTestApp();
53
69
  app.with(TestService);
54
70
 
55
- const workerProvider = app.inject(WorkerProvider);
56
- const logSpy = vi.spyOn(workerProvider["log"], "debug");
71
+ const workerProvider = app.inject(TestWorkerProvider);
72
+ const logSpy = vi.spyOn(workerProvider.log, "debug");
57
73
 
58
74
  await app.start();
59
75
 
60
76
  expect(logSpy).toHaveBeenCalledWith("Starting worker n-0");
61
- expect(workerProvider["workersRunning"]).toBe(1);
77
+ expect(workerProvider.workersRunning).toBe(1);
62
78
 
63
79
  await app.stop();
64
- expect(workerProvider["workersRunning"]).toBe(0);
80
+ expect(workerProvider.workersRunning).toBe(0);
65
81
  });
66
82
 
67
83
  test("should start multiple workers with concurrency", async () => {
@@ -76,32 +92,32 @@ describe("WorkerProvider", () => {
76
92
  const app = await createTestApp({ workerConcurrency: 3 });
77
93
  app.with(TestService);
78
94
 
79
- const workerProvider = app.inject(WorkerProvider);
80
- const logSpy = vi.spyOn(workerProvider["log"], "debug");
95
+ const workerProvider = app.inject(TestWorkerProvider);
96
+ const logSpy = vi.spyOn(workerProvider.log, "debug");
81
97
 
82
98
  await app.start();
83
99
 
84
100
  expect(logSpy).toHaveBeenCalledWith("Starting worker n-0");
85
101
  expect(logSpy).toHaveBeenCalledWith("Starting worker n-1");
86
102
  expect(logSpy).toHaveBeenCalledWith("Starting worker n-2");
87
- expect(workerProvider["workersRunning"]).toBe(3);
103
+ expect(workerProvider.workersRunning).toBe(3);
88
104
 
89
105
  await app.stop();
90
- expect(workerProvider["workersRunning"]).toBe(0);
106
+ expect(workerProvider.workersRunning).toBe(0);
91
107
  });
92
108
 
93
109
  test("should not start workers when no consumers", async () => {
94
110
  const app = await createTestApp();
95
111
 
96
- const workerProvider = app.inject(WorkerProvider);
97
- const logSpy = vi.spyOn(workerProvider["log"], "debug");
112
+ const workerProvider = app.inject(TestWorkerProvider);
113
+ const logSpy = vi.spyOn(workerProvider.log, "debug");
98
114
 
99
115
  await app.start();
100
116
 
101
117
  expect(logSpy).not.toHaveBeenCalledWith(
102
118
  expect.stringMatching(/Starting worker/),
103
119
  );
104
- expect(workerProvider["workersRunning"]).toBe(0);
120
+ expect(workerProvider.workersRunning).toBe(0);
105
121
 
106
122
  await app.stop();
107
123
  });
@@ -120,20 +136,20 @@ describe("WorkerProvider", () => {
120
136
  const app = await createTestApp({ workerConcurrency: 2 });
121
137
  app.with(TestService);
122
138
 
123
- const workerProvider = app.inject(WorkerProvider);
124
- const debugSpy = vi.spyOn(workerProvider["log"], "debug");
139
+ const workerProvider = app.inject(TestWorkerProvider);
140
+ const debugSpy = vi.spyOn(workerProvider.log, "debug");
125
141
 
126
142
  await app.start();
127
- expect(workerProvider["workersRunning"]).toBe(2);
143
+ expect(workerProvider.workersRunning).toBe(2);
128
144
 
129
145
  // Simulate worker crash by manually decrementing counter
130
- workerProvider["workersRunning"] = 1;
146
+ workerProvider.workersRunning = 1;
131
147
 
132
148
  // Call wakeUp - should detect missing worker and restart it
133
149
  workerProvider.wakeUp();
134
150
 
135
151
  expect(debugSpy).toHaveBeenCalledWith("Waking up workers...");
136
- expect(workerProvider["workersRunning"]).toBe(2);
152
+ expect(workerProvider.workersRunning).toBe(2);
137
153
 
138
154
  await app.stop();
139
155
  });
@@ -150,16 +166,16 @@ describe("WorkerProvider", () => {
150
166
  const app = await createTestApp();
151
167
  app.with(TestService);
152
168
 
153
- const workerProvider = app.inject(WorkerProvider);
169
+ const workerProvider = app.inject(TestWorkerProvider);
154
170
 
155
171
  await app.start();
156
172
 
157
- const oldController = workerProvider["abortController"];
173
+ const oldController = workerProvider.abortController;
158
174
  expect(oldController.signal.aborted).toBe(false);
159
175
 
160
176
  workerProvider.wakeUp();
161
177
 
162
- const newController = workerProvider["abortController"];
178
+ const newController = workerProvider.abortController;
163
179
  expect(oldController.signal.aborted).toBe(true);
164
180
  expect(newController.signal.aborted).toBe(false);
165
181
  expect(newController).not.toBe(oldController);
@@ -217,8 +233,8 @@ describe("WorkerProvider", () => {
217
233
  const app = await createTestApp();
218
234
  app.with(TestService);
219
235
 
220
- const workerProvider = app.inject(WorkerProvider);
221
- const errorSpy = vi.spyOn(workerProvider["log"], "error");
236
+ const workerProvider = app.inject(TestWorkerProvider);
237
+ const errorSpy = vi.spyOn(workerProvider.log, "error");
222
238
 
223
239
  await app.start();
224
240
 
@@ -230,7 +246,7 @@ describe("WorkerProvider", () => {
230
246
  .toBeTruthy();
231
247
 
232
248
  // Worker should still be running after processing error
233
- expect(workerProvider["workersRunning"]).toBe(1);
249
+ expect(workerProvider.workersRunning).toBe(1);
234
250
 
235
251
  await app.stop();
236
252
  });
@@ -282,9 +298,9 @@ describe("WorkerProvider", () => {
282
298
  const app = await createTestApp();
283
299
  app.with(TestService);
284
300
 
285
- const workerProvider = app.inject(WorkerProvider);
301
+ const workerProvider = app.inject(TestWorkerProvider);
286
302
  const queueProvider = app.inject(QueueProvider);
287
- const errorSpy = vi.spyOn(workerProvider["log"], "error");
303
+ const errorSpy = vi.spyOn(workerProvider.log, "error");
288
304
 
289
305
  await app.start();
290
306
 
@@ -301,7 +317,7 @@ describe("WorkerProvider", () => {
301
317
  );
302
318
 
303
319
  // Worker should still be running
304
- expect(workerProvider["workersRunning"]).toBe(1);
320
+ expect(workerProvider.workersRunning).toBe(1);
305
321
 
306
322
  await app.stop();
307
323
  });
@@ -318,9 +334,9 @@ describe("WorkerProvider", () => {
318
334
  const app = await createTestApp();
319
335
  app.with(TestService);
320
336
 
321
- const workerProvider = app.inject(WorkerProvider);
337
+ const workerProvider = app.inject(TestWorkerProvider);
322
338
  const queueProvider = app.inject(QueueProvider);
323
- const errorSpy = vi.spyOn(workerProvider["log"], "error");
339
+ const errorSpy = vi.spyOn(workerProvider.log, "error");
324
340
 
325
341
  await app.start();
326
342
 
@@ -342,7 +358,7 @@ describe("WorkerProvider", () => {
342
358
  );
343
359
 
344
360
  // Worker should still be running
345
- expect(workerProvider["workersRunning"]).toBe(1);
361
+ expect(workerProvider.workersRunning).toBe(1);
346
362
 
347
363
  await app.stop();
348
364
  });
@@ -359,16 +375,16 @@ describe("WorkerProvider", () => {
359
375
  const app = await createTestApp({ workerInterval: 5000 });
360
376
  app.with(TestService);
361
377
 
362
- const workerProvider = app.inject(WorkerProvider);
363
- const warnSpy = vi.spyOn(workerProvider["log"], "warn");
378
+ const workerProvider = app.inject(TestWorkerProvider);
379
+ const warnSpy = vi.spyOn(workerProvider.log, "warn");
364
380
 
365
381
  await app.start();
366
382
 
367
383
  // Abort the controller to simulate abort during wait
368
- workerProvider["abortController"].abort();
384
+ workerProvider.abortController.abort();
369
385
 
370
386
  // This should detect the abort and return early
371
- await workerProvider["waitForNextMessage"](0);
387
+ await workerProvider.waitForNextMessage(0);
372
388
 
373
389
  expect(warnSpy).toHaveBeenCalledWith("Worker n-0 aborted.");
374
390
 
@@ -865,6 +865,189 @@ describe("ServerCacheProvider", () => {
865
865
  });
866
866
  });
867
867
 
868
+ describe("Stream caching support", () => {
869
+ test("should cache ReadableStream responses via tee", async ({
870
+ expect,
871
+ }) => {
872
+ // Create a simple ReadableStream that emits chunks
873
+ const chunks = ["Hello", " ", "World"];
874
+ const stream = new ReadableStream<Uint8Array>({
875
+ start(controller) {
876
+ const encoder = new TextEncoder();
877
+ for (const chunk of chunks) {
878
+ controller.enqueue(encoder.encode(chunk));
879
+ }
880
+ controller.close();
881
+ },
882
+ });
883
+
884
+ // Access the protected method via type assertion
885
+ const provider = cacheProvider as any;
886
+ const key = "test-stream-key";
887
+
888
+ // Collect the stream for cache
889
+ const hash = await provider.collectStreamForCache(
890
+ stream,
891
+ key,
892
+ 200,
893
+ "text/html",
894
+ true,
895
+ );
896
+
897
+ expect(hash).toBeDefined();
898
+ expect(hash).toMatch(/^"[a-f0-9]+"$/); // ETag format
899
+
900
+ // Verify the cache contains the collected content
901
+ const cached = await provider.cache.get(key);
902
+ expect(cached).toBeDefined();
903
+ expect(cached.body).toBe("Hello World");
904
+ expect(cached.status).toBe(200);
905
+ expect(cached.contentType).toBe("text/html");
906
+ });
907
+
908
+ test("should tee stream so client and cache both receive data", async ({
909
+ expect,
910
+ }) => {
911
+ const encoder = new TextEncoder();
912
+ const chunks = ["<html>", "<body>", "Content", "</body>", "</html>"];
913
+
914
+ const originalStream = new ReadableStream<Uint8Array>({
915
+ start(controller) {
916
+ for (const chunk of chunks) {
917
+ controller.enqueue(encoder.encode(chunk));
918
+ }
919
+ controller.close();
920
+ },
921
+ });
922
+
923
+ // Tee the stream like ServerCacheProvider does
924
+ const [clientStream, cacheStream] = originalStream.tee();
925
+
926
+ // Read from client stream (simulates client receiving data)
927
+ const clientReader = clientStream.getReader();
928
+ const clientChunks: string[] = [];
929
+ const decoder = new TextDecoder();
930
+
931
+ while (true) {
932
+ const { done, value } = await clientReader.read();
933
+ if (done) break;
934
+ clientChunks.push(decoder.decode(value, { stream: true }));
935
+ }
936
+ clientChunks.push(decoder.decode()); // flush
937
+
938
+ // Read from cache stream (simulates cache collection)
939
+ const cacheReader = cacheStream.getReader();
940
+ const cacheChunks: string[] = [];
941
+ const cacheDecoder = new TextDecoder();
942
+
943
+ while (true) {
944
+ const { done, value } = await cacheReader.read();
945
+ if (done) break;
946
+ cacheChunks.push(cacheDecoder.decode(value, { stream: true }));
947
+ }
948
+ cacheChunks.push(cacheDecoder.decode()); // flush
949
+
950
+ // Both should receive the same data
951
+ const clientData = clientChunks.join("");
952
+ const cacheData = cacheChunks.join("");
953
+
954
+ expect(clientData).toBe("<html><body>Content</body></html>");
955
+ expect(cacheData).toBe("<html><body>Content</body></html>");
956
+ expect(clientData).toBe(cacheData);
957
+ });
958
+
959
+ test("should handle empty stream gracefully", async ({ expect }) => {
960
+ const stream = new ReadableStream<Uint8Array>({
961
+ start(controller) {
962
+ controller.close();
963
+ },
964
+ });
965
+
966
+ const provider = cacheProvider as any;
967
+ const key = "empty-stream-key";
968
+
969
+ const hash = await provider.collectStreamForCache(
970
+ stream,
971
+ key,
972
+ 200,
973
+ "text/html",
974
+ true,
975
+ );
976
+
977
+ expect(hash).toBeDefined();
978
+
979
+ const cached = await provider.cache.get(key);
980
+ expect(cached).toBeDefined();
981
+ expect(cached.body).toBe("");
982
+ });
983
+
984
+ test("should handle large stream with multiple chunks", async ({
985
+ expect,
986
+ }) => {
987
+ const encoder = new TextEncoder();
988
+ const chunkCount = 100;
989
+ const chunkContent = "x".repeat(1000);
990
+
991
+ const stream = new ReadableStream<Uint8Array>({
992
+ start(controller) {
993
+ for (let i = 0; i < chunkCount; i++) {
994
+ controller.enqueue(encoder.encode(chunkContent));
995
+ }
996
+ controller.close();
997
+ },
998
+ });
999
+
1000
+ const provider = cacheProvider as any;
1001
+ const key = "large-stream-key";
1002
+
1003
+ const hash = await provider.collectStreamForCache(
1004
+ stream,
1005
+ key,
1006
+ 200,
1007
+ "text/html",
1008
+ false,
1009
+ );
1010
+
1011
+ // hash should be undefined when generateEtag is false
1012
+ expect(hash).toBeUndefined();
1013
+
1014
+ const cached = await provider.cache.get(key);
1015
+ expect(cached).toBeDefined();
1016
+ expect(cached.body.length).toBe(chunkCount * chunkContent.length);
1017
+ });
1018
+
1019
+ test("should generate correct ETag for streamed content", async ({
1020
+ expect,
1021
+ }) => {
1022
+ const content = "Test content for ETag";
1023
+ const encoder = new TextEncoder();
1024
+
1025
+ const stream = new ReadableStream<Uint8Array>({
1026
+ start(controller) {
1027
+ controller.enqueue(encoder.encode(content));
1028
+ controller.close();
1029
+ },
1030
+ });
1031
+
1032
+ const provider = cacheProvider as any;
1033
+
1034
+ // Generate ETag from stream
1035
+ const streamHash = await provider.collectStreamForCache(
1036
+ stream,
1037
+ "etag-test-key",
1038
+ 200,
1039
+ "text/html",
1040
+ true,
1041
+ );
1042
+
1043
+ // Generate ETag from string directly
1044
+ const stringHash = cacheProvider.generateETag(content);
1045
+
1046
+ // Both should produce the same ETag
1047
+ expect(streamHash).toBe(stringHash);
1048
+ });
1049
+ });
1050
+
868
1051
  describe("Error response caching", () => {
869
1052
  test("should NOT cache 500 error responses", async ({ expect }) => {
870
1053
  const response1 = await app.errorAction.fetch();
@@ -261,24 +261,55 @@ export class ServerCacheProvider {
261
261
  return;
262
262
  }
263
263
 
264
- // Only process string responses (text, html, json, etc.)
265
- // Buffer is not supported by alepha/cache for now
266
- if (typeof response.body !== "string") {
267
- return;
268
- }
269
-
270
264
  // Don't cache error responses (status >= 400)
271
265
  if (response.status && response.status >= 400) {
272
266
  return;
273
267
  }
274
268
 
269
+ // Initialize headers if not present
270
+ response.headers ??= {};
271
+
275
272
  const key = this.createCacheKey(route, request);
273
+
274
+ // Handle ReadableStream responses (e.g., SSR streaming)
275
+ if (response.body instanceof ReadableStream && shouldStore) {
276
+ // Tee the stream: one for client, one for cache collection
277
+ const [clientStream, cacheStream] = (
278
+ response.body as ReadableStream<Uint8Array>
279
+ ).tee();
280
+
281
+ // Replace response body with client stream (continues streaming to client)
282
+ response.body = clientStream as typeof response.body;
283
+
284
+ // Collect cache stream in background (non-blocking)
285
+ this.collectStreamForCache(
286
+ cacheStream,
287
+ key,
288
+ response.status,
289
+ response.headers?.["content-type"],
290
+ shouldUseEtag,
291
+ )
292
+ .then((hash) => {
293
+ if (shouldUseEtag && hash) {
294
+ // Note: headers already sent for streaming, etag only useful for future requests
295
+ this.log.trace("Stream cached with hash", { key, hash });
296
+ }
297
+ })
298
+ .catch((err) => {
299
+ this.log.warn("Failed to cache stream", { key, error: err });
300
+ });
301
+
302
+ return;
303
+ }
304
+
305
+ // Only process string responses (text, html, json, etc.)
306
+ if (typeof response.body !== "string") {
307
+ return;
308
+ }
309
+
276
310
  const generatedEtag = this.generateETag(response.body);
277
311
  const lastModified = this.time.toISOString();
278
312
 
279
- // Initialize headers if not present
280
- response.headers ??= {};
281
-
282
313
  // Store response if storing is enabled
283
314
  if (shouldStore) {
284
315
  this.log.trace("Storing response", {
@@ -404,6 +435,60 @@ export class ServerCacheProvider {
404
435
 
405
436
  return `${route.method}:${route.path.replaceAll(":", "")}:${params.join(",").replaceAll(":", "")}`;
406
437
  }
438
+
439
+ /**
440
+ * Collect a ReadableStream into a string and store it in the cache.
441
+ * This runs in the background while the original stream is sent to the client.
442
+ *
443
+ * @param stream - The stream to collect
444
+ * @param key - Cache key
445
+ * @param status - HTTP status code
446
+ * @param contentType - Content-Type header
447
+ * @param generateEtag - Whether to generate and return an ETag
448
+ * @returns The generated ETag hash, or undefined
449
+ */
450
+ protected async collectStreamForCache(
451
+ stream: ReadableStream<Uint8Array>,
452
+ key: string,
453
+ status: number | undefined,
454
+ contentType: string | undefined,
455
+ generateEtag: boolean,
456
+ ): Promise<string | undefined> {
457
+ const chunks: Uint8Array[] = [];
458
+ const reader = stream.getReader();
459
+
460
+ try {
461
+ while (true) {
462
+ const { done, value } = await reader.read();
463
+ if (done) break;
464
+ chunks.push(value);
465
+ }
466
+
467
+ // Combine chunks into a single string
468
+ const decoder = new TextDecoder();
469
+ const body =
470
+ chunks
471
+ .map((chunk) => decoder.decode(chunk, { stream: true }))
472
+ .join("") + decoder.decode(); // Flush remaining
473
+
474
+ const hash = this.generateETag(body);
475
+ const lastModified = this.time.toISOString();
476
+
477
+ this.log.trace("Storing streamed response", { key });
478
+
479
+ await this.cache.set(key, {
480
+ body,
481
+ status,
482
+ contentType,
483
+ lastModified,
484
+ hash,
485
+ });
486
+
487
+ return generateEtag ? hash : undefined;
488
+ } finally {
489
+ reader.releaseLock();
490
+ }
491
+ }
407
492
  }
408
493
 
409
494
  export type ServerRouteCache =