@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.
@@ -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 { ReactSessionProvider, Session } from "./ReactSessionProvider";
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 = { layers: [], pathname: "", search: "" };
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 getEmbeddedCache():
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.getEmbeddedCache();
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, session);
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 RouteObject,
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 starting in development mode");
52
- const templateUrl = `${this.server.hostname}/index.html`;
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
- url: "/*", // alias for "not found handler"
66
- handler: route.handler,
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
- for (const it of maybe) {
80
- if (existsSync(it)) {
81
- root = it;
82
- break;
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
- if (!root) {
87
- this.log.warn("Missing static files, SSR will be disabled");
88
- return;
89
- }
90
+ if (!root) {
91
+ this.log.warn("Missing static files, SSR will be disabled");
92
+ return;
93
+ }
90
94
 
91
- await this.server.serve(this.createStaticHandler(root));
95
+ await this.server.serve(this.createStaticHandler(root));
96
+ }
92
97
 
93
- const template = await readFile(join(root, "index.html"), "utf-8");
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
- url: "/*", // alias for "not found handler"
102
- handler: route.handler,
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
- ): RouteObject {
162
+ ): CreateRoute {
132
163
  return {
164
+ method: "GET",
133
165
  url: "/",
134
- handler: async ({ url, user }) => {
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, user);
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: string) {
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 user
232
+ * @param page
197
233
  */
198
234
  public async ssr(
199
- url: string,
235
+ url: URL,
200
236
  template: string = this.env.REACT_SSR_OUTLET,
201
- user?: UserAccountInfo,
237
+ page: PageContext = {},
202
238
  ): Promise<Response> {
203
- const { element, layers, redirect } = await this.router.render(url, {
204
- user,
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
- return new Response(template.replace(this.env.REACT_SSR_OUTLET, appHtml), {
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
+ }