@timber-js/app 0.2.0-alpha.37 → 0.2.0-alpha.38

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 (56) hide show
  1. package/dist/adapters/nitro.d.ts.map +1 -1
  2. package/dist/adapters/nitro.js +27 -4
  3. package/dist/adapters/nitro.js.map +1 -1
  4. package/dist/cache/index.d.ts +5 -2
  5. package/dist/cache/index.d.ts.map +1 -1
  6. package/dist/cache/index.js +32 -8
  7. package/dist/cache/index.js.map +1 -1
  8. package/dist/cache/singleflight.d.ts +18 -1
  9. package/dist/cache/singleflight.d.ts.map +1 -1
  10. package/dist/cache/timber-cache.d.ts.map +1 -1
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +53 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/plugins/dev-error-overlay.d.ts +26 -1
  16. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  17. package/dist/plugins/entries.d.ts.map +1 -1
  18. package/dist/server/html-injectors.d.ts +2 -2
  19. package/dist/server/html-injectors.d.ts.map +1 -1
  20. package/dist/server/index.d.ts +2 -0
  21. package/dist/server/index.d.ts.map +1 -1
  22. package/dist/server/index.js +27 -1
  23. package/dist/server/index.js.map +1 -1
  24. package/dist/server/node-stream-transforms.d.ts +13 -1
  25. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  26. package/dist/server/render-timeout.d.ts +51 -0
  27. package/dist/server/render-timeout.d.ts.map +1 -0
  28. package/dist/server/rsc-entry/helpers.d.ts +46 -3
  29. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  30. package/dist/server/rsc-entry/index.d.ts +6 -1
  31. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  32. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  33. package/dist/server/rsc-entry/rsc-stream.d.ts +3 -0
  34. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  35. package/dist/server/ssr-entry.d.ts.map +1 -1
  36. package/dist/server/ssr-render.d.ts +2 -0
  37. package/dist/server/ssr-render.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/adapters/nitro.ts +27 -4
  40. package/src/cache/index.ts +5 -2
  41. package/src/cache/singleflight.ts +54 -4
  42. package/src/cache/timber-cache.ts +17 -16
  43. package/src/index.ts +12 -0
  44. package/src/plugins/dev-error-overlay.ts +70 -1
  45. package/src/plugins/dev-server.ts +38 -4
  46. package/src/plugins/entries.ts +1 -0
  47. package/src/server/html-injectors.ts +32 -7
  48. package/src/server/index.ts +4 -0
  49. package/src/server/node-stream-transforms.ts +49 -13
  50. package/src/server/render-timeout.ts +108 -0
  51. package/src/server/rsc-entry/helpers.ts +122 -3
  52. package/src/server/rsc-entry/index.ts +34 -4
  53. package/src/server/rsc-entry/rsc-payload.ts +11 -3
  54. package/src/server/rsc-entry/rsc-stream.ts +24 -3
  55. package/src/server/ssr-entry.ts +9 -2
  56. package/src/server/ssr-render.ts +94 -13
@@ -42,7 +42,19 @@ export declare function createNodeHeadInjector(headHtml: string): Transform;
42
42
  * stream). We read from it using the Web API — this is the one bridge
43
43
  * point between Web Streams and Node.js streams in the pipeline.
44
44
  */
