@alepha/react 0.8.1 → 0.9.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,9 @@
1
1
  import {
2
- __descriptor,
3
2
  type Async,
3
+ createDescriptor,
4
+ Descriptor,
4
5
  KIND,
5
6
  NotImplementedError,
6
- OPTIONS,
7
7
  type Static,
8
8
  type TSchema,
9
9
  } from "@alepha/core";
@@ -13,8 +13,6 @@ import type { FC, ReactNode } from "react";
13
13
  import type { ClientOnlyProps } from "../components/ClientOnly.tsx";
14
14
  import type { PageReactContext } from "../providers/PageDescriptorProvider.ts";
15
15
 
16
- const KEY = "PAGE";
17
-
18
16
  /**
19
17
  * Main descriptor for defining a React route in the application.
20
18
  */
@@ -25,45 +23,14 @@ export const $page = <
25
23
  >(
26
24
  options: PageDescriptorOptions<TConfig, TProps, TPropsParent>,
27
25
  ): PageDescriptor<TConfig, TProps, TPropsParent> => {
28
- __descriptor(KEY);
29
-
30
- // if (options.children) {
31
- // for (const child of options.children) {
32
- // child[OPTIONS].parent = {
33
- // [OPTIONS]: options as PageDescriptorOptions<any, any, any>,
34
- // };
35
- // }
36
- // }
37
-
38
- // if (options.parent) {
39
- // options.parent[OPTIONS].children ??= [];
40
- // options.parent[OPTIONS].children.push({
41
- // [OPTIONS]: options as PageDescriptorOptions<any, any, any>,
42
- // });
43
- // }
44
-
45
- return {
46
- [KIND]: KEY,
47
- [OPTIONS]: options,
48
- render: () => {
49
- throw new NotImplementedError(KEY);
50
- },
51
- };
26
+ return createDescriptor(
27
+ PageDescriptor<TConfig, TProps, TPropsParent>,
28
+ options,
29
+ );
52
30
  };
53
31
 
54
- $page[KIND] = KEY;
55
-
56
32
  // ---------------------------------------------------------------------------------------------------------------------
57
33
 
58
- export interface PageConfigSchema {
59
- query?: TSchema;
60
- params?: TSchema;
61
- }
62
-
63
- export type TPropsDefault = any;
64
-
65
- export type TPropsParentDefault = {};
66
-
67
34
  export interface PageDescriptorOptions<
68
35
  TConfig extends PageConfigSchema = PageConfigSchema,
69
36
  TProps extends object = TPropsDefault,
@@ -136,11 +103,9 @@ export interface PageDescriptorOptions<
136
103
  *
137
104
  * If you still want to render at this pathname, add a child page with an empty path.
138
105
  */
139
- children?:
140
- | Array<{ [OPTIONS]: PageDescriptorOptions }>
141
- | (() => Array<{ [OPTIONS]: PageDescriptorOptions }>);
106
+ children?: Array<PageDescriptor> | (() => Array<PageDescriptor>);
142
107
 
143
- parent?: { [OPTIONS]: PageDescriptorOptions<PageConfigSchema, TPropsParent> };
108
+ parent?: PageDescriptor<PageConfigSchema, TPropsParent>;
144
109
 
145
110
  can?: () => boolean;
146
111
 
@@ -168,23 +133,39 @@ export interface PageDescriptorOptions<
168
133
  cache?: ServerRouteCache;
169
134
  }
170
135
 
171
- export interface PageDescriptor<
136
+ export class PageDescriptor<
172
137
  TConfig extends PageConfigSchema = PageConfigSchema,
173
138
  TProps extends object = TPropsDefault,
174
139
  TPropsParent extends object = TPropsParentDefault,
