@alepha/react 0.7.5 → 0.7.7

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.
package/dist/index.js CHANGED
@@ -1,362 +1,1253 @@
1
- import { t, $logger, $inject, Alepha, $hook, OPTIONS, __bind } from '@alepha/core';
2
- import { ServerRouterProvider, ServerTimingProvider, ServerLinksProvider, apiLinksResponseSchema, AlephaServer } from '@alepha/server';
3
- import { AlephaServerCache } from '@alepha/server-cache';
4
- import { P as PageDescriptorProvider, $ as $page } from './useRouterState-cCucJfTC.js';
5
- export { C as ClientOnly, E as ErrorBoundary, L as Link, N as NestedView, l as ReactBrowserProvider, b as RedirectionError, R as RouterContext, c as RouterHookApi, a as RouterLayerContext, k as isPageRoute, u as useActive, d as useAlepha, e as useClient, f as useInject, g as useQueryParams, h as useRouter, i as useRouterEvents, j as useRouterState } from './useRouterState-cCucJfTC.js';
6
- import { existsSync } from 'node:fs';
7
- import { join } from 'node:path';
8
- import { ServerStaticProvider } from '@alepha/server-static';
9
- import { renderToString } from 'react-dom/server';
10
- import 'react/jsx-runtime';
11
- import 'react';
12
- import '@alepha/router';
1
+ import { $hook, $inject, $logger, Alepha, KIND, NotImplementedError, OPTIONS, __bind, __descriptor, t } from "@alepha/core";
2
+ import { AlephaServer, ServerRouterProvider, ServerTimingProvider, apiLinksResponseSchema } from "@alepha/server";
3
+ import { AlephaServerCache } from "@alepha/server-cache";
4
+ import React, { StrictMode, createContext, createElement, useContext, useEffect, useMemo, useState } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { LinkProvider, ServerLinksProvider } from "@alepha/server-links";
9
+ import { ServerStaticProvider } from "@alepha/server-static";
10
+ import { renderToString } from "react-dom/server";
11
+ import { RouterProvider } from "@alepha/router";
13
12
 