45
- export declare function createNodeFlightInjector(rscStream: ReadableStream<Uint8Array> | undefined): Transform;
45
+ /**
46
+ * Options for the Node.js flight injector.
47
+ */
48
+ export interface NodeFlightInjectorOptions {
49
+ /**
50
+ * Timeout in milliseconds for individual RSC stream reads.
51
+ * If a single `rscReader.read()` call does not resolve within
52
+ * this duration, the read is aborted and the stream errors with
53
+ * a RenderTimeoutError. Default: 30000 (30s).
54
+ */
55
+ renderTimeoutMs?: number;
56
+ }
57
+ export declare function createNodeFlightInjector(rscStream: ReadableStream<Uint8Array> | undefined, options?: NodeFlightInjectorOptions): Transform;
46
58
  /**
47
59
  * Node.js Transform that catches post-shell streaming errors.
48
60
  *
@@ -1 +1 @@
1
- {"version":3,"file":"node-stream-transforms.d.ts","sourceRoot":"","sources":["../../src/server/node-stream-transforms.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAgBxC;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CA+ClE;AAID;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,GAChD,SAAS,CAkIX;AAOD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAwBtE;AAoBD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,cAAc,EAAE,OAAO,EACvB,eAAe,EAAE,OAAO,GACvB,SAAS,GAAG,IAAI,CA8BlB"}
1
+ {"version":3,"file":"node-stream-transforms.d.ts","sourceRoot":"","sources":["../../src/server/node-stream-transforms.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAiBxC;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CA+ClE;AAID;;;;;;;;;;;;;;;;GAgBG;AACH;;GAEG;AACH,MAAM,WAAW,yBAAyB;IACxC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,EACjD,OAAO,CAAC,EAAE,yBAAyB,GAClC,SAAS,CAuJX;AAOD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAwBtE;AAoBD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,cAAc,EAAE,OAAO,EACvB,eAAe,EAAE,OAAO,GACvB,SAAS,GAAG,IAAI,CA8BlB"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Render timeout utilities for SSR streaming pipeline.
3
+ *
4
+ * Provides a RenderTimeoutError class and a helper to create
5
+ * timeout-guarded AbortSignals. Used to defend against hung RSC
6
+ * streams and infinite SSR renders.
7
+ *
8
+ * Design doc: 02-rendering-pipeline.md §"Streaming Constraints"
9
+ */
10
+ /**
11
+ * Error thrown when an SSR render or RSC stream read exceeds the
12
+ * configured timeout. Callers can check `instanceof RenderTimeoutError`
13
+ * to distinguish timeout from other errors and return a 504 or close
14
+ * the connection cleanly.
15
+ */
16
+ export declare class RenderTimeoutError extends Error {
17
+ readonly timeoutMs: number;
18
+ constructor(timeoutMs: number, context?: string);
19
+ }
20
+ /**
21
+ * Result of createRenderTimeout — an AbortSignal that fires after
22
+ * the given duration, plus a cancel function to clear the timer
23
+ * when the render completes normally.
24
+ */
25
+ export interface RenderTimeout {
26
+ /** AbortSignal that aborts after timeoutMs. */
27
+ signal: AbortSignal;
28
+ /** Cancel the timeout timer. Call this when the render completes. */
29
+ cancel: () => void;
30
+ }
31
+ /**
32
+ * Create a render timeout that aborts after the given duration.
33
+ *
34
+ * Returns an AbortSignal and a cancel function. The signal fires
35
+ * with a RenderTimeoutError as the abort reason after `timeoutMs`.
36
+ * Call `cancel()` when the render completes to prevent the timeout
37
+ * from firing.
38
+ *
39
+ * If an existing `parentSignal` is provided, the returned signal
40
+ * aborts when either the parent signal or the timeout fires —
41
+ * whichever comes first.
42
+ */
43
+ export declare function createRenderTimeout(timeoutMs: number, parentSignal?: AbortSignal): RenderTimeout;
44
+ /**
45
+ * Race a promise against a timeout. Rejects with RenderTimeoutError
46
+ * if the promise does not resolve within `timeoutMs`.
47
+ *
48
+ * Used to guard individual `rscReader.read()` calls inside pullLoop.
49
+ */
50
+ export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, context?: string): Promise<T>;
51
+ //# sourceMappingURL=render-timeout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-timeout.d.ts","sourceRoot":"","sources":["../../src/server/render-timeout.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;gBAEf,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM;CAQhD;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,+CAA+C;IAC/C,MAAM,EAAE,WAAW,CAAC;IACpB,qEAAqE;IACrE,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,WAAW,GAAG,aAAa,CA+BhG;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAC3B,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,CAAC,CAAC,CAWZ"}
@@ -17,14 +17,57 @@ export declare const RSC_CONTENT_TYPE = "text/x-component";
17
17
  * stream that we drain and discard.
18
18
  *
19
19
  * See design/13-security.md §"Server component source leak"
20
- *
21
- * TODO: In the future, expose this debug data to the browser in dev mode
22
- * for inline error overlays (e.g. component stack traces).
23
20
  */
24
21
  export declare function createDebugChannelSink(): {
25
22
  readable: ReadableStream;
26
23
  writable: WritableStream;
27
24
  };
