@lovrozagar/flare 0.1.2 → 0.2.1

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.
Files changed (85) hide show
  1. package/README.md +42 -19
  2. package/package.json +8 -6
  3. package/src/broadcast/index.ts +1 -1
  4. package/src/broadcast/provider.tsx +2 -2
  5. package/src/caches/index.ts +26 -0
  6. package/src/components/await.tsx +36 -39
  7. package/src/components/dev-error-overlay.tsx +8 -9
  8. package/src/components/direction-script.tsx +2 -2
  9. package/src/components/locale-script.tsx +2 -2
  10. package/src/components/reset-css.tsx +1 -1
  11. package/src/components/ssr-context.tsx +7 -14
  12. package/src/components/theme-script.tsx +2 -2
  13. package/src/components/view-transition-css.tsx +1 -1
  14. package/src/defer/index.ts +22 -0
  15. package/src/direction/index.tsx +61 -36
  16. package/src/errors/index.ts +45 -11
  17. package/src/fonts/component.tsx +2 -1
  18. package/src/form/index.tsx +18 -12
  19. package/src/generators/index.ts +73 -30
  20. package/src/head-client/index.ts +86 -29
  21. package/src/history/index.ts +6 -0
  22. package/src/hydrate/index.tsx +90 -60
  23. package/src/hydration/index.ts +26 -6
  24. package/src/i18n/index.ts +11 -2
  25. package/src/image/index.tsx +7 -4
  26. package/src/intercept-outlet/index.tsx +6 -2
  27. package/src/lazy/index.tsx +50 -50
  28. package/src/link/index.tsx +97 -58
  29. package/src/loader-pipeline/index.ts +119 -38
  30. package/src/locale/cookie.ts +15 -0
  31. package/src/locale/index.tsx +24 -35
  32. package/src/locale.ts +1 -0
  33. package/src/middleware/builtins/api-proxy.ts +58 -4
  34. package/src/middleware/builtins/cdn-proxy.ts +24 -3
  35. package/src/middleware/builtins/i18n.ts +23 -25
  36. package/src/middleware/builtins/keepalive.ts +2 -3
  37. package/src/mount/index.ts +8 -1
  38. package/src/navigation/index.ts +575 -129
  39. package/src/navigation/types.ts +11 -1
  40. package/src/navigation-progress/index.tsx +17 -16
  41. package/src/ndjson-client/index.ts +42 -17
  42. package/src/ndjson-server/index.ts +78 -29
  43. package/src/outlet/index.tsx +192 -97
  44. package/src/outlet/types.ts +9 -1
  45. package/src/plugins/dev-cdn-cache.ts +18 -5
  46. package/src/plugins/dev-dashboard/client.tsx +130 -118
  47. package/src/plugins/dev-prerender.ts +6 -1
  48. package/src/plugins/dev-server.ts +12 -3
  49. package/src/plugins/image-plugin.ts +18 -4
  50. package/src/plugins/index.ts +10 -7
  51. package/src/plugins/prerender-plugin.ts +3 -3
  52. package/src/plugins/server-fn.ts +15 -20
  53. package/src/plugins/solid-jsx.d.ts +3 -3
  54. package/src/plugins/sx-ast/atomic.ts +5 -5
  55. package/src/plugins/sx-ast/index.ts +9 -0
  56. package/src/plugins/sx.d.ts +4 -5
  57. package/src/plugins/virtual.ts +11 -6
  58. package/src/prerender/index.ts +68 -32
  59. package/src/protocol.ts +65 -0
  60. package/src/query-client/index.tsx +73 -14
  61. package/src/rewrite/index.ts +10 -4
  62. package/src/route-builder/create-layout.ts +1 -1
  63. package/src/route-builder/create-root-layout.ts +1 -1
  64. package/src/route-builder/types.ts +5 -1
  65. package/src/router-primitives/types.ts +2 -0
  66. package/src/security/index.ts +41 -5
  67. package/src/server/index.ts +1 -2
  68. package/src/server-fn/get-input.ts +90 -0
  69. package/src/server-fn/http-error.ts +13 -0
  70. package/src/server-fn/index.ts +94 -70
  71. package/src/server-fn-query.ts +9 -17
  72. package/src/server-handler/etag.ts +4 -2
  73. package/src/server-handler/headers.ts +1 -2
  74. package/src/server-handler/index.ts +166 -58
  75. package/src/server-handler/types.ts +2 -0
  76. package/src/service-worker/template.ts +10 -42
  77. package/src/ssr/head-prefix.ts +2 -1
  78. package/src/ssr/hoist-head-markers.ts +25 -0
  79. package/src/ssr/index.tsx +109 -92
  80. package/src/state-parser/index.ts +38 -26
  81. package/src/styles/index.ts +89 -60
  82. package/src/testing/index.ts +8 -3
  83. package/src/theme/index.tsx +35 -32
  84. package/src/tracing/timing.ts +6 -1
  85. package/src/url/index.ts +9 -0
