@alepha/react 0.9.2 → 0.9.4

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.
Files changed (40) hide show
  1. package/README.md +46 -0
  2. package/dist/index.browser.js +378 -325
  3. package/dist/index.browser.js.map +1 -1
  4. package/dist/index.cjs +570 -458
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +305 -213
  7. package/dist/index.d.cts.map +1 -1
  8. package/dist/index.d.ts +304 -212
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +567 -460
  11. package/dist/index.js.map +1 -1
  12. package/package.json +16 -13
  13. package/src/components/ErrorViewer.tsx +1 -1
  14. package/src/components/Link.tsx +4 -24
  15. package/src/components/NestedView.tsx +20 -9
  16. package/src/components/NotFound.tsx +5 -2
  17. package/src/descriptors/$page.ts +86 -12
  18. package/src/errors/Redirection.ts +13 -0
  19. package/src/hooks/useActive.ts +28 -30
  20. package/src/hooks/useAlepha.ts +16 -2
  21. package/src/hooks/useClient.ts +7 -2
  22. package/src/hooks/useInject.ts +4 -1
  23. package/src/hooks/useQueryParams.ts +9 -6
  24. package/src/hooks/useRouter.ts +18 -30
  25. package/src/hooks/useRouterEvents.ts +7 -4
  26. package/src/hooks/useRouterState.ts +8 -20
  27. package/src/hooks/useSchema.ts +10 -15
  28. package/src/hooks/useStore.ts +9 -8
  29. package/src/index.browser.ts +11 -11
  30. package/src/index.shared.ts +4 -5
  31. package/src/index.ts +21 -30
  32. package/src/providers/ReactBrowserProvider.ts +155 -65
  33. package/src/providers/ReactBrowserRouterProvider.ts +132 -0
  34. package/src/providers/{PageDescriptorProvider.ts → ReactPageProvider.ts} +164 -112
  35. package/src/providers/ReactServerProvider.ts +100 -68
  36. package/src/{hooks/RouterHookApi.ts → services/ReactRouter.ts} +75 -61
  37. package/src/contexts/RouterContext.ts +0 -14
  38. package/src/errors/RedirectionError.ts +0 -10
  39. package/src/providers/BrowserRouterProvider.ts +0 -146
  40. package/src/providers/ReactBrowserRenderer.ts +0 -93
@@ -4,13 +4,13 @@ import {
4
4
  $env,
5
5
  $hook,
6
6
  $inject,
7
- $logger,
8
7
  Alepha,
8
+ AlephaError,
9
9
  type Static,
10
10
  t,
11
11
  } from "@alepha/core";
12
+ import { $logger } from "@alepha/logger";
12
13
  import {
13
- apiLinksResponseSchema,
14
14
  type ServerHandler,
15
15
  ServerRouterProvider,
16
16
  ServerTimingProvider,
@@ -22,14 +22,13 @@ import {
22
22
  $page,
23
23
  type PageDescriptorRenderOptions,
24
24
  } from "../descriptors/$page.ts";
25
+ import { Redirection } from "../errors/Redirection.ts";
26
+ import type { ReactHydrationState } from "./ReactBrowserProvider.ts";
25
27
  import {
26
- PageDescriptorProvider,
27
- type PageReactContext,
28
- type PageRequest,
29
28
  type PageRoute,
30
- type RouterState,
31
- } from "./PageDescriptorProvider.ts";
32
- import type { ReactHydrationState } from "./ReactBrowserProvider.ts";
29
+ ReactPageProvider,
30
+ type ReactRouterState,
31
+ } from "./ReactPageProvider.ts";
33
32
 
