@netrojs/fnetro 0.2.21 → 0.3.0

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/server.js CHANGED
@@ -1,22 +1,61 @@
1
1
  // server.ts
2
2
  import { Hono } from "hono";
3
- import { createComponent } from "solid-js";
4
- import { renderToStringAsync, generateHydrationScript } from "solid-js/web";
5
- import { Router } from "@solidjs/router";
3
+ import { createSSRApp, defineComponent, h } from "vue";
4
+ import { createRouter, createMemoryHistory, RouterView } from "vue-router";
5
+ import { renderToString, renderToWebStream } from "@vue/server-renderer";
6
+
7
+ // types.ts
8
+ var SPA_HEADER = "x-fnetro-spa";
9
+ var STATE_KEY = "__FNETRO_STATE__";
10
+ var PARAMS_KEY = "__FNETRO_PARAMS__";
11
+ var SEO_KEY = "__FNETRO_SEO__";
12
+ var DATA_KEY = /* @__PURE__ */ Symbol.for("fnetro:data");
6
13
 
7
14
  // core.ts
15
+ var VUE_BRANDS = ["__name", "__file", "__vccOpts", "setup", "render", "data", "components"];
16
+ function isAsyncLoader(c) {
17
+ if (typeof c !== "function") return false;
18
+ const f = c;
19
+ for (const brand of VUE_BRANDS) {
20
+ if (brand in f) return false;
21
+ }
22
+ return true;
23
+ }
8
24
  function definePage(def) {
9
25
  return { __type: "page", ...def };
10
26
  }
11
27
  function defineGroup(def) {
12
28
  return { __type: "group", ...def };
13
29
  }
14
- function defineLayout(Component) {
15
- return { __type: "layout", Component };
30
+ function defineLayout(component) {
31
+ return { __type: "layout", component };
16
32
  }
17
33
  function defineApiRoute(path, register) {
18
34
  return { __type: "api", path, register };
19
35
  }
36
+ function compilePath(path) {
37
+ const keys = [];
38
+ const src = path.replace(/\[\.\.\.([^\]]+)\]/g, (_, k) => {
39
+ keys.push(k);
40
+ return "(.*)";
41
+ }).replace(/\[([^\]]+)\]/g, (_, k) => {
42
+ keys.push(k);
43
+ return "([^/]+)";
44
+ }).replace(/\*/g, "(.*)");
45
+ return { re: new RegExp(`^${src}$`), keys };
46
+ }
47
+ function matchPath(cp, pathname) {
48
+ const m = pathname.match(cp.re);
49
+ if (!m) return null;
50
+ const params = {};
51
+ cp.keys.forEach((k, i) => {
52
+ params[k] = decodeURIComponent(m[i + 1] ?? "");
53
+ });
54
+ return params;
55
+ }
56
+ function toVueRouterPath(fnetroPath) {
57
+ return fnetroPath.replace(/\[\.\.\.([^\]]+)\]/g, ":$1(.*)*").replace(/\[([^\]]+)\]/g, ":$1");
58
+ }
20
59
  function resolveRoutes(routes, options = {}) {
21
60
  const pages = [];
22
61
  const apis = [];
@@ -27,7 +66,11 @@ function resolveRoutes(routes, options = {}) {
27
66
  const prefix = (options.prefix ?? "") + route.prefix;
28
67
  const mw = [...options.middleware ?? [], ...route.middleware ?? []];
29
68
  const layout = route.layout !== void 0 ? route.layout : options.layout;
30
- const sub = resolveRoutes(route.routes, { prefix, middleware: mw, layout });
69
+ const sub = resolveRoutes(route.routes, {
70
+ prefix,
71
+ middleware: mw,
72
+ ...layout !== void 0 && { layout }
73
+ });
31
74
  pages.push(...sub.pages);
32
75
  apis.push(...sub.apis);
33
76
  } else {
@@ -41,32 +84,9 @@ function resolveRoutes(routes, options = {}) {
41
84
  }
42
85
  return { pages, apis };
43
86
  }
44
- function compilePath(path) {
45
- const keys = [];
46
- const src = path.replace(/\[\.\.\.([^\]]+)\]/g, (_, k) => {
47
- keys.push(k);
48
- return "(.*)";
49
- }).replace(/\[([^\]]+)\]/g, (_, k) => {
50
- keys.push(k);
51
- return "([^/]+)";
52
- }).replace(/\*/g, "(.*)");
53
- return { re: new RegExp(`^${src}$`), keys };
54
- }
55
- function matchPath(compiled, pathname) {
56
- const m = pathname.match(compiled.re);
57
- if (!m) return null;
58
- const params = {};
59
- compiled.keys.forEach((k, i) => {
60
- params[k] = decodeURIComponent(m[i + 1] ?? "");
61
- });
62
- return params;
63
- }
64
- var SPA_HEADER = "x-fnetro-spa";
65
- var STATE_KEY = "__FNETRO_STATE__";
66
- var PARAMS_KEY = "__FNETRO_PARAMS__";
67
- var SEO_KEY = "__FNETRO_SEO__";
68
87
 
