dinou 4.0.11 → 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,32 @@ 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
+
25
+ ## [4.0.12] - 2026-06-28
26
+
27
+ ### Security
28
+ - **Fix DOM XSS / Script Injection Vulnerability:** Replaced inline HTML/Script injection (`<script>` tags executed via `createContextualFragment` in client-side proxies) with a secure, data-driven line-based protocol for active streams and custom HTTP headers/JSON payload signaling for redirects.
29
+ - **CSP (Content Security Policy) Compatibility:** Removed all `'unsafe-inline'` script injections, ensuring full compatibility with strict security policies.
30
+
31
+ ### Fixed
32
+ - **Stream Redirection & Cookie Handling:** Improved robustness and predictability of stream reading in server-function proxies by switching to a line-by-line (`\n` delimited) streaming command parser, eliminating regex-based tag extraction.
33
+
8
34
  ## [4.0.11]
9
35
 
10
36
  ### Fixed
@@ -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) => {
@@ -30,15 +34,30 @@ function createServerFunctionProxy(id) {
30
34
 
31
35
  if (!res.ok) throw new Error("Server function failed");
32
36
 
37
+ // Check header first
38
+ const redirectUrl = res.headers.get("X-Dinou-Redirect");
39
+ if (redirectUrl) {
40
+ window.location.href = isSafeRedirect(redirectUrl) ? redirectUrl : "/";
41
+ return new Promise(() => { });
42
+ }
43
+
33
44
  const contentType = res.headers.get("content-type") || "";
34
45
 
35
- // Case 1: Pure HTML (Simple Redirect)
46
+ // Reject unexpected HTML responses entirely to prevent security bugs
36
47
  if (contentType.includes("text/html")) {
37
48
  const html = await res.text();
38
- const range = document.createRange();
39
- const documentFragment = range.createContextualFragment(html);
40
- document.body.appendChild(documentFragment);
41
- return new Promise(() => { });
49
+ console.warn("[Dinou] Received unexpected HTML response from server function:", html);
50
+ throw new Error("Unexpected HTML response from server function");
51
+ }
52
+
53
+ // Check if it's application/json (Scenario A clean redirect or json response)
54
+ if (contentType.includes("application/json")) {
55
+ const data = await res.json();
56
+ if (data && data.redirect) {
57
+ window.location.href = isSafeRedirect(data.redirect) ? data.redirect : "/";
58
+ return new Promise(() => { });
59
+ }
60
+ return data;
42
61
  }
43
62
 
44
63
  // Case 2: RSC or Hybrid Stream (Buffered)
@@ -46,7 +65,6 @@ function createServerFunctionProxy(id) {
46
65
  const reader = res.body.getReader();
47
66
  const decoder = new TextDecoder();
48
67
  const encoder = new TextEncoder();
49
- const scriptRegex = /<script>(.*?)<\/script>/gs;
50
68
 
51
69
  const readableStream = new ReadableStream({
52
70
  async start(controller) {
@@ -58,16 +76,20 @@ function createServerFunctionProxy(id) {
58
76
  const { done, value } = await reader.read();
59
77
 
60
78
  if (done) {
61
- // If something remains in the buffer upon finishing, we send it (as long as it's not a broken script)
62
79
  if (buffer.length > 0) {
63
- // CHECK: We search for the tag start, whether it has the closing > or not
64
- if (buffer.includes("<script")) {
65
- console.warn(
66
- "[Dinou] Stream ended with incomplete script. Discarding tail.",
67
- );
68
- // We do not enqueue.
80
+ if (buffer.startsWith("D:")) {
81
+ try {
82
+ const payload = JSON.parse(buffer.slice(2));
83
+ if (payload.type === "redirect") {
84
+ isRedirecting = true;
85
+ window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
86
+ } else if (payload.type === "cookie") {
87
+ document.cookie = payload.cookie;
88
+ }
89
+ } catch (e) {
90
+ console.error("[Dinou] Failed to parse stream command line at end:", e);
91
+ }
69
92
  } else {
70
- // It is safe content (e.g., "a < b" or a cut JSON that React will handle)
71
93
  controller.enqueue(encoder.encode(buffer));
72
94
  }
73
95
  }
@@ -77,65 +99,46 @@ function createServerFunctionProxy(id) {
77
99
  // 1. ACCUMULATE
78
100
  buffer += decoder.decode(value, { stream: true });
79
101
 
80
- // 2. PROCESS COMPLETE SCRIPTS
81
- // We search for complete pairs of <script>...</script>
82
- let match;
102
+ // 2. PROCESS COMPLETE LINES
103
+ const lastNewlineIndex = buffer.lastIndexOf("\n");
104
+ if (lastNewlineIndex !== -1) {
105
+ const completeChunk = buffer.slice(0, lastNewlineIndex + 1);
106
+ buffer = buffer.slice(lastNewlineIndex + 1);
83
107
 
84
- // We execute all complete scripts we find
85
- while ((match = scriptRegex.exec(buffer)) !== null) {
86
- const fullMatch = match[0];
87
- const scriptContent = match[1];
108
+ const lines = completeChunk.split("\n");
109
+ let cleanChunk = "";
88
110
 
89
- // Detect redirect
90
- if (scriptContent.includes("window.location.href")) {
91
- isRedirecting = true;
92
- }
93
-
94
- // Inject to DOM
95
- const range = document.createRange();
96
- const fragment = range.createContextualFragment(fullMatch); // fullMatch includes tags for correct context
97
- document.body.appendChild(fragment);
98
- }
111
+ for (let i = 0; i < lines.length; i++) {
112
+ const line = lines[i];
113
+ if (!line && i === lines.length - 1) {
114
+ continue;
115
+ }
99
116
 
100
- // 3. CLEAN PROCESSED SCRIPTS FROM BUFFER
101
- // Once executed, we remove them so they don't go to React
102
- buffer = buffer.replace(scriptRegex, "");
103
-
104
- // 4. CALCULATE WHAT IS SAFE TO SEND (The anti-cut logic)
105
- // We need to know if the buffer ends with something that LOOKS like the start of a script
106
- // Dangerous patterns at the end: <, <s, <sc, <scr, <scri, <scrip, <script
107
-
108
- let cutoffIndex = buffer.length; // Default send everything
109
-
110
- // A) If there is an open but not closed <script> in the buffer
111
- const openScriptIndex = buffer.indexOf("<script>");
112
- if (openScriptIndex !== -1) {
113
- // We keep everything from the <script> onwards
114
- cutoffIndex = openScriptIndex;
115
- } else {
116
- // B) If no open script, check if the end looks like a cut tag
117
- // Regex: Looks for '<' optionally followed by s, c, r, i, p, t AT THE END of the string ($)
118
- const partialTagMatch = buffer.match(/<s?c?r?i?p?t?$/);
119
-
120
- if (partialTagMatch) {
121
- // We save from where the suspicion starts
122
- cutoffIndex = partialTagMatch.index;
117
+ if (line.startsWith("D:")) {
118
+ try {
119
+ const payload = JSON.parse(line.slice(2));
120
+ if (payload.type === "redirect") {
121
+ isRedirecting = true;
122
+ window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
123
+ } else if (payload.type === "cookie") {
124
+ document.cookie = payload.cookie;
125
+ }
126
+ } catch (e) {
127
+ console.error("[Dinou] Failed to parse stream command line:", e);
128
+ }
129
+ } else {
130
+ cleanChunk += line + "\n";
131
+ }
123
132
  }
124
- }
125
133
 
126
- // 5. SEND SAFE CONTENT
127
- const safeChunk = buffer.slice(0, cutoffIndex);
128
- // What remains stays in the buffer for the next loop (chunk)
129
- buffer = buffer.slice(cutoffIndex);
130
-
131
- if (safeChunk) {
132
- controller.enqueue(encoder.encode(safeChunk));
134
+ if (cleanChunk) {
135
+ controller.enqueue(encoder.encode(cleanChunk));
136
+ }
133
137
  }
134
138
  }
135
139
  } catch (err) {
136
140
  controller.error(err);
137
141
  } finally {
138
- // If it is NOT a redirect, we close. If it is a redirect, we leave hanging (your master trick).
139
142
  if (!isRedirecting) {
140
143
  controller.close();
141
144
  }
@@ -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) => {
@@ -31,15 +35,30 @@ export function createServerFunctionProxy(id) {
31
35
 
32
36
  if (!res.ok) throw new Error("Server function failed");
33
37
 
38
+ // Check header first
39
+ const redirectUrl = res.headers.get("X-Dinou-Redirect");
40
+ if (redirectUrl) {
41
+ window.location.href = isSafeRedirect(redirectUrl) ? redirectUrl : "/";
42
+ return new Promise(() => {});
43
+ }
44
+
34
45
  const contentType = res.headers.get("content-type") || "";
35
46
 
36
- // Case 1: Pure HTML (Simple Redirect)
47
+ // Reject unexpected HTML responses entirely to prevent security bugs
37
48
  if (contentType.includes("text/html")) {
38
49
  const html = await res.text();
39
- const range = document.createRange();
40
- const documentFragment = range.createContextualFragment(html);
41
- document.body.appendChild(documentFragment);
42
- return new Promise(() => {});
50
+ console.warn("[Dinou] Received unexpected HTML response from server function:", html);
51
+ throw new Error("Unexpected HTML response from server function");
52
+ }
53
+
54
+ // Check if it's application/json (Scenario A clean redirect or json response)
55
+ if (contentType.includes("application/json")) {
56
+ const data = await res.json();
57
+ if (data && data.redirect) {
58
+ window.location.href = isSafeRedirect(data.redirect) ? data.redirect : "/";
59
+ return new Promise(() => {});
60
+ }
61
+ return data;
43
62
  }
44
63
 
45
64
  // Case 2: RSC or Hybrid Stream (Buffered)
@@ -47,7 +66,6 @@ export function createServerFunctionProxy(id) {
47
66
  const reader = res.body.getReader();
48
67
  const decoder = new TextDecoder();
49
68
  const encoder = new TextEncoder();
50
- const scriptRegex = /<script>(.*?)<\/script>/gs;
51
69
 
52
70
  const readableStream = new ReadableStream({
53
71
  async start(controller) {
@@ -59,16 +77,20 @@ export function createServerFunctionProxy(id) {
59
77
  const { done, value } = await reader.read();
60
78
 
61
79
  if (done) {
62
- // If something remains in the buffer upon finishing, we send it (as long as it's not a broken script)
63
80
  if (buffer.length > 0) {
64
- // CHECK: We search for the tag start, whether it has the closing > or not
65
- if (buffer.includes("<script")) {
66
- console.warn(
67
- "[Dinou] Stream ended with incomplete script. Discarding tail.",
68
- );
69
- // We do not enqueue.
81
+ if (buffer.startsWith("D:")) {
82
+ try {
83
+ const payload = JSON.parse(buffer.slice(2));
84
+ if (payload.type === "redirect") {
85
+ isRedirecting = true;
86
+ window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
87
+ } else if (payload.type === "cookie") {
88
+ document.cookie = payload.cookie;
89
+ }
90
+ } catch (e) {
91
+ console.error("[Dinou] Failed to parse stream command line at end:", e);
92
+ }
70
93
  } else {
71
- // It is safe content (e.g., "a < b" or a cut JSON that React will handle)
72
94
  controller.enqueue(encoder.encode(buffer));
73
95
  }
74
96
  }
@@ -78,65 +100,46 @@ export function createServerFunctionProxy(id) {
78
100
  // 1. ACCUMULATE
79
101
  buffer += decoder.decode(value, { stream: true });
80
102
 
81
- // 2. PROCESS COMPLETE SCRIPTS
82
- // We search for complete pairs of <script>...</script>
83
- let match;
103
+ // 2. PROCESS COMPLETE LINES
104
+ const lastNewlineIndex = buffer.lastIndexOf("\n");
105
+ if (lastNewlineIndex !== -1) {
106
+ const completeChunk = buffer.slice(0, lastNewlineIndex + 1);
107
+ buffer = buffer.slice(lastNewlineIndex + 1);
84
108
 
85
- // We execute all complete scripts we find
86
- while ((match = scriptRegex.exec(buffer)) !== null) {
87
- const fullMatch = match[0];
88
- const scriptContent = match[1];
109
+ const lines = completeChunk.split("\n");
110
+ let cleanChunk = "";
89
111
 
90
- // Detect redirect
91
- if (scriptContent.includes("window.location.href")) {
92
- isRedirecting = true;
93
- }
94
-
95
- // Inject to DOM
96
- const range = document.createRange();
97
- const fragment = range.createContextualFragment(fullMatch); // fullMatch includes tags for correct context
98
- document.body.appendChild(fragment);
99
- }
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const line = lines[i];
114
+ if (!line && i === lines.length - 1) {
115
+ continue;
116
+ }
100
117
 
101
- // 3. CLEAN PROCESSED SCRIPTS FROM BUFFER
102
- // Once executed, we remove them so they don't go to React
103
- buffer = buffer.replace(scriptRegex, "");
104
-
105
- // 4. CALCULATE WHAT IS SAFE TO SEND (The anti-cut logic)
106
- // We need to know if the buffer ends with something that LOOKS like the start of a script
107
- // Dangerous patterns at the end: <, <s, <sc, <scr, <scri, <scrip, <script
108
-
109
- let cutoffIndex = buffer.length; // Default send everything
110
-
111
- // A) If there is an open but not closed <script> in the buffer
112
- const openScriptIndex = buffer.indexOf("<script>");
113
- if (openScriptIndex !== -1) {
114
- // We keep everything from the <script> onwards
115
- cutoffIndex = openScriptIndex;
116
- } else {
117
- // B) If no open script, check if the end looks like a cut tag
118
- // Regex: Looks for '<' optionally followed by s, c, r, i, p, t AT THE END of the string ($)
119
- const partialTagMatch = buffer.match(/<s?c?r?i?p?t?$/);
120
-
121
- if (partialTagMatch) {
122
- // We save from where the suspicion starts
123
- cutoffIndex = partialTagMatch.index;
118
+ if (line.startsWith("D:")) {
119
+ try {
120
+ const payload = JSON.parse(line.slice(2));
121
+ if (payload.type === "redirect") {
122
+ isRedirecting = true;
123
+ window.location.href = isSafeRedirect(payload.url) ? payload.url : "/";
124
+ } else if (payload.type === "cookie") {
125
+ document.cookie = payload.cookie;
126
+ }
127
+ } catch (e) {
128
+ console.error("[Dinou] Failed to parse stream command line:", e);
129
+ }
130
+ } else {
131
+ cleanChunk += line + "\n";
132
+ }
124
133
  }
125
- }
126
134
 
127
- // 5. SEND SAFE CONTENT
128
- const safeChunk = buffer.slice(0, cutoffIndex);
129
- // What remains stays in the buffer for the next loop (chunk)
130
- buffer = buffer.slice(cutoffIndex);
131
-
132
- if (safeChunk) {
133
- controller.enqueue(encoder.encode(safeChunk));
135
+ if (cleanChunk) {
136
+ controller.enqueue(encoder.encode(cleanChunk));
137
+ }
134
138
  }
135
139
  }
136
140
  } catch (err) {
137
141
  controller.error(err);
138
142
  } finally {
139
- // If it is NOT a redirect, we close. If it is a redirect, we leave hanging (your master trick).
140
143
  if (!isRedirecting) {
141
144
  controller.close();
142
145
  }
@@ -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) => {
@@ -380,7 +415,7 @@ function getContextForServerFunctionEndpoint(req, res) {
380
415
  }
381
416
 
382
417
  // SCENARIO B: Streaming active (Headers Sent)
383
- // Inject JavaScript.
418
+ // Send safe stream command.
384
419
 
385
420
  // 🛑 Security: JS cannot write HttpOnly cookies
386
421
  if (options && options.httpOnly) {
@@ -404,11 +439,11 @@ function getContextForServerFunctionEndpoint(req, res) {
404
439
  if (options.sameSite) cookieStr += `; samesite=${options.sameSite}`;
405
440
  }
406
441
 
407
- // Safe packaging for injection
442
+ // Safe packaging for stream command
408
443
  const safeCookieStr = JSON.stringify(cookieStr);
409
444
 
410
445
  // Write to stream
411
- res.write(`<script>document.cookie = ${safeCookieStr};</script>`);
446
+ res.write(`D:{"type":"cookie","cookie":${safeCookieStr}}\n`);
412
447
  },
413
448
 
414
449
  clearCookie: (name, options) => {
@@ -421,13 +456,10 @@ function getContextForServerFunctionEndpoint(req, res) {
421
456
  return;
422
457
  }
423
458
 
424
- // SCENARIO B: Script Injection
425
- const safeName = JSON.stringify(name);
426
- const safePath = JSON.stringify(path);
427
-
428
- res.write(
429
- `<script>document.cookie = ${safeName} + "=; Max-Age=0; path=" + ${safePath} + ";";</script>`,
430
- );
459
+ // SCENARIO B: Custom stream command
460
+ const cookieStr = `${name}=; Max-Age=0; path=${path};`;
461
+ const safeCookieStr = JSON.stringify(cookieStr);
462
+ res.write(`D:{"type":"cookie","cookie":${safeCookieStr}}\n`);
431
463
  },
432
464
  },
433
465
  };
@@ -533,6 +565,11 @@ async function serveRSCPayload(req, res, isOld = false, isStatic = false) {
533
565
  isNotFound,
534
566
  isDevelopment,
535
567
  );
568
+
569
+ if (res.headersSent) {
570
+ return;
571
+ }
572
+
536
573
  const manifest = isDevelopment
537
574
  ? JSON.parse(
538
575
  readFileSync(
@@ -935,18 +972,17 @@ app.post("/____server_function____", async (req, res) => {
935
972
  } catch (err) {
936
973
  // 💡 WE INTERCEPT THE REDIRECT
937
974
  if (err && err.$$type === "dinou-internal-redirect") {
938
- // 1. Always sanitize the URL
939
975
  const safeUrl = JSON.stringify(err.url);
940
- const script = `<script>window.location.href = ${safeUrl};</script>`;
941
976
 
942
977
  if (!res.headersSent) {
943
- // SCENARIO A: Clean (Content-Type html)
944
- res.setHeader("Content-Type", "text/html");
945
- return res.send(script); // res.send calls end() and return stops the function
978
+ // SCENARIO A: Clean (Content-Type application/json)
979
+ res.setHeader("Content-Type", "application/json");
980
+ res.setHeader("X-Dinou-Redirect", err.url);
981
+ return res.status(200).json({ redirect: err.url });
946
982
  } else {
947
- // SCENARIO B: Dirty/Active Stream (Content-Type already set by clearCookie)
948
- // Write the script to the existing stream
949
- res.write(script);
983
+ // SCENARIO B: Dirty/Active Stream
984
+ // Write a custom line-based stream command
985
+ res.write(`D:{"type":"redirect","url":${safeUrl}}\n`);
950
986
 
951
987
  // ⚠️ IMPORTANT:
952
988
  // 1. We close the response, since we redirected and there will be no RSC payload.
@@ -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.11",
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.11",
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": {