25
+ /**
26
+ * Parsed component debug info extracted from the Flight debug channel.
27
+ *
28
+ * Contains only component names, environment labels, and stack frames —
29
+ * never source code or props. See design/13-security.md §"Server source
30
+ * never reaches the client".
31
+ */
32
+ export interface DebugComponentEntry {
33
+ name: string;
34
+ env: string | null;
35
+ key: string | null;
36
+ stack: unknown[] | null;
37
+ }
38
+ /**
39
+ * A debug channel that collects Flight debug rows instead of discarding them.
40
+ *
41
+ * Used in dev mode to capture server component tree information for the
42
+ * Vite error overlay. The collector provides the same `{ readable, writable }`
43
+ * shape as the discard sink, plus methods to retrieve collected data.
44
+ *
45
+ * Security: only component names, environments, and stack frames are
46
+ * extracted — props and source code are stripped. In production builds,
47
+ * use `createDebugChannelSink()` instead (this function is never called).
48
+ */
49
+ export interface DebugChannelCollector {
50
+ readable: ReadableStream;
51
+ writable: WritableStream;
52
+ /** Get the raw collected text from the debug channel. */
53
+ getCollectedText(): string;
54
+ /** Get parsed component entries (names, stacks — no props or source). */
55
+ getComponents(): DebugComponentEntry[];
56
+ }
57
+ export declare function createDebugChannelCollector(): DebugChannelCollector;
58
+ /**
59
+ * Parse React Flight debug rows into component entries.
60
+ *
61
+ * The Flight debug channel writes rows in `hexId:json\n` format. Each row
62
+ * with a JSON object containing a `name` field is a component debug info
63
+ * entry. Rows without `name` (timing rows, reference rows like `D"$id"`)
64
+ * are skipped.
65
+ *
66
+ * Security: `props` are explicitly stripped from parsed entries — they may
67
+ * contain rendered output or user data. Only `name`, `env`, `key`, and
68
+ * `stack` are retained.
69
+ */
70
+ export declare function parseDebugRows(text: string): DebugComponentEntry[];
28
71
  /**
29
72
  * Build segment metadata for the X-Timber-Segments response header.
30
73
  * Describes the rendered segment chain with async status, enabling
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/helpers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAExD,+DAA+D;AAC/D,eAAO,MAAM,gBAAgB,qBAAqB,CAAC;AAEnD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,IAAI;IAAE,QAAQ,EAAE,cAAc,CAAC;IAAC,QAAQ,EAAE,cAAc,CAAA;CAAE,CAQ/F;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,KAAK,CAAC;IACtB,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B,CAAC,GACD,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CA0B3C;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAGzD;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,cAAc,EACtB,eAAe,EAAE,OAAO,GACvB,QAAQ,CAOV;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIpD;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM9C;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAW1E"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/helpers.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAExD,+DAA+D;AAC/D,eAAO,MAAM,gBAAgB,qBAAqB,CAAC;AAEnD;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,IAAI;IAAE,QAAQ,EAAE,cAAc,CAAC;IAAC,QAAQ,EAAE,cAAc,CAAA;CAAE,CAQ/F;AAID;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,KAAK,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,cAAc,CAAC;IACzB,QAAQ,EAAE,cAAc,CAAC;IACzB,yDAAyD;IACzD,gBAAgB,IAAI,MAAM,CAAC;IAC3B,yEAAyE;IACzE,aAAa,IAAI,mBAAmB,EAAE,CAAC;CACxC;AAED,wBAAgB,2BAA2B,IAAI,qBAAqB,CAkCnE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,EAAE,CAoClE;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,KAAK,CAAC;IACtB,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B,CAAC,GACD,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CA0B3C;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAGzD;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,cAAc,EACtB,eAAe,EAAE,OAAO,GACvB,QAAQ,CAOV;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIpD;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM9C;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAW1E"}
@@ -1,10 +1,15 @@
1
+ import { type DebugComponentEntry } from './helpers.js';
1
2
  /**
2
3
  * Set the dev pipeline error handler.
3
4
  *
4
5
  * Called by the dev server after importing this module to wire pipeline
5
6
  * errors into the Vite browser error overlay. No-op in production.
7
+ *
8
+ * The handler receives an optional third argument with RSC debug component
9
+ * info — component names, environments, and stack frames from the Flight
10
+ * debug channel. This is only populated for render-phase errors.
6
11
  */
7
- export declare function setDevPipelineErrorHandler(handler: (error: Error, phase: string) => void): void;
12
+ export declare function setDevPipelineErrorHandler(handler: (error: Error, phase: string, debugComponents?: DebugComponentEntry[]) => void): void;
8
13
  export { runWithEarlyHintsSender } from '#/server/early-hints-sender.js';
9
14
  export { runWithWaitUntil } from '#/server/waituntil-bridge.js';
10
15
  declare const _default: (req: Request) => Promise<Response>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA+FA;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAE/F;AA6aD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;8BA5R3C,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA8RhD,wBAAiE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA8DA,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAiCtB;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,mBAAmB,EAAE,KAAK,IAAI,GACtF,IAAI,CAEN;AA+bD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;8BAhS3C,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AAkShD,wBAAiE"}
@@ -1 +1 @@
1
- {"version":3,"file":"rsc-payload.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/rsc-payload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,KAAK,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC3F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAQrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,OAAO,EAAE,aAAa,EACtB,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,YAAY,EAAE,WAAW,EAAE,EAC3B,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,eAAe,CAAC,EAAE,MAAM,EAAE,GACzB,OAAO,CAAC,QAAQ,CAAC,CA0HnB"}
1
+ {"version":3,"file":"rsc-payload.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/rsc-payload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,KAAK,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC3F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAQrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,OAAO,EAAE,aAAa,EACtB,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,YAAY,EAAE,WAAW,EAAE,EAC3B,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,eAAe,CAAC,EAAE,MAAM,EAAE,GACzB,OAAO,CAAC,QAAQ,CAAC,CAkInB"}
@@ -10,6 +10,7 @@
10
10
  * 13-security.md §"Errors don't leak"
