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 +23 -0
- package/dinou/core/client-error-webpack.jsx +2 -2
- package/dinou/core/client-error.jsx +2 -2
- package/dinou/core/client-redirect.jsx +11 -4
- package/dinou/core/client-webpack.jsx +41 -16
- package/dinou/core/client.jsx +41 -16
- package/dinou/core/link.jsx +8 -7
- package/dinou/core/navigation-utils.js +35 -2
- package/dinou/core/render-app-to-html.js +29 -7
- package/dinou/core/server-function-proxy-webpack.js +21 -11
- package/dinou/core/server-function-proxy.js +21 -10
- package/dinou/core/server.js +51 -11
- package/dinou/core/url-resolver.js +13 -0
- package/dinou/package.json +1 -1
- package/package.json +1 -1
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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(
|
|
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
|
package/dinou/core/client.jsx
CHANGED
|
@@ -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(
|
|
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
|
package/dinou/core/link.jsx
CHANGED
|
@@ -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__(
|
|
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={
|
|
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,
|
|
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,
|
|
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
|
|
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: ${
|
|
90
|
+
`[Dinou] Streaming active. Redirecting via JavaScript to: ${resolvedUrl}`,
|
|
88
91
|
);
|
|
89
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
!
|
|
214
|
+
typeof resolvedUrl === "string" &&
|
|
215
|
+
resolvedUrl.startsWith("/") &&
|
|
216
|
+
!resolvedUrl.startsWith("//")
|
|
197
217
|
) {
|
|
198
|
-
finalUrl =
|
|
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
|
-
|
|
37
|
-
return
|
|
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
|
-
|
|
54
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
return
|
|
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
|
-
|
|
55
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dinou/core/server.js
CHANGED
|
@@ -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
|
-
|
|
279
|
+
const resolvedUrl = resolveRelativeUrl(targetUrl, req.path);
|
|
280
|
+
let finalUrl = "/";
|
|
276
281
|
if (
|
|
277
|
-
typeof
|
|
278
|
-
|
|
279
|
-
!
|
|
282
|
+
typeof resolvedUrl === "string" &&
|
|
283
|
+
resolvedUrl.startsWith("/") &&
|
|
284
|
+
!resolvedUrl.startsWith("//")
|
|
280
285
|
) {
|
|
281
|
-
|
|
286
|
+
finalUrl = resolvedUrl;
|
|
282
287
|
} else {
|
|
283
|
-
|
|
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
|
|
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:
|
|
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 };
|
package/dinou/package.json
CHANGED