@alepha/react 0.5.2 → 0.6.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.
@@ -1,9 +1,20 @@
1
- import { __descriptor, NotImplementedError, KIND, EventEmitter, $logger, $inject, Alepha, $hook } from '@alepha/core';
2
- import { createContext, useContext, useState, useEffect, createElement, useMemo } from 'react';
1
+ import { __descriptor, KIND, NotImplementedError, EventEmitter, $logger, $inject, Alepha, $hook } from '@alepha/core';
2
+ import { jsx } from 'react/jsx-runtime';
3
+ import React, { createContext, useContext, useState, useEffect, createElement, useMemo } from 'react';
3
4
  import { HttpClient } from '@alepha/server';
4
5
  import { hydrateRoot, createRoot } from 'react-dom/client';
5
6
  import { compile, match } from 'path-to-regexp';
6
7
 
8
+ const KEY = "AUTH";
9
+ const $auth = (options) => {
10
+ __descriptor(KEY);
11
+ return {
12
+ [KIND]: KEY,
13
+ options
14
+ };
15
+ };
16
+ $auth[KIND] = KEY;
17
+
7
18
  const pageDescriptorKey = "PAGE";
8
19
  const $page = (options) => {
9
20
  __descriptor(pageDescriptorKey);
@@ -38,20 +49,21 @@ const NestedView = (props) => {
38
49
  );
39
50
  useEffect(() => {
40
51
  if (app?.alepha.isBrowser()) {
41
- return app?.router.on("end", ({ layers }) => {
42
- setView(layers[index]?.element);
52
+ return app?.router.on("end", (state) => {
53
+ setView(state.layers[index]?.element);
43
54
  });
44
55
  }
45
56
  }, [app]);
46
57
  return view ?? props.children ?? null;
47
58
  };
48
59
 
49
- class RedirectException extends Error {
60
+ class RedirectionError extends Error {
50
61
  constructor(page) {
51
62
  super("Redirection");
52
63
  this.page = page;
53
64
  }
54
65
  }
66
+
55
67
  class Router extends EventEmitter {
56
68
  log = $logger();
57
69
  alepha = $inject(Alepha);
@@ -73,7 +85,7 @@ class Router extends EventEmitter {
73
85
  /**
74
86
  *
75
87
  */
76
- root(state, opts = {}) {
88
+ root(state, context = {}) {
77
89
  return createElement(
78
90
  RouterContext.Provider,
79
91
  {
@@ -81,7 +93,10 @@ class Router extends EventEmitter {
81
93
  state,
82
94
  router: this,
83
95
  alepha: this.alepha,
84
- session: opts.user ? { user: opts.user } : void 0
96
+ args: {
97
+ user: context.user,
98
+ cookies: context.cookies
99
+ }
85
100
  }
86
101
  },
87
102
  state.layers[0]?.element
@@ -97,11 +112,12 @@ class Router extends EventEmitter {
97
112
  const state = {
98
113
  pathname,
99
114
  search,
100
- layers: []
115
+ layers: [],
116
+ context: {}
101
117
  };
102
- this.emit("begin", void 0);
118
+ await this.emit("begin", void 0);
103
119
  try {
104
- let layers = await this.match(url, options);
120
+ let layers = await this.match(url, options, state.context);
105
121
  if (layers.length === 0) {
106
122
  if (this.notFoundPageRoute) {
107
123
  layers = await this.createLayers(url, this.notFoundPageRoute);
@@ -115,13 +131,14 @@ class Router extends EventEmitter {
115
131
  }
116
132
  }
117
133
  state.layers = layers;
118
- this.emit("success", void 0);
134
+ await this.emit("success", void 0);
119
135
  } catch (e) {
120
- if (e instanceof RedirectException) {
136
+ if (e instanceof RedirectionError) {
121
137
  return {
122
138
  element: null,
123
139
  layers: [],
124
- redirect: typeof e.page === "string" ? e.page : this.href(e.page)
140
+ redirect: typeof e.page === "string" ? e.page : this.href(e.page),
141
+ context: state.context
125
142
  };
126
143
  }
127
144
  this.log.error(e);
@@ -133,31 +150,35 @@ class Router extends EventEmitter {
133
150
  path: "/"
134
151
  }
135
152
  ];
136
- this.emit("error", e);
153
+ await this.emit("error", e);
137
154
  }
138
155
  if (options.state) {
139
156
  options.state.layers = state.layers;
140
157
  options.state.pathname = state.pathname;
141
158
  options.state.search = state.search;
142
- this.emit("end", options.state);
159
+ options.state.context = state.context;
160
+ await this.emit("end", options.state);
143
161
  return {
144
- element: this.root(options.state, options),
145
- layers: options.state.layers
162
+ element: this.root(options.state, options.args),
163
+ layers: options.state.layers,
164
+ context: state.context
146
165
  };
147
166
  }
148
- this.emit("end", state);
167
+ await this.emit("end", state);
149
168
  return {
150
- element: this.root(state, options),
151
- layers: state.layers
169
+ element: this.root(state, options.args),
170
+ layers: state.layers,
171
+ context: state.context
152
172
  };
153
173
  }
154
174
  /**
155
175
  *
156
176
  * @param url
157
177
  * @param options
178
+ * @param context
158
179
  * @protected
159
180
  */
160
- async match(url, options = {}) {
181
+ async match(url, options = {}, context = {}) {
161
182
  const pages = this.pages;
162
183
  const previous = options.previous;
163
184
  const [pathname, search] = url.split("?");
@@ -179,7 +200,8 @@ class Router extends EventEmitter {
179
200
  params,
180
201
  query,
181
202
  previous,
182
- options.user
203
+ options.args,
204
+ context
183
205
  );
184
206
  }
185
207
  }
@@ -188,14 +210,16 @@ class Router extends EventEmitter {
188
210
  /**
189
211
  * Create layers for the given route.
190
212
  *
213
+ * @param url
191
214
  * @param route
192
215
  * @param params
193
216
  * @param query
194
217
  * @param previous
195
- * @param user
218
+ * @param args
219
+ * @param renderContext
196
220
  * @protected
197
221
  */
198
- async createLayers(url, route, params = {}, query = {}, previous = [], user) {
222
+ async createLayers(url, route, params = {}, query = {}, previous = [], args, renderContext) {
199
223
  const layers = [];
200
224
  let context = {};
201
225
  const stack = [{ route }];
@@ -210,13 +234,13 @@ class Router extends EventEmitter {
210
234
  const route2 = it.route;
211
235
  const config = {};
212
236
  try {
213
- config.query = route2.schema?.query ? this.alepha.parse(route2.schema.query, query) : {};
237
+ config.query = route2.schema?.query ? this.alepha.parse(route2.schema.query, query) : query;
214
238
  } catch (e) {
215
239
  it.error = e;
216
240
  break;
217
241
  }
218
242
  try {
219
- config.params = route2.schema?.params ? this.alepha.parse(route2.schema.params, params) : {};
243
+ config.params = route2.schema?.params ? this.alepha.parse(route2.schema.params, params) : params;
220
244
  } catch (e) {
221
245
  it.error = e;
222
246
  break;
@@ -248,12 +272,15 @@ class Router extends EventEmitter {
248
272
  forceRefresh = true;
249
273
  }
250
274
  try {
251
- const props = await route2.resolve?.({
252
- ...config,
253
- ...context,
254
- user,
255
- url
256
- }) ?? {};
275
+ const props = await route2.resolve?.(
276
+ {
277
+ ...config,
278
+ ...context,
279
+ context: args,
280
+ url
281
+ },
282
+ args ?? {}
283
+ ) ?? {};
257
284
  it.props = {
258
285
  ...props
259
286
  };
@@ -262,7 +289,7 @@ class Router extends EventEmitter {
262
289
  ...props
263
290
  };
264
291
  } catch (e) {
265
- if (e instanceof RedirectException) {
292
+ if (e instanceof RedirectionError) {
266
293
  throw e;
267
294
  }
268
295
  this.log.error(e);
@@ -278,6 +305,12 @@ class Router extends EventEmitter {
278
305
  for (const key of Object.keys(params2)) {
279
306
  params2[key] = String(params2[key]);
280
307
  }
308
+ if (it.route.helmet && renderContext) {
309
+ this.mergeRenderContext(it.route, renderContext, {
310
+ ...props,
311
+ ...context
312
+ });
313
+ }
281
314
  acc += "/";
282
315
  acc += it.route.path ? compile(it.route.path)(params2) : "";
283
316
  const path = acc.replace(/\/+/, "/");
@@ -344,6 +377,27 @@ class Router extends EventEmitter {
344
377
  }
345
378
  return void 0;
346
379
  }
380
+ /**
381
+ * Merge the render context with the page context.
382
+ *
383
+ * @param page
384
+ * @param ctx
385
+ * @param props
386
+ * @protected
387
+ */
388
+ mergeRenderContext(page, ctx, props) {
389
+ if (page.helmet) {
390
+ const helmet = typeof page.helmet === "function" ? page.helmet(props) : page.helmet;
391
+ if (helmet.title) {
392
+ ctx.helmet ??= {};
393
+ if (ctx.helmet?.title) {
394
+ ctx.helmet.title = `${helmet.title} - ${ctx.helmet.title}`;
395
+ } else {
396
+ ctx.helmet.title = helmet.title;
397
+ }
398
+ }
399
+ }
400
+ }
347
401
  /**
348
402
  *
349
403
  * @param e
@@ -515,7 +569,12 @@ class ReactBrowserProvider {
515
569
  router = $inject(Router);
516
570
  root;
517
571
  transitioning;
518
- state = { layers: [], pathname: "", search: "" };
572
+ state = {
573
+ layers: [],
574
+ pathname: "",
575
+ search: "",
576
+ context: {}
577
+ };
519
578
  /**
520
579
  *
521
580
  */
@@ -597,12 +656,17 @@ class ReactBrowserProvider {
597
656
  this.transitioning = void 0;
598
657
  return { url };
599
658
  }
659
+ renderHelmetContext(ctx) {
660
+ if (ctx.title) {
661
+ this.document.title = ctx.title;
662
+ }
663
+ }
600
664
  /**
601
665
  * Get embedded layers from the server.
602
666
  *
603
667
  * @protected
604
668
  */
605
- getEmbeddedCache() {
669
+ getHydrationState() {
606
670
  try {
607
671
  if ("__ssr" in window && typeof window.__ssr === "object") {
608
672
  return window.__ssr;
@@ -625,6 +689,18 @@ class ReactBrowserProvider {
625
689
  this.document.body.appendChild(div);
626
690
  return div;
627
691
  }
692
+ getUserFromCookies() {
693
+ const cookies = this.document.cookie.split("; ");
694
+ const userCookie = cookies.find((cookie) => cookie.startsWith("user="));
695
+ try {
696
+ if (userCookie) {
697
+ return JSON.parse(decodeURIComponent(userCookie.split("=")[1]));
698
+ }
699
+ } catch (error) {
700
+ this.log.warn(error, "Failed to parse user cookie");
701
+ }
702
+ return void 0;
703
+ }
628
704
  // -------------------------------------------------------------------------------------------------------------------
629
705
  /**
630
706
  *
@@ -633,11 +709,12 @@ class ReactBrowserProvider {
633
709
  ready = $hook({
634
710
  name: "ready",
635
711
  handler: async () => {
636
- const cache = this.getEmbeddedCache();
712
+ const cache = this.getHydrationState();
637
713
  const previous = cache?.layers ?? [];
638
- const session = cache?.session ?? await this.client.of().session();
639
714
  await this.render({ previous });
640
- const element = this.router.root(this.state, session);
715
+ const element = this.router.root(this.state, {
716
+ user: cache?.user ?? this.getUserFromCookies()
717
+ });
641
718
  if (previous.length > 0) {
642
719
  this.root = hydrateRoot(this.getRootElement(), element);
643
720
  this.log.info("Hydrated root element");
@@ -649,6 +726,11 @@ class ReactBrowserProvider {
649
726
  window.addEventListener("popstate", () => {
650
727
  this.render();
651
728
  });
729
+ this.router.on("end", ({ context }) => {
730
+ if (context.helmet) {
731
+ this.renderHelmetContext(context.helmet);
732
+ }
733
+ });
652
734
  }
653
735
  });
654
736
  /**
@@ -666,6 +748,38 @@ class ReactBrowserProvider {
666
748
  });
667
749
  }
668
750
 
751
+ class Auth {
752
+ alepha = $inject(Alepha);
753
+ log = $logger();
754
+ client = $inject(HttpClient);
755
+ api = "/api/_oauth/login";
756
+ start = $hook({
757
+ name: "start",
758
+ handler: async () => {
759
+ this.client.on("onError", (err) => {
760
+ if (err.statusCode === 401) {
761
+ this.login();
762
+ }
763
+ });
764
+ }
765
+ });
766
+ login = (provider) => {
767
+ if (this.alepha.isBrowser()) {
768
+ const browser = this.alepha.get(ReactBrowserProvider);
769
+ const redirect = browser.transitioning ? window.location.origin + browser.transitioning.to : window.location.href;
770
+ window.location.href = `${this.api}?redirect=${redirect}`;
771
+ if (browser.transitioning) {
772
+ throw new RedirectionError(browser.state.pathname);
773
+ }
774
+ return;
775
+ }
776
+ throw new RedirectionError(this.api);
777
+ };
778
+ logout = () => {
779
+ window.location.href = `/api/_oauth/logout?redirect=${encodeURIComponent(window.location.origin)}`;
780
+ };
781
+ }
782
+
669
783
  class RouterHookApi {
670
784
  constructor(state, layer, browser) {
671
785
  this.state = state;
@@ -790,51 +904,18 @@ const useRouter = () => {
790
904
  );
791
905
  };
792
906
 
793
- const useActive = (path) => {
907
+ const Link = (props) => {
908
+ React.useContext(RouterContext);
794
909
  const router = useRouter();
795
- const ctx = useContext(RouterContext);
796
- const layer = useContext(RouterLayerContext);
797
- if (!ctx || !layer) {
798
- throw new Error("useRouter must be used within a RouterProvider");
799
- }
800
- let name;
801
- if (typeof path === "object" && path.options.name) {
802
- name = path.options.name;
803
- }
804
- const [current, setCurrent] = useState(ctx.state.pathname);
805
- const href = useMemo(() => router.createHref(path, layer), [path, layer]);
806
- const [isPending, setPending] = useState(false);
807
- const isActive = current === href;
808
- useEffect(
809
- () => ctx.router.on("end", ({ pathname }) => setCurrent(pathname)),
810
- []
811
- );
812
- return {
813
- name,
814
- isPending,
815
- isActive,
816
- anchorProps: {
817
- href,
818
- onClick: (ev) => {
819
- ev.stopPropagation();
820
- ev.preventDefault();
821
- if (isActive) return;
822
- if (isPending) return;
823
- setPending(true);
824
- router.go(href).then(() => {
825
- setPending(false);
826
- });
827
- }
828
- }
829
- };
910
+ return /* @__PURE__ */ jsx("a", { ...router.createAnchorProps(props.to), ...props, children: props.children });
830
911
  };
831
912
 
832
- const useInject = (classEntry) => {
913
+ const useInject = (clazz) => {
833
914
  const ctx = useContext(RouterContext);
834
915
  if (!ctx) {
835
916
  throw new Error("useRouter must be used within a <RouterProvider>");
836
917
  }
837
- return ctx.alepha.get(classEntry);
918
+ return ctx.alepha.get(clazz);
838
919
  };
839
920
 
840
921
  const useClient = () => {
@@ -923,4 +1004,60 @@ const useRouterState = () => {
923
1004
  return state;
924
1005
  };
925
1006
 
926
- export { $page as $, NestedView as N, PageDescriptorProvider as P, Router as R, RouterContext as a, RouterLayerContext as b, useClient as c, useInject as d, useQueryParams as e, RouterHookApi as f, useRouter as g, useRouterEvents as h, useRouterState as i, RedirectException as j, ReactBrowserProvider as k, pageDescriptorKey as p, useActive as u };
1007
+ const useActive = (path) => {
1008
+ const router = useRouter();
1009
+ const ctx = useContext(RouterContext);
1010
+ const layer = useContext(RouterLayerContext);
1011
+ if (!ctx || !layer) {
1012
+ throw new Error("useRouter must be used within a RouterProvider");
1013
+ }
1014
+ let name;
1015
+ if (typeof path === "object" && path.options.name) {
1016
+ name = path.options.name;
1017
+ }
1018
+ const [current, setCurrent] = useState(ctx.state.pathname);
1019
+ const href = useMemo(() => router.createHref(path, layer), [path, layer]);
1020
+ const [isPending, setPending] = useState(false);
1021
+ const isActive = current === href;
1022
+ useEffect(
1023
+ () => ctx.router.on("end", ({ pathname }) => setCurrent(pathname)),
1024
+ []
1025
+ );
1026
+ return {
1027
+ name,
1028
+ isPending,
1029
+ isActive,
1030
+ anchorProps: {
1031
+ href,
1032
+ onClick: (ev) => {
1033
+ ev.stopPropagation();
1034
+ ev.preventDefault();
1035
+ if (isActive) return;
1036
+ if (isPending) return;
1037
+ setPending(true);
1038
+ router.go(href).then(() => {
1039
+ setPending(false);
1040
+ });
1041
+ }
1042
+ }
1043
+ };
1044
+ };
1045
+
1046
+ const useAuth = () => {
1047
+ const ctx = useContext(RouterContext);
1048
+ if (!ctx) {
1049
+ throw new Error("useAuth must be used within a RouterContext");
1050
+ }
1051
+ const args = ctx.args ?? {};
1052
+ return {
1053
+ user: args.user,
1054
+ logout: () => {
1055
+ ctx.alepha.get(Auth).logout();
1056
+ },
1057
+ login: (provider) => {
1058
+ ctx.alepha.get(Auth).login();
1059
+ }
1060
+ };
1061
+ };
1062
+
1063
+ export { $auth as $, Auth as A, Link as L, NestedView as N, PageDescriptorProvider as P, Router as R, $page as a, RouterContext as b, RouterLayerContext as c, RouterHookApi as d, useClient as e, useQueryParams as f, useRouter as g, useRouterEvents as h, useRouterState as i, useActive as j, useAuth as k, ReactBrowserProvider as l, RedirectionError as m, pageDescriptorKey as p, useInject as u };
package/package.json CHANGED
@@ -1,39 +1,41 @@
1
1
  {
2
2
  "name": "@alepha/react",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "main": "./dist/index.cjs",
7
- "module": "./dist/index.js",
6
+ "main": "./dist/index.js",
8
7
  "types": "./dist/index.d.ts",
9
- "browser": "./dist/index.browser.js",
8
+ "browser": {
9
+ "./dist/index.js": "./dist/index.browser.js"
10
+ },
10
11
  "dependencies": {
11
- "@alepha/cache": "0.5.2",
12
- "@alepha/core": "0.5.2",
13
- "@alepha/security": "0.5.2",
14
- "@alepha/server": "0.5.2",
15
- "openid-client": "^6.4.1",
12
+ "@alepha/core": "0.6.1",
13
+ "@alepha/security": "0.6.1",
14
+ "@alepha/server": "0.6.1",
15
+ "openid-client": "^6.4.2",
16
16
  "path-to-regexp": "^8.2.0",
17
- "react": "^18.3.1",
18
17
  "react-dom": "^18.3.1"
19
18
  },
20
19
  "devDependencies": {
21
20
  "@types/react": "^18.3.20",
22
- "@types/react-dom": "^18.3.5",
21
+ "@types/react-dom": "^18.3.6",
23
22
  "pkgroll": "^2.12.1",
23
+ "react": "^18.3.1",
24
24
  "vitest": "^3.1.1"
25
25
  },
26
+ "peerDependencies": {
27
+ "@types/react": "^18",
28
+ "react": "^18"
29
+ },
26
30
  "scripts": {
27
31
  "build": "pkgroll --clean-dist"
28
32
  },
29
33
  "exports": {
30
- "default": "./dist/index.js",
31
- "node": {
32
- "require": "./dist/index.cjs",
34
+ ".": {
33
35
  "import": "./dist/index.js",
34
- "module": "./dist/index.js",
35
- "types": "./dist/index.d.ts"
36
- },
37
- "browser": "./dist/index.browser.js"
36
+ "require": "./dist/index.cjs",
37
+ "types": "./dist/index.d.ts",
38
+ "browser": "./dist/index.browser.js"
39
+ }
38
40
  }
39
41
  }
@@ -1,36 +0,0 @@
1
- import type { ReactNode } from "react";
2
- import { useContext, useEffect, useState } from "react";
3
- import { RouterContext } from "../contexts/RouterContext";
4
- import { RouterLayerContext } from "../contexts/RouterLayerContext";
5
-
6
- export interface NestedViewProps {
7
- children?: ReactNode;
8
- }
9
-
10
- /**
11
- * Nested view component
12
- *
13
- * @param props
14
- * @constructor
15
- */
16
- const NestedView = (props: NestedViewProps) => {
17
- const app = useContext(RouterContext);
18
- const layer = useContext(RouterLayerContext);
19
- const index = layer?.index ?? 0;
20
-
21
- const [view, setView] = useState<ReactNode | undefined>(
22
- app?.state.layers[index]?.element,
23
- );
24
-
25
- useEffect(() => {
26
- if (app?.alepha.isBrowser()) {
27
- return app?.router.on("end", ({ layers }) => {
28
- setView(layers[index]?.element);
29
- });
30
- }
31
- }, [app]);
32
-
33
- return view ?? props.children ?? null;
34
- };
35
-
36
- export default NestedView;
@@ -1,15 +0,0 @@
1
- import type { Alepha } from "@alepha/core";
2
- import { createContext } from "react";
3
- import type { Session } from "../providers/ReactSessionProvider";
4
- import type { Router, RouterState } from "../services/Router";
5
-
6
- export interface RouterContextValue {
7
- router: Router;
8
- alepha: Alepha;
9
- state: RouterState;
10
- session?: Session;
11
- }
12
-
13
- export const RouterContext = createContext<RouterContextValue | undefined>(
14
- undefined,
15
- );
@@ -1,10 +0,0 @@
1
- import { createContext } from "react";
2
-
3
- export interface RouterLayerContextValue {
4
- index: number;
5
- path: string;
6
- }
7
-
8
- export const RouterLayerContext = createContext<
9
- RouterLayerContextValue | undefined
10
- >(undefined);