dinou 4.0.12 → 4.0.14

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/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [4.0.14] - 2026-06-29
9
+ ### Added
10
+ - **Soft Redirects**: Exposes the SPA transition navigation internally via `window.__DINOU_ROUTER_NAVIGATE__` to enable smooth client-side redirects from both Server Functions and RSC payloads, keeping the React client state intact and preventing full browser page reloads.
11
+ ### Fixed
12
+ - **External Links Interception**: Restored native browser navigation for external domains, protocol-relative links (`//...`), and special protocols (`mailto:`, `tel:`, etc.) by preventing the SPA router from capturing or prefetching non-internal routes.
13
+
14
+ ## [4.0.13] - 2026-06-29
15
+
16
+ ### Added
17
+ - **Universal Redirects**: Full support for redirects on Server Components, `getProps`, Server Actions, and client SPA navigation.
18
+ - **Standard Relative Paths**: Support for relative paths (`../sibling`, `./child`, `?query`, `#hash`) matching directory-first URL resolution.
19
+ - **Absolute Link Hover**: Links now render resolved absolute paths to ensure correct browser previews and right-click behaviors.
20
+
21
+ ### Fixed
22
+ - **RSC Stream Stability**: Resolved socket crashes (`Connection closed`) and duplicate Express header warnings when redirects occur during streamed RSC requests.
23
+ - **SSR Process Leaks**: Render processes are now cleaned up immediately after an HTML redirect is triggered.
24
+
25
+ ### Security
26
+ - **Redirect Sanitization**: Blocked unsafe external domains (`https://...`), protocol-relative URLs (`//...`), and scripting payloads (`javascript:...`).
27
+
28
+ ### Refactored
29
+ - **Shared Helpers**: Unified path resolution logic into a shared module (`url-resolver.js`).
30
+
8
31
  ## [4.0.12] - 2026-06-28
9
32
 
10
33
  ### Security
