@alepha/react 0.6.3 → 0.6.5

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 (37) hide show
  1. package/dist/index.browser.cjs +2 -1
  2. package/dist/index.browser.cjs.map +1 -0
  3. package/dist/index.browser.js +3 -2
  4. package/dist/index.browser.js.map +1 -0
  5. package/dist/index.cjs +4 -3
  6. package/dist/index.cjs.map +1 -0
  7. package/dist/index.d.ts +34 -24
  8. package/dist/index.js +6 -5
  9. package/dist/index.js.map +1 -0
  10. package/dist/{useActive-dAmCT31a.js → useActive-BzjLwZjs.js} +103 -78
  11. package/dist/useActive-BzjLwZjs.js.map +1 -0
  12. package/dist/{useActive-BVqdq757.cjs → useActive-Ce3Xvs5V.cjs} +102 -77
  13. package/dist/useActive-Ce3Xvs5V.cjs.map +1 -0
  14. package/package.json +46 -38
  15. package/src/components/Link.tsx +37 -0
  16. package/src/components/NestedView.tsx +38 -0
  17. package/src/contexts/RouterContext.ts +16 -0
  18. package/src/contexts/RouterLayerContext.ts +10 -0
  19. package/src/descriptors/$page.ts +142 -0
  20. package/src/errors/RedirectionError.ts +7 -0
  21. package/src/hooks/RouterHookApi.ts +156 -0
  22. package/src/hooks/useActive.ts +57 -0
  23. package/src/hooks/useClient.ts +6 -0
  24. package/src/hooks/useInject.ts +14 -0
  25. package/src/hooks/useQueryParams.ts +59 -0
  26. package/src/hooks/useRouter.ts +25 -0
  27. package/src/hooks/useRouterEvents.ts +58 -0
  28. package/src/hooks/useRouterState.ts +21 -0
  29. package/src/index.browser.ts +21 -0
  30. package/src/index.shared.ts +15 -0
  31. package/src/index.ts +63 -0
  32. package/src/providers/BrowserHeadProvider.ts +43 -0
  33. package/src/providers/BrowserRouterProvider.ts +152 -0
  34. package/src/providers/PageDescriptorProvider.ts +522 -0
  35. package/src/providers/ReactBrowserProvider.ts +232 -0
  36. package/src/providers/ReactServerProvider.ts +286 -0
  37. package/src/providers/ServerHeadProvider.ts +91 -0
