arcway 0.4.14 → 0.4.16
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/client/router.js +23 -9
- package/package.json +1 -1
- package/server/bin/commands/start.js +5 -1
- package/server/config/modules/pages.js +16 -0
- package/server/mail/index.js +1 -5
- package/server/pages/build-client.js +22 -14
- package/server/pages/build-manifest.js +1 -0
- package/server/pages/discovery.js +6 -6
- package/server/pages/handler.js +276 -47
- package/server/pages/pages-router.js +1 -0
- package/server/pages/server-loader-transform.js +191 -0
- package/server/pages/ssr.js +33 -10
- package/server/pages/vite-dev.js +26 -6
package/client/router.js
CHANGED
|
@@ -23,9 +23,9 @@ import { parseQuery, stringifyQuery } from './query.js';
|
|
|
23
23
|
const ROUTER_CTX_KEY = '__router_context__';
|
|
24
24
|
const RouterContext = (globalThis[ROUTER_CTX_KEY] ??= createContext(null));
|
|
25
25
|
|
|
26
|
-
function wrapInLayouts(element, layouts) {
|
|
26
|
+
function wrapInLayouts(element, layouts, props = {}) {
|
|
27
27
|
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
28
|
-
element = createElement(layouts[i],
|
|
28
|
+
element = createElement(layouts[i], props, element);
|
|
29
29
|
}
|
|
30
30
|
return element;
|
|
31
31
|
}
|
|
@@ -56,8 +56,10 @@ function useRouter() {
|
|
|
56
56
|
pathname: ctx.pathname,
|
|
57
57
|
params: ctx.params,
|
|
58
58
|
query: typeof window !== 'undefined' ? parseQuery(window.location.search) : {},
|
|
59
|
-
push: (to, options) =>
|
|
60
|
-
|
|
59
|
+
push: (to, options) =>
|
|
60
|
+
ctx.navigate(normalizeNavigateTarget(to), { ...options, replace: false }),
|
|
61
|
+
replace: (to, options) =>
|
|
62
|
+
ctx.navigate(normalizeNavigateTarget(to), { ...options, replace: true }),
|
|
61
63
|
back: () => {
|
|
62
64
|
if (typeof window !== 'undefined') window.history.back();
|
|
63
65
|
},
|
|
@@ -77,6 +79,7 @@ function useRouter() {
|
|
|
77
79
|
function Router({
|
|
78
80
|
initialPath,
|
|
79
81
|
initialParams,
|
|
82
|
+
initialPageProps,
|
|
80
83
|
initialPattern,
|
|
81
84
|
initialComponent,
|
|
82
85
|
initialLayouts,
|
|
@@ -92,6 +95,7 @@ function Router({
|
|
|
92
95
|
layouts: initialLayouts ?? [],
|
|
93
96
|
loadings: initialLoadings ?? [],
|
|
94
97
|
params: initialParams ?? {},
|
|
98
|
+
props: initialPageProps ?? initialParams ?? {},
|
|
95
99
|
});
|
|
96
100
|
const [isNavigating, setIsNavigating] = useState(false);
|
|
97
101
|
const [isPending, startTransition] = useTransition();
|
|
@@ -140,6 +144,7 @@ function Router({
|
|
|
140
144
|
layouts: loaded.layouts,
|
|
141
145
|
loadings: loaded.loadings,
|
|
142
146
|
params: loaded.params,
|
|
147
|
+
props: loaded.params,
|
|
143
148
|
});
|
|
144
149
|
setIsNavigating(false);
|
|
145
150
|
if (bumpQuery) setQueryVersion((v) => v + 1);
|
|
@@ -158,8 +163,7 @@ function Router({
|
|
|
158
163
|
const qIdx = to.indexOf('?');
|
|
159
164
|
const pathOnly = qIdx === -1 ? to : to.slice(0, qIdx);
|
|
160
165
|
const search = qIdx === -1 ? '' : to.slice(qIdx);
|
|
161
|
-
const currentSearch =
|
|
162
|
-
typeof window !== 'undefined' ? window.location.search : '';
|
|
166
|
+
const currentSearch = typeof window !== 'undefined' ? window.location.search : '';
|
|
163
167
|
|
|
164
168
|
if (pathOnly === pathname && search === currentSearch) return;
|
|
165
169
|
|
|
@@ -184,6 +188,10 @@ function Router({
|
|
|
184
188
|
window.location.href = to;
|
|
185
189
|
return;
|
|
186
190
|
}
|
|
191
|
+
if (matched.route.hasLoader) {
|
|
192
|
+
window.location.href = to;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
187
195
|
|
|
188
196
|
setIsNavigating(true);
|
|
189
197
|
if (replace) {
|
|
@@ -200,6 +208,7 @@ function Router({
|
|
|
200
208
|
pattern: matched.route.pattern,
|
|
201
209
|
loadings: targetLoadings,
|
|
202
210
|
params: matched.params,
|
|
211
|
+
props: matched.params,
|
|
203
212
|
}));
|
|
204
213
|
}
|
|
205
214
|
|
|
@@ -244,6 +253,11 @@ function Router({
|
|
|
244
253
|
}
|
|
245
254
|
|
|
246
255
|
try {
|
|
256
|
+
const matched = matchClientRoute(manifest, newPath);
|
|
257
|
+
if (matched?.route.hasLoader) {
|
|
258
|
+
window.location.reload();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
247
261
|
const loaded = await loadPage(manifest, newPath);
|
|
248
262
|
if (loaded) {
|
|
249
263
|
applyLoaded(loaded, newPath, true);
|
|
@@ -265,15 +279,15 @@ function Router({
|
|
|
265
279
|
}
|
|
266
280
|
}, [pageState.pattern]);
|
|
267
281
|
|
|
268
|
-
const { component: PageComponent, layouts, loadings, params } = pageState;
|
|
282
|
+
const { component: PageComponent, layouts, loadings, params, props } = pageState;
|
|
269
283
|
|
|
270
284
|
let content;
|
|
271
285
|
if (PageComponent) {
|
|
272
286
|
const inner =
|
|
273
287
|
isNavigating && loadings.length > 0
|
|
274
288
|
? createElement(loadings.at(-1))
|
|
275
|
-
: createElement(PageComponent,
|
|
276
|
-
content = wrapInLayouts(inner, layouts);
|
|
289
|
+
: createElement(PageComponent, props);
|
|
290
|
+
content = wrapInLayouts(inner, layouts, props);
|
|
277
291
|
} else {
|
|
278
292
|
content = children;
|
|
279
293
|
}
|
package/package.json
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import boot from '#server/boot.js';
|
|
2
1
|
import { loadEnvFiles } from '#server/env.js';
|
|
3
2
|
|
|
4
3
|
async function startServer(mode) {
|
|
@@ -11,6 +10,11 @@ async function startServer(mode) {
|
|
|
11
10
|
// dotenv never overrides already-set variables.
|
|
12
11
|
loadEnvFiles(process.cwd(), mode);
|
|
13
12
|
|
|
13
|
+
// Importing the application boot graph loads React-backed page modules.
|
|
14
|
+
// Production mode must be established first so React and react-dom/server
|
|
15
|
+
// select matching builds. A static import here can cache development React
|
|
16
|
+
// before `arcway start` gets a chance to set NODE_ENV.
|
|
17
|
+
const { default: boot } = await import('#server/boot.js');
|
|
14
18
|
const app = await boot({ mode, rootDir: process.cwd() });
|
|
15
19
|
app.logger.info('Arcway framework ready', { mode, port: app.port });
|
|
16
20
|
|
|
@@ -72,6 +72,22 @@ function resolve(config, { rootDir } = {}) {
|
|
|
72
72
|
...(hmr ? { hmr } : {}),
|
|
73
73
|
},
|
|
74
74
|
};
|
|
75
|
+
if (pages.rewrite != null) {
|
|
76
|
+
if (
|
|
77
|
+
typeof pages.rewrite !== 'object' ||
|
|
78
|
+
Array.isArray(pages.rewrite) ||
|
|
79
|
+
typeof pages.rewrite.handler !== 'function' ||
|
|
80
|
+
typeof pages.rewrite.reservedPrefix !== 'string' ||
|
|
81
|
+
pages.rewrite.reservedPrefix === '/' ||
|
|
82
|
+
!pages.rewrite.reservedPrefix.startsWith('/') ||
|
|
83
|
+
pages.rewrite.reservedPrefix.endsWith('/') ||
|
|
84
|
+
/[?#\s]/.test(pages.rewrite.reservedPrefix)
|
|
85
|
+
) {
|
|
86
|
+
throw new TypeError(
|
|
87
|
+
'Invalid config: pages.rewrite requires a handler function and a reservedPrefix such as "/_sites"',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
75
91
|
if (pages.dir && !path.isAbsolute(pages.dir)) {
|
|
76
92
|
pages.dir = path.resolve(rootDir, pages.dir);
|
|
77
93
|
}
|
package/server/mail/index.js
CHANGED
|
@@ -95,13 +95,9 @@ function registerSendJob(driver, queue, throughput) {
|
|
|
95
95
|
handler: async () => {
|
|
96
96
|
if (!queue) return;
|
|
97
97
|
const items = await queue.pop(MAIL_TOPIC, 10);
|
|
98
|
-
const ids = [];
|
|
99
98
|
for (const item of items) {
|
|
100
99
|
await driver.send(item.data);
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
if (ids.length > 0) {
|
|
104
|
-
await queue.remove(ids);
|
|
100
|
+
await queue.remove([item.id]);
|
|
105
101
|
}
|
|
106
102
|
},
|
|
107
103
|
throughput,
|
|
@@ -6,6 +6,7 @@ import { resolveLayoutChain, resolveLoadingChain } from './discovery.js';
|
|
|
6
6
|
import { clientIsolationPlugin } from './build-plugins.js';
|
|
7
7
|
import { patternToFileName, layoutDirToFileName } from './build-server.js';
|
|
8
8
|
import { reactRefreshPlugin } from './hmr.js';
|
|
9
|
+
import { serverLoaderClientPlugin } from './server-loader-transform.js';
|
|
9
10
|
import {
|
|
10
11
|
sha256,
|
|
11
12
|
ESBUILD_VERSION,
|
|
@@ -68,7 +69,14 @@ function planHydrationEntries(pages, layouts, loadings, tempDir) {
|
|
|
68
69
|
entryPoints[loadingName] = navPath;
|
|
69
70
|
entryToLoadingDir.set(navPath, loading.dirPath);
|
|
70
71
|
}
|
|
71
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
files,
|
|
74
|
+
entryPoints,
|
|
75
|
+
entryToPattern,
|
|
76
|
+
entryToNavPattern,
|
|
77
|
+
entryToLayoutDir,
|
|
78
|
+
entryToLoadingDir,
|
|
79
|
+
};
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
function resolveReactAlias(rootDir) {
|
|
@@ -144,6 +152,7 @@ function buildClientEsbuildOptions({
|
|
|
144
152
|
nodeEnv,
|
|
145
153
|
reactAlias,
|
|
146
154
|
rootDir,
|
|
155
|
+
pagePaths,
|
|
147
156
|
}) {
|
|
148
157
|
return {
|
|
149
158
|
entryPoints,
|
|
@@ -162,7 +171,11 @@ function buildClientEsbuildOptions({
|
|
|
162
171
|
metafile: true,
|
|
163
172
|
alias: reactAlias,
|
|
164
173
|
define: { 'process.env.NODE_ENV': JSON.stringify(nodeEnv) },
|
|
165
|
-
plugins: [
|
|
174
|
+
plugins: [
|
|
175
|
+
...(devMode ? [reactRefreshPlugin(rootDir)] : []),
|
|
176
|
+
serverLoaderClientPlugin(pagePaths),
|
|
177
|
+
clientIsolationPlugin(),
|
|
178
|
+
],
|
|
166
179
|
};
|
|
167
180
|
}
|
|
168
181
|
|
|
@@ -210,6 +223,7 @@ async function createClientBuildContext(
|
|
|
210
223
|
nodeEnv,
|
|
211
224
|
reactAlias,
|
|
212
225
|
rootDir,
|
|
226
|
+
pagePaths: pages.map((page) => page.filePath),
|
|
213
227
|
}),
|
|
214
228
|
);
|
|
215
229
|
|
|
@@ -262,9 +276,7 @@ async function unlinkAll(absPaths) {
|
|
|
262
276
|
// an atomic rename, so the served clientDir always contains exactly the
|
|
263
277
|
// current build's outputs.
|
|
264
278
|
async function collectStaleOutputs(metafile, clientDir) {
|
|
265
|
-
const currentOutputs = new Set(
|
|
266
|
-
Object.keys(metafile.outputs ?? {}).map((p) => path.resolve(p)),
|
|
267
|
-
);
|
|
279
|
+
const currentOutputs = new Set(Object.keys(metafile.outputs ?? {}).map((p) => path.resolve(p)));
|
|
268
280
|
// Scope the scan to directories esbuild actually writes to. This keeps
|
|
269
281
|
// sibling artifacts owned by other build steps (e.g. the HMR runtime under
|
|
270
282
|
// `client/hmr/`) out of the GC set, since they will never appear in the
|
|
@@ -315,14 +327,7 @@ async function tryRestoreClientFromCache({ rootDir, clientDir, coarseKey }) {
|
|
|
315
327
|
return { hit: true, bucket: lookup.bucket, metadata: deserializeMetadata(lookup.meta.metadata) };
|
|
316
328
|
}
|
|
317
329
|
|
|
318
|
-
async function storeClientInCache({
|
|
319
|
-
bucket,
|
|
320
|
-
coarseKey,
|
|
321
|
-
clientDir,
|
|
322
|
-
tempDir,
|
|
323
|
-
result,
|
|
324
|
-
metadata,
|
|
325
|
-
}) {
|
|
330
|
+
async function storeClientInCache({ bucket, coarseKey, clientDir, tempDir, result, metadata }) {
|
|
326
331
|
const outputs = Object.keys(result.metafile.outputs)
|
|
327
332
|
.map((p) => path.relative(clientDir, path.resolve(p)).replace(/\\/g, '/'))
|
|
328
333
|
.filter((rel) => !rel.startsWith('..'));
|
|
@@ -403,6 +408,7 @@ async function buildClientBundles(
|
|
|
403
408
|
nodeEnv,
|
|
404
409
|
reactAlias,
|
|
405
410
|
rootDir,
|
|
411
|
+
pagePaths: pages.map((page) => page.filePath),
|
|
406
412
|
});
|
|
407
413
|
const result = await limit(() => runClientEsbuild(plan, esbuildOptions, tempDir));
|
|
408
414
|
const metadata = extractMetadata(result, plan, outDir);
|
|
@@ -494,9 +500,11 @@ function generateHydrationEntry(componentPath, layouts = [], loadings = [], patt
|
|
|
494
500
|
const container = document.getElementById('__app');
|
|
495
501
|
const propsEl = document.getElementById('__app_props');
|
|
496
502
|
const props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {};
|
|
503
|
+
const routeParamsEl = document.getElementById('__app_route_params');
|
|
504
|
+
const routeParams = routeParamsEl ? JSON.parse(routeParamsEl.textContent || '{}') : props;
|
|
497
505
|
const ROOT_KEY = '__arcway_root__';
|
|
498
506
|
const rootOwner = window;
|
|
499
|
-
const element = <ApiProvider><Router initialPath={window.location.pathname} initialParams={props} initialPattern={${JSON.stringify(pattern)}} initialComponent={Component} initialLayouts={${layoutsArray}} initialLoadings={${loadingsArray}} /></ApiProvider>;
|
|
507
|
+
const element = <ApiProvider><Router initialPath={window.location.pathname} initialParams={routeParams} initialPageProps={props} initialPattern={${JSON.stringify(pattern)}} initialComponent={Component} initialLayouts={${layoutsArray}} initialLoadings={${loadingsArray}} /></ApiProvider>;
|
|
500
508
|
|
|
501
509
|
if (!container) {
|
|
502
510
|
throw new Error('Arcway hydrate entry could not find #__app');
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
sortBySpecificity,
|
|
8
8
|
matchPattern,
|
|
9
9
|
} from '../router/routes.js';
|
|
10
|
+
import { hasServerLoaderExport } from './server-loader-transform.js';
|
|
10
11
|
function dirDepth(dirPath) {
|
|
11
12
|
return dirPath === '' ? 0 : dirPath.split(path.sep).length;
|
|
12
13
|
}
|
|
@@ -49,6 +50,7 @@ async function discoverPages(pagesDir) {
|
|
|
49
50
|
const fileName = path.basename(filePath);
|
|
50
51
|
if (fileName.startsWith('_')) continue;
|
|
51
52
|
const relPath = path.relative(pagesDir, filePath);
|
|
53
|
+
const source = await fs.readFile(filePath, 'utf8');
|
|
52
54
|
const urlPattern = filePathToPattern(relPath);
|
|
53
55
|
const { regex, paramNames, catchAllParam } = compilePattern(urlPattern);
|
|
54
56
|
pages.push({
|
|
@@ -57,6 +59,7 @@ async function discoverPages(pagesDir) {
|
|
|
57
59
|
paramNames,
|
|
58
60
|
...(catchAllParam ? { catchAllParam } : {}),
|
|
59
61
|
filePath,
|
|
62
|
+
hasLoader: await hasServerLoaderExport(source, filePath),
|
|
60
63
|
});
|
|
61
64
|
}
|
|
62
65
|
sortBySpecificity(pages);
|
|
@@ -125,9 +128,7 @@ function mapFileToAffected(filePath, manifest) {
|
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
const layoutEntries = asDirPathList(manifest.layouts);
|
|
128
|
-
const hitLayout = layoutEntries.find(
|
|
129
|
-
(l) => l.srcPath && path.resolve(l.srcPath) === target,
|
|
130
|
-
);
|
|
131
|
+
const hitLayout = layoutEntries.find((l) => l.srcPath && path.resolve(l.srcPath) === target);
|
|
131
132
|
if (hitLayout) {
|
|
132
133
|
result.layouts.push(hitLayout.dirPath);
|
|
133
134
|
for (const entry of entries) {
|
|
@@ -139,9 +140,7 @@ function mapFileToAffected(filePath, manifest) {
|
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
const middlewareEntries = asDirPathList(manifest.middlewares);
|
|
142
|
-
const hitMw = middlewareEntries.find(
|
|
143
|
-
(m) => m.srcPath && path.resolve(m.srcPath) === target,
|
|
144
|
-
);
|
|
143
|
+
const hitMw = middlewareEntries.find((m) => m.srcPath && path.resolve(m.srcPath) === target);
|
|
145
144
|
if (hitMw) {
|
|
146
145
|
result.middlewares.push(hitMw.dirPath);
|
|
147
146
|
for (const entry of entries) {
|
|
@@ -179,6 +178,7 @@ function buildPageManifestEntry(page, { layouts, middlewares, loadings }) {
|
|
|
179
178
|
paramNames: page.paramNames,
|
|
180
179
|
...(page.catchAllParam ? { catchAllParam: page.catchAllParam } : {}),
|
|
181
180
|
srcPath: path.resolve(page.filePath),
|
|
181
|
+
...(page.hasLoader ? { hasLoader: true } : {}),
|
|
182
182
|
layoutDirs: resolveLayoutChain(page.pattern, layouts).map((l) => l.dirPath),
|
|
183
183
|
middlewareDirs: resolvePageMiddlewareChain(page.pattern, middlewares).map((m) => m.dirPath),
|
|
184
184
|
loadingDirs: resolveLoadingChain(page.pattern, loadings).map((l) => l.dirPath),
|
package/server/pages/handler.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import { domainToASCII } from 'node:url';
|
|
3
4
|
import { createRequire } from 'node:module';
|
|
4
5
|
import { EventEmitter } from 'node:events';
|
|
5
6
|
import { compilePattern, sortBySpecificity, matchPattern } from '../router/routes.js';
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
buildRouteCssLinkTags,
|
|
10
11
|
buildScriptTags,
|
|
11
12
|
loadComponent,
|
|
13
|
+
loadPageModule,
|
|
12
14
|
renderErrorPage,
|
|
13
15
|
renderPage,
|
|
14
16
|
syncReactInternals,
|
|
@@ -33,6 +35,7 @@ function createPagesHandler(options) {
|
|
|
33
35
|
let manifestPath = path.join(outDir, 'pages-manifest.json');
|
|
34
36
|
const sessionConfig = options.session;
|
|
35
37
|
const appContext = options.appContext ?? null;
|
|
38
|
+
const rewrite = options.rewrite ?? null;
|
|
36
39
|
const mode = options.mode ?? 'production';
|
|
37
40
|
const devMode = mode === 'development';
|
|
38
41
|
const viteDev = options.viteDev === true;
|
|
@@ -57,6 +60,7 @@ function createPagesHandler(options) {
|
|
|
57
60
|
rootDir,
|
|
58
61
|
outDir,
|
|
59
62
|
viteDev,
|
|
63
|
+
reservedPrefix: rewrite?.reservedPrefix,
|
|
60
64
|
});
|
|
61
65
|
let lastSeenVersion = lazyContext ? manifest.version : 0;
|
|
62
66
|
const componentCache = new Map();
|
|
@@ -82,6 +86,7 @@ function createPagesHandler(options) {
|
|
|
82
86
|
rootDir,
|
|
83
87
|
outDir,
|
|
84
88
|
viteDev,
|
|
89
|
+
reservedPrefix: rewrite?.reservedPrefix,
|
|
85
90
|
});
|
|
86
91
|
componentCache.clear();
|
|
87
92
|
cacheVersion++;
|
|
@@ -110,6 +115,7 @@ function createPagesHandler(options) {
|
|
|
110
115
|
rootDir,
|
|
111
116
|
outDir,
|
|
112
117
|
viteDev,
|
|
118
|
+
reservedPrefix: rewrite?.reservedPrefix,
|
|
113
119
|
});
|
|
114
120
|
componentCache.clear();
|
|
115
121
|
cacheVersion++;
|
|
@@ -144,7 +150,8 @@ function createPagesHandler(options) {
|
|
|
144
150
|
async function handle(req, res) {
|
|
145
151
|
const method = req.method ?? 'GET';
|
|
146
152
|
const url = req.url ?? '/';
|
|
147
|
-
const
|
|
153
|
+
const requestUrl = new URL(url, 'http://arcway.internal');
|
|
154
|
+
const pathname = requestUrl.pathname;
|
|
148
155
|
if (method !== 'GET') return false;
|
|
149
156
|
// Internal `/_arcway/*` endpoints (dev only) resolve before the route
|
|
150
157
|
// matcher so they can never be shadowed by a user-defined page.
|
|
@@ -173,20 +180,32 @@ function createPagesHandler(options) {
|
|
|
173
180
|
// last request before we resolve the route.
|
|
174
181
|
if (!lazyContext) reload();
|
|
175
182
|
refreshFromLazy();
|
|
176
|
-
|
|
183
|
+
let requestContext;
|
|
184
|
+
let rewritten;
|
|
185
|
+
try {
|
|
186
|
+
requestContext = await createPageRequestContext({
|
|
187
|
+
req,
|
|
188
|
+
pathname,
|
|
189
|
+
internalPathname: pathname,
|
|
190
|
+
params: {},
|
|
191
|
+
requestUrl,
|
|
192
|
+
sessionConfig,
|
|
193
|
+
appContext,
|
|
194
|
+
});
|
|
195
|
+
rewritten = await resolvePageRewrite(rewrite, requestContext);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
await renderRuntimeError(err, res);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
if (rewritten.notFound) {
|
|
201
|
+
await renderNotFound(pathname);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
const internalPathname = rewritten.pathname;
|
|
205
|
+
let matched = matchPageRoute(routes, internalPathname);
|
|
177
206
|
if (!matched) {
|
|
178
|
-
if (manifest.notFoundBundle) {
|
|
179
|
-
await
|
|
180
|
-
manifest.notFoundBundle,
|
|
181
|
-
404,
|
|
182
|
-
{ pathname },
|
|
183
|
-
outDir,
|
|
184
|
-
res,
|
|
185
|
-
componentCache,
|
|
186
|
-
manifest,
|
|
187
|
-
cacheVersion,
|
|
188
|
-
projectReact,
|
|
189
|
-
);
|
|
207
|
+
if (rewrite || manifest.notFoundBundle) {
|
|
208
|
+
await renderNotFound(pathname);
|
|
190
209
|
return true;
|
|
191
210
|
}
|
|
192
211
|
return false;
|
|
@@ -210,20 +229,19 @@ function createPagesHandler(options) {
|
|
|
210
229
|
return true;
|
|
211
230
|
}
|
|
212
231
|
refreshFromLazy();
|
|
213
|
-
const rematched = matchPageRoute(routes,
|
|
232
|
+
const rematched = matchPageRoute(routes, internalPathname);
|
|
214
233
|
if (rematched) matched.route = rematched.route;
|
|
215
234
|
}
|
|
235
|
+
requestContext.page.internalPathname = internalPathname;
|
|
236
|
+
requestContext.page.params = matched.params;
|
|
237
|
+
requestContext.page.query = matched.params;
|
|
216
238
|
if (matched.route.middlewareServerBundles.length > 0) {
|
|
217
239
|
const middlewareResult = await runPageMiddleware(
|
|
218
240
|
matched.route,
|
|
219
|
-
matched.params,
|
|
220
|
-
pathname,
|
|
221
|
-
req,
|
|
222
241
|
outDir,
|
|
223
242
|
componentCache,
|
|
224
243
|
cacheVersion,
|
|
225
|
-
|
|
226
|
-
appContext,
|
|
244
|
+
requestContext,
|
|
227
245
|
);
|
|
228
246
|
if (middlewareResult) {
|
|
229
247
|
if (middlewareResult.redirect) {
|
|
@@ -246,6 +264,37 @@ function createPagesHandler(options) {
|
|
|
246
264
|
return true;
|
|
247
265
|
}
|
|
248
266
|
}
|
|
267
|
+
let loaderResult;
|
|
268
|
+
try {
|
|
269
|
+
loaderResult = await runPageLoader(
|
|
270
|
+
matched.route,
|
|
271
|
+
matched.params,
|
|
272
|
+
outDir,
|
|
273
|
+
componentCache,
|
|
274
|
+
cacheVersion,
|
|
275
|
+
requestContext,
|
|
276
|
+
);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
await renderRuntimeError(err, res);
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
if (loaderResult?.redirect) {
|
|
282
|
+
res.writeHead(loaderResult.status ?? 302, {
|
|
283
|
+
Location: loaderResult.redirect,
|
|
284
|
+
...loaderResult.headers,
|
|
285
|
+
});
|
|
286
|
+
res.end();
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
if (loaderResult?.notFound) {
|
|
290
|
+
await renderNotFound(pathname);
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
const pageProps = {
|
|
294
|
+
...matched.params,
|
|
295
|
+
...(loaderResult?.props ?? {}),
|
|
296
|
+
...(loaderResult?.metadata === undefined ? {} : { metadata: loaderResult.metadata }),
|
|
297
|
+
};
|
|
249
298
|
try {
|
|
250
299
|
await renderPage(
|
|
251
300
|
matched.route,
|
|
@@ -261,11 +310,35 @@ function createPagesHandler(options) {
|
|
|
261
310
|
devMode,
|
|
262
311
|
viteDev,
|
|
263
312
|
(err) => renderRuntimeError(err, res),
|
|
313
|
+
pageProps,
|
|
314
|
+
{
|
|
315
|
+
status: loaderResult?.status,
|
|
316
|
+
headers: loaderResult?.headers,
|
|
317
|
+
},
|
|
264
318
|
);
|
|
265
319
|
} catch (err) {
|
|
266
320
|
await renderRuntimeError(err, res);
|
|
267
321
|
}
|
|
268
322
|
return true;
|
|
323
|
+
|
|
324
|
+
async function renderNotFound(originalPathname) {
|
|
325
|
+
if (manifest.notFoundBundle) {
|
|
326
|
+
await renderErrorPage(
|
|
327
|
+
manifest.notFoundBundle,
|
|
328
|
+
404,
|
|
329
|
+
{ pathname: originalPathname },
|
|
330
|
+
outDir,
|
|
331
|
+
res,
|
|
332
|
+
componentCache,
|
|
333
|
+
manifest,
|
|
334
|
+
cacheVersion,
|
|
335
|
+
projectReact,
|
|
336
|
+
);
|
|
337
|
+
} else {
|
|
338
|
+
res.writeHead(404, getHtmlHeaders());
|
|
339
|
+
res.end('<h1>404 - Not Found</h1>');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
269
342
|
}
|
|
270
343
|
return {
|
|
271
344
|
handle,
|
|
@@ -329,6 +402,7 @@ function compileRoutes(manifest, { rootDir, outDir, viteDev } = {}) {
|
|
|
329
402
|
middlewareServerBundles: entry.middlewareServerBundles ?? [],
|
|
330
403
|
sharedChunks: entry.sharedChunks ?? [],
|
|
331
404
|
sharedCssChunks: entry.sharedCssChunks ?? [],
|
|
405
|
+
hasLoader: entry.hasLoader === true,
|
|
332
406
|
};
|
|
333
407
|
if (viteDev) {
|
|
334
408
|
route = buildViteRoute(route, { rootDir, outDir });
|
|
@@ -343,28 +417,51 @@ function matchPageRoute(routes, pathname) {
|
|
|
343
417
|
if (!result) return null;
|
|
344
418
|
return { route: result.match, params: result.params };
|
|
345
419
|
}
|
|
346
|
-
async function runPageMiddleware(
|
|
347
|
-
route
|
|
348
|
-
|
|
349
|
-
|
|
420
|
+
async function runPageMiddleware(route, outDir, componentCache, cacheVersion, ctx) {
|
|
421
|
+
for (const bundlePath of route.middlewareServerBundles) {
|
|
422
|
+
const fullPath = path.join(outDir, bundlePath);
|
|
423
|
+
const middlewareFn = await loadComponent(
|
|
424
|
+
fullPath,
|
|
425
|
+
`middleware:${bundlePath}`,
|
|
426
|
+
componentCache,
|
|
427
|
+
cacheVersion,
|
|
428
|
+
);
|
|
429
|
+
if (!middlewareFn || typeof middlewareFn !== 'function') {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const result = await middlewareFn(ctx);
|
|
433
|
+
if (result) {
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function createPageRequestContext({
|
|
350
441
|
req,
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
442
|
+
pathname,
|
|
443
|
+
internalPathname,
|
|
444
|
+
params,
|
|
445
|
+
requestUrl,
|
|
354
446
|
sessionConfig,
|
|
355
447
|
appContext,
|
|
356
|
-
) {
|
|
448
|
+
}) {
|
|
357
449
|
const headers = flattenHeaders(req.headers);
|
|
358
450
|
const cookies = parseCookies(req);
|
|
359
451
|
const session = await resolveSession(cookies, sessionConfig);
|
|
360
452
|
const page = {
|
|
453
|
+
host: normalizeHost(headers.host),
|
|
361
454
|
pathname,
|
|
455
|
+
internalPathname,
|
|
456
|
+
params,
|
|
362
457
|
query: params,
|
|
458
|
+
search: requestUrl.search,
|
|
459
|
+
searchParams: requestUrl.searchParams,
|
|
363
460
|
headers,
|
|
364
461
|
cookies,
|
|
365
462
|
session,
|
|
366
463
|
};
|
|
367
|
-
|
|
464
|
+
return appContext
|
|
368
465
|
? {
|
|
369
466
|
page,
|
|
370
467
|
db: appContext.db,
|
|
@@ -377,34 +474,166 @@ async function runPageMiddleware(
|
|
|
377
474
|
meta: appContext.meta,
|
|
378
475
|
}
|
|
379
476
|
: { page };
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function normalizeHost(value) {
|
|
480
|
+
if (typeof value !== 'string' || !value || /[\s,/@?#]/.test(value)) return null;
|
|
481
|
+
const match = value.match(/^([^:]+?)(?::([0-9]+))?$/);
|
|
482
|
+
if (!match) return null;
|
|
483
|
+
const port = match[2] ? Number(match[2]) : null;
|
|
484
|
+
if (port !== null && (port < 1 || port > 65535)) return null;
|
|
485
|
+
const source = match[1].endsWith('.') ? match[1].slice(0, -1) : match[1];
|
|
486
|
+
const host = domainToASCII(source.toLowerCase());
|
|
487
|
+
if (
|
|
488
|
+
!host ||
|
|
489
|
+
host.length > 253 ||
|
|
490
|
+
!host
|
|
491
|
+
.split('.')
|
|
492
|
+
.every(
|
|
493
|
+
(label) =>
|
|
494
|
+
label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label),
|
|
495
|
+
)
|
|
496
|
+
) {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
return host;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function resolvePageRewrite(rewrite, ctx) {
|
|
503
|
+
if (!rewrite) return { pathname: ctx.page.pathname };
|
|
504
|
+
const { reservedPrefix, handler } = rewrite;
|
|
505
|
+
if (
|
|
506
|
+
!ctx.page.host ||
|
|
507
|
+
ctx.page.pathname === reservedPrefix ||
|
|
508
|
+
ctx.page.pathname.startsWith(`${reservedPrefix}/`)
|
|
509
|
+
) {
|
|
510
|
+
return { notFound: true };
|
|
511
|
+
}
|
|
512
|
+
const result = await handler(ctx);
|
|
513
|
+
if (!result) return { pathname: ctx.page.pathname };
|
|
514
|
+
if (result.notFound === true) return { notFound: true };
|
|
515
|
+
if (
|
|
516
|
+
typeof result.pathname !== 'string' ||
|
|
517
|
+
(result.pathname !== reservedPrefix && !result.pathname.startsWith(`${reservedPrefix}/`)) ||
|
|
518
|
+
/[?#]/.test(result.pathname)
|
|
519
|
+
) {
|
|
520
|
+
throw new TypeError('Page rewrite must target its configured reservedPrefix');
|
|
521
|
+
}
|
|
522
|
+
return { pathname: result.pathname };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function runPageLoader(route, params, outDir, componentCache, cacheVersion, ctx) {
|
|
526
|
+
const bundlePath = path.join(outDir, route.serverBundle);
|
|
527
|
+
const pageModule = await loadPageModule(
|
|
528
|
+
bundlePath,
|
|
529
|
+
`page-module:${route.pattern}`,
|
|
530
|
+
componentCache,
|
|
531
|
+
cacheVersion,
|
|
532
|
+
);
|
|
533
|
+
if (pageModule.loader === undefined) return null;
|
|
534
|
+
if (typeof pageModule.loader !== 'function') {
|
|
535
|
+
throw new TypeError(`Page loader for ${route.pattern} must be a function`);
|
|
536
|
+
}
|
|
537
|
+
const result = await pageModule.loader(ctx);
|
|
538
|
+
if (result == null) return { props: {} };
|
|
539
|
+
if (typeof result !== 'object' || Array.isArray(result)) {
|
|
540
|
+
throw new TypeError(`Page loader for ${route.pattern} must return an object`);
|
|
541
|
+
}
|
|
542
|
+
if (result.redirect !== undefined) {
|
|
543
|
+
if (typeof result.redirect !== 'string' || !result.redirect) {
|
|
544
|
+
throw new TypeError('Page loader redirect must be a non-empty string');
|
|
390
545
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
546
|
+
if (
|
|
547
|
+
result.status !== undefined &&
|
|
548
|
+
(!Number.isInteger(result.status) || result.status < 300 || result.status > 399)
|
|
549
|
+
) {
|
|
550
|
+
throw new TypeError('Page loader redirect status must be an integer from 300 to 399');
|
|
394
551
|
}
|
|
552
|
+
validateLoaderHeaders(result.headers);
|
|
553
|
+
return result;
|
|
395
554
|
}
|
|
396
|
-
return
|
|
555
|
+
if (result.notFound === true) return result;
|
|
556
|
+
assertJsonSerializable(result.props ?? {}, 'loader props');
|
|
557
|
+
if (result.metadata !== undefined) {
|
|
558
|
+
assertJsonSerializable(result.metadata, 'loader metadata');
|
|
559
|
+
}
|
|
560
|
+
if (
|
|
561
|
+
result.status !== undefined &&
|
|
562
|
+
(!Number.isInteger(result.status) || result.status < 200 || result.status > 599)
|
|
563
|
+
) {
|
|
564
|
+
throw new TypeError('Page loader status must be an integer from 200 to 599');
|
|
565
|
+
}
|
|
566
|
+
validateLoaderHeaders(result.headers);
|
|
567
|
+
return result;
|
|
397
568
|
}
|
|
398
|
-
|
|
569
|
+
|
|
570
|
+
function validateLoaderHeaders(headers) {
|
|
571
|
+
if (headers === undefined) return;
|
|
572
|
+
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
|
|
573
|
+
throw new TypeError('Page loader headers must be an object');
|
|
574
|
+
}
|
|
575
|
+
const forbidden = new Set([
|
|
576
|
+
'connection',
|
|
577
|
+
'content-length',
|
|
578
|
+
'keep-alive',
|
|
579
|
+
'proxy-authenticate',
|
|
580
|
+
'proxy-authorization',
|
|
581
|
+
'set-cookie',
|
|
582
|
+
'te',
|
|
583
|
+
'trailer',
|
|
584
|
+
'transfer-encoding',
|
|
585
|
+
'upgrade',
|
|
586
|
+
]);
|
|
587
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
588
|
+
if (forbidden.has(name.toLowerCase())) {
|
|
589
|
+
throw new TypeError(`Page loader may not set the ${name} header`);
|
|
590
|
+
}
|
|
591
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
592
|
+
throw new TypeError('Page loader header values must be strings or numbers');
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function assertJsonSerializable(value, label, seen = new Set()) {
|
|
598
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
|
599
|
+
if (typeof value === 'number' && Number.isFinite(value)) return;
|
|
600
|
+
if (typeof value !== 'object') {
|
|
601
|
+
throw new TypeError(`${label} must contain only JSON-serializable values`);
|
|
602
|
+
}
|
|
603
|
+
if (
|
|
604
|
+
seen.has(value) ||
|
|
605
|
+
(!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype)
|
|
606
|
+
) {
|
|
607
|
+
throw new TypeError(`${label} must contain only JSON-serializable values`);
|
|
608
|
+
}
|
|
609
|
+
seen.add(value);
|
|
610
|
+
for (const entry of Array.isArray(value) ? value : Object.values(value)) {
|
|
611
|
+
assertJsonSerializable(entry, label, seen);
|
|
612
|
+
}
|
|
613
|
+
seen.delete(value);
|
|
614
|
+
}
|
|
615
|
+
function buildClientManifestJson(manifest, { rootDir, viteDev, reservedPrefix } = {}) {
|
|
616
|
+
const clientVisibleManifest = reservedPrefix
|
|
617
|
+
? {
|
|
618
|
+
...manifest,
|
|
619
|
+
entries: manifest.entries.filter(
|
|
620
|
+
(entry) =>
|
|
621
|
+
entry.pattern !== reservedPrefix && !entry.pattern.startsWith(`${reservedPrefix}/`),
|
|
622
|
+
),
|
|
623
|
+
}
|
|
624
|
+
: manifest;
|
|
399
625
|
if (viteDev) {
|
|
400
|
-
return buildViteClientManifestJson(
|
|
626
|
+
return buildViteClientManifestJson(clientVisibleManifest, rootDir);
|
|
401
627
|
}
|
|
402
628
|
const clientManifest = {
|
|
403
|
-
cssBundle:
|
|
404
|
-
|
|
629
|
+
cssBundle: clientVisibleManifest.cssBundle
|
|
630
|
+
? `/static/${clientVisibleManifest.cssBundle}`
|
|
631
|
+
: null,
|
|
632
|
+
routes: clientVisibleManifest.entries.map((entry) => {
|
|
405
633
|
const route = {
|
|
406
634
|
pattern: entry.pattern,
|
|
407
635
|
paramNames: entry.paramNames,
|
|
636
|
+
...(entry.hasLoader ? { hasLoader: true } : {}),
|
|
408
637
|
clientBundle: `/static/${entry.navBundle}`,
|
|
409
638
|
layoutBundles: (entry.layoutClientBundles ?? []).map((b) => `/static/${b}`),
|
|
410
639
|
loadingBundles: (entry.loadingClientBundles ?? []).map((b) => `/static/${b}`),
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { parseAsync, transformAsync } from '@babel/core';
|
|
4
|
+
|
|
5
|
+
function exportedName(specifier) {
|
|
6
|
+
if (specifier?.exported?.type === 'Identifier') return specifier.exported.name;
|
|
7
|
+
if (specifier?.exported?.type === 'StringLiteral') return specifier.exported.value;
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function hasServerLoaderExport(code, filename) {
|
|
12
|
+
if (!code.includes('loader') && !/\bexport\s*\*/.test(code)) return false;
|
|
13
|
+
const ast = await parseAsync(code, {
|
|
14
|
+
filename,
|
|
15
|
+
babelrc: false,
|
|
16
|
+
configFile: false,
|
|
17
|
+
parserOpts: { plugins: ['jsx', 'typescript'] },
|
|
18
|
+
});
|
|
19
|
+
return ast.program.body.some((node) => {
|
|
20
|
+
// A star re-export can acquire a loader through any depth of barrel module.
|
|
21
|
+
// Treat it conservatively so client navigation can never bypass a loader
|
|
22
|
+
// that is only visible after module resolution.
|
|
23
|
+
if (node.type === 'ExportAllDeclaration') return true;
|
|
24
|
+
if (node.type !== 'ExportNamedDeclaration') return false;
|
|
25
|
+
if (node.declaration?.type === 'FunctionDeclaration') {
|
|
26
|
+
return node.declaration.id?.name === 'loader';
|
|
27
|
+
}
|
|
28
|
+
if (node.declaration?.type === 'VariableDeclaration') {
|
|
29
|
+
return node.declaration.declarations.some(
|
|
30
|
+
(item) => item.id.type === 'Identifier' && item.id.name === 'loader',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return node.specifiers.some((specifier) => exportedName(specifier) === 'loader');
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rememberDependencies(targetPath, state) {
|
|
38
|
+
const dependencies = state.file.get('arcwayLoaderDependencies') ?? new Set();
|
|
39
|
+
targetPath.traverse({
|
|
40
|
+
ReferencedIdentifier(identifierPath) {
|
|
41
|
+
const binding = identifierPath.scope.getBinding(identifierPath.node.name);
|
|
42
|
+
if (binding?.scope.path.isProgram()) dependencies.add(identifierPath.node.name);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
state.file.set('arcwayLoaderDependencies', dependencies);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function removeBinding(binding, state) {
|
|
49
|
+
if (!binding) return;
|
|
50
|
+
const bindingPath = binding.path;
|
|
51
|
+
rememberDependencies(bindingPath, state);
|
|
52
|
+
if (bindingPath.isFunctionDeclaration() || bindingPath.isClassDeclaration()) {
|
|
53
|
+
bindingPath.remove();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (bindingPath.isVariableDeclarator()) {
|
|
57
|
+
const declarationPath = bindingPath.parentPath;
|
|
58
|
+
bindingPath.remove();
|
|
59
|
+
if (declarationPath.node?.declarations.length === 0) declarationPath.remove();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stripLoaderExportPlugin() {
|
|
64
|
+
return {
|
|
65
|
+
visitor: {
|
|
66
|
+
ExportAllDeclaration(exportPath) {
|
|
67
|
+
// Page named exports have no browser runtime contract. Removing a star
|
|
68
|
+
// export is the only safe client transform because its export names can
|
|
69
|
+
// change through transitive barrels without changing this page file.
|
|
70
|
+
exportPath.remove();
|
|
71
|
+
},
|
|
72
|
+
ExportNamedDeclaration(exportPath, state) {
|
|
73
|
+
const declaration = exportPath.node.declaration;
|
|
74
|
+
if (declaration?.type === 'FunctionDeclaration' && declaration.id?.name === 'loader') {
|
|
75
|
+
rememberDependencies(exportPath.get('declaration'), state);
|
|
76
|
+
exportPath.remove();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
80
|
+
for (const declaratorPath of exportPath.get('declaration.declarations')) {
|
|
81
|
+
if (
|
|
82
|
+
declaratorPath.node.id.type === 'Identifier' &&
|
|
83
|
+
declaratorPath.node.id.name === 'loader'
|
|
84
|
+
) {
|
|
85
|
+
rememberDependencies(declaratorPath, state);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
declaration.declarations = declaration.declarations.filter(
|
|
89
|
+
(item) => item.id.type !== 'Identifier' || item.id.name !== 'loader',
|
|
90
|
+
);
|
|
91
|
+
if (declaration.declarations.length === 0) exportPath.remove();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
exportPath.node.specifiers = exportPath.node.specifiers.filter((specifier) => {
|
|
95
|
+
if (exportedName(specifier) !== 'loader') return true;
|
|
96
|
+
if (!exportPath.node.source && specifier.local?.name) {
|
|
97
|
+
const names = state.file.get('arcwayLoaderBindings') ?? [];
|
|
98
|
+
names.push(specifier.local.name);
|
|
99
|
+
state.file.set('arcwayLoaderBindings', names);
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
});
|
|
103
|
+
if (exportPath.node.specifiers.length === 0 && !declaration) exportPath.remove();
|
|
104
|
+
},
|
|
105
|
+
Program: {
|
|
106
|
+
exit(programPath, state) {
|
|
107
|
+
for (const name of state.file.get('arcwayLoaderBindings') ?? []) {
|
|
108
|
+
const binding = programPath.scope.getBinding(name);
|
|
109
|
+
removeBinding(binding, state);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const pending = state.file.get('arcwayLoaderDependencies') ?? new Set();
|
|
113
|
+
const inspected = new Set();
|
|
114
|
+
while (pending.size > 0) {
|
|
115
|
+
programPath.scope.crawl();
|
|
116
|
+
const name = pending.values().next().value;
|
|
117
|
+
pending.delete(name);
|
|
118
|
+
if (inspected.has(name)) continue;
|
|
119
|
+
inspected.add(name);
|
|
120
|
+
const binding = programPath.scope.getBinding(name);
|
|
121
|
+
if (binding && !binding.referenced) removeBinding(binding, state);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
programPath.scope.crawl();
|
|
125
|
+
for (const importPath of programPath.get('body')) {
|
|
126
|
+
if (!importPath.isImportDeclaration()) continue;
|
|
127
|
+
const hadSpecifiers = importPath.node.specifiers.length > 0;
|
|
128
|
+
for (const specifierPath of importPath.get('specifiers')) {
|
|
129
|
+
const binding = programPath.scope.getBinding(specifierPath.node.local.name);
|
|
130
|
+
if (!binding?.referenced) specifierPath.remove();
|
|
131
|
+
}
|
|
132
|
+
if (hadSpecifiers && importPath.node.specifiers.length === 0) importPath.remove();
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function stripServerLoaderExport(code, filename) {
|
|
141
|
+
if (!code.includes('loader') && !/\bexport\s*\*/.test(code)) return code;
|
|
142
|
+
const result = await transformAsync(code, {
|
|
143
|
+
filename,
|
|
144
|
+
babelrc: false,
|
|
145
|
+
configFile: false,
|
|
146
|
+
sourceMaps: false,
|
|
147
|
+
parserOpts: {
|
|
148
|
+
plugins: ['jsx', 'typescript'],
|
|
149
|
+
},
|
|
150
|
+
plugins: [stripLoaderExportPlugin],
|
|
151
|
+
});
|
|
152
|
+
return result?.code ?? code;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function serverLoaderClientPlugin(pagePaths) {
|
|
156
|
+
const pages = new Set(pagePaths.map((filePath) => path.resolve(filePath)));
|
|
157
|
+
return {
|
|
158
|
+
name: 'arcway-server-page-loader',
|
|
159
|
+
setup(build) {
|
|
160
|
+
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
|
|
161
|
+
if (!pages.has(path.resolve(args.path))) return null;
|
|
162
|
+
const source = await fs.readFile(args.path, 'utf8');
|
|
163
|
+
const contents = await stripServerLoaderExport(source, args.path);
|
|
164
|
+
const extension = path.extname(args.path).toLowerCase();
|
|
165
|
+
const loader = extension === '.ts' ? 'ts' : extension === '.tsx' ? 'tsx' : 'jsx';
|
|
166
|
+
return { contents, loader };
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function serverLoaderVitePlugin(pagesRoot) {
|
|
173
|
+
const pagesDir = `${path.resolve(pagesRoot)}${path.sep}`;
|
|
174
|
+
return {
|
|
175
|
+
name: 'arcway-server-page-loader',
|
|
176
|
+
enforce: 'pre',
|
|
177
|
+
async transform(code, id) {
|
|
178
|
+
const filename = id.split('?')[0];
|
|
179
|
+
if (!filename.startsWith(pagesDir) || !/\.[cm]?[jt]sx?$/.test(filename)) return null;
|
|
180
|
+
const transformed = await stripServerLoaderExport(code, filename);
|
|
181
|
+
return transformed === code ? null : { code: transformed, map: null };
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export {
|
|
187
|
+
hasServerLoaderExport,
|
|
188
|
+
serverLoaderClientPlugin,
|
|
189
|
+
serverLoaderVitePlugin,
|
|
190
|
+
stripServerLoaderExport,
|
|
191
|
+
};
|
package/server/pages/ssr.js
CHANGED
|
@@ -56,6 +56,16 @@ async function loadComponent(bundlePath, cacheKey, componentCache, cacheVersion)
|
|
|
56
56
|
return null;
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
async function loadPageModule(bundlePath, cacheKey, componentCache, cacheVersion) {
|
|
60
|
+
if (componentCache.has(cacheKey)) {
|
|
61
|
+
return componentCache.get(cacheKey);
|
|
62
|
+
}
|
|
63
|
+
const fileUrl = pathToFileURL(bundlePath).href;
|
|
64
|
+
const specifier = cacheVersion > 0 ? `${fileUrl}?v=${cacheVersion}` : fileUrl;
|
|
65
|
+
const mod = await import(specifier);
|
|
66
|
+
componentCache.set(cacheKey, mod);
|
|
67
|
+
return mod;
|
|
68
|
+
}
|
|
59
69
|
const ROUTER_CTX_KEY = '__router_context__';
|
|
60
70
|
const PROVIDER_CTX_KEY = '__provider_context__';
|
|
61
71
|
function wrapWithProviders(createElement, element, pathname, params) {
|
|
@@ -153,15 +163,22 @@ async function renderPage(
|
|
|
153
163
|
devMode,
|
|
154
164
|
viteDev = false,
|
|
155
165
|
onRenderFailure = null,
|
|
166
|
+
pageProps = params,
|
|
167
|
+
response = {},
|
|
156
168
|
) {
|
|
157
169
|
const { createElement, renderToPipeableStream } = react;
|
|
158
170
|
const bundlePath = path.join(outDir, route.serverBundle);
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
const legacyCachedComponent = componentCache.get(`page:${route.pattern}`);
|
|
172
|
+
const Component = legacyCachedComponent
|
|
173
|
+
? legacyCachedComponent
|
|
174
|
+
: (
|
|
175
|
+
await loadPageModule(
|
|
176
|
+
bundlePath,
|
|
177
|
+
`page-module:${route.pattern}`,
|
|
178
|
+
componentCache,
|
|
179
|
+
cacheVersion,
|
|
180
|
+
)
|
|
181
|
+
).default;
|
|
165
182
|
if (!Component || typeof Component !== 'function') {
|
|
166
183
|
res.writeHead(500, getHtmlHeaders());
|
|
167
184
|
res.end('<h1>500 - Page component is not a valid React component</h1>');
|
|
@@ -188,12 +205,13 @@ async function renderPage(
|
|
|
188
205
|
const envScriptTag = buildEnvScriptTag(collectPublicEnv());
|
|
189
206
|
const hmrTag = devMode && !viteDev ? buildHmrScript() : '';
|
|
190
207
|
const liveReloadTag = devMode && !viteDev && !hmrTag ? buildLiveReloadScript() : '';
|
|
191
|
-
const propsJson = JSON.stringify(
|
|
208
|
+
const propsJson = JSON.stringify(pageProps).replace(/</g, '\\u003c');
|
|
209
|
+
const routeParamsJson = JSON.stringify(params).replace(/</g, '\\u003c');
|
|
192
210
|
const headData = { meta: [], links: [] };
|
|
193
211
|
setSSRHeadData(headData);
|
|
194
|
-
let element = createElement(Component,
|
|
212
|
+
let element = createElement(Component, pageProps);
|
|
195
213
|
for (let i = layoutComponents.length - 1; i >= 0; i--) {
|
|
196
|
-
element = createElement(layoutComponents[i],
|
|
214
|
+
element = createElement(layoutComponents[i], pageProps, element);
|
|
197
215
|
}
|
|
198
216
|
element = wrapWithProviders(createElement, element, pathname, params);
|
|
199
217
|
const { PassThrough } = await import('node:stream');
|
|
@@ -209,7 +227,10 @@ async function renderPage(
|
|
|
209
227
|
// React may call onAllReady after onShellError but before the recovery
|
|
210
228
|
// promise starts. Never let the failed stream win that race and commit 200.
|
|
211
229
|
if (res.headersSent || closed || renderFailureHandled) return;
|
|
212
|
-
res.writeHead(200,
|
|
230
|
+
res.writeHead(response.status ?? 200, {
|
|
231
|
+
...getHtmlHeaders(),
|
|
232
|
+
...response.headers,
|
|
233
|
+
});
|
|
213
234
|
const headHtml = renderHeadToString(headData);
|
|
214
235
|
const shell = buildHtmlShell({
|
|
215
236
|
headHtml,
|
|
@@ -217,6 +238,7 @@ async function renderPage(
|
|
|
217
238
|
cssLinkTag,
|
|
218
239
|
bodysuffix: `${envScriptTag}
|
|
219
240
|
<script id="__app_props" type="application/json">${propsJson}</script>
|
|
241
|
+
<script id="__app_route_params" type="application/json">${routeParamsJson}</script>
|
|
220
242
|
<script id="__app_manifest" type="application/json">${clientManifestJson}</script>
|
|
221
243
|
${hmrTag}
|
|
222
244
|
${scriptTags}
|
|
@@ -372,6 +394,7 @@ export {
|
|
|
372
394
|
buildRouteCssLinkTags,
|
|
373
395
|
buildScriptTags,
|
|
374
396
|
loadComponent,
|
|
397
|
+
loadPageModule,
|
|
375
398
|
renderErrorPage,
|
|
376
399
|
renderPage,
|
|
377
400
|
syncReactInternals,
|
package/server/pages/vite-dev.js
CHANGED
|
@@ -6,6 +6,7 @@ import tailwindcss from '@tailwindcss/vite';
|
|
|
6
6
|
import { createServer as createViteServer } from 'vite';
|
|
7
7
|
import { patternToFileName } from './build-server.js';
|
|
8
8
|
import { closeServer, createHttpServer, listen } from '../server.js';
|
|
9
|
+
import { serverLoaderVitePlugin } from './server-loader-transform.js';
|
|
9
10
|
|
|
10
11
|
function toPosixPath(value) {
|
|
11
12
|
return value.replace(/\\/g, '/');
|
|
@@ -72,9 +73,11 @@ function buildViteHydrationEntry({
|
|
|
72
73
|
const container = document.getElementById('__app');
|
|
73
74
|
const propsEl = document.getElementById('__app_props');
|
|
74
75
|
const props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {};
|
|
76
|
+
const routeParamsEl = document.getElementById('__app_route_params');
|
|
77
|
+
const routeParams = routeParamsEl ? JSON.parse(routeParamsEl.textContent || '{}') : props;
|
|
75
78
|
const ROOT_KEY = '__arcway_root__';
|
|
76
79
|
const rootOwner = window;
|
|
77
|
-
const element = <ApiProvider><Router initialPath={window.location.pathname} initialParams={props} initialPattern={${JSON.stringify(pattern)}} initialComponent={Component} initialLayouts={${layoutsArray}} initialLoadings={${loadingsArray}} /></ApiProvider>;
|
|
80
|
+
const element = <ApiProvider><Router initialPath={window.location.pathname} initialParams={routeParams} initialPageProps={props} initialPattern={${JSON.stringify(pattern)}} initialComponent={Component} initialLayouts={${layoutsArray}} initialLoadings={${loadingsArray}} /></ApiProvider>;
|
|
78
81
|
|
|
79
82
|
if (!container) {
|
|
80
83
|
throw new Error('Arcway Vite hydrate entry could not find #__app');
|
|
@@ -153,6 +156,7 @@ function buildViteClientManifestJson(manifest, rootDir) {
|
|
|
153
156
|
const route = {
|
|
154
157
|
pattern: entry.pattern,
|
|
155
158
|
paramNames: entry.paramNames,
|
|
159
|
+
...(entry.hasLoader ? { hasLoader: true } : {}),
|
|
156
160
|
clientBundle: toViteModuleUrl(rootDir, entry.srcPath),
|
|
157
161
|
layoutBundles: entry.layoutDirs
|
|
158
162
|
.map((dirPath) => manifest.layouts.get(dirPath)?.srcPath)
|
|
@@ -187,14 +191,26 @@ function resolveAppAliases(rootDir) {
|
|
|
187
191
|
try {
|
|
188
192
|
const reactRoot = path.dirname(appRequire.resolve('react/package.json'));
|
|
189
193
|
aliases.push({ find: /^react$/, replacement: toPosixPath(path.join(reactRoot, 'index.js')) });
|
|
190
|
-
aliases.push({
|
|
191
|
-
|
|
194
|
+
aliases.push({
|
|
195
|
+
find: /^react\/jsx-runtime$/,
|
|
196
|
+
replacement: toPosixPath(path.join(reactRoot, 'jsx-runtime.js')),
|
|
197
|
+
});
|
|
198
|
+
aliases.push({
|
|
199
|
+
find: /^react\/jsx-dev-runtime$/,
|
|
200
|
+
replacement: toPosixPath(path.join(reactRoot, 'jsx-dev-runtime.js')),
|
|
201
|
+
});
|
|
192
202
|
} catch {}
|
|
193
203
|
|
|
194
204
|
try {
|
|
195
205
|
const reactDomRoot = path.dirname(appRequire.resolve('react-dom/package.json'));
|
|
196
|
-
aliases.push({
|
|
197
|
-
|
|
206
|
+
aliases.push({
|
|
207
|
+
find: /^react-dom$/,
|
|
208
|
+
replacement: toPosixPath(path.join(reactDomRoot, 'index.js')),
|
|
209
|
+
});
|
|
210
|
+
aliases.push({
|
|
211
|
+
find: /^react-dom\/client$/,
|
|
212
|
+
replacement: toPosixPath(path.join(reactDomRoot, 'client.js')),
|
|
213
|
+
});
|
|
198
214
|
} catch {}
|
|
199
215
|
|
|
200
216
|
try {
|
|
@@ -251,7 +267,11 @@ async function createViteDevRouter({ rootDir, log, config }) {
|
|
|
251
267
|
'@radix-ui/react-use-is-hydrated',
|
|
252
268
|
],
|
|
253
269
|
},
|
|
254
|
-
plugins: [
|
|
270
|
+
plugins: [
|
|
271
|
+
serverLoaderVitePlugin(config?.pages?.dir ?? path.resolve(rootDir, 'pages')),
|
|
272
|
+
reactPlugin(),
|
|
273
|
+
tailwindcss(),
|
|
274
|
+
],
|
|
255
275
|
clearScreen: false,
|
|
256
276
|
});
|
|
257
277
|
} catch (error) {
|