@@ -1,5 +1,5 @@
1
- import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js";
2
- import { render } from "solid-js/web";
1
+ import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js";
2
+ import { render } from "@solidjs/web";
3
3
  import type { LocaleMatch } from "../../router-primitives/tree.ts";
4
4
  import { matchRouteTree } from "./match-route-tree.ts";
5
5
 
@@ -1070,12 +1070,15 @@ function VirtualList<T>(props: {
1070
1070
  }
1071
1071
  };
1072
1072
 
1073
- createEffect(() => {
1074
- if (scrollEl) {
1075
- setViewHeight(scrollEl.clientHeight);
1076
- if (props.containerRef) props.containerRef(scrollEl);
1077
- }
1078
- });
1073
+ createEffect(
1074
+ () => scrollEl,
1075
+ (el) => {
1076
+ if (el) {
1077
+ setViewHeight(el.clientHeight);
1078
+ if (props.containerRef) props.containerRef(el);
1079
+ }
1080
+ },
1081
+ );
1079
1082
 
1080
1083
  return (
1081
1084
  <div
@@ -1142,9 +1145,7 @@ function TreeRow(props: {
1142
1145
  >
1143
1146
  {typeBadgeLabel(props.node.type)}
1144
1147
  </span>
1145
- <span class="tree-segment" classList={{ "is-group": isGroup(), "is-param": isParam() }}>
1146
- {label()}
1147
- </span>
1148
+ <span class={["tree-segment", { "is-group": isGroup(), "is-param": isParam() }]}>{label()}</span>
1148
1149
  <Show when={props.node.urlPath && props.node.type !== "segment"}>
1149
1150
  <span class="tree-url">{props.node.urlPath}</span>
1150
1151
  </Show>
@@ -1228,7 +1229,7 @@ function ListItem(props: {
1228
1229
  }
1229
1230
  }}
1230
1231
  role="button"
1231
- tabIndex={0}
1232
+ tabindex={0}
1232
1233
  >
1233
1234
  <div class="list-item-header">
1234
1235
  <span class="list-item-url">{props.urlPath}</span>
@@ -1404,16 +1405,16 @@ function ServerFnsTab(props: {
1404
1405
  <table class="fn-table">
1405
1406
  <thead>
1406
1407
  <tr>
1407
- <th classList={{ sorted: sortCol() === "name" }} onClick={() => handleSort("name")}>
1408
+ <th class={{ sorted: sortCol() === "name" }} onClick={() => handleSort("name")}>
1408
1409
  Name{sortIndicator("name")}
1409
1410
  </th>
1410
- <th classList={{ sorted: sortCol() === "method" }} onClick={() => handleSort("method")}>
1411
+ <th class={{ sorted: sortCol() === "method" }} onClick={() => handleSort("method")}>
1411
1412
  Method{sortIndicator("method")}
1412
1413
  </th>
1413
- <th classList={{ sorted: sortCol() === "authenticate" }} onClick={() => handleSort("authenticate")}>
1414
+ <th class={{ sorted: sortCol() === "authenticate" }} onClick={() => handleSort("authenticate")}>
1414
1415
  Auth{sortIndicator("authenticate")}
1415
1416
  </th>
1416
- <th classList={{ sorted: sortCol() === "stream" }} onClick={() => handleSort("stream")}>
1417
+ <th class={{ sorted: sortCol() === "stream" }} onClick={() => handleSort("stream")}>
1417
1418
  Stream{sortIndicator("stream")}
1418
1419
  </th>
1419
1420
  <th>File</th>
@@ -1531,7 +1532,7 @@ function ChainNode(props: {
1531
1532
  }
1532
1533
  }}
1533
1534
  role="button"
1534
- tabIndex={0}
1535
+ tabindex={0}
1535
1536
  >
1536
1537
  <div class="cur-chain-header">
1537
1538
  <span
@@ -1543,7 +1544,7 @@ function ChainNode(props: {
1543
1544
  >
1544
1545
  {typeBadgeLabel(props.node.type)}
1545
1546
  </span>
1546
- <span class="cur-chain-seg" classList={{ "is-group": isGroup(), "is-param": isParam() }}>
1547
+ <span class={["cur-chain-seg", { "is-group": isGroup(), "is-param": isParam() }]}>
1547
1548
  {props.node.segment || "/"}
1548
1549
  </span>
1549
1550
  <Show when={props.node.filePath}>
@@ -1667,61 +1668,67 @@ function CurrentTab(props: {
1667
1668
  const [docTitle, setDocTitle] = createSignal(document.title);
1668
1669
 
1669
1670
  /* Watch URL changes */
1670
- createEffect(() => {
1671
- const update = () => {
1672
- setUrl({
1673
- hash: window.location.hash,
1674
- pathname: window.location.pathname,
1675
- search: window.location.search,
1676
- });
1677
- setDocTitle(document.title);
1678
- };
1671
+ createEffect(
1672
+ () => undefined,
1673
+ () => {
1674
+ const update = () => {
1675
+ setUrl({
1676
+ hash: window.location.hash,
1677
+ pathname: window.location.pathname,
1678
+ search: window.location.search,
1679
+ });
1680
+ setDocTitle(document.title);
1681
+ };
1679
1682
 
1680
- window.addEventListener("popstate", update);
1683
+ window.addEventListener("popstate", update);
1681
1684
 
1682
- const origPush = history.pushState.bind(history);
1683
- const origReplace = history.replaceState.bind(history);
1684
- history.pushState = (...args: Parameters<typeof history.pushState>) => {
1685
- origPush(...args);
1686
- update();
1687
- };
1688
- history.replaceState = (...args: Parameters<typeof history.replaceState>) => {
1689
- origReplace(...args);
1690
- update();
1691
- };
1685
+ const origPush = history.pushState.bind(history);
1686
+ const origReplace = history.replaceState.bind(history);
1687
+ history.pushState = (...args: Parameters<typeof history.pushState>) => {
1688
+ origPush(...args);
1689
+ update();
1690
+ };
1691
+ history.replaceState = (...args: Parameters<typeof history.replaceState>) => {
1692
+ origReplace(...args);
1693
+ update();
1694
+ };
1692
1695
 
1693
- onCleanup(() => {
1694
- window.removeEventListener("popstate", update);
1695
- history.pushState = origPush;
1696
- history.replaceState = origReplace;
1697
- });
1698
- });
1696
+ return () => {
1697
+ window.removeEventListener("popstate", update);
1698
+ history.pushState = origPush;
1699
+ history.replaceState = origReplace;
1700
+ };
1701
+ },
1702
+ );
1699
1703
 
1700
1704
  /* Watch <html> attributes + title */
1701
- createEffect(() => {
1702
- const observer = new MutationObserver(() => {
1703
- setHtmlAttrs({
1704
- dir: document.documentElement.getAttribute("dir") ?? "ltr",
1705
- lang: document.documentElement.getAttribute("lang") ?? "",
1706
- theme: document.documentElement.getAttribute("data-theme") ?? "",
1705
+ createEffect(
1706
+ () => undefined,
1707
+ () => {
1708
+ const observer = new MutationObserver(() => {
1709
+ setHtmlAttrs({
1710
+ dir: document.documentElement.getAttribute("dir") ?? "ltr",
1711
+ lang: document.documentElement.getAttribute("lang") ?? "",
1712
+ theme: document.documentElement.getAttribute("data-theme") ?? "",
1713
+ });
1714
+ });
1715
+ observer.observe(document.documentElement, {
1716
+ attributeFilter: ["data-theme", "dir", "lang"],
1717
+ attributes: true,
1707
1718
  });
1708
- });
1709
- observer.observe(document.documentElement, {
1710
- attributeFilter: ["data-theme", "dir", "lang"],
1711
- attributes: true,
1712
- });
1713
1719
 
1714
- const titleObs = new MutationObserver(() => setDocTitle(document.title));
1715
- const titleEl = document.querySelector("title");
1716
- if (titleEl) {
1717
- titleObs.observe(titleEl, { characterData: true, childList: true, subtree: true });
1718
- }
1720
+ const titleObs = new MutationObserver(() => setDocTitle(document.title));
1721
+ const titleEl = document.querySelector("title");
1722
+ if (titleEl) {
1723
+ titleObs.observe(titleEl, { characterData: true, childList: true, subtree: true });
1724
+ }
1719
1725
 
1720
- onCleanup(() => {
1721
- observer.disconnect();
1722
- titleObs.disconnect();
1723
- });
1724
- });
1726
+ return () => {
1727
+ observer.disconnect();
1728
+ titleObs.disconnect();
1729
+ };
1730
+ },
1731
+ );
1725
1732
 
1726
1733
  const matched = createMemo(() => matchRouteTree(props.tree, url().pathname, props.localeMatch));
1727
1734
  const searchParams = createMemo(() => parseSearchParams(url().search));
@@ -1730,20 +1737,20 @@ function CurrentTab(props: {
1730
1737
  const [runtimeMatches, setRuntimeMatches] = createSignal<RuntimeMatch[]>([]);
1731
1738
  const [cachedMatches, setCachedMatches] = createSignal<CachedMatchInfo[]>([]);
1732
1739
 
1733
- createEffect(() => {
1734
- const read = () => {
1735
- const matches = window.__flare_devtools_matches__;
1736
- if (matches) setRuntimeMatches(matches);
1737
- const cache = window.__flare_devtools_cache__;
1738
- if (cache) setCachedMatches(cache);
1739
- };
1740
- read();
1741
-
1742
- /* Re-read on navigation (URL signal triggers this effect) */
1743
- void url();
1744
- const timer = setTimeout(read, 100);
1745
- onCleanup(() => clearTimeout(timer));
1746
- });
1740
+ createEffect(
1741
+ () => url(),
1742
+ () => {
1743
+ const read = () => {
1744
+ const matches = window.__flare_devtools_matches__;
1745
+ if (matches) setRuntimeMatches(matches);
1746
+ const cache = window.__flare_devtools_cache__;
1747
+ if (cache) setCachedMatches(cache);
1748
+ };
1749
+ read();
1750
+ const timer = setTimeout(read, 100);
1751
+ return () => clearTimeout(timer);
1752
+ },
1753
+ );
1747
1754
 
1748
1755
  const runtimeMatchFor = (node: RouteTreeNode): RuntimeMatch | undefined => {
1749
1756
  if (!node.virtualPath) return undefined;
@@ -1785,12 +1792,14 @@ function CurrentTab(props: {
1785
1792
  setHeadTags(tags);
1786
1793
  };
1787
1794
 
1788
- createEffect(() => {
1789
- void url();
1790
- readHeadTags();
1791
- const timer = setTimeout(readHeadTags, 200);
1792
- onCleanup(() => clearTimeout(timer));
1793
- });
1795
+ createEffect(
1796
+ () => url(),
1797
+ () => {
1798
+ readHeadTags();
1799
+ const timer = setTimeout(readHeadTags, 200);
1800
+ return () => clearTimeout(timer);
1801
+ },
1802
+ );
1794
1803
 
1795
1804
  const leafMatch = createMemo(() => {
1796
1805
  const chain = matched().chain;
@@ -2068,7 +2077,7 @@ function CopyButton(props: {
2068
2077
  };
2069
2078
 
2070
2079
  return (
2071
- <button class="act-btn" classList={{ "is-copied": copied() }} onClick={handleCopy} type="button">
2080
+ <button class={["act-btn", { "is-copied": copied() }]} onClick={handleCopy} type="button">
2072
2081
  {copied() ? "Copied" : props.label}
2073
2082
  </button>
2074
2083
  );
@@ -2108,11 +2117,14 @@ function ActionsTab(props: {
2108
2117
  if (a) setCacheStats(a.getCacheStats());
2109
2118
  };
2110
2119
 
2111
- createEffect(() => {
2112
- refreshStats();
2113
- const timer = setInterval(refreshStats, 2000);
2114
- onCleanup(() => clearInterval(timer));
2115
- });
2120
+ createEffect(
2121
+ () => undefined,
2122
+ () => {
2123
+ refreshStats();
2124
+ const timer = setInterval(refreshStats, 2000);
2125
+ return () => clearInterval(timer);
2126
+ },
2127
+ );
2116
2128
 
2117
2129
  /* Navigate section */
2118
2130
  const pageRoutes = createMemo(() =>
@@ -2306,8 +2318,7 @@ function ActionsTab(props: {
2306
2318
  Forward
2307
2319
  </button>
2308
2320
  <button
2309
- class="act-btn"
2310
- classList={{ "is-copied": useReplace() }}
2321
+ class={["act-btn", { "is-copied": useReplace() }]}
2311
2322
  onClick={() => setUseReplace((r) => !r)}
2312
2323
  style={useReplace() ? { "border-color": "#3b82f6", color: "#3b82f6" } : {}}
2313
2324
  type="button"
@@ -2409,8 +2420,8 @@ function DevTools(): ReturnType<(typeof import("solid-js"))["createComponent"]>
2409
2420
  let searchRef: HTMLInputElement | null = null;
2410
2421
 
2411
2422
  /* Fetch data on open */
2412
- createEffect(() => {
2413
- if (isOpen()) {
2423
+ createEffect(isOpen, (open) => {
2424
+ if (open) {
2414
2425
  fetch("/__flare/api")
2415
2426
  .then((r) => r.json())
2416
2427
  .then((d) => setData(d as ApiData));
@@ -2418,9 +2429,9 @@ function DevTools(): ReturnType<(typeof import("solid-js"))["createComponent"]>
2418
2429
  });
2419
2430
 
2420
2431
  /* Persist state */
2421
- createEffect(() => {
2432
+ createEffect(activeTab, (tab) => {
2422
2433
  saveState({
2423
- activeTab: activeTab(),
2434
+ activeTab: tab,
2424
2435
  expandedNodes: [],
2425
2436
  sortCol: "name",
2426
2437
  sortDir: "asc",
@@ -2428,23 +2439,26 @@ function DevTools(): ReturnType<(typeof import("solid-js"))["createComponent"]>
2428
2439
  });
2429
2440
 
2430
2441
  /* Keyboard shortcuts */
2431
- createEffect(() => {
2432
- const handler = (e: KeyboardEvent) => {
2433
- if (e.ctrlKey && e.shiftKey && e.key === "D") {
2434
- e.preventDefault();
2435
- setIsOpen((o) => !o);
2436
- }
2437
- if (e.key === "Escape" && isOpen()) {
2438
- setIsOpen(false);
2439
- }
2440
- if (e.key === "/" && isOpen() && document.activeElement !== searchRef) {
2441
- e.preventDefault();
2442
- searchRef?.focus();
2443
- }
2444
- };
2445
- window.addEventListener("keydown", handler);
2446
- onCleanup(() => window.removeEventListener("keydown", handler));
2447
- });
2442
+ createEffect(
2443
+ () => undefined,
2444
+ () => {
2445
+ const handler = (e: KeyboardEvent) => {
2446
+ if (e.ctrlKey && e.shiftKey && e.key === "D") {
2447
+ e.preventDefault();
2448
+ setIsOpen((o) => !o);
2449
+ }
2450
+ if (e.key === "Escape" && isOpen()) {
2451
+ setIsOpen(false);
2452
+ }
2453
+ if (e.key === "/" && isOpen() && document.activeElement !== searchRef) {
2454
+ e.preventDefault();
2455
+ searchRef?.focus();
2456
+ }
2457
+ };
2458
+ window.addEventListener("keydown", handler);
2459
+ return () => window.removeEventListener("keydown", handler);
2460
+ },
2461
+ );
2448
2462
 
2449
2463
  const tabCount = (tabId: TabId): number | null => {
2450
2464
  const d = data();
@@ -2460,8 +2474,7 @@ function DevTools(): ReturnType<(typeof import("solid-js"))["createComponent"]>
2460
2474
  return (
2461
2475
  <>
2462
2476
  <button
2463
- class="toggle-btn"
2464
- classList={{ "is-open": isOpen() }}
2477
+ class={["toggle-btn", { "is-open": isOpen() }]}
2465
2478
  onClick={() => setIsOpen((o) => !o)}
2466
2479
  title="Flare DevTools (Ctrl+Shift+D)"
2467
2480
  type="button"
@@ -2483,8 +2496,7 @@ function DevTools(): ReturnType<(typeof import("solid-js"))["createComponent"]>
2483
2496
  <For each={TABS}>
2484
2497
  {(tab) => (
2485
2498
  <button
2486
- class="tab-btn"
2487
- classList={{ active: activeTab() === tab.id }}
2499
+ class={["tab-btn", { active: activeTab() === tab.id }]}
2488
2500
  onClick={() => {
2489
2501
  setActiveTab(tab.id);
2490
2502
  setSearch("");
@@ -48,7 +48,11 @@ export function filterDevPrerenderRoutes(defs: RouteDefinition[]): PrerenderRout
48
48
  /* skip dynamic routes — they need params we don't have at dev time */
49
49
  if (pathname.includes("[")) continue;
50
50
 
51
- routes.push({ mode: "static", pathname });
51
+ const route: PrerenderRoute = { mode: "static", pathname };
52
+ if (def.cache.cdnTags && def.cache.cdnTags.length > 0) {
53
+ route.tags = def.cache.cdnTags;
54
+ }
55
+ routes.push(route);
52
56
  }
53
57
 
54
58
  return routes;
@@ -103,6 +107,7 @@ export function createDevPrerenderPlugin(config: FlarePluginConfig, entries: Res
103
107
  ndjson: entry.ndjson,
104
108
  },
105
109
  storedAt: Date.now(),
110
+ tags: entry.tags,
106
111
  });
107
112
  }
108
113
 
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { hoistHydrationHeadMarkers } from "../ssr/hoist-head-markers.ts";
3
4
  import type { ResolvedEntries, VitePlugin } from "./types.ts";
4
5
 
5
6
  interface NodeReq {
@@ -200,10 +201,18 @@ export function createDevServerPlugin(entries: ResolvedEntries, _assetsBase: str
200
201
  */
201
202
  const lastSlash = url.pathname.lastIndexOf("/");
202
203
  const hasFileExt = url.pathname.indexOf(".", lastSlash) !== -1;
204
+ let htmlPath = "/";
205
+ if (!hasFileExt) {
206
+ try {
207
+ decodeURIComponent(url.pathname);
208
+ htmlPath = url.pathname;
209
+ } catch {
210
+ htmlPath = "/";
211
+ }
212
+ }
203
213
  const { html: peeledHtml, tags } = peelInlineTags(html);
204
- html = restoreInlineTags(
205
- await vite.transformIndexHtml(hasFileExt ? "/" : url.pathname, peeledHtml),
206
- tags,
214
+ html = hoistHydrationHeadMarkers(
215
+ restoreInlineTags(await vite.transformIndexHtml(htmlPath, peeledHtml), tags),
207
216
  );
208
217
 
209
218
  const htmlHeaders: Record<string, string | string[]> = {
@@ -1,13 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { realpathSync } from "node:fs";
3
- import { basename, dirname, extname, resolve } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
4
4
  import type { VitePlugin } from "./types.ts";
5
5
 
6
6
  const IMAGE_EXTS = new Set([".avif", ".gif", ".jpeg", ".jpg", ".png", ".tiff", ".webp"]);
7
7
  const ANIMATED_EXTS = new Set([".gif"]);
8
8
  const IMAGE_DEFAULT_WIDTHS = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
9
+ export const IMAGE_MAX_WIDTH = 3840;
9
10
  const IMAGE_DEFAULT_QUALITY = 75;
10
11
 
12
+ export function isAllowedImageWidth(w: number): boolean {
13
+ return Number.isInteger(w) && w >= 1 && w <= IMAGE_MAX_WIDTH;
14
+ }
15
+
11
16
  interface SharpInstance {
12
17
  metadata: () => Promise<{ height?: number; width?: number }>;
13
18
  resize: (width: number) => SharpInstance;
@@ -17,6 +22,14 @@ interface SharpInstance {
17
22
 
18
23
  type SharpFactory = (input: string) => SharpInstance;
19
24
 
25
+ /** True when `target` is `root` or a descendant. Prefix match is not enough (`/proj/flare-evil` vs `/proj/flare`). */
26
+ export function isPathWithinRoot(target: string, root: string): boolean {
27
+ const rel = relative(root, target);
28
+ if (rel === "") return true;
29
+ if (isAbsolute(rel)) return false;
30
+ return rel !== ".." && !rel.startsWith(`..${sep}`);
31
+ }
32
+
20
33
  async function importSharp(): Promise<SharpFactory> {
21
34
  try {
22
35
  const mod = await import("sharp");
@@ -64,18 +77,19 @@ export function createImagePlugin(
64
77
  const url = new URL(req.url, "http://localhost");
65
78
  const src = url.searchParams.get("src");
66
79
  const w = Number(url.searchParams.get("w"));
67
- if (!src || !w) return next();
80
+ if (!src || !isAllowedImageWidth(w)) return next();
68
81
 
69
82
  /* Guard against path traversal — resolve symlinks then check within cwd */
83
+ const cwd = process.cwd();
70
84
  const resolvedSrc = resolve(src);
71
- if (!resolvedSrc.startsWith(process.cwd())) return next();
85
+ if (!isPathWithinRoot(resolvedSrc, cwd)) return next();
72
86
  let realSrc: string;
73
87
  try {
74
88
  realSrc = realpathSync(resolvedSrc);
75
89
  } catch {
76
90
  return next();
77
91
  }
78
- if (!realSrc.startsWith(process.cwd())) return next();
92
+ if (!isPathWithinRoot(realSrc, cwd)) return next();
79
93
 
80
94
  const sharp = await importSharp();
81
95
  const sourceExt = extname(realSrc).toLowerCase();
@@ -1,8 +1,8 @@
1
1
  import { existsSync, watch } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import type { Options as SolidPluginOptions } from "vite-plugin-solid";
5
- import solid from "vite-plugin-solid";
4
+ import type { Options as SolidPluginOptions } from "@solidjs/vite-plugin";
5
+ import solid from "@solidjs/vite-plugin";
6
6
  import { runGenerate } from "../generators/index.ts";
7
7
  import { EMPTY_OBJ } from "../internal/index.ts";
8
8
  import type { LogLevel } from "../logger.ts";
@@ -285,11 +285,11 @@ function createSsrBuildPlugin(
285
285
  },
286
286
  },
287
287
  optimizeDeps: {
288
- include: ["solid-js", "solid-js/web", "solid-js/store"],
288
+ include: ["solid-js", "@solidjs/web"],
289
289
  },
290
290
  resolve: {
291
291
  ...(config.alias ? { alias: config.alias } : {}),
292
- dedupe: ["solid-js", "solid-js/web", "solid-js/store"],
292
+ dedupe: ["solid-js", "@solidjs/web"],
293
293
  },
294
294
  server: {
295
295
  ...(config.port ? { port: config.port } : {}),
@@ -298,7 +298,7 @@ function createSsrBuildPlugin(
298
298
  },
299
299
  },
300
300
  ssr: {
301
- noExternal: ["solid-js", "@lovrozagar/flare", "@tanstack/solid-query"],
301
+ noExternal: ["solid-js", "@solidjs/web", "@lovrozagar/flare", "@tanstack/solid-query"],
302
302
  },
303
303
  };
304
304
  },
@@ -386,16 +386,19 @@ export function flare(config: FlarePluginConfig = EMPTY_OBJ): VitePlugin[] {
386
386
  server: resolveEntry(root, "server", config.entry?.server),
387
387
  };
388
388
 
389
+ if (config.solid && "start" in config.solid && (config.solid as { start?: unknown }).start) {
390
+ throw new Error("Flare: solid.start is not supported. Flare owns entries, SSR, and server functions.");
391
+ }
392
+
389
393
  const solidConfig = {
390
394
  extensions: [".tsx", ".jsx"],
391
- hydratable: true,
392
395
  ssr: true,
393
396
  ...config.solid,
394
397
  };
395
398
 
396
399
  const resolvedSw = normalizeSwConfig(config.serviceWorker);
397
400
 
398
- /* TS 7: vite + vite-plugin-solid Plugin identities overflow. Keep this list on VitePlugin. */
401
+ /* TS 7: vite + @solidjs/vite-plugin Plugin identities overflow. Keep this list on VitePlugin. */
399
402
  const plugins: VitePlugin[] = [
400
403
  createTwDeprecatedPlugin(),
401
404
  /* manifest: true always enabled so virtual:flare-sx-manifest is populated at SSR build time */
@@ -92,13 +92,13 @@ export function createPrerenderPlugin(config: {
92
92
  const mod = (await import(`${serverPath}?t=${Date.now()}`)) as ServerModule;
93
93
 
94
94
  /* 4. Resolve static params for dynamic routes */
95
- const hasDynamic = defs.some(
96
- (d) => d.type === "page" && (d.cache.ssg === "dynamic" || d.cache.isr === "dynamic"),
95
+ const needsStaticParams = defs.some(
96
+ (d) => d.type === "page" && Boolean(d.cache.ssg || d.cache.isr) && d.virtualPath.includes("["),
97
97
  );
98
98
  const resolvedHandler = mod.server ?? mod.handler;
99
99
  if (!resolvedHandler) return;
100
100
  const staticParams =
101
- hasDynamic && resolvedHandler.getStaticParams ? await resolvedHandler.getStaticParams() : undefined;
101
+ needsStaticParams && resolvedHandler.getStaticParams ? await resolvedHandler.getStaticParams() : undefined;
102
102
 
103
103
  /* 5. Convert to prerender routes */
104
104
  const routes = buildPrerenderRoutes(defs, staticParams);
@@ -412,34 +412,29 @@ function stripCommentsAndStrings(code: string): string {
412
412
  * Preserves _registration (client needs id, name, method for RPC).
413
413
  * Uses paren-depth tracking to find matching closing paren.
414
414
  */
415
+ const CLIENT_STUB_TOKENS = [".handler(", ".stream(", ".authorize(", ".input("] as const;
416
+
417
+ function nextClientStub(code: string, from: number): { idx: number; token: string } | null {
418
+ let best: { idx: number; token: string } | null = null;
419
+ for (const token of CLIENT_STUB_TOKENS) {
420
+ const idx = code.indexOf(token, from);
421
+ if (idx === -1) continue;
422
+ if (!best || idx < best.idx) best = { idx, token };
423
+ }
424
+ return best;
425
+ }
426
+
415
427
  export function stripHandlerBodies(code: string): string {
416
428
  let result = "";
417
429
  let cursor = 0;
418
430
 
419
431
  while (cursor < code.length) {
420
- const handlerIdx = code.indexOf(".handler(", cursor);
421
- const streamIdx = code.indexOf(".stream(", cursor);
422
-
423
- /* pick whichever comes first, skip -1 (not found) */
424
- let idx: number;
425
- let token: string;
426
- if (handlerIdx === -1 && streamIdx === -1) {
432
+ const found = nextClientStub(code, cursor);
433
+ if (!found) {
427
434
  result += code.slice(cursor);
428
435
  break;
429
436
  }
430
- if (handlerIdx === -1) {
431
- idx = streamIdx;
432
- token = ".stream(";
433
- } else if (streamIdx === -1) {
434
- idx = handlerIdx;
435
- token = ".handler(";
436
- } else if (handlerIdx <= streamIdx) {
437
- idx = handlerIdx;
438
- token = ".handler(";
439
- } else {
440
- idx = streamIdx;
441
- token = ".stream(";
442
- }
437
+ const { idx, token } = found;
443
438
 
444
439
  /* include everything up to and including the token */
445
440
  const handlerStart = idx + token.length;
@@ -1,11 +1,11 @@
1
- import "solid-js";
1
+ import "@solidjs/web";
2
2
 
3
- declare module "solid-js" {
3
+ declare module "@solidjs/web" {
4
4
  namespace JSX {
5
5
  interface HTMLAttributes<T> {
6
6
  css?: string;
7
7
  }
8
- interface CoreSVGAttributes<T> {
8
+ interface SVGAttributes<T> {
9
9
  css?: string;
10
10
  }
11
11
  }
@@ -28,12 +28,12 @@ function hash4(s: string): string {
28
28
  * Class name triple: selector\0property\0value.
29
29
  * Null-byte separators prevent `color:red` ↔ `col:or:red` collisions.
30
30
  */
31
- function tripleKey(selector: string, prop: string, value: string): string {
32
- return `${selector}\0${prop}\0${value}`;
31
+ function tripleKey(selector: string, prop: string, value: string, atWrap = ""): string {
32
+ return `${selector}\0${prop}\0${value}\0${atWrap}`;
33
33
  }
34
34
 
35
- function className(selector: string, prop: string, value: string, mode: "dev" | "prod"): string {
36
- const key = tripleKey(selector, prop, value);
35
+ function className(selector: string, prop: string, value: string, mode: "dev" | "prod", atWrap = ""): string {
36
+ const key = tripleKey(selector, prop, value, atWrap);
37
37
  if (mode === "prod") {
38
38
  return `a1-${hash8(key)}`;
39
39
  }
@@ -101,7 +101,7 @@ function emitDeclarations(ctx: EmitCtx, cssSelector: string, base: SxIR["base"],
101
101
  }
102
102
 
103
103
  const cssProp = toKebab(prop);
104
- const cls = className(cssSelector, cssProp, value, ctx.mode);
104
+ const cls = className(cssSelector, cssProp, value, ctx.mode, atWrap);
105
105
  ctx.classes.push(cls);
106
106
 
107
107
  if (ctx.cssRules.has(cls)) continue;