@rangojs/router 0.0.0-experimental.4 → 0.0.0-experimental.6

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.
@@ -714,7 +714,7 @@ import { resolve } from "node:path";
714
714
  // package.json
715
715
  var package_default = {
716
716
  name: "@rangojs/router",
717
- version: "0.0.0-experimental.4",
717
+ version: "0.0.0-experimental.6",
718
718
  type: "module",
719
719
  description: "Django-inspired RSC router with composable URL patterns",
720
720
  author: "Ivo Todorov",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.4",
3
+ "version": "0.0.0-experimental.6",
4
4
  "type": "module",
5
5
  "description": "Django-inspired RSC router with composable URL patterns",
6
6
  "author": "Ivo Todorov",
@@ -97,6 +97,9 @@ interface RSCRouterOptions<TEnv> {
97
97
  // Theme configuration
98
98
  theme?: ThemeConfig | true;
99
99
 
100
+ // Connection warmup (default: true)
101
+ warmup?: boolean;
102
+
100
103
  // CSP nonce provider (for router.fetch)
101
104
  nonce?: (request: Request, env: TEnv) => string | true | Promise<string | true>;
102
105
 
@@ -313,3 +316,31 @@ const router = createRouter<AppEnv>({
313
316
  urls: urlpatterns,
314
317
  });
315
318
  ```
319
+
320
+ ## Connection Warmup
321
+
322
+ Enabled by default. Keeps TCP+TLS connections alive so navigations after idle periods
323
+ don't pay handshake costs.
324
+
325
+ After 60s of no user interaction, the connection is marked cold. When the user returns
326
+ (tab becomes visible or first mouse/touch), a `HEAD ?_rsc_warmup` request re-establishes
327
+ the TLS connection before the next navigation. The server responds with 204 No Content
328
+ before any middleware or routing runs.
329
+
330
+ ```typescript
331
+ // Enabled by default
332
+ const router = createRouter({
333
+ document: Document,
334
+ urls: urlpatterns,
335
+ });
336
+
337
+ // Disable warmup
338
+ const router = createRouter({
339
+ document: Document,
340
+ urls: urlpatterns,
341
+ warmup: false,
342
+ });
343
+ ```
344
+
345
+ The warmup request is relative to the current page path, so it works correctly
346
+ with subpath deployments (reverse proxy, base path).
@@ -120,6 +120,12 @@ export interface NavigationProviderProps {
120
120
  * Only used when themeConfig is provided
121
121
  */
122
122
  initialTheme?: Theme;
123
+
124
+ /**
125
+ * Whether connection warmup is enabled.
126
+ * When true, keeps TLS alive by sending HEAD requests after idle periods.
127
+ */
128
+ warmupEnabled?: boolean;
123
129
  }
124
130
 
125
131
  /**
@@ -150,6 +156,7 @@ export function NavigationProvider({
150
156
  bridge,
151
157
  themeConfig,
152
158
  initialTheme,
159
+ warmupEnabled,
153
160
  }: NavigationProviderProps): ReactNode {
154
161
  // Track current payload for rendering (this triggers re-renders)
155
162
  const [payload, setPayload] = useState(initialPayload);
@@ -182,6 +189,88 @@ export function NavigationProvider({
182
189
  []
183
190
  );
184
191
 
192
+ // Connection warmup: keep TLS alive after idle periods.
193
+ // After 60s of no user interaction, marks connection as "cold".
194
+ // On next interaction or visibility change, sends a HEAD request to warm TLS
195
+ // before the user actually clicks a link.
196
+ useEffect(() => {
197
+ if (!warmupEnabled) return;
198
+
199
+ const IDLE_TIMEOUT = 60_000;
200
+ const DEBOUNCE_DELAY = 150;
201
+
202
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
203
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
204
+ let isCold = false;
205
+ let warmupListenersAttached = false;
206
+
207
+ function sendWarmup() {
208
+ isCold = false;
209
+ fetch("/?_rsc_warmup", { method: "HEAD" }).catch(() => {});
210
+ }
211
+
212
+ function triggerWarmup() {
213
+ if (!isCold) return;
214
+ clearTimeout(debounceTimer);
215
+ debounceTimer = setTimeout(() => {
216
+ sendWarmup();
217
+ detachWarmupListeners();
218
+ resetIdleTimer();
219
+ }, DEBOUNCE_DELAY);
220
+ }
221
+
222
+ function onVisibilityChange() {
223
+ if (document.visibilityState === "visible" && isCold) {
224
+ triggerWarmup();
225
+ }
226
+ }
227
+
228
+ function attachWarmupListeners() {
229
+ if (warmupListenersAttached) return;
230
+ warmupListenersAttached = true;
231
+ document.addEventListener("visibilitychange", onVisibilityChange);
232
+ document.addEventListener("mousemove", triggerWarmup, { once: true });
233
+ document.addEventListener("touchstart", triggerWarmup, { once: true });
234
+ }
235
+
236
+ function detachWarmupListeners() {
237
+ warmupListenersAttached = false;
238
+ document.removeEventListener("visibilitychange", onVisibilityChange);
239
+ document.removeEventListener("mousemove", triggerWarmup);
240
+ document.removeEventListener("touchstart", triggerWarmup);
241
+ }
242
+
243
+ function markCold() {
244
+ isCold = true;
245
+ attachWarmupListeners();
246
+ }
247
+
248
+ function resetIdleTimer() {
249
+ clearTimeout(idleTimer);
250
+ isCold = false;
251
+ idleTimer = setTimeout(markCold, IDLE_TIMEOUT);
252
+ }
253
+
254
+ // Activity events that reset the idle timer
255
+ const activityEvents = ["mousemove", "keydown", "touchstart", "scroll"] as const;
256
+ const activityOptions: AddEventListenerOptions = { passive: true };
257
+
258
+ for (const event of activityEvents) {
259
+ document.addEventListener(event, resetIdleTimer, activityOptions);
260
+ }
261
+
262
+ resetIdleTimer();
263
+
264
+ return () => {
265
+ clearTimeout(idleTimer);
266
+ clearTimeout(debounceTimer);
267
+ detachWarmupListeners();
268
+ for (const event of activityEvents) {
269
+ document.removeEventListener(event, resetIdleTimer);
270
+ }
271
+ };
272
+ }, [warmupEnabled]);
273
+
185
274
  // Subscribe to UI updates (for re-rendering the tree)
186
275
  useEffect(() => {
187
276
  const unsubscribe = store.onUpdate((update) => {
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { createContext, type Context } from "react";
3
+ import { createContext, createElement, type Context, type ReactNode } from "react";
4
4
 
5
5
  /**
6
6
  * Context for the current include() mount path.
@@ -12,3 +12,21 @@ import { createContext, type Context } from "react";
12
12
  * Default value "/" means root-level (no include wrapping).
13
13
  */
14
14
  export const MountContext: Context<string> = createContext<string>("/");
15
+
16
+ /**
17
+ * Provider wrapper for MountContext.
18
+ *
19
+ * RSC server components cannot use MountContext.Provider directly because
20
+ * .Provider is a property on the context object, not a named export.
21
+ * Client reference proxies on the RSC server return undefined for property
22
+ * access. This wrapper is a proper "use client" export that RSC can reference.
23
+ */
24
+ export function MountContextProvider({
25
+ value,
26
+ children,
27
+ }: {
28
+ value: string;
29
+ children: ReactNode;
30
+ }): ReactNode {
31
+ return createElement(MountContext, { value }, children);
32
+ }
@@ -13,7 +13,6 @@ import { createServerActionBridge } from "./server-action-bridge.js";
13
13
  import { createNavigationBridge } from "./navigation-bridge.js";
14
14
  import { NavigationProvider, initHandleDataSync, initSegmentsSync } from "./react/index.js";
15
15
  import { initThemeConfigSync } from "../theme/theme-context.js";
16
- import { initWarmupSync } from "../warmup/warmup-context.js";
17
16
  import type {
18
17
  RscPayload,
19
18
  RscBrowserDependencies,
@@ -105,6 +104,8 @@ export interface BrowserAppContext {
105
104
  themeConfig?: ResolvedThemeConfig | null;
106
105
  /** Initial theme from server */
107
106
  initialTheme?: Theme;
107
+ /** Whether connection warmup is enabled */
108
+ warmupEnabled?: boolean;
108
109
  }
109
110
 
110
111
  // Module-level state for the initialized app
@@ -158,9 +159,6 @@ export async function initBrowserApp(
158
159
  // Initialize theme config for MetaTags (must match SSR state)
159
160
  initThemeConfigSync(effectiveThemeConfig);
160
161
 
161
- // Initialize warmup config for MetaTags (must match SSR state)
162
- initWarmupSync(initialPayload.metadata?.warmupEnabled ?? true);
163
-
164
162
  // Initialize event controller with segment order (even without handles)
165
163
  eventController.setHandleData({}, initialPayload.metadata?.matched);
166
164
 
@@ -279,6 +277,7 @@ export async function initBrowserApp(
279
277
  initialTree,
280
278
  themeConfig: effectiveThemeConfig,
281
279
  initialTheme: effectiveInitialTheme,
280
+ warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
282
281
  };
283
282
  browserAppContext = context;
284
283
 
@@ -334,7 +333,7 @@ export interface RSCRouterProps {}
334
333
  * ```
335
334
  */
336
335
  export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
337
- const { store, eventController, bridge, initialPayload, initialTree, themeConfig, initialTheme } =
336
+ const { store, eventController, bridge, initialPayload, initialTree, themeConfig, initialTheme, warmupEnabled } =
338
337
  getBrowserAppContext();
339
338
 
340
339
  return (
@@ -345,6 +344,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
345
344
  bridge={bridge}
346
345
  themeConfig={themeConfig}
347
346
  initialTheme={initialTheme}
347
+ warmupEnabled={warmupEnabled}
348
348
  />
349
349
  );
350
350
  }
@@ -30,8 +30,6 @@ import { Meta } from "./meta.js";
30
30
  import type { MetaDescriptor, MetaDescriptorBase } from "../router/types.js";
31
31
  import { getSSRThemeConfig } from "../theme/theme-context.js";
32
32
  import { generateThemeScript } from "../theme/theme-script.js";
33
- import { getSSRWarmupEnabled } from "../warmup/warmup-context.js";
34
- import { ConnectionWarmup } from "../warmup/connection-warmup.js";
35
33
 
36
34
  // Type guards for MetaDescriptorBase variants
37
35
  function hasCharSet(d: MetaDescriptorBase): d is { charSet: "utf-8" } {
@@ -175,7 +173,6 @@ function AsyncMetaTag({ promise, index }: { promise: Promise<MetaDescriptorBase>
175
173
  export function MetaTags(): React.ReactNode {
176
174
  const descriptors = useHandle(Meta) as MetaDescriptor[];
177
175
  const themeConfig = getSSRThemeConfig();
178
- const warmupEnabled = getSSRWarmupEnabled();
179
176
 
180
177
  return (
181
178
  <>
@@ -191,7 +188,6 @@ export function MetaTags(): React.ReactNode {
191
188
  }
192
189
  return renderMetaDescriptor(descriptor, index);
193
190
  })}
194
- {warmupEnabled && <ConnectionWarmup />}
195
191
  </>
196
192
  );
197
193
  }
@@ -977,7 +977,6 @@ export function createRSCHandler<
977
977
  handles: handleStore.stream(),
978
978
  version,
979
979
  themeConfig: router.themeConfig,
980
- warmupEnabled: router.warmupEnabled,
981
980
  initialTheme: requireRequestContext().theme,
982
981
  },