34
33
  const envSchema = t.object({
35
34
  REACT_SERVER_DIST: t.string({ default: "public" }),
@@ -53,7 +52,7 @@ declare module "@alepha/core" {
53
52
  export class ReactServerProvider {
54
53
  protected readonly log = $logger();
55
54
  protected readonly alepha = $inject(Alepha);
56
- protected readonly pageDescriptorProvider = $inject(PageDescriptorProvider);
55
+ protected readonly pageApi = $inject(ReactPageProvider);
57
56
  protected readonly serverStaticProvider = $inject(ServerStaticProvider);
58
57
  protected readonly serverRouterProvider = $inject(ServerRouterProvider);
59
58
  protected readonly serverTimingProvider = $inject(ServerTimingProvider);
@@ -135,7 +134,7 @@ export class ReactServerProvider {
135
134
  }
136
135
 
137
136
  protected async registerPages(templateLoader: TemplateLoader) {
138
- for (const page of this.pageDescriptorProvider.getPages()) {
137
+ for (const page of this.pageApi.getPages()) {
139
138
  if (page.children?.length) {
140
139
  continue;
141
140
  }
@@ -196,43 +195,56 @@ export class ReactServerProvider {
196
195
  */
197
196
  protected createRenderFunction(name: string, withIndex = false) {
198
197
  return async (options: PageDescriptorRenderOptions = {}) => {
199
- const page = this.pageDescriptorProvider.page(name);
200
- const url = new URL(this.pageDescriptorProvider.url(name, options));
201
- const context: PageRequest = {
198
+ const page = this.pageApi.page(name);
199
+ const url = new URL(this.pageApi.url(name, options));
200
+
201
+ const entry: Partial<ReactRouterState> = {
202
202
  url,
203
203
  params: options.params ?? {},
204
204
  query: options.query ?? {},
205
- head: {},
206
205
  onError: () => null,
206
+ layers: [],
207
207
  };
208
208
 
209
+ const state = entry as ReactRouterState;
210
+
211
+ this.log.trace("Rendering", {
212
+ url,
213
+ });
214
+
209
215
  await this.alepha.emit("react:server:render:begin", {
210
- context,
216
+ state,
211
217
  });
212
218
 
213
- const state = await this.pageDescriptorProvider.createLayers(
219
+ const { redirect } = await this.pageApi.createLayers(
214
220
  page,
215
- context,
221
+ state as ReactRouterState,
216
222
  );
217
223
 
224
+ if (redirect) {
225
+ throw new AlephaError("Redirection is not supported in this context");
226
+ }
227
+
218
228
  if (!withIndex && !options.html) {
229
+ this.alepha.state("react.router.state", state);
230
+
219
231
  return {
220
- context,
221
- html: renderToString(
222
- this.pageDescriptorProvider.root(state, context),
223
- ),
232
+ state,
233
+ html: renderToString(this.pageApi.root(state)),
224
234
  };
225
235
  }
226
236
 
227
237
  const html = this.renderToHtml(
228
238
  this.template ?? "",
229
239
  state,
230
- context,
231
240
  options.hydration,
232
241
  );
233
242
 
243
+ if (html instanceof Redirection) {
244
+ throw new Error("Redirection is not supported in this context");
245
+ }
246
+
234
247
  const result = {
235
- context,
236
248
  state,
237
249
  html,
238
250
  };
@@ -244,7 +256,7 @@ export class ReactServerProvider {
244
256
  }
245
257
 
246
258
  protected createHandler(
247
- page: PageRoute,
259
+ route: PageRoute,
248
260
  templateLoader: TemplateLoader,
249
261
  ): ServerHandler {
250
262
  return async (serverRequest) => {
@@ -254,33 +266,33 @@ export class ReactServerProvider {
254
266
  throw new Error("Template not found");
255
267
  }
256
268
 
257
- const context: PageRequest = {
269
+ this.log.trace("Rendering page", {
270
+ name: route.name,
271
+ });
272
+
273
+ const entry: Partial<ReactRouterState> = {
258
274
  url,
259
275
  params,
260
276
  query,
261
- // plugins
262
- head: {},
263
277
  onError: () => null,
278
+ layers: [],
264
279
  };
265
280
 
266
- if (this.alepha.has(ServerLinksProvider)) {
267
- const srv = this.alepha.inject(ServerLinksProvider);
268
- const schema = apiLinksResponseSchema as any;
281
+ const state = entry as ReactRouterState;
269
282
 
270
- context.links = this.alepha.parse(
271
- schema,
272
- await srv.getLinks({
273
- user: serverRequest.user,
283
+ if (this.alepha.has(ServerLinksProvider)) {
284
+ this.alepha.state(
285
+ "api",
286
+ await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
287
+ user: (serverRequest as any).user, // TODO: fix type
274
288
  authorization: serverRequest.headers.authorization,
275
289
  }),
276
- ) as any;
277
-
278
- this.alepha.context.set("links", context.links);
290
+ );
279
291
  }
280
292
 
281
- let target: PageRoute | undefined = page; // TODO: move to PageDescriptorProvider
293
+ let target: PageRoute | undefined = route; // TODO: move to PageDescriptorProvider
282
294
  while (target) {
283
- if (page.can && !page.can()) {
295
+ if (route.can && !route.can()) {
284
296
  // if the page is not accessible, return 403
285
297
  reply.status = 403;
286
298
  reply.headers["content-type"] = "text/plain";
@@ -302,20 +314,17 @@ export class ReactServerProvider {
302
314
 
303
315
  await this.alepha.emit("react:server:render:begin", {
304
316
  request: serverRequest,
305
- context,
317
+ state,
306
318
  });
307
319
 
308
320
  this.serverTimingProvider.beginTiming("createLayers");
309
321
 
310
- const state = await this.pageDescriptorProvider.createLayers(
311
- page,
312
- context,
313
- );
322
+ const { redirect } = await this.pageApi.createLayers(route, state);
314
323
 
315
324
  this.serverTimingProvider.endTiming("createLayers");
316
325
 
317
- if (state.redirect) {
318
- return reply.redirect(state.redirect);
326
+ if (redirect) {
327
+ return reply.redirect(redirect);
319
328
  }
320
329
 
321
330
  reply.headers["content-type"] = "text/html";
@@ -327,44 +336,62 @@ export class ReactServerProvider {
327
336
  reply.headers.pragma = "no-cache";
328
337
  reply.headers.expires = "0";
329
338
 
330
- // don't cache user links
331
- if (page.cache && serverRequest.user) {
332
- delete context.links;
339
+ const html = this.renderToHtml(template, state);
340
+ if (html instanceof Redirection) {
341
+ reply.redirect(
342
+ typeof html.redirect === "string"
343
+ ? html.redirect
344
+ : this.pageApi.href(html.redirect),
345
+ );
346
+ return;
333
347
  }
334
348
 
335
- const html = this.renderToHtml(template, state, context);
336
-
337
- await this.alepha.emit("react:server:render:end", {
349
+ const event = {
338
350
  request: serverRequest,
339
- context,
340
351
  state,
341
352
  html,
342
- });
353
+ };
354
+
355
+ await this.alepha.emit("react:server:render:end", event);
343
356
 
344
- page.afterHandler?.(serverRequest);
357
+ route.onServerResponse?.(serverRequest);
358
+
359
+ this.log.trace("Page rendered", {
360
+ name: route.name,
361
+ });
345
362
 
346
- return html;
363
+ return event.html;
347
364
  };
348
365
  }
349
366
 
350
367
  public renderToHtml(
351
368
  template: string,
352
- state: RouterState,
353
- context: PageReactContext,
369
+ state: ReactRouterState,
354
370
  hydration = true,
355
- ) {
356
- const element = this.pageDescriptorProvider.root(state, context);
371
+ ): string | Redirection {
372
+ const element = this.pageApi.root(state);
357
373
 
358
- this.serverTimingProvider.beginTiming("renderToString");
374
+ // attach react router state to the http request context
375
+ this.alepha.state("react.router.state", state);
359
376
 
377
+ this.serverTimingProvider.beginTiming("renderToString");
360
378
  let app = "";
361
379
  try {
362
380
  app = renderToString(element);
363
381
  } catch (error) {
364
- this.log.error("Error during SSR", error);
365
- app = renderToString(context.onError(error as Error));
366
- }
382
+ this.log.error(
383
+ "renderToString has failed, fallback to error handler",
384
+ error,
385
+ );
386
+ const element = state.onError(error as Error, state);
387
+ if (element instanceof Redirection) {
388
+ // if the error is a redirection, return the redirection URL
389
+ return element;
390
+ }
367
391
 
392
+ app = renderToString(element);
393
+ this.log.debug("Error handled successfully with fallback");
394
+ }
368
395
  this.serverTimingProvider.endTiming("renderToString");
369
396
 
370
397
  const response = {
@@ -372,8 +399,13 @@ export class ReactServerProvider {
372
399
  };
373
400
 
374
401
  if (hydration) {
402
+ const { request, context, ...store } =
403
+ this.alepha.context.als?.getStore() ?? {}; /// TODO: als must be protected, find a way to iterate on alepha.state
404
+
375
405
  const hydrationData: ReactHydrationState = {
376
- links: context.links,
406
+ ...store,
407
+ // map react.router.state to the hydration state
408
+ "react.router.state": undefined,
377
409
  layers: state.layers.map((it) => ({
378
410
  ...it,
379
411
  error: it.error
@@ -381,7 +413,7 @@ export class ReactServerProvider {
381
413
  ...it.error,
382
414
  name: it.error.name,
383
415
  message: it.error.message,
384
- stack: it.error.stack, // TODO: Hide stack in production ?
416
+ stack: !this.alepha.isProduction() ? it.error.stack : undefined,
385
417
  }
386
418
  : undefined,
387
419
  index: undefined,
@@ -418,7 +450,7 @@ export class ReactServerProvider {
418
450
  const bodyOpenTag = /<body([^>]*)>/i;
419
451
  if (bodyOpenTag.test(response.html)) {
420
452
  response.html = response.html.replace(bodyOpenTag, (match) => {
421
- return `${match}\n<div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
453
+ return `${match}<div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
422
454
  });
423
455
  }
424
456
  }
@@ -427,7 +459,7 @@ export class ReactServerProvider {
427
459
  if (bodyCloseTagRegex.test(response.html)) {
428
460
  response.html = response.html.replace(
429
461
  bodyCloseTagRegex,
430
- `${script}\n</body>`,
462
+ `${script}</body>`,
431
463
  );
432
464
  }
433
465
  }
@@ -1,30 +1,56 @@
1
+ import { $inject, Alepha } from "@alepha/core";
1
2
  import type { PageDescriptor } from "../descriptors/$page.ts";
2
- import type {
3
- AnchorProps,
4
- PageReactContext,
5
- PageRoute,
6
- RouterState,
7
- } from "../providers/PageDescriptorProvider.ts";
8
- import type {
3
+ import {
9
4
  ReactBrowserProvider,
10
- RouterGoOptions,
5
+ type RouterGoOptions,
11
6
  } from "../providers/ReactBrowserProvider.ts";
7
+ import {
8
+ type AnchorProps,
9
+ ReactPageProvider,
10
+ type ReactRouterState,
11
+ } from "../providers/ReactPageProvider.ts";
12
+
13
+ export class ReactRouter<T extends object> {
14
+ protected readonly alepha = $inject(Alepha);
15
+ protected readonly pageApi = $inject(ReactPageProvider);
16
+
17
+ public get state(): ReactRouterState {
18
+ return this.alepha.state("react.router.state")!;
19
+ }
20
+
21
+ public get pages() {
22
+ return this.pageApi.getPages();
23
+ }
24
+
25
+ public get browser(): ReactBrowserProvider | undefined {
26
+ if (this.alepha.isBrowser()) {
27
+ return this.alepha.inject(ReactBrowserProvider);
28
+ }
29
+ // server-side
30
+ return undefined;
31
+ }
12
32
 
13
- export class RouterHookApi {
14
- constructor(
15
- private readonly pages: PageRoute[],
16
- private readonly context: PageReactContext,
17
- private readonly state: RouterState,
18
- private readonly layer: {
19
- path: string;
20
- },
21
- private readonly browser?: ReactBrowserProvider,
22
- ) {}
33
+ public path(
34
+ name: keyof VirtualRouter<T>,
35
+ config: {
36
+ params?: Record<string, string>;
37
+ query?: Record<string, string>;
38
+ } = {},
39
+ ): string {
40
+ return this.pageApi.pathname(name as string, {
41
+ params: {
42
+ ...this.state.params,
43
+ ...config.params,
44
+ },
45
+ query: config.query,
46
+ });
47
+ }
23
48
 
24
49
  public getURL(): URL {
25
50
  if (!this.browser) {
26
- return this.context.url;
51
+ return this.state.url;
27
52
  }
53
+
28
54
  return new URL(this.location.href);
29
55
  }
30
56
 
@@ -36,19 +62,19 @@ export class RouterHookApi {
36
62
  return this.browser.location;
37
63
  }
38
64
 
39
- public get current(): RouterState {
65
+ public get current(): ReactRouterState {
40
66
  return this.state;
41
67
  }
42
68
 
43
69
  public get pathname(): string {
44
- return this.state.pathname;
70
+ return this.state.url.pathname;
45
71
  }
46
72
 
47
73
  public get query(): Record<string, string> {
48
74
  const query: Record<string, string> = {};
49
75
 
50
76
  for (const [key, value] of new URLSearchParams(
51
- this.state.search,
77
+ this.state.url.search,
52
78
  ).entries()) {
53
79
  query[key] = String(value);
54
80
  }
@@ -68,79 +94,69 @@ export class RouterHookApi {
68
94
  await this.browser?.invalidate(props);
69
95
  }
70
96
 
71
- /**
72
- * Create a valid href for the given pathname.
73
- *
74
- * @param pathname
75
- * @param layer
76
- */
77
- public createHref(
78
- pathname: HrefLike,
79
- layer: { path: string } = this.layer,
80
- options: { params?: Record<string, any> } = {},
81
- ) {
82
- if (typeof pathname === "object") {
83
- pathname = pathname.options.path ?? "";
84
- }
85
-
86
- if (options.params) {
87
- for (const [key, value] of Object.entries(options.params)) {
88
- pathname = pathname.replace(`:${key}`, String(value));
89
- }
90
- }
91
-
92
- return pathname.startsWith("/")
93
- ? pathname
94
- : `${layer.path}/${pathname}`.replace(/\/\/+/g, "/");
95
- }
96
-
97
97
  public async go(path: string, options?: RouterGoOptions): Promise<void>;
98
- public async go<T extends object>(
98
+ public async go(
99
99
  path: keyof VirtualRouter<T>,
100
100
  options?: RouterGoOptions,
101
101
  ): Promise<void>;
102
- public async go(path: string, options?: RouterGoOptions): Promise<void> {
102
+ public async go(
103
+ path: string | keyof VirtualRouter<T>,
104
+ options?: RouterGoOptions,
105
+ ): Promise<void> {
103
106
  for (const page of this.pages) {
104
107
  if (page.name === path) {
105
- path = page.path ?? "";
106
- break;
108
+ await this.browser?.go(
109
+ this.path(path as keyof VirtualRouter<T>, options),
110
+ options,
111
+ );
112
+ return;
107
113
  }
108
114
  }
109
115
 
110
- await this.browser?.go(this.createHref(path, this.layer, options), options);
116
+ await this.browser?.go(path as string, options);
111
117
  }
112
118
 
113
119
  public anchor(
114
120
  path: string,
115
121
  options?: { params?: Record<string, any> },
116
122
  ): AnchorProps;
117
- public anchor<T extends object>(
123
+ public anchor(
118
124
  path: keyof VirtualRouter<T>,
119
125
  options?: { params?: Record<string, any> },
120
126
  ): AnchorProps;
121
127
  public anchor(
122
- path: string,
128
+ path: string | keyof VirtualRouter<T>,
123
129
  options: { params?: Record<string, any> } = {},
124
130
  ): AnchorProps {
131
+ let href = path as string;
132
+
125
133
  for (const page of this.pages) {
126
134
  if (page.name === path) {
127
- path = page.path ?? "";
135
+ href = this.path(path as keyof VirtualRouter<T>, options);
128
136
  break;
129
137
  }
130
138
  }
131
139
 
132
- const href = this.createHref(path, this.layer, options);
133
140
  return {
134
- href,
141
+ href: this.base(href),
135
142
  onClick: (ev: any) => {
136
143
  ev.stopPropagation();
137
144
  ev.preventDefault();
138
145
 
139
- this.go(path, options).catch(console.error);
146
+ this.go(href, options).catch(console.error);
140
147
  },
141
148
  };
142
149
  }
143
150
 
151
+ public base(path: string): string {
152
+ const base = import.meta.env?.BASE_URL;
153
+ if (!base || base === "/") {
154
+ return path;
155
+ }
156
+
157
+ return base + path;
158
+ }
159
+
144
160
  /**
145
161
  * Set query params.
146
162
  *
@@ -170,8 +186,6 @@ export class RouterHookApi {
170
186
  }
171
187
  }
172
188
 
173
- export type HrefLike = string | { options: { path?: string; name?: string } };
174
-
175
189
  export type VirtualRouter<T> = {
176
190
  [K in keyof T as T[K] extends PageDescriptor ? K : never]: T[K];
177
191
  };
@@ -1,14 +0,0 @@
1
- import { createContext } from "react";
2
- import type {
3
- PageReactContext,
4
- RouterState,
5
- } from "../providers/PageDescriptorProvider.ts";
6
-
7
- export interface RouterContextValue {
8
- state: RouterState;
9
- context: PageReactContext;
10
- }
11
-
12
- export const RouterContext = createContext<RouterContextValue | undefined>(
13
- undefined,
14
- );
@@ -1,10 +0,0 @@
1
- import type { HrefLike } from "../hooks/RouterHookApi.ts";
2
-
3
- export class RedirectionError extends Error {
4
- public readonly page: HrefLike;
5
-
6
- constructor(page: HrefLike) {
7
- super("Redirection");
8
- this.page = page;
9
- }
10
- }