@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.js ADDED
@@ -0,0 +1,554 @@
1
+ import { $logger, $inject, Alepha, t, $hook, autoInject } from '@alepha/core';
2
+ import { FastifyCookieProvider, $cookie, $route, BadRequestError, ServerProvider, ServerModule, ServerLinksProvider } from '@alepha/server';
3
+ import { $ as $auth, R as Router, a as $page, A as Auth, P as PageDescriptorProvider } from './useAuth-i7wbKVrt.js';
4
+ export { L as Link, N as NestedView, l as ReactBrowserProvider, m as RedirectionError, b as RouterContext, d as RouterHookApi, c as RouterLayerContext, p as pageDescriptorKey, j as useActive, k as useAuth, e as useClient, u as useInject, f as useQueryParams, g as useRouter, h as useRouterEvents, i as useRouterState } from './useAuth-i7wbKVrt.js';
5
+ import { discovery, allowInsecureRequests, refreshTokenGrant, randomPKCECodeVerifier, calculatePKCECodeChallenge, buildAuthorizationUrl, authorizationCodeGrant, buildEndSessionUrl } from 'openid-client';
6
+ import { existsSync } from 'node:fs';
7
+ import { readFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { renderToString } from 'react-dom/server';
10
+ import 'react/jsx-runtime';
11
+ import 'react';
12
+ import 'react-dom/client';
13
+ import 'path-to-regexp';
14
+
15
+ class ReactAuthProvider {
16
+ log = $logger();
17
+ alepha = $inject(Alepha);
18
+ fastifyCookieProvider = $inject(FastifyCookieProvider);
19
+ authProviders = [];
20
+ authorizationCode = $cookie({
21
+ name: "authorizationCode",
22
+ ttl: { minutes: 15 },
23
+ httpOnly: true,
24
+ schema: t.object({
25
+ codeVerifier: t.optional(t.string({ size: "long" })),
26
+ redirectUri: t.optional(t.string({ size: "long" }))
27
+ })
28
+ });
29
+ tokens = $cookie({
30
+ name: "tokens",
31
+ ttl: { days: 1 },
32
+ httpOnly: true,
33
+ compress: true,
34
+ schema: t.object({
35
+ access_token: t.optional(t.string({ size: "rich" })),
36
+ expires_in: t.optional(t.number()),
37
+ refresh_token: t.optional(t.string({ size: "rich" })),
38
+ id_token: t.optional(t.string({ size: "rich" })),
39
+ scope: t.optional(t.string()),
40
+ issued_at: t.optional(t.number())
41
+ })
42
+ });
43
+ user = $cookie({
44
+ name: "user",
45
+ ttl: { days: 1 },
46
+ schema: t.object({
47
+ id: t.string(),
48
+ name: t.optional(t.string()),
49
+ email: t.optional(t.string())
50
+ })
51
+ });
52
+ configure = $hook({
53
+ name: "configure",
54
+ handler: async () => {
55
+ const auths = this.alepha.getDescriptorValues($auth);
56
+ for (const auth of auths) {
57
+ const options = auth.value.options;
58
+ if (options.oidc) {
59
+ this.authProviders.push({
60
+ name: options.name ?? auth.key,
61
+ redirectUri: options.oidc.redirectUri ?? "/api/_oauth/callback",
62
+ client: await discovery(
63
+ new URL(options.oidc.issuer),
64
+ options.oidc.clientId,
65
+ {
66
+ client_secret: options.oidc.clientSecret
67
+ },
68
+ void 0,
69
+ {
70
+ execute: [allowInsecureRequests]
71
+ }
72
+ )
73
+ });
74
+ }
75
+ }
76
+ }
77
+ });
78
+ /**
79
+ * Configure Fastify to forward Session Access Token to Header Authorization.
80
+ */
81
+ configureFastify = $hook({
82
+ name: "configure:fastify",
83
+ after: this.fastifyCookieProvider,
84
+ handler: async (app) => {
85
+ app.addHook("onRequest", async (req) => {
86
+ if (req.cookies && !this.isViteFile(req.url) && !!this.authProviders.length) {
87
+ const tokens = await this.refresh(req.cookies);
88
+ if (tokens) {
89
+ req.headers.authorization = `Bearer ${tokens.access_token}`;
90
+ }
91
+ if (this.user.get(req.cookies) && !this.tokens.get(req.cookies)) {
92
+ this.user.del(req.cookies);
93
+ }
94
+ }
95
+ });
96
+ }
97
+ });
98
+ /**
99
+ *
100
+ * @param cookies
101
+ * @protected
102
+ */
103
+ async refresh(cookies) {
104
+ const now = Date.now();
105
+ const tokens = this.tokens.get(cookies);
106
+ if (!tokens) {
107
+ return;
108
+ }
109
+ if (tokens.expires_in && tokens.issued_at) {
110
+ const expiresAt = tokens.issued_at + (tokens.expires_in - 10) * 1e3;
111
+ if (expiresAt < now) {
112
+ if (tokens.refresh_token) {
113
+ try {
114
+ const newTokens = await refreshTokenGrant(
115
+ this.authProviders[0].client,
116
+ tokens.refresh_token
117
+ );
118
+ this.tokens.set(cookies, {
119
+ ...newTokens,
120
+ issued_at: Date.now()
121
+ });
122
+ return newTokens;
123
+ } catch (e) {
124
+ this.log.warn(e, "Failed to refresh token -");
125
+ }
126
+ }
127
+ this.tokens.del(cookies);
128
+ this.user.del(cookies);
129
+ return;
130
+ }
131
+ }
132
+ if (!tokens.issued_at && tokens.access_token) {
133
+ this.tokens.del(cookies);
134
+ this.user.del(cookies);
135
+ return;
136
+ }
137
+ return tokens;
138
+ }
139
+ /**
140
+ *
141
+ */
142
+ login = $route({
143
+ security: false,
144
+ private: true,
145
+ url: "/_oauth/login",
146
+ group: "auth",
147
+ method: "GET",
148
+ schema: {
149
+ query: t.object({
150
+ redirect: t.optional(t.string()),
151
+ provider: t.optional(t.string())
152
+ })
153
+ },
154
+ handler: async ({ query, cookies, url }) => {
155
+ const { client } = this.provider(query.provider);
156
+ const codeVerifier = randomPKCECodeVerifier();
157
+ const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
158
+ const scope = "openid profile email";
159
+ let redirect_uri = this.authProviders[0].redirectUri;
160
+ if (redirect_uri.startsWith("/")) {
161
+ redirect_uri = `${url.protocol}://${url.host}${redirect_uri}`;
162
+ }
163
+ const parameters = {
164
+ redirect_uri,
165
+ scope,
166
+ code_challenge: codeChallenge,
167
+ code_challenge_method: "S256"
168
+ };
169
+ this.authorizationCode.set(cookies, {
170
+ codeVerifier,
171
+ redirectUri: query.redirect ?? "/"
172
+ });
173
+ return new Response("", {
174
+ status: 302,
175
+ headers: {
176
+ Location: buildAuthorizationUrl(client, parameters).toString()
177
+ }
178
+ });
179
+ }
180
+ });
181
+ /**
182
+ *
183
+ */
184
+ callback = $route({
185
+ security: false,
186
+ private: true,
187
+ url: "/_oauth/callback",
188
+ group: "auth",
189
+ method: "GET",
190
+ schema: {
191
+ query: t.object({
192
+ provider: t.optional(t.string())
193
+ })
194
+ },
195
+ handler: async ({ url, cookies, query }) => {
196
+ const { client } = this.provider(query.provider);
197
+ const authorizationCode = this.authorizationCode.get(cookies);
198
+ if (!authorizationCode) {
199
+ throw new BadRequestError("Missing code verifier");
200
+ }
201
+ const tokens = await authorizationCodeGrant(client, url, {
202
+ pkceCodeVerifier: authorizationCode.codeVerifier
203
+ });
204
+ this.authorizationCode.del(cookies);
205
+ this.tokens.set(cookies, {
206
+ ...tokens,
207
+ issued_at: Date.now()
208
+ });
209
+ const user = this.userFromAccessToken(tokens.access_token);
210
+ if (user) {
211
+ this.user.set(cookies, user);
212
+ }
213
+ return Response.redirect(authorizationCode.redirectUri ?? "/");
214
+ }
215
+ });
216
+ /**
217
+ *
218
+ * @param accessToken
219
+ * @protected
220
+ */
221
+ userFromAccessToken(accessToken) {
222
+ try {
223
+ const parts = accessToken.split(".");
224
+ if (parts.length !== 3) {
225
+ return;
226
+ }
227
+ const payload = parts[1];
228
+ const decoded = JSON.parse(atob(payload));
229
+ if (!decoded.sub) {
230
+ return;
231
+ }
232
+ return {
233
+ id: decoded.sub,
234
+ name: decoded.name,
235
+ email: decoded.email
236
+ // organization
237
+ // ...
238
+ };
239
+ } catch (e) {
240
+ this.log.warn(e, "Failed to decode access token");
241
+ }
242
+ }
243
+ /**
244
+ *
245
+ */
246
+ logout = $route({
247
+ security: false,
248
+ private: true,
249
+ url: "/_oauth/logout",
250
+ group: "auth",
251
+ method: "GET",
252
+ schema: {
253
+ query: t.object({
254
+ redirect: t.optional(t.string()),
255
+ provider: t.optional(t.string())
256
+ })
257
+ },
258
+ handler: async ({ query, cookies }) => {
259
+ const { client } = this.provider(query.provider);
260
+ const tokens = this.tokens.get(cookies);
261
+ const idToken = tokens?.id_token;
262
+ const redirect = query.redirect ?? "/";
263
+ const params = new URLSearchParams();
264
+ params.set("post_logout_redirect_uri", redirect);
265
+ if (idToken) {
266
+ params.set("id_token_hint", idToken);
267
+ }
268
+ this.tokens.del(cookies);
269
+ this.user.del(cookies);
270
+ return Response.redirect(buildEndSessionUrl(client, params).toString());
271
+ }
272
+ });
273
+ /**
274
+ *
275
+ * @param name
276
+ * @protected
277
+ */
278
+ provider(name) {
279
+ if (!name) {
280
+ const client = this.authProviders[0];
281
+ if (!client) {
282
+ throw new BadRequestError("Client name is required");
283
+ }
284
+ return client;
285
+ }
286
+ const authProvider = this.authProviders.find(
287
+ (provider) => provider.name === name
288
+ );
289
+ if (!authProvider) {
290
+ throw new BadRequestError(`Client ${name} not found`);
291
+ }
292
+ return authProvider;
293
+ }
294
+ /**
295
+ *
296
+ * @param file
297
+ * @protected
298
+ */
299
+ isViteFile(file) {
300
+ const [pathname] = file.split("?");
301
+ if (pathname.startsWith("/docs")) {
302
+ return false;
303
+ }
304
+ if (pathname.match(/\.\w{2,5}$/)) {
305
+ return true;
306
+ }
307
+ if (pathname.startsWith("/@")) {
308
+ return true;
309
+ }
310
+ return false;
311
+ }
312
+ }
313
+
314
+ const envSchema$1 = t.object({
315
+ REACT_SERVER_DIST: t.string({ default: "client" }),
316
+ REACT_SERVER_PREFIX: t.string({ default: "" }),
317
+ REACT_SSR_ENABLED: t.boolean({ default: false }),
318
+ REACT_SSR_OUTLET: t.string({ default: "<!--ssr-outlet-->" })
319
+ });
320
+ class ReactServerProvider {
321
+ log = $logger();
322
+ alepha = $inject(Alepha);
323
+ router = $inject(Router);
324
+ server = $inject(ServerProvider);
325
+ env = $inject(envSchema$1);
326
+ configure = $hook({
327
+ name: "configure",
328
+ handler: async () => {
329
+ await this.configureRoutes();
330
+ }
331
+ });
332
+ async configureRoutes() {
333
+ if (this.alepha.isTest()) {
334
+ this.processDescriptors();
335
+ }
336
+ if (this.router.empty()) {
337
+ return;
338
+ }
339
+ if (process.env.VITE_ALEPHA_DEV === "true") {
340
+ this.log.info("SSR (vite) OK");
341
+ const templateUrl = "http://127.0.0.1:5173/index.html";
342
+ this.log.debug(`Fetch template from ${templateUrl}`);
343
+ const route2 = this.createHandler(
344
+ () => fetch(templateUrl).then((it) => it.text()).catch(() => void 0).then((it) => it ? this.checkTemplate(it) : void 0)
345
+ );
346
+ await this.server.route(route2);
347
+ await this.server.route({
348
+ ...route2,
349
+ url: "*"
350
+ });
351
+ return;
352
+ }
353
+ let root = "";
354
+ if (!this.alepha.isServerless()) {
355
+ const maybe = [
356
+ join(process.cwd(), this.env.REACT_SERVER_DIST),
357
+ join(process.cwd(), "..", this.env.REACT_SERVER_DIST)
358
+ ];
359
+ for (const it of maybe) {
360
+ if (existsSync(it)) {
361
+ root = it;
362
+ break;
363
+ }
364
+ }
365
+ if (!root) {
366
+ this.log.warn("Missing static files, SSR will be disabled");
367
+ return;
368
+ }
369
+ await this.server.serve(this.createStaticHandler(root));
370
+ }
371
+ const template = this.checkTemplate(
372
+ this.alepha.state("ReactServerProvider.template") ?? await readFile(join(root, "index.html"), "utf-8")
373
+ );
374
+ const route = this.createHandler(async () => template);
375
+ await this.server.route(route);
376
+ await this.server.route({
377
+ ...route,
378
+ url: "*"
379
+ });
380
+ }
381
+ /**
382
+ * Check if the template contains the outlet.
383
+ *
384
+ * @param template
385
+ * @protected
386
+ */
387
+ checkTemplate(template) {
388
+ if (!template.includes(this.env.REACT_SSR_OUTLET)) {
389
+ if (!template.includes('<div id="root"></div>')) {
390
+ throw new Error(
391
+ `Missing React SSR outlet in index.html, please add ${this.env.REACT_SSR_OUTLET} to the index.html file`
392
+ );
393
+ }
394
+ return template.replace(
395
+ `<div id="root"></div>`,
396
+ `<div id="root">${this.env.REACT_SSR_OUTLET}</div>`
397
+ );
398
+ }
399
+ return template;
400
+ }
401
+ /**
402
+ *
403
+ * @param root
404
+ * @protected
405
+ */
406
+ createStaticHandler(root) {
407
+ return {
408
+ root,
409
+ prefix: this.env.REACT_SERVER_PREFIX,
410
+ logLevel: "warn",
411
+ cacheControl: true,
412
+ immutable: true,
413
+ preCompressed: true,
414
+ maxAge: "30d",
415
+ index: false
416
+ };
417
+ }
418
+ /**
419
+ *
420
+ * @param templateLoader
421
+ * @protected
422
+ */
423
+ createHandler(templateLoader) {
424
+ return {
425
+ method: "GET",
426
+ url: "/",
427
+ handler: async (ctx) => {
428
+ const template = await templateLoader();
429
+ if (!template) {
430
+ return new Response("Not found", { status: 404 });
431
+ }
432
+ const response = this.notFoundHandler(ctx.url);
433
+ if (response) {
434
+ return response;
435
+ }
436
+ return await this.ssr(ctx.url, template, {
437
+ user: ctx.user,
438
+ cookies: ctx.cookies
439
+ });
440
+ }
441
+ };
442
+ }
443
+ processDescriptors() {
444
+ const pages = this.alepha.getDescriptorValues($page);
445
+ for (const { key, instance, value } of pages) {
446
+ instance[key].render = async (options = {}) => {
447
+ const name = value.options.name ?? key;
448
+ const page = this.router.page(name);
449
+ const layers = await this.router.createLayers(
450
+ "",
451
+ page,
452
+ options.params ?? {},
453
+ options.query ?? {},
454
+ []
455
+ );
456
+ return renderToString(
457
+ this.router.root({
458
+ layers,
459
+ pathname: "",
460
+ search: "",
461
+ context: {}
462
+ })
463
+ );
464
+ };
465
+ }
466
+ }
467
+ /**
468
+ *
469
+ * @param url
470
+ * @protected
471
+ */
472
+ notFoundHandler(url) {
473
+ if (url.pathname.match(/\.\w+$/)) {
474
+ return new Response("Not found", { status: 404 });
475
+ }
476
+ }
477
+ /**
478
+ *
479
+ * @param url
480
+ * @param template
481
+ * @param page
482
+ */
483
+ async ssr(url, template = this.env.REACT_SSR_OUTLET, page = {}) {
484
+ const { element, layers, redirect, context } = await this.router.render(
485
+ url.pathname + url.search,
486
+ {
487
+ args: page
488
+ }
489
+ );
490
+ if (redirect) {
491
+ return new Response("", {
492
+ status: 302,
493
+ headers: {
494
+ Location: redirect
495
+ }
496
+ });
497
+ }
498
+ const appHtml = renderToString(element);
499
+ const script = `<script>window.__ssr=${JSON.stringify({
500
+ layers: layers.map((it) => ({
501
+ ...it,
502
+ index: void 0,
503
+ path: void 0,
504
+ element: void 0
505
+ }))
506
+ })}<\/script>`;
507
+ const index = template.indexOf("</body>");
508
+ if (index !== -1) {
509
+ template = template.slice(0, index) + script + template.slice(index);
510
+ }
511
+ if (context.helmet) {
512
+ template = this.renderHelmetContext(template, context.helmet);
513
+ }
514
+ template = template.replace(this.env.REACT_SSR_OUTLET, appHtml);
515
+ return new Response(template, {
516
+ headers: { "Content-Type": "text/html" }
517
+ });
518
+ }
519
+ renderHelmetContext(template, helmetContext) {
520
+ if (helmetContext.title) {
521
+ if (template.includes("<title>")) {
522
+ template = template.replace(
523
+ /<title>.*<\/title>/,
524
+ `<title>${helmetContext.title}</title>`
525
+ );
526
+ } else {
527
+ template = template.replace(
528
+ "</head>",
529
+ `<title>${helmetContext.title}</title></head>`
530
+ );
531
+ }
532
+ }
533
+ return template;
534
+ }
535
+ }
536
+
537
+ const envSchema = t.object({
538
+ REACT_AUTH_ENABLED: t.boolean({ default: false })
539
+ });
540
+ class ReactModule {
541
+ env = $inject(envSchema);
542
+ alepha = $inject(Alepha);
543
+ constructor() {
544
+ this.alepha.with(ServerModule).with(ServerLinksProvider).with(PageDescriptorProvider).with(ReactServerProvider);
545
+ if (this.env.REACT_AUTH_ENABLED) {
546
+ this.alepha.with(ReactAuthProvider);
547
+ this.alepha.with(Auth);
548
+ }
549
+ }
550
+ }
551
+ autoInject($page, ReactModule);
552
+ autoInject($auth, ReactAuthProvider, Auth);
553
+
554
+ export { $auth, $page, Auth, PageDescriptorProvider, ReactAuthProvider, ReactModule, ReactServerProvider, Router, envSchema$1 as envSchema };