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