175
- > {
176
- [KIND]: typeof KEY;
177
- [OPTIONS]: PageDescriptorOptions<TConfig, TProps, TPropsParent>;
140
+ > extends Descriptor<PageDescriptorOptions<TConfig, TProps, TPropsParent>> {
141
+ public get name(): string {
142
+ return this.options.name ?? this.config.propertyKey;
143
+ }
178
144
 
179
145
  /**
180
146
  * For testing or build purposes, this will render the page (with or without the HTML layout) and return the HTML and context.
181
147
  * Only valid for server-side rendering, it will throw an error if called on the client-side.
182
148
  */
183
- render: (
149
+ public async render(
184
150
  options?: PageDescriptorRenderOptions,
185
- ) => Promise<PageDescriptorRenderResult>;
151
+ ): Promise<PageDescriptorRenderResult> {
152
+ throw new NotImplementedError("");
153
+ }
186
154
  }
187
155
 
156
+ $page[KIND] = PageDescriptor;
157
+
158
+ // ---------------------------------------------------------------------------------------------------------------------
159
+
160
+ export interface PageConfigSchema {
161
+ query?: TSchema;
162
+ params?: TSchema;
163
+ }
164
+
165
+ export type TPropsDefault = any;
166
+
167
+ export type TPropsParentDefault = {};
168
+
188
169
  export interface PageDescriptorRenderOptions {
189
170
  params?: Record<string, string>;
190
171
  query?: Record<string, string>;
@@ -8,5 +8,5 @@ export const useInject = <T extends object>(clazz: Service<T>): T => {
8
8
  throw new Error("useRouter must be used within a <RouterProvider>");
9
9
  }
10
10
 
11
- return useMemo(() => ctx.alepha.get(clazz), []);
11
+ return useMemo(() => ctx.alepha.inject(clazz), []);
12
12
  };
@@ -13,7 +13,7 @@ export const useRouter = (): RouterHookApi => {
13
13
  }
14
14
 
15
15
  const pages = useMemo(() => {
16
- return ctx.alepha.get(PageDescriptorProvider).getPages();
16
+ return ctx.alepha.inject(PageDescriptorProvider).getPages();
17
17
  }, []);
18
18
 
19
19
  return useMemo(
@@ -24,7 +24,7 @@ export const useRouter = (): RouterHookApi => {
24
24
  ctx.state,
25
25
  layer,
26
26
  ctx.alepha.isBrowser()
27
- ? ctx.alepha.get(ReactBrowserProvider)
27
+ ? ctx.alepha.inject(ReactBrowserProvider)
28
28
  : undefined,
29
29
  ),
30
30
  [layer],
@@ -1,4 +1,4 @@
1
- import { __bind, type Alepha, type Module } from "@alepha/core";
1
+ import { $module } from "@alepha/core";
2
2
  import { AlephaServer } from "@alepha/server";
3
3
  import { AlephaServerLinks } from "@alepha/server-links";
4
4
  import { $page } from "./descriptors/$page.ts";
@@ -16,16 +16,21 @@ export * from "./providers/ReactBrowserProvider.ts";
16
16
 
17
17
  // ---------------------------------------------------------------------------------------------------------------------
18
18
 
19
- export class AlephaReact implements Module {
20
- public readonly name = "alepha.react";
21
- public readonly $services = (alepha: Alepha) =>
19
+ export const AlephaReact = $module({
20
+ name: "alepha.react",
21
+ descriptors: [$page],
22
+ services: [
23
+ PageDescriptorProvider,
24
+ ReactBrowserRenderer,
25
+ BrowserRouterProvider,
26
+ ReactBrowserProvider,
27
+ ],
28
+ register: (alepha) =>
22
29
  alepha
23
30
  .with(AlephaServer)
24
31
  .with(AlephaServerLinks)
25
32
  .with(PageDescriptorProvider)
26
33
  .with(ReactBrowserProvider)
27
34
  .with(BrowserRouterProvider)
28
- .with(ReactBrowserRenderer);
29
- }
30
-
31
- __bind($page, AlephaReact);
35
+ .with(ReactBrowserRenderer),
36
+ });
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { __bind, type Alepha, type Module } from "@alepha/core";
1
+ import { $module } from "@alepha/core";
2
2
  import { AlephaServer, type ServerRequest } from "@alepha/server";
3
3
  import { AlephaServerCache } from "@alepha/server-cache";
4
4
  import { AlephaServerLinks } from "@alepha/server-links";
@@ -9,7 +9,10 @@ import {
9
9
  type PageRequest,
10
10
  type RouterState,
11
11
  } from "./providers/PageDescriptorProvider.ts";
12
- import type { ReactHydrationState } from "./providers/ReactBrowserProvider.ts";
12
+ import {
13
+ ReactBrowserProvider,
14
+ type ReactHydrationState,
15
+ } from "./providers/ReactBrowserProvider.ts";
13
16
  import { ReactServerProvider } from "./providers/ReactServerProvider.ts";
14
17
 
15
18
  // ---------------------------------------------------------------------------------------------------------------------
@@ -72,139 +75,18 @@ declare module "@alepha/core" {
72
75
  * It delivers seamless server-side rendering, automatic code splitting, and client-side navigation with full
73
76
  * type safety and schema validation for route parameters and data.
74
77
  *
75
- * **Key Features:**
76
- * - Declarative page definition with `$page` descriptor
77
- * - Server-side rendering (SSR) with automatic hydration
78
- * - Type-safe routing with parameter validation
79
- * - Schema-based data resolution and validation
80
- * - SEO-friendly meta tag management
81
- * - Automatic code splitting and lazy loading
82
- * - Client-side navigation with browser history
83
- *
84
- * **Basic Usage:**
85
- * ```ts
86
- * import { Alepha, run, t } from "alepha";
87
- * import { AlephaReact, $page } from "alepha/react";
88
- *
89
- * class AppRoutes {
90
- * // Home page
91
- * home = $page({
92
- * path: "/",
93
- * component: () => (
94
- * <div>
95
- * <h1>Welcome to Alepha</h1>
96
- * <p>Build amazing React applications!</p>
97
- * </div>
98
- * ),
99
- * });
100
- *
101
- * // About page with meta tags
102
- * about = $page({
103
- * path: "/about",
104
- * head: {
105
- * title: "About Us",
106
- * description: "Learn more about our mission",
107
- * },
108
- * component: () => (
109
- * <div>
110
- * <h1>About Us</h1>
111
- * <p>Learn more about our mission.</p>
112
- * </div>
113
- * ),
114
- * });
115
- * }
116
- *
117
- * const alepha = Alepha.create()
118
- * .with(AlephaReact)
119
- * .with(AppRoutes);
120
- *
121
- * run(alepha);
122
- * ```
123
- *
124
- * **Dynamic Routes with Parameters:**
125
- * ```tsx
126
- * class UserRoutes {
127
- * userProfile = $page({
128
- * path: "/users/:id",
129
- * schema: {
130
- * params: t.object({
131
- * id: t.string(),
132
- * }),
133
- * },
134
- * resolve: async ({ params }) => {
135
- * // Fetch user data server-side
136
- * const user = await getUserById(params.id);
137
- * return { user };
138
- * },
139
- * head: ({ user }) => ({
140
- * title: `${user.name} - Profile`,
141
- * description: `View ${user.name}'s profile`,
142
- * }),
143
- * component: ({ user }) => (
144
- * <div>
145
- * <h1>{user.name}</h1>
146
- * <p>Email: {user.email}</p>
147
- * </div>
148
- * ),
149
- * });
150
- *
151
- * userSettings = $page({
152
- * path: "/users/:id/settings",
153
- * schema: {
154
- * params: t.object({
155
- * id: t.string(),
156
- * }),
157
- * },
158
- * component: ({ params }) => (
159
- * <UserSettings userId={params.id} />
160
- * ),
161
- * });
162
- * }
163
- * ```
164
- *
165
- * **Static Generation:**
166
- * ```tsx
167
- * class BlogRoutes {
168
- * blogPost = $page({
169
- * path: "/blog/:slug",
170
- * schema: {
171
- * params: t.object({
172
- * slug: t.string(),
173
- * }),
174
- * },
175
- * static: {
176
- * entries: [
177
- * { params: { slug: "getting-started" } },
178
- * { params: { slug: "advanced-features" } },
179
- * { params: { slug: "deployment" } },
180
- * ],
181
- * },
182
- * resolve: ({ params }) => {
183
- * const post = getBlogPost(params.slug);
184
- * return { post };
185
- * },
186
- * component: ({ post }) => (
187
- * <article>
188
- * <h1>{post.title}</h1>
189
- * <div>{post.content}</div>
190
- * </article>
191
- * ),
192
- * });
193
- * }
194
- * ```
195
- *
196
78
  * @see {@link $page}
197
79
  * @module alepha.react
198
80
  */
199
- export class AlephaReact implements Module {
200
- public readonly name = "alepha.react";
201
- public readonly $services = (alepha: Alepha) =>
81
+ export const AlephaReact = $module({
82
+ name: "alepha.react",
83
+ descriptors: [$page],
84
+ services: [ReactServerProvider, PageDescriptorProvider, ReactBrowserProvider],
85
+ register: (alepha) =>
202
86
  alepha
203
87
  .with(AlephaServer)
204
88
  .with(AlephaServerCache)
205
89
  .with(AlephaServerLinks)
206
90
  .with(ReactServerProvider)
207
- .with(PageDescriptorProvider);
208
- }
209
-
210
- __bind($page, AlephaReact);
91
+ .with(PageDescriptorProvider),
92
+ });
@@ -1,5 +1,12 @@
1
- import type { Static } from "@alepha/core";
2
- import { $hook, $inject, $logger, Alepha, OPTIONS, t } from "@alepha/core";
1
+ import {
2
+ $env,
3
+ $hook,
4
+ $inject,
5
+ $logger,
6
+ Alepha,
7
+ type Static,
8
+ t,
9
+ } from "@alepha/core";
3
10
  import type { ApiLinksResponse } from "@alepha/server";
4
11
  import { createElement, type ReactNode, StrictMode } from "react";
5
12
  import ClientOnly from "../components/ClientOnly.tsx";
@@ -8,7 +15,11 @@ import NestedView from "../components/NestedView.tsx";
8
15
  import NotFoundPage from "../components/NotFound.tsx";
9
16
  import { RouterContext } from "../contexts/RouterContext.ts";
10
17
  import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
11
- import { $page, type PageDescriptorOptions } from "../descriptors/$page.ts";
18
+ import {
19
+ $page,
20
+ type PageDescriptor,
21
+ type PageDescriptorOptions,
22
+ } from "../descriptors/$page.ts";
12
23
  import { RedirectionError } from "../errors/RedirectionError.ts";
13
24
 
14
25
  const envSchema = t.object({
@@ -21,7 +32,7 @@ declare module "@alepha/core" {
21
32
 
22
33
  export class PageDescriptorProvider {
23
34
  protected readonly log = $logger();
24
- protected readonly env = $inject(envSchema);
35
+ protected readonly env = $env(envSchema);
25
36
  protected readonly alepha = $inject(Alepha);
26
37
  protected readonly pages: PageRoute[] = [];
27
38
 
@@ -289,7 +300,7 @@ export class PageDescriptorProvider {
289
300
  }
290
301
 
291
302
  public renderError(error: Error): ReactNode {
292
- return createElement(ErrorViewer, { error });
303
+ return createElement(ErrorViewer, { error, alepha: this.alepha });
293
304
  }
294
305
 
295
306
  public renderEmptyView(): ReactNode {
@@ -356,14 +367,14 @@ export class PageDescriptorProvider {
356
367
  on: "configure",
357
368
  handler: () => {
358
369
  let hasNotFoundHandler = false;
359
- const pages = this.alepha.getDescriptorValues($page);
370
+ const pages = this.alepha.descriptors($page);
360
371
 
361
- const hasParent = (it: { [OPTIONS]: PageDescriptorOptions }) => {
372
+ const hasParent = (it: PageDescriptor) => {
362
373
  for (const page of pages) {
363
- const children = page.value[OPTIONS].children
364
- ? Array.isArray(page.value[OPTIONS].children)
365
- ? page.value[OPTIONS].children
366
- : page.value[OPTIONS].children()
374
+ const children = page.options.children
375
+ ? Array.isArray(page.options.children)
376
+ ? page.options.children
377
+ : page.options.children()
367
378
  : [];
368
379
  if (children.includes(it)) {
369
380
  return true;
@@ -371,21 +382,17 @@ export class PageDescriptorProvider {
371
382
  }
372
383
  };
373
384
 
374
- for (const { value, key } of pages) {
375
- value[OPTIONS].name ??= key;
376
- }
377
-
378
- for (const { value } of pages) {
379
- if (value[OPTIONS].path === "/*") {
385
+ for (const page of pages) {
386
+ if (page.options.path === "/*") {
380
387
  hasNotFoundHandler = true;
381
388
  }
382
389
 
383
390
  // skip children, we only want root pages
384
- if (hasParent(value)) {
391
+ if (hasParent(page)) {
385
392
  continue;
386
393
  }
387
394
 
388
- this.add(this.map(pages, value));
395
+ this.add(this.map(pages, page));
389
396
  }
390
397
 
391
398
  if (!hasNotFoundHandler && pages.length > 0) {
@@ -404,17 +411,18 @@ export class PageDescriptorProvider {
404
411
  });
405
412
 
406
413
  protected map(
407
- pages: Array<{ value: { [OPTIONS]: PageDescriptorOptions } }>,
408
- target: { [OPTIONS]: PageDescriptorOptions },
414
+ pages: Array<PageDescriptor>,
415
+ target: PageDescriptor,
409
416
  ): PageRouteEntry {
410
- const children = target[OPTIONS].children
411
- ? Array.isArray(target[OPTIONS].children)
412
- ? target[OPTIONS].children
413
- : target[OPTIONS].children()
417
+ const children = target.options.children
418
+ ? Array.isArray(target.options.children)
419
+ ? target.options.children
420
+ : target.options.children()
414
421
  : [];
415
422
 
416
423
  return {
417
- ...target[OPTIONS],
424
+ ...target.options,
425
+ name: target.name,
418
426
  parent: undefined,
419
427
  children: children.map((it) => this.map(pages, it)),
420
428
  } as PageRoute;
@@ -174,7 +174,7 @@ export class ReactBrowserProvider {
174
174
  window.addEventListener("popstate", () => {
175
175
  // when you update silently queryparams or hash, skip rendering
176
176
  // if you want to force a rendering, use #go()
177
- if (this.state.pathname === location.pathname) {
177
+ if (this.state.pathname === this.url) {
178
178
  return;
179
179
  }
180
180
 
@@ -1,4 +1,4 @@
1
- import { $hook, $inject, $logger, type Static, t } from "@alepha/core";
1
+ import { $env, $hook, $inject, $logger, type Static, t } from "@alepha/core";
2
2
  import type { ApiLinksResponse } from "@alepha/server";
3
3
  import type { Root } from "react-dom/client";
4
4
  import { createRoot, hydrateRoot } from "react-dom/client";
@@ -25,7 +25,7 @@ export interface ReactBrowserRendererOptions {
25
25
  export class ReactBrowserRenderer {
26
26
  protected readonly browserProvider = $inject(ReactBrowserProvider);
27
27
  protected readonly browserRouterProvider = $inject(BrowserRouterProvider);
28
- protected readonly env = $inject(envSchema);
28
+ protected readonly env = $env(envSchema);
29
29
  protected readonly log = $logger();
30
30
 
31
31
  protected root!: Root;
@@ -1,11 +1,11 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import {
4
+ $env,
4
5
  $hook,
5
6
  $inject,
6
7
  $logger,
7
8
  Alepha,
8
- OPTIONS,
9
9
  type Static,
10
10
  t,
11
11
  } from "@alepha/core";
@@ -36,12 +36,16 @@ const envSchema = t.object({
36
36
  REACT_SERVER_PREFIX: t.string({ default: "" }),
37
37
  REACT_SSR_ENABLED: t.optional(t.boolean()),
38
38
  REACT_ROOT_ID: t.string({ default: "root" }),
39
+ REACT_SERVER_TEMPLATE: t.optional(
40
+ t.string({
41
+ size: "rich",
42
+ }),
43
+ ),
39
44
  });
40
45
 
41
46
  declare module "@alepha/core" {
42
47
  interface Env extends Partial<Static<typeof envSchema>> {}
43
48
  interface State {
44
- "react.server.template"?: string;
45
49
  "react.server.ssr"?: boolean;
46
50
  }
47
51
  }
@@ -53,7 +57,7 @@ export class ReactServerProvider {
53
57
  protected readonly serverStaticProvider = $inject(ServerStaticProvider);
54
58
  protected readonly serverRouterProvider = $inject(ServerRouterProvider);
55
59
  protected readonly serverTimingProvider = $inject(ServerTimingProvider);
56
- protected readonly env = $inject(envSchema);
60
+ protected readonly env = $env(envSchema);
57
61
  protected readonly ROOT_DIV_REGEX = new RegExp(
58
62
  `<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
