dinou 4.0.12 → 4.0.13

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,23 @@ 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.13] - 2026-06-29
9
+
10
+ ### Added
11
+ - **Universal Redirects**: Full support for redirects on Server Components, `getProps`, Server Actions, and client SPA navigation.
12
+ - **Standard Relative Paths**: Support for relative paths (`../sibling`, `./child`, `?query`, `#hash`) matching directory-first URL resolution.
13
+ - **Absolute Link Hover**: Links now render resolved absolute paths to ensure correct browser previews and right-click behaviors.
14
+
15
+ ### Fixed
16
+ - **RSC Stream Stability**: Resolved socket crashes (`Connection closed`) and duplicate Express header warnings when redirects occur during streamed RSC requests.
17
+ - **SSR Process Leaks**: Render processes are now cleaned up immediately after an HTML redirect is triggered.
18
+
19
+ ### Security
20
+ - **Redirect Sanitization**: Blocked unsafe external domains (`https://...`), protocol-relative URLs (`//...`), and scripting payloads (`javascript:...`).
21
+
22
+ ### Refactored
23
+ - **Shared Helpers**: Unified path resolution logic into a shared module (`url-resolver.js`).
24
+
8
25
  ## [4.0.12] - 2026-06-28
9
26
 
10
27
  ### Security
@@ -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,21 @@ 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
+ if (!isPrefetch) {
72
+ window.location.href = redirectUrl;
73
+ } else {
74
+ cache.delete(url);
75
+ }
76
+ // Return a promise that never resolves to avoid React Server DOM throwing "Connection closed"
77
+ return new Promise(() => {});
78
+ }
79
+ return res;
80
+ }),
81
+ );
68
82
  cache.set(url, promise); // <--- KEY TO AVOID LOOP
69
83
  return promise;
70
84
  };
@@ -84,7 +98,7 @@ function Router() {
84
98
  window.__DINOU_PREFETCH__ = (url) => {
85
99
  // 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
86
100
  if (isHashChangeOnly(url)) return;
87
- getRSCPayload(url);
101
+ getRSCPayload(url, true);
88
102
  };
89
103
 
90
104
  // Hydration
@@ -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,21 @@ 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
+ if (!isPrefetch) {
73
+ window.location.href = redirectUrl;
74
+ } else {
75
+ cache.delete(url);
76
+ }
77
+ // Return a promise that never resolves to avoid React Server DOM throwing "Connection closed"
78
+ return new Promise(() => {});
79
+ }
80
+ return res;
81
+ }),
82
+ );
69
83
  cache.set(url, promise); // <--- KEY TO AVOID LOOP
70
84
  return promise;
71
85
  };
@@ -85,7 +99,7 @@ function Router() {
85
99
  window.__DINOU_PREFETCH__ = (url) => {
86
100
  // 🛡️ PREFETCH PROTECTION: If it's a local hash, do nothing
87
101
  if (isHashChangeOnly(url)) return;
88
- getRSCPayload(url);
102
+ getRSCPayload(url, true);
89
103
  };
90
104
 
91
105
  // Hydration
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useRouter } from "./navigation.js";
3
+ import { useRouter, usePathname } from "./navigation.js";
4
4
  import { resolveUrl } from "./navigation-utils.js";
5
5
 