@@ -0,0 +1,232 @@
1
+ import { $hook, $inject, $logger, Alepha, type Static, t } from "@alepha/core";
2
+ import { HttpClient, type HttpClientLink } from "@alepha/server";
3
+ import type { Root } from "react-dom/client";
4
+ import { createRoot, hydrateRoot } from "react-dom/client";
5
+ import type { Head } from "../descriptors/$page.ts";
6
+ import { BrowserHeadProvider } from "./BrowserHeadProvider.ts";
7
+ import { BrowserRouterProvider } from "./BrowserRouterProvider.ts";
8
+ import type {
9
+ PreviousLayerData,
10
+ RouterState,
11
+ TransitionOptions,
12
+ } from "./PageDescriptorProvider.ts";
13
+
14
+ const envSchema = t.object({
15
+ REACT_ROOT_ID: t.string({ default: "root" }),
16
+ });
17
+
18
+ declare module "@alepha/core" {
19
+ interface Env extends Partial<Static<typeof envSchema>> {}
20
+ }
21
+
22
+ export class ReactBrowserProvider {
23
+ protected readonly log = $logger();
24
+ protected readonly client = $inject(HttpClient);
25
+ protected readonly alepha = $inject(Alepha);
26
+ protected readonly router = $inject(BrowserRouterProvider);
27
+ protected readonly headProvider = $inject(BrowserHeadProvider);
28
+ protected readonly env = $inject(envSchema);
29
+ protected root!: Root;
30
+
31
+ public transitioning?: {
32
+ to: string;
33
+ };
34
+
35
+ public state: RouterState = {
36
+ layers: [],
37
+ pathname: "",
38
+ search: "",
39
+ head: {},
40
+ };
41
+
42
+ public get document() {
43
+ return window.document;
44
+ }
45
+
46
+ public get history() {
47
+ return window.history;
48
+ }
49
+
50
+ public get url(): string {
51
+ return window.location.pathname + window.location.search;
52
+ }
53
+
54
+ public async invalidate(props?: Record<string, any>) {
55
+ const previous: PreviousLayerData[] = [];
56
+
57
+ if (props) {
58
+ const [key] = Object.keys(props);
59
+ const value = props[key];
60
+
61
+ for (const layer of this.state.layers) {
62
+ if (layer.props?.[key]) {
63
+ previous.push({
64
+ ...layer,
65
+ props: {
66
+ ...layer.props,
67
+ [key]: value,
68
+ },
69
+ });
70
+ break;
71
+ }
72
+ previous.push(layer);
73
+ }
74
+ }
75
+
76
+ await this.render({ previous });
77
+ }
78
+
79
+ /**
80
+ *
81
+ * @param url
82
+ * @param options
83
+ */
84
+ public async go(url: string, options: RouterGoOptions = {}): Promise<void> {
85
+ const result = await this.render({
86
+ url,
87
+ });
88
+
89
+ if (result.url !== url) {
90
+ this.history.replaceState({}, "", result.url);
91
+ return;
92
+ }
93
+
94
+ if (options.replace) {
95
+ this.history.replaceState({}, "", url);
96
+ return;
97
+ }
98
+
99
+ this.history.pushState({}, "", url);
100
+ }
101
+
102
+ protected async render(
103
+ options: {
104
+ url?: string;
105
+ previous?: PreviousLayerData[];
106
+ } = {},
107
+ ): Promise<{ url: string; head: Head }> {
108
+ const previous = options.previous ?? this.state.layers;
109
+ const url = options.url ?? this.url;
110
+
111
+ this.transitioning = { to: url };
112
+
113
+ const result = await this.router.transition(
114
+ new URL(`http://localhost${url}`),
115
+ {
116
+ previous,
117
+ state: this.state,
118
+ },
119
+ );
120
+
121
+ if (result.redirect) {
122
+ return await this.render({ url: result.redirect });
123
+ }
124
+
125
+ this.transitioning = undefined;
126
+
127
+ return { url, head: result.head };
128
+ }
129
+
130
+ /**
131
+ * Get embedded layers from the server.
132
+ *
133
+ * @protected
134
+ */
135
+ protected getHydrationState(): ReactHydrationState | undefined {
136
+ try {
137
+ if ("__ssr" in window && typeof window.__ssr === "object") {
138
+ return window.__ssr as ReactHydrationState;
139
+ }
140
+ } catch (error) {
141
+ console.error(error);
142
+ }
143
+ }
144
+
145
+ /**
146
+ *
147
+ * @protected
148
+ */
149
+ protected getRootElement() {
150
+ const root = this.document.getElementById(this.env.REACT_ROOT_ID);
151
+ if (root) {
152
+ return root;
153
+ }
154
+
155
+ const div = this.document.createElement("div");
156
+ div.id = this.env.REACT_ROOT_ID;
157
+
158
+ this.document.body.prepend(div);
159
+
160
+ return div;
161
+ }
162
+
163
+ // -------------------------------------------------------------------------------------------------------------------
164
+
165
+ /**
166
+ *
167
+ * @protected
168
+ */
169
+ public readonly ready = $hook({
170
+ name: "ready",
171
+ handler: async () => {
172
+ const hydration = this.getHydrationState();
173
+ const previous = hydration?.layers ?? [];
174
+
175
+ if (hydration?.links) {
176
+ this.client.links = hydration.links as HttpClientLink[];
177
+ }
178
+
179
+ const { head } = await this.render({ previous });
180
+ if (head) {
181
+ this.headProvider.renderHead(this.document, head);
182
+ }
183
+
184
+ const context = {};
185
+
186
+ await this.alepha.emit("react:browser:render", {
187
+ context,
188
+ hydration,
189
+ });
190
+
191
+ const element = this.router.root(this.state, context);
192
+
193
+ if (previous.length > 0) {
194
+ this.root = hydrateRoot(this.getRootElement(), element);
195
+ this.log.info("Hydrated root element");
196
+ } else {
197
+ this.root ??= createRoot(this.getRootElement());
198
+ this.root.render(element);
199
+ this.log.info("Created root element");
200
+ }
201
+
202
+ window.addEventListener("popstate", () => {
203
+ this.render();
204
+ });
205
+
206
+ this.alepha.on("react:transition:end", {
207
+ callback: ({ state }) => {
208
+ this.headProvider.renderHead(this.document, state.head);
209
+ },
210
+ });
211
+ },
212
+ });
213
+
214
+ public readonly onTransitionEnd = $hook({
215
+ name: "react:transition:end",
216
+ handler: async ({ state }) => {
217
+ this.headProvider.renderHead(this.document, state.head);
218
+ },
219
+ });
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------------------------------------------------
223
+
224
+ export interface RouterGoOptions {
225
+ replace?: boolean;
226
+ match?: TransitionOptions;
227
+ }
228
+
229
+ export interface ReactHydrationState {
230
+ layers?: PreviousLayerData[];
231
+ links?: HttpClientLink[];
232
+ }
@@ -0,0 +1,286 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import {
5
+ $hook,
6
+ $inject,
7
+ $logger,
8
+ Alepha,
9
+ OPTIONS,
10
+ type Static,
11
+ t,
12
+ } from "@alepha/core";
13
+ import {
14
+ type ServerHandler,
15
+ ServerLinksProvider,
16
+ ServerRouterProvider,
17
+ } from "@alepha/server";
18
+ import { ServerStaticProvider } from "@alepha/server-static";
19
+ import { renderToString } from "react-dom/server";
20
+ import { $page } from "../descriptors/$page.ts";
21
+ import {
22
+ PageDescriptorProvider,
23
+ type PageRequest,
24
+ type PageRoute,
25
+ } from "./PageDescriptorProvider.ts";
26
+ import { ServerHeadProvider } from "./ServerHeadProvider.ts";
27
+
28
+ export const envSchema = t.object({
29
+ REACT_SERVER_DIST: t.string({ default: "client" }),
30
+ REACT_SERVER_PREFIX: t.string({ default: "" }),
31
+ REACT_SSR_ENABLED: t.boolean({ default: false }),
32
+ REACT_ROOT_ID: t.string({ default: "root" }),
33
+ });
34
+
35
+ declare module "@alepha/core" {
36
+ interface Env extends Partial<Static<typeof envSchema>> {}
37
+ interface State {
38
+ "ReactServerProvider.template"?: string;
39
+ "ReactServerProvider.ssr"?: boolean;
40
+ }
41
+ }
42
+
43
+ export class ReactServerProvider {
44
+ protected readonly log = $logger();
45
+ protected readonly alepha = $inject(Alepha);
46
+ protected readonly pageDescriptorProvider = $inject(PageDescriptorProvider);
47
+ protected readonly serverStaticProvider = $inject(ServerStaticProvider);
48
+ protected readonly serverRouterProvider = $inject(ServerRouterProvider);
49
+ protected readonly headProvider = $inject(ServerHeadProvider);
50
+ protected readonly env = $inject(envSchema);
51
+ protected readonly ROOT_DIV_REGEX = new RegExp(
52
+ `<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
53
+ "is",
54
+ );
55
+
56
+ protected readonly configure = $hook({
57
+ name: "configure",
58
+ handler: async () => {
59
+ const pages = this.alepha.getDescriptorValues($page);
60
+ if (pages.length === 0) {
61
+ return;
62
+ }
63
+
64
+ for (const { key, instance, value } of pages) {
65
+ const name = value[OPTIONS].name ?? key;
66
+
67
+ if (this.alepha.isTest()) {
68
+ instance[key].render = this.createRenderFunction(name);
69
+ }
70
+ }
71
+
72
+ if (this.alepha.isServerless() === "vite") {
73
+ await this.configureVite();
74
+ return;
75
+ }
76
+
77
+ let root = "";
78
+ if (!this.alepha.isServerless()) {
79
+ root = this.getPublicDirectory();
80
+
81
+ if (!root) {
82
+ this.log.warn("Missing static files, SSR will be disabled");
83
+ return;
84
+ }
85
+
86
+ await this.configureStaticServer(root);
87
+ }
88
+
89
+ const template =
90
+ this.alepha.state("ReactServerProvider.template") ??
91
+ (await readFile(join(root, "index.html"), "utf-8"));
92
+
93
+ await this.registerPages(async () => template);
94
+
95
+ this.alepha.state("ReactServerProvider.ssr", true);
96
+ },
97
+ });
98
+
99
+ protected async registerPages(
100
+ templateLoader: () => Promise<string | undefined>,
101
+ ) {
102
+ for (const page of this.pageDescriptorProvider.getPages()) {
103
+ this.log.debug(`+ ${page.match} -> ${page.name}`);
104
+ await this.serverRouterProvider.route({
105
+ method: "GET",
106
+ path: page.match,
107
+ handler: this.createHandler(page, templateLoader),
108
+ });
109
+ }
110
+ }
111
+
112
+ protected getPublicDirectory(): string {
113
+ const maybe = [
114
+ join(process.cwd(), this.env.REACT_SERVER_DIST),
115
+ join(process.cwd(), "..", this.env.REACT_SERVER_DIST),
116
+ ];
117
+
118
+ for (const it of maybe) {
119
+ if (existsSync(it)) {
120
+ return it;
121
+ }
122
+ }
123
+
124
+ return "";
125
+ }
126
+
127
+ protected async configureStaticServer(root: string) {
128
+ await this.serverStaticProvider.serve({
129
+ root,
130
+ path: this.env.REACT_SERVER_PREFIX,
131
+ });
132
+ }
133
+
134
+ protected async configureVite() {
135
+ const url = `http://${process.env.SERVER_HOST}:${process.env.SERVER_PORT}`;
136
+ this.log.info("SSR (vite) OK");
137
+ this.alepha.state("ReactServerProvider.ssr", true);
138
+ const templateUrl = `${url}/index.html`;
139
+
140
+ await this.registerPages(() =>
141
+ fetch(templateUrl)
142
+ .then((it) => it.text())
143
+ .catch(() => undefined),
144
+ );
145
+ }
146
+
147
+ protected createRenderFunction(name: string) {
148
+ return async (
149
+ options: {
150
+ params?: Record<string, string>;
151
+ query?: Record<string, string>;
152
+ } = {},
153
+ ) => {
154
+ const page = this.pageDescriptorProvider.page(name);
155
+
156
+ const state = await this.pageDescriptorProvider.createLayers(page, {
157
+ url: new URL("http://localhost"),
158
+ params: options.params ?? {},
159
+ query: options.query ?? {},
160
+ head: {},
161
+ });
162
+
163
+ return renderToString(this.pageDescriptorProvider.root(state));
164
+ };
165
+ }
166
+
167
+ protected createHandler(
168
+ page: PageRoute,
169
+ templateLoader: () => Promise<string | undefined>,
170
+ ): ServerHandler {
171
+ return async (serverRequest) => {
172
+ const { url, reply, query, params } = serverRequest;
173
+ const template = await templateLoader();
174
+ if (!template) {
175
+ throw new Error("Template not found");
176
+ }
177
+
178
+ const request: PageRequest = {
179
+ url,
180
+ params,
181
+ query,
182
+ head: {},
183
+ };
184
+
185
+ // -- links
186
+ if (this.alepha.has(ServerLinksProvider)) {
187
+ const srv = this.alepha.get(ServerLinksProvider);
188
+ request.links = (await srv.links()) as any;
189
+ this.alepha.als.set("links", request.links);
190
+ }
191
+
192
+ await this.alepha.emit(
193
+ "react:server:render",
194
+ {
195
+ request: serverRequest,
196
+ pageRequest: request,
197
+ },
198
+ {
199
+ log: false,
200
+ },
201
+ );
202
+
203
+ const state = await this.pageDescriptorProvider.createLayers(
204
+ page,
205
+ request,
206
+ );
207
+
208
+ if (state.redirect) {
209
+ return reply.redirect(state.redirect);
210
+ }
211
+
212
+ const element = this.pageDescriptorProvider.root(state, request);
213
+ const app = renderToString(element);
214
+
215
+ // create hydration data
216
+ const script = `<script>window.__ssr=${JSON.stringify({
217
+ links: request.links,
218
+ layers: state.layers.map((it) => ({
219
+ ...it,
220
+ error: it.error
221
+ ? {
222
+ ...it.error,
223
+ name: it.error.name,
224
+ message: it.error.message,
225
+ stack: it.error.stack, // TODO: Hide stack in production ?
226
+ }
227
+ : undefined,
228
+ index: undefined,
229
+ path: undefined,
230
+ element: undefined,
231
+ })),
232
+ })}</script>`;
233
+
234
+ const response = {
235
+ html: template,
236
+ };
237
+
238
+ reply.status = 200;
239
+ reply.headers["content-type"] = "text/html";
240
+ reply.headers["cache-control"] =
241
+ "no-store, no-cache, must-revalidate, proxy-revalidate";
242
+ reply.headers.pragma = "no-cache";
243
+ reply.headers.expires = "0";
244
+
245
+ // inject app into template
246
+ this.fillTemplate(response, app, script);
247
+
248
+ // inject head meta
249
+ if (state.head) {
250
+ response.html = this.headProvider.renderHead(response.html, state.head);
251
+ }
252
+
253
+ // TODO: hook for plugins "react:server:template"
254
+ // { response: { html: string }, request, state }
255
+
256
+ return response.html;
257
+ };
258
+ }
259
+
260
+ fillTemplate(response: { html: string }, app: string, script: string) {
261
+ if (this.ROOT_DIV_REGEX.test(response.html)) {
262
+ // replace contents of the existing <div id="root">
263
+ response.html = response.html.replace(
264
+ this.ROOT_DIV_REGEX,
265
+ (_match, beforeId, afterId) => {
266
+ return `<div${beforeId} id="${this.env.REACT_ROOT_ID}"${afterId}>${app}</div>`;
267
+ },
268
+ );
269
+ } else {
270
+ const bodyOpenTag = /<body([^>]*)>/i;
271
+ if (bodyOpenTag.test(response.html)) {
272
+ response.html = response.html.replace(bodyOpenTag, (match) => {
273
+ return `${match}\n<div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
274
+ });
275
+ }
276
+ }
277
+
278
+ const bodyCloseTagRegex = /<\/body>/i;
279
+ if (bodyCloseTagRegex.test(response.html)) {
280
+ response.html = response.html.replace(
281
+ bodyCloseTagRegex,
282
+ `${script}\n</body>`,
283
+ );
284
+ }
285
+ }
286
+ }
@@ -0,0 +1,91 @@
1
+ export interface Head {
2
+ title?: string;
3
+ htmlAttributes?: Record<string, string>;
4
+ bodyAttributes?: Record<string, string>;
5
+ meta?: Array<{ name: string; content: string }>;
6
+ }
7
+
8
+ export class ServerHeadProvider {
9
+ renderHead(template: string, head: Head): string {
10
+ let result = template;
11
+
12
+ // Inject htmlAttributes
13
+ const htmlAttributes = head.htmlAttributes;
14
+ if (htmlAttributes) {
15
+ result = result.replace(
16
+ /<html([^>]*)>/i,
17
+ (_, existingAttrs) =>
18
+ `<html${this.mergeAttributes(existingAttrs, htmlAttributes)}>`,
19
+ );
20
+ }
21
+
22
+ // Inject bodyAttributes
23
+ const bodyAttributes = head.bodyAttributes;
24
+ if (bodyAttributes) {
25
+ result = result.replace(
26
+ /<body([^>]*)>/i,
27
+ (_, existingAttrs) =>
28
+ `<body${this.mergeAttributes(existingAttrs, bodyAttributes)}>`,
29
+ );
30
+ }
31
+
32
+ // Build head content
33
+ let headContent = "";
34
+ const title = head.title;
35
+ if (title) {
36
+ if (template.includes("<title>")) {
37
+ result = result.replace(
38
+ /<title>(.*?)<\/title>/i,
39
+ () => `<title>${this.escapeHtml(title)}</title>`,
40
+ );
41
+ } else {
42
+ headContent += `<title>${this.escapeHtml(title)}</title>\n`;
43
+ }
44
+ }
45
+
46
+ if (head.meta) {
47
+ for (const meta of head.meta) {
48
+ headContent += `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
49
+ }
50
+ }
51
+
52
+ // Inject into <head>...</head>
53
+ result = result.replace(
54
+ /<head([^>]*)>(.*?)<\/head>/is,
55
+ (_, existingAttrs, existingHead) =>
56
+ `<head${existingAttrs}>${existingHead}${headContent}</head>`,
57
+ );
58
+
59
+ return result.trim();
60
+ }
61
+
62
+ mergeAttributes(existing: string, attrs: Record<string, string>): string {
63
+ const existingAttrs = this.parseAttributes(existing);
64
+ const merged = { ...existingAttrs, ...attrs };
65
+ return Object.entries(merged)
66
+ .map(([k, v]) => ` ${k}="${this.escapeHtml(v)}"`)
67
+ .join("");
68
+ }
69
+
70
+ parseAttributes(attrStr: string): Record<string, string> {
71
+ const attrs: Record<string, string> = {};
72
+ const attrRegex = /([^\s=]+)(?:="([^"]*)")?/g;
73
+ let match: RegExpExecArray | null = attrRegex.exec(attrStr);
74
+
75
+ while (match) {
76
+ attrs[match[1]] = match[2] ?? "";
77
+ match = attrRegex.exec(attrStr);
78
+ }
79
+
80
+ return attrs;
81
+ }
82
+
83
+ escapeHtml(str: string): string {
84
+ return str
85
+ .replace(/&/g, "&amp;")
86
+ .replace(/</g, "&lt;")
87
+ .replace(/>/g, "&gt;")
88
+ .replace(/"/g, "&quot;")
89
+ .replace(/'/g, "&#039;");
90
+ }
91
+ }