59
63
  "is",
@@ -62,17 +66,15 @@ export class ReactServerProvider {
62
66
  public readonly onConfigure = $hook({
63
67
  on: "configure",
64
68
  handler: async () => {
65
- const pages = this.alepha.getDescriptorValues($page);
69
+ const pages = this.alepha.descriptors($page);
66
70
 
67
71
  const ssrEnabled =
68
72
  pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
69
73
 
70
74
  this.alepha.state("react.server.ssr", ssrEnabled);
71
75
 
72
- for (const { key, instance, value } of pages) {
73
- const name = value[OPTIONS].name ?? key;
74
-
75
- instance[key].render = this.createRenderFunction(name);
76
+ for (const page of pages) {
77
+ page.render = this.createRenderFunction(page.name);
76
78
  }
77
79
 
78
80
  // development mode
@@ -105,7 +107,7 @@ export class ReactServerProvider {
105
107
 
106
108
  // no SSR enabled, serve index.html for all unmatched routes
107
109
  this.log.info("SSR is disabled, use History API fallback");
108
- await this.serverRouterProvider.route({
110
+ this.serverRouterProvider.createRoute({
109
111
  path: "*",
110
112
  handler: async ({ url, reply }) => {
111
113
  if (url.pathname.includes(".")) {
@@ -127,7 +129,7 @@ export class ReactServerProvider {
127
129
 
128
130
  public get template() {
129
131
  return (
130
- this.alepha.state("react.server.template") ??
132
+ this.alepha.env.REACT_SERVER_TEMPLATE ??
131
133
  "<!DOCTYPE html><html lang='en'><head></head><body></body></html>"
132
134
  );
133
135
  }
@@ -140,7 +142,7 @@ export class ReactServerProvider {
140
142
 
141
143
  this.log.debug(`+ ${page.match} -> ${page.name}`);
142
144
 
143
- await this.serverRouterProvider.route({
145
+ this.serverRouterProvider.createRoute({
144
146
  ...page,
145
147
  schema: undefined, // schema is handled by the page descriptor provider for now (shared by browser and server)
146
148
  method: "GET",
@@ -166,7 +168,7 @@ export class ReactServerProvider {
166
168
  }
167
169
 
168
170
  protected async configureStaticServer(root: string) {
169
- await this.serverStaticProvider.serve({
171
+ await this.serverStaticProvider.createStaticServer({
170
172
  root,
171
173
  path: this.env.REACT_SERVER_PREFIX,
172
174
  });
@@ -262,7 +264,7 @@ export class ReactServerProvider {
262
264
  };
263
265
 
264
266
  if (this.alepha.has(ServerLinksProvider)) {
265
- const srv = this.alepha.get(ServerLinksProvider);
267
+ const srv = this.alepha.inject(ServerLinksProvider);
266
268
  const schema = apiLinksResponseSchema as any;
267
269
 
268
270
  context.links = this.alepha.parse(