aplosjs 0.15.0
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/README.md +28 -0
- package/aplos.config.dist.js +30 -0
- package/bin/aplos +60 -0
- package/create-aplos/index.js +95 -0
- package/create-aplos/package.json +29 -0
- package/create-aplos/templates/minimal/README.md +38 -0
- package/create-aplos/templates/minimal/_gitignore +7 -0
- package/create-aplos/templates/minimal/aplos.config.js +13 -0
- package/create-aplos/templates/minimal/package.json +22 -0
- package/create-aplos/templates/minimal/public/favicon.svg +4 -0
- package/create-aplos/templates/minimal/src/pages/_app.tsx +6 -0
- package/create-aplos/templates/minimal/src/pages/about.tsx +24 -0
- package/create-aplos/templates/minimal/src/pages/index.tsx +40 -0
- package/create-aplos/templates/minimal/src/styles/global.css +53 -0
- package/create-aplos/templates/minimal/tsconfig.json +18 -0
- package/package.json +92 -0
- package/postcss.config.js +9 -0
- package/rspack.config.js +306 -0
- package/rspack.ssr.config.js +129 -0
- package/src/build/config.js +42 -0
- package/src/build/css-noop-loader.cjs +3 -0
- package/src/build/router.js +609 -0
- package/src/build/ssg.js +198 -0
- package/src/client/public/index.html +8 -0
- package/src/command/build.js +105 -0
- package/src/command/create.js +91 -0
- package/src/command/devServer.js +198 -0
- package/src/command/router.js +137 -0
- package/src/components/head.jsx +65 -0
- package/src/components/navigation.jsx +11 -0
- package/src/config.js +5 -0
- package/src/pages/_app.tsx +9 -0
- package/src/pages/blog/[slug].tsx +6 -0
- package/src/pages/crash.tsx +6 -0
- package/src/pages/index.tsx +10 -0
- package/src/pages/test.tsx +5 -0
- package/src/runtime/DefaultErrorPage.jsx +76 -0
- package/src/runtime/ErrorBoundary.jsx +40 -0
- package/src/runtime/MiddlewareGate.jsx +149 -0
- package/src/runtime/app-ssr.jsx +42 -0
- package/src/runtime/app.jsx +126 -0
- package/src/runtime/default-middleware.js +10 -0
- package/src/runtime/default-not-found.jsx +3 -0
- package/src/runtime/passthrough-layout.jsx +5 -0
- package/src/runtime/redirect.js +46 -0
- package/src/runtime/ssr-entry.jsx +104 -0
- package/templates/minimal/README.md +38 -0
- package/templates/minimal/_gitignore +7 -0
- package/templates/minimal/aplos.config.js +13 -0
- package/templates/minimal/package.json +22 -0
- package/templates/minimal/public/favicon.svg +4 -0
- package/templates/minimal/src/pages/_app.tsx +6 -0
- package/templates/minimal/src/pages/about.tsx +24 -0
- package/templates/minimal/src/pages/index.tsx +40 -0
- package/templates/minimal/src/styles/global.css +53 -0
- package/templates/minimal/tsconfig.json +18 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { buildRouter } from "../build/router.js";
|
|
2
|
+
import get_config from "../build/config.js";
|
|
3
|
+
import Table from "cli-table3";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
// Function to check if URL matches a route pattern with requirements
|
|
8
|
+
const matchRoute = (url, routePath, requirements = {}) => {
|
|
9
|
+
// Convert React Router path to regex
|
|
10
|
+
let pattern = routePath.replace(/\//g, '\\/'); // Escape forward slashes
|
|
11
|
+
|
|
12
|
+
// Get parameter names first
|
|
13
|
+
const paramNames = [...routePath.matchAll(/:([^/]+)/g)].map(m => m[1]);
|
|
14
|
+
|
|
15
|
+
// Replace each parameter with its requirement pattern or default
|
|
16
|
+
paramNames.forEach(paramName => {
|
|
17
|
+
const requirement = requirements[paramName] || '[^/]+';
|
|
18
|
+
pattern = pattern.replace(`:${paramName}`, `(${requirement})`);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const regex = new RegExp(`^${pattern}$`);
|
|
22
|
+
const match = url.match(regex);
|
|
23
|
+
|
|
24
|
+
if (match) {
|
|
25
|
+
// Extract parameters
|
|
26
|
+
const params = {};
|
|
27
|
+
paramNames.forEach((name, index) => {
|
|
28
|
+
params[name] = match[index + 1];
|
|
29
|
+
});
|
|
30
|
+
return { match: true, params };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { match: false };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default async (options) => {
|
|
37
|
+
let projectDirectory = process.cwd();
|
|
38
|
+
|
|
39
|
+
await buildRouter(await get_config(projectDirectory));
|
|
40
|
+
|
|
41
|
+
const data = fs
|
|
42
|
+
.readFileSync(projectDirectory + "/.aplos/cache/router.js")
|
|
43
|
+
.toString();
|
|
44
|
+
const routes = JSON.parse(data);
|
|
45
|
+
|
|
46
|
+
// Handle router:match command
|
|
47
|
+
if (options && options.url) {
|
|
48
|
+
const url = options.url;
|
|
49
|
+
let matchedRoute = null;
|
|
50
|
+
let matchParams = {};
|
|
51
|
+
|
|
52
|
+
// Test each route
|
|
53
|
+
for (const route of routes) {
|
|
54
|
+
// Skip config routes without path
|
|
55
|
+
if (!route.path) continue;
|
|
56
|
+
|
|
57
|
+
const result = matchRoute(url, route.path, route.requirements || route.requirement || {});
|
|
58
|
+
if (result.match) {
|
|
59
|
+
matchedRoute = route;
|
|
60
|
+
matchParams = result.params;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const table = new Table({
|
|
66
|
+
head: ["Property", "Value"],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (matchedRoute) {
|
|
70
|
+
table.push(["Route Name", matchedRoute.component]);
|
|
71
|
+
table.push(["Path", matchedRoute.path]);
|
|
72
|
+
table.push(["File", matchedRoute.file ? `src/pages${matchedRoute.file}` : "-"]);
|
|
73
|
+
const regexPattern = matchedRoute.path.replace(/\//g, '\\/').replace(/:([^/]+)/g, '(?P<$1>[^/]++)');
|
|
74
|
+
table.push(["Path Regex", `{^${regexPattern}$}`]);
|
|
75
|
+
table.push(["Host", "ANY"]);
|
|
76
|
+
table.push(["Scheme", "ANY"]);
|
|
77
|
+
const reqs = matchedRoute.requirements || matchedRoute.requirement || {};
|
|
78
|
+
table.push(["Requirements", Object.keys(reqs).length > 0 ? JSON.stringify(reqs) : "NO CUSTOM"]);
|
|
79
|
+
|
|
80
|
+
if (Object.keys(matchParams).length > 0) {
|
|
81
|
+
table.push(["Parameters", JSON.stringify(matchParams)]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log(`✅ URL "${url}" matches route:`);
|
|
85
|
+
console.log(table.toString());
|
|
86
|
+
} else {
|
|
87
|
+
console.log(`❌ URL "${url}" does not match any route.`);
|
|
88
|
+
console.log("\nAvailable routes:");
|
|
89
|
+
const availableTable = new Table({
|
|
90
|
+
head: ["Component", "Path"],
|
|
91
|
+
});
|
|
92
|
+
routes.forEach(route => {
|
|
93
|
+
if (route.path) {
|
|
94
|
+
availableTable.push([route.component, route.path]);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
console.log(availableTable.toString());
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (typeof options === "string") {
|
|
103
|
+
const table = new Table({
|
|
104
|
+
head: ["Property", "Value"],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const route = routes.find((route) => route.component === options);
|
|
108
|
+
if (route) {
|
|
109
|
+
table.push(["Route name", route.path]);
|
|
110
|
+
|
|
111
|
+
table.push(["Path", route.path]);
|
|
112
|
+
|
|
113
|
+
table.push(["File", route.file ? `src/pages${route.file}` : "-"]);
|
|
114
|
+
|
|
115
|
+
table.push(["Path Regex", ""]);
|
|
116
|
+
|
|
117
|
+
table.push(["Host", ""]);
|
|
118
|
+
|
|
119
|
+
table.push(["Scheme", ""]);
|
|
120
|
+
|
|
121
|
+
table.push(["Requirement", route.requirement]);
|
|
122
|
+
|
|
123
|
+
console.log(table.toString());
|
|
124
|
+
} else {
|
|
125
|
+
console.log("Component not found");
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
const table = new Table({
|
|
129
|
+
head: ["Component", "File", "Scheme", "Host", "Path"],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
routes.map((route) => {
|
|
133
|
+
table.push([route.component, route.file ? `src/pages${route.file}` : "-", "Any", "Any", route.path]);
|
|
134
|
+
});
|
|
135
|
+
console.log(table.toString());
|
|
136
|
+
}
|
|
137
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, Children } from "react";
|
|
2
|
+
|
|
3
|
+
const MANAGED_ATTR = "data-head";
|
|
4
|
+
|
|
5
|
+
export default function Head({ children }) {
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const managedElements = [];
|
|
8
|
+
|
|
9
|
+
Children.forEach(children, (child) => {
|
|
10
|
+
if (!child || !child.props) return;
|
|
11
|
+
|
|
12
|
+
const { type } = child;
|
|
13
|
+
const { children: textContent, ...props } = child.props;
|
|
14
|
+
|
|
15
|
+
if (type === "title") {
|
|
16
|
+
document.title = typeof textContent === "string" ? textContent : "";
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (type === "html") {
|
|
21
|
+
Object.entries(props).forEach(([key, value]) => {
|
|
22
|
+
document.documentElement.setAttribute(key, value);
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (type === "body") {
|
|
28
|
+
Object.entries(props).forEach(([key, value]) => {
|
|
29
|
+
document.body.setAttribute(key, value);
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const el = document.createElement(type);
|
|
35
|
+
el.setAttribute(MANAGED_ATTR, "true");
|
|
36
|
+
|
|
37
|
+
Object.entries(props).forEach(([key, value]) => {
|
|
38
|
+
if (key === "crossOrigin") {
|
|
39
|
+
el.setAttribute("crossorigin", value);
|
|
40
|
+
} else if (key === "hrefLang") {
|
|
41
|
+
el.setAttribute("hreflang", value);
|
|
42
|
+
} else {
|
|
43
|
+
el.setAttribute(key, value);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (type === "script" && textContent) {
|
|
48
|
+
el.textContent = typeof textContent === "string" ? textContent : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
document.head.appendChild(el);
|
|
52
|
+
managedElements.push(el);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return () => {
|
|
56
|
+
managedElements.forEach((el) => {
|
|
57
|
+
if (el.parentNode) {
|
|
58
|
+
el.parentNode.removeChild(el);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
}, [children]);
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export default function DefaultErrorPage({ error }) {
|
|
2
|
+
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
3
|
+
|
|
4
|
+
if (isDevelopment) {
|
|
5
|
+
return (
|
|
6
|
+
<div style={{
|
|
7
|
+
padding: '20px',
|
|
8
|
+
backgroundColor: '#fff5f5',
|
|
9
|
+
border: '1px solid #fed7d7',
|
|
10
|
+
borderRadius: '6px',
|
|
11
|
+
margin: '20px',
|
|
12
|
+
fontFamily: 'monospace'
|
|
13
|
+
}}>
|
|
14
|
+
<h1 style={{ color: '#c53030', fontSize: '24px', marginBottom: '16px' }}>
|
|
15
|
+
Something went wrong
|
|
16
|
+
</h1>
|
|
17
|
+
<h2 style={{ color: '#2d3748', fontSize: '18px', marginBottom: '12px' }}>
|
|
18
|
+
{error && error.toString()}
|
|
19
|
+
</h2>
|
|
20
|
+
<details style={{ whiteSpace: 'pre-wrap', fontSize: '14px', color: '#4a5568' }}>
|
|
21
|
+
<summary style={{ cursor: 'pointer', marginBottom: '8px' }}>
|
|
22
|
+
Click to see stack trace
|
|
23
|
+
</summary>
|
|
24
|
+
{error?.stack}
|
|
25
|
+
</details>
|
|
26
|
+
<button
|
|
27
|
+
onClick={() => window.location.reload()}
|
|
28
|
+
style={{
|
|
29
|
+
marginTop: '16px',
|
|
30
|
+
padding: '8px 16px',
|
|
31
|
+
backgroundColor: '#4299e1',
|
|
32
|
+
color: 'white',
|
|
33
|
+
border: 'none',
|
|
34
|
+
borderRadius: '4px',
|
|
35
|
+
cursor: 'pointer'
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
Reload page
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div style={{
|
|
46
|
+
display: 'flex',
|
|
47
|
+
flexDirection: 'column',
|
|
48
|
+
alignItems: 'center',
|
|
49
|
+
justifyContent: 'center',
|
|
50
|
+
minHeight: '50vh',
|
|
51
|
+
textAlign: 'center',
|
|
52
|
+
padding: '20px'
|
|
53
|
+
}}>
|
|
54
|
+
<h2 style={{ fontSize: '24px', marginBottom: '8px', color: '#2d3748' }}>
|
|
55
|
+
Oops! Something went wrong
|
|
56
|
+
</h2>
|
|
57
|
+
<p style={{ fontSize: '16px', color: '#718096', marginBottom: '24px' }}>
|
|
58
|
+
We're sorry, but something unexpected happened. Please try refreshing the page.
|
|
59
|
+
</p>
|
|
60
|
+
<button
|
|
61
|
+
onClick={() => window.location.reload()}
|
|
62
|
+
style={{
|
|
63
|
+
padding: '12px 24px',
|
|
64
|
+
backgroundColor: '#4299e1',
|
|
65
|
+
color: 'white',
|
|
66
|
+
border: 'none',
|
|
67
|
+
borderRadius: '6px',
|
|
68
|
+
fontSize: '16px',
|
|
69
|
+
cursor: 'pointer'
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
Refresh page
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export default function ErrorBoundary({ errorComponent, children }) {
|
|
4
|
+
const [hasError, setHasError] = React.useState(false);
|
|
5
|
+
const [error, setError] = React.useState(null);
|
|
6
|
+
|
|
7
|
+
React.useEffect(() => {
|
|
8
|
+
const handleError = (error, errorInfo) => {
|
|
9
|
+
setHasError(true);
|
|
10
|
+
setError(error);
|
|
11
|
+
|
|
12
|
+
if (process.env.NODE_ENV === 'development') {
|
|
13
|
+
console.error('Error caught by boundary:', error, errorInfo);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const handleGlobalError = (event) => {
|
|
18
|
+
handleError(event.error, { componentStack: event.error?.stack });
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const handleUnhandledRejection = (event) => {
|
|
22
|
+
handleError(event.reason, { componentStack: event.reason?.stack });
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
window.addEventListener('error', handleGlobalError);
|
|
26
|
+
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
|
27
|
+
|
|
28
|
+
return () => {
|
|
29
|
+
window.removeEventListener('error', handleGlobalError);
|
|
30
|
+
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
|
31
|
+
};
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
if (hasError) {
|
|
35
|
+
const ErrorComponent = errorComponent;
|
|
36
|
+
return <ErrorComponent error={error} />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return children;
|
|
40
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useLocation, useNavigate } from 'react-router-dom';
|
|
3
|
+
import userMiddleware from '@aplos_middleware';
|
|
4
|
+
import defaultMiddleware from 'aplos/internal/default-middleware';
|
|
5
|
+
import { isRedirect } from './redirect.js';
|
|
6
|
+
|
|
7
|
+
// When the project defines no `src/middleware.*`, the generated cache
|
|
8
|
+
// re-exports the framework no-op, so `userMiddleware` is identically the
|
|
9
|
+
// default function (ESM re-export preserves reference identity). In that
|
|
10
|
+
// case there is no decision to make: skip the gate entirely so apps that
|
|
11
|
+
// don't use middleware pay zero cost — no `null` frame, no extra render,
|
|
12
|
+
// no layout effect on every navigation.
|
|
13
|
+
const HAS_MIDDLEWARE = userMiddleware !== defaultMiddleware;
|
|
14
|
+
|
|
15
|
+
// A middleware can redirect to a path it also intercepts. If the chain never
|
|
16
|
+
// reaches a route that renders (each target redirects again), navigation never
|
|
17
|
+
// settles: the app freezes on a blank `null` with navigate() looping. No
|
|
18
|
+
// amount of documentation prevents a user from writing that by accident, so
|
|
19
|
+
// the runtime breaks the loop itself. This bounds *consecutive* redirects
|
|
20
|
+
// with no route rendered in between — a legitimate auth→onboarding→dashboard
|
|
21
|
+
// hop is a handful; anything past this is a cycle.
|
|
22
|
+
const MAX_REDIRECT_CHAIN = 20;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runs the project route middleware before the matched route renders.
|
|
26
|
+
*
|
|
27
|
+
* Placed inside `<BrowserRouter>` and wrapping `<Routes>`, it intercepts every
|
|
28
|
+
* navigation: it calls the user middleware with the current location, and if
|
|
29
|
+
* the middleware returns `redirect(...)` it navigates away *before* committing
|
|
30
|
+
* the guarded route to the DOM. This avoids the "flash of protected content"
|
|
31
|
+
* you get when guarding inside a `useEffect` in a layout/page.
|
|
32
|
+
*
|
|
33
|
+
* The decision runs synchronously in a layout effect (pre-paint). While a
|
|
34
|
+
* redirect is pending we render `null` so the guarded tree is never painted.
|
|
35
|
+
*
|
|
36
|
+
* A middleware may be sync or async. Async middleware cannot block the first
|
|
37
|
+
* paint (we can't await before rendering), so it renders `null` until the
|
|
38
|
+
* promise settles — async is intended for cases where a brief blank frame is
|
|
39
|
+
* acceptable (e.g. token refresh). Prefer sync middleware for auth guards.
|
|
40
|
+
*/
|
|
41
|
+
export default function MiddlewareGate({ children }) {
|
|
42
|
+
// `HAS_MIDDLEWARE` is a module constant — it never changes for the life of
|
|
43
|
+
// the process — so branching on it here does not violate the Rules of
|
|
44
|
+
// Hooks: a given render path always calls the same hooks. Apps without a
|
|
45
|
+
// middleware render through with no gate machinery at all.
|
|
46
|
+
if (!HAS_MIDDLEWARE) {
|
|
47
|
+
return children;
|
|
48
|
+
}
|
|
49
|
+
return <ActiveMiddlewareGate>{children}</ActiveMiddlewareGate>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ActiveMiddlewareGate({ children }) {
|
|
53
|
+
const location = useLocation();
|
|
54
|
+
const navigate = useNavigate();
|
|
55
|
+
|
|
56
|
+
// Key the decision on the full location so back/forward and query changes
|
|
57
|
+
// re-run the middleware. `null` => undecided (don't paint yet).
|
|
58
|
+
const [decidedFor, setDecidedFor] = useState(null);
|
|
59
|
+
const pendingRef = useRef(null);
|
|
60
|
+
// Counts consecutive redirects with no route rendered in between. Reset to
|
|
61
|
+
// 0 whenever a navigation is allowed through (a route renders), so it only
|
|
62
|
+
// grows while the middleware is bouncing the user without ever settling.
|
|
63
|
+
const redirectChainRef = useRef(0);
|
|
64
|
+
|
|
65
|
+
const locationKey = location.pathname + location.search;
|
|
66
|
+
|
|
67
|
+
useLayoutEffect(() => {
|
|
68
|
+
let cancelled = false;
|
|
69
|
+
|
|
70
|
+
function letThrough() {
|
|
71
|
+
redirectChainRef.current = 0;
|
|
72
|
+
setDecidedFor(locationKey);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function applyResult(result) {
|
|
76
|
+
if (cancelled) return;
|
|
77
|
+
if (isRedirect(result)) {
|
|
78
|
+
if (redirectChainRef.current >= MAX_REDIRECT_CHAIN) {
|
|
79
|
+
// The middleware is in a redirect cycle (e.g. it redirects
|
|
80
|
+
// to a path it also intercepts, with no exit condition).
|
|
81
|
+
// Fail open like a thrown middleware: a guard bug must not
|
|
82
|
+
// freeze the app. The user sees the route instead of a
|
|
83
|
+
// hung blank page, and the error names the cause.
|
|
84
|
+
console.error(
|
|
85
|
+
`[aplos] route middleware redirected ${redirectChainRef.current} ` +
|
|
86
|
+
`times without settling — aborting the redirect chain to ` +
|
|
87
|
+
`avoid an infinite loop. Check that your redirect target ` +
|
|
88
|
+
`is not itself redirected (its condition must become ` +
|
|
89
|
+
`false after the redirect).`,
|
|
90
|
+
);
|
|
91
|
+
letThrough();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
redirectChainRef.current += 1;
|
|
95
|
+
navigate(result.to, { replace: result.replace });
|
|
96
|
+
// Leave `decidedFor` unset for this location: the navigate()
|
|
97
|
+
// will change the location and re-run this effect for the new
|
|
98
|
+
// target, which is where the decision that matters is made.
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
letThrough();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let result;
|
|
105
|
+
try {
|
|
106
|
+
result = userMiddleware({
|
|
107
|
+
pathname: location.pathname,
|
|
108
|
+
search: location.search,
|
|
109
|
+
searchParams: new URLSearchParams(location.search),
|
|
110
|
+
hash: location.hash,
|
|
111
|
+
state: location.state,
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// A throwing middleware must not wedge navigation. Surface it and
|
|
115
|
+
// fail open (let the route render) so the app stays usable.
|
|
116
|
+
console.error('[aplos] route middleware threw:', error);
|
|
117
|
+
letThrough();
|
|
118
|
+
return () => {
|
|
119
|
+
cancelled = true;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (result && typeof result.then === 'function') {
|
|
124
|
+
const token = {};
|
|
125
|
+
pendingRef.current = token;
|
|
126
|
+
Promise.resolve(result)
|
|
127
|
+
.then((resolved) => {
|
|
128
|
+
if (pendingRef.current === token) applyResult(resolved);
|
|
129
|
+
})
|
|
130
|
+
.catch((error) => {
|
|
131
|
+
if (pendingRef.current !== token) return;
|
|
132
|
+
console.error('[aplos] async route middleware rejected:', error);
|
|
133
|
+
if (!cancelled) letThrough();
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
applyResult(result);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return () => {
|
|
140
|
+
cancelled = true;
|
|
141
|
+
};
|
|
142
|
+
}, [locationKey, location.pathname, location.search, location.hash, location.state, navigate]);
|
|
143
|
+
|
|
144
|
+
if (decidedFor !== locationKey) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return children;
|
|
149
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React, { createElement } from 'react';
|
|
2
|
+
import { StaticRouter, Routes, Route } from 'react-router';
|
|
3
|
+
|
|
4
|
+
import { routeTree } from '@aplos_routes';
|
|
5
|
+
import { CustomError, NoMatch } from '@aplos_pages';
|
|
6
|
+
import { reactStrictMode } from '@aplos_head';
|
|
7
|
+
|
|
8
|
+
import ErrorBoundary from './ErrorBoundary.jsx';
|
|
9
|
+
import DefaultErrorPage from './DefaultErrorPage.jsx';
|
|
10
|
+
|
|
11
|
+
function renderRoutes(nodes) {
|
|
12
|
+
return nodes.map((node, i) => {
|
|
13
|
+
if (node.children) {
|
|
14
|
+
return (
|
|
15
|
+
<Route key={i} element={createElement(node.element)}>
|
|
16
|
+
{renderRoutes(node.children)}
|
|
17
|
+
</Route>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return <Route key={i} path={node.path} element={createElement(node.element)} />;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function AppSSR({ url }) {
|
|
25
|
+
const ErrorComponent = CustomError || DefaultErrorPage;
|
|
26
|
+
const tree = (
|
|
27
|
+
<ErrorBoundary errorComponent={ErrorComponent}>
|
|
28
|
+
<StaticRouter location={url}>
|
|
29
|
+
<Routes>
|
|
30
|
+
{renderRoutes(routeTree)}
|
|
31
|
+
<Route path="*" element={createElement(NoMatch)} />
|
|
32
|
+
</Routes>
|
|
33
|
+
</StaticRouter>
|
|
34
|
+
</ErrorBoundary>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (reactStrictMode) {
|
|
38
|
+
const { StrictMode } = React;
|
|
39
|
+
return <StrictMode>{tree}</StrictMode>;
|
|
40
|
+
}
|
|
41
|
+
return tree;
|
|
42
|
+
}
|