@@ -9,7 +9,7 @@ import {
9
9
  import { createFromFetch } from "react-server-dom-webpack/client";
10
10
  import { hydrateRoot } from "react-dom/client";
11
11
  import { RouterContext } from "./navigation.js";
12
- import { resolveUrl } from "./navigation-utils.js";
12
+ import { resolveUrl, isExternalUrl } from "./navigation-utils.js";
13
13
 
14
14
  // ====================================================================
15
15
  // 1. GLOBAL STATE (Outside the component)
@@ -167,7 +167,7 @@ function Router() {
167
167
  }
168
168
 
169
169
  const href = anchor.getAttribute("href");
170
- if (!href || href.startsWith("mailto:") || href.startsWith("tel:"))
170
+ if (!href || href.startsWith("mailto:") || href.startsWith("tel:") || isExternalUrl(href))
171
171
  return;
172
172
 
173
173
  // We use the unified helper
@@ -9,7 +9,7 @@ import {
9
9
  import { createFromFetch } from "@roggc/react-server-dom-esm/client";
10
10
  import { hydrateRoot } from "react-dom/client";
11
11
  import { RouterContext } from "./navigation.js";
12
- import { resolveUrl } from "./navigation-utils.js";
12
+ import { resolveUrl, isExternalUrl } from "./navigation-utils.js";
13
13
 
14
14
  // ====================================================================
15
15
  // 1. GLOBAL STATE (Outside the component)
@@ -167,7 +167,7 @@ function Router() {
167
167
  }
168
168
 
169
169
  const href = anchor.getAttribute("href");
170
- if (!href || href.startsWith("mailto:") || href.startsWith("tel:"))
170
+ if (!href || href.startsWith("mailto:") || href.startsWith("tel:") || isExternalUrl(href))
171
171
  return;
172
172
 
173
173
  // We use the unified helper
@@ -1,15 +1,22 @@
1
1
  // dinou/core/client-redirect.jsx
2
2
  "use client";
3
3
 
4
- import { useEffect } from "react";
5
4
  import { useRouter } from "./navigation.js";
6
5
 
7
6
  export function ClientRedirect({ to }) {
8
7
  const router = useRouter();
9
8
 
10
- useEffect(() => {
11
- router.replace(to);
12
- }, [to, router]);
9
+ if (typeof window !== "undefined") {
10
+ Promise.resolve().then(() => {
11
+ if (window.__DINOU_ROUTER_NAVIGATE__) {
12
+ window.__DINOU_ROUTER_NAVIGATE__(to, { replace: true });
13
+ } else {
14
+ router.replace(to);
15
+ }
16
+ });
17
+ // Suspend to prevent React from committing this intermediate page
18
+ throw new Promise(() => {});
19
+ }
13
20
 
14
21
  return null;
15
22
  }
@@ -9,7 +9,7 @@ import {
9
9
  import { createFromFetch } from "react-server-dom-webpack/client";
10
10
  import { hydrateRoot } from "react-dom/client";
11
11
  import { RouterContext } from "./navigation.js";
12
- import { resolveUrl } from "./navigation-utils.js";
12
+ import { resolveUrl, isExternalUrl } from "./navigation-utils.js";
13
13
 
14
14
  // ====================================================================
15
15
  // 1. GLOBAL STATE (Outside the component)
@@ -38,7 +38,7 @@ const isHashChangeOnly = (finalPath) => {
38
38
  );
39
39
  };
40
40
 
41
- const getRSCPayload = (rscKey) => {
41
+ const getRSCPayload = (rscKey, isPrefetch = false) => {
42
42
  const url = rscKey.split("::")[0];
43
43
  // 1. Check Idempotence (Avoids the infinite loop of React)
44
44
  if (cache.has(url)) {
@@ -64,7 +64,24 @@ const getRSCPayload = (rscKey) => {
64
64
  }
65
65
 
66
66
  // 3. Fetch and Cache Storage
67
- const promise = createFromFetch(fetch(payloadUrl));
67
+ const promise = createFromFetch(
68
+ fetch(payloadUrl).then((res) => {
69
+ if (res.headers.has("x-rsc-redirect")) {
70
+ const redirectUrl = res.headers.get("x-rsc-redirect");
71
+ cache.delete(url);
72
+ if (!isPrefetch) {
73
+ if (window.__DINOU_ROUTER_NAVIGATE__) {
74
+ window.__DINOU_ROUTER_NAVIGATE__(redirectUrl, { replace: true });
75
+ } else {
76
+ window.location.href = redirectUrl;
77
+ }
78
+ }
79
+ // Return a promise that never resolves to avoid React Server DOM throwing "Connection closed"
80
+ return new Promise(() => {});
81
+ }
82
+ return res;
83
+ }),
84
+ );
68
85
  cache.set(url, promise); // <--- KEY TO AVOID LOOP
69
86
  return promise;
70
87
  };
@@ -79,18 +96,6 @@ function Router() {
79
96
  const [isPending, startTransition] = useTransition();
80
97
  const [version, setVersion] = useState(0);
81
98
 
82
- // 🔌 EFFECT 1: Expose Global Prefetch
83
- useEffect(() => {
84
- window.__DINOU_PREFETCH__ = (url) => {
85
- // 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
86
- if (isHashChangeOnly(url)) return;
87
- getRSCPayload(url);
88
- };
89
-
90
- // Hydration
91
- document.body.setAttribute("data-hydrated", "true");
92
- }, []); // Only on mount
93
-
94
99
  // 🧭 NAVIGATE FUNCTION (Core Logic)
95
100
  const navigate = (href, options = {}) => {
96
101
  const finalPath = resolveUrl(href, window.location.pathname);
@@ -136,6 +141,26 @@ function Router() {
136
141
  });
137
142
  };
138
143
 
144
+ // 🔌 EFFECT 1: Expose Global Prefetch & Navigation
145
+ useEffect(() => {
146
+ window.__DINOU_PREFETCH__ = (url) => {
147
+ // 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
148
+ if (isHashChangeOnly(url)) return;
149
+ getRSCPayload(url, true);
150
+ };
151
+
152
+ window.__DINOU_ROUTER_NAVIGATE__ = navigate;
153
+
154
+ // Hydration
155
+ document.body.setAttribute("data-hydrated", "true");
156
+
157
+ return () => {
158
+ if (window.__DINOU_ROUTER_NAVIGATE__ === navigate) {
159
+ window.__DINOU_ROUTER_NAVIGATE__ = undefined;
160
+ }
161
+ };
162
+ }, [navigate]);
163
+
139
164
  const back = () => window.history.back();
140
165
  const forward = () => window.history.forward();
141
166
  const refresh = () => {
@@ -174,7 +199,7 @@ function Router() {
174
199
  }
175
200
 
176
201
  const href = anchor.getAttribute("href");
177
- if (!href || href.startsWith("mailto:") || href.startsWith("tel:"))
202
+ if (!href || href.startsWith("mailto:") || href.startsWith("tel:") || isExternalUrl(href))
178
203
  return;
179
204
 
180
205
  // We use the unified helper
@@ -10,7 +10,7 @@ import {
10
10
  import { createFromFetch } from "@roggc/react-server-dom-esm/client";
11
11
  import { hydrateRoot } from "react-dom/client";
12
12
  import { RouterContext } from "./navigation.js";
13
- import { resolveUrl } from "./navigation-utils.js";
13
+ import { resolveUrl, isExternalUrl } from "./navigation-utils.js";
14
14
 
15
15
  // ====================================================================
16
16
  // 1. GLOBAL STATE (Outside the component)
@@ -39,7 +39,7 @@ const isHashChangeOnly = (finalPath) => {
39
39
  );
40
40
  };
41
41
 
42
- const getRSCPayload = (rscKey) => {
42
+ const getRSCPayload = (rscKey, isPrefetch = false) => {
43
43
  const url = rscKey.split("::")[0];
44
44
  // 1. Check Idempotence (Avoids the infinite loop of React)
45
45
  if (cache.has(url)) {
@@ -65,7 +65,24 @@ const getRSCPayload = (rscKey) => {
65
65
  }
66
66
 
67
67
  // 3. Fetch and Cache Storage
68
- const promise = createFromFetch(fetch(payloadUrl));
68
+ const promise = createFromFetch(
69
+ fetch(payloadUrl).then((res) => {
70
+ if (res.headers.has("x-rsc-redirect")) {
71
+ const redirectUrl = res.headers.get("x-rsc-redirect");
72
+ cache.delete(url);
73
+ if (!isPrefetch) {
74
+ if (window.__DINOU_ROUTER_NAVIGATE__) {
75
+ window.__DINOU_ROUTER_NAVIGATE__(redirectUrl, { replace: true });
76
+ } else {
77
+ window.location.href = redirectUrl;
78
+ }
79
+ }
80
+ // Return a promise that never resolves to avoid React Server DOM throwing "Connection closed"
81
+ return new Promise(() => {});
82
+ }
83
+ return res;
84
+ }),
85
+ );
69
86
  cache.set(url, promise); // <--- KEY TO AVOID LOOP
70
87
  return promise;
71
88
  };
@@ -80,18 +97,6 @@ function Router() {
80
97
  const [isPending, startTransition] = useTransition();
81
98
  const [version, setVersion] = useState(0);
82
99
 
83
- // 🔌 EFFECT 1: Expose Global Prefetch
84
- useEffect(() => {
85
- window.__DINOU_PREFETCH__ = (url) => {
86
- // 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
87
- if (isHashChangeOnly(url)) return;
88
- getRSCPayload(url);
89
- };
90
-
91
- // Hydration
92
- document.body.setAttribute("data-hydrated", "true");
93
- }, []); // Only on mount
94
-
95
100
  // 🧭 NAVIGATE FUNCTION (Core Logic)
96
101
  const navigate = (href, options = {}) => {
97
102
  const finalPath = resolveUrl(href, window.location.pathname);
@@ -137,6 +142,26 @@ function Router() {
137
142
  });
138
143
  };
139
144
 
145
+ // 🔌 EFFECT 1: Expose Global Prefetch & Navigation
146
+ useEffect(() => {
147
+ window.__DINOU_PREFETCH__ = (url) => {
148
+ // 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
149
+ if (isHashChangeOnly(url)) return;
150
+ getRSCPayload(url, true);
151
+ };
152
+
153
+ window.__DINOU_ROUTER_NAVIGATE__ = navigate;
154
+
155
+ // Hydration
156
+ document.body.setAttribute("data-hydrated", "true");
157
+
158
+ return () => {
159
+ if (window.__DINOU_ROUTER_NAVIGATE__ === navigate) {
160
+ window.__DINOU_ROUTER_NAVIGATE__ = undefined;
161
+ }
162
+ };
163
+ }, [navigate]);
164
+
140
165
  const back = () => window.history.back();
141
166
  const forward = () => window.history.forward();
142
167
  const refresh = () => {
@@ -175,7 +200,7 @@ function Router() {
175
200
  }
176
201
 
177
202
  const href = anchor.getAttribute("href");
178
- if (!href || href.startsWith("mailto:") || href.startsWith("tel:"))
203
+ if (!href || href.startsWith("mailto:") || href.startsWith("tel:") || isExternalUrl(href))
179
204
  return;
180
205
 
181
206
  // We use the unified helper
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useRouter } from "./navigation.js";
4
- import { resolveUrl } from "./navigation-utils.js";
3
+ import { useRouter, usePathname } from "./navigation.js";
4
+ import { resolveUrl, isExternalUrl } from "./navigation-utils.js";
5
5
 
6
6
  export function Link({
7
7
  href,
@@ -11,17 +11,18 @@ export function Link({
11
11
  ...props
12
12
  }) {
13
13
  const { push } = useRouter();
14
+ const pathname = usePathname();
15
+ const resolvedHref = resolveUrl(href, pathname);
14
16
 
15
17
  const handlePrefetch = () => {
16
- if (!prefetch || !href || fresh) return;
17
- const finalPath = resolveUrl(href, window.location.pathname);
18
+ if (!prefetch || !href || fresh || isExternalUrl(href)) return;
18
19
  if (window.__DINOU_PREFETCH__) {
19
- window.__DINOU_PREFETCH__(finalPath);
20
+ window.__DINOU_PREFETCH__(resolvedHref);
20
21
  }
21
22
  };
22
23
 
23
24
  const handleClick = (e) => {
24
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
25
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || isExternalUrl(href)) return;
25
26
 
26
27
  e.preventDefault();
27
28
  push(href, { fresh });
@@ -29,7 +30,7 @@ export function Link({
29
30
 
30
31
  return (
31
32
  <a
32
- href={href}
33
+ href={resolvedHref}
33
34
  onClick={handleClick}
34
35
  onMouseEnter={handlePrefetch}
35
36
  {...props}
@@ -1,13 +1,46 @@
1
+ export function isExternalUrl(href) {
2
+ if (!href) return false;
3
+
4
+ // Protocol-relative (e.g. //google.com)
5
+ if (href.startsWith("//")) {
6
+ return true;
7
+ }
8
+
9
+ // Absolute with protocol (e.g. https://google.com)
10
+ if (href.includes("://")) {
11
+ try {
12
+ const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
13
+ const url = new URL(href, origin);
14
+ return url.origin !== origin;
15
+ } catch (e) {
16
+ return true;
17
+ }
18
+ }
19
+
20
+ // Other protocols (mailto:, tel:, javascript:, etc.)
21
+ if (/^[a-zA-Z0-9+-.]+:[^//]/.test(href) || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("javascript:")) {
22
+ return true;
23
+ }
24
+
25
+ return false;
26
+ }
27
+
1
28
  export function resolveUrl(href, currentPathname) {
29
+ if (isExternalUrl(href)) {
30
+ return href;
31
+ }
32
+
33
+ const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
34
+
2
35
  if (href.startsWith("/") || href.includes("://")) {
3
- const url = new URL(href, window.location.origin);
36
+ const url = new URL(href, origin);
4
37
  return normalize(url.pathname + url.search + url.hash);
5
38
  }
6
39
 
7
40
  let base = currentPathname;
8
41
  if (!base.endsWith("/")) base += "/";
9
42
 
10
- const resolved = new URL(href, window.location.origin + base);
43
+ const resolved = new URL(href, origin + base);
11
44
  return normalize(resolved.pathname + resolved.search + resolved.hash);
12
45
  }
13
46
 
@@ -27,6 +27,8 @@ const childExecArgv = ESSENTIAL_NODE_ARGS.concat(loaderArg);
27
27
 
28
28
  // ----------------------------------------------------
29
29
 
30
+ const { resolveRelativeUrl } = require("./url-resolver");
31
+
30
32
  function renderAppToHtml(
31
33
  reqPath,
32
34
  paramsString,
@@ -82,13 +84,28 @@ function renderAppToHtml(
82
84
  if (res.headersSent) {
83
85
  // --- REDIRECT (JS Injection) ---
84
86
  if (command === "redirect") {
85
- const url = args.length === 1 ? args[0] : args[1];
87
+ const rawUrl = args.length === 1 ? args[0] : args[1];
88
+ const resolvedUrl = resolveRelativeUrl(rawUrl, reqPath);
86
89
  console.log(
87
- `[Dinou] Streaming active. Redirecting via JavaScript to: ${url}`,
90
+ `[Dinou] Streaming active. Redirecting via JavaScript to: ${resolvedUrl}`,
88
91
  );
89
- const safeUrl = JSON.stringify(url);
92
+ let finalUrl = "/";
93
+ if (
94
+ typeof resolvedUrl === "string" &&
95
+ resolvedUrl.startsWith("/") &&
96
+ !resolvedUrl.startsWith("//")
97
+ ) {
98
+ finalUrl = resolvedUrl;
99
+ } else {
100
+ console.warn(
101
+ `[Dinou Security] Blocked unsafe streaming redirect to: ${rawUrl}`,
102
+ );
103
+ }
104
+ const safeUrl = JSON.stringify(finalUrl);
90
105
  res.write(`<script>window.location.href = ${safeUrl};</script>`);
91
106
  res.end();
107
+ child.stdout.unpipe(res);
108
+ child.kill();
92
109
  return;
93
110
  }
94
111
 
@@ -186,16 +203,19 @@ function renderAppToHtml(
186
203
  rawUrl = args[0];
187
204
  }
188
205
 
206
+ // Resolve relative URL relative to reqPath
207
+ const resolvedUrl = resolveRelativeUrl(rawUrl, reqPath);
208
+
189
209
  // 2. Security Logic (Safe Redirect)
190
210
  // We allow only relative routes starting with '/' but not '//'
191
211
  let finalUrl = "/";
192
212
 
193
213
  if (
194
- typeof rawUrl === "string" &&
195
- rawUrl.startsWith("/") &&
196
- !rawUrl.startsWith("//")
214
+ typeof resolvedUrl === "string" &&
215
+ resolvedUrl.startsWith("/") &&
216
+ !resolvedUrl.startsWith("//")
197
217
  ) {
198
- finalUrl = rawUrl;
218
+ finalUrl = resolvedUrl;
199
219
  } else {
200
220
  console.warn(
201
221
  `[Dinou Security] Blocked unsafe redirect to: ${rawUrl}`,
@@ -205,6 +225,8 @@ function renderAppToHtml(
205
225
 
206
226
  // 3. Execute safe redirect always using (status, url)
207
227
  res.redirect.apply(res, [status, finalUrl]);
228
+ child.stdout.unpipe(res);
229
+ child.kill();
208
230
  return;
209
231
  }
210
232
 
@@ -1,5 +1,19 @@
1
1
  import { createFromFetch } from "react-server-dom-webpack/client";
2
2
 
3
+ function isSafeRedirect(url) {
4
+ return typeof url === "string" && url.startsWith("/") && !url.startsWith("//");
5
+ }
6
+
7
+ function executeRedirect(url) {
8
+ const safeUrl = isSafeRedirect(url) ? url : "/";
9
+ const isInternal = safeUrl.startsWith("/") && !safeUrl.startsWith("//");
10
+ if (isInternal && typeof window !== "undefined" && window.__DINOU_ROUTER_NAVIGATE__) {
11
+ window.__DINOU_ROUTER_NAVIGATE__(safeUrl);
12
+ } else if (typeof window !== "undefined") {
13
+ window.location.href = safeUrl;
14
+ }
15
+ }
16
+
3
17
  function createServerFunctionProxy(id) {
4
18
  return new Proxy(() => { }, {
5
19
  apply: async (_target, _thisArg, args) => {
@@ -33,8 +47,8 @@ function createServerFunctionProxy(id) {
33
47
  // Check header first
34
48
  const redirectUrl = res.headers.get("X-Dinou-Redirect");
35
49
  if (redirectUrl) {
36
- window.location.href = redirectUrl;
37
- return new Promise(() => { });
50
+ executeRedirect(redirectUrl);
51
+ return Promise.resolve();
38
52
  }
39
53
 
40
54
  const contentType = res.headers.get("content-type") || "";
@@ -50,8 +64,8 @@ function createServerFunctionProxy(id) {
50
64
  if (contentType.includes("application/json")) {
51
65
  const data = await res.json();
52
66
  if (data && data.redirect) {
53
- window.location.href = data.redirect;
54
- return new Promise(() => { });
67
+ executeRedirect(data.redirect);
68
+ return Promise.resolve();
55
69
  }
56
70
  return data;
57
71
  }
@@ -78,7 +92,7 @@ function createServerFunctionProxy(id) {
78
92
  const payload = JSON.parse(buffer.slice(2));
79
93
  if (payload.type === "redirect") {
80
94
  isRedirecting = true;
81
- window.location.href = payload.url;
95
+ executeRedirect(payload.url);
82
96
  } else if (payload.type === "cookie") {
83
97
  document.cookie = payload.cookie;
84
98
  }
@@ -115,7 +129,7 @@ function createServerFunctionProxy(id) {
115
129
  const payload = JSON.parse(line.slice(2));
116
130
  if (payload.type === "redirect") {
117
131
  isRedirecting = true;
118
- window.location.href = payload.url;
132
+ executeRedirect(payload.url);
119
133
  } else if (payload.type === "cookie") {
120
134
  document.cookie = payload.cookie;
121
135
  }
@@ -126,18 +140,14 @@ function createServerFunctionProxy(id) {
126
140
  cleanChunk += line + "\n";
127
141
  }
128
142
  }
129
-
130
143
  if (cleanChunk) {
131
144
  controller.enqueue(encoder.encode(cleanChunk));
132
145
  }
133
146
  }
134
147
  }
148
+ controller.close();
135
149
  } catch (err) {
136
150
  controller.error(err);
137
- } finally {
138
- if (!isRedirecting) {
139
- controller.close();
140
- }
141
151
  }
142
152
  },
143
153
  });
@@ -1,6 +1,20 @@
1
1
  // public/server-function-proxy.js
2
2
  import { createFromFetch } from "@roggc/react-server-dom-esm/client";
3
3
 
4
+ function isSafeRedirect(url) {
5
+ return typeof url === "string" && url.startsWith("/") && !url.startsWith("//");
6
+ }
7
+
8
+ function executeRedirect(url) {
9
+ const safeUrl = isSafeRedirect(url) ? url : "/";
10
+ const isInternal = safeUrl.startsWith("/") && !safeUrl.startsWith("//");
11
+ if (isInternal && typeof window !== "undefined" && window.__DINOU_ROUTER_NAVIGATE__) {
12
+ window.__DINOU_ROUTER_NAVIGATE__(safeUrl);
13
+ } else if (typeof window !== "undefined") {
14
+ window.location.href = safeUrl;
15
+ }
16
+ }
17
+
4
18
  export function createServerFunctionProxy(id) {
5
19
  return new Proxy(() => {}, {
6
20
  apply: async (_target, _thisArg, args) => {
@@ -34,8 +48,8 @@ export function createServerFunctionProxy(id) {
34
48
  // Check header first
35
49
  const redirectUrl = res.headers.get("X-Dinou-Redirect");
36
50
  if (redirectUrl) {
37
- window.location.href = redirectUrl;
38
- return new Promise(() => {});
51
+ executeRedirect(redirectUrl);
52
+ return Promise.resolve();
39
53
  }
40
54
 
41
55
  const contentType = res.headers.get("content-type") || "";
@@ -51,8 +65,8 @@ export function createServerFunctionProxy(id) {
51
65
  if (contentType.includes("application/json")) {
52
66
  const data = await res.json();
53
67
  if (data && data.redirect) {
54
- window.location.href = data.redirect;
55
- return new Promise(() => {});
68
+ executeRedirect(data.redirect);
69
+ return Promise.resolve();
56
70
  }
57
71
  return data;
58
72
  }
@@ -79,7 +93,7 @@ export function createServerFunctionProxy(id) {
79
93
  const payload = JSON.parse(buffer.slice(2));
80
94
  if (payload.type === "redirect") {
81
95
  isRedirecting = true;
82
- window.location.href = payload.url;
96
+ executeRedirect(payload.url);
83
97
  } else if (payload.type === "cookie") {
84
98
  document.cookie = payload.cookie;
85
99
  }
@@ -116,7 +130,7 @@ export function createServerFunctionProxy(id) {
116
130
  const payload = JSON.parse(line.slice(2));
117
131
  if (payload.type === "redirect") {
118
132
  isRedirecting = true;
119
- window.location.href = payload.url;
133
+ executeRedirect(payload.url);
120
134
  } else if (payload.type === "cookie") {
121
135
  document.cookie = payload.cookie;
122
136
  }
@@ -133,12 +147,9 @@ export function createServerFunctionProxy(id) {
133
147
  }
134
148
  }
135
149
  }
150
+ controller.close();
136
151
  } catch (err) {
137
152
  controller.error(err);
138
- } finally {
139
- if (!isRedirecting) {
140
- controller.close();
141
- }
142
153
  }
143
154
  },
144
155
  });
@@ -248,20 +248,24 @@ const appUseCookieParser = cookieParser();
248
248
  const app = express();
249
249
  app.use(appUseCookieParser);
250
250
  app.use(express.json());
251
+ const { resolveRelativeUrl } = require("./url-resolver");
251
252
 
252
253
  function getContext(req, res) {
254
+ let hasRedirected = false;
253
255
  // Helper to execute res methods safely
254
256
  const safeResCall = (methodName, ...args) => {
257
+ if (hasRedirected) return;
255
258
  if (res.headersSent) {
259
+ if (methodName === "redirect" && req.path.includes("____rsc_payload")) {
260
+ return; // Silence streaming redirects during RSC payload requests
261
+ }
256
262
  console.log(
257
263
  `[Dinou] res.${methodName} called but headers already sent. Ignoring.`,
258
264
  );
259
- // console.warn(
260
- // `[Dinou Warning] RSC Stream active. Ignoring res.${methodName}() to avoid crash.`
261
- // );
262
265
  return; // Exit silently
263
266
  }
264
267
  if (methodName === "redirect") {
268
+ hasRedirected = true;
265
269
  // 1. Normalize arguments (Status and URL)
266
270
  let url = args[0];
267
271
  let status = 302; // Default
@@ -272,16 +276,27 @@ function getContext(req, res) {
272
276
  }
273
277
 
274
278
  function safeRedirect(targetUrl) {
275
- // Validate it's a string before calling startsWith
279
+ const resolvedUrl = resolveRelativeUrl(targetUrl, req.path);
280
+ let finalUrl = "/";
276
281
  if (
277
- typeof targetUrl === "string" &&
278
- targetUrl.startsWith("/") &&
279
- !targetUrl.startsWith("//")
282
+ typeof resolvedUrl === "string" &&
283
+ resolvedUrl.startsWith("/") &&
284
+ !resolvedUrl.startsWith("//")
280
285
  ) {
281
- res.redirect.apply(res, [status, targetUrl]);
286
+ finalUrl = resolvedUrl;
282
287
  } else {
283
- res.redirect.apply(res, [status, "/"]);
288
+ console.warn(
289
+ `[Dinou Security] Blocked unsafe redirect to: ${targetUrl}`,
290
+ );
291
+ }
292
+
293
+ if (req.path.includes("____rsc_payload")) {
294
+ res.setHeader("x-rsc-redirect", finalUrl);
295
+ res.status(200).end();
296
+ return;
284
297
  }
298
+
299
+ res.redirect.apply(res, [status, finalUrl]);
285
300
  }
286
301
  return safeRedirect(url);
287
302
  }
@@ -351,11 +366,31 @@ function getContextForServerFunctionEndpoint(req, res) {
351
366
  },
352
367
  res: {
353
368
  redirect: (urlOrStatus, url) => {
354
- const targetUrl = url || urlOrStatus;
369
+ const rawUrl = url || urlOrStatus;
370
+ const referer = req.headers["referer"];
371
+ let refererPath = "/";
372
+ if (referer) {
373
+ try {
374
+ refererPath = new URL(referer).pathname;
375
+ } catch (e) {}
376
+ }
377
+ const resolvedUrl = resolveRelativeUrl(rawUrl, refererPath);
378
+ let finalUrl = "/";
379
+ if (
380
+ typeof resolvedUrl === "string" &&
381
+ resolvedUrl.startsWith("/") &&
382
+ !resolvedUrl.startsWith("//")
383
+ ) {
384
+ finalUrl = resolvedUrl;
385
+ } else {
386
+ console.warn(
387
+ `[Dinou Security] Blocked unsafe server function redirect to: ${rawUrl}`,
388
+ );
389
+ }
355
390
  // We throw a special object that the endpoint will intercept
356
391
  throw {
357
392
  $$type: "dinou-internal-redirect",
358
- url: targetUrl,
393
+ url: finalUrl,
359
394
  };
360
395
  },
361
396
  status: (code) => {
@@ -530,6 +565,11 @@ async function serveRSCPayload(req, res, isOld = false, isStatic = false) {
530
565
  isNotFound,
531
566
  isDevelopment,
532
567
  );
568
+
569
+ if (res.headersSent) {
570
+ return;
571
+ }
572
+
533
573
  const manifest = isDevelopment
534
574
  ? JSON.parse(
535
575
  readFileSync(
@@ -0,0 +1,13 @@
1
+ function resolveRelativeUrl(href, currentPathname) {
2
+ if (href.startsWith("/") || href.includes("://")) {
3
+ return href;
4
+ }
5
+ let base = currentPathname || "/";
6
+ if (!base.endsWith("/")) {
7
+ base += "/";
8
+ }
9
+ const resolved = new URL(href, "http://localhost" + base);
10
+ return resolved.pathname + resolved.search + resolved.hash;
11
+ }
12
+
13
+ module.exports = { resolveRelativeUrl };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dinou-ejected",
3
- "version": "4.0.12",
3
+ "version": "4.0.14",
4
4
  "main": "index.js",
5
5
  "private": true,
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dinou",
3
- "version": "4.0.12",
3
+ "version": "4.0.14",
4
4
  "description": "Full-Stack React 19 framework with React Server Components, Server Functions, and Streaming SSR.",
5
5
  "main": "index.js",
6
6
  "bin": {