14
- class ServerHeadProvider {
15
- renderHead(template, head) {
16
- let result = template;
17
- const htmlAttributes = head.htmlAttributes;
18
- if (htmlAttributes) {
19
- result = result.replace(
20
- /<html([^>]*)>/i,
21
- (_, existingAttrs) => `<html${this.mergeAttributes(existingAttrs, htmlAttributes)}>`
22
- );
23
- }
24
- const bodyAttributes = head.bodyAttributes;
25
- if (bodyAttributes) {
26
- result = result.replace(
27
- /<body([^>]*)>/i,
28
- (_, existingAttrs) => `<body${this.mergeAttributes(existingAttrs, bodyAttributes)}>`
29
- );
30
- }
31
- let headContent = "";
32
- const title = head.title;
33
- if (title) {
34
- if (template.includes("<title>")) {
35
- result = result.replace(
36
- /<title>(.*?)<\/title>/i,
37
- () => `<title>${this.escapeHtml(title)}</title>`
38
- );
39
- } else {
40
- headContent += `<title>${this.escapeHtml(title)}</title>
41
- `;
42
- }
43
- }
44
- if (head.meta) {
45
- for (const meta of head.meta) {
46
- headContent += `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">
47
- `;
48
- }
49
- }
50
- result = result.replace(
51
- /<head([^>]*)>(.*?)<\/head>/is,
52
- (_, existingAttrs, existingHead) => `<head${existingAttrs}>${existingHead}${headContent}</head>`
53
- );
54
- return result.trim();
55
- }
56
- mergeAttributes(existing, attrs) {
57
- const existingAttrs = this.parseAttributes(existing);
58
- const merged = { ...existingAttrs, ...attrs };
59
- return Object.entries(merged).map(([k, v]) => ` ${k}="${this.escapeHtml(v)}"`).join("");
60
- }
61
- parseAttributes(attrStr) {
62
- const attrs = {};
63
- const attrRegex = /([^\s=]+)(?:="([^"]*)")?/g;
64
- let match = attrRegex.exec(attrStr);
65
- while (match) {
66
- attrs[match[1]] = match[2] ?? "";
67
- match = attrRegex.exec(attrStr);
68
- }
69
- return attrs;
70
- }
71
- escapeHtml(str) {
72
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
73
- }
13
+ //#region src/descriptors/$page.ts
14
+ const KEY = "PAGE";
15
+ /**
16
+ * Main descriptor for defining a React route in the application.
17
+ */
18
+ const $page = (options) => {
19
+ __descriptor(KEY);
20
+ if (options.children) for (const child of options.children) child[OPTIONS].parent = { [OPTIONS]: options };
21
+ if (options.parent) {
22
+ options.parent[OPTIONS].children ??= [];
23
+ options.parent[OPTIONS].children.push({ [OPTIONS]: options });
24
+ }
25
+ return {
26
+ [KIND]: KEY,
27
+ [OPTIONS]: options,
28
+ render: () => {
29
+ throw new NotImplementedError(KEY);
30
+ }
31
+ };
32
+ };
33
+ $page[KIND] = KEY;
34
+
35
+ //#endregion
36
+ //#region src/components/ClientOnly.tsx
37
+ /**
38
+ * A small utility component that renders its children only on the client side.
39
+ *
40
+ * Optionally, you can provide a fallback React node that will be rendered.
41
+ *
42
+ * You should use this component when
43
+ * - you have code that relies on browser-specific APIs
44
+ * - you want to avoid server-side rendering for a specific part of your application
45
+ * - you want to prevent pre-rendering of a component
46
+ */
47
+ const ClientOnly = (props) => {
48
+ const [mounted, setMounted] = useState(false);
49
+ useEffect(() => setMounted(true), []);
50
+ if (props.disabled) return props.children;
51
+ return mounted ? props.children : props.fallback;
52
+ };
53
+ var ClientOnly_default = ClientOnly;
54
+
55
+ //#endregion
56
+ //#region src/contexts/RouterContext.ts
57
+ const RouterContext = createContext(void 0);
58
+
59
+ //#endregion
60
+ //#region src/hooks/useAlepha.ts
61
+ const useAlepha = () => {
62
+ const routerContext = useContext(RouterContext);
63
+ if (!routerContext) throw new Error("useAlepha must be used within a RouterProvider");
64
+ return routerContext.alepha;
65
+ };
66
+
67
+ //#endregion
68
+ //#region src/components/ErrorViewer.tsx
69
+ const ErrorViewer = ({ error }) => {
70
+ const [expanded, setExpanded] = useState(false);
71
+ const isProduction = useAlepha().isProduction();
72
+ if (isProduction) return /* @__PURE__ */ jsx(ErrorViewerProduction, {});
73
+ const stackLines = error.stack?.split("\n") ?? [];
74
+ const previewLines = stackLines.slice(0, 5);
75
+ const hiddenLineCount = stackLines.length - previewLines.length;
76
+ const copyToClipboard = (text) => {
77
+ navigator.clipboard.writeText(text).catch((err) => {
78
+ console.error("Clipboard error:", err);
79
+ });
80
+ };
81
+ const styles = {
82
+ container: {
83
+ padding: "24px",
84
+ backgroundColor: "#FEF2F2",
85
+ color: "#7F1D1D",
86
+ border: "1px solid #FECACA",
87
+ borderRadius: "16px",
88
+ boxShadow: "0 8px 24px rgba(0,0,0,0.05)",
89
+ fontFamily: "monospace",
90
+ maxWidth: "768px",
91
+ margin: "40px auto"
92
+ },
93
+ heading: {
94
+ fontSize: "20px",
95
+ fontWeight: "bold",
96
+ marginBottom: "4px"
97
+ },
98
+ name: {
99
+ fontSize: "16px",
100
+ fontWeight: 600
101
+ },
102
+ message: {
103
+ fontSize: "14px",
104
+ marginBottom: "16px"
105
+ },
106
+ sectionHeader: {
107
+ display: "flex",
108
+ justifyContent: "space-between",
109
+ alignItems: "center",
110
+ fontSize: "12px",
111
+ marginBottom: "4px",
112
+ color: "#991B1B"
113
+ },
114
+ copyButton: {
115
+ fontSize: "12px",
116
+ color: "#DC2626",
117
+ background: "none",
118
+ border: "none",
119
+ cursor: "pointer",
120
+ textDecoration: "underline"
121
+ },
122
+ stackContainer: {
123
+ backgroundColor: "#FEE2E2",
124
+ padding: "12px",
125
+ borderRadius: "8px",
126
+ fontSize: "13px",
127
+ lineHeight: "1.4",
128
+ overflowX: "auto",
129
+ whiteSpace: "pre-wrap"
130
+ },
131
+ expandLine: {
132
+ color: "#F87171",
133
+ cursor: "pointer",
134
+ marginTop: "8px"
135
+ }
136
+ };
137
+ return /* @__PURE__ */ jsxs("div", {
138
+ style: styles.container,
139
+ children: [/* @__PURE__ */ jsxs("div", { children: [
140
+ /* @__PURE__ */ jsx("div", {
141
+ style: styles.heading,
142
+ children: "🔥 Error"
143
+ }),
144
+ /* @__PURE__ */ jsx("div", {
145
+ style: styles.name,
146
+ children: error.name
147
+ }),
148
+ /* @__PURE__ */ jsx("div", {
149
+ style: styles.message,
150
+ children: error.message
151
+ })
152
+ ] }), stackLines.length > 0 && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("div", {
153
+ style: styles.sectionHeader,
154
+ children: [/* @__PURE__ */ jsx("span", { children: "Stack trace" }), /* @__PURE__ */ jsx("button", {
155
+ onClick: () => copyToClipboard(error.stack),
156
+ style: styles.copyButton,
157
+ children: "Copy all"
158
+ })]
159
+ }), /* @__PURE__ */ jsxs("pre", {
160
+ style: styles.stackContainer,
161
+ children: [(expanded ? stackLines : previewLines).map((line, i) => /* @__PURE__ */ jsx("div", { children: line }, i)), !expanded && hiddenLineCount > 0 && /* @__PURE__ */ jsxs("div", {
162
+ style: styles.expandLine,
163
+ onClick: () => setExpanded(true),
164
+ children: [
165
+ "+ ",
166
+ hiddenLineCount,
167
+ " more lines..."
168
+ ]
169
+ })]
170
+ })] })]
171
+ });
172
+ };
173
+ var ErrorViewer_default = ErrorViewer;
174
+ const ErrorViewerProduction = () => {
175
+ const styles = {
176
+ container: {
177
+ padding: "24px",
178
+ backgroundColor: "#FEF2F2",
179
+ color: "#7F1D1D",
180
+ border: "1px solid #FECACA",
181
+ borderRadius: "16px",
182
+ boxShadow: "0 8px 24px rgba(0,0,0,0.05)",
183
+ fontFamily: "monospace",
184
+ maxWidth: "768px",
185
+ margin: "40px auto",
186
+ textAlign: "center"
187
+ },
188
+ heading: {
189
+ fontSize: "20px",
190
+ fontWeight: "bold",
191
+ marginBottom: "8px"
192
+ },
193
+ name: {
194
+ fontSize: "16px",
195
+ fontWeight: 600,
196
+ marginBottom: "4px"
197
+ },
198
+ message: {
199
+ fontSize: "14px",
200
+ opacity: .85
201
+ }
202
+ };
203
+ return /* @__PURE__ */ jsxs("div", {
204
+ style: styles.container,
205
+ children: [/* @__PURE__ */ jsx("div", {
206
+ style: styles.heading,
207
+ children: "🚨 An error occurred"
208
+ }), /* @__PURE__ */ jsx("div", {
209
+ style: styles.message,
210
+ children: "Something went wrong. Please try again later."
211
+ })]
212
+ });
213
+ };
214
+
215
+ //#endregion
216
+ //#region src/contexts/RouterLayerContext.ts
217
+ const RouterLayerContext = createContext(void 0);
218
+
219
+ //#endregion
220
+ //#region src/hooks/useRouterEvents.ts
221
+ const useRouterEvents = (opts = {}, deps = []) => {
222
+ const ctx = useContext(RouterContext);
223
+ if (!ctx) throw new Error("useRouter must be used within a RouterProvider");
224
+ useEffect(() => {
225
+ if (!ctx.alepha.isBrowser()) return;
226
+ const subs = [];
227
+ const onBegin = opts.onBegin;
228
+ const onEnd = opts.onEnd;
229
+ const onError = opts.onError;
230
+ if (onBegin) subs.push(ctx.alepha.on("react:transition:begin", { callback: onBegin }));
231
+ if (onEnd) subs.push(ctx.alepha.on("react:transition:end", { callback: onEnd }));
232
+ if (onError) subs.push(ctx.alepha.on("react:transition:error", { callback: onError }));
233
+ return () => {
234
+ for (const sub of subs) sub();
235
+ };
236
+ }, deps);
237
+ };
238
+
239
+ //#endregion
240
+ //#region src/components/ErrorBoundary.tsx
241
+ /**
242
+ * A reusable error boundary for catching rendering errors
243
+ * in any part of the React component tree.
244
+ */
245
+ var ErrorBoundary = class extends React.Component {
246
+ constructor(props) {
247
+ super(props);
248
+ this.state = {};
249
+ }
250
+ /**
251
+ * Update state so the next render shows the fallback UI.
252
+ */
253
+ static getDerivedStateFromError(error) {
254
+ return { error };
255
+ }
256
+ /**
257
+ * Lifecycle method called when an error is caught.
258
+ * You can log the error or perform side effects here.
259
+ */
260
+ componentDidCatch(error, info) {
261
+ if (this.props.onError) this.props.onError(error, info);
262
+ }
263
+ render() {
264
+ if (this.state.error) return this.props.fallback(this.state.error);
265
+ return this.props.children;
266
+ }
267
+ };
268
+ var ErrorBoundary_default = ErrorBoundary;
269
+
270
+ //#endregion
271
+ //#region src/components/NestedView.tsx
272
+ /**
273
+ * A component that renders the current view of the nested router layer.
274
+ *
275
+ * To be simple, it renders the `element` of the current child page of a parent page.
276
+ *
277
+ * @example
278
+ * ```tsx
279
+ * import { NestedView } from "@alepha/react";
280
+ *
281
+ * class App {
282
+ * parent = $page({
283
+ * component: () => <NestedView />,
284
+ * });
285
+ *
286
+ * child = $page({
287
+ * parent: this.root,
288
+ * component: () => <div>Child Page</div>,
289
+ * });
290
+ * }
291
+ * ```
292
+ */
293
+ const NestedView = (props) => {
294
+ const app = useContext(RouterContext);
295
+ const layer = useContext(RouterLayerContext);
296
+ const index = layer?.index ?? 0;
297
+ const [view, setView] = useState(app?.state.layers[index]?.element);
298
+ useRouterEvents({ onEnd: ({ state }) => {
299
+ setView(state.layers[index]?.element);
300
+ } }, [app]);
301
+ if (!app) throw new Error("NestedView must be used within a RouterContext.");
302
+ const element = view ?? props.children ?? null;
303
+ return /* @__PURE__ */ jsx(ErrorBoundary_default, {
304
+ fallback: app.context.onError,
305
+ children: element
306
+ });
307
+ };
308
+ var NestedView_default = NestedView;
309
+
310
+ //#endregion
311
+ //#region src/components/NotFound.tsx
312
+ function NotFoundPage() {
313
+ return /* @__PURE__ */ jsx("div", {
314
+ style: {
315
+ height: "100vh",
316
+ display: "flex",
317
+ flexDirection: "column",
318
+ justifyContent: "center",
319
+ alignItems: "center",
320
+ textAlign: "center",
321
+ fontFamily: "sans-serif",
322
+ padding: "1rem"
323
+ },
324
+ children: /* @__PURE__ */ jsx("h1", {
325
+ style: {
326
+ fontSize: "1rem",
327
+ marginBottom: "0.5rem"
328
+ },
329
+ children: "This page does not exist"
330
+ })
331
+ });
74
332
  }
