@alepha/react 0.5.1 → 0.6.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/dist/index.browser.cjs +23 -19
- package/dist/index.browser.js +18 -0
- package/dist/index.cjs +419 -340
- package/dist/{index.d.cts → index.d.ts} +591 -420
- package/dist/index.js +554 -0
- package/dist/{useRouterState-BlKHWZwk.cjs → useAuth-DOVx2kqa.cjs} +243 -102
- package/dist/{useRouterState-CvFCmaq7.mjs → useAuth-i7wbKVrt.js} +214 -77
- package/package.json +21 -23
- package/src/components/Link.tsx +22 -0
- package/src/components/NestedView.tsx +2 -2
- package/src/constants/SSID.ts +1 -0
- package/src/contexts/RouterContext.ts +2 -2
- package/src/descriptors/$auth.ts +28 -0
- package/src/descriptors/$page.ts +57 -3
- package/src/errors/RedirectionError.ts +7 -0
- package/src/hooks/useAuth.ts +29 -0
- package/src/hooks/useInject.ts +3 -3
- package/src/index.browser.ts +3 -1
- package/src/index.shared.ts +14 -3
- package/src/index.ts +23 -6
- package/src/providers/ReactAuthProvider.ts +410 -0
- package/src/providers/ReactBrowserProvider.ts +41 -19
- package/src/providers/ReactServerProvider.ts +106 -49
- package/src/services/Auth.ts +45 -0
- package/src/services/Router.ts +154 -41
- package/dist/index.browser.mjs +0 -17
- package/dist/index.d.mts +0 -872
- package/dist/index.mjs +0 -477
- package/src/providers/ReactSessionProvider.ts +0 -363
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { $hook, $inject, $logger } from "@alepha/core";
|
|
2
|
+
import type { UserAccountToken } from "@alepha/security";
|
|
2
3
|
import { HttpClient } from "@alepha/server";
|
|
3
4
|
import type { Root } from "react-dom/client";
|
|
4
5
|
import { createRoot, hydrateRoot } from "react-dom/client";
|
|
@@ -8,7 +9,8 @@ import type {
|
|
|
8
9
|
RouterState,
|
|
9
10
|
} from "../services/Router";
|
|
10
11
|
import { Router } from "../services/Router";
|
|
11
|
-
import type {
|
|
12
|
+
import type { RouterRenderHelmetContext } from "../services/Router";
|
|
13
|
+
import type { ReactHydrationState } from "./ReactAuthProvider";
|
|
12
14
|
|
|
13
15
|
export class ReactBrowserProvider {
|
|
14
16
|
protected readonly log = $logger();
|
|
@@ -20,7 +22,12 @@ export class ReactBrowserProvider {
|
|
|
20
22
|
to: string;
|
|
21
23
|
};
|
|
22
24
|
|
|
23
|
-
public state: RouterState = {
|
|
25
|
+
public state: RouterState = {
|
|
26
|
+
layers: [],
|
|
27
|
+
pathname: "",
|
|
28
|
+
search: "",
|
|
29
|
+
context: {},
|
|
30
|
+
};
|
|
24
31
|
|
|
25
32
|
/**
|
|
26
33
|
*
|
|
@@ -125,23 +132,21 @@ export class ReactBrowserProvider {
|
|
|
125
132
|
return { url };
|
|
126
133
|
}
|
|
127
134
|
|
|
135
|
+
protected renderHelmetContext(ctx: RouterRenderHelmetContext) {
|
|
136
|
+
if (ctx.title) {
|
|
137
|
+
this.document.title = ctx.title;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
/**
|
|
129
142
|
* Get embedded layers from the server.
|
|
130
143
|
*
|
|
131
144
|
* @protected
|
|
132
145
|
*/
|
|
133
|
-
protected
|
|
134
|
-
| {
|
|
135
|
-
session?: Session;
|
|
136
|
-
layers?: PreviousLayerData[];
|
|
137
|
-
}
|
|
138
|
-
| undefined {
|
|
146
|
+
protected getHydrationState(): ReactHydrationState | undefined {
|
|
139
147
|
try {
|
|
140
148
|
if ("__ssr" in window && typeof window.__ssr === "object") {
|
|
141
|
-
return window.__ssr as
|
|
142
|
-
session?: Session;
|
|
143
|
-
layers?: PreviousLayerData[];
|
|
144
|
-
};
|
|
149
|
+
return window.__ssr as ReactHydrationState;
|
|
145
150
|
}
|
|
146
151
|
} catch (error) {
|
|
147
152
|
console.error(error);
|
|
@@ -166,6 +171,20 @@ export class ReactBrowserProvider {
|
|
|
166
171
|
return div;
|
|
167
172
|
}
|
|
168
173
|
|
|
174
|
+
protected getUserFromCookies(): UserAccountToken | undefined {
|
|
175
|
+
const cookies = this.document.cookie.split("; ");
|
|
176
|
+
const userCookie = cookies.find((cookie) => cookie.startsWith("user="));
|
|
177
|
+
try {
|
|
178
|
+
if (userCookie) {
|
|
179
|
+
return JSON.parse(decodeURIComponent(userCookie.split("=")[1]));
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
this.log.warn(error, "Failed to parse user cookie");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
169
188
|
// -------------------------------------------------------------------------------------------------------------------
|
|
170
189
|
|
|
171
190
|
/**
|
|
@@ -175,17 +194,14 @@ export class ReactBrowserProvider {
|
|
|
175
194
|
protected ready = $hook({
|
|
176
195
|
name: "ready",
|
|
177
196
|
handler: async () => {
|
|
178
|
-
const cache = this.
|
|
197
|
+
const cache = this.getHydrationState();
|
|
179
198
|
const previous = cache?.layers ?? [];
|
|
180
199
|
|
|
181
|
-
// if session
|
|
182
|
-
const session =
|
|
183
|
-
cache?.session ??
|
|
184
|
-
(await this.client.of<ReactSessionProvider>().session());
|
|
185
|
-
|
|
186
200
|
await this.render({ previous });
|
|
187
201
|
|
|
188
|
-
const element = this.router.root(this.state,
|
|
202
|
+
const element = this.router.root(this.state, {
|
|
203
|
+
user: cache?.user ?? this.getUserFromCookies(),
|
|
204
|
+
});
|
|
189
205
|
|
|
190
206
|
if (previous.length > 0) {
|
|
191
207
|
this.root = hydrateRoot(this.getRootElement(), element);
|
|
@@ -199,6 +215,12 @@ export class ReactBrowserProvider {
|
|
|
199
215
|
window.addEventListener("popstate", () => {
|
|
200
216
|
this.render();
|
|
201
217
|
});
|
|
218
|
+
|
|
219
|
+
this.router.on("end", ({ context }) => {
|
|
220
|
+
if (context.helmet) {
|
|
221
|
+
this.renderHelmetContext(context.helmet);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
202
224
|
},
|
|
203
225
|
});
|
|
204
226
|
|
|
@@ -3,15 +3,14 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { $logger, Alepha, type Static } from "@alepha/core";
|
|
5
5
|
import { $hook, $inject, t } from "@alepha/core";
|
|
6
|
-
import type { UserAccountInfo } from "@alepha/security";
|
|
7
6
|
import {
|
|
8
|
-
type
|
|
7
|
+
type CreateRoute,
|
|
9
8
|
type ServeDescriptorOptions,
|
|
10
9
|
ServerProvider,
|
|
11
10
|
} from "@alepha/server";
|
|
12
11
|
import { renderToString } from "react-dom/server";
|
|
13
|
-
import { $page } from "../descriptors/$page";
|
|
14
|
-
import { Router } from "../services/Router";
|
|
12
|
+
import { $page, type PageContext } from "../descriptors/$page";
|
|
13
|
+
import { Router, type RouterRenderHelmetContext } from "../services/Router";
|
|
15
14
|
|
|
16
15
|
export const envSchema = t.object({
|
|
17
16
|
REACT_SERVER_DIST: t.string({ default: "client" }),
|
|
@@ -22,6 +21,9 @@ export const envSchema = t.object({
|
|
|
22
21
|
|
|
23
22
|
declare module "@alepha/core" {
|
|
24
23
|
interface Env extends Partial<Static<typeof envSchema>> {}
|
|
24
|
+
interface State {
|
|
25
|
+
"ReactServerProvider.template"?: string;
|
|
26
|
+
}
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export class ReactServerProvider {
|
|
@@ -48,49 +50,55 @@ export class ReactServerProvider {
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
if (process.env.VITE_ALEPHA_DEV === "true") {
|
|
51
|
-
this.log.info("SSR
|
|
52
|
-
const templateUrl =
|
|
53
|
+
this.log.info("SSR (vite) OK");
|
|
54
|
+
const templateUrl = "http://127.0.0.1:5173/index.html"; // TODO: use env variable from vite
|
|
53
55
|
this.log.debug(`Fetch template from ${templateUrl}`);
|
|
54
56
|
|
|
55
57
|
const route = this.createHandler(() =>
|
|
56
58
|
fetch(templateUrl)
|
|
57
59
|
.then((it) => it.text())
|
|
58
|
-
.catch(() => undefined)
|
|
60
|
+
.catch(() => undefined)
|
|
61
|
+
.then((it) => (it ? this.checkTemplate(it) : undefined)),
|
|
59
62
|
);
|
|
60
63
|
|
|
61
64
|
await this.server.route(route);
|
|
62
65
|
|
|
63
66
|
// fallback for static files
|
|
64
67
|
await this.server.route({
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
...route,
|
|
69
|
+
url: "*",
|
|
67
70
|
});
|
|
68
71
|
|
|
69
72
|
return;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
const maybe = [
|
|
73
|
-
join(process.cwd(), this.env.REACT_SERVER_DIST),
|
|
74
|
-
join(process.cwd(), "..", this.env.REACT_SERVER_DIST),
|
|
75
|
-
join(process.cwd(), "dist", this.env.REACT_SERVER_DIST),
|
|
76
|
-
];
|
|
77
|
-
|
|
78
75
|
let root = "";
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
|
|
77
|
+
if (!this.alepha.isServerless()) {
|
|
78
|
+
const maybe = [
|
|
79
|
+
join(process.cwd(), this.env.REACT_SERVER_DIST),
|
|
80
|
+
join(process.cwd(), "..", this.env.REACT_SERVER_DIST),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
for (const it of maybe) {
|
|
84
|
+
if (existsSync(it)) {
|
|
85
|
+
root = it;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
83
88
|
}
|
|
84
|
-
}
|
|
85
89
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
if (!root) {
|
|
91
|
+
this.log.warn("Missing static files, SSR will be disabled");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
await this.server.serve(this.createStaticHandler(root));
|
|
96
|
+
}
|
|
92
97
|
|
|
93
|
-
const template =
|
|
98
|
+
const template = this.checkTemplate(
|
|
99
|
+
this.alepha.state("ReactServerProvider.template") ??
|
|
100
|
+
(await readFile(join(root, "index.html"), "utf-8")),
|
|
101
|
+
);
|
|
94
102
|
|
|
95
103
|
const route = this.createHandler(async () => template);
|
|
96
104
|
|
|
@@ -98,11 +106,34 @@ export class ReactServerProvider {
|
|
|
98
106
|
|
|
99
107
|
// fallback for static files
|
|
100
108
|
await this.server.route({
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
...route,
|
|
110
|
+
url: "*",
|
|
103
111
|
});
|
|
104
112
|
}
|
|
105
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Check if the template contains the outlet.
|
|
116
|
+
*
|
|
117
|
+
* @param template
|
|
118
|
+
* @protected
|
|
119
|
+
*/
|
|
120
|
+
protected checkTemplate(template: string) {
|
|
121
|
+
if (!template.includes(this.env.REACT_SSR_OUTLET)) {
|
|
122
|
+
if (!template.includes('<div id="root"></div>')) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Missing React SSR outlet in index.html, please add ${this.env.REACT_SSR_OUTLET} to the index.html file`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return template.replace(
|
|
129
|
+
`<div id="root"></div>`,
|
|
130
|
+
`<div id="root">${this.env.REACT_SSR_OUTLET}</div>`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return template;
|
|
135
|
+
}
|
|
136
|
+
|
|
106
137
|
/**
|
|
107
138
|
*
|
|
108
139
|
* @param root
|
|
@@ -128,22 +159,26 @@ export class ReactServerProvider {
|
|
|
128
159
|
*/
|
|
129
160
|
protected createHandler(
|
|
130
161
|
templateLoader: () => Promise<string | undefined>,
|
|
131
|
-
):
|
|
162
|
+
): CreateRoute {
|
|
132
163
|
return {
|
|
164
|
+
method: "GET",
|
|
133
165
|
url: "/",
|
|
134
|
-
handler: async (
|
|
166
|
+
handler: async (ctx) => {
|
|
135
167
|
const template = await templateLoader();
|
|
136
168
|
if (!template) {
|
|
137
169
|
return new Response("Not found", { status: 404 });
|
|
138
170
|
}
|
|
139
171
|
|
|
140
|
-
const response = this.notFoundHandler(url);
|
|
172
|
+
const response = this.notFoundHandler(ctx.url);
|
|
141
173
|
if (response) {
|
|
142
174
|
// not found handler for static files (favicon, css, js, etc)
|
|
143
175
|
return response;
|
|
144
176
|
}
|
|
145
177
|
|
|
146
|
-
return await this.ssr(url, template,
|
|
178
|
+
return await this.ssr(ctx.url, template, {
|
|
179
|
+
user: ctx.user,
|
|
180
|
+
cookies: ctx.cookies,
|
|
181
|
+
});
|
|
147
182
|
},
|
|
148
183
|
};
|
|
149
184
|
}
|
|
@@ -172,6 +207,7 @@ export class ReactServerProvider {
|
|
|
172
207
|
layers,
|
|
173
208
|
pathname: "",
|
|
174
209
|
search: "",
|
|
210
|
+
context: {},
|
|
175
211
|
}),
|
|
176
212
|
);
|
|
177
213
|
};
|
|
@@ -183,8 +219,8 @@ export class ReactServerProvider {
|
|
|
183
219
|
* @param url
|
|
184
220
|
* @protected
|
|
185
221
|
*/
|
|
186
|
-
protected notFoundHandler(url:
|
|
187
|
-
if (url.match(/\.\w+$/)) {
|
|
222
|
+
protected notFoundHandler(url: URL) {
|
|
223
|
+
if (url.pathname.match(/\.\w+$/)) {
|
|
188
224
|
return new Response("Not found", { status: 404 });
|
|
189
225
|
}
|
|
190
226
|
}
|
|
@@ -193,16 +229,19 @@ export class ReactServerProvider {
|
|
|
193
229
|
*
|
|
194
230
|
* @param url
|
|
195
231
|
* @param template
|
|
196
|
-
* @param
|
|
232
|
+
* @param page
|
|
197
233
|
*/
|
|
198
234
|
public async ssr(
|
|
199
|
-
url:
|
|
235
|
+
url: URL,
|
|
200
236
|
template: string = this.env.REACT_SSR_OUTLET,
|
|
201
|
-
|
|
237
|
+
page: PageContext = {},
|
|
202
238
|
): Promise<Response> {
|
|
203
|
-
const { element, layers, redirect } = await this.router.render(
|
|
204
|
-
|
|
205
|
-
|
|
239
|
+
const { element, layers, redirect, context } = await this.router.render(
|
|
240
|
+
url.pathname + url.search,
|
|
241
|
+
{
|
|
242
|
+
args: page,
|
|
243
|
+
},
|
|
244
|
+
);
|
|
206
245
|
|
|
207
246
|
if (redirect) {
|
|
208
247
|
return new Response("", {
|
|
@@ -222,14 +261,6 @@ export class ReactServerProvider {
|
|
|
222
261
|
path: undefined,
|
|
223
262
|
element: undefined,
|
|
224
263
|
})),
|
|
225
|
-
session: {
|
|
226
|
-
user: user
|
|
227
|
-
? {
|
|
228
|
-
id: user.id,
|
|
229
|
-
name: user.name,
|
|
230
|
-
}
|
|
231
|
-
: undefined,
|
|
232
|
-
},
|
|
233
264
|
})}</script>`;
|
|
234
265
|
|
|
235
266
|
const index = template.indexOf("</body>");
|
|
@@ -237,8 +268,34 @@ export class ReactServerProvider {
|
|
|
237
268
|
template = template.slice(0, index) + script + template.slice(index);
|
|
238
269
|
}
|
|
239
270
|
|
|
240
|
-
|
|
271
|
+
if (context.helmet) {
|
|
272
|
+
template = this.renderHelmetContext(template, context.helmet);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
template = template.replace(this.env.REACT_SSR_OUTLET, appHtml);
|
|
276
|
+
|
|
277
|
+
return new Response(template, {
|
|
241
278
|
headers: { "Content-Type": "text/html" },
|
|
242
279
|
});
|
|
243
280
|
}
|
|
281
|
+
|
|
282
|
+
protected renderHelmetContext(
|
|
283
|
+
template: string,
|
|
284
|
+
helmetContext: RouterRenderHelmetContext,
|
|
285
|
+
) {
|
|
286
|
+
if (helmetContext.title) {
|
|
287
|
+
if (template.includes("<title>")) {
|
|
288
|
+
template = template.replace(
|
|
289
|
+
/<title>.*<\/title>/,
|
|
290
|
+
`<title>${helmetContext.title}</title>`,
|
|
291
|
+
);
|
|
292
|
+
} else {
|
|
293
|
+
template = template.replace(
|
|
294
|
+
"</head>",
|
|
295
|
+
`<title>${helmetContext.title}</title></head>`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return template;
|
|
300
|
+
}
|
|
244
301
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { $hook, $inject, $logger, Alepha } from "@alepha/core";
|
|
2
|
+
import { HttpClient } from "@alepha/server";
|
|
3
|
+
import { RedirectionError } from "../errors/RedirectionError";
|
|
4
|
+
import { ReactBrowserProvider } from "../providers/ReactBrowserProvider";
|
|
5
|
+
|
|
6
|
+
export class Auth {
|
|
7
|
+
alepha = $inject(Alepha);
|
|
8
|
+
log = $logger();
|
|
9
|
+
client = $inject(HttpClient);
|
|
10
|
+
api = "/api/_oauth/login";
|
|
11
|
+
|
|
12
|
+
start = $hook({
|
|
13
|
+
name: "start",
|
|
14
|
+
handler: async () => {
|
|
15
|
+
this.client.on("onError", (err) => {
|
|
16
|
+
if (err.statusCode === 401) {
|
|
17
|
+
this.login();
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
login = (provider?: string) => {
|
|
24
|
+
if (this.alepha.isBrowser()) {
|
|
25
|
+
const browser = this.alepha.get(ReactBrowserProvider);
|
|
26
|
+
const redirect = browser.transitioning
|
|
27
|
+
? window.location.origin + browser.transitioning.to
|
|
28
|
+
: window.location.href;
|
|
29
|
+
|
|
30
|
+
window.location.href = `${this.api}?redirect=${redirect}`;
|
|
31
|
+
|
|
32
|
+
if (browser.transitioning) {
|
|
33
|
+
throw new RedirectionError(browser.state.pathname);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new RedirectionError(this.api);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
logout = () => {
|
|
43
|
+
window.location.href = `/api/_oauth/logout?redirect=${encodeURIComponent(window.location.origin)}`;
|
|
44
|
+
};
|
|
45
|
+
}
|