69
88
  // server.ts
89
+ import { build } from "vite";
70
90
  function esc(s) {
71
91
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
72
92
  }
@@ -85,33 +105,19 @@ function buildHeadMeta(seo, extraHead = "") {
85
105
  if (seo.ogDescription) parts.push(p("og:description", seo.ogDescription));
86
106
  if (seo.ogImage) parts.push(p("og:image", seo.ogImage));
87
107
  if (seo.ogImageAlt) parts.push(p("og:image:alt", seo.ogImageAlt));
88
- if (seo.ogImageWidth) parts.push(p("og:image:width", seo.ogImageWidth));
89
- if (seo.ogImageHeight) parts.push(p("og:image:height", seo.ogImageHeight));
90
108
  if (seo.ogUrl) parts.push(p("og:url", seo.ogUrl));
91
109
  if (seo.ogType) parts.push(p("og:type", seo.ogType));
92
110
  if (seo.ogSiteName) parts.push(p("og:site_name", seo.ogSiteName));
93
- if (seo.ogLocale) parts.push(p("og:locale", seo.ogLocale));
94
111
  if (seo.twitterCard) parts.push(m("twitter:card", seo.twitterCard));
95
112
  if (seo.twitterSite) parts.push(m("twitter:site", seo.twitterSite));
96
- if (seo.twitterCreator) parts.push(m("twitter:creator", seo.twitterCreator));
97
113
  if (seo.twitterTitle) parts.push(m("twitter:title", seo.twitterTitle));
98
114
  if (seo.twitterDescription) parts.push(m("twitter:description", seo.twitterDescription));
99
115
  if (seo.twitterImage) parts.push(m("twitter:image", seo.twitterImage));
100
- if (seo.twitterImageAlt) parts.push(m("twitter:image:alt", seo.twitterImageAlt));
101
- for (const tag of seo.extra ?? []) {
102
- const attrs = [
103
- tag.name ? `name="${esc(tag.name)}"` : "",
104
- tag.property ? `property="${esc(tag.property)}"` : "",
105
- tag.httpEquiv ? `http-equiv="${esc(tag.httpEquiv)}"` : "",
106
- `content="${esc(tag.content)}"`
107
- ].filter(Boolean).join(" ");
108
- parts.push(`<meta ${attrs}>`);
109
- }
110
116
  const ld = seo.jsonLd;
111
117
  if (ld) {
112
118
  const schemas = Array.isArray(ld) ? ld : [ld];
113
- for (const schema of schemas) {
114
- parts.push(`<script type="application/ld+json">${JSON.stringify(schema)}</script>`);
119
+ for (const s of schemas) {
120
+ parts.push(`<script type="application/ld+json">${JSON.stringify(s)}</script>`);
115
121
  }
116
122
  }
117
123
  if (extraHead) parts.push(extraHead);
@@ -120,103 +126,118 @@ function buildHeadMeta(seo, extraHead = "") {
120
126
  function mergeSEO(base, override) {
121
127
  return { ...base ?? {}, ...override ?? {} };
122
128
  }
123
- var _assets = null;
129
+ var _assetsCache = null;
124
130
  async function resolveAssets(cfg, defaultEntry) {
125
- if (_assets) return _assets;
131
+ if (_assetsCache) return _assetsCache;
126
132
  if (cfg.manifestDir) {
127
133
  try {
128
134
  const [{ readFileSync }, { join }] = await Promise.all([
129
135
  import("fs"),
130
136
  import("path")
131
137
  ]);
132
- const raw = readFileSync(join(cfg.manifestDir, "manifest.json"), "utf-8");
138
+ const raw = readFileSync(join(cfg.manifestDir, ".vite", "manifest.json"), "utf-8");
133
139
  const manifest = JSON.parse(raw);
134
- const entryKey = cfg.manifestEntry ?? Object.keys(manifest).find((k) => k.endsWith(defaultEntry)) ?? defaultEntry;
135
- const entry = manifest[entryKey];
140
+ const key = cfg.manifestEntry ?? Object.keys(manifest).find((k) => k.endsWith(defaultEntry)) ?? defaultEntry;
141
+ const entry = manifest[key];
136
142
  if (entry) {
137
- _assets = {
143
+ _assetsCache = {
138
144
  scripts: [`/assets/${entry.file}`],
139
145
  styles: (entry.css ?? []).map((f) => `/assets/${f}`)
140
146
  };
141
- return _assets;
147
+ return _assetsCache;
142
148
  }
143
149
  } catch {
144
150
  }
145
151
  }
146
- _assets = {
152
+ _assetsCache = {
147
153
  scripts: cfg.scripts ?? ["/assets/client.js"],
148
154
  styles: cfg.styles ?? []
149
155
  };
150
- return _assets;
156
+ return _assetsCache;
151
157
  }
152
- function buildShell(o) {
153
- const htmlAttrStr = Object.entries(o.htmlAttrs ?? { lang: "en" }).map(([k, v]) => `${k}="${esc(v)}"`).join(" ");
154
- const styleLinks = o.styles.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join("\n");
155
- const scriptTags = o.scripts.map((src) => `<script type="module" src="${esc(src)}"></script>`).join("\n");
156
- return [
158
+ function buildShellParts(title, metaHtml, stateJson, paramsJson, seoJson, scripts, styles, htmlAttrs) {
159
+ const attrs = Object.entries(htmlAttrs ?? { lang: "en" }).map(([k, v]) => `${k}="${esc(v)}"`).join(" ");
160
+ const styleLinks = styles.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join("\n");
161
+ const scriptTags = scripts.map((src) => `<script type="module" src="${esc(src)}"></script>`).join("\n");
162
+ const head = [
157
163
  "<!DOCTYPE html>",
158
- `<html ${htmlAttrStr}>`,
164
+ `<html ${attrs}>`,
159
165
  "<head>",
160
166
  '<meta charset="UTF-8">',
161
167
  '<meta name="viewport" content="width=device-width,initial-scale=1">',
162
- `<title>${esc(o.title)}</title>`,
163
- o.metaHtml,
164
- generateHydrationScript(),
168
+ `<title>${esc(title)}</title>`,
169
+ metaHtml,
165
170
  styleLinks,
166
171
  "</head>",
167
172
  "<body>",
168
- `<div id="fnetro-app">${o.bodyHtml}</div>`,
173
+ '<div id="fnetro-app">'
174
+ ].filter(Boolean).join("\n");
175
+ const tail = [
176
+ "</div>",
169
177
  "<script>",
170
- `window.${STATE_KEY}=${o.stateJson};`,
171
- `window.${PARAMS_KEY}=${o.paramsJson};`,
172
- `window.${SEO_KEY}=${o.seoJson};`,
178
+ `window.${STATE_KEY}=${stateJson};`,
179
+ `window.${PARAMS_KEY}=${paramsJson};`,
180
+ `window.${SEO_KEY}=${seoJson};`,
173
181
  "</script>",
174
182
  scriptTags,
175
183
  "</body>",
176
184
  "</html>"
177
- ].filter(Boolean).join("\n");
185
+ ].join("\n");
186
+ return { head, tail };
187
+ }
188
+ async function resolveComponent(comp) {
189
+ if (isAsyncLoader(comp)) {
190
+ const mod = await comp();
191
+ return mod.default ?? mod;
192
+ }
193
+ return comp;
178
194
  }
179
195
  async function renderPage(route, data, url, params, appLayout) {
180
196
  const layout = route.layout !== void 0 ? route.layout : appLayout;
181
- return renderToStringAsync(() => {
182
- const pageEl = createComponent(route.page.Page, { ...data, url, params });
183
- const content = layout ? createComponent(layout.Component, {
184
- url,
185
- params,
186
- get children() {
187
- return pageEl;
188
- }
189
- }) : pageEl;
190
- return createComponent(Router, {
191
- url,
192
- get children() {
193
- return content;
194
- }
195
- });
197
+ const PageComp = await resolveComponent(route.page.component);
198
+ const routeComp = layout ? defineComponent({
199
+ name: "FNetroRoute",
200
+ setup: () => () => h(layout.component, null, {
201
+ default: () => h(PageComp)
202
+ })
203
+ }) : PageComp;
204
+ const app = createSSRApp({ render: () => h(RouterView) });
205
+ app.provide(DATA_KEY, data);
206
+ const router = createRouter({
207
+ history: createMemoryHistory(),
208
+ routes: [{ path: toVueRouterPath(route.fullPath), component: routeComp }]
196
209
  });
210
+ app.use(router);
211
+ await router.push(url);
212
+ await router.isReady();
213
+ return renderToWebStream(app);
197
214
  }
198
- async function renderFullPage(route, data, url, params, config, assets) {
199
- const pageSEO = typeof route.page.seo === "function" ? route.page.seo(data, params) : route.page.seo;
200
- const seo = mergeSEO(config.seo, pageSEO);
201
- const title = seo.title ?? "FNetro";
202
- const bodyHtml = await renderPage(route, data, url, params, config.layout);
203
- return buildShell({
204
- title,
205
- metaHtml: buildHeadMeta(seo, config.head),
206
- bodyHtml,
207
- stateJson: JSON.stringify({ [url]: data }),
208
- paramsJson: JSON.stringify(params),
209
- seoJson: JSON.stringify(seo),
210
- scripts: assets.scripts,
211
- styles: assets.styles,
212
- htmlAttrs: config.htmlAttrs
213
- });
215
+ function buildResponseStream(headHtml, bodyStream, tailHtml) {
216
+ const enc = new TextEncoder();
217
+ const { readable, writable } = new TransformStream();
218
+ (async () => {
219
+ const writer = writable.getWriter();
220
+ try {
221
+ await writer.write(enc.encode(headHtml));
222
+ const reader = bodyStream.getReader();
223
+ while (true) {
224
+ const { done, value } = await reader.read();
225
+ if (done) break;
226
+ await writer.write(value);
227
+ }
228
+ await writer.write(enc.encode(tailHtml));
229
+ await writer.close();
230
+ } catch (err) {
231
+ await writer.abort(err);
232
+ }
233
+ })();
234
+ return readable;
214
235
  }
215
236
  function createFNetro(config) {
216
237
  const app = new Hono();
217
238
  for (const mw of config.middleware ?? []) app.use("*", mw);
218
239
  const { pages, apis } = resolveRoutes(config.routes, {
219
- layout: config.layout,
240
+ ...config.layout !== void 0 && { layout: config.layout },
220
241
  middleware: []
221
242
  });
222
243
  const compiled = pages.map((r) => ({ route: r, cp: compilePath(r.fullPath) }));
@@ -240,45 +261,57 @@ function createFNetro(config) {
240
261
  }
241
262
  if (!matched) {
242
263
  if (config.notFound) {
243
- const html2 = await renderToStringAsync(
244
- () => createComponent(config.notFound, {})
245
- );
246
- return c.html(
247
- `<!DOCTYPE html><html lang="en"><body>${html2}</body></html>`,
248
- 404
249
- );
264
+ const html = await renderToString(createSSRApp(config.notFound));
265
+ return c.html(`<!DOCTYPE html><html lang="en"><body>${html}</body></html>`, 404);
250
266
  }
251
267
  return c.text("Not Found", 404);
252
268
  }
253
269
  const { route, params } = matched;
254
270
  const origParam = c.req.param.bind(c.req);
255
271
  c.req["param"] = (key) => key != null ? params[key] ?? origParam(key) : { ...origParam(), ...params };
256
- let early;
257
- const handlers = [...route.middleware];
272
+ let earlyResponse;
258
273
  let idx = 0;
259
274
  const runNext = async () => {
260
- const mw = handlers[idx++];
275
+ const mw = route.middleware[idx++];
261
276
  if (!mw) return;
262
277
  const res = await mw(c, runNext);
263
- if (res instanceof Response && !early) early = res;
278
+ if (res instanceof Response && !earlyResponse) earlyResponse = res;
264
279
  };
265
280
  await runNext();
266
- if (early) return early;
281
+ if (earlyResponse) return earlyResponse;
267
282
  const rawData = route.page.loader ? await route.page.loader(c) : {};
268
283
  const data = rawData ?? {};
269
284
  if (isSPA) {
270
- const pageSEO = typeof route.page.seo === "function" ? route.page.seo(data, params) : route.page.seo;
285
+ const pageSEO2 = typeof route.page.seo === "function" ? route.page.seo(data, params) : route.page.seo;
271
286
  return c.json({
272
287
  state: data,
273
288
  params,
274
289
  url: pathname,
275
- seo: mergeSEO(config.seo, pageSEO)
290
+ seo: mergeSEO(config.seo, pageSEO2)
276
291
  });
277
292
  }
278
293
  const clientEntry = config.assets?.manifestEntry ?? "client.ts";
279
294
  const assets = isDev ? { scripts: [`/${clientEntry}`], styles: [] } : await resolveAssets(config.assets ?? {}, clientEntry);
280
- const html = await renderFullPage(route, data, pathname, params, config, assets);
281
- return c.html(html);
295
+ const pageSEO = typeof route.page.seo === "function" ? route.page.seo(data, params) : route.page.seo;
296
+ const seo = mergeSEO(config.seo, pageSEO);
297
+ const title = seo.title ?? "FNetro";
298
+ const { head, tail } = buildShellParts(
299
+ title,
300
+ buildHeadMeta(seo, config.head),
301
+ JSON.stringify({ [pathname]: data }),
302
+ JSON.stringify(params),
303
+ JSON.stringify(seo),
304
+ assets.scripts,
305
+ assets.styles,
306
+ config.htmlAttrs
307
+ );
308
+ const bodyStream = await renderPage(route, data, pathname, params, config.layout);
309
+ const stream = buildResponseStream(head, bodyStream, tail);
310
+ return c.body(stream, 200, {
311
+ "Content-Type": "text/html; charset=UTF-8",
312
+ "Transfer-Encoding": "chunked",
313
+ "X-Content-Type-Options": "nosniff"
314
+ });
282
315
  });
283
316
  return { app, handler: app.fetch.bind(app) };
284
317
  }
@@ -295,7 +328,7 @@ async function serve(opts) {
295
328
  const staticDir = opts.staticDir ?? "./dist";
296
329
  const displayHost = hostname === "0.0.0.0" ? "localhost" : hostname;
297
330
  const logReady = () => console.log(`
298
- \u{1F525} FNetro [${runtime}] ready \u2192 http://${displayHost}:${port}
331
+ \u{1F525} FNetro [${runtime}] \u2192 http://${displayHost}:${port}
299
332
  `);
300
333
  switch (runtime) {
301
334
  case "node": {
@@ -309,38 +342,21 @@ async function serve(opts) {
309
342
  logReady();
310
343
  break;
311
344
  }
312
- case "bun": {
345
+ case "bun":
313
346
  ;
314
347
  globalThis["Bun"].serve({ fetch: opts.app.handler, port, hostname });
315
348
  logReady();
316
349
  break;
317
- }
318
- case "deno": {
350
+ case "deno":
319
351
  ;
320
352
  globalThis["Deno"].serve({ port, hostname }, opts.app.handler);
321
353
  logReady();
322
354
  break;
323
- }
324
355
  default:
325
- console.warn(
326
- "[fnetro] serve() is a no-op on edge runtimes \u2014 export `fnetro.handler` instead."
327
- );
356
+ console.warn("[fnetro] serve() is a no-op on edge \u2014 export fnetro.handler instead.");
328
357
  }
329
358
  }
330
359
  var NODE_BUILTINS = /^node:|^(assert|buffer|child_process|cluster|crypto|dgram|dns|domain|events|fs|http|https|module|net|os|path|perf_hooks|process|punycode|querystring|readline|repl|stream|string_decoder|sys|timers|tls|trace_events|tty|url|util|v8|vm|worker_threads|zlib)$/;
331
- async function loadSolid() {
332
- try {
333
- const mod = await import("vite-plugin-solid");
334
- return mod.default ?? mod;
335
- } catch {
336
- throw new Error(
337
- "[fnetro] vite-plugin-solid is required.\n Install it: npm i -D vite-plugin-solid"
338
- );
339
- }
340
- }
341
- function toPlugins(v) {
342
- return Array.isArray(v) ? v : [v];
343
- }
344
360
  function fnetroVitePlugin(opts = {}) {
345
361
  const {
346
362
  serverEntry = "server.ts",
@@ -348,85 +364,46 @@ function fnetroVitePlugin(opts = {}) {
348
364
  serverOutDir = "dist/server",
349
365
  clientOutDir = "dist/assets",
350
366
  serverExternal = [],
351
- solidOptions = {}
367
+ vueOptions = {}
352
368
  } = opts;
353
- let _solid = null;
354
- let _solidPlugins = [];
355
- const jsxPlugin = {
356
- name: "fnetro:jsx",
357
- enforce: "pre",
358
- // Sync config hook — must return Omit<UserConfig, 'plugins'> | null
359
- // Note: Vite 6+ deprecated `esbuild.jsx`; Vite 8 uses `oxc` instead.
360
- config(_cfg, env) {
361
- return {
362
- oxc: {
363
- jsx: "automatic",
364
- jsxImportSource: "solid-js"
365
- }
366
- };
367
- },
368
- async buildStart() {
369
- if (!_solid) {
370
- _solid = await loadSolid();
371
- _solidPlugins = toPlugins(_solid({ ssr: true, ...solidOptions }));
372
- }
373
- }
374
- };
375
- const solidProxy = {
376
- name: "fnetro:solid-proxy",
377
- enforce: "pre",
378
- async transform(code, id, options) {
379
- if (!_solidPlugins[0]?.transform) return null;
380
- const hook = _solidPlugins[0].transform;
381
- const fn = typeof hook === "function" ? hook : hook.handler;
382
- if (!fn) return null;
383
- return fn.call(this, code, id, options);
384
- },
385
- async resolveId(id) {
386
- if (!_solidPlugins[0]?.resolveId) return null;
387
- const hook = _solidPlugins[0].resolveId;
388
- const fn = typeof hook === "function" ? hook : hook.handler;
389
- if (!fn) return null;
390
- return fn.call(this, id, void 0, {});
391
- },
392
- async load(id) {
393
- if (!_solidPlugins[0]?.load) return null;
394
- const hook = _solidPlugins[0].load;
395
- const fn = typeof hook === "function" ? hook : hook.handler;
396
- if (!fn) return null;
397
- return fn.call(this, id, {});
398
- }
399
- };
400
- const buildPlugin = {
369
+ return {
401
370
  name: "fnetro:build",
402
371
  apply: "build",
403
372
  enforce: "pre",
404
- // Sync config hook — Omit<UserConfig, 'plugins'> satisfies the ObjectHook constraint
405
- config(_cfg, _env) {
373
+ // Server (SSR) bundle configuration
374
+ config() {
406
375
  return {
407
376
  build: {
408
377
  ssr: serverEntry,
409
378
  outDir: serverOutDir,
410
379
  rollupOptions: {
411
380
  input: serverEntry,
412
- output: {
413
- format: "es",
414
- entryFileNames: "server.js"
415
- },
416
- external: (id) => NODE_BUILTINS.test(id) || id === "@hono/node-server" || id === "@hono/node-server/serve-static" || id === "@solidjs/router" || id.startsWith("@solidjs/router/") || serverExternal.includes(id)
381
+ output: { format: "es", entryFileNames: "server.js" },
382
+ external: (id) => NODE_BUILTINS.test(id) || id === "vue" || id.startsWith("vue/") || id === "vue-router" || id === "@vue/server-renderer" || id === "@vitejs/plugin-vue" || id === "@hono/node-server" || id === "@hono/node-server/serve-static" || serverExternal.includes(id)
417
383
  }
418
384
  }
419
385
  };
420
386
  },
387
+ // After the server bundle is written, trigger the client SPA build
421
388
  async closeBundle() {
422
389
  console.log("\n\u26A1 FNetro: building client bundle\u2026\n");
423
- const solid = _solid ?? await loadSolid();
424
- const { build } = await import("vite");
390
+ let vuePlugin;
391
+ try {
392
+ const mod = await import("@vitejs/plugin-vue");
393
+ const factory = mod.default ?? mod;
394
+ vuePlugin = factory(vueOptions);
395
+ } catch {
396
+ throw new Error(
397
+ "[fnetro] @vitejs/plugin-vue is required for the client build.\n Install: npm i -D @vitejs/plugin-vue"
398
+ );
399
+ }
400
+ const plugins = Array.isArray(vuePlugin) ? vuePlugin : [vuePlugin];
425
401
  await build({
426
402
  configFile: false,
427
- plugins: toPlugins(solid({ ...solidOptions })),
403
+ plugins,
428
404
  build: {
429
405
  outDir: clientOutDir,
406
+ // Vite 5+ writes manifest to <outDir>/.vite/manifest.json
430
407
  manifest: true,
431
408
  rollupOptions: {
432
409
  input: clientEntry,
@@ -442,9 +419,9 @@ function fnetroVitePlugin(opts = {}) {
442
419
  console.log("\u2705 FNetro: both bundles ready\n");
443
420
  }
444
421
  };
445
- return [jsxPlugin, solidProxy, buildPlugin];
446
422
  }
447
423
  export {
424
+ DATA_KEY,
448
425
  PARAMS_KEY,
449
426
  SEO_KEY,
450
427
  SPA_HEADER,
@@ -457,7 +434,9 @@ export {
457
434
  definePage,
458
435
  detectRuntime,
459
436
  fnetroVitePlugin,
437
+ isAsyncLoader,
460
438
  matchPath,
461
439
  resolveRoutes,
462
- serve
440
+ serve,
441
+ toVueRouterPath
463
442
  };
@@ -0,0 +1,99 @@
1
+ import { Component } from 'vue';
2
+ import { Hono, MiddlewareHandler, Context } from 'hono';
3
+
4
+ type HonoMiddleware = MiddlewareHandler;
5
+ type LoaderCtx = Context;
6
+ interface SEOMeta {
7
+ title?: string;
8
+ description?: string;
9
+ keywords?: string;
10
+ author?: string;
11
+ robots?: string;
12
+ canonical?: string;
13
+ themeColor?: string;
14
+ ogTitle?: string;
15
+ ogDescription?: string;
16
+ ogImage?: string;
17
+ ogImageAlt?: string;
18
+ ogUrl?: string;
19
+ ogType?: string;
20
+ ogSiteName?: string;
21
+ twitterCard?: 'summary' | 'summary_large_image' | 'app' | 'player';
22
+ twitterSite?: string;
23
+ twitterTitle?: string;
24
+ twitterDescription?: string;
25
+ twitterImage?: string;
26
+ /** Structured data injected as <script type="application/ld+json">. */
27
+ jsonLd?: Record<string, unknown> | Record<string, unknown>[];
28
+ }
29
+ type AsyncLoader = () => Promise<{
30
+ default: Component;
31
+ } | Component>;
32
+ interface PageDef<TData extends object = Record<string, never>> {
33
+ readonly __type: 'page';
34
+ path: string;
35
+ middleware?: HonoMiddleware[];
36
+ loader?: (c: LoaderCtx) => TData | Promise<TData>;
37
+ seo?: SEOMeta | ((data: TData, params: Record<string, string>) => SEOMeta);
38
+ /** Override or disable the app-level layout for this route. */
39
+ layout?: LayoutDef | false;
40
+ /**
41
+ * The Vue component to render for this route.
42
+ * Use () => import('./Page.vue') for automatic code splitting.
43
+ */
44
+ component: Component | AsyncLoader;
45
+ }
46
+ interface GroupDef {
47
+ readonly __type: 'group';
48
+ prefix: string;
49
+ layout?: LayoutDef | false;
50
+ middleware?: HonoMiddleware[];
51
+ routes: Route[];
52
+ }
53
+ interface LayoutDef {
54
+ readonly __type: 'layout';
55
+ /** Vue layout component — must contain <slot /> for page content. */
56
+ component: Component;
57
+ }
58
+ interface ApiRouteDef {
59
+ readonly __type: 'api';
60
+ path: string;
61
+ register: (app: Hono, globalMiddleware: HonoMiddleware[]) => void;
62
+ }
63
+ type Route = PageDef<any> | GroupDef | ApiRouteDef;
64
+ interface AppConfig {
65
+ layout?: LayoutDef;
66
+ seo?: SEOMeta;
67
+ middleware?: HonoMiddleware[];
68
+ routes: Route[];
69
+ notFound?: Component;
70
+ htmlAttrs?: Record<string, string>;
71
+ /** Extra HTML injected into <head> (e.g. font preloads). */
72
+ head?: string;
73
+ }
74
+ interface ResolvedRoute {
75
+ fullPath: string;
76
+ page: PageDef<any>;
77
+ layout: LayoutDef | false | undefined;
78
+ middleware: HonoMiddleware[];
79
+ }
80
+ interface CompiledPath {
81
+ re: RegExp;
82
+ keys: string[];
83
+ }
84
+ type ClientMiddleware = (url: string, next: () => Promise<void>) => Promise<void>;
85
+ /** Custom request header that identifies an SPA navigation (JSON payload). */
86
+ declare const SPA_HEADER = "x-fnetro-spa";
87
+ /** window key for SSR-injected per-page loader data. */
88
+ declare const STATE_KEY = "__FNETRO_STATE__";
89
+ /** window key for SSR-injected URL params. */
90
+ declare const PARAMS_KEY = "__FNETRO_PARAMS__";
91
+ /** window key for SSR-injected SEO meta. */
92
+ declare const SEO_KEY = "__FNETRO_SEO__";
93
+ /**
94
+ * Vue provide/inject key for the reactive page-data object.
95
+ * Symbol.for() ensures the same reference across module instances (SSR safe).
96
+ */
97
+ declare const DATA_KEY: unique symbol;
98
+
99
+ export { type ApiRouteDef, type AppConfig, type AsyncLoader, type ClientMiddleware, type CompiledPath, DATA_KEY, type GroupDef, type HonoMiddleware, type LayoutDef, type LoaderCtx, PARAMS_KEY, type PageDef, type ResolvedRoute, type Route, type SEOMeta, SEO_KEY, SPA_HEADER, STATE_KEY };