@alepha/react 0.10.6 → 0.10.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.browser.js +27 -42
- package/dist/index.browser.js.map +1 -1
- package/dist/index.d.ts +48 -48
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -57
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/components/ClientOnly.tsx +12 -12
- package/src/components/ErrorBoundary.tsx +43 -43
- package/src/components/ErrorViewer.tsx +140 -140
- package/src/components/Link.tsx +7 -7
- package/src/components/NestedView.tsx +177 -177
- package/src/components/NotFound.tsx +19 -19
- package/src/contexts/RouterLayerContext.ts +3 -3
- package/src/descriptors/$page.ts +292 -290
- package/src/errors/Redirection.ts +5 -5
- package/src/hooks/useActive.ts +41 -41
- package/src/hooks/useAlepha.ts +7 -7
- package/src/hooks/useClient.ts +5 -5
- package/src/hooks/useInject.ts +2 -2
- package/src/hooks/useQueryParams.ts +37 -37
- package/src/hooks/useRouter.ts +1 -1
- package/src/hooks/useRouterEvents.ts +46 -46
- package/src/hooks/useRouterState.ts +5 -5
- package/src/hooks/useSchema.ts +55 -55
- package/src/hooks/useStore.ts +25 -25
- package/src/index.browser.ts +18 -18
- package/src/index.ts +49 -49
- package/src/providers/ReactBrowserProvider.ts +268 -268
- package/src/providers/ReactBrowserRendererProvider.ts +15 -15
- package/src/providers/ReactBrowserRouterProvider.ts +124 -124
- package/src/providers/ReactPageProvider.ts +616 -615
- package/src/providers/ReactServerProvider.ts +505 -505
- package/src/services/ReactRouter.ts +191 -191
|
@@ -1,535 +1,535 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
$env,
|
|
5
|
+
$hook,
|
|
6
|
+
$inject,
|
|
7
|
+
Alepha,
|
|
8
|
+
AlephaError,
|
|
9
|
+
type Static,
|
|
10
|
+
t,
|
|
11
11
|
} from "@alepha/core";
|
|
12
12
|
import { $logger } from "@alepha/logger";
|
|
13
13
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
type ServerHandler,
|
|
15
|
+
ServerProvider,
|
|
16
|
+
ServerRouterProvider,
|
|
17
|
+
ServerTimingProvider,
|
|
18
18
|
} from "@alepha/server";
|
|
19
19
|
import { ServerLinksProvider } from "@alepha/server-links";
|
|
20
20
|
import { ServerStaticProvider } from "@alepha/server-static";
|
|
21
21
|
import { renderToString } from "react-dom/server";
|
|
22
22
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
$page,
|
|
24
|
+
type PageDescriptorRenderOptions,
|
|
25
|
+
type PageDescriptorRenderResult,
|
|
26
26
|
} from "../descriptors/$page.ts";
|
|
27
27
|
import { Redirection } from "../errors/Redirection.ts";
|
|
28
28
|
import type { ReactHydrationState } from "./ReactBrowserProvider.ts";
|
|
29
29
|
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
type PageRoute,
|
|
31
|
+
ReactPageProvider,
|
|
32
|
+
type ReactRouterState,
|
|
33
33
|
} from "./ReactPageProvider.ts";
|
|
34
34
|
|
|
35
35
|
const envSchema = t.object({
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
REACT_SERVER_DIST: t.text({ default: "public" }),
|
|
37
|
+
REACT_SERVER_PREFIX: t.text({ default: "" }),
|
|
38
|
+
REACT_SSR_ENABLED: t.optional(t.boolean()),
|
|
39
|
+
REACT_ROOT_ID: t.text({ default: "root" }),
|
|
40
|
+
REACT_SERVER_TEMPLATE: t.optional(
|
|
41
|
+
t.text({
|
|
42
|
+
size: "rich",
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
declare module "@alepha/core" {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
interface Env extends Partial<Static<typeof envSchema>> {}
|
|
49
|
+
interface State {
|
|
50
|
+
"react.server.ssr"?: boolean;
|
|
51
|
+
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export class ReactServerProvider {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
55
|
+
protected readonly log = $logger();
|
|
56
|
+
protected readonly alepha = $inject(Alepha);
|
|
57
|
+
protected readonly pageApi = $inject(ReactPageProvider);
|
|
58
|
+
protected readonly serverProvider = $inject(ServerProvider);
|
|
59
|
+
protected readonly serverStaticProvider = $inject(ServerStaticProvider);
|
|
60
|
+
protected readonly serverRouterProvider = $inject(ServerRouterProvider);
|
|
61
|
+
protected readonly serverTimingProvider = $inject(ServerTimingProvider);
|
|
62
|
+
protected readonly env = $env(envSchema);
|
|
63
|
+
protected readonly ROOT_DIV_REGEX = new RegExp(
|
|
64
|
+
`<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
|
|
65
|
+
"is",
|
|
66
|
+
);
|
|
67
|
+
protected preprocessedTemplate: PreprocessedTemplate | null = null;
|
|
68
|
+
|
|
69
|
+
public readonly onConfigure = $hook({
|
|
70
|
+
on: "configure",
|
|
71
|
+
handler: async () => {
|
|
72
|
+
const pages = this.alepha.descriptors($page);
|
|
73
|
+
|
|
74
|
+
const ssrEnabled =
|
|
75
|
+
pages.length > 0 && this.env.REACT_SSR_ENABLED !== false;
|
|
76
|
+
|
|
77
|
+
this.alepha.state.set("react.server.ssr", ssrEnabled);
|
|
78
|
+
|
|
79
|
+
for (const page of pages) {
|
|
80
|
+
page.render = this.createRenderFunction(page.name);
|
|
81
|
+
page.fetch = async (options) => {
|
|
82
|
+
const response = await fetch(
|
|
83
|
+
`${this.serverProvider.hostname}/${page.pathname(options)}`,
|
|
84
|
+
);
|
|
85
|
+
const html = await response.text();
|
|
86
|
+
if (options?.html) return { html, response };
|
|
87
|
+
// take only text inside the root div
|
|
88
|
+
const match = html.match(this.ROOT_DIV_REGEX);
|
|
89
|
+
if (match) {
|
|
90
|
+
return { html: match[3], response };
|
|
91
|
+
}
|
|
92
|
+
throw new AlephaError("Invalid HTML response");
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// development mode
|
|
97
|
+
if (this.alepha.isViteDev()) {
|
|
98
|
+
await this.configureVite(ssrEnabled);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// production mode
|
|
103
|
+
let root = "";
|
|
104
|
+
|
|
105
|
+
// non-serverless mode only -> serve static files
|
|
106
|
+
if (!this.alepha.isServerless()) {
|
|
107
|
+
root = this.getPublicDirectory();
|
|
108
|
+
if (!root) {
|
|
109
|
+
this.log.warn(
|
|
110
|
+
"Missing static files, static file server will be disabled",
|
|
111
|
+
);
|
|
112
|
+
} else {
|
|
113
|
+
this.log.debug(`Using static files from: ${root}`);
|
|
114
|
+
await this.configureStaticServer(root);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (ssrEnabled) {
|
|
119
|
+
await this.registerPages(async () => this.template);
|
|
120
|
+
this.log.info("SSR OK");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// no SSR enabled, serve index.html for all unmatched routes
|
|
125
|
+
this.log.info("SSR is disabled, use History API fallback");
|
|
126
|
+
this.serverRouterProvider.createRoute({
|
|
127
|
+
path: "*",
|
|
128
|
+
handler: async ({ url, reply }) => {
|
|
129
|
+
if (url.pathname.includes(".")) {
|
|
130
|
+
// If the request is for a file (e.g., /style.css), do not fallback
|
|
131
|
+
reply.headers["content-type"] = "text/plain";
|
|
132
|
+
reply.body = "Not Found";
|
|
133
|
+
reply.status = 404;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
reply.headers["content-type"] = "text/html";
|
|
138
|
+
|
|
139
|
+
// serve index.html for all unmatched routes
|
|
140
|
+
return this.template;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
public get template() {
|
|
147
|
+
return (
|
|
148
|
+
this.alepha.env.REACT_SERVER_TEMPLATE ??
|
|
149
|
+
"<!DOCTYPE html><html lang='en'><head></head><body></body></html>"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
protected async registerPages(templateLoader: TemplateLoader) {
|
|
154
|
+
// Preprocess template once
|
|
155
|
+
const template = await templateLoader();
|
|
156
|
+
if (template) {
|
|
157
|
+
this.preprocessedTemplate = this.preprocessTemplate(template);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const page of this.pageApi.getPages()) {
|
|
161
|
+
if (page.children?.length) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this.log.debug(`+ ${page.match} -> ${page.name}`);
|
|
166
|
+
|
|
167
|
+
this.serverRouterProvider.createRoute({
|
|
168
|
+
...page,
|
|
169
|
+
schema: undefined, // schema is handled by the page descriptor provider for now (shared by browser and server)
|
|
170
|
+
method: "GET",
|
|
171
|
+
path: page.match,
|
|
172
|
+
handler: this.createHandler(page, templateLoader),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
protected getPublicDirectory(): string {
|
|
178
|
+
const maybe = [
|
|
179
|
+
join(process.cwd(), `dist/${this.env.REACT_SERVER_DIST}`),
|
|
180
|
+
join(process.cwd(), this.env.REACT_SERVER_DIST),
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
for (const it of maybe) {
|
|
184
|
+
if (existsSync(it)) {
|
|
185
|
+
return it;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
protected async configureStaticServer(root: string) {
|
|
193
|
+
await this.serverStaticProvider.createStaticServer({
|
|
194
|
+
root,
|
|
195
|
+
path: this.env.REACT_SERVER_PREFIX,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
protected async configureVite(ssrEnabled: boolean) {
|
|
200
|
+
if (!ssrEnabled) {
|
|
201
|
+
// do nothing, vite will handle everything for us
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this.log.info("SSR (vite) OK");
|
|
206
|
+
|
|
207
|
+
const url = `http://${process.env.SERVER_HOST}:${process.env.SERVER_PORT}`;
|
|
208
|
+
|
|
209
|
+
await this.registerPages(() =>
|
|
210
|
+
fetch(`${url}/index.html`)
|
|
211
|
+
.then((it) => it.text())
|
|
212
|
+
.catch(() => undefined),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* For testing purposes, creates a render function that can be used.
|
|
218
|
+
*/
|
|
219
|
+
protected createRenderFunction(name: string, withIndex = false) {
|
|
220
|
+
return async (
|
|
221
|
+
options: PageDescriptorRenderOptions = {},
|
|
222
|
+
): Promise<PageDescriptorRenderResult> => {
|
|
223
|
+
const page = this.pageApi.page(name);
|
|
224
|
+
const url = new URL(this.pageApi.url(name, options));
|
|
225
|
+
|
|
226
|
+
const entry: Partial<ReactRouterState> = {
|
|
227
|
+
url,
|
|
228
|
+
params: options.params ?? {},
|
|
229
|
+
query: options.query ?? {},
|
|
230
|
+
onError: () => null,
|
|
231
|
+
layers: [],
|
|
232
|
+
meta: {},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const state = entry as ReactRouterState;
|
|
236
|
+
|
|
237
|
+
this.log.trace("Rendering", {
|
|
238
|
+
url,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await this.alepha.events.emit("react:server:render:begin", {
|
|
242
|
+
state,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const { redirect } = await this.pageApi.createLayers(
|
|
246
|
+
page,
|
|
247
|
+
state as ReactRouterState,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
if (redirect) {
|
|
251
|
+
return { state, html: "", redirect };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!withIndex && !options.html) {
|
|
255
|
+
this.alepha.state.set("react.router.state", state);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
state,
|
|
259
|
+
html: renderToString(this.pageApi.root(state)),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const template = this.template ?? "";
|
|
264
|
+
const html = this.renderToHtml(template, state, options.hydration);
|
|
265
|
+
|
|
266
|
+
if (html instanceof Redirection) {
|
|
267
|
+
return { state, html: "", redirect };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const result = {
|
|
271
|
+
state,
|
|
272
|
+
html,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
await this.alepha.events.emit("react:server:render:end", result);
|
|
276
|
+
|
|
277
|
+
return result;
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
protected createHandler(
|
|
282
|
+
route: PageRoute,
|
|
283
|
+
templateLoader: TemplateLoader,
|
|
284
|
+
): ServerHandler {
|
|
285
|
+
return async (serverRequest) => {
|
|
286
|
+
const { url, reply, query, params } = serverRequest;
|
|
287
|
+
const template = await templateLoader();
|
|
288
|
+
if (!template) {
|
|
289
|
+
throw new Error("Template not found");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
this.log.trace("Rendering page", {
|
|
293
|
+
name: route.name,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const entry: Partial<ReactRouterState> = {
|
|
297
|
+
url,
|
|
298
|
+
params,
|
|
299
|
+
query,
|
|
300
|
+
onError: () => null,
|
|
301
|
+
layers: [],
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const state = entry as ReactRouterState;
|
|
305
|
+
|
|
306
|
+
if (this.alepha.has(ServerLinksProvider)) {
|
|
307
|
+
this.alepha.state.set(
|
|
308
|
+
"api",
|
|
309
|
+
await this.alepha.inject(ServerLinksProvider).getUserApiLinks({
|
|
310
|
+
user: (serverRequest as any).user, // TODO: fix type
|
|
311
|
+
authorization: serverRequest.headers.authorization,
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let target: PageRoute | undefined = route; // TODO: move to PageDescriptorProvider
|
|
317
|
+
while (target) {
|
|
318
|
+
if (route.can && !route.can()) {
|
|
319
|
+
// if the page is not accessible, return 403
|
|
320
|
+
reply.status = 403;
|
|
321
|
+
reply.headers["content-type"] = "text/plain";
|
|
322
|
+
return "Forbidden";
|
|
323
|
+
}
|
|
324
|
+
target = target.parent;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// TODO: SSR strategies
|
|
328
|
+
// - only when googlebot
|
|
329
|
+
// - only child pages
|
|
330
|
+
// if (page.client) {
|
|
331
|
+
// // if the page is a client-only page, return 404
|
|
332
|
+
// reply.status = 200;
|
|
333
|
+
// reply.headers["content-type"] = "text/html";
|
|
334
|
+
// reply.body = template;
|
|
335
|
+
// return;
|
|
336
|
+
// }
|
|
337
|
+
|
|
338
|
+
await this.alepha.events.emit("react:server:render:begin", {
|
|
339
|
+
request: serverRequest,
|
|
340
|
+
state,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
this.serverTimingProvider.beginTiming("createLayers");
|
|
344
|
+
|
|
345
|
+
const { redirect } = await this.pageApi.createLayers(route, state);
|
|
346
|
+
|
|
347
|
+
this.serverTimingProvider.endTiming("createLayers");
|
|
348
|
+
|
|
349
|
+
if (redirect) {
|
|
350
|
+
return reply.redirect(redirect);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
reply.headers["content-type"] = "text/html";
|
|
354
|
+
|
|
355
|
+
// by default, disable caching for SSR responses
|
|
356
|
+
// some plugins may override this
|
|
357
|
+
reply.headers["cache-control"] =
|
|
358
|
+
"no-store, no-cache, must-revalidate, proxy-revalidate";
|
|
359
|
+
reply.headers.pragma = "no-cache";
|
|
360
|
+
reply.headers.expires = "0";
|
|
361
|
+
|
|
362
|
+
const html = this.renderToHtml(template, state);
|
|
363
|
+
if (html instanceof Redirection) {
|
|
364
|
+
reply.redirect(
|
|
365
|
+
typeof html.redirect === "string"
|
|
366
|
+
? html.redirect
|
|
367
|
+
: this.pageApi.href(html.redirect),
|
|
368
|
+
);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const event = {
|
|
373
|
+
request: serverRequest,
|
|
374
|
+
state,
|
|
375
|
+
html,
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
await this.alepha.events.emit("react:server:render:end", event);
|
|
379
|
+
|
|
380
|
+
route.onServerResponse?.(serverRequest);
|
|
381
|
+
|
|
382
|
+
this.log.trace("Page rendered", {
|
|
383
|
+
name: route.name,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
return event.html;
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
public renderToHtml(
|
|
391
|
+
template: string,
|
|
392
|
+
state: ReactRouterState,
|
|
393
|
+
hydration = true,
|
|
394
|
+
): string | Redirection {
|
|
395
|
+
const element = this.pageApi.root(state);
|
|
396
|
+
|
|
397
|
+
// attach react router state to the http request context
|
|
398
|
+
this.alepha.state.set("react.router.state", state);
|
|
399
|
+
|
|
400
|
+
this.serverTimingProvider.beginTiming("renderToString");
|
|
401
|
+
let app = "";
|
|
402
|
+
try {
|
|
403
|
+
app = renderToString(element);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
this.log.error(
|
|
406
|
+
"renderToString has failed, fallback to error handler",
|
|
407
|
+
error,
|
|
408
|
+
);
|
|
409
|
+
const element = state.onError(error as Error, state);
|
|
410
|
+
if (element instanceof Redirection) {
|
|
411
|
+
// if the error is a redirection, return the redirection URL
|
|
412
|
+
return element;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
app = renderToString(element);
|
|
416
|
+
this.log.debug("Error handled successfully with fallback");
|
|
417
|
+
}
|
|
418
|
+
this.serverTimingProvider.endTiming("renderToString");
|
|
419
|
+
|
|
420
|
+
const response = {
|
|
421
|
+
html: template,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
if (hydration) {
|
|
425
|
+
const { request, context, ...store } =
|
|
426
|
+
this.alepha.context.als?.getStore() ?? {}; /// TODO: als must be protected, find a way to iterate on alepha.state
|
|
427
|
+
|
|
428
|
+
const hydrationData: ReactHydrationState = {
|
|
429
|
+
...store,
|
|
430
|
+
// map react.router.state to the hydration state
|
|
431
|
+
"react.router.state": undefined,
|
|
432
|
+
layers: state.layers.map((it) => ({
|
|
433
|
+
...it,
|
|
434
|
+
error: it.error
|
|
435
|
+
? {
|
|
436
|
+
...it.error,
|
|
437
|
+
name: it.error.name,
|
|
438
|
+
message: it.error.message,
|
|
439
|
+
stack: !this.alepha.isProduction() ? it.error.stack : undefined,
|
|
440
|
+
}
|
|
441
|
+
: undefined,
|
|
442
|
+
index: undefined,
|
|
443
|
+
path: undefined,
|
|
444
|
+
element: undefined,
|
|
445
|
+
route: undefined,
|
|
446
|
+
})),
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// create hydration data
|
|
450
|
+
const script = `<script>window.__ssr=${JSON.stringify(hydrationData)}</script>`;
|
|
451
|
+
|
|
452
|
+
// inject app into template
|
|
453
|
+
this.fillTemplate(response, app, script);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return response.html;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
protected preprocessTemplate(template: string): PreprocessedTemplate {
|
|
460
|
+
// Find the body close tag for script injection
|
|
461
|
+
const bodyCloseMatch = template.match(/<\/body>/i);
|
|
462
|
+
const bodyCloseIndex = bodyCloseMatch?.index ?? template.length;
|
|
463
|
+
|
|
464
|
+
const beforeScript = template.substring(0, bodyCloseIndex);
|
|
465
|
+
const afterScript = template.substring(bodyCloseIndex);
|
|
466
|
+
|
|
467
|
+
// Check if there's an existing root div
|
|
468
|
+
const rootDivMatch = beforeScript.match(this.ROOT_DIV_REGEX);
|
|
469
|
+
|
|
470
|
+
if (rootDivMatch) {
|
|
471
|
+
// Split around the existing root div content
|
|
472
|
+
const beforeDiv = beforeScript.substring(0, rootDivMatch.index!);
|
|
473
|
+
const afterDivStart = rootDivMatch.index! + rootDivMatch[0].length;
|
|
474
|
+
const afterDiv = beforeScript.substring(afterDivStart);
|
|
475
|
+
|
|
476
|
+
const beforeApp = `${beforeDiv}<div${rootDivMatch[1]} id="${this.env.REACT_ROOT_ID}"${rootDivMatch[2]}>`;
|
|
477
|
+
const afterApp = `</div>${afterDiv}`;
|
|
478
|
+
|
|
479
|
+
return { beforeApp, afterApp, beforeScript: "", afterScript };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// No existing root div, find body tag to inject new div
|
|
483
|
+
const bodyMatch = beforeScript.match(/<body([^>]*)>/i);
|
|
484
|
+
if (bodyMatch) {
|
|
485
|
+
const beforeBody = beforeScript.substring(
|
|
486
|
+
0,
|
|
487
|
+
bodyMatch.index! + bodyMatch[0].length,
|
|
488
|
+
);
|
|
489
|
+
const afterBody = beforeScript.substring(
|
|
490
|
+
bodyMatch.index! + bodyMatch[0].length,
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
const beforeApp = `${beforeBody}<div id="${this.env.REACT_ROOT_ID}">`;
|
|
494
|
+
const afterApp = `</div>${afterBody}`;
|
|
495
|
+
|
|
496
|
+
return { beforeApp, afterApp, beforeScript: "", afterScript };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Fallback: no body tag found, just wrap everything
|
|
500
|
+
return {
|
|
501
|
+
beforeApp: `<div id="${this.env.REACT_ROOT_ID}">`,
|
|
502
|
+
afterApp: `</div>`,
|
|
503
|
+
beforeScript,
|
|
504
|
+
afterScript,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
protected fillTemplate(
|
|
509
|
+
response: { html: string },
|
|
510
|
+
app: string,
|
|
511
|
+
script: string,
|
|
512
|
+
) {
|
|
513
|
+
if (!this.preprocessedTemplate) {
|
|
514
|
+
// Fallback to old logic if preprocessing failed
|
|
515
|
+
this.preprocessedTemplate = this.preprocessTemplate(response.html);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Pure concatenation - no regex replacements needed
|
|
519
|
+
response.html =
|
|
520
|
+
this.preprocessedTemplate.beforeApp +
|
|
521
|
+
app +
|
|
522
|
+
this.preprocessedTemplate.afterApp +
|
|
523
|
+
script +
|
|
524
|
+
this.preprocessedTemplate.afterScript;
|
|
525
|
+
}
|
|
526
526
|
}
|
|
527
527
|
|
|
528
528
|
type TemplateLoader = () => Promise<string | undefined>;
|
|
529
529
|
|
|
530
530
|
interface PreprocessedTemplate {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
531
|
+
beforeApp: string;
|
|
532
|
+
afterApp: string;
|
|
533
|
+
beforeScript: string;
|
|
534
|
+
afterScript: string;
|
|
535
535
|
}
|