6
6
  export function Link({
@@ -11,12 +11,13 @@ 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
18
  if (!prefetch || !href || fresh) return;
17
- const finalPath = resolveUrl(href, window.location.pathname);
18
19
  if (window.__DINOU_PREFETCH__) {
19
- window.__DINOU_PREFETCH__(finalPath);
20
+ window.__DINOU_PREFETCH__(resolvedHref);
20
21
  }
21
22
  };
22
23
 
@@ -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,15 @@
1
1
  export function resolveUrl(href, currentPathname) {
2
+ const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
3
+
2
4
  if (href.startsWith("/") || href.includes("://")) {
3
- const url = new URL(href, window.location.origin);
5
+ const url = new URL(href, origin);
4
6
  return normalize(url.pathname + url.search + url.hash);
5
7
  }
6
8
 
7
9
  let base = currentPathname;
8
10
  if (!base.endsWith("/")) base += "/";
9
11
 
10
- const resolved = new URL(href, window.location.origin + base);
12
+ const resolved = new URL(href, origin + base);
11
13
  return normalize(resolved.pathname + resolved.search + resolved.hash);
12
14
  }
13
15
 
@@ -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,9 @@
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
+
3
7
  function createServerFunctionProxy(id) {
4
8
  return new Proxy(() => { }, {
5
9
  apply: async (_target, _thisArg, args) => {
@@ -33,7 +37,7 @@ function createServerFunctionProxy(id) {
33
37
  // Check header first
34
38
  const redirectUrl = res.headers.get("X-Dinou-Redirect");
35
39
  if (redirectUrl) {
36
- window.location.href = redirectUrl;
40
+ window.location.href = isSafeRedirect(redirectUrl) ? redirectUrl : "/";
37
41
  return new Promise(() => { });
38
42
  }
39
43
 
@@ -50,7 +54,7 @@ function createServerFunctionProxy(id) {
50
54
  if (contentType.includes("application/json")) {
51
55
  const data = await res.json();
52
56
  if (data && data.redirect) {
53
- window.location.href = data.redirect;
57
+ window.location.href = isSafeRedirect(data.redirect) ? data.redirect : "/";
54
58
  return new Promise(() => { });
55
59
  }
56
60
  return data;
@@ -78,7 +82,7 @@ function createServerFunctionProxy(id) {
78
82
  const payload = JSON.parse(buffer.slice(2));
79
83
  if (payload.type === "redirect") {
80
84
  isRedirecting = true;
81
- window.location.href = payload.url;
85
+ window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
82
86
  } else if (payload.type === "cookie") {
83
87
  document.cookie = payload.cookie;
84
88
  }
@@ -115,7 +119,7 @@ function createServerFunctionProxy(id) {
115
119
  const payload = JSON.parse(line.slice(2));
116
120
  if (payload.type === "redirect") {
117
121
  isRedirecting = true;
118
- window.location.href = payload.url;
122
+ window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
119
123
  } else if (payload.type === "cookie") {
120
124
  document.cookie = payload.cookie;
121
125
  }
@@ -1,6 +1,10 @@
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
+
4
8
  export function createServerFunctionProxy(id) {
5
9
  return new Proxy(() => {}, {
6
10
  apply: async (_target, _thisArg, args) => {
@@ -34,7 +38,7 @@ export function createServerFunctionProxy(id) {
34
38
  // Check header first
35
39
  const redirectUrl = res.headers.get("X-Dinou-Redirect");
36
40
  if (redirectUrl) {
37
- window.location.href = redirectUrl;
41
+ window.location.href = isSafeRedirect(redirectUrl) ? redirectUrl : "/";
38
42
  return new Promise(() => {});
39
43
  }
40
44
 
@@ -51,7 +55,7 @@ export function createServerFunctionProxy(id) {
51
55
  if (contentType.includes("application/json")) {
52
56
  const data = await res.json();
53
57
  if (data && data.redirect) {
54
- window.location.href = data.redirect;
58
+ window.location.href = isSafeRedirect(data.redirect) ? data.redirect : "/";
55
59
  return new Promise(() => {});
56
60
  }
57
61
  return data;
@@ -79,7 +83,7 @@ export function createServerFunctionProxy(id) {
79
83
  const payload = JSON.parse(buffer.slice(2));
80
84
  if (payload.type === "redirect") {
81
85
  isRedirecting = true;
82
- window.location.href = payload.url;
86
+ window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
83
87
  } else if (payload.type === "cookie") {
84
88
  document.cookie = payload.cookie;
85
89
  }
@@ -116,7 +120,7 @@ export function createServerFunctionProxy(id) {
116
120
  const payload = JSON.parse(line.slice(2));
117
121
  if (payload.type === "redirect") {
118
122
  isRedirecting = true;
119
- window.location.href = payload.url;
123
+ window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
120
124
  } else if (payload.type === "cookie") {
121
125
  document.cookie = payload.cookie;
122
126
  }
@@ -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.13",
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.13",
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": {