11
11
  */
12
12
  import { DenySignal, RedirectSignal } from '#/server/primitives.js';
13
+ import { type DebugComponentEntry } from './helpers.js';
13
14
  /**
14
15
  * Mutable signal state captured during RSC rendering.
15
16
  *
@@ -33,6 +34,8 @@ export interface RenderSignals {
33
34
  export interface RscStreamResult {
34
35
  rscStream: ReadableStream<Uint8Array> | undefined;
35
36
  signals: RenderSignals;
37
+ /** Dev-only: server component debug info from the Flight debug channel. */
38
+ getDebugComponents?: () => DebugComponentEntry[];
36
39
  }
37
40
  /**
38
41
  * Render a React element tree to an RSC Flight stream.
@@ -1 +1 @@
1
- {"version":3,"file":"rsc-stream.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/rsc-stream.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,EAAE,UAAU,EAAE,cAAc,EAAe,MAAM,wBAAwB,CAAC;AAMjF;;;;;;;;;GASG;AACH,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,UAAU,GAAG,IAAI,CAAC;IAC9B,cAAc,EAAE,cAAc,GAAG,IAAI,CAAC;IACtC,WAAW,EAAE;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACvD,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,CAAC;IAClD,OAAO,EAAE,aAAa,CAAC;CACxB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,KAAK,CAAC,YAAY,EAAE,GAAG,EAAE,OAAO,GAAG,eAAe,CA4G1F"}
1
+ {"version":3,"file":"rsc-stream.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/rsc-stream.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,EAAE,UAAU,EAAE,cAAc,EAAe,MAAM,wBAAwB,CAAC;AAGjF,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAItB;;;;;;;;;GASG;AACH,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,UAAU,GAAG,IAAI,CAAC;IAC9B,cAAc,EAAE,cAAc,GAAG,IAAI,CAAC;IACtC,WAAW,EAAE;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACvD,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,CAAC;IAClD,OAAO,EAAE,aAAa,CAAC;IACvB,2EAA2E;IAC3E,kBAAkB,CAAC,EAAE,MAAM,mBAAmB,EAAE,CAAC;CAClD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,KAAK,CAAC,YAAY,EAAE,GAAG,EAAE,OAAO,GAAG,eAAe,CAyH1F"}
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA0EH;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,iCAAiC;IACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,eAAe,EAAE,OAAO,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACvC;;;0DAGsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;iFAE6E;IAC7E,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;4DACwD;IACxD,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAEjC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE;QACZ,gFAAgF;QAChF,QAAQ,EAAE,MAAM,CAAC;QACjB,uDAAuD;QACvD,OAAO,EAAE,MAAM,CAAC;QAChB,gEAAgE;QAChE,MAAM,EAAE,MAAM,CAAC;QACf,wEAAwE;QACxE,UAAU,EAAE,MAAM,CAAC;QACnB,+CAA+C;QAC/C,OAAO,EAAE,MAAM,CAAC;QAChB,sDAAsD;QACtD,WAAW,EAAE,OAAO,CAAC;KACtB,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,QAAQ,CAAC,CAyJnB;AAED,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA0EH;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,iCAAiC;IACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,eAAe,EAAE,OAAO,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACvC;;;0DAGsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;iFAE6E;IAC7E,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;4DACwD;IACxD,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAEjC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE;QACZ,gFAAgF;QAChF,QAAQ,EAAE,MAAM,CAAC;QACjB,uDAAuD;QACvD,OAAO,EAAE,MAAM,CAAC;QAChB,gEAAgE;QAChE,MAAM,EAAE,MAAM,CAAC;QACf,wEAAwE;QACxE,UAAU,EAAE,MAAM,CAAC;QACnB,+CAA+C;QAC/C,OAAO,EAAE,MAAM,CAAC;QAChB,sDAAsD;QACtD,WAAW,EAAE,OAAO,CAAC;KACtB,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,QAAQ,CAAC,CAgKnB;AAED,eAAe,SAAS,CAAC"}
@@ -38,6 +38,7 @@ export declare function renderSsrStream(element: ReactNode, options?: {
38
38
  bootstrapScriptContent?: string;
39
39
  deferSuspenseFor?: number;
40
40
  signal?: AbortSignal;
41
+ renderTimeoutMs?: number;
41
42
  }): Promise<ReadableStream<Uint8Array>>;
42
43
  /** Whether the current platform uses native Node.js streams for SSR. */
43
44
  export declare const useNodeStreams: boolean;
