@timber-js/app 0.2.0-alpha.34 → 0.2.0-alpha.35
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.
- package/dist/_chunks/{als-registry-B7DbZ2hS.js → als-registry-Ba7URUIn.js} +1 -1
- package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -0
- package/dist/_chunks/chunk-DYhsFzuS.js +33 -0
- package/dist/_chunks/{debug-B3Gypr3D.js → debug-ECi_61pb.js} +1 -1
- package/dist/_chunks/{debug-B3Gypr3D.js.map → debug-ECi_61pb.js.map} +1 -1
- package/dist/_chunks/define-cookie-w5GWm_bL.js +93 -0
- package/dist/_chunks/define-cookie-w5GWm_bL.js.map +1 -0
- package/dist/_chunks/error-boundary-TYEQJZ1-.js +211 -0
- package/dist/_chunks/error-boundary-TYEQJZ1-.js.map +1 -0
- package/dist/_chunks/{format-RyoGQL74.js → format-cX7wzEp2.js} +2 -2
- package/dist/_chunks/{format-RyoGQL74.js.map → format-cX7wzEp2.js.map} +1 -1
- package/dist/_chunks/{interception-BOoWmLUA.js → interception-D2djYaIm.js} +112 -77
- package/dist/_chunks/interception-D2djYaIm.js.map +1 -0
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-BU684ls2.js} +1 -1
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js.map → metadata-routes-BU684ls2.js.map} +1 -1
- package/dist/_chunks/{request-context-BQUC8PHn.js → request-context-CZz_T0Bc.js} +40 -71
- package/dist/_chunks/request-context-CZz_T0Bc.js.map +1 -0
- package/dist/_chunks/segment-context-Dpq2XOKg.js +34 -0
- package/dist/_chunks/segment-context-Dpq2XOKg.js.map +1 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js +47 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js.map +1 -0
- package/dist/_chunks/{tracing-CemImE6h.js → tracing-BPyIzIdu.js} +2 -2
- package/dist/_chunks/{tracing-CemImE6h.js.map → tracing-BPyIzIdu.js.map} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js → use-query-states-BvW0TKDn.js} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js.map → use-query-states-BvW0TKDn.js.map} +1 -1
- package/dist/_chunks/wrappers-C1SN725w.js +331 -0
- package/dist/_chunks/wrappers-C1SN725w.js.map +1 -0
- package/dist/cache/index.js +1 -1
- package/dist/client/error-boundary.d.ts +10 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -125
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +193 -90
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +8 -8
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +2 -2
- package/dist/client/router.d.ts +25 -3
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +23 -2
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/stale-reload.d.ts +15 -0
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/use-params.d.ts +2 -2
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts +1 -1
- package/dist/codec.d.ts +21 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/cookies/define-cookie.d.ts +33 -12
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -81
- package/dist/index.d.ts +87 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +346 -210
- package/dist/index.js.map +1 -1
- package/dist/params/define.d.ts +76 -0
- package/dist/params/define.d.ts.map +1 -0
- package/dist/params/index.d.ts +8 -0
- package/dist/params/index.d.ts.map +1 -0
- package/dist/params/index.js +104 -0
- package/dist/params/index.js.map +1 -0
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts +32 -0
- package/dist/plugins/client-chunks.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts +2 -2
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/status-file-lint.d.ts +2 -1
- package/dist/routing/status-file-lint.d.ts.map +1 -1
- package/dist/routing/types.d.ts +6 -4
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/search-params/codecs.d.ts +1 -1
- package/dist/search-params/define.d.ts +153 -0
- package/dist/search-params/define.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +4 -5
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +3 -474
- package/dist/search-params/registry.d.ts +1 -1
- package/dist/search-params/wrappers.d.ts +53 -0
- package/dist/search-params/wrappers.d.ts.map +1 -0
- package/dist/server/access-gate.d.ts +4 -0
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-encryption.d.ts +76 -0
- package/dist/server/action-encryption.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +4 -4
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +2 -2
- package/dist/server/early-hints.d.ts +13 -5
- package/dist/server/early-hints.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +4 -0
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/flight-injection-state.d.ts +78 -0
- package/dist/server/flight-injection-state.d.ts.map +1 -0
- package/dist/server/form-data.d.ts +29 -0
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1819 -1629
- package/dist/server/index.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +28 -40
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +2 -2
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +3 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +12 -8
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/server/types.d.ts +1 -3
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/version-skew.d.ts +61 -0
- package/dist/server/version-skew.d.ts.map +1 -0
- package/dist/shims/navigation-client.d.ts +1 -1
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/dist/shims/navigation.d.ts +1 -1
- package/dist/shims/navigation.d.ts.map +1 -1
- package/dist/utils/state-machine.d.ts +80 -0
- package/dist/utils/state-machine.d.ts.map +1 -0
- package/package.json +12 -8
- package/src/client/browser-entry.ts +55 -13
- package/src/client/error-boundary.tsx +18 -1
- package/src/client/index.ts +9 -1
- package/src/client/link.tsx +9 -9
- package/src/client/navigation-context.ts +2 -2
- package/src/client/router.ts +102 -55
- package/src/client/rsc-fetch.ts +63 -2
- package/src/client/segment-cache.ts +1 -1
- package/src/client/stale-reload.ts +28 -0
- package/src/client/top-loader.tsx +2 -2
- package/src/client/use-params.ts +3 -3
- package/src/client/use-query-states.ts +1 -1
- package/src/codec.ts +21 -0
- package/src/cookies/define-cookie.ts +69 -18
- package/src/index.ts +255 -65
- package/src/params/define.ts +260 -0
- package/src/params/index.ts +28 -0
- package/src/plugins/adapter-build.ts +6 -0
- package/src/plugins/build-manifest.ts +11 -0
- package/src/plugins/client-chunks.ts +65 -0
- package/src/plugins/entries.ts +3 -6
- package/src/plugins/routing.ts +40 -14
- package/src/plugins/server-bundle.ts +32 -1
- package/src/plugins/shims.ts +1 -1
- package/src/plugins/static-build.ts +8 -4
- package/src/routing/codegen.ts +109 -88
- package/src/routing/scanner.ts +55 -6
- package/src/routing/status-file-lint.ts +2 -1
- package/src/routing/types.ts +7 -4
- package/src/rsc-runtime/rsc.ts +2 -0
- package/src/search-params/codecs.ts +1 -1
- package/src/search-params/define.ts +504 -0
- package/src/search-params/index.ts +12 -18
- package/src/search-params/registry.ts +1 -1
- package/src/search-params/wrappers.ts +85 -0
- package/src/server/access-gate.tsx +38 -8
- package/src/server/action-encryption.ts +144 -0
- package/src/server/action-handler.ts +16 -0
- package/src/server/als-registry.ts +4 -4
- package/src/server/build-manifest.ts +4 -4
- package/src/server/early-hints.ts +36 -15
- package/src/server/error-boundary-wrapper.ts +57 -14
- package/src/server/flight-injection-state.ts +152 -0
- package/src/server/form-data.ts +76 -0
- package/src/server/html-injectors.ts +42 -26
- package/src/server/index.ts +2 -4
- package/src/server/node-stream-transforms.ts +68 -41
- package/src/server/pipeline.ts +98 -26
- package/src/server/request-context.ts +49 -124
- package/src/server/route-element-builder.ts +102 -99
- package/src/server/route-matcher.ts +2 -2
- package/src/server/rsc-entry/error-renderer.ts +3 -2
- package/src/server/rsc-entry/index.ts +26 -11
- package/src/server/rsc-entry/rsc-payload.ts +2 -2
- package/src/server/rsc-entry/ssr-renderer.ts +4 -4
- package/src/server/slot-resolver.ts +204 -206
- package/src/server/ssr-entry.ts +3 -1
- package/src/server/ssr-render.ts +3 -0
- package/src/server/tree-builder.ts +84 -48
- package/src/server/types.ts +1 -3
- package/src/server/version-skew.ts +104 -0
- package/src/shims/navigation-client.ts +1 -1
- package/src/shims/navigation.ts +1 -1
- package/src/utils/state-machine.ts +111 -0
- package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
- package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
- package/dist/_chunks/request-context-BQUC8PHn.js.map +0 -1
- package/dist/_chunks/ssr-data-MjmprTmO.js +0 -88
- package/dist/_chunks/ssr-data-MjmprTmO.js.map +0 -1
- package/dist/_chunks/use-cookie-DX-l1_5E.js +0 -91
- package/dist/_chunks/use-cookie-DX-l1_5E.js.map +0 -1
- package/dist/client/error-boundary.js.map +0 -1
- package/dist/cookies/index.js.map +0 -1
- package/dist/plugins/dynamic-transform.d.ts +0 -72
- package/dist/plugins/dynamic-transform.d.ts.map +0 -1
- package/dist/search-params/analyze.d.ts +0 -54
- package/dist/search-params/analyze.d.ts.map +0 -1
- package/dist/search-params/builtin-codecs.d.ts +0 -105
- package/dist/search-params/builtin-codecs.d.ts.map +0 -1
- package/dist/search-params/create.d.ts +0 -106
- package/dist/search-params/create.d.ts.map +0 -1
- package/dist/search-params/index.js.map +0 -1
- package/dist/server/prerender.d.ts +0 -77
- package/dist/server/prerender.d.ts.map +0 -1
- package/src/plugins/dynamic-transform.ts +0 -161
- package/src/search-params/analyze.ts +0 -192
- package/src/search-params/builtin-codecs.ts +0 -228
- package/src/search-params/create.ts +0 -321
- package/src/server/prerender.ts +0 -139
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared state machine types and transitions for RSC Flight injection.
|
|
3
|
+
*
|
|
4
|
+
* Both html-injectors.ts (Web Streams) and node-stream-transforms.ts
|
|
5
|
+
* (Node.js streams) implement identical state transitions — only the
|
|
6
|
+
* I/O primitives differ. This module defines the discriminated union
|
|
7
|
+
* states and the transition map once.
|
|
8
|
+
*
|
|
9
|
+
* Valid state flow:
|
|
10
|
+
* init → streaming → body-level → flushing → done
|
|
11
|
+
* └──────────────→ flushing → done
|
|
12
|
+
* (any) → error
|
|
13
|
+
*
|
|
14
|
+
* Design doc: 02-rendering-pipeline.md
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { TransitionMap } from '../utils/state-machine.js';
|
|
18
|
+
|
|
19
|
+
// ─── States ──────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** Waiting for first HTML chunk. Pull loop not started. */
|
|
22
|
+
export interface InitState {
|
|
23
|
+
phase: 'init';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** HTML chunks flowing, pull loop running, suffix not yet stripped. */
|
|
27
|
+
export interface StreamingState {
|
|
28
|
+
phase: 'streaming';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Suffix (</body></html>) stripped. RSC scripts injected at body level.
|
|
33
|
+
* HTML may still be streaming (Suspense chunks after suffix).
|
|
34
|
+
*/
|
|
35
|
+
export interface BodyLevelState {
|
|
36
|
+
phase: 'body-level';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** HTML stream done (flush fired). Draining remaining RSC chunks. */
|
|
40
|
+
export interface FlushingState {
|
|
41
|
+
phase: 'flushing';
|
|
42
|
+
/** When true, suffix was found before flushing. */
|
|
43
|
+
hadSuffix: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** All streams consumed. Terminal state. */
|
|
47
|
+
export interface DoneState {
|
|
48
|
+
phase: 'done';
|
|
49
|
+
hadSuffix: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Pull loop failed. Terminal state with captured error. */
|
|
53
|
+
export interface ErrorState {
|
|
54
|
+
phase: 'error';
|
|
55
|
+
error: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type FlightInjectionState =
|
|
59
|
+
| InitState
|
|
60
|
+
| StreamingState
|
|
61
|
+
| BodyLevelState
|
|
62
|
+
| FlushingState
|
|
63
|
+
| DoneState
|
|
64
|
+
| ErrorState;
|
|
65
|
+
|
|
66
|
+
// ─── Events ──────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** First HTML chunk arrived — start the pull loop. */
|
|
69
|
+
export interface FirstChunkEvent {
|
|
70
|
+
type: 'FIRST_CHUNK';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The </body></html> suffix was found and stripped. */
|
|
74
|
+
export interface SuffixFoundEvent {
|
|
75
|
+
type: 'SUFFIX_FOUND';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** HTML stream finished (flush/end). */
|
|
79
|
+
export interface HtmlDoneEvent {
|
|
80
|
+
type: 'HTML_DONE';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** RSC pull loop completed (all chunks read or stream closed). */
|
|
84
|
+
export interface PullDoneEvent {
|
|
85
|
+
type: 'PULL_DONE';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** RSC pull loop encountered an error. */
|
|
89
|
+
export interface PullErrorEvent {
|
|
90
|
+
type: 'PULL_ERROR';
|
|
91
|
+
error: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type FlightInjectionEvent =
|
|
95
|
+
| FirstChunkEvent
|
|
96
|
+
| SuffixFoundEvent
|
|
97
|
+
| HtmlDoneEvent
|
|
98
|
+
| PullDoneEvent
|
|
99
|
+
| PullErrorEvent;
|
|
100
|
+
|
|
101
|
+
// ─── Transitions ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export const flightInjectionTransitions: TransitionMap<FlightInjectionState, FlightInjectionEvent> =
|
|
104
|
+
{
|
|
105
|
+
'init': {
|
|
106
|
+
FIRST_CHUNK: (): StreamingState => ({ phase: 'streaming' }),
|
|
107
|
+
// Edge case: HTML stream ends immediately (empty body)
|
|
108
|
+
HTML_DONE: (): FlushingState => ({ phase: 'flushing', hadSuffix: false }),
|
|
109
|
+
},
|
|
110
|
+
'streaming': {
|
|
111
|
+
SUFFIX_FOUND: (): BodyLevelState => ({ phase: 'body-level' }),
|
|
112
|
+
HTML_DONE: (): FlushingState => ({ phase: 'flushing', hadSuffix: false }),
|
|
113
|
+
PULL_DONE: (): StreamingState => ({ phase: 'streaming' }),
|
|
114
|
+
PULL_ERROR: (_s, e): ErrorState => ({ phase: 'error', error: e.error }),
|
|
115
|
+
},
|
|
116
|
+
'body-level': {
|
|
117
|
+
HTML_DONE: (): FlushingState => ({ phase: 'flushing', hadSuffix: true }),
|
|
118
|
+
PULL_DONE: (): BodyLevelState => ({ phase: 'body-level' }),
|
|
119
|
+
PULL_ERROR: (_s, e): ErrorState => ({ phase: 'error', error: e.error }),
|
|
120
|
+
},
|
|
121
|
+
'flushing': {
|
|
122
|
+
PULL_DONE: (s): DoneState => ({ phase: 'done', hadSuffix: s.hadSuffix }),
|
|
123
|
+
PULL_ERROR: (_s, e): ErrorState => ({ phase: 'error', error: e.error }),
|
|
124
|
+
// Suffix can be found during flushing if flush() processes remaining text
|
|
125
|
+
SUFFIX_FOUND: (_s): FlushingState => ({ phase: 'flushing', hadSuffix: true }),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** Whether the machine is in a state where the suffix has been stripped. */
|
|
132
|
+
export function isSuffixStripped(state: FlightInjectionState): boolean {
|
|
133
|
+
switch (state.phase) {
|
|
134
|
+
case 'body-level':
|
|
135
|
+
return true;
|
|
136
|
+
case 'flushing':
|
|
137
|
+
case 'done':
|
|
138
|
+
return state.hadSuffix;
|
|
139
|
+
default:
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Whether the HTML stream has finished (flush/end fired). */
|
|
145
|
+
export function isHtmlDone(state: FlightInjectionState): boolean {
|
|
146
|
+
return state.phase === 'flushing' || state.phase === 'done';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether the RSC pull loop has completed. */
|
|
150
|
+
export function isPullDone(state: FlightInjectionState): boolean {
|
|
151
|
+
return state.phase === 'done' || state.phase === 'error';
|
|
152
|
+
}
|
package/src/server/form-data.ts
CHANGED
|
@@ -173,4 +173,80 @@ export const coerce = {
|
|
|
173
173
|
return undefined;
|
|
174
174
|
}
|
|
175
175
|
},
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Coerce a date string to a Date object.
|
|
179
|
+
* Handles `<input type="date">` (`"2024-01-15"`), `<input type="datetime-local">`
|
|
180
|
+
* (`"2024-01-15T10:30"`), and full ISO 8601 strings.
|
|
181
|
+
* - Valid date string → `Date`
|
|
182
|
+
* - `""` / `undefined` / `null` → `undefined`
|
|
183
|
+
* - Invalid date strings → `undefined` (schema validation will catch this)
|
|
184
|
+
* - Impossible dates that `new Date()` silently normalizes (e.g. Feb 31) → `undefined`
|
|
185
|
+
*/
|
|
186
|
+
date(value: unknown): Date | undefined {
|
|
187
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
188
|
+
if (value instanceof Date) return value;
|
|
189
|
+
if (typeof value !== 'string') return undefined;
|
|
190
|
+
const date = new Date(value);
|
|
191
|
+
if (Number.isNaN(date.getTime())) return undefined;
|
|
192
|
+
|
|
193
|
+
// Overflow detection: extract Y/M/D from the input string and verify
|
|
194
|
+
// they match the parsed Date components. new Date('2024-02-31') silently
|
|
195
|
+
// normalizes to March 2nd — we reject such inputs.
|
|
196
|
+
const ymdMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
197
|
+
if (ymdMatch) {
|
|
198
|
+
const inputYear = Number(ymdMatch[1]);
|
|
199
|
+
const inputMonth = Number(ymdMatch[2]);
|
|
200
|
+
const inputDay = Number(ymdMatch[3]);
|
|
201
|
+
|
|
202
|
+
// Use UTC methods for date-only and Z-suffixed strings to avoid
|
|
203
|
+
// timezone offset shifting the day. For datetime-local (no Z suffix),
|
|
204
|
+
// the Date constructor parses in local time, so use local methods.
|
|
205
|
+
const isUTC = value.length === 10 || value.endsWith('Z');
|
|
206
|
+
const parsedYear = isUTC ? date.getUTCFullYear() : date.getFullYear();
|
|
207
|
+
const parsedMonth = isUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;
|
|
208
|
+
const parsedDay = isUTC ? date.getUTCDate() : date.getDate();
|
|
209
|
+
|
|
210
|
+
if (inputYear !== parsedYear || inputMonth !== parsedMonth || inputDay !== parsedDay) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return date;
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Create a File coercion function with optional size and mime type validation.
|
|
220
|
+
* Returns the File if valid, `undefined` otherwise.
|
|
221
|
+
*
|
|
222
|
+
* ```ts
|
|
223
|
+
* // Basic — just checks it's a real File
|
|
224
|
+
* z.preprocess(coerce.file(), z.instanceof(File))
|
|
225
|
+
*
|
|
226
|
+
* // With constraints
|
|
227
|
+
* z.preprocess(
|
|
228
|
+
* coerce.file({ maxSize: 5 * 1024 * 1024, accept: ['image/png', 'image/jpeg'] }),
|
|
229
|
+
* z.instanceof(File)
|
|
230
|
+
* )
|
|
231
|
+
* ```
|
|
232
|
+
*/
|
|
233
|
+
file(options?: { maxSize?: number; accept?: string[] }): (value: unknown) => File | undefined {
|
|
234
|
+
return (value: unknown): File | undefined => {
|
|
235
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
236
|
+
if (!(value instanceof File)) return undefined;
|
|
237
|
+
|
|
238
|
+
// Empty file input (no selection): browsers submit File with name="" and size=0
|
|
239
|
+
if (value.size === 0 && value.name === '') return undefined;
|
|
240
|
+
|
|
241
|
+
if (options?.maxSize !== undefined && value.size > options.maxSize) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (options?.accept !== undefined && !options.accept.includes(value.type)) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return value;
|
|
250
|
+
};
|
|
251
|
+
},
|
|
176
252
|
};
|
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
* Design docs: 02-rendering-pipeline.md, 18-build-system.md §"Entry Files"
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { createMachine } from '../utils/state-machine.js';
|
|
11
|
+
import {
|
|
12
|
+
flightInjectionTransitions,
|
|
13
|
+
isSuffixStripped,
|
|
14
|
+
isHtmlDone,
|
|
15
|
+
isPullDone,
|
|
16
|
+
type FlightInjectionState,
|
|
17
|
+
type FlightInjectionEvent,
|
|
18
|
+
} from './flight-injection-state.js';
|
|
19
|
+
|
|
10
20
|
/**
|
|
11
21
|
* Inject HTML content before a closing tag in the stream.
|
|
12
22
|
*
|
|
@@ -179,6 +189,11 @@ export function createInlinedRscStream(
|
|
|
179
189
|
* scanning or depth tracking needed — the suffix removal is the
|
|
180
190
|
* structural guarantee.
|
|
181
191
|
*
|
|
192
|
+
* State machine phases:
|
|
193
|
+
* init → streaming → body-level → flushing → done
|
|
194
|
+
* └──────────────→ flushing → done
|
|
195
|
+
* (any) → error
|
|
196
|
+
*
|
|
182
197
|
* Inspired by Next.js createFlightDataInjectionTransformStream.
|
|
183
198
|
*/
|
|
184
199
|
function createFlightInjectionTransform(
|
|
@@ -190,17 +205,13 @@ function createFlightInjectionTransform(
|
|
|
190
205
|
const suffixBytes = encoder.encode(suffix);
|
|
191
206
|
|
|
192
207
|
const rscReader = rscScriptStream.getReader();
|
|
208
|
+
|
|
209
|
+
const machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
|
|
210
|
+
initial: { phase: 'init' },
|
|
211
|
+
transitions: flightInjectionTransitions,
|
|
212
|
+
});
|
|
213
|
+
|
|
193
214
|
let pullPromise: Promise<void> | null = null;
|
|
194
|
-
let donePulling = false;
|
|
195
|
-
let pullError: unknown = null;
|
|
196
|
-
// Once the suffix is stripped, all content is body-level and
|
|
197
|
-
// scripts can safely be drained after any HTML chunk.
|
|
198
|
-
let foundSuffix = false;
|
|
199
|
-
// Set to true in flush() — once all HTML chunks have been emitted,
|
|
200
|
-
// there's no need to yield between RSC reads. This eliminates
|
|
201
|
-
// ~36 macrotask yields per request (18 chunks × 2 yields each)
|
|
202
|
-
// that were the primary source of SSR overhead vs Next.js.
|
|
203
|
-
let htmlStreamFinished = false;
|
|
204
215
|
|
|
205
216
|
// RSC script chunks waiting to be injected at the body level.
|
|
206
217
|
const pending: Uint8Array[] = [];
|
|
@@ -220,7 +231,7 @@ function createFlightInjectionTransform(
|
|
|
220
231
|
for (;;) {
|
|
221
232
|
const { done, value } = await rscReader.read();
|
|
222
233
|
if (done) {
|
|
223
|
-
|
|
234
|
+
machine.send({ type: 'PULL_DONE' });
|
|
224
235
|
return;
|
|
225
236
|
}
|
|
226
237
|
pending.push(value);
|
|
@@ -228,13 +239,12 @@ function createFlightInjectionTransform(
|
|
|
228
239
|
// through transform() first — but only while HTML is still
|
|
229
240
|
// streaming. Once flush() fires (all HTML emitted), drain
|
|
230
241
|
// remaining RSC chunks without yielding.
|
|
231
|
-
if (!
|
|
242
|
+
if (!isHtmlDone(machine.state)) {
|
|
232
243
|
await new Promise<void>((r) => setImmediate(r));
|
|
233
244
|
}
|
|
234
245
|
}
|
|
235
246
|
} catch (err) {
|
|
236
|
-
|
|
237
|
-
donePulling = true;
|
|
247
|
+
machine.send({ type: 'PULL_ERROR', error: err });
|
|
238
248
|
}
|
|
239
249
|
}
|
|
240
250
|
|
|
@@ -243,10 +253,8 @@ function createFlightInjectionTransform(
|
|
|
243
253
|
while (pending.length > 0) {
|
|
244
254
|
controller.enqueue(pending.shift()!);
|
|
245
255
|
}
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
pullError = null;
|
|
249
|
-
controller.error(err);
|
|
256
|
+
if (machine.state.phase === 'error') {
|
|
257
|
+
controller.error(machine.state.error);
|
|
250
258
|
}
|
|
251
259
|
}
|
|
252
260
|
|
|
@@ -257,11 +265,12 @@ function createFlightInjectionTransform(
|
|
|
257
265
|
// and ensures the shell HTML is enqueued before any RSC
|
|
258
266
|
// script tags. Without this, the pull loop starts eagerly
|
|
259
267
|
// and may read RSC data before the browser has any HTML.
|
|
260
|
-
if (
|
|
268
|
+
if (machine.state.phase === 'init') {
|
|
269
|
+
machine.send({ type: 'FIRST_CHUNK' });
|
|
261
270
|
pullPromise = pullLoop();
|
|
262
271
|
}
|
|
263
272
|
|
|
264
|
-
if (
|
|
273
|
+
if (isSuffixStripped(machine.state)) {
|
|
265
274
|
// Post-suffix: everything is body-level (Suspense chunks).
|
|
266
275
|
// Emit HTML, then drain any buffered scripts.
|
|
267
276
|
controller.enqueue(chunk);
|
|
@@ -273,7 +282,7 @@ function createFlightInjectionTransform(
|
|
|
273
282
|
const text = decoder.decode(chunk, { stream: true });
|
|
274
283
|
const idx = text.indexOf(suffix);
|
|
275
284
|
if (idx !== -1) {
|
|
276
|
-
|
|
285
|
+
machine.send({ type: 'SUFFIX_FOUND' });
|
|
277
286
|
// Emit everything before the suffix (still inside <body>'s
|
|
278
287
|
// child elements — don't inject scripts here).
|
|
279
288
|
const before = text.slice(0, idx);
|
|
@@ -290,20 +299,27 @@ function createFlightInjectionTransform(
|
|
|
290
299
|
}
|
|
291
300
|
},
|
|
292
301
|
flush(controller) {
|
|
293
|
-
// All HTML chunks have been emitted.
|
|
294
|
-
// stop yielding between RSC reads
|
|
295
|
-
|
|
302
|
+
// All HTML chunks have been emitted. Transition to flushing —
|
|
303
|
+
// the pull loop will stop yielding between RSC reads since
|
|
304
|
+
// isHtmlDone() now returns true. This eliminates ~36 macrotask
|
|
305
|
+
// yields per request (18 chunks × 2 yields each) that were the
|
|
306
|
+
// primary source of SSR overhead vs Next.js.
|
|
307
|
+
machine.send({ type: 'HTML_DONE' });
|
|
296
308
|
|
|
297
309
|
// Drain remaining RSC chunks at body level
|
|
298
310
|
const finish = () => {
|
|
299
311
|
drainPending(controller);
|
|
300
312
|
// Re-emit the suffix at the very end so HTML is well-formed
|
|
301
|
-
if (
|
|
313
|
+
if (machine.state.phase === 'done' && machine.state.hadSuffix) {
|
|
314
|
+
controller.enqueue(suffixBytes);
|
|
315
|
+
} else if (machine.state.phase === 'flushing' && machine.state.hadSuffix) {
|
|
316
|
+
// Pull was already done before flush — drainPending didn't
|
|
317
|
+
// transition, but we still need the suffix
|
|
302
318
|
controller.enqueue(suffixBytes);
|
|
303
319
|
}
|
|
304
320
|
};
|
|
305
321
|
|
|
306
|
-
if (
|
|
322
|
+
if (isPullDone(machine.state)) {
|
|
307
323
|
finish();
|
|
308
324
|
return;
|
|
309
325
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -6,19 +6,17 @@ export type { MiddlewareContext } from './types';
|
|
|
6
6
|
export type { RouteContext } from './types';
|
|
7
7
|
export type { Metadata, MetadataRoute } from './types';
|
|
8
8
|
|
|
9
|
-
// Request Context — ALS-backed headers(), cookies(), and
|
|
9
|
+
// Request Context — ALS-backed headers(), cookies(), and rawSearchParams()
|
|
10
10
|
// Design doc: design/04-authorization.md §"AccessContext does not include cookies or headers"
|
|
11
11
|
// Design doc: design/23-search-params.md §"Server Integration"
|
|
12
12
|
export {
|
|
13
13
|
headers,
|
|
14
14
|
cookies,
|
|
15
|
-
|
|
16
|
-
setParsedSearchParams,
|
|
15
|
+
rawSearchParams,
|
|
17
16
|
runWithRequestContext,
|
|
18
17
|
setMutableCookieContext,
|
|
19
18
|
markResponseFlushed,
|
|
20
19
|
getSetCookieHeaders,
|
|
21
|
-
setCookieSecrets,
|
|
22
20
|
} from './request-context';
|
|
23
21
|
export type { ReadonlyHeaders, RequestCookies, CookieOptions } from './request-context';
|
|
24
22
|
|
|
@@ -20,6 +20,16 @@
|
|
|
20
20
|
import { Transform } from 'node:stream';
|
|
21
21
|
import { createGzip, constants } from 'node:zlib';
|
|
22
22
|
|
|
23
|
+
import { createMachine } from '../utils/state-machine.js';
|
|
24
|
+
import {
|
|
25
|
+
flightInjectionTransitions,
|
|
26
|
+
isSuffixStripped,
|
|
27
|
+
isHtmlDone,
|
|
28
|
+
isPullDone,
|
|
29
|
+
type FlightInjectionState,
|
|
30
|
+
type FlightInjectionEvent,
|
|
31
|
+
} from './flight-injection-state.js';
|
|
32
|
+
|
|
23
33
|
// ─── Head Injection ──────────────────────────────────────────────────────────
|
|
24
34
|
|
|
25
35
|
/**
|
|
@@ -124,29 +134,26 @@ export function createNodeFlightInjector(
|
|
|
124
134
|
const rscReader = rscStream.getReader();
|
|
125
135
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
126
136
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// Reference to the transform instance, set on first transform() call.
|
|
132
|
-
// Used by pullLoop to push RSC chunks directly to the output without
|
|
133
|
-
// waiting for HTML chunks to trigger drainPending.
|
|
134
|
-
let transformRef: Transform | null = null;
|
|
137
|
+
const machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
|
|
138
|
+
initial: { phase: 'init' },
|
|
139
|
+
transitions: flightInjectionTransitions,
|
|
140
|
+
});
|
|
135
141
|
|
|
136
142
|
// pullLoop reads RSC chunks and pushes them directly to the transform
|
|
137
143
|
// output as <script> tags. This ensures RSC data is delivered to the
|
|
138
144
|
// browser as soon as it's available — not deferred until the next HTML
|
|
139
145
|
// chunk. Critical for streaming: the shell RSC payload must arrive
|
|
140
146
|
// with the shell HTML so hydration can start before Suspense resolves.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
//
|
|
147
|
+
|
|
148
|
+
async function pullLoop(stream: Transform): Promise<void> {
|
|
149
|
+
// Yield once so the first transform() call can emit the bootstrap
|
|
150
|
+
// signal before we start pushing data chunks.
|
|
144
151
|
await new Promise<void>((r) => setImmediate(r));
|
|
145
152
|
try {
|
|
146
153
|
for (;;) {
|
|
147
154
|
const { done, value } = await rscReader.read();
|
|
148
155
|
if (done) {
|
|
149
|
-
|
|
156
|
+
machine.send({ type: 'PULL_DONE' });
|
|
150
157
|
return;
|
|
151
158
|
}
|
|
152
159
|
const decoded = decoder.decode(value, { stream: true });
|
|
@@ -154,34 +161,37 @@ export function createNodeFlightInjector(
|
|
|
154
161
|
const scriptBuf = Buffer.from(`<script>self.__timber_f.push(${escaped})</script>`, 'utf-8');
|
|
155
162
|
// Push directly to the transform output — don't wait for an
|
|
156
163
|
// HTML chunk to trigger drainPending.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
164
|
+
stream.push(scriptBuf);
|
|
165
|
+
// Yield between reads so HTML chunks get a chance to flow
|
|
166
|
+
// through transform() first — but only while HTML is still
|
|
167
|
+
// streaming. Once flush() fires (all HTML emitted), drain
|
|
168
|
+
// remaining RSC chunks without yielding.
|
|
169
|
+
if (!isHtmlDone(machine.state)) {
|
|
161
170
|
await new Promise<void>((r) => setImmediate(r));
|
|
162
171
|
}
|
|
163
172
|
}
|
|
164
173
|
} catch (err) {
|
|
165
|
-
|
|
166
|
-
donePulling = true;
|
|
174
|
+
machine.send({ type: 'PULL_ERROR', error: err });
|
|
167
175
|
}
|
|
168
176
|
}
|
|
169
177
|
|
|
170
|
-
|
|
178
|
+
// Bootstrap script to emit after the first HTML chunk (but before
|
|
179
|
+
// any RSC data chunks). Must come AFTER the doctype + <html> so
|
|
180
|
+
// browsers don't enter Quirks Mode.
|
|
181
|
+
const bootstrapBuf = Buffer.from(
|
|
182
|
+
`<script>(self.__timber_f=self.__timber_f||[]).push(${htmlEscapeJsonString(JSON.stringify([0]))})</script>`,
|
|
183
|
+
'utf-8'
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const transform = new Transform({
|
|
171
187
|
transform(chunk: Buffer, _encoding, callback) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// This must arrive before any data chunks.
|
|
176
|
-
const bootstrap = `<script>(self.__timber_f=self.__timber_f||[]).push(${htmlEscapeJsonString(JSON.stringify([0]))})</script>`;
|
|
177
|
-
this.push(Buffer.from(bootstrap, 'utf-8'));
|
|
178
|
-
// Start the pull loop — it will push RSC data chunks directly
|
|
179
|
-
// to the transform output as they arrive from the RSC stream.
|
|
180
|
-
pullLoop();
|
|
188
|
+
const isFirst = machine.state.phase === 'init';
|
|
189
|
+
if (isFirst) {
|
|
190
|
+
machine.send({ type: 'FIRST_CHUNK' });
|
|
181
191
|
}
|
|
182
192
|
|
|
183
|
-
if (
|
|
184
|
-
|
|
193
|
+
if (isSuffixStripped(machine.state)) {
|
|
194
|
+
transform.push(chunk);
|
|
185
195
|
callback();
|
|
186
196
|
return;
|
|
187
197
|
}
|
|
@@ -189,38 +199,53 @@ export function createNodeFlightInjector(
|
|
|
189
199
|
const text = chunk.toString('utf-8');
|
|
190
200
|
const idx = text.indexOf(suffix);
|
|
191
201
|
if (idx !== -1) {
|
|
192
|
-
|
|
202
|
+
machine.send({ type: 'SUFFIX_FOUND' });
|
|
193
203
|
const before = text.slice(0, idx);
|
|
194
204
|
const after = text.slice(idx + suffix.length);
|
|
195
|
-
if (before)
|
|
196
|
-
if (after)
|
|
205
|
+
if (before) transform.push(Buffer.from(before, 'utf-8'));
|
|
206
|
+
if (after) transform.push(Buffer.from(after, 'utf-8'));
|
|
197
207
|
} else {
|
|
198
|
-
|
|
208
|
+
transform.push(chunk);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Emit bootstrap AFTER the first HTML chunk so the doctype and
|
|
212
|
+
// <html> tag are the first bytes the browser sees. Then start
|
|
213
|
+
// the pull loop to stream RSC data chunks.
|
|
214
|
+
if (isFirst) {
|
|
215
|
+
transform.push(bootstrapBuf);
|
|
216
|
+
pullLoop(transform);
|
|
199
217
|
}
|
|
200
218
|
callback();
|
|
201
219
|
},
|
|
202
220
|
flush(callback) {
|
|
203
|
-
|
|
221
|
+
// All HTML chunks have been emitted. Transition to flushing —
|
|
222
|
+
// the pull loop will stop yielding between RSC reads since
|
|
223
|
+
// isHtmlDone() now returns true.
|
|
224
|
+
machine.send({ type: 'HTML_DONE' });
|
|
204
225
|
|
|
205
226
|
const finish = () => {
|
|
206
|
-
if (
|
|
207
|
-
|
|
227
|
+
if (machine.state.phase === 'error') {
|
|
228
|
+
const err = machine.state.error;
|
|
229
|
+
transform.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
208
230
|
return;
|
|
209
231
|
}
|
|
210
|
-
|
|
211
|
-
|
|
232
|
+
const hadSuffix =
|
|
233
|
+
(machine.state.phase === 'done' && machine.state.hadSuffix) ||
|
|
234
|
+
(machine.state.phase === 'flushing' && machine.state.hadSuffix);
|
|
235
|
+
if (hadSuffix) {
|
|
236
|
+
transform.push(suffixBuf);
|
|
212
237
|
}
|
|
213
238
|
callback();
|
|
214
239
|
};
|
|
215
240
|
|
|
216
|
-
if (
|
|
241
|
+
if (isPullDone(machine.state)) {
|
|
217
242
|
finish();
|
|
218
243
|
return;
|
|
219
244
|
}
|
|
220
245
|
// Wait for the RSC stream to finish before closing.
|
|
221
246
|
// pullLoop is already running and pushing directly.
|
|
222
247
|
const waitForPull = () => {
|
|
223
|
-
if (
|
|
248
|
+
if (isPullDone(machine.state)) {
|
|
224
249
|
finish();
|
|
225
250
|
} else {
|
|
226
251
|
setImmediate(waitForPull);
|
|
@@ -229,6 +254,8 @@ export function createNodeFlightInjector(
|
|
|
229
254
|
waitForPull();
|
|
230
255
|
},
|
|
231
256
|
});
|
|
257
|
+
|
|
258
|
+
return transform;
|
|
232
259
|
}
|
|
233
260
|
|
|
234
261
|
// ─── Error Handling ──────────────────────────────────────────────────────────
|