@finesoft/front 0.1.39 → 0.1.41
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/app-re7jUCuM.mjs +178 -0
- package/dist/app-re7jUCuM.mjs.map +1 -0
- package/dist/index.d.mts +1276 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2749 -0
- package/dist/index.mjs.map +1 -0
- package/dist/locale-D2Bu7w47.mjs +27 -0
- package/dist/locale-D2Bu7w47.mjs.map +1 -0
- package/dist/rolldown-runtime-wcPFST8Q.mjs +13 -0
- package/dist/server-peer-modules-BSSsxBaF.d.mts +41 -0
- package/dist/server-peer-modules-BSSsxBaF.d.mts.map +1 -0
- package/package.json +21 -32
- package/README.md +0 -1136
- package/dist/app-XEMPAI6H.js +0 -10
- package/dist/app-XEMPAI6H.js.map +0 -1
- package/dist/browser.cjs +0 -1465
- package/dist/browser.cjs.map +0 -1
- package/dist/browser.d.cts +0 -904
- package/dist/browser.d.ts +0 -904
- package/dist/browser.js +0 -115
- package/dist/browser.js.map +0 -1
- package/dist/chunk-4PPCVAKZ.js +0 -463
- package/dist/chunk-4PPCVAKZ.js.map +0 -1
- package/dist/chunk-OYTIGVEG.js +0 -193
- package/dist/chunk-OYTIGVEG.js.map +0 -1
- package/dist/chunk-PSPVIVC2.js +0 -25
- package/dist/chunk-PSPVIVC2.js.map +0 -1
- package/dist/chunk-SDPWQT2T.js +0 -936
- package/dist/chunk-SDPWQT2T.js.map +0 -1
- package/dist/chunk-XQ3UWZOS.js +0 -187
- package/dist/chunk-XQ3UWZOS.js.map +0 -1
- package/dist/index.cjs +0 -3661
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -582
- package/dist/index.d.ts +0 -582
- package/dist/index.js +0 -1648
- package/dist/index.js.map +0 -1
- package/dist/locale-CAZ4INCX.js +0 -7
- package/dist/locale-CAZ4INCX.js.map +0 -1
- package/dist/src-XNC7QNVI.js +0 -21
- package/dist/src-XNC7QNVI.js.map +0 -1
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2749 @@
|
|
|
1
|
+
import "./server-peer-modules-BSSsxBaF.d.mts";
|
|
2
|
+
import { a as SSR_PLACEHOLDERS, i as createInternalFetch, n as createSSRApp, o as injectCSRShell, s as injectSSRContent } from "./app-re7jUCuM.mjs";
|
|
3
|
+
import { n as parseAcceptLanguage } from "./locale-D2Bu7w47.mjs";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
//#region ../core/src/actions/types.ts
|
|
6
|
+
/**
|
|
7
|
+
* Action 类型定义
|
|
8
|
+
*
|
|
9
|
+
* FlowAction — SPA 内部导航
|
|
10
|
+
* ExternalUrlAction — 打开外部链接
|
|
11
|
+
* CompoundAction — 组合多个 Action
|
|
12
|
+
*/
|
|
13
|
+
/** Action Kind 常量 */
|
|
14
|
+
const ACTION_KINDS = {
|
|
15
|
+
FLOW: "flow",
|
|
16
|
+
EXTERNAL_URL: "externalUrl",
|
|
17
|
+
COMPOUND: "compound"
|
|
18
|
+
};
|
|
19
|
+
function isFlowAction(action) {
|
|
20
|
+
return action.kind === ACTION_KINDS.FLOW;
|
|
21
|
+
}
|
|
22
|
+
function isExternalUrlAction(action) {
|
|
23
|
+
return action.kind === ACTION_KINDS.EXTERNAL_URL;
|
|
24
|
+
}
|
|
25
|
+
function isCompoundAction(action) {
|
|
26
|
+
return action.kind === ACTION_KINDS.COMPOUND;
|
|
27
|
+
}
|
|
28
|
+
function makeFlowAction(url, presentationContext) {
|
|
29
|
+
return {
|
|
30
|
+
kind: ACTION_KINDS.FLOW,
|
|
31
|
+
url,
|
|
32
|
+
presentationContext
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function makeExternalUrlAction(url) {
|
|
36
|
+
return {
|
|
37
|
+
kind: ACTION_KINDS.EXTERNAL_URL,
|
|
38
|
+
url
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region ../core/src/actions/dispatcher.ts
|
|
43
|
+
var ActionDispatcher = class {
|
|
44
|
+
handlers = /* @__PURE__ */ new Map();
|
|
45
|
+
wiredActions = /* @__PURE__ */ new Set();
|
|
46
|
+
/** 注册指定 kind 的 handler(防止重复注册) */
|
|
47
|
+
onAction(kind, handler) {
|
|
48
|
+
if (this.wiredActions.has(kind)) {
|
|
49
|
+
console.warn(`[ActionDispatcher] kind="${kind}" already registered, skipping`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
this.wiredActions.add(kind);
|
|
53
|
+
this.handlers.set(kind, handler);
|
|
54
|
+
}
|
|
55
|
+
/** 执行一个 Action(CompoundAction 递归展开) */
|
|
56
|
+
async perform(action) {
|
|
57
|
+
if (isCompoundAction(action)) {
|
|
58
|
+
for (const subAction of action.actions) await this.perform(subAction);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const handler = this.handlers.get(action.kind);
|
|
62
|
+
if (!handler) {
|
|
63
|
+
console.warn(`[ActionDispatcher] No handler for kind="${action.kind}"`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await handler(action);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region ../core/src/intents/dispatcher.ts
|
|
71
|
+
var IntentDispatcher = class {
|
|
72
|
+
controllers = /* @__PURE__ */ new Map();
|
|
73
|
+
/** 注册一个 IntentController */
|
|
74
|
+
register(controller) {
|
|
75
|
+
this.controllers.set(controller.intentId, controller);
|
|
76
|
+
}
|
|
77
|
+
/** 分发 Intent 到对应 Controller */
|
|
78
|
+
async dispatch(intent, container) {
|
|
79
|
+
const controller = this.controllers.get(intent.id);
|
|
80
|
+
if (!controller) throw new Error(`[IntentDispatcher] No controller for "${intent.id}". Registered: [${Array.from(this.controllers.keys()).join(", ")}]`);
|
|
81
|
+
return controller.perform(intent, container);
|
|
82
|
+
}
|
|
83
|
+
/** 检查是否已注册某个 Intent */
|
|
84
|
+
has(intentId) {
|
|
85
|
+
return this.controllers.has(intentId);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region ../core/src/dependencies/container.ts
|
|
90
|
+
var Container = class {
|
|
91
|
+
registrations = /* @__PURE__ */ new Map();
|
|
92
|
+
/** 注册依赖(默认单例) */
|
|
93
|
+
register(key, factory, singleton = true) {
|
|
94
|
+
this.registrations.set(key, {
|
|
95
|
+
factory,
|
|
96
|
+
singleton
|
|
97
|
+
});
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
/** 解析依赖 */
|
|
101
|
+
resolve(key) {
|
|
102
|
+
const reg = this.registrations.get(key);
|
|
103
|
+
if (!reg) throw new Error(`[Container] No registration for key: "${key}"`);
|
|
104
|
+
if (reg.singleton) {
|
|
105
|
+
if (reg.instance === void 0) reg.instance = reg.factory();
|
|
106
|
+
return reg.instance;
|
|
107
|
+
}
|
|
108
|
+
return reg.factory();
|
|
109
|
+
}
|
|
110
|
+
/** 检查是否已注册 */
|
|
111
|
+
has(key) {
|
|
112
|
+
return this.registrations.has(key);
|
|
113
|
+
}
|
|
114
|
+
/** 销毁容器,清除所有缓存 */
|
|
115
|
+
dispose() {
|
|
116
|
+
for (const reg of this.registrations.values()) reg.instance = void 0;
|
|
117
|
+
this.registrations.clear();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region ../core/src/logger/base.ts
|
|
122
|
+
var BaseLogger = class {
|
|
123
|
+
category;
|
|
124
|
+
constructor(category) {
|
|
125
|
+
this.category = category;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region ../core/src/logger/local-storage-filter.ts
|
|
130
|
+
const LEVEL_TO_NUM = {
|
|
131
|
+
"*": 4,
|
|
132
|
+
debug: 4,
|
|
133
|
+
info: 3,
|
|
134
|
+
warn: 2,
|
|
135
|
+
error: 1,
|
|
136
|
+
off: 0,
|
|
137
|
+
"": 0
|
|
138
|
+
};
|
|
139
|
+
let cachedRules;
|
|
140
|
+
let cachedRaw;
|
|
141
|
+
function parseRules() {
|
|
142
|
+
if (typeof globalThis.localStorage === "undefined") return {};
|
|
143
|
+
let raw;
|
|
144
|
+
try {
|
|
145
|
+
raw = globalThis.localStorage.getItem("onyxLog");
|
|
146
|
+
} catch {
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
if (!raw) return {};
|
|
150
|
+
if (raw === cachedRaw && cachedRules) return cachedRules;
|
|
151
|
+
cachedRaw = raw;
|
|
152
|
+
const rules = {};
|
|
153
|
+
const parts = raw.split(",");
|
|
154
|
+
for (const part of parts) {
|
|
155
|
+
const [name, level] = part.trim().split("=");
|
|
156
|
+
if (!name || level === void 0) continue;
|
|
157
|
+
const num = LEVEL_TO_NUM[level.toLowerCase()] ?? void 0;
|
|
158
|
+
if (num === void 0) continue;
|
|
159
|
+
if (name === "*") rules.defaultLevel = num;
|
|
160
|
+
else {
|
|
161
|
+
rules.named ??= {};
|
|
162
|
+
rules.named[name] = num;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
cachedRules = rules;
|
|
166
|
+
return rules;
|
|
167
|
+
}
|
|
168
|
+
function shouldLog(name, level) {
|
|
169
|
+
const rules = parseRules();
|
|
170
|
+
if (rules.defaultLevel === void 0 && !rules.named) return true;
|
|
171
|
+
const currentNum = LEVEL_TO_NUM[level] ?? 4;
|
|
172
|
+
if (rules.named?.[name] !== void 0) return currentNum <= rules.named[name];
|
|
173
|
+
if (rules.defaultLevel !== void 0) return currentNum <= rules.defaultLevel;
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
function resetFilterCache() {
|
|
177
|
+
cachedRules = void 0;
|
|
178
|
+
cachedRaw = void 0;
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region ../core/src/logger/console.ts
|
|
182
|
+
/**
|
|
183
|
+
* ConsoleLogger — 基于 console 的日志实现
|
|
184
|
+
*/
|
|
185
|
+
var ConsoleLogger = class extends BaseLogger {
|
|
186
|
+
debug(...args) {
|
|
187
|
+
if (shouldLog(this.category, "debug")) console.debug(`[${this.category}]`, ...args);
|
|
188
|
+
return "";
|
|
189
|
+
}
|
|
190
|
+
info(...args) {
|
|
191
|
+
if (shouldLog(this.category, "info")) console.info(`[${this.category}]`, ...args);
|
|
192
|
+
return "";
|
|
193
|
+
}
|
|
194
|
+
warn(...args) {
|
|
195
|
+
if (shouldLog(this.category, "warn")) console.warn(`[${this.category}]`, ...args);
|
|
196
|
+
return "";
|
|
197
|
+
}
|
|
198
|
+
error(...args) {
|
|
199
|
+
console.error(`[${this.category}]`, ...args);
|
|
200
|
+
return "";
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
var ConsoleLoggerFactory = class {
|
|
204
|
+
loggerFor(category) {
|
|
205
|
+
return new ConsoleLogger(category);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region ../core/src/dependencies/make-dependencies.ts
|
|
210
|
+
/**
|
|
211
|
+
* 依赖工厂 — 创建所有基础依赖
|
|
212
|
+
*/
|
|
213
|
+
const DEP_KEYS = {
|
|
214
|
+
LOGGER: "logger",
|
|
215
|
+
LOGGER_FACTORY: "loggerFactory",
|
|
216
|
+
NET: "net",
|
|
217
|
+
LOCALE: "locale",
|
|
218
|
+
STORAGE: "storage",
|
|
219
|
+
FEATURE_FLAGS: "featureFlags",
|
|
220
|
+
METRICS: "metrics",
|
|
221
|
+
FETCH: "fetch"
|
|
222
|
+
};
|
|
223
|
+
var DefaultLocale = class {
|
|
224
|
+
language = "en";
|
|
225
|
+
storefront = "us";
|
|
226
|
+
setActiveLocale(language, storefront) {
|
|
227
|
+
this.language = language;
|
|
228
|
+
this.storefront = storefront;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
var MemoryStorage = class {
|
|
232
|
+
store = /* @__PURE__ */ new Map();
|
|
233
|
+
get(key) {
|
|
234
|
+
return this.store.get(key);
|
|
235
|
+
}
|
|
236
|
+
set(key, value) {
|
|
237
|
+
this.store.set(key, value);
|
|
238
|
+
}
|
|
239
|
+
delete(key) {
|
|
240
|
+
this.store.delete(key);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
var DefaultFeatureFlags = class {
|
|
244
|
+
flags;
|
|
245
|
+
constructor(flags = {}) {
|
|
246
|
+
this.flags = flags;
|
|
247
|
+
}
|
|
248
|
+
isEnabled(key) {
|
|
249
|
+
return this.flags[key] === true;
|
|
250
|
+
}
|
|
251
|
+
getString(key) {
|
|
252
|
+
const v = this.flags[key];
|
|
253
|
+
return typeof v === "string" ? v : void 0;
|
|
254
|
+
}
|
|
255
|
+
getNumber(key) {
|
|
256
|
+
const v = this.flags[key];
|
|
257
|
+
return typeof v === "number" ? v : void 0;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
var ConsoleMetrics = class {
|
|
261
|
+
recordPageView(page, fields) {
|
|
262
|
+
console.info(`[Metrics:PageView] ${page}`, fields ?? "");
|
|
263
|
+
}
|
|
264
|
+
recordEvent(name, fields) {
|
|
265
|
+
console.info(`[Metrics:Event] ${name}`, fields ?? "");
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
function makeDependencies(container, options = {}) {
|
|
269
|
+
const { fetch: fetchFn = globalThis.fetch?.bind(globalThis), language = "en", storefront = "us", featureFlags = {} } = options;
|
|
270
|
+
const loggerFactory = new ConsoleLoggerFactory();
|
|
271
|
+
container.register(DEP_KEYS.LOGGER_FACTORY, () => loggerFactory);
|
|
272
|
+
container.register(DEP_KEYS.LOGGER, () => loggerFactory.loggerFor("framework"));
|
|
273
|
+
container.register(DEP_KEYS.NET, () => ({ fetch: (url, opts) => fetchFn(url, opts) }));
|
|
274
|
+
container.register(DEP_KEYS.LOCALE, () => {
|
|
275
|
+
const locale = new DefaultLocale();
|
|
276
|
+
locale.setActiveLocale(language, storefront);
|
|
277
|
+
return locale;
|
|
278
|
+
});
|
|
279
|
+
container.register(DEP_KEYS.STORAGE, () => new MemoryStorage());
|
|
280
|
+
container.register(DEP_KEYS.FEATURE_FLAGS, () => new DefaultFeatureFlags(featureFlags));
|
|
281
|
+
container.register(DEP_KEYS.METRICS, () => new ConsoleMetrics());
|
|
282
|
+
container.register(DEP_KEYS.FETCH, () => fetchFn);
|
|
283
|
+
}
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region ../core/src/router/router.ts
|
|
286
|
+
/**
|
|
287
|
+
* URL 路由器 — URL pattern → Intent + FlowAction
|
|
288
|
+
*/
|
|
289
|
+
var Router = class {
|
|
290
|
+
routes = [];
|
|
291
|
+
/** 添加路由规则 */
|
|
292
|
+
add(pattern, intentId, renderModeOrOptions) {
|
|
293
|
+
const opts = typeof renderModeOrOptions === "string" ? { renderMode: renderModeOrOptions } : renderModeOrOptions ?? {};
|
|
294
|
+
const paramNames = [];
|
|
295
|
+
const regexStr = pattern.split(/(\/:[\w]+\??)/).map((segment) => {
|
|
296
|
+
const paramMatch = segment.match(/^\/:(\w+)(\?)?$/);
|
|
297
|
+
if (paramMatch) {
|
|
298
|
+
paramNames.push(paramMatch[1]);
|
|
299
|
+
return paramMatch[2] ? "(?:/([^/]+))?" : "/([^/]+)";
|
|
300
|
+
}
|
|
301
|
+
return segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
302
|
+
}).join("");
|
|
303
|
+
this.routes.push({
|
|
304
|
+
pattern,
|
|
305
|
+
intentId,
|
|
306
|
+
regex: new RegExp(`^${regexStr}/?$`),
|
|
307
|
+
paramNames,
|
|
308
|
+
renderMode: opts.renderMode,
|
|
309
|
+
beforeGuards: opts.beforeGuards,
|
|
310
|
+
afterGuards: opts.afterGuards
|
|
311
|
+
});
|
|
312
|
+
return this;
|
|
313
|
+
}
|
|
314
|
+
/** 解析 URL → RouteMatch */
|
|
315
|
+
resolve(urlOrPath) {
|
|
316
|
+
const path = this.extractPath(urlOrPath);
|
|
317
|
+
const queryParams = this.extractQueryParams(urlOrPath);
|
|
318
|
+
for (const route of this.routes) {
|
|
319
|
+
const match = path.match(route.regex);
|
|
320
|
+
if (match) {
|
|
321
|
+
const params = {};
|
|
322
|
+
route.paramNames.forEach((name, index) => {
|
|
323
|
+
const value = match[index + 1];
|
|
324
|
+
if (value) params[name] = value;
|
|
325
|
+
});
|
|
326
|
+
for (const [k, v] of Object.entries(queryParams)) if (!(k in params)) params[k] = v;
|
|
327
|
+
return {
|
|
328
|
+
intent: {
|
|
329
|
+
id: route.intentId,
|
|
330
|
+
params
|
|
331
|
+
},
|
|
332
|
+
action: makeFlowAction(urlOrPath),
|
|
333
|
+
renderMode: route.renderMode,
|
|
334
|
+
beforeGuards: route.beforeGuards,
|
|
335
|
+
afterGuards: route.afterGuards
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
/** 获取所有已注册的路由 */
|
|
342
|
+
getRoutes() {
|
|
343
|
+
return this.routes.map((r) => `${r.pattern} → ${r.intentId}`);
|
|
344
|
+
}
|
|
345
|
+
extractPath(url) {
|
|
346
|
+
try {
|
|
347
|
+
return new URL(url, "http://localhost").pathname;
|
|
348
|
+
} catch {
|
|
349
|
+
return url.split("?")[0].split("#")[0];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
extractQueryParams(url) {
|
|
353
|
+
try {
|
|
354
|
+
const parsed = new URL(url, "http://localhost");
|
|
355
|
+
const params = {};
|
|
356
|
+
parsed.searchParams.forEach((v, k) => {
|
|
357
|
+
params[k] = v;
|
|
358
|
+
});
|
|
359
|
+
return params;
|
|
360
|
+
} catch {
|
|
361
|
+
return {};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
//#endregion
|
|
366
|
+
//#region ../core/src/logger/composite.ts
|
|
367
|
+
var CompositeLoggerFactory = class {
|
|
368
|
+
constructor(factories) {
|
|
369
|
+
this.factories = factories;
|
|
370
|
+
}
|
|
371
|
+
loggerFor(name) {
|
|
372
|
+
return new CompositeLogger(this.factories.map((f) => f.loggerFor(name)));
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
var CompositeLogger = class {
|
|
376
|
+
constructor(loggers) {
|
|
377
|
+
this.loggers = loggers;
|
|
378
|
+
}
|
|
379
|
+
debug(...args) {
|
|
380
|
+
return this.callAll("debug", args);
|
|
381
|
+
}
|
|
382
|
+
info(...args) {
|
|
383
|
+
return this.callAll("info", args);
|
|
384
|
+
}
|
|
385
|
+
warn(...args) {
|
|
386
|
+
return this.callAll("warn", args);
|
|
387
|
+
}
|
|
388
|
+
error(...args) {
|
|
389
|
+
return this.callAll("error", args);
|
|
390
|
+
}
|
|
391
|
+
callAll(method, args) {
|
|
392
|
+
for (const logger of this.loggers) logger[method](...args);
|
|
393
|
+
return "";
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region ../core/src/middleware/pipeline.ts
|
|
398
|
+
/** 执行 beforeLoad 守卫链 */
|
|
399
|
+
async function runBeforeLoadGuards(guards, ctx) {
|
|
400
|
+
for (const guard of guards) {
|
|
401
|
+
const result = await guard(ctx);
|
|
402
|
+
if (result.kind !== "next") return result;
|
|
403
|
+
}
|
|
404
|
+
return { kind: "next" };
|
|
405
|
+
}
|
|
406
|
+
/** 执行 afterLoad 守卫链 */
|
|
407
|
+
async function runAfterLoadGuards(guards, ctx) {
|
|
408
|
+
for (const guard of guards) {
|
|
409
|
+
const result = await guard(ctx);
|
|
410
|
+
if (result.kind !== "next") return result;
|
|
411
|
+
}
|
|
412
|
+
return { kind: "next" };
|
|
413
|
+
}
|
|
414
|
+
//#endregion
|
|
415
|
+
//#region ../core/src/prefetched-intents/stable-stringify.ts
|
|
416
|
+
/**
|
|
417
|
+
* stableStringify — 确定性 JSON 序列化(keys 按字母排序)
|
|
418
|
+
*
|
|
419
|
+
* 用作缓存 key:相同内容的对象始终产生相同字符串。
|
|
420
|
+
*/
|
|
421
|
+
function stableStringify(obj, _seen) {
|
|
422
|
+
if (obj === null || obj === void 0) return String(obj);
|
|
423
|
+
if (typeof obj !== "object") return JSON.stringify(obj);
|
|
424
|
+
const seen = _seen ?? /* @__PURE__ */ new Set();
|
|
425
|
+
if (seen.has(obj)) return "\"[Circular]\"";
|
|
426
|
+
seen.add(obj);
|
|
427
|
+
if (Array.isArray(obj)) return "[" + obj.map((v) => stableStringify(v, seen)).join(",") + "]";
|
|
428
|
+
return "{" + Object.keys(obj).sort().filter((k) => obj[k] !== void 0).map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k], seen)).join(",") + "}";
|
|
429
|
+
}
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region ../core/src/prefetched-intents/prefetched-intents.ts
|
|
432
|
+
var PrefetchedIntents = class PrefetchedIntents {
|
|
433
|
+
intents;
|
|
434
|
+
constructor(intents) {
|
|
435
|
+
this.intents = intents;
|
|
436
|
+
}
|
|
437
|
+
/** 从 PrefetchedIntent 数组创建缓存实例 */
|
|
438
|
+
static fromArray(items) {
|
|
439
|
+
const map = /* @__PURE__ */ new Map();
|
|
440
|
+
for (const item of items) if (item.intent && item.data !== void 0) {
|
|
441
|
+
const key = stableStringify(item.intent);
|
|
442
|
+
map.set(key, item.data);
|
|
443
|
+
}
|
|
444
|
+
return new PrefetchedIntents(map);
|
|
445
|
+
}
|
|
446
|
+
/** 创建空缓存实例 */
|
|
447
|
+
static empty() {
|
|
448
|
+
return new PrefetchedIntents(/* @__PURE__ */ new Map());
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* 获取缓存的 Intent 结果(一次性使用)。
|
|
452
|
+
* 命中后从缓存中删除。
|
|
453
|
+
*/
|
|
454
|
+
get(intent) {
|
|
455
|
+
const key = stableStringify(intent);
|
|
456
|
+
const data = this.intents.get(key);
|
|
457
|
+
if (data !== void 0) {
|
|
458
|
+
this.intents.delete(key);
|
|
459
|
+
return data;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** 检查缓存中是否有某个 Intent 的数据 */
|
|
463
|
+
has(intent) {
|
|
464
|
+
return this.intents.has(stableStringify(intent));
|
|
465
|
+
}
|
|
466
|
+
/** 缓存中的条目数 */
|
|
467
|
+
get size() {
|
|
468
|
+
return this.intents.size;
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region ../core/src/framework.ts
|
|
473
|
+
/**
|
|
474
|
+
* Framework — 框架核心类
|
|
475
|
+
*
|
|
476
|
+
* 对应原版 Jet 类,统一管理: DI 容器、Intent 分发、Action 分发、路由、Metrics。
|
|
477
|
+
* 纯 TypeScript,不依赖任何 UI 框架。
|
|
478
|
+
*/
|
|
479
|
+
var Framework = class Framework {
|
|
480
|
+
container;
|
|
481
|
+
intentDispatcher;
|
|
482
|
+
actionDispatcher;
|
|
483
|
+
router;
|
|
484
|
+
prefetchedIntents;
|
|
485
|
+
beforeGuards = [];
|
|
486
|
+
afterGuards = [];
|
|
487
|
+
constructor(container, prefetchedIntents) {
|
|
488
|
+
this.container = container;
|
|
489
|
+
this.intentDispatcher = new IntentDispatcher();
|
|
490
|
+
this.actionDispatcher = new ActionDispatcher();
|
|
491
|
+
this.router = new Router();
|
|
492
|
+
this.prefetchedIntents = prefetchedIntents;
|
|
493
|
+
}
|
|
494
|
+
/** 创建并初始化 Framework 实例 */
|
|
495
|
+
static create(config = {}) {
|
|
496
|
+
const container = new Container();
|
|
497
|
+
makeDependencies(container, config);
|
|
498
|
+
const fw = new Framework(container, config.prefetchedIntents ?? PrefetchedIntents.empty());
|
|
499
|
+
config.setupRoutes?.(fw.router);
|
|
500
|
+
return fw;
|
|
501
|
+
}
|
|
502
|
+
/** 分发 Intent — 获取页面数据 */
|
|
503
|
+
async dispatch(intent) {
|
|
504
|
+
const logger = this.container.resolve(DEP_KEYS.LOGGER);
|
|
505
|
+
const cached = this.prefetchedIntents.get(intent);
|
|
506
|
+
if (cached !== void 0) {
|
|
507
|
+
logger.debug(`[Framework] re-using prefetched intent response for: ${intent.id}`, intent.params);
|
|
508
|
+
return cached;
|
|
509
|
+
}
|
|
510
|
+
logger.debug(`[Framework] dispatch intent: ${intent.id}`, intent.params);
|
|
511
|
+
return this.intentDispatcher.dispatch(intent, this.container);
|
|
512
|
+
}
|
|
513
|
+
/** 执行 Action — 处理用户交互 */
|
|
514
|
+
async perform(action) {
|
|
515
|
+
this.container.resolve(DEP_KEYS.LOGGER).debug(`[Framework] perform action: ${action.kind}`);
|
|
516
|
+
return this.actionDispatcher.perform(action);
|
|
517
|
+
}
|
|
518
|
+
/** 路由 URL — 将 URL 解析为 Intent + Action */
|
|
519
|
+
routeUrl(url) {
|
|
520
|
+
return this.router.resolve(url);
|
|
521
|
+
}
|
|
522
|
+
/** 记录页面访问事件 */
|
|
523
|
+
didEnterPage(page) {
|
|
524
|
+
this.container.resolve(DEP_KEYS.METRICS).recordPageView(page.pageType, {
|
|
525
|
+
pageId: page.id,
|
|
526
|
+
title: page.title
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/** 注册 Action 处理器 */
|
|
530
|
+
onAction(kind, handler) {
|
|
531
|
+
this.actionDispatcher.onAction(kind, handler);
|
|
532
|
+
}
|
|
533
|
+
/** 注册 Intent Controller */
|
|
534
|
+
registerIntent(controller) {
|
|
535
|
+
this.intentDispatcher.register(controller);
|
|
536
|
+
}
|
|
537
|
+
/** 注册 beforeLoad 守卫(路由匹配后、数据加载前) */
|
|
538
|
+
beforeLoad(guard) {
|
|
539
|
+
this.beforeGuards.push(guard);
|
|
540
|
+
}
|
|
541
|
+
/** 注册 afterLoad 守卫(数据加载后、渲染前) */
|
|
542
|
+
afterLoad(guard) {
|
|
543
|
+
this.afterGuards.push(guard);
|
|
544
|
+
}
|
|
545
|
+
/** 执行所有 beforeLoad 守卫(全局 → 路由级) */
|
|
546
|
+
runBeforeLoad(ctx, routeGuards) {
|
|
547
|
+
return runBeforeLoadGuards(routeGuards?.length ? [...this.beforeGuards, ...routeGuards] : this.beforeGuards, ctx);
|
|
548
|
+
}
|
|
549
|
+
/** 执行所有 afterLoad 守卫(全局 → 路由级) */
|
|
550
|
+
runAfterLoad(ctx, routeGuards) {
|
|
551
|
+
return runAfterLoadGuards(routeGuards?.length ? [...this.afterGuards, ...routeGuards] : this.afterGuards, ctx);
|
|
552
|
+
}
|
|
553
|
+
/** 销毁 Framework 实例 */
|
|
554
|
+
dispose() {
|
|
555
|
+
this.container.dispose();
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
//#endregion
|
|
559
|
+
//#region ../core/src/http/client.ts
|
|
560
|
+
/**
|
|
561
|
+
* HttpClient — 通用 HTTP 客户端基类
|
|
562
|
+
*
|
|
563
|
+
* 为 API Client 提供标准化的 HTTP 请求能力。
|
|
564
|
+
* 子类继承后只需关注业务端点定义,不需要重复实现 fetch / JSON 解析 / 错误处理。
|
|
565
|
+
*/
|
|
566
|
+
/** HTTP 请求错误 */
|
|
567
|
+
var HttpError = class extends Error {
|
|
568
|
+
constructor(status, statusText, body) {
|
|
569
|
+
super(`HTTP ${status}: ${statusText}`);
|
|
570
|
+
this.status = status;
|
|
571
|
+
this.statusText = statusText;
|
|
572
|
+
this.body = body;
|
|
573
|
+
this.name = "HttpError";
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
/**
|
|
577
|
+
* 通用 HTTP 客户端基类
|
|
578
|
+
*
|
|
579
|
+
* 使用方式: 创建子类继承 HttpClient,定义业务方法调用 this.get() / this.post() 等。
|
|
580
|
+
*
|
|
581
|
+
* @example
|
|
582
|
+
* ```ts
|
|
583
|
+
* class MyApiClient extends HttpClient {
|
|
584
|
+
* async getUser(id: string) {
|
|
585
|
+
* return this.get<User>(`/users/${id}`);
|
|
586
|
+
* }
|
|
587
|
+
* }
|
|
588
|
+
* ```
|
|
589
|
+
*/
|
|
590
|
+
var HttpClient = class {
|
|
591
|
+
baseUrl;
|
|
592
|
+
defaultHeaders;
|
|
593
|
+
fetchFn;
|
|
594
|
+
constructor(config) {
|
|
595
|
+
this.baseUrl = config.baseUrl;
|
|
596
|
+
this.defaultHeaders = config.defaultHeaders ?? {};
|
|
597
|
+
this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
598
|
+
}
|
|
599
|
+
/** GET 请求,返回解析后的 JSON */
|
|
600
|
+
async get(path, params) {
|
|
601
|
+
return this.request("GET", path, { params });
|
|
602
|
+
}
|
|
603
|
+
/** POST 请求,自动序列化 body 为 JSON */
|
|
604
|
+
async post(path, body, params) {
|
|
605
|
+
return this.request("POST", path, {
|
|
606
|
+
body,
|
|
607
|
+
params
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
/** PUT 请求 */
|
|
611
|
+
async put(path, body, params) {
|
|
612
|
+
return this.request("PUT", path, {
|
|
613
|
+
body,
|
|
614
|
+
params
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
/** DELETE 请求 */
|
|
618
|
+
async del(path, params) {
|
|
619
|
+
return this.request("DELETE", path, { params });
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* 底层请求方法 — 子类可覆写以自定义行为
|
|
623
|
+
*
|
|
624
|
+
* 自动处理:
|
|
625
|
+
* - URL 拼接 (baseUrl + path + params)
|
|
626
|
+
* - 默认 headers 合并
|
|
627
|
+
* - JSON body 序列化
|
|
628
|
+
* - 响应 JSON 解析
|
|
629
|
+
* - 非 2xx 状态码抛出 HttpError
|
|
630
|
+
*/
|
|
631
|
+
async request(method, path, options) {
|
|
632
|
+
const url = this.buildUrl(path, options?.params);
|
|
633
|
+
const headers = {
|
|
634
|
+
...this.defaultHeaders,
|
|
635
|
+
...options?.headers
|
|
636
|
+
};
|
|
637
|
+
const init = {
|
|
638
|
+
method,
|
|
639
|
+
headers
|
|
640
|
+
};
|
|
641
|
+
if (options?.body !== void 0) {
|
|
642
|
+
headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
|
|
643
|
+
init.body = JSON.stringify(options.body);
|
|
644
|
+
}
|
|
645
|
+
const response = await this.fetchFn(url, init);
|
|
646
|
+
if (!response.ok) {
|
|
647
|
+
const body = await response.text().catch(() => void 0);
|
|
648
|
+
throw new HttpError(response.status, response.statusText, body);
|
|
649
|
+
}
|
|
650
|
+
return response.json();
|
|
651
|
+
}
|
|
652
|
+
/** 构建完整 URL — 子类可覆写以自定义 URL 拼接逻辑 */
|
|
653
|
+
buildUrl(path, params) {
|
|
654
|
+
const base = this.baseUrl.endsWith("/") ? this.baseUrl.slice(0, -1) : this.baseUrl;
|
|
655
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
656
|
+
const url = new URL(`${base}${normalizedPath}`, "http://placeholder");
|
|
657
|
+
if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
658
|
+
if (this.baseUrl.startsWith("http")) return url.toString();
|
|
659
|
+
return `${url.pathname}${url.search}`;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region ../core/src/intents/base-controller.ts
|
|
664
|
+
/**
|
|
665
|
+
* 抽象 Controller 基类
|
|
666
|
+
*
|
|
667
|
+
* 统一处理:
|
|
668
|
+
* - 类型安全的参数提取 (TParams)
|
|
669
|
+
* - 返回类型约束 (TResult)
|
|
670
|
+
* - try/catch 错误处理 + 可选 fallback
|
|
671
|
+
*
|
|
672
|
+
* @example
|
|
673
|
+
* ```ts
|
|
674
|
+
* class ProductController extends BaseController<{ productId: string }, ProductPage> {
|
|
675
|
+
* readonly intentId = "product-page";
|
|
676
|
+
*
|
|
677
|
+
* async execute(params: { productId: string }, container: Container) {
|
|
678
|
+
* const api = container.resolve<ApiClient>("api");
|
|
679
|
+
* return api.getProduct(params.productId);
|
|
680
|
+
* }
|
|
681
|
+
*
|
|
682
|
+
* fallback(params: { productId: string }, error: Error) {
|
|
683
|
+
* return getMockProduct(params.productId);
|
|
684
|
+
* }
|
|
685
|
+
* }
|
|
686
|
+
* ```
|
|
687
|
+
*/
|
|
688
|
+
var BaseController = class {
|
|
689
|
+
/**
|
|
690
|
+
* 错误回退 — 子类可选覆写
|
|
691
|
+
*
|
|
692
|
+
* 当 execute() 抛出异常时调用。
|
|
693
|
+
* 默认行为: 重新抛出原始错误。
|
|
694
|
+
*
|
|
695
|
+
* @param params - Intent 参数
|
|
696
|
+
* @param error - execute() 抛出的错误
|
|
697
|
+
* @returns 回退数据
|
|
698
|
+
*/
|
|
699
|
+
fallback(params, error) {
|
|
700
|
+
throw error;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* IntentController.perform() 实现
|
|
704
|
+
*
|
|
705
|
+
* 自动 try/catch → fallback 模式。
|
|
706
|
+
*/
|
|
707
|
+
async perform(intent, container) {
|
|
708
|
+
const params = intent.params ?? {};
|
|
709
|
+
try {
|
|
710
|
+
return await this.execute(params, container);
|
|
711
|
+
} catch (e) {
|
|
712
|
+
return this.fallback(params, e instanceof Error ? e : new Error(String(e)));
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region ../core/src/data/mapper.ts
|
|
718
|
+
function pipe(...mappers) {
|
|
719
|
+
return (input) => mappers.reduce((acc, mapper) => mapper(acc), input);
|
|
720
|
+
}
|
|
721
|
+
function pipeAsync(...mappers) {
|
|
722
|
+
return async (input) => {
|
|
723
|
+
let acc = input;
|
|
724
|
+
for (const mapper of mappers) acc = await mapper(acc);
|
|
725
|
+
return acc;
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* 将一个 Mapper 应用到数组的每个元素
|
|
730
|
+
*/
|
|
731
|
+
function mapEach(mapper) {
|
|
732
|
+
return (items) => items.map(mapper);
|
|
733
|
+
}
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region ../core/src/bootstrap/define-routes.ts
|
|
736
|
+
/**
|
|
737
|
+
* 声明式注册路由和 Controller
|
|
738
|
+
*
|
|
739
|
+
* - 自动去重: 同一 intentId 的 controller 只注册一次
|
|
740
|
+
* - 路由和 controller 在同一个配置数组中,方便检查一致性
|
|
741
|
+
*
|
|
742
|
+
* @example
|
|
743
|
+
* ```ts
|
|
744
|
+
* defineRoutes(framework, [
|
|
745
|
+
* { path: "/", intentId: "home", controller: new HomeController() },
|
|
746
|
+
* { path: "/product/:id", intentId: "product", controller: new ProductController() },
|
|
747
|
+
* { path: "/search", intentId: "search", controller: new SearchController() },
|
|
748
|
+
* { path: "/charts/:type", intentId: "charts", controller: new ChartsController() },
|
|
749
|
+
* { path: "/charts", intentId: "charts" }, // 同 intentId,不需要重复 controller
|
|
750
|
+
* ]);
|
|
751
|
+
* ```
|
|
752
|
+
*/
|
|
753
|
+
function defineRoutes(framework, definitions) {
|
|
754
|
+
const registeredIntents = /* @__PURE__ */ new Set();
|
|
755
|
+
for (const def of definitions) {
|
|
756
|
+
if (def.controller && !registeredIntents.has(def.intentId)) {
|
|
757
|
+
framework.registerIntent(def.controller);
|
|
758
|
+
registeredIntents.add(def.intentId);
|
|
759
|
+
}
|
|
760
|
+
framework.router.add(def.path, def.intentId, {
|
|
761
|
+
renderMode: def.renderMode,
|
|
762
|
+
beforeGuards: def.beforeLoad,
|
|
763
|
+
afterGuards: def.afterLoad
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
//#endregion
|
|
768
|
+
//#region ../core/src/utils/lru-map.ts
|
|
769
|
+
/**
|
|
770
|
+
* LruMap — 固定容量的 LRU 缓存
|
|
771
|
+
*/
|
|
772
|
+
var LruMap = class {
|
|
773
|
+
map = /* @__PURE__ */ new Map();
|
|
774
|
+
capacity;
|
|
775
|
+
constructor(capacity) {
|
|
776
|
+
this.capacity = capacity;
|
|
777
|
+
}
|
|
778
|
+
get(key) {
|
|
779
|
+
const value = this.map.get(key);
|
|
780
|
+
if (value !== void 0) {
|
|
781
|
+
this.map.delete(key);
|
|
782
|
+
this.map.set(key, value);
|
|
783
|
+
}
|
|
784
|
+
return value;
|
|
785
|
+
}
|
|
786
|
+
set(key, value) {
|
|
787
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
788
|
+
else if (this.map.size >= this.capacity) {
|
|
789
|
+
const oldest = this.map.keys().next().value;
|
|
790
|
+
if (oldest !== void 0) this.map.delete(oldest);
|
|
791
|
+
}
|
|
792
|
+
this.map.set(key, value);
|
|
793
|
+
}
|
|
794
|
+
has(key) {
|
|
795
|
+
return this.map.has(key);
|
|
796
|
+
}
|
|
797
|
+
delete(key) {
|
|
798
|
+
return this.map.delete(key);
|
|
799
|
+
}
|
|
800
|
+
get size() {
|
|
801
|
+
return this.map.size;
|
|
802
|
+
}
|
|
803
|
+
clear() {
|
|
804
|
+
this.map.clear();
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
//#endregion
|
|
808
|
+
//#region ../core/src/utils/optional.ts
|
|
809
|
+
function isSome(value) {
|
|
810
|
+
return value !== null && value !== void 0;
|
|
811
|
+
}
|
|
812
|
+
function isNone(value) {
|
|
813
|
+
return value === null || value === void 0;
|
|
814
|
+
}
|
|
815
|
+
//#endregion
|
|
816
|
+
//#region ../core/src/utils/url.ts
|
|
817
|
+
/**
|
|
818
|
+
* URL 工具函数
|
|
819
|
+
*/
|
|
820
|
+
/** 移除 URL scheme (https://, http://) */
|
|
821
|
+
function removeScheme(url) {
|
|
822
|
+
return url.replace(/^https?:\/\//, "");
|
|
823
|
+
}
|
|
824
|
+
/** 移除 URL host 部分,保留路径 */
|
|
825
|
+
function removeHost(url) {
|
|
826
|
+
try {
|
|
827
|
+
const parsed = new URL(url);
|
|
828
|
+
return parsed.pathname + parsed.search + parsed.hash;
|
|
829
|
+
} catch {
|
|
830
|
+
return url;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/** 移除 query 参数 */
|
|
834
|
+
function removeQueryParams(url) {
|
|
835
|
+
return url.split("?")[0];
|
|
836
|
+
}
|
|
837
|
+
/** 获取 URL 的基础路径(无 query、hash) */
|
|
838
|
+
function getBaseUrl(url) {
|
|
839
|
+
return url.split("?")[0].split("#")[0];
|
|
840
|
+
}
|
|
841
|
+
/** 构建 URL(路径 + query 参数) */
|
|
842
|
+
function buildUrl(path, params) {
|
|
843
|
+
if (!params) return path;
|
|
844
|
+
const searchParams = new URLSearchParams();
|
|
845
|
+
for (const [key, value] of Object.entries(params)) if (value !== void 0) searchParams.set(key, value);
|
|
846
|
+
const qs = searchParams.toString();
|
|
847
|
+
return qs ? `${path}?${qs}` : path;
|
|
848
|
+
}
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region ../core/src/utils/uuid.ts
|
|
851
|
+
/**
|
|
852
|
+
* UUID v4 生成器
|
|
853
|
+
*/
|
|
854
|
+
function generateUuid() {
|
|
855
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
|
|
856
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
857
|
+
const r = Math.random() * 16 | 0;
|
|
858
|
+
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
//#endregion
|
|
862
|
+
//#region ../core/src/middleware/context.ts
|
|
863
|
+
function parseCookieString(str) {
|
|
864
|
+
const map = /* @__PURE__ */ new Map();
|
|
865
|
+
if (!str) return map;
|
|
866
|
+
for (const pair of str.split(";")) {
|
|
867
|
+
const idx = pair.indexOf("=");
|
|
868
|
+
if (idx === -1) continue;
|
|
869
|
+
const key = pair.slice(0, idx).trim();
|
|
870
|
+
const val = pair.slice(idx + 1).trim();
|
|
871
|
+
if (key) map.set(key, val);
|
|
872
|
+
}
|
|
873
|
+
return map;
|
|
874
|
+
}
|
|
875
|
+
/** 从 Request 对象构建服务端上下文 */
|
|
876
|
+
function createServerContext(options) {
|
|
877
|
+
const { url, intent, container, request } = options;
|
|
878
|
+
const parsed = new URL(url, "http://localhost");
|
|
879
|
+
const cookies = parseCookieString(request?.headers.get("cookie") ?? "");
|
|
880
|
+
return {
|
|
881
|
+
url,
|
|
882
|
+
path: parsed.pathname,
|
|
883
|
+
params: intent.params ?? {},
|
|
884
|
+
intent,
|
|
885
|
+
isServer: true,
|
|
886
|
+
container,
|
|
887
|
+
getCookie: (name) => cookies.get(name),
|
|
888
|
+
getHeader: (name) => request?.headers.get(name) ?? void 0
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
/** 从 document.cookie 构建浏览器端上下文 */
|
|
892
|
+
function createBrowserContext(options) {
|
|
893
|
+
const { url, intent, container } = options;
|
|
894
|
+
return {
|
|
895
|
+
url,
|
|
896
|
+
path: new URL(url, window.location.origin).pathname,
|
|
897
|
+
params: intent.params ?? {},
|
|
898
|
+
intent,
|
|
899
|
+
isServer: false,
|
|
900
|
+
container,
|
|
901
|
+
getCookie: (name) => parseCookieString(document.cookie).get(name),
|
|
902
|
+
getHeader: () => void 0
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
//#endregion
|
|
906
|
+
//#region ../core/src/middleware/types.ts
|
|
907
|
+
/** 继续执行 */
|
|
908
|
+
function next() {
|
|
909
|
+
return { kind: "next" };
|
|
910
|
+
}
|
|
911
|
+
/** 重定向到新 URL */
|
|
912
|
+
function redirect(url, status = 302) {
|
|
913
|
+
return {
|
|
914
|
+
kind: "redirect",
|
|
915
|
+
url,
|
|
916
|
+
status
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
/** URL 重写(不重新加载数据) */
|
|
920
|
+
function rewrite(url) {
|
|
921
|
+
return {
|
|
922
|
+
kind: "rewrite",
|
|
923
|
+
url
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
/** 拒绝访问 */
|
|
927
|
+
function deny(status = 403, message = "Forbidden") {
|
|
928
|
+
return {
|
|
929
|
+
kind: "deny",
|
|
930
|
+
status,
|
|
931
|
+
message
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
//#endregion
|
|
935
|
+
//#region ../browser/src/action-handlers/external-url-action.ts
|
|
936
|
+
function registerExternalUrlHandler(deps) {
|
|
937
|
+
const { framework, log } = deps;
|
|
938
|
+
framework.onAction(ACTION_KINDS.EXTERNAL_URL, (action) => {
|
|
939
|
+
log.debug(`ExternalUrlAction → ${action.url}`);
|
|
940
|
+
window.open(action.url, "_blank", "noopener,noreferrer");
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
//#endregion
|
|
944
|
+
//#region ../browser/src/utils/try-scroll.ts
|
|
945
|
+
const MAX_TRIES = 100;
|
|
946
|
+
const FUDGE = 16;
|
|
947
|
+
let pendingFrame = null;
|
|
948
|
+
function tryScroll(log, getScrollableElement, scrollY) {
|
|
949
|
+
if (pendingFrame !== null) {
|
|
950
|
+
cancelAnimationFrame(pendingFrame);
|
|
951
|
+
pendingFrame = null;
|
|
952
|
+
}
|
|
953
|
+
let tries = 0;
|
|
954
|
+
pendingFrame = requestAnimationFrame(function attempt() {
|
|
955
|
+
if (++tries >= MAX_TRIES) {
|
|
956
|
+
log.warn(`tryScroll: gave up after ${MAX_TRIES} frames, target=${scrollY}`);
|
|
957
|
+
pendingFrame = null;
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const el = getScrollableElement();
|
|
961
|
+
if (!el) {
|
|
962
|
+
log.warn("could not restore scroll: the scrollable element is missing");
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const { scrollHeight, offsetHeight } = el;
|
|
966
|
+
if (!(scrollY + offsetHeight <= scrollHeight + FUDGE)) {
|
|
967
|
+
log.info("page is not tall enough for scroll yet", {
|
|
968
|
+
scrollHeight,
|
|
969
|
+
offsetHeight
|
|
970
|
+
});
|
|
971
|
+
pendingFrame = requestAnimationFrame(attempt);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
el.scrollTop = scrollY;
|
|
975
|
+
log.info("scroll restored to", scrollY);
|
|
976
|
+
pendingFrame = null;
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
//#endregion
|
|
980
|
+
//#region ../browser/src/utils/history.ts
|
|
981
|
+
const HISTORY_SIZE_LIMIT = 10;
|
|
982
|
+
var History = class {
|
|
983
|
+
entries;
|
|
984
|
+
log;
|
|
985
|
+
getScrollablePageElement;
|
|
986
|
+
currentStateId;
|
|
987
|
+
constructor(log, options, sizeLimit = HISTORY_SIZE_LIMIT) {
|
|
988
|
+
this.entries = new LruMap(sizeLimit);
|
|
989
|
+
this.log = log;
|
|
990
|
+
this.getScrollablePageElement = options.getScrollablePageElement;
|
|
991
|
+
}
|
|
992
|
+
replaceState(state, url) {
|
|
993
|
+
const id = generateUuid();
|
|
994
|
+
window.history.replaceState({ id }, "", url);
|
|
995
|
+
this.currentStateId = id;
|
|
996
|
+
this.entries.set(id, {
|
|
997
|
+
state,
|
|
998
|
+
scrollY: 0
|
|
999
|
+
});
|
|
1000
|
+
this.scrollTop = 0;
|
|
1001
|
+
this.log.info("replaceState", state, url, id);
|
|
1002
|
+
}
|
|
1003
|
+
pushState(state, url) {
|
|
1004
|
+
const id = generateUuid();
|
|
1005
|
+
window.history.pushState({ id }, "", url);
|
|
1006
|
+
this.currentStateId = id;
|
|
1007
|
+
this.entries.set(id, {
|
|
1008
|
+
state,
|
|
1009
|
+
scrollY: 0
|
|
1010
|
+
});
|
|
1011
|
+
this.scrollTop = 0;
|
|
1012
|
+
this.log.info("pushState", state, url, id);
|
|
1013
|
+
}
|
|
1014
|
+
beforeTransition() {
|
|
1015
|
+
const { state } = window.history;
|
|
1016
|
+
if (!state) return;
|
|
1017
|
+
const oldEntry = this.entries.get(state.id);
|
|
1018
|
+
if (!oldEntry) {
|
|
1019
|
+
this.log.info("current history state evicted from LRU, not saving scroll position");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const { scrollTop } = this;
|
|
1023
|
+
this.entries.set(state.id, {
|
|
1024
|
+
...oldEntry,
|
|
1025
|
+
scrollY: scrollTop
|
|
1026
|
+
});
|
|
1027
|
+
this.log.info("saving scroll position", scrollTop);
|
|
1028
|
+
}
|
|
1029
|
+
onPopState(listener) {
|
|
1030
|
+
window.addEventListener("popstate", async (event) => {
|
|
1031
|
+
this.currentStateId = event.state?.id;
|
|
1032
|
+
if (!this.currentStateId) this.log.warn("encountered a null event.state.id in onPopState event:", window.location.href);
|
|
1033
|
+
this.log.info("popstate", this.entries, this.currentStateId);
|
|
1034
|
+
const entry = this.currentStateId ? this.entries.get(this.currentStateId) : void 0;
|
|
1035
|
+
await listener(window.location.href, entry?.state);
|
|
1036
|
+
if (!entry) return;
|
|
1037
|
+
const { scrollY } = entry;
|
|
1038
|
+
this.log.info("restoring scroll to", scrollY);
|
|
1039
|
+
tryScroll(this.log, () => this.getScrollablePageElement(), scrollY);
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
/** 仅推入 URL,不缓存页面状态(用于页面加载失败场景) */
|
|
1043
|
+
pushUrl(url) {
|
|
1044
|
+
const id = generateUuid();
|
|
1045
|
+
window.history.pushState({ id }, "", url);
|
|
1046
|
+
this.currentStateId = id;
|
|
1047
|
+
this.scrollTop = 0;
|
|
1048
|
+
this.log.info("pushUrl (no state)", url, id);
|
|
1049
|
+
}
|
|
1050
|
+
/** 仅替换 URL,不缓存页面状态(用于页面加载失败场景) */
|
|
1051
|
+
replaceUrl(url) {
|
|
1052
|
+
const id = generateUuid();
|
|
1053
|
+
window.history.replaceState({ id }, "", url);
|
|
1054
|
+
this.currentStateId = id;
|
|
1055
|
+
this.scrollTop = 0;
|
|
1056
|
+
this.log.info("replaceUrl (no state)", url, id);
|
|
1057
|
+
}
|
|
1058
|
+
updateState(update) {
|
|
1059
|
+
if (!this.currentStateId) {
|
|
1060
|
+
this.log.warn("failed: encountered a null currentStateId inside updateState");
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const currentState = this.entries.get(this.currentStateId);
|
|
1064
|
+
const newState = update(currentState?.state);
|
|
1065
|
+
this.log.info("updateState", newState, this.currentStateId);
|
|
1066
|
+
this.entries.set(this.currentStateId, {
|
|
1067
|
+
...currentState,
|
|
1068
|
+
state: newState
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
get scrollTop() {
|
|
1072
|
+
return this.getScrollablePageElement()?.scrollTop || 0;
|
|
1073
|
+
}
|
|
1074
|
+
set scrollTop(scrollTop) {
|
|
1075
|
+
const element = this.getScrollablePageElement();
|
|
1076
|
+
if (element) element.scrollTop = scrollTop;
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
//#endregion
|
|
1080
|
+
//#region ../browser/src/action-handlers/flow-action.ts
|
|
1081
|
+
function registerFlowActionHandler(deps) {
|
|
1082
|
+
const { framework, log, callbacks, updateApp } = deps;
|
|
1083
|
+
let isFirstPage = true;
|
|
1084
|
+
let navigationId = 0;
|
|
1085
|
+
/** 重定向循环保护计数器 */
|
|
1086
|
+
const MAX_REDIRECTS = 5;
|
|
1087
|
+
const defaultGetScrollable = () => document.getElementById("scrollable-page-override") || document.getElementById("scrollable-page") || document.documentElement;
|
|
1088
|
+
const history = new History(log, { getScrollablePageElement: deps.getScrollablePageElement ?? defaultGetScrollable });
|
|
1089
|
+
/**
|
|
1090
|
+
* 核心导航逻辑(支持递归重定向)
|
|
1091
|
+
* @param redirectCount 当前重定向次数,用于循环保护
|
|
1092
|
+
*/
|
|
1093
|
+
async function navigateTo(url, redirectCount, thisNav) {
|
|
1094
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
1095
|
+
log.error(`Navigation redirect loop detected (${MAX_REDIRECTS} redirects), stopping at: ${url}`);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const shouldReplace = isFirstPage || url === window.location.pathname + window.location.search;
|
|
1099
|
+
const match = framework.routeUrl(url);
|
|
1100
|
+
if (!match) {
|
|
1101
|
+
log.warn(`FlowAction: no route for ${url}`);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const navCtx = createBrowserContext({
|
|
1105
|
+
url,
|
|
1106
|
+
intent: match.intent,
|
|
1107
|
+
container: framework.container
|
|
1108
|
+
});
|
|
1109
|
+
const beforeResult = await framework.runBeforeLoad(navCtx, match.beforeGuards);
|
|
1110
|
+
if (beforeResult.kind === "redirect") {
|
|
1111
|
+
log.debug(`beforeLoad → redirect to ${beforeResult.url}`);
|
|
1112
|
+
await navigateTo(beforeResult.url, redirectCount + 1, thisNav);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (beforeResult.kind === "deny") {
|
|
1116
|
+
log.warn(`beforeLoad → denied (${beforeResult.status}): ${beforeResult.message}`);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
if (beforeResult.kind === "rewrite") {
|
|
1120
|
+
log.debug(`beforeLoad → rewrite to ${beforeResult.url}`);
|
|
1121
|
+
await navigateTo(beforeResult.url, redirectCount + 1, thisNav);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const pagePromise = framework.dispatch(match.intent);
|
|
1125
|
+
await Promise.race([pagePromise, new Promise((r) => setTimeout(r, 500))]).catch(() => {});
|
|
1126
|
+
if (thisNav !== navigationId) {
|
|
1127
|
+
log.info("FlowAction superseded by newer navigation", url);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
history.beforeTransition();
|
|
1131
|
+
updateApp({
|
|
1132
|
+
page: pagePromise.then(async (page) => {
|
|
1133
|
+
if (thisNav !== navigationId) {
|
|
1134
|
+
log.info("FlowAction commit superseded", url);
|
|
1135
|
+
return page;
|
|
1136
|
+
}
|
|
1137
|
+
const postCtx = {
|
|
1138
|
+
...navCtx,
|
|
1139
|
+
page
|
|
1140
|
+
};
|
|
1141
|
+
const afterResult = await framework.runAfterLoad(postCtx, match.afterGuards);
|
|
1142
|
+
if (afterResult.kind === "redirect") {
|
|
1143
|
+
log.debug(`afterLoad → redirect to ${afterResult.url}`);
|
|
1144
|
+
navigateTo(afterResult.url, redirectCount + 1, thisNav);
|
|
1145
|
+
return page;
|
|
1146
|
+
}
|
|
1147
|
+
let canonicalURL = url;
|
|
1148
|
+
if (afterResult.kind === "rewrite") {
|
|
1149
|
+
canonicalURL = afterResult.url;
|
|
1150
|
+
log.debug(`afterLoad → rewrite URL to ${canonicalURL}`);
|
|
1151
|
+
}
|
|
1152
|
+
if (afterResult.kind === "deny") {
|
|
1153
|
+
log.warn(`afterLoad → denied (${afterResult.status})`);
|
|
1154
|
+
return page;
|
|
1155
|
+
}
|
|
1156
|
+
if (shouldReplace) history.replaceState({ page }, canonicalURL);
|
|
1157
|
+
else history.pushState({ page }, canonicalURL);
|
|
1158
|
+
callbacks.onNavigate(new URL(canonicalURL, window.location.origin).pathname);
|
|
1159
|
+
didEnterPage(page);
|
|
1160
|
+
return page;
|
|
1161
|
+
}, (error) => {
|
|
1162
|
+
if (thisNav === navigationId) {
|
|
1163
|
+
const canonicalURL = url;
|
|
1164
|
+
if (shouldReplace) history.replaceUrl(canonicalURL);
|
|
1165
|
+
else history.pushUrl(canonicalURL);
|
|
1166
|
+
callbacks.onNavigate(new URL(canonicalURL, window.location.origin).pathname);
|
|
1167
|
+
}
|
|
1168
|
+
throw error;
|
|
1169
|
+
}),
|
|
1170
|
+
isFirstPage
|
|
1171
|
+
});
|
|
1172
|
+
isFirstPage = false;
|
|
1173
|
+
}
|
|
1174
|
+
framework.onAction(ACTION_KINDS.FLOW, async (action) => {
|
|
1175
|
+
const flowAction = action;
|
|
1176
|
+
const url = flowAction.url;
|
|
1177
|
+
log.debug(`FlowAction → ${url}`);
|
|
1178
|
+
if (flowAction.presentationContext === "modal") {
|
|
1179
|
+
const match = framework.routeUrl(url);
|
|
1180
|
+
if (match) {
|
|
1181
|
+
const page = await framework.dispatch(match.intent);
|
|
1182
|
+
callbacks.onModal(page);
|
|
1183
|
+
}
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
await navigateTo(url, 0, ++navigationId);
|
|
1187
|
+
});
|
|
1188
|
+
history.onPopState(async (url, cachedState) => {
|
|
1189
|
+
log.debug(`popstate → ${url}, cached=${!!cachedState}`);
|
|
1190
|
+
callbacks.onNavigate(new URL(url).pathname);
|
|
1191
|
+
if (cachedState) {
|
|
1192
|
+
const { page } = cachedState;
|
|
1193
|
+
didEnterPage(page);
|
|
1194
|
+
updateApp({
|
|
1195
|
+
page,
|
|
1196
|
+
isFirstPage
|
|
1197
|
+
});
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const parsed = new URL(url);
|
|
1201
|
+
const routeMatch = framework.routeUrl(parsed.pathname + parsed.search);
|
|
1202
|
+
if (!routeMatch) {
|
|
1203
|
+
log.error("received popstate without data, but URL was unroutable:", url);
|
|
1204
|
+
didEnterPage(null);
|
|
1205
|
+
updateApp({
|
|
1206
|
+
page: Promise.reject(/* @__PURE__ */ new Error("404")),
|
|
1207
|
+
isFirstPage
|
|
1208
|
+
});
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
const navCtx = createBrowserContext({
|
|
1212
|
+
url: parsed.pathname + parsed.search,
|
|
1213
|
+
intent: routeMatch.intent,
|
|
1214
|
+
container: framework.container
|
|
1215
|
+
});
|
|
1216
|
+
const beforeResult = await framework.runBeforeLoad(navCtx, routeMatch.beforeGuards);
|
|
1217
|
+
if (beforeResult.kind === "redirect") {
|
|
1218
|
+
log.debug(`popstate beforeLoad → redirect to ${beforeResult.url}`);
|
|
1219
|
+
const thisNav = ++navigationId;
|
|
1220
|
+
await navigateTo(beforeResult.url, 0, thisNav);
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
if (beforeResult.kind === "deny" || beforeResult.kind === "rewrite") {
|
|
1224
|
+
if (beforeResult.kind === "deny") log.warn(`popstate beforeLoad → denied`);
|
|
1225
|
+
else {
|
|
1226
|
+
const thisNav = ++navigationId;
|
|
1227
|
+
await navigateTo(beforeResult.url, 0, thisNav);
|
|
1228
|
+
}
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const pagePromise = framework.dispatch(routeMatch.intent);
|
|
1232
|
+
await Promise.race([pagePromise, new Promise((r) => setTimeout(r, 500))]).catch(() => {});
|
|
1233
|
+
updateApp({
|
|
1234
|
+
page: pagePromise.then((page) => {
|
|
1235
|
+
didEnterPage(page);
|
|
1236
|
+
return page;
|
|
1237
|
+
}),
|
|
1238
|
+
isFirstPage
|
|
1239
|
+
});
|
|
1240
|
+
});
|
|
1241
|
+
function didEnterPage(page) {
|
|
1242
|
+
(async () => {
|
|
1243
|
+
try {
|
|
1244
|
+
if (page) framework.didEnterPage(page);
|
|
1245
|
+
} catch (e) {
|
|
1246
|
+
log.error("didEnterPage error:", e);
|
|
1247
|
+
}
|
|
1248
|
+
})();
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
//#endregion
|
|
1252
|
+
//#region ../browser/src/action-handlers/register.ts
|
|
1253
|
+
function registerActionHandlers(deps) {
|
|
1254
|
+
const { framework, log, callbacks, updateApp } = deps;
|
|
1255
|
+
registerFlowActionHandler({
|
|
1256
|
+
framework,
|
|
1257
|
+
log,
|
|
1258
|
+
callbacks,
|
|
1259
|
+
updateApp,
|
|
1260
|
+
getScrollablePageElement: deps.getScrollablePageElement
|
|
1261
|
+
});
|
|
1262
|
+
registerExternalUrlHandler({
|
|
1263
|
+
framework,
|
|
1264
|
+
log
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
//#endregion
|
|
1268
|
+
//#region ../browser/src/server-data.ts
|
|
1269
|
+
/**
|
|
1270
|
+
* Server Data (browser side) — 从 DOM 反序列化服务端嵌入数据
|
|
1271
|
+
*/
|
|
1272
|
+
/** DOM 中嵌入数据的 script 标签 ID */
|
|
1273
|
+
const SERVER_DATA_ID = "serialized-server-data";
|
|
1274
|
+
/**
|
|
1275
|
+
* 从 DOM 反序列化服务端嵌入的数据。
|
|
1276
|
+
* 读取 `<script id="serialized-server-data">` 的内容并移除标签。
|
|
1277
|
+
*/
|
|
1278
|
+
function deserializeServerData() {
|
|
1279
|
+
const script = document.getElementById(SERVER_DATA_ID);
|
|
1280
|
+
if (!script?.textContent) return void 0;
|
|
1281
|
+
script.parentNode?.removeChild(script);
|
|
1282
|
+
try {
|
|
1283
|
+
return JSON.parse(script.textContent);
|
|
1284
|
+
} catch {
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* 从 DOM 提取 SSR 数据并构建 PrefetchedIntents 实例。
|
|
1290
|
+
* 替代原来的 PrefetchedIntents.fromDom()。
|
|
1291
|
+
*/
|
|
1292
|
+
function createPrefetchedIntentsFromDom() {
|
|
1293
|
+
const data = deserializeServerData();
|
|
1294
|
+
if (!data || !Array.isArray(data)) return PrefetchedIntents.empty();
|
|
1295
|
+
return PrefetchedIntents.fromArray(data);
|
|
1296
|
+
}
|
|
1297
|
+
//#endregion
|
|
1298
|
+
//#region ../browser/src/start-app.ts
|
|
1299
|
+
/**
|
|
1300
|
+
* 启动客户端应用
|
|
1301
|
+
*
|
|
1302
|
+
* 自动执行 hydration 全流程。
|
|
1303
|
+
*/
|
|
1304
|
+
async function startBrowserApp(config) {
|
|
1305
|
+
const { bootstrap, defaultLocale = "en", mountId = "app", mount, callbacks } = config;
|
|
1306
|
+
const prefetchedIntents = createPrefetchedIntentsFromDom();
|
|
1307
|
+
const framework = Framework.create({ prefetchedIntents });
|
|
1308
|
+
bootstrap(framework);
|
|
1309
|
+
const log = framework.container.resolve(DEP_KEYS.LOGGER_FACTORY).loggerFor("browser");
|
|
1310
|
+
const initialAction = framework.routeUrl(window.location.pathname + window.location.search);
|
|
1311
|
+
const locale = document.documentElement.lang || defaultLocale;
|
|
1312
|
+
const updateApp = mount(document.getElementById(mountId), {
|
|
1313
|
+
framework,
|
|
1314
|
+
locale
|
|
1315
|
+
});
|
|
1316
|
+
registerActionHandlers({
|
|
1317
|
+
framework,
|
|
1318
|
+
log,
|
|
1319
|
+
callbacks,
|
|
1320
|
+
updateApp,
|
|
1321
|
+
getScrollablePageElement: config.getScrollablePageElement
|
|
1322
|
+
});
|
|
1323
|
+
if (initialAction) await framework.perform(initialAction.action);
|
|
1324
|
+
else updateApp({
|
|
1325
|
+
page: Promise.reject(/* @__PURE__ */ new Error("404")),
|
|
1326
|
+
isFirstPage: true
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
//#endregion
|
|
1330
|
+
//#region ../ssr/src/render.ts
|
|
1331
|
+
/**
|
|
1332
|
+
* ssrRender — 通用 SSR 渲染管线
|
|
1333
|
+
*
|
|
1334
|
+
* 1. 创建 Framework + 注册 Controllers
|
|
1335
|
+
* 2. routeUrl → Intent
|
|
1336
|
+
* 3. dispatch → Page 数据
|
|
1337
|
+
* 4. 调用应用层提供的渲染函数
|
|
1338
|
+
*/
|
|
1339
|
+
async function ssrRender(options) {
|
|
1340
|
+
const { url, frameworkConfig, bootstrap, getErrorPage, renderApp, ssrContext } = options;
|
|
1341
|
+
const mergedConfig = ssrContext?.fetch ? {
|
|
1342
|
+
...frameworkConfig,
|
|
1343
|
+
fetch: ssrContext.fetch
|
|
1344
|
+
} : frameworkConfig;
|
|
1345
|
+
const framework = Framework.create(mergedConfig);
|
|
1346
|
+
bootstrap(framework);
|
|
1347
|
+
try {
|
|
1348
|
+
const parsed = new URL(url, "http://localhost");
|
|
1349
|
+
const fullPath = parsed.pathname + parsed.search;
|
|
1350
|
+
const match = framework.routeUrl(fullPath);
|
|
1351
|
+
if (match?.renderMode === "csr") return {
|
|
1352
|
+
html: "",
|
|
1353
|
+
head: "",
|
|
1354
|
+
css: "",
|
|
1355
|
+
serverData: [],
|
|
1356
|
+
renderMode: "csr"
|
|
1357
|
+
};
|
|
1358
|
+
let page;
|
|
1359
|
+
let serverData = [];
|
|
1360
|
+
if (match) {
|
|
1361
|
+
const navCtx = createServerContext({
|
|
1362
|
+
url: fullPath,
|
|
1363
|
+
intent: match.intent,
|
|
1364
|
+
container: framework.container,
|
|
1365
|
+
request: ssrContext?.request
|
|
1366
|
+
});
|
|
1367
|
+
const beforeResult = await framework.runBeforeLoad(navCtx, match.beforeGuards);
|
|
1368
|
+
if (beforeResult.kind !== "next") {
|
|
1369
|
+
const earlyReturn = handleMiddlewareResult(beforeResult, getErrorPage, renderApp, framework);
|
|
1370
|
+
if (earlyReturn) return earlyReturn;
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
page = await framework.dispatch(match.intent);
|
|
1374
|
+
serverData = [{
|
|
1375
|
+
intent: match.intent,
|
|
1376
|
+
data: page
|
|
1377
|
+
}];
|
|
1378
|
+
} catch (e) {
|
|
1379
|
+
console.error(`[SSR] dispatch failed for intent "${match.intent.id}":`, e);
|
|
1380
|
+
page = getErrorPage(500, "Internal error");
|
|
1381
|
+
}
|
|
1382
|
+
const postCtx = {
|
|
1383
|
+
...navCtx,
|
|
1384
|
+
page
|
|
1385
|
+
};
|
|
1386
|
+
const afterResult = await framework.runAfterLoad(postCtx, match.afterGuards);
|
|
1387
|
+
if (afterResult.kind !== "next") {
|
|
1388
|
+
const lateReturn = handleMiddlewareResult(afterResult, getErrorPage, renderApp, framework);
|
|
1389
|
+
if (lateReturn) return lateReturn;
|
|
1390
|
+
}
|
|
1391
|
+
} else page = getErrorPage(404, "Page not found");
|
|
1392
|
+
const result = renderApp(page, framework);
|
|
1393
|
+
return {
|
|
1394
|
+
html: result.html,
|
|
1395
|
+
head: result.head,
|
|
1396
|
+
css: result.css,
|
|
1397
|
+
serverData,
|
|
1398
|
+
renderMode: match?.renderMode
|
|
1399
|
+
};
|
|
1400
|
+
} finally {
|
|
1401
|
+
framework.dispose();
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* 将中间件结果转换为 SSRRenderResult(如果需要短路返回)。
|
|
1406
|
+
* 返回 null 表示继续正常流程。
|
|
1407
|
+
*/
|
|
1408
|
+
function handleMiddlewareResult(result, getErrorPage, renderApp, framework) {
|
|
1409
|
+
switch (result.kind) {
|
|
1410
|
+
case "next": return null;
|
|
1411
|
+
case "redirect": return {
|
|
1412
|
+
html: "",
|
|
1413
|
+
head: "",
|
|
1414
|
+
css: "",
|
|
1415
|
+
serverData: [],
|
|
1416
|
+
redirect: {
|
|
1417
|
+
url: result.url,
|
|
1418
|
+
status: result.status
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
case "rewrite": return {
|
|
1422
|
+
html: "",
|
|
1423
|
+
head: "",
|
|
1424
|
+
css: "",
|
|
1425
|
+
serverData: [],
|
|
1426
|
+
redirect: {
|
|
1427
|
+
url: result.url,
|
|
1428
|
+
status: 301
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
case "deny": {
|
|
1432
|
+
const rendered = renderApp(getErrorPage(result.status, result.message), framework);
|
|
1433
|
+
return {
|
|
1434
|
+
html: rendered.html,
|
|
1435
|
+
head: rendered.head,
|
|
1436
|
+
css: rendered.css,
|
|
1437
|
+
serverData: []
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
//#endregion
|
|
1443
|
+
//#region ../ssr/src/create-render.ts
|
|
1444
|
+
/**
|
|
1445
|
+
* 创建 render 函数
|
|
1446
|
+
*
|
|
1447
|
+
* @returns `render(url, locale, ssrContext?)` — 供 @finesoft/server SSRModule 使用
|
|
1448
|
+
*/
|
|
1449
|
+
function createSSRRender(config) {
|
|
1450
|
+
const { bootstrap, getErrorPage, renderApp, frameworkConfig } = config;
|
|
1451
|
+
return (url, locale, ssrContext) => ssrRender({
|
|
1452
|
+
url,
|
|
1453
|
+
frameworkConfig: frameworkConfig ?? {},
|
|
1454
|
+
bootstrap,
|
|
1455
|
+
getErrorPage,
|
|
1456
|
+
renderApp: (page) => renderApp(page, locale),
|
|
1457
|
+
ssrContext
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
//#endregion
|
|
1461
|
+
//#region ../ssr/src/server-data.ts
|
|
1462
|
+
const HTML_REPLACEMENTS = {
|
|
1463
|
+
"<": "\\u003C",
|
|
1464
|
+
">": "\\u003E",
|
|
1465
|
+
"/": "\\u002F",
|
|
1466
|
+
"\u2028": "\\u2028",
|
|
1467
|
+
"\u2029": "\\u2029"
|
|
1468
|
+
};
|
|
1469
|
+
const HTML_ESCAPE_PATTERN = /[<>/\u2028\u2029]/g;
|
|
1470
|
+
function serializeServerData(data) {
|
|
1471
|
+
return JSON.stringify(data).replace(HTML_ESCAPE_PATTERN, (match) => HTML_REPLACEMENTS[match] ?? match);
|
|
1472
|
+
}
|
|
1473
|
+
//#endregion
|
|
1474
|
+
//#region ../server/src/proxy.ts
|
|
1475
|
+
/**
|
|
1476
|
+
* 校验代理路径,防止 SSRF(协议相对 URL 绕过)。
|
|
1477
|
+
* 返回规范化的路径,或 null 表示非法。
|
|
1478
|
+
*/
|
|
1479
|
+
function sanitizeProxyPath(raw) {
|
|
1480
|
+
if (raw.startsWith("//")) return null;
|
|
1481
|
+
return raw.startsWith("/") ? raw : `/${raw}`;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* 校验代理配置合法性。
|
|
1485
|
+
* 在注册时(启动阶段)调用,非法配置直接抛错阻止启动。
|
|
1486
|
+
*/
|
|
1487
|
+
function validateConfig(config) {
|
|
1488
|
+
if (!config.prefix.startsWith("/")) throw new Error(`[proxy] prefix must start with "/": "${config.prefix}"`);
|
|
1489
|
+
if (!config.target.startsWith("https://")) throw new Error(`[proxy] target must use HTTPS: "${config.target}"`);
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* 注册声明式代理路由到 Hono app(运行时使用:dev / preview / createServer)
|
|
1493
|
+
*/
|
|
1494
|
+
function registerProxyRoutes(app, configs) {
|
|
1495
|
+
for (const config of configs) {
|
|
1496
|
+
validateConfig(config);
|
|
1497
|
+
const methods = config.methods ?? ["all"];
|
|
1498
|
+
const pattern = `${config.prefix}/*`;
|
|
1499
|
+
const handler = async (c) => {
|
|
1500
|
+
const subPath = sanitizeProxyPath(c.req.path.replace(config.prefix, ""));
|
|
1501
|
+
if (!subPath) return c.text("Invalid path", 400);
|
|
1502
|
+
const targetUrl = new URL(subPath, config.target);
|
|
1503
|
+
new URL(c.req.url).searchParams.forEach((v, k) => targetUrl.searchParams.set(k, v));
|
|
1504
|
+
const headers = { ...config.headers };
|
|
1505
|
+
if (config.auth) {
|
|
1506
|
+
const token = process.env[config.auth.envKey] ?? "";
|
|
1507
|
+
if (token) headers.Authorization = config.auth.type === "bearer" ? `Bearer ${token}` : `Basic ${token}`;
|
|
1508
|
+
}
|
|
1509
|
+
try {
|
|
1510
|
+
const resp = await fetch(targetUrl.toString(), {
|
|
1511
|
+
headers,
|
|
1512
|
+
redirect: config.followRedirects ? "follow" : "manual"
|
|
1513
|
+
});
|
|
1514
|
+
const body = await resp.text();
|
|
1515
|
+
const respHeaders = { "Content-Type": resp.headers.get("Content-Type") ?? "application/json" };
|
|
1516
|
+
if (config.cache) respHeaders["Cache-Control"] = config.cache;
|
|
1517
|
+
return c.newResponse(body, resp.status, respHeaders);
|
|
1518
|
+
} catch (e) {
|
|
1519
|
+
console.error(`[Proxy ${config.prefix}]`, e);
|
|
1520
|
+
return c.json({ error: "Proxy request failed" }, 502);
|
|
1521
|
+
}
|
|
1522
|
+
};
|
|
1523
|
+
for (const method of methods) app[method](pattern, handler);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* 生成代理路由的内联代码(用于 serverless/edge 入口,避免运行时依赖)
|
|
1528
|
+
*/
|
|
1529
|
+
function generateProxyCode(configs) {
|
|
1530
|
+
if (!configs || configs.length === 0) return "";
|
|
1531
|
+
for (const config of configs) validateConfig(config);
|
|
1532
|
+
const blocks = [];
|
|
1533
|
+
blocks.push(`
|
|
1534
|
+
// ─── 框架声明式代理路由 ───
|
|
1535
|
+
function _sanitizeProxyPath(raw) {
|
|
1536
|
+
if (raw.startsWith("//")) return null;
|
|
1537
|
+
return raw.startsWith("/") ? raw : "/" + raw;
|
|
1538
|
+
}
|
|
1539
|
+
`);
|
|
1540
|
+
for (const config of configs) {
|
|
1541
|
+
const methods = config.methods ?? ["all"];
|
|
1542
|
+
const pattern = `"${config.prefix}/*"`;
|
|
1543
|
+
const headersJson = JSON.stringify(config.headers ?? {});
|
|
1544
|
+
const cacheStr = config.cache ? JSON.stringify(config.cache) : "null";
|
|
1545
|
+
const redirect = config.followRedirects ? "\"follow\"" : "\"manual\"";
|
|
1546
|
+
let authCode = "";
|
|
1547
|
+
if (config.auth) authCode = `
|
|
1548
|
+
const _token = (typeof process !== "undefined" && process.env && process.env[${JSON.stringify(config.auth.envKey)}]) || "";
|
|
1549
|
+
if (_token) _headers.Authorization = "${config.auth.type === "bearer" ? "Bearer " : "Basic "}" + _token;`;
|
|
1550
|
+
const handlerCode = `async (c) => {
|
|
1551
|
+
const _sub = _sanitizeProxyPath(c.req.path.replace(${JSON.stringify(config.prefix)}, ""));
|
|
1552
|
+
if (!_sub) return c.text("Invalid path", 400);
|
|
1553
|
+
const _target = new URL(_sub, ${JSON.stringify(config.target)});
|
|
1554
|
+
const _reqUrl = new URL(c.req.url);
|
|
1555
|
+
_reqUrl.searchParams.forEach((v, k) => _target.searchParams.set(k, v));
|
|
1556
|
+
const _headers = ${headersJson};${authCode}
|
|
1557
|
+
try {
|
|
1558
|
+
const _resp = await fetch(_target.toString(), { headers: _headers, redirect: ${redirect} });
|
|
1559
|
+
const _body = await _resp.text();
|
|
1560
|
+
const _rh = { "Content-Type": _resp.headers.get("Content-Type") || "application/json" };
|
|
1561
|
+
if (${cacheStr}) _rh["Cache-Control"] = ${cacheStr};
|
|
1562
|
+
return c.newResponse(_body, _resp.status, _rh);
|
|
1563
|
+
} catch (_e) {
|
|
1564
|
+
console.error("[Proxy ${config.prefix}]", _e);
|
|
1565
|
+
return c.json({ error: "Proxy request failed" }, 502);
|
|
1566
|
+
}
|
|
1567
|
+
}`;
|
|
1568
|
+
for (const method of methods) blocks.push(`app.${method}(${pattern}, ${handlerCode});`);
|
|
1569
|
+
}
|
|
1570
|
+
return blocks.join("\n");
|
|
1571
|
+
}
|
|
1572
|
+
//#endregion
|
|
1573
|
+
//#region ../server/src/adapters/shared.ts
|
|
1574
|
+
/**
|
|
1575
|
+
* 适配器共享工具函数
|
|
1576
|
+
*
|
|
1577
|
+
* 提供 generateSSREntry / buildBundle / copyStaticAssets 三个方法,
|
|
1578
|
+
* 避免各适配器重复实现相同逻辑。
|
|
1579
|
+
*/
|
|
1580
|
+
const BUILD_TOOL_EXTERNALS = [
|
|
1581
|
+
"vite",
|
|
1582
|
+
"esbuild",
|
|
1583
|
+
"rollup",
|
|
1584
|
+
"fsevents",
|
|
1585
|
+
"lightningcss"
|
|
1586
|
+
];
|
|
1587
|
+
/**
|
|
1588
|
+
* 生成 SSR serverless/edge 入口源码
|
|
1589
|
+
*
|
|
1590
|
+
* 内联 parseAcceptLanguage / injectSSR 以避免
|
|
1591
|
+
* @finesoft/front → @finesoft/server → vite-plugin → import("vite") 依赖链。
|
|
1592
|
+
*/
|
|
1593
|
+
function generateSSREntry(ctx, opts) {
|
|
1594
|
+
const setupImport = ctx.setupPath ? `import _setupDefault from "./${ctx.setupPath}";` : ``;
|
|
1595
|
+
const setupCall = ctx.setupPath ? `if (typeof _setupDefault === "function") await _setupDefault(app);` : ``;
|
|
1596
|
+
const locales = JSON.stringify(ctx.locales);
|
|
1597
|
+
const defaultLocale = JSON.stringify(ctx.defaultLocale);
|
|
1598
|
+
const renderModes = JSON.stringify(ctx.renderModes ?? {});
|
|
1599
|
+
const cacheImpl = opts.platformCache ? opts.platformCache : `
|
|
1600
|
+
const ISR_CACHE_MAX = 1000;
|
|
1601
|
+
const _isrMap = new Map();
|
|
1602
|
+
async function platformCacheGet(url) {
|
|
1603
|
+
return _isrMap.get(url) ?? null;
|
|
1604
|
+
}
|
|
1605
|
+
async function platformCacheSet(url, html) {
|
|
1606
|
+
if (_isrMap.size >= ISR_CACHE_MAX) {
|
|
1607
|
+
const first = _isrMap.keys().next().value;
|
|
1608
|
+
_isrMap.delete(first);
|
|
1609
|
+
}
|
|
1610
|
+
_isrMap.set(url, html);
|
|
1611
|
+
}`;
|
|
1612
|
+
return `
|
|
1613
|
+
import { Hono } from "hono";
|
|
1614
|
+
${opts.platformImport}
|
|
1615
|
+
import { render, serializeServerData } from "./${ctx.ssrEntry}";
|
|
1616
|
+
${setupImport}
|
|
1617
|
+
|
|
1618
|
+
const TEMPLATE = ${JSON.stringify(ctx.templateHtml)};
|
|
1619
|
+
const LOCALES = ${locales};
|
|
1620
|
+
const DEFAULT_LOCALE = ${defaultLocale};
|
|
1621
|
+
const RENDER_MODES = ${renderModes};
|
|
1622
|
+
${cacheImpl}
|
|
1623
|
+
|
|
1624
|
+
function parseAcceptLanguage(header) {
|
|
1625
|
+
if (!header) return DEFAULT_LOCALE;
|
|
1626
|
+
const langs = header.split(",").map(p => {
|
|
1627
|
+
const [l, q] = p.trim().split(";q=");
|
|
1628
|
+
return { l: l.trim().toLowerCase(), q: q ? (+q || 0) : 1 };
|
|
1629
|
+
}).sort((a, b) => b.q - a.q);
|
|
1630
|
+
for (const { l } of langs) {
|
|
1631
|
+
const prefix = l.split("-")[0];
|
|
1632
|
+
if (LOCALES.includes(prefix)) return prefix;
|
|
1633
|
+
}
|
|
1634
|
+
return DEFAULT_LOCALE;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function injectSSR(t, locale, head, css, html, data) {
|
|
1638
|
+
return t
|
|
1639
|
+
.replace("<!--ssr-lang-->", locale)
|
|
1640
|
+
.replace("<!--ssr-head-->", head + "\\n<style>" + css + "</style>")
|
|
1641
|
+
.replace("<!--ssr-body-->", html)
|
|
1642
|
+
.replace("<!--ssr-data-->", '<script id="serialized-server-data" type="application/json">' + data + "<\/script>");
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function injectCSRShell(t, locale) {
|
|
1646
|
+
return t
|
|
1647
|
+
.replace("<!--ssr-lang-->", locale)
|
|
1648
|
+
.replace("<!--ssr-head-->", "")
|
|
1649
|
+
.replace("<!--ssr-body-->", "")
|
|
1650
|
+
.replace("<!--ssr-data-->", "");
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
function matchRenderMode(url) {
|
|
1654
|
+
const path = url.split("?")[0];
|
|
1655
|
+
if (RENDER_MODES[path]) return RENDER_MODES[path];
|
|
1656
|
+
for (const [pattern, mode] of Object.entries(RENDER_MODES)) {
|
|
1657
|
+
if (pattern.includes("*")) {
|
|
1658
|
+
const escaped = pattern.replace(/[.+?^\${}()|[\\]\\\\]/g, "\\\\$&");
|
|
1659
|
+
const re = new RegExp("^" + escaped.replace(/\\*/g, ".*") + "$");
|
|
1660
|
+
if (re.test(path)) return mode;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
return null;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const app = new Hono();
|
|
1667
|
+
${generateProxyCode(ctx.proxies ?? [])}
|
|
1668
|
+
${setupCall}
|
|
1669
|
+
${opts.platformMiddleware ?? ""}
|
|
1670
|
+
|
|
1671
|
+
// 内部 fetch 回环:SSR 控制器的 fetch 请求直接走 Hono 内存路由
|
|
1672
|
+
// 深度通过请求头传递,并发安全且能跨渲染正确追踪递归
|
|
1673
|
+
const _SSR_DEPTH_HEADER = "x-ssr-depth";
|
|
1674
|
+
const _MAX_SSR_DEPTH = 5;
|
|
1675
|
+
|
|
1676
|
+
function _createInternalFetch(depth) {
|
|
1677
|
+
return function(input, init) {
|
|
1678
|
+
if (typeof input === "string" && input.startsWith("/")) {
|
|
1679
|
+
const req = new Request("http://localhost" + input, init);
|
|
1680
|
+
req.headers.set(_SSR_DEPTH_HEADER, String(depth));
|
|
1681
|
+
return app.fetch(req);
|
|
1682
|
+
}
|
|
1683
|
+
return globalThis.fetch(input, init);
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
app.get("*", async (c) => {
|
|
1688
|
+
// 递归深度保护:从请求头读取 SSR 深度
|
|
1689
|
+
const _ssrDepth = parseInt(c.req.header(_SSR_DEPTH_HEADER) || "0", 10);
|
|
1690
|
+
if (_ssrDepth >= _MAX_SSR_DEPTH) {
|
|
1691
|
+
return c.text("SSR recursion loop detected", 508);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const url = c.req.path + (c.req.url.includes("?") ? "?" + c.req.url.split("?")[1] : "");
|
|
1695
|
+
try {
|
|
1696
|
+
const locale = parseAcceptLanguage(c.req.header("accept-language"));
|
|
1697
|
+
|
|
1698
|
+
// Vite 配置级别覆盖: CSR 直接返回空壳
|
|
1699
|
+
const overrideMode = matchRenderMode(url);
|
|
1700
|
+
if (overrideMode === "csr") {
|
|
1701
|
+
return c.html(injectCSRShell(TEMPLATE, locale));
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// ISR 缓存命中(key 含 locale,避免跨语言缓存污染)
|
|
1705
|
+
const _cacheKey = locale + ":" + url;
|
|
1706
|
+
const cached = await platformCacheGet(_cacheKey);
|
|
1707
|
+
if (cached) return c.html(cached);
|
|
1708
|
+
|
|
1709
|
+
const { html: appHtml, head, css, serverData, renderMode } = await render(url, locale, { fetch: _createInternalFetch(_ssrDepth + 1) });
|
|
1710
|
+
|
|
1711
|
+
// 路由级 CSR
|
|
1712
|
+
if (renderMode === "csr") {
|
|
1713
|
+
return c.html(injectCSRShell(TEMPLATE, locale));
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
const serializedData = serializeServerData(serverData);
|
|
1717
|
+
const finalHtml = injectSSR(TEMPLATE, locale, head, css, appHtml, serializedData);
|
|
1718
|
+
|
|
1719
|
+
// Prerender ISR 缓存(包括 Vite 配置覆盖和路由级)
|
|
1720
|
+
if (renderMode === "prerender" || overrideMode === "prerender") {
|
|
1721
|
+
await platformCacheSet(_cacheKey, finalHtml);
|
|
1722
|
+
${opts.platformPrerenderResponseHook ?? ""}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
return c.html(finalHtml);
|
|
1726
|
+
} catch (e) {
|
|
1727
|
+
console.error("[SSR Error]", e);
|
|
1728
|
+
return c.text("Internal Server Error", 500);
|
|
1729
|
+
}
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
${opts.platformExport}
|
|
1733
|
+
`;
|
|
1734
|
+
}
|
|
1735
|
+
/** 用 Vite SSR 模式构建 bundle */
|
|
1736
|
+
async function buildBundle(ctx, opts) {
|
|
1737
|
+
await ctx.vite.build({
|
|
1738
|
+
root: ctx.root,
|
|
1739
|
+
build: {
|
|
1740
|
+
ssr: opts.entry,
|
|
1741
|
+
outDir: opts.outDir,
|
|
1742
|
+
emptyOutDir: true,
|
|
1743
|
+
target: opts.target ?? "node18",
|
|
1744
|
+
rollupOptions: { output: { entryFileNames: opts.fileName ?? "index.mjs" } }
|
|
1745
|
+
},
|
|
1746
|
+
ssr: {
|
|
1747
|
+
noExternal: opts.noExternal !== false,
|
|
1748
|
+
external: opts.external ?? BUILD_TOOL_EXTERNALS
|
|
1749
|
+
},
|
|
1750
|
+
resolve: ctx.resolvedResolve,
|
|
1751
|
+
css: ctx.resolvedCss
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
/** 复制 dist/client 静态资源到目标目录 */
|
|
1755
|
+
function copyStaticAssets(ctx, destDir, opts) {
|
|
1756
|
+
const { fs, path } = ctx;
|
|
1757
|
+
fs.cpSync(path.resolve(ctx.root, "dist/client"), destDir, { recursive: true });
|
|
1758
|
+
if (opts?.excludeHtml !== false) fs.rmSync(path.join(destDir, "index.html"), { force: true });
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* 构建时预渲染 prerender 路由。
|
|
1762
|
+
*
|
|
1763
|
+
* 1. 加载路由定义文件,找出 renderMode === "prerender" 的路由
|
|
1764
|
+
* 2. 合并 ctx.renderModes 配置覆盖
|
|
1765
|
+
* 3. 渲染每个 URL × locale
|
|
1766
|
+
*/
|
|
1767
|
+
async function prerenderRoutes(ctx) {
|
|
1768
|
+
const { fs, path, root, vite } = ctx;
|
|
1769
|
+
const { pathToFileURL } = await import(
|
|
1770
|
+
/* @vite-ignore */
|
|
1771
|
+
"node:url"
|
|
1772
|
+
);
|
|
1773
|
+
const routesExport = ctx.bootstrapEntry ?? "src/lib/bootstrap.ts";
|
|
1774
|
+
let routes = [];
|
|
1775
|
+
if (fs.existsSync(path.resolve(root, routesExport))) {
|
|
1776
|
+
await vite.build({
|
|
1777
|
+
root,
|
|
1778
|
+
build: {
|
|
1779
|
+
ssr: routesExport,
|
|
1780
|
+
outDir: path.resolve(root, "dist/server"),
|
|
1781
|
+
emptyOutDir: false,
|
|
1782
|
+
rollupOptions: { output: { entryFileNames: "_routes_prerender.mjs" } }
|
|
1783
|
+
},
|
|
1784
|
+
resolve: ctx.resolvedResolve
|
|
1785
|
+
});
|
|
1786
|
+
const routesMod = await import(pathToFileURL(path.resolve(root, "dist/server/_routes_prerender.mjs")).href);
|
|
1787
|
+
routes = routesMod.routes ?? routesMod.default ?? [];
|
|
1788
|
+
fs.rmSync(path.resolve(root, "dist/server/_routes_prerender.mjs"), { force: true });
|
|
1789
|
+
}
|
|
1790
|
+
const prerenderPaths = /* @__PURE__ */ new Set();
|
|
1791
|
+
for (const r of routes) if (r.renderMode === "prerender" && r.path && !r.path.includes(":")) prerenderPaths.add(r.path);
|
|
1792
|
+
if (ctx.renderModes) {
|
|
1793
|
+
for (const [pattern, mode] of Object.entries(ctx.renderModes)) if (mode === "prerender" && !pattern.includes("*") && !pattern.includes(":")) prerenderPaths.add(pattern);
|
|
1794
|
+
}
|
|
1795
|
+
if (prerenderPaths.size === 0) return [];
|
|
1796
|
+
const ssrModule = await import(pathToFileURL(path.resolve(root, "dist/server/ssr.js")).href);
|
|
1797
|
+
const results = [];
|
|
1798
|
+
for (const routePath of prerenderPaths) for (const locale of ctx.locales) {
|
|
1799
|
+
const url = locale === ctx.defaultLocale ? routePath : `/${locale}${routePath === "/" ? "" : routePath}`;
|
|
1800
|
+
try {
|
|
1801
|
+
const { html: appHtml, head, css, serverData } = await ssrModule.render(url, locale);
|
|
1802
|
+
const serializedData = ssrModule.serializeServerData(serverData);
|
|
1803
|
+
const finalHtml = ctx.templateHtml.replace("<!--ssr-lang-->", locale).replace("<!--ssr-head-->", head + "\n<style>" + css + "</style>").replace("<!--ssr-body-->", appHtml).replace("<!--ssr-data-->", "<script id=\"serialized-server-data\" type=\"application/json\">" + serializedData + "<\/script>");
|
|
1804
|
+
results.push({
|
|
1805
|
+
url,
|
|
1806
|
+
html: finalHtml
|
|
1807
|
+
});
|
|
1808
|
+
} catch (e) {
|
|
1809
|
+
console.warn(` [prerender] Failed to render ${url}:`, e);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
if (results.length > 0) console.log(` Pre-rendered ${results.length} pages (${prerenderPaths.size} routes × ${ctx.locales.length} locales)\n`);
|
|
1813
|
+
return results;
|
|
1814
|
+
}
|
|
1815
|
+
//#endregion
|
|
1816
|
+
//#region ../server/src/adapters/cloudflare.ts
|
|
1817
|
+
/**
|
|
1818
|
+
* Cloudflare Pages 适配器
|
|
1819
|
+
*
|
|
1820
|
+
* 生成 dist/cloudflare/ 目录:
|
|
1821
|
+
* - _worker.js — Workers 入口(Hono 原生支持 CF fetch 接口)
|
|
1822
|
+
* - assets/ — 静态资源
|
|
1823
|
+
*
|
|
1824
|
+
* 注意:Cloudflare Workers 不支持原生 Node.js API。
|
|
1825
|
+
* 若 setup 代理使用了 process.env,需在 wrangler.toml 启用 nodejs_compat。
|
|
1826
|
+
*/
|
|
1827
|
+
function cloudflareAdapter() {
|
|
1828
|
+
return {
|
|
1829
|
+
name: "cloudflare",
|
|
1830
|
+
async build(ctx) {
|
|
1831
|
+
const { fs, path, root } = ctx;
|
|
1832
|
+
const outputDir = path.resolve(root, "dist/cloudflare");
|
|
1833
|
+
fs.rmSync(outputDir, {
|
|
1834
|
+
recursive: true,
|
|
1835
|
+
force: true
|
|
1836
|
+
});
|
|
1837
|
+
const entrySource = generateSSREntry(ctx, {
|
|
1838
|
+
platformImport: ``,
|
|
1839
|
+
platformExport: `export default app;`,
|
|
1840
|
+
platformCache: `
|
|
1841
|
+
const ISR_CACHE_TTL = 3600; // 1 hour
|
|
1842
|
+
async function platformCacheGet(url) {
|
|
1843
|
+
try {
|
|
1844
|
+
const cache = caches.default;
|
|
1845
|
+
const cacheKey = new Request("https://isr-cache/" + encodeURIComponent(url));
|
|
1846
|
+
const resp = await cache.match(cacheKey);
|
|
1847
|
+
if (resp) return await resp.text();
|
|
1848
|
+
} catch {}
|
|
1849
|
+
return null;
|
|
1850
|
+
}
|
|
1851
|
+
async function platformCacheSet(url, html) {
|
|
1852
|
+
try {
|
|
1853
|
+
const cache = caches.default;
|
|
1854
|
+
const cacheKey = new Request("https://isr-cache/" + encodeURIComponent(url));
|
|
1855
|
+
const resp = new Response(html, {
|
|
1856
|
+
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "public, max-age=" + ISR_CACHE_TTL },
|
|
1857
|
+
});
|
|
1858
|
+
await cache.put(cacheKey, resp);
|
|
1859
|
+
} catch {}
|
|
1860
|
+
}`
|
|
1861
|
+
});
|
|
1862
|
+
const tempEntry = path.resolve(root, ".cf-entry.tmp.mjs");
|
|
1863
|
+
fs.writeFileSync(tempEntry, entrySource);
|
|
1864
|
+
try {
|
|
1865
|
+
await buildBundle(ctx, {
|
|
1866
|
+
entry: ".cf-entry.tmp.mjs",
|
|
1867
|
+
outDir: outputDir,
|
|
1868
|
+
target: "es2022",
|
|
1869
|
+
fileName: "_worker.js"
|
|
1870
|
+
});
|
|
1871
|
+
copyStaticAssets(ctx, path.resolve(outputDir, "assets"));
|
|
1872
|
+
const prerendered = await prerenderRoutes(ctx);
|
|
1873
|
+
for (const { url, html } of prerendered) {
|
|
1874
|
+
const filePath = url === "/" ? path.join(outputDir, "assets", "index.html") : path.join(outputDir, "assets", url, "index.html");
|
|
1875
|
+
fs.mkdirSync(path.resolve(filePath, ".."), { recursive: true });
|
|
1876
|
+
fs.writeFileSync(filePath, html);
|
|
1877
|
+
}
|
|
1878
|
+
} finally {
|
|
1879
|
+
fs.rmSync(tempEntry, { force: true });
|
|
1880
|
+
}
|
|
1881
|
+
console.log(" Cloudflare output → dist/cloudflare/\n");
|
|
1882
|
+
}
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
//#endregion
|
|
1886
|
+
//#region ../server/src/adapters/netlify.ts
|
|
1887
|
+
/**
|
|
1888
|
+
* Netlify 适配器 — Netlify Functions v2
|
|
1889
|
+
*
|
|
1890
|
+
* 生成:
|
|
1891
|
+
* - .netlify/functions-internal/ssr/index.mjs — Serverless Function
|
|
1892
|
+
* - dist/client/_redirects — 路由重写规则
|
|
1893
|
+
*/
|
|
1894
|
+
function netlifyAdapter() {
|
|
1895
|
+
return {
|
|
1896
|
+
name: "netlify",
|
|
1897
|
+
async build(ctx) {
|
|
1898
|
+
const { fs, path, root } = ctx;
|
|
1899
|
+
const funcDir = path.resolve(root, ".netlify/functions-internal/ssr");
|
|
1900
|
+
fs.rmSync(path.resolve(root, ".netlify"), {
|
|
1901
|
+
recursive: true,
|
|
1902
|
+
force: true
|
|
1903
|
+
});
|
|
1904
|
+
const entrySource = generateSSREntry(ctx, {
|
|
1905
|
+
platformImport: `import { handle } from "hono/netlify";`,
|
|
1906
|
+
platformExport: `export default handle(app);
|
|
1907
|
+
export const config = { path: "/*", preferStatic: true };`,
|
|
1908
|
+
platformCache: `
|
|
1909
|
+
const ISR_SWR_TTL = 3600;
|
|
1910
|
+
const ISR_CACHE_MAX = 1000;
|
|
1911
|
+
const _isrMap = new Map();
|
|
1912
|
+
async function platformCacheGet(url) {
|
|
1913
|
+
return _isrMap.get(url) ?? null;
|
|
1914
|
+
}
|
|
1915
|
+
async function platformCacheSet(url, html) {
|
|
1916
|
+
if (_isrMap.size >= ISR_CACHE_MAX) {
|
|
1917
|
+
const first = _isrMap.keys().next().value;
|
|
1918
|
+
_isrMap.delete(first);
|
|
1919
|
+
}
|
|
1920
|
+
_isrMap.set(url, html);
|
|
1921
|
+
}`,
|
|
1922
|
+
platformPrerenderResponseHook: `c.header("Cache-Control", "public, max-age=0, must-revalidate");
|
|
1923
|
+
c.header("Netlify-CDN-Cache-Control", "public, max-age=" + ISR_SWR_TTL + ", stale-while-revalidate=" + ISR_SWR_TTL + ", durable");`
|
|
1924
|
+
});
|
|
1925
|
+
const tempEntry = path.resolve(root, ".netlify-entry.tmp.mjs");
|
|
1926
|
+
fs.writeFileSync(tempEntry, entrySource);
|
|
1927
|
+
try {
|
|
1928
|
+
await buildBundle(ctx, {
|
|
1929
|
+
entry: ".netlify-entry.tmp.mjs",
|
|
1930
|
+
outDir: funcDir,
|
|
1931
|
+
target: "node18"
|
|
1932
|
+
});
|
|
1933
|
+
} finally {
|
|
1934
|
+
fs.rmSync(tempEntry, { force: true });
|
|
1935
|
+
}
|
|
1936
|
+
fs.writeFileSync(path.resolve(root, "dist/client/_redirects"), `/* /.netlify/functions/ssr 200\n`);
|
|
1937
|
+
const prerendered = await prerenderRoutes(ctx);
|
|
1938
|
+
const clientDir = path.resolve(root, "dist/client");
|
|
1939
|
+
for (const { url, html } of prerendered) {
|
|
1940
|
+
const filePath = url === "/" ? path.join(clientDir, "index.html") : path.join(clientDir, url, "index.html");
|
|
1941
|
+
fs.mkdirSync(path.resolve(filePath, ".."), { recursive: true });
|
|
1942
|
+
fs.writeFileSync(filePath, html);
|
|
1943
|
+
}
|
|
1944
|
+
console.log(" Netlify output → .netlify/functions-internal/ssr/\n Publish dir: dist/client/\n");
|
|
1945
|
+
}
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
//#endregion
|
|
1949
|
+
//#region ../server/src/adapters/node.ts
|
|
1950
|
+
/**
|
|
1951
|
+
* Node 适配器 — 独立 HTTP 服务器
|
|
1952
|
+
*
|
|
1953
|
+
* 生成 dist/server/index.mjs,使用 @hono/node-server 监听端口。
|
|
1954
|
+
* 运行:node dist/server/index.mjs
|
|
1955
|
+
*/
|
|
1956
|
+
function nodeAdapter() {
|
|
1957
|
+
return {
|
|
1958
|
+
name: "node",
|
|
1959
|
+
async build(ctx) {
|
|
1960
|
+
const { fs, path, root } = ctx;
|
|
1961
|
+
const entrySource = generateSSREntry(ctx, {
|
|
1962
|
+
platformImport: `import { serve } from "@hono/node-server";
|
|
1963
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
1964
|
+
import { resolve, dirname } from "node:path";
|
|
1965
|
+
import { fileURLToPath } from "node:url";`,
|
|
1966
|
+
platformMiddleware: `
|
|
1967
|
+
// 预渲染文件中间件:检查 dist/prerender/ 下是否有对应的静态 HTML
|
|
1968
|
+
const __entry_dirname = dirname(fileURLToPath(import.meta.url));
|
|
1969
|
+
const prerenderDir = resolve(__entry_dirname, "../prerender");
|
|
1970
|
+
|
|
1971
|
+
app.use("*", async (c, next) => {
|
|
1972
|
+
const urlPath = c.req.path;
|
|
1973
|
+
const candidates = [
|
|
1974
|
+
resolve(prerenderDir, "." + urlPath, "index.html"),
|
|
1975
|
+
resolve(prerenderDir, "." + urlPath + ".html"),
|
|
1976
|
+
];
|
|
1977
|
+
if (urlPath === "/") candidates.unshift(resolve(prerenderDir, "index.html"));
|
|
1978
|
+
for (const f of candidates) {
|
|
1979
|
+
if (existsSync(f)) {
|
|
1980
|
+
const html = readFileSync(f, "utf-8");
|
|
1981
|
+
return c.html(html);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
await next();
|
|
1985
|
+
});
|
|
1986
|
+
`,
|
|
1987
|
+
platformExport: `
|
|
1988
|
+
const port = +(process.env.PORT || 3000);
|
|
1989
|
+
serve({ fetch: app.fetch, port }, (info) => {
|
|
1990
|
+
console.log(\`Server running at http://localhost:\${info.port}\`);
|
|
1991
|
+
});
|
|
1992
|
+
`
|
|
1993
|
+
});
|
|
1994
|
+
const tempEntry = path.resolve(root, ".node-entry.tmp.mjs");
|
|
1995
|
+
fs.writeFileSync(tempEntry, entrySource);
|
|
1996
|
+
try {
|
|
1997
|
+
await buildBundle(ctx, {
|
|
1998
|
+
entry: ".node-entry.tmp.mjs",
|
|
1999
|
+
outDir: path.resolve(root, "dist/server"),
|
|
2000
|
+
target: "node18"
|
|
2001
|
+
});
|
|
2002
|
+
} finally {
|
|
2003
|
+
fs.rmSync(tempEntry, { force: true });
|
|
2004
|
+
}
|
|
2005
|
+
const prerendered = await prerenderRoutes(ctx);
|
|
2006
|
+
if (prerendered.length > 0) {
|
|
2007
|
+
const prerenderDir = path.resolve(root, "dist/prerender");
|
|
2008
|
+
fs.mkdirSync(prerenderDir, { recursive: true });
|
|
2009
|
+
for (const { url, html } of prerendered) {
|
|
2010
|
+
const filePath = url === "/" ? path.join(prerenderDir, "index.html") : path.join(prerenderDir, url, "index.html");
|
|
2011
|
+
fs.mkdirSync(path.resolve(filePath, ".."), { recursive: true });
|
|
2012
|
+
fs.writeFileSync(filePath, html);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
console.log(" Node output → dist/server/index.mjs\n Run: node dist/server/index.mjs\n");
|
|
2016
|
+
}
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
//#endregion
|
|
2020
|
+
//#region ../server/src/adapters/static.ts
|
|
2021
|
+
function staticAdapter(opts = {}) {
|
|
2022
|
+
return {
|
|
2023
|
+
name: "static",
|
|
2024
|
+
async build(ctx) {
|
|
2025
|
+
const { fs, path, root } = ctx;
|
|
2026
|
+
const outputDir = path.resolve(root, "dist/static");
|
|
2027
|
+
fs.rmSync(outputDir, {
|
|
2028
|
+
recursive: true,
|
|
2029
|
+
force: true
|
|
2030
|
+
});
|
|
2031
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
2032
|
+
const { pathToFileURL } = await import(
|
|
2033
|
+
/* @vite-ignore */
|
|
2034
|
+
"node:url"
|
|
2035
|
+
);
|
|
2036
|
+
const ssrModule = await import(pathToFileURL(path.resolve(root, "dist/server/ssr.js")).href);
|
|
2037
|
+
ctx.copyStaticAssets(outputDir, { excludeHtml: true });
|
|
2038
|
+
const { paths: routePaths, defs: routeDefs } = await extractRoutesWithModes(ctx, opts);
|
|
2039
|
+
const allUrls = [];
|
|
2040
|
+
for (const routePath of routePaths) for (const locale of ctx.locales) {
|
|
2041
|
+
const url = locale === ctx.defaultLocale ? routePath : `/${locale}${routePath === "/" ? "" : routePath}`;
|
|
2042
|
+
allUrls.push(url);
|
|
2043
|
+
}
|
|
2044
|
+
console.log(` Pre-rendering ${allUrls.length} pages (${routePaths.length} routes × ${ctx.locales.length} locales)...\n`);
|
|
2045
|
+
for (const url of allUrls) try {
|
|
2046
|
+
const locale = inferLocale(url, ctx.locales, ctx.defaultLocale);
|
|
2047
|
+
const routeDef = routeDefs.find((r) => r.path === stripLocalePrefix(url, ctx.locales));
|
|
2048
|
+
const mode = resolveRenderMode(stripLocalePrefix(url, ctx.locales), routeDef?.renderMode, ctx.renderModes);
|
|
2049
|
+
let finalHtml;
|
|
2050
|
+
if (mode === "csr") finalHtml = injectCSRShellForStatic(ctx.templateHtml, locale);
|
|
2051
|
+
else {
|
|
2052
|
+
const { html: appHtml, head, css, serverData } = await ssrModule.render(url, locale);
|
|
2053
|
+
const serializedData = ssrModule.serializeServerData(serverData);
|
|
2054
|
+
finalHtml = injectSSRForStatic(ctx.templateHtml, locale, head, css, appHtml, serializedData);
|
|
2055
|
+
}
|
|
2056
|
+
const filePath = url === "/" ? path.join(outputDir, "index.html") : path.join(outputDir, url, "index.html");
|
|
2057
|
+
fs.mkdirSync(path.resolve(filePath, ".."), { recursive: true });
|
|
2058
|
+
fs.writeFileSync(filePath, finalHtml);
|
|
2059
|
+
} catch (e) {
|
|
2060
|
+
console.warn(` [static] Failed to render ${url}:`, e);
|
|
2061
|
+
}
|
|
2062
|
+
console.log(` Static output → dist/static/\n`);
|
|
2063
|
+
}
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
/** 从路由文件提取无参数路由 + 合并 dynamicRoutes(含 renderMode) */
|
|
2067
|
+
async function extractRoutesWithModes(ctx, opts) {
|
|
2068
|
+
const routesFile = opts.routesExport ?? "src/lib/bootstrap.ts";
|
|
2069
|
+
const paths = [];
|
|
2070
|
+
const defs = [];
|
|
2071
|
+
try {
|
|
2072
|
+
const { pathToFileURL } = await import(
|
|
2073
|
+
/* @vite-ignore */
|
|
2074
|
+
"node:url"
|
|
2075
|
+
);
|
|
2076
|
+
await ctx.vite.build({
|
|
2077
|
+
root: ctx.root,
|
|
2078
|
+
build: {
|
|
2079
|
+
ssr: routesFile,
|
|
2080
|
+
outDir: ctx.path.resolve(ctx.root, "dist/server"),
|
|
2081
|
+
emptyOutDir: false,
|
|
2082
|
+
rollupOptions: { output: { entryFileNames: "_routes.mjs" } }
|
|
2083
|
+
},
|
|
2084
|
+
resolve: ctx.resolvedResolve
|
|
2085
|
+
});
|
|
2086
|
+
const routesMod = await import(pathToFileURL(ctx.path.resolve(ctx.root, "dist/server/_routes.mjs")).href);
|
|
2087
|
+
const routes = routesMod.routes ?? routesMod.default;
|
|
2088
|
+
if (Array.isArray(routes)) {
|
|
2089
|
+
for (const r of routes) if (r.path && !r.path.includes(":")) {
|
|
2090
|
+
paths.push(r.path);
|
|
2091
|
+
defs.push({
|
|
2092
|
+
path: r.path,
|
|
2093
|
+
renderMode: r.renderMode
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
ctx.fs.rmSync(ctx.path.resolve(ctx.root, "dist/server/_routes.mjs"), { force: true });
|
|
2098
|
+
} catch (e) {
|
|
2099
|
+
console.warn(` [static] Could not load routes from "${routesFile}". Using "/" only.`, e);
|
|
2100
|
+
if (paths.length === 0) paths.push("/");
|
|
2101
|
+
}
|
|
2102
|
+
if (opts.dynamicRoutes) {
|
|
2103
|
+
for (const r of opts.dynamicRoutes) if (!paths.includes(r)) paths.push(r);
|
|
2104
|
+
}
|
|
2105
|
+
if (paths.length === 0) paths.push("/");
|
|
2106
|
+
return {
|
|
2107
|
+
paths,
|
|
2108
|
+
defs
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
/** 从 URL 推断 locale */
|
|
2112
|
+
function inferLocale(url, locales, defaultLocale) {
|
|
2113
|
+
const segments = url.split("/").filter(Boolean);
|
|
2114
|
+
if (segments.length > 0 && locales.includes(segments[0])) return segments[0];
|
|
2115
|
+
return defaultLocale;
|
|
2116
|
+
}
|
|
2117
|
+
/** 内联 SSR 注入(同 shared 中的逻辑) */
|
|
2118
|
+
function injectSSRForStatic(template, locale, head, css, html, serializedData) {
|
|
2119
|
+
return template.replace("<!--ssr-lang-->", locale).replace("<!--ssr-head-->", head + "\n<style>" + css + "</style>").replace("<!--ssr-body-->", html).replace("<!--ssr-data-->", "<script id=\"serialized-server-data\" type=\"application/json\">" + serializedData + "<\/script>");
|
|
2120
|
+
}
|
|
2121
|
+
/** CSR 空壳注入 */
|
|
2122
|
+
function injectCSRShellForStatic(template, locale) {
|
|
2123
|
+
return template.replace("<!--ssr-lang-->", locale).replace("<!--ssr-head-->", "").replace("<!--ssr-body-->", "").replace("<!--ssr-data-->", "");
|
|
2124
|
+
}
|
|
2125
|
+
/** 从 URL 去除 locale 前缀,还原路由路径 */
|
|
2126
|
+
function stripLocalePrefix(url, locales) {
|
|
2127
|
+
const segments = url.split("/").filter(Boolean);
|
|
2128
|
+
if (segments.length > 0 && locales.includes(segments[0])) {
|
|
2129
|
+
const rest = segments.slice(1).join("/");
|
|
2130
|
+
return rest ? `/${rest}` : "/";
|
|
2131
|
+
}
|
|
2132
|
+
return url;
|
|
2133
|
+
}
|
|
2134
|
+
/** 解析最终渲染模式:Vite 配置覆盖 > 路由级 > 默认 "ssr" */
|
|
2135
|
+
function resolveRenderMode(routePath, routeRenderMode, renderModes) {
|
|
2136
|
+
if (renderModes) {
|
|
2137
|
+
if (renderModes[routePath]) return renderModes[routePath];
|
|
2138
|
+
for (const [pattern, mode] of Object.entries(renderModes)) if (pattern.includes("*")) {
|
|
2139
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
2140
|
+
if (new RegExp("^" + escaped.replace(/\*/g, ".*") + "$").test(routePath)) return mode;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
return routeRenderMode ?? "ssr";
|
|
2144
|
+
}
|
|
2145
|
+
//#endregion
|
|
2146
|
+
//#region ../server/src/adapters/vercel.ts
|
|
2147
|
+
/**
|
|
2148
|
+
* Vercel 适配器 — Build Output API v3
|
|
2149
|
+
*
|
|
2150
|
+
* 生成 .vercel/output/ 目录:
|
|
2151
|
+
* - config.json — 路由规则
|
|
2152
|
+
* - static/ — 静态资源
|
|
2153
|
+
* - functions/ssr.func/ — Serverless Function
|
|
2154
|
+
*/
|
|
2155
|
+
function vercelAdapter() {
|
|
2156
|
+
return {
|
|
2157
|
+
name: "vercel",
|
|
2158
|
+
async build(ctx) {
|
|
2159
|
+
const { fs, path, root } = ctx;
|
|
2160
|
+
const outputDir = path.resolve(root, ".vercel/output");
|
|
2161
|
+
fs.rmSync(outputDir, {
|
|
2162
|
+
recursive: true,
|
|
2163
|
+
force: true
|
|
2164
|
+
});
|
|
2165
|
+
const entrySource = generateSSREntry(ctx, {
|
|
2166
|
+
platformImport: `import { getRequestListener } from "@hono/node-server";`,
|
|
2167
|
+
platformExport: [
|
|
2168
|
+
`const _listener = getRequestListener(app.fetch);`,
|
|
2169
|
+
`export default (req, res) => {`,
|
|
2170
|
+
` const m = req.headers["x-now-route-matches"];`,
|
|
2171
|
+
` if (typeof m === "string") {`,
|
|
2172
|
+
` try {`,
|
|
2173
|
+
` const p = new URLSearchParams(m);`,
|
|
2174
|
+
` const c = p.get("1");`,
|
|
2175
|
+
` if (c != null) {`,
|
|
2176
|
+
` const qi = (req.url || "").indexOf("?");`,
|
|
2177
|
+
` const qs = qi !== -1 ? req.url.slice(qi) : "";`,
|
|
2178
|
+
` req.url = "/" + decodeURIComponent(c) + qs;`,
|
|
2179
|
+
` }`,
|
|
2180
|
+
` } catch {}`,
|
|
2181
|
+
` }`,
|
|
2182
|
+
` return _listener(req, res);`,
|
|
2183
|
+
`};`
|
|
2184
|
+
].join("\n")
|
|
2185
|
+
});
|
|
2186
|
+
const tempEntry = path.resolve(root, ".vercel-entry.tmp.mjs");
|
|
2187
|
+
fs.writeFileSync(tempEntry, entrySource);
|
|
2188
|
+
try {
|
|
2189
|
+
const funcDir = path.resolve(root, ".vercel/output/functions/ssr.func");
|
|
2190
|
+
await buildBundle(ctx, {
|
|
2191
|
+
entry: ".vercel-entry.tmp.mjs",
|
|
2192
|
+
outDir: funcDir,
|
|
2193
|
+
target: "node18"
|
|
2194
|
+
});
|
|
2195
|
+
fs.writeFileSync(path.resolve(funcDir, ".vc-config.json"), JSON.stringify({
|
|
2196
|
+
runtime: "nodejs20.x",
|
|
2197
|
+
handler: "index.mjs",
|
|
2198
|
+
launcherType: "Nodejs"
|
|
2199
|
+
}, null, 2));
|
|
2200
|
+
copyStaticAssets(ctx, path.resolve(root, ".vercel/output/static"));
|
|
2201
|
+
fs.writeFileSync(path.resolve(root, ".vercel/output/config.json"), JSON.stringify({
|
|
2202
|
+
version: 3,
|
|
2203
|
+
routes: [{ handle: "filesystem" }, {
|
|
2204
|
+
src: "/(.*)",
|
|
2205
|
+
dest: "/ssr"
|
|
2206
|
+
}]
|
|
2207
|
+
}, null, 2));
|
|
2208
|
+
} finally {
|
|
2209
|
+
fs.rmSync(tempEntry, { force: true });
|
|
2210
|
+
}
|
|
2211
|
+
const prerendered = await prerenderRoutes(ctx);
|
|
2212
|
+
const staticDir = path.resolve(root, ".vercel/output/static");
|
|
2213
|
+
for (const { url, html } of prerendered) {
|
|
2214
|
+
const filePath = url === "/" ? path.join(staticDir, "index.html") : path.join(staticDir, url, "index.html");
|
|
2215
|
+
fs.mkdirSync(path.resolve(filePath, ".."), { recursive: true });
|
|
2216
|
+
fs.writeFileSync(filePath, html);
|
|
2217
|
+
}
|
|
2218
|
+
if (prerendered.length > 0) {
|
|
2219
|
+
const configPath = path.resolve(root, ".vercel/output/config.json");
|
|
2220
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
2221
|
+
config.overrides = config.overrides ?? {};
|
|
2222
|
+
for (const { url } of prerendered) {
|
|
2223
|
+
const key = url === "/" ? "index.html" : `${url.replace(/^\//, "")}/index.html`;
|
|
2224
|
+
config.overrides[key] = {
|
|
2225
|
+
path: url === "/" ? "/" : url,
|
|
2226
|
+
contentType: "text/html; charset=utf-8"
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
2230
|
+
}
|
|
2231
|
+
console.log(" Vercel output → .vercel/output/\n");
|
|
2232
|
+
}
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
//#endregion
|
|
2236
|
+
//#region ../server/src/adapters/resolve.ts
|
|
2237
|
+
/**
|
|
2238
|
+
* resolveAdapter — 字符串 → Adapter 映射
|
|
2239
|
+
*/
|
|
2240
|
+
function resolveAdapter(value) {
|
|
2241
|
+
if (typeof value !== "string") return value;
|
|
2242
|
+
switch (value) {
|
|
2243
|
+
case "vercel": return vercelAdapter();
|
|
2244
|
+
case "cloudflare": return cloudflareAdapter();
|
|
2245
|
+
case "netlify": return netlifyAdapter();
|
|
2246
|
+
case "node": return nodeAdapter();
|
|
2247
|
+
case "static": return staticAdapter();
|
|
2248
|
+
case "auto": return autoAdapter();
|
|
2249
|
+
default: throw new Error(`[finesoft] Unknown adapter: "${value}". Available: vercel, cloudflare, netlify, node, static, auto`);
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
//#endregion
|
|
2253
|
+
//#region ../server/src/adapters/auto.ts
|
|
2254
|
+
/**
|
|
2255
|
+
* Auto 适配器 — 根据环境变量自动选择目标平台
|
|
2256
|
+
*
|
|
2257
|
+
* 检测顺序:
|
|
2258
|
+
* VERCEL → vercel
|
|
2259
|
+
* CF_PAGES → cloudflare
|
|
2260
|
+
* NETLIFY → netlify
|
|
2261
|
+
* (default) → node
|
|
2262
|
+
*/
|
|
2263
|
+
function autoAdapter() {
|
|
2264
|
+
return {
|
|
2265
|
+
name: "auto",
|
|
2266
|
+
async build(ctx) {
|
|
2267
|
+
const detected = detectPlatform();
|
|
2268
|
+
console.log(` [auto] Detected platform: ${detected}\n`);
|
|
2269
|
+
return resolveAdapter(detected).build(ctx);
|
|
2270
|
+
}
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
function detectPlatform() {
|
|
2274
|
+
if (process.env.VERCEL) return "vercel";
|
|
2275
|
+
if (process.env.CF_PAGES) return "cloudflare";
|
|
2276
|
+
if (process.env.NETLIFY) return "netlify";
|
|
2277
|
+
return "node";
|
|
2278
|
+
}
|
|
2279
|
+
//#endregion
|
|
2280
|
+
//#region ../server/src/runtime.ts
|
|
2281
|
+
/** 检测当前运行时环境 */
|
|
2282
|
+
function detectRuntime() {
|
|
2283
|
+
return {
|
|
2284
|
+
isDeno: typeof globalThis.Deno !== "undefined",
|
|
2285
|
+
isBun: typeof globalThis.Bun !== "undefined",
|
|
2286
|
+
isVercel: !!process.env.VERCEL,
|
|
2287
|
+
isProduction: process.env.NODE_ENV === "production"
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* 从 `import.meta.url` 推导项目根路径
|
|
2292
|
+
*
|
|
2293
|
+
* @param importMetaUrl - 调用方的 `import.meta.url`
|
|
2294
|
+
* @param levelsUp - 向上移动多少级(默认 0,即调用方所在目录就是项目根)
|
|
2295
|
+
*/
|
|
2296
|
+
async function resolveRoot(importMetaUrl, levelsUp = 0) {
|
|
2297
|
+
if (typeof globalThis.Deno !== "undefined") {
|
|
2298
|
+
let url = new URL(importMetaUrl);
|
|
2299
|
+
for (let i = 0; i < levelsUp; i++) url = new URL("..", url);
|
|
2300
|
+
return url.pathname;
|
|
2301
|
+
}
|
|
2302
|
+
const path = await import(
|
|
2303
|
+
/* @vite-ignore */
|
|
2304
|
+
"node:path"
|
|
2305
|
+
);
|
|
2306
|
+
const { fileURLToPath } = await import(
|
|
2307
|
+
/* @vite-ignore */
|
|
2308
|
+
"node:url"
|
|
2309
|
+
);
|
|
2310
|
+
let dir = path.normalize(path.dirname(fileURLToPath(importMetaUrl)));
|
|
2311
|
+
for (let i = 0; i < levelsUp; i++) dir = path.resolve(dir, "..");
|
|
2312
|
+
return dir;
|
|
2313
|
+
}
|
|
2314
|
+
//#endregion
|
|
2315
|
+
//#region ../server/src/start.ts
|
|
2316
|
+
/**
|
|
2317
|
+
* startServer — 多运行时自动启动
|
|
2318
|
+
*
|
|
2319
|
+
* 支持 Node.js (dev HMR + prod)、Deno、Bun、Vercel。
|
|
2320
|
+
*/
|
|
2321
|
+
async function startServer(options) {
|
|
2322
|
+
const { app, root, port = 3e3, isProduction, vite, routes, locales, ssrEntryPath } = options;
|
|
2323
|
+
const { isDeno, isBun, isVercel } = options.runtime ?? detectRuntime();
|
|
2324
|
+
function printStartupBanner() {
|
|
2325
|
+
const lines = [`\n Server running at http://localhost:${port}\n`];
|
|
2326
|
+
if (routes && routes.length > 0) {
|
|
2327
|
+
lines.push(" Routes:");
|
|
2328
|
+
for (const r of routes) lines.push(` ${r}`);
|
|
2329
|
+
lines.push("");
|
|
2330
|
+
}
|
|
2331
|
+
if (locales && locales.length > 0) lines.push(` Locales: ${locales.join(", ")}`);
|
|
2332
|
+
if (ssrEntryPath) lines.push(` SSR Entry: ${ssrEntryPath}`);
|
|
2333
|
+
if (locales?.length || ssrEntryPath) lines.push("");
|
|
2334
|
+
console.log(lines.join("\n"));
|
|
2335
|
+
}
|
|
2336
|
+
if (isVercel) return { vite };
|
|
2337
|
+
if (!isProduction) {
|
|
2338
|
+
let devVite = vite;
|
|
2339
|
+
if (!devVite) {
|
|
2340
|
+
const { createServer: createViteServer } = await import(
|
|
2341
|
+
/* @vite-ignore */
|
|
2342
|
+
"vite"
|
|
2343
|
+
);
|
|
2344
|
+
devVite = await createViteServer({
|
|
2345
|
+
root,
|
|
2346
|
+
server: { middlewareMode: true },
|
|
2347
|
+
appType: "custom"
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
const { getRequestListener } = await import(
|
|
2351
|
+
/* @vite-ignore */
|
|
2352
|
+
"@hono/node-server"
|
|
2353
|
+
);
|
|
2354
|
+
const { createServer } = await import(
|
|
2355
|
+
/* @vite-ignore */
|
|
2356
|
+
"node:http"
|
|
2357
|
+
);
|
|
2358
|
+
const listener = getRequestListener(app.fetch);
|
|
2359
|
+
createServer((req, res) => {
|
|
2360
|
+
devVite.middlewares(req, res, () => listener(req, res));
|
|
2361
|
+
}).listen(port, () => {
|
|
2362
|
+
printStartupBanner();
|
|
2363
|
+
});
|
|
2364
|
+
return { vite: devVite };
|
|
2365
|
+
}
|
|
2366
|
+
if (isDeno) globalThis.Deno.serve({ port }, app.fetch);
|
|
2367
|
+
else if (isBun) {} else {
|
|
2368
|
+
const { serveStatic } = await import(
|
|
2369
|
+
/* @vite-ignore */
|
|
2370
|
+
"@hono/node-server/serve-static"
|
|
2371
|
+
);
|
|
2372
|
+
const path = await import(
|
|
2373
|
+
/* @vite-ignore */
|
|
2374
|
+
"node:path"
|
|
2375
|
+
);
|
|
2376
|
+
const prodApp = new Hono();
|
|
2377
|
+
const clientDir = path.resolve(root, "dist/client");
|
|
2378
|
+
prodApp.use("/*", serveStatic({
|
|
2379
|
+
root: clientDir,
|
|
2380
|
+
rewriteRequestPath: (path) => path.endsWith("/") ? "/__nosuchfile__" : path
|
|
2381
|
+
}));
|
|
2382
|
+
prodApp.route("/", app);
|
|
2383
|
+
const { serve } = await import(
|
|
2384
|
+
/* @vite-ignore */
|
|
2385
|
+
"@hono/node-server"
|
|
2386
|
+
);
|
|
2387
|
+
serve({
|
|
2388
|
+
fetch: prodApp.fetch,
|
|
2389
|
+
port
|
|
2390
|
+
}, () => {
|
|
2391
|
+
printStartupBanner();
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
return { vite };
|
|
2395
|
+
}
|
|
2396
|
+
//#endregion
|
|
2397
|
+
//#region ../server/src/create-server.ts
|
|
2398
|
+
/**
|
|
2399
|
+
* createServer — 一站式服务器工厂
|
|
2400
|
+
*
|
|
2401
|
+
* 封装 env 加载、运行时检测、Vite 创建、Hono app、SSR、启动。
|
|
2402
|
+
* 保留 setup() 钩子用于注册业务路由。
|
|
2403
|
+
*/
|
|
2404
|
+
/**
|
|
2405
|
+
* 创建并启动 SSR 服务器
|
|
2406
|
+
*
|
|
2407
|
+
* @example
|
|
2408
|
+
* ```ts
|
|
2409
|
+
* const { app } = await createServer({
|
|
2410
|
+
* locales: ["zh", "en"],
|
|
2411
|
+
* setup: (app) => registerProxies(app),
|
|
2412
|
+
* });
|
|
2413
|
+
* export { app };
|
|
2414
|
+
* ```
|
|
2415
|
+
*/
|
|
2416
|
+
async function createServer(config = {}) {
|
|
2417
|
+
const { root: rootOverride, locales, defaultLocale, port = Number(process.env.PORT) || 3e3, setup, proxies, ssr } = config;
|
|
2418
|
+
const root = rootOverride ?? process.cwd();
|
|
2419
|
+
const { existsSync } = await import(
|
|
2420
|
+
/* @vite-ignore */
|
|
2421
|
+
"node:fs"
|
|
2422
|
+
);
|
|
2423
|
+
const envPath = (await import(
|
|
2424
|
+
/* @vite-ignore */
|
|
2425
|
+
"node:path"
|
|
2426
|
+
)).resolve(root, ".env");
|
|
2427
|
+
if (existsSync(envPath)) try {
|
|
2428
|
+
const { config: dotenvConfig } = await import(
|
|
2429
|
+
/* @vite-ignore */
|
|
2430
|
+
"dotenv"
|
|
2431
|
+
);
|
|
2432
|
+
dotenvConfig({ path: envPath });
|
|
2433
|
+
} catch {}
|
|
2434
|
+
const runtime = detectRuntime();
|
|
2435
|
+
let vite;
|
|
2436
|
+
if (!runtime.isProduction && !runtime.isVercel) {
|
|
2437
|
+
const { createServer: createViteServer } = await import(
|
|
2438
|
+
/* @vite-ignore */
|
|
2439
|
+
"vite"
|
|
2440
|
+
);
|
|
2441
|
+
vite = await createViteServer({
|
|
2442
|
+
root,
|
|
2443
|
+
server: { middlewareMode: true },
|
|
2444
|
+
appType: "custom"
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
const app = new Hono();
|
|
2448
|
+
if (proxies?.length) registerProxyRoutes(app, proxies);
|
|
2449
|
+
if (setup) await setup(app);
|
|
2450
|
+
const ssrApp = createSSRApp({
|
|
2451
|
+
root,
|
|
2452
|
+
vite,
|
|
2453
|
+
isProduction: runtime.isProduction,
|
|
2454
|
+
supportedLocales: locales,
|
|
2455
|
+
defaultLocale,
|
|
2456
|
+
parentFetch: app.fetch.bind(app),
|
|
2457
|
+
...ssr
|
|
2458
|
+
});
|
|
2459
|
+
app.route("/", ssrApp);
|
|
2460
|
+
await startServer({
|
|
2461
|
+
app,
|
|
2462
|
+
root,
|
|
2463
|
+
port,
|
|
2464
|
+
isProduction: runtime.isProduction,
|
|
2465
|
+
vite,
|
|
2466
|
+
runtime,
|
|
2467
|
+
locales,
|
|
2468
|
+
ssrEntryPath: ssr?.ssrEntryPath
|
|
2469
|
+
});
|
|
2470
|
+
return {
|
|
2471
|
+
app,
|
|
2472
|
+
vite,
|
|
2473
|
+
runtime
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
//#endregion
|
|
2477
|
+
//#region ../server/src/vite-plugin.ts
|
|
2478
|
+
/**
|
|
2479
|
+
* 从 setup 模块中查找 setup 函数:优先 default,其次 setup 命名导出。
|
|
2480
|
+
*/
|
|
2481
|
+
function resolveSetupFn(mod) {
|
|
2482
|
+
if (typeof mod.default === "function") return mod.default;
|
|
2483
|
+
if (typeof mod.setup === "function") return mod.setup;
|
|
2484
|
+
return Object.values(mod).find((v) => typeof v === "function") ?? null;
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* 匹配 Vite 配置级别的 renderMode 覆盖。
|
|
2488
|
+
* 精确路径优先,然后 glob 模式。
|
|
2489
|
+
*/
|
|
2490
|
+
function matchRenderModeConfig(url, renderModes) {
|
|
2491
|
+
if (!renderModes) return null;
|
|
2492
|
+
const path = url.split("?")[0];
|
|
2493
|
+
if (renderModes[path]) return renderModes[path];
|
|
2494
|
+
for (const [pattern, mode] of Object.entries(renderModes)) if (pattern.includes("*")) {
|
|
2495
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
2496
|
+
if (new RegExp("^" + escaped.replace(/\*/g, ".*") + "$").test(path)) return mode;
|
|
2497
|
+
}
|
|
2498
|
+
return null;
|
|
2499
|
+
}
|
|
2500
|
+
function finesoftFrontViteConfig(options = {}) {
|
|
2501
|
+
const ssrEntry = options.ssr?.entry ?? "src/ssr.ts";
|
|
2502
|
+
let root = process.cwd();
|
|
2503
|
+
let resolvedCommand;
|
|
2504
|
+
let resolvedResolve;
|
|
2505
|
+
let resolvedCss;
|
|
2506
|
+
const CSS_EXTENSIONS = /\.(css|scss|less|sass|styl|stylus|pcss|postcss)($|\?)/;
|
|
2507
|
+
return {
|
|
2508
|
+
name: "finesoft-front",
|
|
2509
|
+
config(userConfig, env) {
|
|
2510
|
+
const overrides = { appType: "custom" };
|
|
2511
|
+
if (env.command === "build" && !process.env.__FINESOFT_SUB_BUILD__) overrides.build = { outDir: userConfig.build?.outDir ?? "dist/client" };
|
|
2512
|
+
return overrides;
|
|
2513
|
+
},
|
|
2514
|
+
configResolved(config) {
|
|
2515
|
+
resolvedCommand = config.command;
|
|
2516
|
+
resolvedResolve = config.resolve;
|
|
2517
|
+
resolvedCss = config.css;
|
|
2518
|
+
root = config.root;
|
|
2519
|
+
},
|
|
2520
|
+
transformIndexHtml: {
|
|
2521
|
+
order: "pre",
|
|
2522
|
+
async handler(html, ctx) {
|
|
2523
|
+
const server = ctx.server;
|
|
2524
|
+
if (!server) return;
|
|
2525
|
+
const urlPath = (ctx.originalUrl || ctx.path || "").split("?")[0];
|
|
2526
|
+
if (/\.\w+$/.test(urlPath) && !urlPath.endsWith(".html")) return;
|
|
2527
|
+
const appEntry = [...html.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/g)].find((m) => !m[1].startsWith("/@"));
|
|
2528
|
+
if (!appEntry) return;
|
|
2529
|
+
const browserEntry = appEntry[1];
|
|
2530
|
+
try {
|
|
2531
|
+
await server.transformRequest(browserEntry);
|
|
2532
|
+
} catch {
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
const cssUrls = [];
|
|
2536
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2537
|
+
function walk(mod) {
|
|
2538
|
+
if (!mod?.url || visited.has(mod.url)) return;
|
|
2539
|
+
visited.add(mod.url);
|
|
2540
|
+
if (CSS_EXTENSIONS.test(mod.url) && !mod.url.includes(".svelte")) cssUrls.push(mod.url);
|
|
2541
|
+
if (mod.importedModules) for (const imported of mod.importedModules) walk(imported);
|
|
2542
|
+
}
|
|
2543
|
+
const browserMod = await server.moduleGraph.getModuleByUrl(browserEntry);
|
|
2544
|
+
if (browserMod) walk(browserMod);
|
|
2545
|
+
if (cssUrls.length === 0) return;
|
|
2546
|
+
const tags = [];
|
|
2547
|
+
for (const url of cssUrls) try {
|
|
2548
|
+
const css = (await server.ssrLoadModule(url))?.default;
|
|
2549
|
+
if (typeof css === "string" && css.length > 0) tags.push({
|
|
2550
|
+
tag: "style",
|
|
2551
|
+
attrs: { "data-vite-dev-id": url },
|
|
2552
|
+
children: css,
|
|
2553
|
+
injectTo: "head"
|
|
2554
|
+
});
|
|
2555
|
+
} catch {}
|
|
2556
|
+
return tags;
|
|
2557
|
+
}
|
|
2558
|
+
},
|
|
2559
|
+
configureServer(server) {
|
|
2560
|
+
return async () => {
|
|
2561
|
+
const { Hono: HonoClass } = await import(
|
|
2562
|
+
/* @vite-ignore */
|
|
2563
|
+
"hono"
|
|
2564
|
+
);
|
|
2565
|
+
const { createSSRApp } = await import("./app-re7jUCuM.mjs").then((n) => n.t);
|
|
2566
|
+
const { getRequestListener } = await import(
|
|
2567
|
+
/* @vite-ignore */
|
|
2568
|
+
"@hono/node-server"
|
|
2569
|
+
);
|
|
2570
|
+
const app = new HonoClass();
|
|
2571
|
+
if (options.proxies?.length) registerProxyRoutes(app, options.proxies);
|
|
2572
|
+
if (typeof options.setup === "function") await options.setup(app);
|
|
2573
|
+
else if (typeof options.setup === "string") {
|
|
2574
|
+
const fn = resolveSetupFn(await server.ssrLoadModule("/" + options.setup));
|
|
2575
|
+
if (fn) await fn(app);
|
|
2576
|
+
}
|
|
2577
|
+
const ssrApp = createSSRApp({
|
|
2578
|
+
root,
|
|
2579
|
+
vite: server,
|
|
2580
|
+
isProduction: false,
|
|
2581
|
+
ssrEntryPath: "/" + ssrEntry,
|
|
2582
|
+
supportedLocales: options.locales,
|
|
2583
|
+
defaultLocale: options.defaultLocale,
|
|
2584
|
+
parentFetch: app.fetch.bind(app),
|
|
2585
|
+
renderModes: options.renderModes
|
|
2586
|
+
});
|
|
2587
|
+
app.route("/", ssrApp);
|
|
2588
|
+
const listener = getRequestListener(app.fetch);
|
|
2589
|
+
server.middlewares.use((req, res) => {
|
|
2590
|
+
listener(req, res);
|
|
2591
|
+
});
|
|
2592
|
+
};
|
|
2593
|
+
},
|
|
2594
|
+
configurePreviewServer(server) {
|
|
2595
|
+
return async () => {
|
|
2596
|
+
const { readFileSync } = await import(
|
|
2597
|
+
/* @vite-ignore */
|
|
2598
|
+
"node:fs"
|
|
2599
|
+
);
|
|
2600
|
+
const path = await import(
|
|
2601
|
+
/* @vite-ignore */
|
|
2602
|
+
"node:path"
|
|
2603
|
+
);
|
|
2604
|
+
const { pathToFileURL } = await import(
|
|
2605
|
+
/* @vite-ignore */
|
|
2606
|
+
"node:url"
|
|
2607
|
+
);
|
|
2608
|
+
const { Hono: HonoClass } = await import(
|
|
2609
|
+
/* @vite-ignore */
|
|
2610
|
+
"hono"
|
|
2611
|
+
);
|
|
2612
|
+
const { injectSSRContent, injectCSRShell } = await import(
|
|
2613
|
+
/* @vite-ignore */
|
|
2614
|
+
"@finesoft/ssr"
|
|
2615
|
+
);
|
|
2616
|
+
const { parseAcceptLanguage } = await import("./locale-D2Bu7w47.mjs").then((n) => n.t);
|
|
2617
|
+
const { getRequestListener } = await import(
|
|
2618
|
+
/* @vite-ignore */
|
|
2619
|
+
"@hono/node-server"
|
|
2620
|
+
);
|
|
2621
|
+
const app = new HonoClass();
|
|
2622
|
+
const isrCache = /* @__PURE__ */ new Map();
|
|
2623
|
+
if (options.proxies?.length) registerProxyRoutes(app, options.proxies);
|
|
2624
|
+
if (typeof options.setup === "function") await options.setup(app);
|
|
2625
|
+
else if (typeof options.setup === "string") try {
|
|
2626
|
+
const fn = resolveSetupFn(await import(pathToFileURL(path.resolve(root, "dist/server/setup.mjs")).href));
|
|
2627
|
+
if (fn) await fn(app);
|
|
2628
|
+
} catch {
|
|
2629
|
+
console.warn("[finesoft] Could not load setup module for preview. API routes disabled.");
|
|
2630
|
+
}
|
|
2631
|
+
const template = readFileSync(path.resolve(root, "dist/client/index.html"), "utf-8");
|
|
2632
|
+
const ssrModule = await import(pathToFileURL(path.resolve(root, "dist/server/ssr.js")).href);
|
|
2633
|
+
app.get("*", async (c) => {
|
|
2634
|
+
const ssrDepth = parseInt(c.req.header("x-ssr-depth") ?? "0", 10);
|
|
2635
|
+
if (ssrDepth >= 5) return c.text("SSR recursion loop detected", 508);
|
|
2636
|
+
const url = c.req.path + (c.req.url.includes("?") ? "?" + c.req.url.split("?")[1] : "");
|
|
2637
|
+
try {
|
|
2638
|
+
const locale = parseAcceptLanguage(c.req.header("accept-language"), options.locales, options.defaultLocale);
|
|
2639
|
+
const overrideMode = matchRenderModeConfig(url, options.renderModes);
|
|
2640
|
+
if (overrideMode === "csr") return c.html(injectCSRShell(template, locale));
|
|
2641
|
+
const cacheKey = `${locale}:${url}`;
|
|
2642
|
+
const cached = isrCache.get(cacheKey);
|
|
2643
|
+
if (cached) return c.html(cached);
|
|
2644
|
+
const { html: appHtml, head, css, serverData, renderMode } = await ssrModule.render(url, locale, { fetch: createInternalFetch(app.fetch.bind(app), ssrDepth + 1) });
|
|
2645
|
+
if (renderMode === "csr") return c.html(injectCSRShell(template, locale));
|
|
2646
|
+
const finalHtml = injectSSRContent({
|
|
2647
|
+
template,
|
|
2648
|
+
locale,
|
|
2649
|
+
head,
|
|
2650
|
+
css,
|
|
2651
|
+
html: appHtml,
|
|
2652
|
+
serializedData: ssrModule.serializeServerData(serverData)
|
|
2653
|
+
});
|
|
2654
|
+
if (renderMode === "prerender" || overrideMode === "prerender") isrCache.set(cacheKey, finalHtml);
|
|
2655
|
+
return c.html(finalHtml);
|
|
2656
|
+
} catch (e) {
|
|
2657
|
+
console.error("[SSR Preview Error]", e);
|
|
2658
|
+
return c.text("Internal Server Error", 500);
|
|
2659
|
+
}
|
|
2660
|
+
});
|
|
2661
|
+
const listener = getRequestListener(app.fetch);
|
|
2662
|
+
server.middlewares.use((req, res) => {
|
|
2663
|
+
listener(req, res);
|
|
2664
|
+
});
|
|
2665
|
+
};
|
|
2666
|
+
},
|
|
2667
|
+
async closeBundle() {
|
|
2668
|
+
if (process.env.__FINESOFT_SUB_BUILD__) return;
|
|
2669
|
+
if (resolvedCommand !== "build") return;
|
|
2670
|
+
process.env.__FINESOFT_SUB_BUILD__ = "1";
|
|
2671
|
+
try {
|
|
2672
|
+
const vite = await import(
|
|
2673
|
+
/* @vite-ignore */
|
|
2674
|
+
"vite"
|
|
2675
|
+
);
|
|
2676
|
+
const fs = await import(
|
|
2677
|
+
/* @vite-ignore */
|
|
2678
|
+
"node:fs"
|
|
2679
|
+
);
|
|
2680
|
+
const path = await import(
|
|
2681
|
+
/* @vite-ignore */
|
|
2682
|
+
"node:path"
|
|
2683
|
+
);
|
|
2684
|
+
console.log("\n Building SSR bundle...\n");
|
|
2685
|
+
await vite.build({
|
|
2686
|
+
root,
|
|
2687
|
+
build: {
|
|
2688
|
+
ssr: ssrEntry,
|
|
2689
|
+
outDir: "dist/server"
|
|
2690
|
+
},
|
|
2691
|
+
resolve: resolvedResolve,
|
|
2692
|
+
css: resolvedCss
|
|
2693
|
+
});
|
|
2694
|
+
if (typeof options.setup === "string") {
|
|
2695
|
+
console.log(" Building setup module...\n");
|
|
2696
|
+
await vite.build({
|
|
2697
|
+
root,
|
|
2698
|
+
build: {
|
|
2699
|
+
ssr: options.setup,
|
|
2700
|
+
outDir: "dist/server",
|
|
2701
|
+
emptyOutDir: false,
|
|
2702
|
+
rollupOptions: { output: { entryFileNames: "setup.mjs" } }
|
|
2703
|
+
},
|
|
2704
|
+
resolve: resolvedResolve
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
if (options.adapter) {
|
|
2708
|
+
const adapter = resolveAdapter(options.adapter);
|
|
2709
|
+
const locales = options.locales ?? ["zh", "en"];
|
|
2710
|
+
const defaultLocale = options.defaultLocale ?? locales[0] ?? "en";
|
|
2711
|
+
const templateHtml = fs.readFileSync(path.resolve(root, "dist/client/index.html"), "utf-8");
|
|
2712
|
+
const ctx = {
|
|
2713
|
+
root,
|
|
2714
|
+
ssrEntry,
|
|
2715
|
+
setupPath: typeof options.setup === "string" ? options.setup : void 0,
|
|
2716
|
+
bootstrapEntry: options.bootstrapEntry,
|
|
2717
|
+
locales,
|
|
2718
|
+
defaultLocale,
|
|
2719
|
+
templateHtml,
|
|
2720
|
+
renderModes: options.renderModes,
|
|
2721
|
+
proxies: options.proxies,
|
|
2722
|
+
resolvedResolve,
|
|
2723
|
+
resolvedCss,
|
|
2724
|
+
vite,
|
|
2725
|
+
fs,
|
|
2726
|
+
path,
|
|
2727
|
+
generateSSREntry(opts) {
|
|
2728
|
+
return generateSSREntry(ctx, opts);
|
|
2729
|
+
},
|
|
2730
|
+
buildBundle(opts) {
|
|
2731
|
+
return buildBundle(ctx, opts);
|
|
2732
|
+
},
|
|
2733
|
+
copyStaticAssets(destDir, opts) {
|
|
2734
|
+
return copyStaticAssets(ctx, destDir, opts);
|
|
2735
|
+
}
|
|
2736
|
+
};
|
|
2737
|
+
console.log(` Running adapter: ${adapter.name}...\n`);
|
|
2738
|
+
await adapter.build(ctx);
|
|
2739
|
+
}
|
|
2740
|
+
} finally {
|
|
2741
|
+
delete process.env.__FINESOFT_SUB_BUILD__;
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
//#endregion
|
|
2747
|
+
export { ACTION_KINDS, ActionDispatcher, BaseController, BaseLogger, CompositeLogger, CompositeLoggerFactory, ConsoleLogger, ConsoleLoggerFactory, Container, DEP_KEYS, Framework, History, HttpClient, HttpError, IntentDispatcher, LruMap, PrefetchedIntents, Router, SSR_PLACEHOLDERS, autoAdapter, buildUrl, cloudflareAdapter, createBrowserContext, createPrefetchedIntentsFromDom, createSSRApp, createSSRRender, createServer, createServerContext, defineRoutes, deny, deserializeServerData, detectRuntime, finesoftFrontViteConfig, generateProxyCode, generateUuid, getBaseUrl, injectCSRShell, injectSSRContent, isCompoundAction, isExternalUrlAction, isFlowAction, isNone, isSome, makeDependencies, makeExternalUrlAction, makeFlowAction, mapEach, netlifyAdapter, next, nodeAdapter, parseAcceptLanguage, pipe, pipeAsync, redirect, registerActionHandlers, registerExternalUrlHandler, registerFlowActionHandler, registerProxyRoutes, removeHost, removeQueryParams, removeScheme, resetFilterCache, resolveAdapter, resolveRoot, rewrite, runAfterLoadGuards, runBeforeLoadGuards, serializeServerData, shouldLog, ssrRender, stableStringify, startBrowserApp, startServer, staticAdapter, tryScroll, vercelAdapter };
|
|
2748
|
+
|
|
2749
|
+
//# sourceMappingURL=index.mjs.map
|