75
333
 
334
+ //#endregion
335
+ //#region src/errors/RedirectionError.ts
336
+ var RedirectionError = class extends Error {
337
+ page;
338
+ constructor(page) {
339
+ super("Redirection");
340
+ this.page = page;
341
+ }
342
+ };
343
+
344
+ //#endregion
345
+ //#region src/providers/PageDescriptorProvider.ts
346
+ const envSchema$1 = t.object({ REACT_STRICT_MODE: t.boolean({ default: true }) });
347
+ var PageDescriptorProvider = class {
348
+ log = $logger();
349
+ env = $inject(envSchema$1);
350
+ alepha = $inject(Alepha);
351
+ pages = [];
352
+ getPages() {
353
+ return this.pages;
354
+ }
355
+ page(name) {
356
+ for (const page of this.pages) if (page.name === name) return page;
357
+ throw new Error(`Page ${name} not found`);
358
+ }
359
+ url(name, options = {}) {
360
+ const page = this.page(name);
361
+ if (!page) throw new Error(`Page ${name} not found`);
362
+ let url = page.path ?? "";
363
+ let parent = page.parent;
364
+ while (parent) {
365
+ url = `${parent.path ?? ""}/${url}`;
366
+ parent = parent.parent;
367
+ }
368
+ url = this.compile(url, options.params ?? {});
369
+ return new URL(url.replace(/\/\/+/g, "/") || "/", options.base ?? `http://localhost`);
370
+ }
371
+ root(state, context) {
372
+ const root = createElement(RouterContext.Provider, { value: {
373
+ alepha: this.alepha,
374
+ state,
375
+ context
376
+ } }, createElement(NestedView_default, {}, state.layers[0]?.element));
377
+ if (this.env.REACT_STRICT_MODE) return createElement(StrictMode, {}, root);
378
+ return root;
379
+ }
380
+ async createLayers(route, request) {
381
+ const { pathname, search } = request.url;
382
+ const layers = [];
383
+ let context = {};
384
+ const stack = [{ route }];
385
+ request.onError = (error) => this.renderError(error);
386
+ let parent = route.parent;
387
+ while (parent) {
388
+ stack.unshift({ route: parent });
389
+ parent = parent.parent;
390
+ }
391
+ let forceRefresh = false;
392
+ for (let i = 0; i < stack.length; i++) {
393
+ const it = stack[i];
394
+ const route$1 = it.route;
395
+ const config = {};
396
+ try {
397
+ config.query = route$1.schema?.query ? this.alepha.parse(route$1.schema.query, request.query) : request.query;
398
+ } catch (e) {
399
+ it.error = e;
400
+ break;
401
+ }
402
+ try {
403
+ config.params = route$1.schema?.params ? this.alepha.parse(route$1.schema.params, request.params) : request.params;
404
+ } catch (e) {
405
+ it.error = e;
406
+ break;
407
+ }
408
+ it.config = { ...config };
409
+ if (!route$1.resolve) continue;
410
+ const previous = request.previous;
411
+ if (previous?.[i] && !forceRefresh && previous[i].name === route$1.name) {
412
+ const url = (str) => str ? str.replace(/\/\/+/g, "/") : "/";
413
+ const prev = JSON.stringify({
414
+ part: url(previous[i].part),
415
+ params: previous[i].config?.params ?? {}
416
+ });
417
+ const curr = JSON.stringify({
418
+ part: url(route$1.path),
419
+ params: config.params ?? {}
420
+ });
421
+ if (prev === curr) {
422
+ it.props = previous[i].props;
423
+ it.error = previous[i].error;
424
+ context = {
425
+ ...context,
426
+ ...it.props
427
+ };
428
+ continue;
429
+ }
430
+ forceRefresh = true;
431
+ }
432
+ try {
433
+ const props = await route$1.resolve?.({
434
+ ...request,
435
+ ...config,
436
+ ...context
437
+ }) ?? {};
438
+ it.props = { ...props };
439
+ context = {
440
+ ...context,
441
+ ...props
442
+ };
443
+ } catch (e) {
444
+ if (e instanceof RedirectionError) return {
445
+ layers: [],
446
+ redirect: typeof e.page === "string" ? e.page : this.href(e.page),
447
+ pathname,
448
+ search
449
+ };
450
+ this.log.error(e);
451
+ it.error = e;
452
+ break;
453
+ }
454
+ }
455
+ let acc = "";
456
+ for (let i = 0; i < stack.length; i++) {
457
+ const it = stack[i];
458
+ const props = it.props ?? {};
459
+ const params = { ...it.config?.params };
460
+ for (const key of Object.keys(params)) params[key] = String(params[key]);
461
+ acc += "/";
462
+ acc += it.route.path ? this.compile(it.route.path, params) : "";
463
+ const path = acc.replace(/\/+/, "/");
464
+ const localErrorHandler = this.getErrorHandler(it.route);
465
+ if (localErrorHandler) request.onError = localErrorHandler;
466
+ if (it.error) {
467
+ let element$1 = await request.onError(it.error);
468
+ if (element$1 === null) element$1 = this.renderError(it.error);
469
+ layers.push({
470
+ props,
471
+ error: it.error,
472
+ name: it.route.name,
473
+ part: it.route.path,
474
+ config: it.config,
475
+ element: this.renderView(i + 1, path, element$1, it.route),
476
+ index: i + 1,
477
+ path,
478
+ route
479
+ });
480
+ break;
481
+ }
482
+ const element = await this.createElement(it.route, {
483
+ ...props,
484
+ ...context
485
+ });
486
+ layers.push({
487
+ name: it.route.name,
488
+ props,
489
+ part: it.route.path,
490
+ config: it.config,
491
+ element: this.renderView(i + 1, path, element, it.route),
492
+ index: i + 1,
493
+ path,
494
+ route
495
+ });
496
+ }
497
+ return {
498
+ layers,
499
+ pathname,
500
+ search
501
+ };
502
+ }
503
+ getErrorHandler(route) {
504
+ if (route.errorHandler) return route.errorHandler;
505
+ let parent = route.parent;
506
+ while (parent) {
507
+ if (parent.errorHandler) return parent.errorHandler;
508
+ parent = parent.parent;
509
+ }
510
+ }
511
+ async createElement(page, props) {
512
+ if (page.lazy) {
513
+ const component = await page.lazy();
514
+ return createElement(component.default, props);
515
+ }
516
+ if (page.component) return createElement(page.component, props);
517
+ return void 0;
518
+ }
519
+ renderError(error) {
520
+ return createElement(ErrorViewer_default, { error });
521
+ }
522
+ renderEmptyView() {
523
+ return createElement(NestedView_default, {});
524
+ }
525
+ href(page, params = {}) {
526
+ const found = this.pages.find((it) => it.name === page.options.name);
527
+ if (!found) throw new Error(`Page ${page.options.name} not found`);
528
+ let url = found.path ?? "";
529
+ let parent = found.parent;
530
+ while (parent) {
531
+ url = `${parent.path ?? ""}/${url}`;
532
+ parent = parent.parent;
533
+ }
534
+ url = this.compile(url, params);
535
+ return url.replace(/\/\/+/g, "/") || "/";
536
+ }
537
+ compile(path, params = {}) {
538
+ for (const [key, value] of Object.entries(params)) path = path.replace(`:${key}`, value);
539
+ return path;
540
+ }
541
+ renderView(index, path, view, page) {
542
+ view ??= this.renderEmptyView();
543
+ const element = page.client ? createElement(ClientOnly_default, typeof page.client === "object" ? page.client : {}, view) : view;
544
+ return createElement(RouterLayerContext.Provider, { value: {
545
+ index,
546
+ path
547
+ } }, element);
548
+ }
549
+ configure = $hook({
550
+ name: "configure",
551
+ handler: () => {
552
+ let hasNotFoundHandler = false;
553
+ const pages = this.alepha.getDescriptorValues($page);
554
+ for (const { value, key } of pages) value[OPTIONS].name ??= key;
555
+ for (const { value } of pages) {
556
+ if (value[OPTIONS].parent) continue;
557
+ if (value[OPTIONS].path === "/*") hasNotFoundHandler = true;
558
+ this.add(this.map(pages, value));
559
+ }
560
+ if (!hasNotFoundHandler && pages.length > 0) this.add({
561
+ path: "/*",
562
+ name: "notFound",
563
+ cache: true,
564
+ component: NotFoundPage,
565
+ afterHandler: ({ reply }) => {
566
+ reply.status = 404;
567
+ }
568
+ });
569
+ }
570
+ });
571
+ map(pages, target) {
572
+ const children = target[OPTIONS].children ?? [];
573
+ return {
574
+ ...target[OPTIONS],
575
+ parent: void 0,
576
+ children: children.map((it) => this.map(pages, it))
577
+ };
578
+ }
579
+ add(entry) {
580
+ if (this.alepha.isReady()) throw new Error("Router is already initialized");
581
+ entry.name ??= this.nextId();
582
+ const page = entry;
583
+ page.match = this.createMatch(page);
584
+ this.pages.push(page);
585
+ if (page.children) for (const child of page.children) {
586
+ child.parent = page;
587
+ this.add(child);
588
+ }
589
+ }
590
+ createMatch(page) {
591
+ let url = page.path ?? "/";
592
+ let target = page.parent;
593
+ while (target) {
594
+ url = `${target.path ?? ""}/${url}`;
595
+ target = target.parent;
596
+ }
597
+ let path = url.replace(/\/\/+/g, "/");
598
+ if (path.endsWith("/") && path !== "/") path = path.slice(0, -1);
599
+ return path;
600
+ }
601
+ _next = 0;
602
+ nextId() {
603
+ this._next += 1;
604
+ return `P${this._next}`;
605
+ }
606
+ };
607
+ const isPageRoute = (it) => {
608
+ return it && typeof it === "object" && typeof it.path === "string" && typeof it.page === "object";
609
+ };
610
+
611
+ //#endregion
612
+ //#region src/providers/ReactServerProvider.ts
76
613
  const envSchema = t.object({
77
- REACT_SERVER_DIST: t.string({ default: "public" }),
78
- REACT_SERVER_PREFIX: t.string({ default: "" }),
79
- REACT_SSR_ENABLED: t.optional(t.boolean()),
80
- REACT_ROOT_ID: t.string({ default: "root" })
614
+ REACT_SERVER_DIST: t.string({ default: "public" }),
615
+ REACT_SERVER_PREFIX: t.string({ default: "" }),
616
+ REACT_SSR_ENABLED: t.optional(t.boolean()),
617
+ REACT_ROOT_ID: t.string({ default: "root" })
81
618
  });
