@alepha/react 0.5.0 → 0.5.1

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.mjs ADDED
@@ -0,0 +1,477 @@
1
+ import { t, $logger, $inject, Alepha, $hook, autoInject } from '@alepha/core';
2
+ import { ServerProvider, $route, BadRequestError, ServerModule, ServerLinksProvider } from '@alepha/server';
3
+ import { R as Router, $ as $page, P as PageDescriptorProvider } from './useRouterState-CvFCmaq7.mjs';
4
+ export { N as NestedView, k as ReactBrowserProvider, j as RedirectException, a as RouterContext, f as RouterHookApi, b as RouterLayerContext, p as pageDescriptorKey, u as useActive, c as useClient, d as useInject, e as useQueryParams, g as useRouter, h as useRouterEvents, i as useRouterState } from './useRouterState-CvFCmaq7.mjs';
5
+ import { existsSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { renderToString } from 'react-dom/server';
9
+ import crypto from 'node:crypto';
10
+ import { $cache } from '@alepha/cache';
11
+ import { SecurityProvider } from '@alepha/security';
12
+ import { discovery, allowInsecureRequests, refreshTokenGrant, randomPKCECodeVerifier, calculatePKCECodeChallenge, buildAuthorizationUrl, authorizationCodeGrant, buildEndSessionUrl } from 'openid-client';
13
+ import 'react';
14
+ import 'react-dom/client';
15
+ import 'path-to-regexp';
16
+
17
+ const envSchema$1 = t.object({
18
+ REACT_SERVER_DIST: t.string({ default: "client" }),
19
+ REACT_SERVER_PREFIX: t.string({ default: "" }),
20
+ REACT_SSR_ENABLED: t.boolean({ default: false }),
21
+ REACT_SSR_OUTLET: t.string({ default: "<!--ssr-outlet-->" })
22
+ });
23
+ class ReactServerProvider {
24
+ log = $logger();
25
+ alepha = $inject(Alepha);
26
+ router = $inject(Router);
27
+ server = $inject(ServerProvider);
28
+ env = $inject(envSchema$1);
29
+ configure = $hook({
30
+ name: "configure",
31
+ handler: async () => {
32
+ await this.configureRoutes();
33
+ }
34
+ });
35
+ async configureRoutes() {
36
+ if (this.alepha.isTest()) {
37
+ this.processDescriptors();
38
+ }
39
+ if (this.router.empty()) {
40
+ return;
41
+ }
42
+ if (process.env.VITE_ALEPHA_DEV === "true") {
43
+ this.log.info("SSR starting in development mode");
44
+ const templateUrl = `${this.server.hostname}/index.html`;
45
+ this.log.debug(`Fetch template from ${templateUrl}`);
46
+ const route2 = this.createHandler(
47
+ () => fetch(templateUrl).then((it) => it.text()).catch(() => void 0)
48
+ );
49
+ await this.server.route(route2);
50
+ await this.server.route({
51
+ url: "/*",
52
+ // alias for "not found handler"
53
+ handler: route2.handler
54
+ });
55
+ return;
56
+ }
57
+ const maybe = [
58
+ join(process.cwd(), this.env.REACT_SERVER_DIST),
59
+ join(process.cwd(), "..", this.env.REACT_SERVER_DIST),
60
+ join(process.cwd(), "dist", this.env.REACT_SERVER_DIST)
61
+ ];
62
+ let root = "";
63
+ for (const it of maybe) {
64
+ if (existsSync(it)) {
65
+ root = it;
66
+ break;
67
+ }
68
+ }
69
+ if (!root) {
70
+ this.log.warn("Missing static files, SSR will be disabled");
71
+ return;
72
+ }
73
+ await this.server.serve(this.createStaticHandler(root));
74
+ const template = await readFile(join(root, "index.html"), "utf-8");
75
+ const route = this.createHandler(async () => template);
76
+ await this.server.route(route);
77
+ await this.server.route({
78
+ url: "/*",
79
+ // alias for "not found handler"
80
+ handler: route.handler
81
+ });
82
+ }
83
+ /**
84
+ *
85
+ * @param root
86
+ * @protected
87
+ */
88
+ createStaticHandler(root) {
89
+ return {
90
+ root,
91
+ prefix: this.env.REACT_SERVER_PREFIX,
92
+ logLevel: "warn",
93
+ cacheControl: true,
94
+ immutable: true,
95
+ preCompressed: true,
96
+ maxAge: "30d",
97
+ index: false
98
+ };
99
+ }
100
+ /**
101
+ *
102
+ * @param templateLoader
103
+ * @protected
104
+ */
105
+ createHandler(templateLoader) {
106
+ return {
107
+ url: "/",
108
+ handler: async ({ url, user }) => {
109
+ const template = await templateLoader();
110
+ if (!template) {
111
+ return new Response("Not found", { status: 404 });
112
+ }
113
+ const response = this.notFoundHandler(url);
114
+ if (response) {
115
+ return response;
116
+ }
117
+ return await this.ssr(url, template, user);
118
+ }
119
+ };
120
+ }
121
+ processDescriptors() {
122
+ const pages = this.alepha.getDescriptorValues($page);
123
+ for (const { key, instance, value } of pages) {
124
+ instance[key].render = async (options = {}) => {
125
+ const name = value.options.name ?? key;
126
+ const page = this.router.page(name);
127
+ const layers = await this.router.createLayers(
128
+ "",
129
+ page,
130
+ options.params ?? {},
131
+ options.query ?? {},
132
+ []
133
+ );
134
+ return renderToString(
135
+ this.router.root({
136
+ layers,
137
+ pathname: "",
138
+ search: ""
139
+ })
140
+ );
141
+ };
142
+ }
143
+ }
144
+ /**
145
+ *
146
+ * @param url
147
+ * @protected
148
+ */
149
+ notFoundHandler(url) {
150
+ if (url.match(/\.\w+$/)) {
151
+ return new Response("Not found", { status: 404 });
152
+ }
153
+ }
154
+ /**
155
+ *
156
+ * @param url
157
+ * @param template
158
+ * @param user
159
+ */
160
+ async ssr(url, template = this.env.REACT_SSR_OUTLET, user) {
161
+ const { element, layers, redirect } = await this.router.render(url, {
162
+ user
163
+ });
164
+ if (redirect) {
165
+ return new Response("", {
166
+ status: 302,
167
+ headers: {
168
+ Location: redirect
169
+ }
170
+ });
171
+ }
172
+ const appHtml = renderToString(element);
173
+ const script = `<script>window.__ssr=${JSON.stringify({
174
+ layers: layers.map((it) => ({
175
+ ...it,
176
+ index: void 0,
177
+ path: void 0,
178
+ element: void 0
179
+ })),
180
+ session: {
181
+ user: user ? {
182
+ id: user.id,
183
+ name: user.name
184
+ } : void 0
185
+ }
186
+ })}<\/script>`;
187
+ const index = template.indexOf("</body>");
188
+ if (index !== -1) {
189
+ template = template.slice(0, index) + script + template.slice(index);
190
+ }
191
+ return new Response(template.replace(this.env.REACT_SSR_OUTLET, appHtml), {
192
+ headers: { "Content-Type": "text/html" }
193
+ });
194
+ }
195
+ }
196
+
197
+ const sessionUserSchema = t.object({
198
+ id: t.string(),
199
+ name: t.optional(t.string())
200
+ });
201
+ const sessionSchema = t.object({
202
+ user: t.optional(sessionUserSchema)
203
+ });
204
+ const envSchema = t.object({
205
+ REACT_OIDC_ISSUER: t.optional(t.string()),
206
+ REACT_OIDC_CLIENT_ID: t.optional(t.string()),
207
+ REACT_OIDC_CLIENT_SECRET: t.optional(t.string()),
208
+ REACT_OIDC_REDIRECT_URI: t.optional(t.string())
209
+ });
210
+ class ReactSessionProvider {
211
+ SSID = "ssid";
212
+ log = $logger();
213
+ env = $inject(envSchema);
214
+ serverProvider = $inject(ServerProvider);
215
+ securityProvider = $inject(SecurityProvider);
216
+ sessions = $cache();
217
+ clients = [];
218
+ get redirectUri() {
219
+ return this.env.REACT_OIDC_REDIRECT_URI ?? `${this.serverProvider.hostname}/api/callback`;
220
+ }
221
+ configure = $hook({
222
+ name: "configure",
223
+ priority: 100,
224
+ handler: async () => {
225
+ const issuer = this.env.REACT_OIDC_ISSUER;
226
+ const clientId = this.env.REACT_OIDC_CLIENT_ID;
227
+ if (!issuer || !clientId) {
228
+ return;
229
+ }
230
+ const client = await discovery(
231
+ new URL(issuer),
232
+ clientId,
233
+ {
234
+ client_secret: this.env.REACT_OIDC_CLIENT_SECRET
235
+ },
236
+ void 0,
237
+ {
238
+ execute: [allowInsecureRequests]
239
+ }
240
+ );
241
+ this.clients = [client];
242
+ }
243
+ });
244
+ /**
245
+ *
246
+ * @param sessionId
247
+ * @param session
248
+ * @protected
249
+ */
250
+ async setSession(sessionId, session) {
251
+ await this.sessions.set(sessionId, session, {
252
+ days: 1
253
+ });
254
+ }
255
+ /**
256
+ *
257
+ * @param sessionId
258
+ * @protected
259
+ */
260
+ async getSession(sessionId) {
261
+ const session = await this.sessions.get(sessionId);
262
+ if (!session) {
263
+ return;
264
+ }
265
+ const now = Date.now();
266
+ if (session.expires_in && session.issued_at) {
267
+ const expiresAt = session.issued_at + (session.expires_in - 10) * 1e3;
268
+ if (expiresAt < now) {
269
+ if (session.refresh_token) {
270
+ try {
271
+ const newTokens = await refreshTokenGrant(
272
+ this.clients[0],
273
+ session.refresh_token
274
+ );
275
+ await this.setSession(sessionId, {
276
+ ...newTokens,
277
+ issued_at: Date.now()
278
+ });
279
+ return newTokens;
280
+ } catch (e) {
281
+ this.log.error(e, "Failed to refresh token");
282
+ }
283
+ }
284
+ await this.sessions.invalidate(sessionId);
285
+ return;
286
+ }
287
+ }
288
+ if (!session.issued_at && session.access_token) {
289
+ await this.sessions.invalidate(sessionId);
290
+ return;
291
+ }
292
+ return session;
293
+ }
294
+ /**
295
+ *
296
+ * @protected
297
+ */
298
+ beforeRequest = $hook({
299
+ name: "configure:fastify",
300
+ priority: 100,
301
+ handler: async (app) => {
302
+ app.decorateRequest("session");
303
+ app.addHook("onRequest", async (req) => {
304
+ const sessionId = req.cookies[this.SSID];
305
+ if (sessionId && !isViteFile(req.url)) {
306
+ const session = await this.getSession(sessionId);
307
+ if (session) {
308
+ req.session = session;
309
+ if (session.access_token) {
310
+ req.headers.authorization = `Bearer ${session.access_token}`;
311
+ }
312
+ }
313
+ }
314
+ });
315
+ }
316
+ });
317
+ /**
318
+ *
319
+ */
320
+ login = $route({
321
+ security: false,
322
+ url: "/login",
323
+ method: "GET",
324
+ schema: {
325
+ query: t.object({
326
+ redirect: t.optional(t.string())
327
+ })
328
+ },
329
+ handler: async ({ query }) => {
330
+ const client = this.clients[0];
331
+ const codeVerifier = randomPKCECodeVerifier();
332
+ const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
333
+ const scope = "openid profile email";
334
+ const parameters = {
335
+ redirect_uri: this.redirectUri,
336
+ scope,
337
+ code_challenge: codeChallenge,
338
+ code_challenge_method: "S256"
339
+ };
340
+ const sessionId = crypto.randomUUID();
341
+ await this.setSession(sessionId, {
342
+ authorizationCodeGrant: {
343
+ codeVerifier,
344
+ redirectUri: query.redirect ?? "/"
345
+ // TODO: add nonce, max_age, state
346
+ }
347
+ });
348
+ return new Response("", {
349
+ status: 302,
350
+ headers: {
351
+ "Set-Cookie": `${this.SSID}=${sessionId}; HttpOnly; Path=/; SameSite=Lax;`,
352
+ Location: buildAuthorizationUrl(client, parameters).toString()
353
+ }
354
+ });
355
+ }
356
+ });
357
+ /**
358
+ *
359
+ */
360
+ callback = $route({
361
+ security: false,
362
+ url: "/callback",
363
+ method: "GET",
364
+ schema: {
365
+ headers: t.record(t.string(), t.string()),
366
+ cookies: t.object({
367
+ ssid: t.string()
368
+ })
369
+ },
370
+ handler: async ({ cookies, url }) => {
371
+ const sessionId = cookies.ssid;
372
+ const session = await this.getSession(sessionId);
373
+ if (!session) {
374
+ throw new BadRequestError("Missing session");
375
+ }
376
+ if (!session.authorizationCodeGrant) {
377
+ throw new BadRequestError("Invalid session - missing code verifier");
378
+ }
379
+ const [, search] = url.split("?");
380
+ const tokens = await authorizationCodeGrant(
381
+ this.clients[0],
382
+ new URL(`${this.redirectUri}?${search}`),
383
+ {
384
+ pkceCodeVerifier: session.authorizationCodeGrant.codeVerifier,
385
+ expectedNonce: session.authorizationCodeGrant.nonce,
386
+ expectedState: session.authorizationCodeGrant.state,
387
+ maxAge: session.authorizationCodeGrant.max_age
388
+ }
389
+ );
390
+ await this.setSession(sessionId, {
391
+ ...tokens,
392
+ issued_at: Date.now()
393
+ });
394
+ return new Response("", {
395
+ status: 302,
396
+ headers: {
397
+ Location: session.authorizationCodeGrant.redirectUri ?? "/"
398
+ }
399
+ });
400
+ }
401
+ });
402
+ logout = $route({
403
+ security: false,
404
+ url: "/logout",
405
+ method: "GET",
406
+ schema: {
407
+ query: t.object({
408
+ redirect: t.optional(t.string())
409
+ }),
410
+ cookies: t.object({
411
+ ssid: t.string()
412
+ })
413
+ },
414
+ handler: async ({ query, cookies }, { fastify }) => {
415
+ const session = fastify?.req.session;
416
+ await this.sessions.invalidate(cookies.ssid);
417
+ const redirect = query.redirect ?? "/";
418
+ const params = new URLSearchParams();
419
+ params.set("post_logout_redirect_uri", redirect);
420
+ if (session?.id_token) {
421
+ params.set("id_token_hint", session.id_token);
422
+ }
423
+ return new Response("", {
424
+ status: 302,
425
+ headers: {
426
+ "Set-Cookie": `${this.SSID}=; HttpOnly; Path=/; SameSite=Lax;`,
427
+ Location: buildEndSessionUrl(this.clients[0], params).toString()
428
+ }
429
+ });
430
+ }
431
+ });
432
+ session = $route({
433
+ security: false,
434
+ url: "/_session",
435
+ method: "GET",
436
+ schema: {
437
+ headers: t.object({
438
+ authorization: t.string()
439
+ }),
440
+ response: sessionSchema
441
+ },
442
+ handler: async ({ headers }) => {
443
+ try {
444
+ return {
445
+ user: await this.securityProvider.createUserFromToken(
446
+ headers.authorization
447
+ )
448
+ };
449
+ } catch (e) {
450
+ return {};
451
+ }
452
+ }
453
+ });
454
+ }
455
+ const isViteFile = (file) => {
456
+ const [pathname] = file.split("?");
457
+ if (pathname.startsWith("/docs")) {
458
+ return false;
459
+ }
460
+ if (pathname.match(/\.\w{2,5}$/)) {
461
+ return true;
462
+ }
463
+ if (pathname.startsWith("/@")) {
464
+ return true;
465
+ }
466
+ return false;
467
+ };
468
+
469
+ class ReactModule {
470
+ alepha = $inject(Alepha);
471
+ constructor() {
472
+ this.alepha.with(ServerModule).with(ServerLinksProvider).with(ReactServerProvider).with(ReactSessionProvider).with(PageDescriptorProvider);
473
+ }
474
+ }
475
+ autoInject($page, ReactModule);
476
+
477
+ export { $page, PageDescriptorProvider, ReactModule, ReactServerProvider, ReactSessionProvider, Router, envSchema$1 as envSchema, sessionSchema, sessionUserSchema };