@@ -52,6 +53,7 @@ export declare function renderSsrNodeStream(element: ReactNode, options?: {
52
53
  bootstrapScriptContent?: string;
53
54
  deferSuspenseFor?: number;
54
55
  signal?: AbortSignal;
56
+ renderTimeoutMs?: number;
55
57
  }): Promise<import('node:stream').Readable>;
56
58
  /** Convert a Node.js Readable to a Web ReadableStream (zero-copy bridge). */
57
59
  export declare function nodeReadableToWeb(readable: import('node:stream').Readable): ReadableStream<Uint8Array>;
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-render.d.ts","sourceRoot":"","sources":["../../src/server/ssr-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAqEvC;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IAAE,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC7F,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAErC;AAED,wEAAwE;AACxE,eAAO,MAAM,cAAc,SAAkB,CAAC;AAU9C;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IAAE,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC7F,OAAO,CAAC,OAAO,aAAa,EAAE,QAAQ,CAAC,CAkDzC;AAED,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,OAAO,aAAa,EAAE,QAAQ,GACvC,cAAc,CAAC,UAAU,CAAC,CAE5B;AA0CD;;;;;;;;;;;;GAYG;AACH,2CAA2C;AAC3C,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,MAAM,CAAC,EAAE,WAAW,GACnB,cAAc,CAAC,UAAU,CAAC,CA2B5B;AAWD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,cAAc,CAAC,UAAU,CAAC,EACtC,UAAU,EAAE,MAAM,EAClB,eAAe,EAAE,OAAO,GACvB,QAAQ,CASV"}
1
+ {"version":3,"file":"ssr-render.d.ts","sourceRoot":"","sources":["../../src/server/ssr-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAsEvC;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IACR,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,GACA,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAErC;AAED,wEAAwE;AACxE,eAAO,MAAM,cAAc,SAAkB,CAAC;AAU9C;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IACR,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,GACA,OAAO,CAAC,OAAO,aAAa,EAAE,QAAQ,CAAC,CAkFzC;AAED,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,OAAO,aAAa,EAAE,QAAQ,GACvC,cAAc,CAAC,UAAU,CAAC,CAE5B;AAgFD;;;;;;;;;;;;GAYG;AACH,2CAA2C;AAC3C,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,MAAM,CAAC,EAAE,WAAW,GACnB,cAAc,CAAC,UAAU,CAAC,CA2B5B;AAWD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,cAAc,CAAC,UAAU,CAAC,EACtC,UAAU,EAAE,MAAM,EAClB,eAAe,EAAE,OAAO,GACvB,QAAQ,CASV"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.37",
3
+ "version": "0.2.0-alpha.38",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -565,14 +565,37 @@ const server = createServer(async (req, res) => {
565
565
 
566
566
  if (webResponse.body) {
567
567
  const reader = webResponse.body.getReader();
568
- const pump = async () => {
568
+
569
+ // Cancel the reader when the client disconnects. This causes any
570
+ // pending reader.read() to reject, breaking the pump loop. Critical
571
+ // for SSE and other infinite streams — without this, disconnected
572
+ // clients leak readers.
573
+ let clientDisconnected = false;
574
+ const onClose = () => {
575
+ clientDisconnected = true;
576
+ reader.cancel('Client disconnected').catch(() => {});
577
+ };
578
+ res.on('close', onClose);
579
+
580
+ try {
569
581
  while (true) {
570
582
  const { done, value } = await reader.read();
571
- if (done) { res.end(); return; }
583
+ if (done) break;
572
584
  res.write(value);
573
585
  }
574
- };
575
- await pump();
586
+ } catch (err) {
587
+ // reader.cancel() from the close handler causes read() to reject.
588
+ // This is expected on client disconnect — not an error.
589
+ if (!clientDisconnected) {
590
+ throw err;
591
+ }
592
+ } finally {
593
+ res.off('close', onClose);
594
+ reader.releaseLock();
595
+ if (!res.writableEnded) {
596
+ res.end();
597
+ }
598
+ }
576
599
  } else {
577
600
  res.end();
578
601
  }
@@ -12,6 +12,9 @@ export interface CacheOptions<Fn extends (...args: any[]) => any> {
12
12
  key?: (...args: Parameters<Fn>) => string;
13
13
  staleWhileRevalidate?: boolean;
14
14
  tags?: string[] | ((...args: Parameters<Fn>) => string[]);
15
+ /** Timeout (ms) for singleflight-coalesced calls. Prevents hung fn() from
16
+ * permanently blocking all future callers for the same cache key. See TIM-518. */
17
+ timeoutMs?: number;
15
18
  }
16
19
 
17
20
  export interface MemoryCacheHandlerOptions {
@@ -87,5 +90,5 @@ export { createCache } from './timber-cache';
87
90
  export { registerCachedFunction } from './register-cached-function';
88
91
  export type { RegisterCachedFunctionOptions } from './register-cached-function';
89
92
  export { stableStringify } from './stable-stringify';
90
- export { createSingleflight } from './singleflight';
91
- export type { Singleflight } from './singleflight';
93
+ export { createSingleflight, SingleflightTimeoutError } from './singleflight';
94
+ export type { Singleflight, SingleflightOptions } from './singleflight';
@@ -3,24 +3,74 @@
3
3
  * execution. All callers receive the same result (or error).
4
4
  *
5
5
  * Per-process, in-memory. Each process coalesces independently.
6
+ *
7
+ * An optional `timeoutMs` prevents hung `fn()` calls from permanently
8
+ * blocking all future callers for that key. When set, `fn()` is raced
9
+ * against a timeout — if the timeout fires first, the promise rejects
10
+ * with `SingleflightTimeoutError`, `finally` cleans up the key, and
11
+ * subsequent callers can retry. See TIM-518.
6
12
  */
13
+
14
+ export interface SingleflightOptions {
15
+ /** Maximum time (ms) a coalesced call may run before being rejected. */
16
+ timeoutMs?: number;
17
+ }
18
+
7
19
  export interface Singleflight {
8
20
  do<T>(key: string, fn: () => Promise<T>): Promise<T>;
9
21
  }
10
22
 
11
- export function createSingleflight(): Singleflight {
23
+ /**
24
+ * Error thrown when a singleflight call exceeds `timeoutMs`.
25
+ * Exported so callers can distinguish timeout from other errors.
26
+ */
27
+ export class SingleflightTimeoutError extends Error {
28
+ constructor(key: string, timeoutMs: number) {
29
+ super(`Singleflight timeout: key "${key}" exceeded ${timeoutMs}ms`);
30
+ this.name = 'SingleflightTimeoutError';
31
+ }
32
+ }
33
+
34
+ export function createSingleflight(opts?: SingleflightOptions): Singleflight {
12
35
  const inflight = new Map<string, Promise<unknown>>();
36
+ const timeoutMs = opts?.timeoutMs;
13
37
 
14
38
  return {
15
39
  do<T>(key: string, fn: () => Promise<T>): Promise<T> {
16
40
  const existing = inflight.get(key);
17
41
  if (existing) return existing as Promise<T>;
18
42
 
19
- const promise = fn().finally(() => {
43
+ let promise: Promise<T>;
44
+
45
+ if (timeoutMs != null && timeoutMs > 0) {
46
+ // Race fn() against a timeout to prevent hung calls from
47
+ // permanently blocking the key. See TIM-518.
48
+ promise = new Promise<T>((resolve, reject) => {
49
+ const timer = setTimeout(
50
+ () => reject(new SingleflightTimeoutError(key, timeoutMs)),
51
+ timeoutMs
52
+ );
53
+ fn().then(
54
+ (value) => {
55
+ clearTimeout(timer);
56
+ resolve(value);
57
+ },
58
+ (err) => {
59
+ clearTimeout(timer);
60
+ reject(err);
61
+ }
62
+ );
63
+ });
64
+ } else {
65
+ promise = fn();
66
+ }
67
+
68
+ const tracked = promise.finally(() => {
20
69
  inflight.delete(key);
21
70
  });
22
- inflight.set(key, promise);
23
- return promise;
71
+
72
+ inflight.set(key, tracked);
73
+ return tracked as Promise<T>;
24
74
  },
25
75
  };
26
76
  }
@@ -4,7 +4,7 @@ import { createSingleflight } from './singleflight';
4
4
  import { addSpanEventSync } from '#/server/tracing.js';
5
5
  import { fnv1aHash } from './fast-hash.js';
6
6
 
7
- const singleflight = createSingleflight();
7
+ const defaultSingleflight = createSingleflight();
8
8
 
9
9
  /**
10
10
  * Generate a cache key from function identity and serialized args.
@@ -55,6 +55,9 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
55
55
  handler: CacheHandler
56
56
  ): (...args: Parameters<Fn>) => Promise<Awaited<ReturnType<Fn>>> {
57
57
  const fnId = `timber-cache:${fnIdCounter++}`;
58
+ const sf = opts.timeoutMs
59
+ ? createSingleflight({ timeoutMs: opts.timeoutMs })
60
+ : defaultSingleflight;
58
61
 
59
62
  return async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {
60
63
  const key = opts.key ? opts.key(...args) : defaultKeyGenerator(fnId, args);
@@ -80,25 +83,23 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
80
83
  stale: true,
81
84
  });
82
85
  // Serve stale immediately, trigger background refetch
83
- singleflight
84
- .do(`swr:${key}`, async () => {
85
- try {
86
- const fresh = await fn(...args);
87
- const tags = resolveTags(opts, args);
88
- await handler.set(key, fresh, { ttl: opts.ttl, tags });
89
- } catch {
90
- // Failed refetch stale entry continues to be served.
91
- // Error is swallowed per design doc: "Error is logged."
92
- }
93
- })
94
- .catch(() => {
95
- // Singleflight promise rejection handled — stale continues.
96
- });
86
+ sf.do(`swr:${key}`, async () => {
87
+ try {
88
+ const fresh = await fn(...args);
89
+ const tags = resolveTags(opts, args);
90
+ await handler.set(key, fresh, { ttl: opts.ttl, tags });
91
+ } catch {
92
+ // Failed refetch — stale entry continues to be served.
93
+ // Error is swallowed per design doc: "Error is logged."
94
+ }
95
+ }).catch(() => {
96
+ // Singleflight promise rejection handled — stale continues.
97
+ });
97
98
  return cached.value as Awaited<ReturnType<Fn>>;
98
99
  }
99
100
 
100
101
  // Cache miss (or stale without SWR) — execute with singleflight
101
- const result = await singleflight.do(key, () => fn(...args));
102
+ const result = await sf.do(key, () => fn(...args));
102
103
  const tags = resolveTags(opts, args);
103
104
  await handler.set(key, result, { ttl: opts.ttl, tags });
104
105
 
package/src/index.ts CHANGED
@@ -90,6 +90,18 @@ export interface TimberUserConfig {
90
90
  * See design/17-logging.md §"slowRequestMs".
91
91
  */
92
92
  slowRequestMs?: number;
93
+ /**
94
+ * Render timeout in milliseconds. If an SSR render or RSC stream read
95
+ * does not complete within this duration, the render is aborted and
96
+ * the connection is closed. Protects against hung fetches and Suspense
97
+ * components that never resolve.
98
+ *
99
+ * Set to 0 to disable (not recommended in production).
100
+ * Default: 30000 (30 seconds).
101
+ *
102
+ * See design/02-rendering-pipeline.md §"Streaming Constraints".
103
+ */
104
+ renderTimeoutMs?: number;
93
105
  /** Dev-mode options. These have no effect in production builds. */
94
106
  dev?: {
95
107
  /** Threshold in ms to highlight slow phases in dev logging output. Default: 200. */
@@ -180,6 +180,63 @@ export function formatTerminalError(error: Error, phase: ErrorPhase, projectRoot
180
180
  return lines.join('\n');
181
181
  }
182
182
 
183
+ // ─── RSC Debug Context ──────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Component info extracted from the RSC debug channel.
187
+ * Contains only names, environments, and stack frames — never source code.
188
+ */
189
+ export interface RscDebugComponentInfo {
190
+ name: string;
191
+ env: string | null;
192
+ stack: unknown[] | null;
193
+ }
194
+
195
+ /**
196
+ * Format RSC debug component info into a readable string for the overlay.
197
+ *
198
+ * Renders the server component tree that was active when an error occurred,
199
+ * including component names and source locations from stack frames. This
200
+ * gives developers visibility into which server components were rendering
201
+ * without exposing source code.
202
+ *
203
+ * Returns an empty string if no components are provided.
204
+ */
205
+ export function formatRscDebugContext(components: RscDebugComponentInfo[]): string {
206
+ if (!components || components.length === 0) return '';
207
+
208
+ // Deduplicate by name — the debug channel may emit the same component
209
+ // multiple times (e.g., when re-rendered or when multiple instances exist).
210
+ const seen = new Set<string>();
211
+ const unique: RscDebugComponentInfo[] = [];
212
+ for (const c of components) {
213
+ if (!seen.has(c.name)) {
214
+ seen.add(c.name);
215
+ unique.push(c);
216
+ }
217
+ }
218
+
219
+ const lines: string[] = ['Server Component Tree:'];
220
+ for (let i = 0; i < unique.length; i++) {
221
+ const c = unique[i]!;
222
+ const indent = ' '.repeat(i + 1);
223
+ const envLabel = c.env ? ` [${c.env}]` : '';
224
+
225
+ // Extract file location from stack frames if available
226
+ let locStr = '';
227
+ if (c.stack && c.stack.length > 0) {
228
+ const frame = c.stack[0] as [string, string, number, number] | undefined;
229
+ if (Array.isArray(frame) && frame.length >= 3) {
230
+ locStr = ` (${frame[1]}:${frame[2]})`;
231
+ }
232
+ }
233
+
234
+ lines.push(`${indent}${c.name}${envLabel}${locStr}`);
235
+ }
236
+
237
+ return lines.join('\n');
238
+ }
239
+
183
240
  // ─── Overlay Integration ────────────────────────────────────────────────────
184
241
 
185
242
  /**
@@ -188,13 +245,19 @@ export function formatTerminalError(error: Error, phase: ErrorPhase, projectRoot
188
245
  * Uses `server.ssrFixStacktrace()` to map stack traces back to source,
189
246
  * then sends the error via `server.hot.send()` for the browser overlay.
190
247
  *
248
+ * When `rscDebugComponents` is provided (dev mode only), the server
249
+ * component tree context is appended to the error message. This helps
250
+ * developers identify which server component caused the error without
251
+ * exposing source code.
252
+ *
191
253
  * The dev server remains running — errors are handled, not fatal.
192
254
  */
193
255
  export function sendErrorToOverlay(
194
256
  server: ViteDevServer,
195
257
  error: Error,
196
258
  phase: ErrorPhase,
197
- projectRoot: string
259
+ projectRoot: string,
260
+ rscDebugComponents?: RscDebugComponentInfo[]
198
261
  ): void {
199
262
  // Fix stack trace to use source-mapped positions
200
263
  server.ssrFixStacktrace(error);
@@ -212,6 +275,12 @@ export function sendErrorToOverlay(
212
275
  message = `${error.message}\n\nComponent Stack:\n${componentStack.trim()}`;
213
276
  }
214
277
 
278
+ // Append RSC debug context if available (dev mode only)
279
+ const debugContext = formatRscDebugContext(rscDebugComponents ?? []);
280
+ if (debugContext) {
281
+ message = `${message}\n\n${debugContext}`;
282
+ }
283
+
215
284
  // Send to browser via Vite's error overlay protocol
216
285
  try {
217
286
  server.hot.send({
@@ -161,12 +161,26 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
161
161
 
162
162
  // Wire pipeline errors into the browser error overlay.
163
163
  // setDevPipelineErrorHandler is only defined in dev (rsc-entry.ts exports it).
164
+ // The handler receives optional RSC debug component data (component names,
165
+ // environments, stack frames) from the Flight debug channel for render errors.
164
166
  const setHandler = rscModule.setDevPipelineErrorHandler as
165
- | ((fn: (error: Error, phase: string) => void) => void)
167
+ | ((
168
+ fn: (
169
+ error: Error,
170
+ phase: string,
171
+ debugComponents?: Array<{ name: string; env: string | null; stack: unknown[] | null }>
172
+ ) => void
173
+ ) => void)
166
174
  | undefined;
167
175
  if (typeof setHandler === 'function') {
168
- setHandler((error) => {
169
- sendErrorToOverlay(server, error, classifyErrorPhase(error, projectRoot), projectRoot);
176
+ setHandler((error, _phase, debugComponents) => {
177
+ sendErrorToOverlay(
178
+ server,
179
+ error,
180
+ classifyErrorPhase(error, projectRoot),
181
+ projectRoot,
182
+ debugComponents
183
+ );
170
184
  });
171
185
  }
172
186
  } catch (error) {
@@ -317,6 +331,17 @@ async function sendWebResponse(nodeRes: ServerResponse, webResponse: Response):
317
331
  nodeRes.flushHeaders();
318
332
 
319
333
  const reader = webResponse.body.getReader();
334
+
335
+ // Cancel the reader when the client disconnects. This causes any pending
336
+ // reader.read() to reject, breaking the pump loop. Critical for SSE and
337
+ // other infinite streams — without this, disconnected clients leak readers.
338
+ let clientDisconnected = false;
339
+ const onClose = () => {
340
+ clientDisconnected = true;
341
+ reader.cancel('Client disconnected').catch(() => {});
342
+ };
343
+ nodeRes.on('close', onClose);
344
+
320
345
  try {
321
346
  while (true) {
322
347
  const { done, value } = await reader.read();
@@ -325,9 +350,18 @@ async function sendWebResponse(nodeRes: ServerResponse, webResponse: Response):
325
350
  // don't need back-pressure here — just keep pushing chunks.
326
351
  nodeRes.write(value);
327
352
  }
353
+ } catch (err) {
354
+ // reader.cancel() from the close handler causes read() to reject.
355
+ // This is expected on client disconnect — not an error.
356
+ if (!clientDisconnected) {
357
+ throw err;
358
+ }
328
359
  } finally {
360
+ nodeRes.off('close', onClose);
329
361
  reader.releaseLock();
330
- nodeRes.end();
362
+ if (!nodeRes.writableEnded) {
363
+ nodeRes.end();
364
+ }
331
365
  }
332
366
  }
333
367
 
@@ -106,6 +106,7 @@ function generateConfigModule(ctx: PluginContext): string {
106
106
  topLoader: ctx.config.topLoader,
107
107
  debug: ctx.config.debug ?? false,
108
108
  serverTiming: ctx.config.serverTiming,
109
+ renderTimeoutMs: ctx.config.renderTimeoutMs ?? 30_000,
109
110
  // Per-build deployment ID for version skew detection (TIM-446).
110
111
  // Null in dev mode — HMR handles code updates without full reloads.
111
112
  deploymentId: ctx.deploymentId ?? null,