82
- class ReactServerProvider {
83
- log = $logger();
84
- alepha = $inject(Alepha);
85
- pageDescriptorProvider = $inject(PageDescriptorProvider);
86
- serverStaticProvider = $inject(ServerStaticProvider);
87
- serverRouterProvider = $inject(ServerRouterProvider);
88
- headProvider = $inject(ServerHeadProvider);
89
- serverTimingProvider = $inject(ServerTimingProvider);
90
- env = $inject(envSchema);
91
- ROOT_DIV_REGEX = new RegExp(
92
- `<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
93
- "is"
94
- );
95
- onConfigure = $hook({
96
- name: "configure",
97
- handler: async () => {
98
- const pages = this.alepha.getDescriptorValues($page);
99
- const ssrEnabled = pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
100
- this.alepha.state("ReactServerProvider.ssr", ssrEnabled);
101
- for (const { key, instance, value } of pages) {
102
- const name = value[OPTIONS].name ?? key;
103
- instance[key].prerender = this.createRenderFunction(name, true);
104
- if (this.alepha.isTest()) {
105
- instance[key].render = this.createRenderFunction(name);
106
- }
107
- }
108
- if (this.alepha.isServerless() === "vite") {
109
- await this.configureVite(ssrEnabled);
110
- return;
111
- }
112
- let root = "";
113
- if (!this.alepha.isServerless()) {
114
- root = this.getPublicDirectory();
115
- if (!root) {
116
- this.log.warn(
117
- "Missing static files, static file server will be disabled"
118
- );
119
- } else {
120
- this.log.debug(`Using static files from: ${root}`);
121
- await this.configureStaticServer(root);
122
- }
123
- }
124
- if (ssrEnabled) {
125
- await this.registerPages(async () => this.template);
126
- this.log.info("SSR OK");
127
- return;
128
- }
129
- this.log.info("SSR is disabled, use History API fallback");
130
- await this.serverRouterProvider.route({
131
- path: "*",
132
- handler: async ({ url, reply }) => {
133
- if (url.pathname.includes(".")) {
134
- reply.headers["content-type"] = "text/plain";
135
- reply.body = "Not Found";
136
- reply.status = 404;
137
- return;
138
- }
139
- reply.headers["content-type"] = "text/html";
140
- return this.template;
141
- }
142
- });
143
- }
144
- });
145
- get template() {
146
- return this.alepha.state("ReactServerProvider.template");
147
- }
148
- async registerPages(templateLoader) {
149
- for (const page of this.pageDescriptorProvider.getPages()) {
150
- if (page.children?.length) {
151
- continue;
152
- }
153
- this.log.debug(`+ ${page.match} -> ${page.name}`);
154
- await this.serverRouterProvider.route({
155
- ...page,
156
- schema: void 0,
157
- // schema is handled by the page descriptor provider for now (shared by browser and server)
158
- method: "GET",
159
- path: page.match,
160
- handler: this.createHandler(page, templateLoader)
161
- });
162
- }
163
- }
164
- getPublicDirectory() {
165
- const maybe = [
166
- join(process.cwd(), `dist/${this.env.REACT_SERVER_DIST}`),
167
- join(process.cwd(), this.env.REACT_SERVER_DIST)
168
- ];
169
- for (const it of maybe) {
170
- if (existsSync(it)) {
171
- return it;
172
- }
173
- }
174
- return "";
175
- }
176
- async configureStaticServer(root) {
177
- await this.serverStaticProvider.serve({
178
- root,
179
- path: this.env.REACT_SERVER_PREFIX
180
- });
181
- }
182
- async configureVite(ssrEnabled) {
183
- if (!ssrEnabled) {
184
- return;
185
- }
186
- this.log.info("SSR (vite) OK");
187
- const url = `http://${process.env.SERVER_HOST}:${process.env.SERVER_PORT}`;
188
- await this.registerPages(
189
- () => fetch(`${url}/index.html`).then((it) => it.text()).catch(() => void 0)
190
- );
191
- }
192
- /**
193
- * For testing purposes, creates a render function that can be used.
194
- */
195
- createRenderFunction(name, withIndex = false) {
196
- return async (options = {}) => {
197
- const page = this.pageDescriptorProvider.page(name);
198
- const url = new URL(this.pageDescriptorProvider.url(name, options));
199
- const context = {
200
- url,
201
- params: options.params ?? {},
202
- query: options.query ?? {},
203
- head: {},
204
- onError: () => null
205
- };
206
- const state = await this.pageDescriptorProvider.createLayers(
207
- page,
208
- context
209
- );
210
- if (!withIndex) {
211
- return {
212
- context,
213
- html: renderToString(
214
- this.pageDescriptorProvider.root(state, context)
215
- )
216
- };
217
- }
218
- return {
219
- context,
220
- html: this.renderToHtml(this.template ?? "", state, context)
221
- };
222
- };
223
- }
224
- createHandler(page, templateLoader) {
225
- return async (serverRequest) => {
226
- const { url, reply, query, params } = serverRequest;
227
- const template = await templateLoader();
228
- if (!template) {
229
- throw new Error("Template not found");
230
- }
231
- const context = {
232
- url,
233
- params,
234
- query,
235
- // plugins
236
- head: {},
237
- onError: () => null
238
- };
239
- if (this.alepha.has(ServerLinksProvider)) {
240
- const srv = this.alepha.get(ServerLinksProvider);
241
- const schema = apiLinksResponseSchema;
242
- context.links = this.alepha.parse(
243
- schema,
244
- await srv.getLinks({
245
- user: serverRequest.user,
246
- authorization: serverRequest.headers.authorization
247
- })
248
- );
249
- this.alepha.context.set("links", context.links);
250
- }
251
- let target = page;
252
- while (target) {
253
- if (page.can && !page.can()) {
254
- reply.status = 403;
255
- reply.headers["content-type"] = "text/plain";
256
- return "Forbidden";
257
- }
258
- target = target.parent;
259
- }
260
- await this.alepha.emit(
261
- "react:server:render",
262
- {
263
- request: serverRequest,
264
- pageRequest: context
265
- },
266
- {
267
- log: false
268
- }
269
- );
270
- this.serverTimingProvider.beginTiming("createLayers");
271
- const state = await this.pageDescriptorProvider.createLayers(
272
- page,
273
- context
274
- );
275
- this.serverTimingProvider.endTiming("createLayers");
276
- if (state.redirect) {
277
- return reply.redirect(state.redirect);
278
- }
279
- reply.headers["content-type"] = "text/html";
280
- reply.headers["cache-control"] = "no-store, no-cache, must-revalidate, proxy-revalidate";
281
- reply.headers.pragma = "no-cache";
282
- reply.headers.expires = "0";
283
- if (page.cache && serverRequest.user) {
284
- delete context.links;
285
- }
286
- const html = this.renderToHtml(template, state, context);
287
- page.afterHandler?.(serverRequest);
288
- return html;
289
- };
290
- }
291
- renderToHtml(template, state, context) {
292
- const element = this.pageDescriptorProvider.root(state, context);
293
- this.serverTimingProvider.beginTiming("renderToString");
294
- let app = "";
295
- try {
296
- app = renderToString(element);
297
- } catch (error) {
298
- this.log.error("Error during SSR", error);
299
- app = renderToString(context.onError(error));
300
- }
301
- this.serverTimingProvider.endTiming("renderToString");
302
- const hydrationData = {
303
- links: context.links,
304
- layers: state.layers.map((it) => ({
305
- ...it,
306
- error: it.error ? {
307
- ...it.error,
308
- name: it.error.name,
309
- message: it.error.message,
310
- stack: it.error.stack
311
- // TODO: Hide stack in production ?
312
- } : void 0,
313
- index: void 0,
314
- path: void 0,
315
- element: void 0
316
- }))
317
- };
318
- const script = `<script>window.__ssr=${JSON.stringify(hydrationData)}<\/script>`;
319
- const response = {
320
- html: template
321
- };
322
- this.fillTemplate(response, app, script);
323
- if (context.head) {
324
- response.html = this.headProvider.renderHead(response.html, context.head);
325
- }
326
- return response.html;
327
- }
328
- fillTemplate(response, app, script) {
329
- if (this.ROOT_DIV_REGEX.test(response.html)) {
330
- response.html = response.html.replace(
331
- this.ROOT_DIV_REGEX,
332
- (_match, beforeId, afterId) => {
333
- return `<div${beforeId} id="${this.env.REACT_ROOT_ID}"${afterId}>${app}</div>`;
334
- }
335
- );
336
- } else {
337
- const bodyOpenTag = /<body([^>]*)>/i;
338
- if (bodyOpenTag.test(response.html)) {
339
- response.html = response.html.replace(bodyOpenTag, (match) => {
340
- return `${match}
341
- <div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
342
- });
343
- }
344
- }
345
- const bodyCloseTagRegex = /<\/body>/i;
346
- if (bodyCloseTagRegex.test(response.html)) {
347
- response.html = response.html.replace(
348
- bodyCloseTagRegex,
349
- `${script}
350
- </body>`
351
- );
352
- }
353
- }
354
- }
619
+ var ReactServerProvider = class {
620
+ log = $logger();
621
+ alepha = $inject(Alepha);
622
+ pageDescriptorProvider = $inject(PageDescriptorProvider);
623
+ serverStaticProvider = $inject(ServerStaticProvider);
624
+ serverRouterProvider = $inject(ServerRouterProvider);
625
+ serverTimingProvider = $inject(ServerTimingProvider);
626
+ env = $inject(envSchema);
627
+ ROOT_DIV_REGEX = new RegExp(`<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`, "is");
628
+ onConfigure = $hook({
629
+ name: "configure",
630
+ handler: async () => {
631
+ const pages = this.alepha.getDescriptorValues($page);
632
+ const ssrEnabled = pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
633
+ this.alepha.state("ReactServerProvider.ssr", ssrEnabled);
634
+ for (const { key, instance, value } of pages) {
635
+ const name = value[OPTIONS].name ?? key;
636
+ instance[key].render = this.createRenderFunction(name);
637
+ }
638
+ if (this.alepha.isServerless() === "vite") {
639
+ await this.configureVite(ssrEnabled);
640
+ return;
641
+ }
642
+ let root = "";
643
+ if (!this.alepha.isServerless()) {
644
+ root = this.getPublicDirectory();
645
+ if (!root) this.log.warn("Missing static files, static file server will be disabled");
646
+ else {
647
+ this.log.debug(`Using static files from: ${root}`);
648
+ await this.configureStaticServer(root);
649
+ }
650
+ }
651
+ if (ssrEnabled) {
652
+ await this.registerPages(async () => this.template);
653
+ this.log.info("SSR OK");
654
+ return;
655
+ }
656
+ this.log.info("SSR is disabled, use History API fallback");
657
+ await this.serverRouterProvider.route({
658
+ path: "*",
659
+ handler: async ({ url, reply }) => {
660
+ if (url.pathname.includes(".")) {
661
+ reply.headers["content-type"] = "text/plain";
662
+ reply.body = "Not Found";
663
+ reply.status = 404;
664
+ return;
665
+ }
666
+ reply.headers["content-type"] = "text/html";
667
+ return this.template;
668
+ }
669
+ });
670
+ }
671
+ });
672
+ get template() {
673
+ return this.alepha.state("ReactServerProvider.template") ?? "<!DOCTYPE html><html lang='en'><head></head><body></body></html>";
674
+ }
675
+ async registerPages(templateLoader) {
676
+ for (const page of this.pageDescriptorProvider.getPages()) {
677
+ if (page.children?.length) continue;
678
+ this.log.debug(`+ ${page.match} -> ${page.name}`);
679
+ await this.serverRouterProvider.route({
680
+ ...page,
681
+ schema: void 0,
682
+ method: "GET",
683
+ path: page.match,
684
+ handler: this.createHandler(page, templateLoader)
685
+ });
686
+ }
687
+ }
688
+ getPublicDirectory() {
689
+ const maybe = [join(process.cwd(), `dist/${this.env.REACT_SERVER_DIST}`), join(process.cwd(), this.env.REACT_SERVER_DIST)];
690
+ for (const it of maybe) if (existsSync(it)) return it;
691
+ return "";
692
+ }
693
+ async configureStaticServer(root) {
694
+ await this.serverStaticProvider.serve({
695
+ root,
696
+ path: this.env.REACT_SERVER_PREFIX
697
+ });
698
+ }
699
+ async configureVite(ssrEnabled) {
700
+ if (!ssrEnabled) return;
701
+ this.log.info("SSR (vite) OK");
702
+ const url = `http://${process.env.SERVER_HOST}:${process.env.SERVER_PORT}`;
703
+ await this.registerPages(() => fetch(`${url}/index.html`).then((it) => it.text()).catch(() => void 0));
704
+ }
705
+ /**
706
+ * For testing purposes, creates a render function that can be used.
707
+ */
708
+ createRenderFunction(name, withIndex = false) {
709
+ return async (options = {}) => {
710
+ const page = this.pageDescriptorProvider.page(name);
711
+ const url = new URL(this.pageDescriptorProvider.url(name, options));
712
+ const context = {
713
+ url,
714
+ params: options.params ?? {},
715
+ query: options.query ?? {},
716
+ head: {},
717
+ onError: () => null
718
+ };
719
+ await this.alepha.emit("react:server:render:begin", { context });
720
+ const state = await this.pageDescriptorProvider.createLayers(page, context);
721
+ if (!withIndex && !options.html) return {
722
+ context,
723
+ html: renderToString(this.pageDescriptorProvider.root(state, context))
724
+ };
725
+ const html = this.renderToHtml(this.template ?? "", state, context, options.hydration);
726
+ const result = {
727
+ context,
728
+ state,
729
+ html
730
+ };
731
+ await this.alepha.emit("react:server:render:end", result);
732
+ return result;
733
+ };
734
+ }
735
+ createHandler(page, templateLoader) {
736
+ return async (serverRequest) => {
737
+ const { url, reply, query, params } = serverRequest;
738
+ const template = await templateLoader();
739
+ if (!template) throw new Error("Template not found");
740
+ const context = {
741
+ url,
742
+ params,
743
+ query,
744
+ head: {},
745
+ onError: () => null
746
+ };
747
+ if (this.alepha.has(ServerLinksProvider)) {
748
+ const srv = this.alepha.get(ServerLinksProvider);
749
+ const schema = apiLinksResponseSchema;
750
+ context.links = this.alepha.parse(schema, await srv.getLinks({
751
+ user: serverRequest.user,
752
+ authorization: serverRequest.headers.authorization
753
+ }));
754
+ this.alepha.context.set("links", context.links);
755
+ }
756
+ let target = page;
757
+ while (target) {
758
+ if (page.can && !page.can()) {
759
+ reply.status = 403;
760
+ reply.headers["content-type"] = "text/plain";
761
+ return "Forbidden";
762
+ }
763
+ target = target.parent;
764
+ }
765
+ await this.alepha.emit("react:server:render:begin", {
766
+ request: serverRequest,
767
+ context
768
+ });
769
+ this.serverTimingProvider.beginTiming("createLayers");
770
+ const state = await this.pageDescriptorProvider.createLayers(page, context);
771
+ this.serverTimingProvider.endTiming("createLayers");
772
+ if (state.redirect) return reply.redirect(state.redirect);
773
+ reply.headers["content-type"] = "text/html";
774
+ reply.headers["cache-control"] = "no-store, no-cache, must-revalidate, proxy-revalidate";
775
+ reply.headers.pragma = "no-cache";
776
+ reply.headers.expires = "0";
777
+ if (page.cache && serverRequest.user) delete context.links;
778
+ const html = this.renderToHtml(template, state, context);
779
+ await this.alepha.emit("react:server:render:end", {
780
+ request: serverRequest,
781
+ context,
782
+ state,
783
+ html
784
+ });
785
+ page.afterHandler?.(serverRequest);
786
+ return html;
787
+ };
788
+ }
789
+ renderToHtml(template, state, context, hydration = true) {
790
+ const element = this.pageDescriptorProvider.root(state, context);
791
+ this.serverTimingProvider.beginTiming("renderToString");
792
+ let app = "";
793
+ try {
794
+ app = renderToString(element);
795
+ } catch (error) {
796
+ this.log.error("Error during SSR", error);
797
+ app = renderToString(context.onError(error));
798
+ }
799
+ this.serverTimingProvider.endTiming("renderToString");
800
+ const response = { html: template };
801
+ if (hydration) {
802
+ const hydrationData = {
803
+ links: context.links,
804
+ layers: state.layers.map((it) => ({
805
+ ...it,
806
+ error: it.error ? {
807
+ ...it.error,
808
+ name: it.error.name,
809
+ message: it.error.message,
810
+ stack: it.error.stack
811
+ } : void 0,
812
+ index: void 0,
813
+ path: void 0,
814
+ element: void 0,
815
+ route: void 0
816
+ }))
817
+ };
818
+ const script = `<script>window.__ssr=${JSON.stringify(hydrationData)}</script>`;
819
+ this.fillTemplate(response, app, script);
820
+ }
821
+ return response.html;
822
+ }
823
+ fillTemplate(response, app, script) {
824
+ if (this.ROOT_DIV_REGEX.test(response.html)) response.html = response.html.replace(this.ROOT_DIV_REGEX, (_match, beforeId, afterId) => {
825
+ return `<div${beforeId} id="${this.env.REACT_ROOT_ID}"${afterId}>${app}</div>`;
826
+ });
827
+ else {
828
+ const bodyOpenTag = /<body([^>]*)>/i;
829
+ if (bodyOpenTag.test(response.html)) response.html = response.html.replace(bodyOpenTag, (match) => {
830
+ return `${match}\n<div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
831
+ });
832
+ }
833
+ const bodyCloseTagRegex = /<\/body>/i;
834
+ if (bodyCloseTagRegex.test(response.html)) response.html = response.html.replace(bodyCloseTagRegex, `${script}\n</body>`);
835
+ }
836
+ };
355
837
 
356
- class AlephaReact {
357
- name = "alepha.react";
358
- $services = (alepha) => alepha.with(AlephaServer).with(AlephaServerCache).with(ReactServerProvider).with(PageDescriptorProvider);
359
- }
838
+ //#endregion
839
+ //#region src/providers/BrowserRouterProvider.ts
840
+ var BrowserRouterProvider = class extends RouterProvider {
841
+ log = $logger();
842
+ alepha = $inject(Alepha);
843
+ pageDescriptorProvider = $inject(PageDescriptorProvider);
844
+ add(entry) {
845
+ this.pageDescriptorProvider.add(entry);
846
+ }
847
+ configure = $hook({
848
+ name: "configure",
849
+ handler: async () => {
850
+ for (const page of this.pageDescriptorProvider.getPages()) if (page.component || page.lazy) this.push({
851
+ path: page.match,
852
+ page
853
+ });
854
+ }
855
+ });
856
+ async transition(url, options = {}) {
857
+ const { pathname, search } = url;
858
+ const state = {
859
+ pathname,
860
+ search,
861
+ layers: []
862
+ };
863
+ const context = {
864
+ url,
865
+ query: {},
866
+ params: {},
867
+ onError: () => null,
868
+ ...options.context ?? {}
869
+ };
870
+ await this.alepha.emit("react:transition:begin", {
871
+ state,
872
+ context
873
+ });
874
+ try {
875
+ const previous = options.previous;
876
+ const { route, params } = this.match(pathname);
877
+ const query = {};
878
+ if (search) for (const [key, value] of new URLSearchParams(search).entries()) query[key] = String(value);
879
+ context.query = query;
880
+ context.params = params ?? {};
881
+ context.previous = previous;
882
+ if (isPageRoute(route)) {
883
+ const result = await this.pageDescriptorProvider.createLayers(route.page, context);
884
+ if (result.redirect) return {
885
+ redirect: result.redirect,
886
+ state,
887
+ context
888
+ };
889
+ state.layers = result.layers;
890
+ }
891
+ if (state.layers.length === 0) state.layers.push({
892
+ name: "not-found",
893
+ element: createElement(NotFoundPage),
894
+ index: 0,
895
+ path: "/"
896
+ });
897
+ await this.alepha.emit("react:transition:success", {
898
+ state,
899
+ context
900
+ });
901
+ } catch (e) {
902
+ this.log.error(e);
903
+ state.layers = [{
904
+ name: "error",
905
+ element: this.pageDescriptorProvider.renderError(e),
906
+ index: 0,
907
+ path: "/"
908
+ }];
909
+ await this.alepha.emit("react:transition:error", {
910
+ error: e,
911
+ state,
912
+ context
913
+ });
914
+ }
915
+ if (options.state) {
916
+ options.state.layers = state.layers;
917
+ options.state.pathname = state.pathname;
918
+ options.state.search = state.search;
919
+ }
920
+ await this.alepha.emit("react:transition:end", {
921
+ state: options.state,
922
+ context
923
+ });
924
+ return {
925
+ context,
926
+ state
927
+ };
928
+ }
929
+ root(state, context) {
930
+ return this.pageDescriptorProvider.root(state, context);
931
+ }
932
+ };
933
+
934
+ //#endregion
935
+ //#region src/providers/ReactBrowserProvider.ts
936
+ var ReactBrowserProvider = class {
937
+ log = $logger();
938
+ client = $inject(LinkProvider);
939
+ alepha = $inject(Alepha);
940
+ router = $inject(BrowserRouterProvider);
941
+ root;
942
+ transitioning;
943
+ state = {
944
+ layers: [],
945
+ pathname: "",
946
+ search: ""
947
+ };
948
+ get document() {
949
+ return window.document;
950
+ }
951
+ get history() {
952
+ return window.history;
953
+ }
954
+ get url() {
955
+ return window.location.pathname + window.location.search;
956
+ }
957
+ async invalidate(props) {
958
+ const previous = [];
959
+ if (props) {
960
+ const [key] = Object.keys(props);
961
+ const value = props[key];
962
+ for (const layer of this.state.layers) {
963
+ if (layer.props?.[key]) {
964
+ previous.push({
965
+ ...layer,
966
+ props: {
967
+ ...layer.props,
968
+ [key]: value
969
+ }
970
+ });
971
+ break;
972
+ }
973
+ previous.push(layer);
974
+ }
975
+ }
976
+ await this.render({ previous });
977
+ }
978
+ async go(url, options = {}) {
979
+ const result = await this.render({ url });
980
+ if (result.context.url.pathname !== url) {
981
+ this.history.replaceState({}, "", result.context.url.pathname);
982
+ return;
983
+ }
984
+ if (options.replace) {
985
+ this.history.replaceState({}, "", url);
986
+ return;
987
+ }
988
+ this.history.pushState({}, "", url);
989
+ }
990
+ async render(options = {}) {
991
+ const previous = options.previous ?? this.state.layers;
992
+ const url = options.url ?? this.url;
993
+ this.transitioning = { to: url };
994
+ const result = await this.router.transition(new URL(`http://localhost${url}`), {
995
+ previous,
996
+ state: this.state
997
+ });
998
+ if (result.redirect) return await this.render({ url: result.redirect });
999
+ this.transitioning = void 0;
1000
+ return result;
1001
+ }
1002
+ /**
1003
+ * Get embedded layers from the server.
1004
+ */
1005
+ getHydrationState() {
1006
+ try {
1007
+ if ("__ssr" in window && typeof window.__ssr === "object") return window.__ssr;
1008
+ } catch (error) {
1009
+ console.error(error);
1010
+ }
1011
+ }
1012
+ ready = $hook({
1013
+ name: "ready",
1014
+ handler: async () => {
1015
+ const hydration = this.getHydrationState();
1016
+ const previous = hydration?.layers ?? [];
1017
+ if (hydration?.links) for (const link of hydration.links.links) this.client.pushLink(link);
1018
+ const { context } = await this.render({ previous });
1019
+ await this.alepha.emit("react:browser:render", {
1020
+ state: this.state,
1021
+ context,
1022
+ hydration
1023
+ });
1024
+ window.addEventListener("popstate", () => {
1025
+ this.render();
1026
+ });
1027
+ }
1028
+ });
1029
+ };
1030
+
1031
+ //#endregion
1032
+ //#region src/hooks/RouterHookApi.ts
1033
+ var RouterHookApi = class {
1034
+ constructor(pages, state, layer, browser) {
1035
+ this.pages = pages;
1036
+ this.state = state;
1037
+ this.layer = layer;
1038
+ this.browser = browser;
1039
+ }
1040
+ get current() {
1041
+ return this.state;
1042
+ }
1043
+ get pathname() {
1044
+ return this.state.pathname;
1045
+ }
1046
+ get query() {
1047
+ const query = {};
1048
+ for (const [key, value] of new URLSearchParams(this.state.search).entries()) query[key] = String(value);
1049
+ return query;
1050
+ }
1051
+ async back() {
1052
+ this.browser?.history.back();
1053
+ }
1054
+ async forward() {
1055
+ this.browser?.history.forward();
1056
+ }
1057
+ async invalidate(props) {
1058
+ await this.browser?.invalidate(props);
1059
+ }
1060
+ /**
1061
+ * Create a valid href for the given pathname.
1062
+ *
1063
+ * @param pathname
1064
+ * @param layer
1065
+ */
1066
+ createHref(pathname, layer = this.layer, options = {}) {
1067
+ if (typeof pathname === "object") pathname = pathname.options.path ?? "";
1068
+ if (options.params) for (const [key, value] of Object.entries(options.params)) pathname = pathname.replace(`:${key}`, String(value));
1069
+ return pathname.startsWith("/") ? pathname : `${layer.path}/${pathname}`.replace(/\/\/+/g, "/");
1070
+ }
1071
+ async go(path, options) {
1072
+ for (const page of this.pages) if (page.name === path) {
1073
+ path = page.path ?? "";
1074
+ break;
1075
+ }
1076
+ await this.browser?.go(this.createHref(path, this.layer, options), options);
1077
+ }
1078
+ anchor(path, options = {}) {
1079
+ for (const page of this.pages) if (page.name === path) {
1080
+ path = page.path ?? "";
1081
+ break;
1082
+ }
1083
+ const href = this.createHref(path, this.layer, options);
1084
+ return {
1085
+ href,
1086
+ onClick: (ev) => {
1087
+ ev.stopPropagation();
1088
+ ev.preventDefault();
1089
+ this.go(path, options).catch(console.error);
1090
+ }
1091
+ };
1092
+ }
1093
+ /**
1094
+ * Set query params.
1095
+ *
1096
+ * @param record
1097
+ * @param options
1098
+ */
1099
+ setQueryParams(record, options = {}) {
1100
+ const func = typeof record === "function" ? record : () => record;
1101
+ const search = new URLSearchParams(func(this.query)).toString();
1102
+ const state = search ? `${this.pathname}?${search}` : this.pathname;
1103
+ if (options.push) window.history.pushState({}, "", state);
1104
+ else window.history.replaceState({}, "", state);
1105
+ }
1106
+ };
1107
+
1108
+ //#endregion
1109
+ //#region src/hooks/useRouter.ts
1110
+ const useRouter = () => {
1111
+ const ctx = useContext(RouterContext);
1112
+ const layer = useContext(RouterLayerContext);
1113
+ if (!ctx || !layer) throw new Error("useRouter must be used within a RouterProvider");
1114
+ const pages = useMemo(() => {
1115
+ return ctx.alepha.get(PageDescriptorProvider).getPages();
1116
+ }, []);
1117
+ return useMemo(() => new RouterHookApi(pages, ctx.state, layer, ctx.alepha.isBrowser() ? ctx.alepha.get(ReactBrowserProvider) : void 0), [layer]);
1118
+ };
1119
+
1120
+ //#endregion
1121
+ //#region src/components/Link.tsx
1122
+ const Link = (props) => {
1123
+ React.useContext(RouterContext);
1124
+ const router = useRouter();
1125
+ const to = typeof props.to === "string" ? props.to : props.to[OPTIONS].path;
1126
+ if (!to) return null;
1127
+ const can = typeof props.to === "string" ? void 0 : props.to[OPTIONS].can;
1128
+ if (can && !can()) return null;
1129
+ const name = typeof props.to === "string" ? void 0 : props.to[OPTIONS].name;
1130
+ const anchorProps = {
1131
+ ...props,
1132
+ to: void 0
1133
+ };
1134
+ return /* @__PURE__ */ jsx("a", {
1135
+ ...router.anchor(to),
1136
+ ...anchorProps,
1137
+ children: props.children ?? name
1138
+ });
1139
+ };
1140
+ var Link_default = Link;
1141
+
1142
+ //#endregion
1143
+ //#region src/hooks/useActive.ts
1144
+ const useActive = (path) => {
1145
+ const router = useRouter();
1146
+ const ctx = useContext(RouterContext);
1147
+ const layer = useContext(RouterLayerContext);
1148
+ if (!ctx || !layer) throw new Error("useRouter must be used within a RouterProvider");
1149
+ let name;
1150
+ if (typeof path === "object" && path.options.name) name = path.options.name;
1151
+ const [current, setCurrent] = useState(ctx.state.pathname);
1152
+ const href = useMemo(() => router.createHref(path, layer), [path, layer]);
1153
+ const [isPending, setPending] = useState(false);
1154
+ const isActive = current === href;
1155
+ useRouterEvents({ onEnd: ({ state }) => setCurrent(state.pathname) });
1156
+ return {
1157
+ name,
1158
+ isPending,
1159
+ isActive,
1160
+ anchorProps: {
1161
+ href,
1162
+ onClick: (ev) => {
1163
+ ev.stopPropagation();
1164
+ ev.preventDefault();
1165
+ if (isActive) return;
1166
+ if (isPending) return;
1167
+ setPending(true);
1168
+ router.go(href).then(() => {
1169
+ setPending(false);
1170
+ });
1171
+ }
1172
+ }
1173
+ };
1174
+ };
1175
+
1176
+ //#endregion
1177
+ //#region src/hooks/useInject.ts
1178
+ const useInject = (clazz) => {
1179
+ const ctx = useContext(RouterContext);
1180
+ if (!ctx) throw new Error("useRouter must be used within a <RouterProvider>");
1181
+ return useMemo(() => ctx.alepha.get(clazz), []);
1182
+ };
1183
+
1184
+ //#endregion
1185
+ //#region src/hooks/useClient.ts
1186
+ const useClient = (_scope) => {
1187
+ return useInject(LinkProvider).client();
1188
+ };
1189
+
1190
+ //#endregion
1191
+ //#region src/hooks/useQueryParams.ts
1192
+ const useQueryParams = (schema, options = {}) => {
1193
+ const ctx = useContext(RouterContext);
1194
+ if (!ctx) throw new Error("useQueryParams must be used within a RouterProvider");
1195
+ const key = options.key ?? "q";
1196
+ const router = useRouter();
1197
+ const querystring = router.query[key];
1198
+ const [queryParams, setQueryParams] = useState(decode(ctx.alepha, schema, router.query[key]));
1199
+ useEffect(() => {
1200
+ setQueryParams(decode(ctx.alepha, schema, querystring));
1201
+ }, [querystring]);
1202
+ return [queryParams, (queryParams$1) => {
1203
+ setQueryParams(queryParams$1);
1204
+ router.setQueryParams((data) => {
1205
+ return {
1206
+ ...data,
1207
+ [key]: encode(ctx.alepha, schema, queryParams$1)
1208
+ };
1209
+ });
1210
+ }];
1211
+ };
1212
+ const encode = (alepha, schema, data) => {
1213
+ return btoa(JSON.stringify(alepha.parse(schema, data)));
1214
+ };
1215
+ const decode = (alepha, schema, data) => {
1216
+ try {
1217
+ return alepha.parse(schema, JSON.parse(atob(decodeURIComponent(data))));
1218
+ } catch (_error) {
1219
+ return {};
1220
+ }
1221
+ };
1222
+
1223
+ //#endregion
1224
+ //#region src/hooks/useRouterState.ts
1225
+ const useRouterState = () => {
1226
+ const ctx = useContext(RouterContext);
1227
+ const layer = useContext(RouterLayerContext);
1228
+ if (!ctx || !layer) throw new Error("useRouter must be used within a RouterProvider");
1229
+ const [state, setState] = useState(ctx.state);
1230
+ useRouterEvents({ onEnd: ({ state: state$1 }) => setState({ ...state$1 }) });
1231
+ return state;
1232
+ };
1233
+
1234
+ //#endregion
1235
+ //#region src/index.ts
1236
+ /**
1237
+ * Alepha React Module
1238
+ *
1239
+ * Alepha React Module contains a router for client-side navigation and server-side rendering.
1240
+ * Routes can be defined using the `$page` descriptor.
1241
+ *
1242
+ * @see {@link $page}
1243
+ * @module alepha.react
1244
+ */
1245
+ var AlephaReact = class {
1246
+ name = "alepha.react";
1247
+ $services = (alepha) => alepha.with(AlephaServer).with(AlephaServerCache).with(ReactServerProvider).with(PageDescriptorProvider);
1248
+ };
360
1249
  __bind($page, AlephaReact);
361
1250
 
362
- export { $page, AlephaReact, PageDescriptorProvider, ReactServerProvider };
1251
+ //#endregion
1252
+ export { $page, AlephaReact, ClientOnly_default as ClientOnly, ErrorBoundary_default as ErrorBoundary, Link_default as Link, NestedView_default as NestedView, PageDescriptorProvider, ReactBrowserProvider, ReactServerProvider, RedirectionError, RouterContext, RouterHookApi, RouterLayerContext, isPageRoute, useActive, useAlepha, useClient, useInject, useQueryParams, useRouter, useRouterEvents, useRouterState };
1253
+ //# sourceMappingURL=index.js.map