983
982
  };
@@ -1035,7 +1034,6 @@ export function createRSCHandler<
1035
1034
  handles: handleStore.stream(),
1036
1035
  version,
1037
1036
  themeConfig: router.themeConfig,
1038
- warmupEnabled: router.warmupEnabled,
1039
1037
  initialTheme: requireRequestContext().theme,
1040
1038
  },
1041
1039
  };
@@ -1,6 +1,6 @@
1
1
  import { createElement, type ReactNode, type ComponentType } from "react";
2
2
  import { OutletProvider } from "./client.js";
3
- import { MountContext } from "./browser/react/mount-context.js";
3
+ import { MountContextProvider } from "./browser/react/mount-context.js";
4
4
  import type {
5
5
  ResolvedSegment,
6
6
  LoaderDataResult,
@@ -282,9 +282,12 @@ export async function renderSegments(
282
282
  });
283
283
  }
284
284
 
285
- // Wrap with MountContext.Provider for include() scoped components
285
+ // Wrap with MountContextProvider for include() scoped components.
286
+ // Must use MountContextProvider (a proper "use client" export) instead of
287
+ // MountContext.Provider directly, because .Provider is a property on the
288
+ // context object and resolves to undefined through RSC client reference proxies.
286
289
  if (node.segment.mountPath && node.segment.type === "layout") {
287
- content = createElement(MountContext.Provider, {
290
+ content = createElement(MountContextProvider, {
288
291
  value: node.segment.mountPath,
289
292
  children: content,
290
293
  });
@@ -1,94 +0,0 @@
1
- "use client";
2
-
3
- /**
4
- * Connection warmup component.
5
- *
6
- * Keeps TCP+TLS connections alive so navigations after idle periods
7
- * don't pay DNS+TCP+TLS handshake costs. Sends a HEAD request with
8
- * ?_rsc_warmup when the connection goes cold and the user returns.
9
- *
10
- * Cold detection: 60s of no user interaction marks the connection as cold.
11
- * Warmup triggers: on visibility change or first user interaction after cold,
12
- * debounced 150ms, sends HEAD /?_rsc_warmup to re-establish TLS.
13
- */
14
-
15
- import { useEffect } from "react";
16
-
17
- const IDLE_TIMEOUT = 60_000;
18
- const DEBOUNCE_MS = 150;
19
-
20
- export function ConnectionWarmup(): null {
21
- useEffect(() => {
22
- let idleTimer: ReturnType<typeof setTimeout> | undefined;
23
- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
24
- let isCold = false;
25
- let warmupListenersAttached = false;
26
-
27
- // Reset idle timer on any activity
28
- function resetIdleTimer(): void {
29
- isCold = false;
30
- clearTimeout(idleTimer);
31
- idleTimer = setTimeout(() => {
32
- isCold = true;
33
- attachWarmupListeners();
34
- }, IDLE_TIMEOUT);
35
- }
36
-
37
- // Send the warmup HEAD request (debounced)
38
- function triggerWarmup(): void {
39
- if (!isCold) return;
40
- clearTimeout(debounceTimer);
41
- debounceTimer = setTimeout(() => {
42
- fetch("/?_rsc_warmup", { method: "HEAD" }).catch(() => {});
43
- isCold = false;
44
- // Detach warmup listeners until next cold period
45
- detachWarmupListeners();
46
- resetIdleTimer();
47
- }, DEBOUNCE_MS);
48
- }
49
-
50
- // Visibility change handler (fires even without mouse/touch)
51
- function onVisibilityChange(): void {
52
- if (document.visibilityState === "visible" && isCold) {
53
- triggerWarmup();
54
- }
55
- }
56
-
57
- // Warmup listeners are only active while cold
58
- function attachWarmupListeners(): void {
59
- if (warmupListenersAttached) return;
60
- warmupListenersAttached = true;
61
- document.addEventListener("visibilitychange", onVisibilityChange);
62
- document.addEventListener("mousemove", triggerWarmup, { once: true });
63
- document.addEventListener("touchstart", triggerWarmup, { once: true });
64
- }
65
-
66
- function detachWarmupListeners(): void {
67
- if (!warmupListenersAttached) return;
68
- warmupListenersAttached = false;
69
- document.removeEventListener("visibilitychange", onVisibilityChange);
70
- document.removeEventListener("mousemove", triggerWarmup);
71
- document.removeEventListener("touchstart", triggerWarmup);
72
- }
73
-
74
- // Track activity for idle detection
75
- const activityEvents = ["mousemove", "keydown", "touchstart", "scroll"] as const;
76
- for (const event of activityEvents) {
77
- document.addEventListener(event, resetIdleTimer, { passive: true });
78
- }
79
-
80
- // Start idle timer immediately
81
- resetIdleTimer();
82
-
83
- return () => {
84
- clearTimeout(idleTimer);
85
- clearTimeout(debounceTimer);
86
- detachWarmupListeners();
87
- for (const event of activityEvents) {
88
- document.removeEventListener(event, resetIdleTimer);
89
- }
90
- };
91
- }, []);
92
-
93
- return null;
94
- }
@@ -1,35 +0,0 @@
1
- "use client";
2
-
3
- /**
4
- * Warmup context for connection keep-alive configuration.
5
- *
6
- * Module-level state populated during browser init (initWarmupSync)
7
- * and read during SSR (getSSRWarmupEnabled) by MetaTags.
8
- */
9
-
10
- /**
11
- * SSR module-level state for warmup enabled flag.
12
- * Populated by initWarmupSync before React renders.
13
- * Used by MetaTags during SSR to conditionally render ConnectionWarmup.
14
- */
15
- let ssrWarmupEnabled = true;
16
-
17
- /**
18
- * Initialize warmup config synchronously for SSR.
19
- * Called before rendering to populate state for MetaTags.
20
- *
21
- * @param enabled - Whether connection warmup is enabled
22
- */
23
- export function initWarmupSync(enabled: boolean): void {
24
- ssrWarmupEnabled = enabled;
25
- }
26
-
27
- /**
28
- * Get warmup enabled flag for SSR/hydration.
29
- * Used by MetaTags to conditionally render ConnectionWarmup.
30
- *
31
- * @returns Whether warmup is enabled
32
- */
33
- export function getSSRWarmupEnabled(): boolean {
34
- return ssrWarmupEnabled;
35
- }