@lovrozagar/flare 0.1.1 → 0.2.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.
Files changed (85) hide show
  1. package/README.md +44 -19
  2. package/package.json +10 -7
  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 +7 -0
  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 +93 -35
  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 +164 -56
  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
@@ -47,6 +47,10 @@ export function __setMaxRegistrySize(size: number): void {
47
47
  const registry = new Map<string, string>();
48
48
  let domInjectionEnabled = false;
49
49
  let ssrSheetPresent = false;
50
+ /* Names whose scoped rules are in the live `#flare-runtime` sheet. Distinct
51
+ * from `registry`: a name can be registered while the SSR sheet gate is up
52
+ * (lazy `styles()` during hydrate) and never inserted. */
53
+ const injected = new Set<string>();
50
54
 
51
55
  export function enableDomInjection(): void {
52
56
  domInjectionEnabled = true;
@@ -58,6 +62,14 @@ export function enableDomInjection(): void {
58
62
 
59
63
  export function finishHydration(): void {
60
64
  ssrSheetPresent = false;
65
+ /* Flush CSS registered while the SSR sheet blocked insertRule — otherwise
66
+ * a later styles() call sees registry.has and never injects. */
67
+ for (const [name, scoped] of registry) {
68
+ ensureInjected(name, scoped, "attr");
69
+ }
70
+ for (const [name, scoped] of classRegistry) {
71
+ ensureInjected(name, scoped, "class");
72
+ }
61
73
  }
62
74
 
63
75
  function hashString(str: string): string {
@@ -892,11 +904,16 @@ export function registerCSS(css: string): string {
892
904
  }
893
905
 
894
906
  export function registerCSSByName(name: string, css: string): string {
895
- if (registry.has(name)) return name;
907
+ const existing = registry.get(name);
908
+ if (existing !== undefined) {
909
+ ensureInjected(name, existing, "attr");
910
+ return name;
911
+ }
896
912
  if (isDev) validateCSS(name, css);
897
913
  const scoped = minifyCSS(scopeCSS(name, css));
898
914
  registry.set(name, scoped);
899
915
  injectStyleToDOM(scoped);
916
+ markInjected(name);
900
917
  /* FIFO eviction: remove oldest entries when over limit */
901
918
  if (registry.size > maxRegistrySize) {
902
919
  const iter = registry.keys();
@@ -904,6 +921,7 @@ export function registerCSSByName(name: string, css: string): string {
904
921
  const oldest = iter.next();
905
922
  if (oldest.done) break;
906
923
  registry.delete(oldest.value);
924
+ injected.delete(oldest.value);
907
925
  }
908
926
  rebuildSheet();
909
927
  }
@@ -917,81 +935,85 @@ export function registerCSSByName(name: string, css: string): string {
917
935
  */
918
936
  export function forceRegisterCSSByName(name: string, css: string): void {
919
937
  registry.delete(name);
938
+ injected.delete(name);
920
939
  registerCSSByName(name, css);
921
940
  }
922
941
 
942
+ function readDocumentNonce(): string {
943
+ if (typeof document === "undefined") return "";
944
+ const meta = document.querySelector('meta[name="csp-nonce"]');
945
+ const fromContent = meta?.getAttribute("content");
946
+ if (fromContent) return fromContent;
947
+ const tagged = document.querySelector("script[nonce], style[nonce]") as HTMLElement | null;
948
+ return tagged?.nonce || tagged?.getAttribute("nonce") || "";
949
+ }
950
+
923
951
  /**
924
- * Split minified CSS into individual rules by tracking brace depth.
925
- * Handles nested @media / @supports blocks correctly.
952
+ * Insert a complete nonce'd `<style id="flare-runtime">`. CSP checks nonce at
953
+ * insertion; mutating an in-document sheet is not covered by that nonce.
926
954
  */
927
- function splitCSSRules(css: string): string[] {
928
- const rules: string[] = [];
929
- let depth = 0;
930
- let start = 0;
931
- for (let i = 0; i < css.length; i++) {
932
- if (css[i] === "{") depth++;
933
- else if (css[i] === "}") {
934
- depth--;
935
- if (depth === 0) {
936
- const rule = css.slice(start, i + 1).trim();
937
- if (rule.length > 0) rules.push(rule);
938
- start = i + 1;
939
- }
940
- }
955
+ function writeRuntimeSheet(css: string): void {
956
+ const el = document.createElement("style");
957
+ el.id = RUNTIME_SHEET_ID;
958
+ const nonce = readDocumentNonce();
959
+ if (nonce) {
960
+ el.setAttribute("nonce", nonce);
961
+ el.nonce = nonce;
941
962
  }
942
- return rules;
963
+ el.textContent = css;
964
+ const old = document.getElementById(RUNTIME_SHEET_ID);
965
+ if (old) old.replaceWith(el);
966
+ else document.head.appendChild(el);
943
967
  }
944
968
 
945
- function getStyleEl(): HTMLStyleElement {
946
- let styleEl = document.getElementById(RUNTIME_SHEET_ID) as HTMLStyleElement | null;
947
- if (!styleEl) {
948
- styleEl = document.createElement("style");
949
- styleEl.id = RUNTIME_SHEET_ID;
950
- document.head.appendChild(styleEl);
969
+ function liveSheetText(): string {
970
+ if (typeof document === "undefined") return "";
971
+ const el = document.getElementById(RUNTIME_SHEET_ID) as HTMLStyleElement | null;
972
+ if (!el) return "";
973
+ if (el.sheet) {
974
+ return Array.from(el.sheet.cssRules)
975
+ .map((r) => r.cssText)
976
+ .join("");
951
977
  }
952
- return styleEl;
978
+ return el.textContent ?? "";
979
+ }
980
+
981
+ function sheetHasName(name: string, kind: "attr" | "class"): boolean {
982
+ const text = liveSheetText();
983
+ if (!text) return false;
984
+ if (kind === "class") return text.includes(`.${name}`);
985
+ const esc = escapeCSSAttrValue(name);
986
+ return text.includes(`[data-c="${esc}"]`) || text.includes(`[data-c=${esc}]`);
987
+ }
988
+
989
+ function markInjected(name: string): void {
990
+ if (domInjectionEnabled && !ssrSheetPresent) injected.add(name);
991
+ }
992
+
993
+ function ensureInjected(name: string, scoped: string, kind: "attr" | "class"): void {
994
+ if (!domInjectionEnabled || ssrSheetPresent || typeof document === "undefined") {
995
+ return;
996
+ }
997
+ if (sheetHasName(name, kind)) {
998
+ injected.add(name);
999
+ return;
1000
+ }
1001
+ injectStyleToDOM(scoped);
1002
+ injected.add(name);
953
1003
  }
954
1004
 
955
- /**
956
- * Inject scoped CSS into the DOM via CSSOM insertRule (CSP-safe).
957
- * Falls back to textContent append for environments without CSSOM (jsdom).
958
- */
959
1005
  function injectStyleToDOM(scoped: string): void {
960
1006
  if (!domInjectionEnabled || ssrSheetPresent || typeof document === "undefined") return;
961
- const styleEl = getStyleEl();
962
- const sheet = styleEl.sheet;
963
- if (sheet) {
964
- for (const rule of splitCSSRules(scoped)) {
965
- try {
966
- sheet.insertRule(rule, sheet.cssRules.length);
967
- } catch {
968
- /* Invalid rule — dev validator catches these separately */
969
- }
970
- }
971
- } else {
972
- styleEl.appendChild(document.createTextNode(scoped));
973
- }
1007
+ const current = document.getElementById(RUNTIME_SHEET_ID)?.textContent ?? "";
1008
+ writeRuntimeSheet(current + scoped);
974
1009
  }
975
1010
 
976
- /**
977
- * Rebuild the sheet from current registry after eviction.
978
- */
979
1011
  function rebuildSheet(): void {
980
1012
  if (!domInjectionEnabled || typeof document === "undefined") return;
981
- const styleEl = getStyleEl();
982
- const sheet = styleEl.sheet;
983
- if (sheet) {
984
- while (sheet.cssRules.length > 0) sheet.deleteRule(0);
985
- for (const scoped of registry.values()) {
986
- for (const rule of splitCSSRules(scoped)) {
987
- try {
988
- sheet.insertRule(rule, sheet.cssRules.length);
989
- } catch {}
990
- }
991
- }
992
- } else {
993
- styleEl.textContent = Array.from(registry.values()).join("");
994
- }
1013
+ injected.clear();
1014
+ writeRuntimeSheet(Array.from(registry.values()).join("") + Array.from(classRegistry.values()).join(""));
1015
+ for (const name of registry.keys()) injected.add(name);
1016
+ for (const name of classRegistry.keys()) injected.add(name);
995
1017
  }
996
1018
 
997
1019
  export function getScopedStyles(): string {
@@ -1020,9 +1042,14 @@ function scopeCssToClass(className: string, css: string): string {
1020
1042
  * Mirrors registerCSSByName but keys by class name and uses class selectors.
1021
1043
  */
1022
1044
  export function registerCSSAsClass(className: string, scopedCssInLayer: string): string {
1023
- if (classRegistry.has(className)) return className;
1045
+ const existing = classRegistry.get(className);
1046
+ if (existing !== undefined) {
1047
+ ensureInjected(className, existing, "class");
1048
+ return className;
1049
+ }
1024
1050
  classRegistry.set(className, scopedCssInLayer);
1025
1051
  injectStyleToDOM(scopedCssInLayer);
1052
+ markInjected(className);
1026
1053
  /* FIFO eviction mirrors main registry */
1027
1054
  if (classRegistry.size > maxRegistrySize) {
1028
1055
  const iter = classRegistry.keys();
@@ -1030,6 +1057,7 @@ export function registerCSSAsClass(className: string, scopedCssInLayer: string):
1030
1057
  const oldest = iter.next();
1031
1058
  if (oldest.done) break;
1032
1059
  classRegistry.delete(oldest.value);
1060
+ injected.delete(oldest.value);
1033
1061
  }
1034
1062
  }
1035
1063
  return className;
@@ -1185,6 +1213,7 @@ export function cn(...inputs: CnValue[]): string {
1185
1213
  export function clearScopedStyles(): void {
1186
1214
  registry.clear();
1187
1215
  classRegistry.clear();
1216
+ injected.clear();
1188
1217
  domInjectionEnabled = false;
1189
1218
  ssrSheetPresent = false;
1190
1219
  if (typeof document !== "undefined") {
@@ -1,4 +1,5 @@
1
1
  import { EMPTY_ARR } from "../internal/index.ts";
2
+ import { ATTR_HYDRATED } from "../protocol.ts";
2
3
 
3
4
  const HYDRATION_PATTERNS = [/hydration/i, /mismatch/i, /unable to find dom nodes/i];
4
5
 
@@ -148,9 +149,13 @@ export class FlarePage {
148
149
  /* ── Hydration ──────────────────────────────────────────────────── */
149
150
 
150
151
  async waitForHydration(timeout = 15_000): Promise<void> {
151
- await this.page.waitForFunction("document.documentElement.hasAttribute('data-hydrated')", undefined, {
152
- timeout,
153
- });
152
+ await this.page.waitForFunction(
153
+ `document.documentElement.hasAttribute(${JSON.stringify(ATTR_HYDRATED)})`,
154
+ undefined,
155
+ {
156
+ timeout,
157
+ },
158
+ );
154
159
  }
155
160
 
156
161
  /* ── State ──────────────────────────────────────────────────────── */
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  createContext,
3
3
  createEffect,
4
+ createMemo,
4
5
  createSignal,
5
- type JSX,
6
- on,
7
- onCleanup,
8
- onMount,
6
+ onSettled,
9
7
  sharedConfig,
8
+ untrack,
10
9
  useContext,
11
10
  } from "solid-js";
11
+ import { isServer, type JSX } from "@solidjs/web";
12
12
 
13
13
  export type Theme = "light" | "dark" | "system";
14
14
  export type ResolvedTheme = "light" | "dark";
@@ -57,7 +57,7 @@ export interface ThemeContextValue {
57
57
  toggleTheme: () => void;
58
58
  }
59
59
 
60
- const ThemeCtx = createContext<ThemeContextValue>();
60
+ const ThemeCtx = createContext<ThemeContextValue | null>(null);
61
61
 
62
62
  export function ThemeProvider(props: { children: JSX.Element; config?: ThemeConfig }): JSX.Element {
63
63
  const cfg: Required<ThemeConfig> = {
@@ -68,7 +68,7 @@ export function ThemeProvider(props: { children: JSX.Element; config?: ThemeConf
68
68
  themes: props.config?.themes ?? DEFAULT_CONFIG.themes,
69
69
  };
70
70
 
71
- const hydrating = !!sharedConfig.context;
71
+ const hydrating = isServer || !!sharedConfig.hydrating;
72
72
 
73
73
  /* During SSR + hydration, always start from defaultTheme so the tree matches.
74
74
  ThemeScript already applied localStorage to <html> before first paint.
@@ -95,10 +95,10 @@ export function ThemeProvider(props: { children: JSX.Element; config?: ThemeConf
95
95
  }
96
96
  const [systemTheme, setSystemTheme] = createSignal<ResolvedTheme>(initialSystem);
97
97
 
98
- const resolvedTheme = (): ResolvedTheme => {
98
+ const resolvedTheme = createMemo((): ResolvedTheme => {
99
99
  const t = theme();
100
100
  return t === "system" ? systemTheme() : t;
101
- };
101
+ });
102
102
 
103
103
  const applyToDocument = (resolved: ResolvedTheme): void => {
104
104
  if (typeof document === "undefined") return;
@@ -114,36 +114,36 @@ export function ThemeProvider(props: { children: JSX.Element; config?: ThemeConf
114
114
  /* Client remount / CSR: apply immediately. During hydration, ThemeScript owns
115
115
  first paint — applying here would overwrite a stored theme with "system". */
116
116
  if (!hydrating) {
117
- applyToDocument(resolvedTheme());
117
+ applyToDocument(untrack(resolvedTheme));
118
118
  }
119
119
 
120
120
  /* After hydrate, sync from localStorage so useTheme() matches ThemeScript. */
121
- onMount(() => {
121
+ onSettled(() => {
122
122
  if (typeof localStorage === "undefined") return;
123
123
  try {
124
124
  const stored = localStorage.getItem(cfg.storageKey);
125
- if (stored && cfg.themes.includes(stored as Theme) && stored !== theme()) {
125
+ if (stored && cfg.themes.includes(stored as Theme) && stored !== untrack(theme)) {
126
126
  setThemeSignal(stored as Theme);
127
127
  }
128
128
  } catch {
129
129
  /* noop */
130
130
  }
131
- applyToDocument(resolvedTheme());
131
+ applyToDocument(untrack(resolvedTheme));
132
132
  });
133
133
 
134
134
  /* Listen for OS preference changes */
135
- onMount(() => {
135
+ onSettled(() => {
136
136
  if (typeof matchMedia === "undefined") return;
137
137
  const mq = matchMedia("(prefers-color-scheme: dark)");
138
138
  const handler = (e: MediaQueryListEvent) => {
139
139
  setSystemTheme(e.matches ? "dark" : "light");
140
140
  };
141
141
  mq.addEventListener("change", handler);
142
- onCleanup(() => mq.removeEventListener("change", handler));
142
+ return () => mq.removeEventListener("change", handler);
143
143
  });
144
144
 
145
145
  /* Cross-tab sync: StorageEvent fires in OTHER tabs only */
146
- onMount(() => {
146
+ onSettled(() => {
147
147
  const handler = (e: StorageEvent) => {
148
148
  if (e.key !== cfg.storageKey || e.storageArea !== localStorage) return;
149
149
  const v = e.newValue;
@@ -152,19 +152,18 @@ export function ThemeProvider(props: { children: JSX.Element; config?: ThemeConf
152
152
  }
153
153
  };
154
154
  window.addEventListener("storage", handler);
155
- onCleanup(() => window.removeEventListener("storage", handler));
155
+ return () => window.removeEventListener("storage", handler);
156
156
  });
157
157
 
158
158
  /* Sync resolved theme to DOM (skip transition-disable on first run) */
159
159
  let skipFirst = true;
160
- createEffect(() => {
161
- const resolved = resolvedTheme();
160
+ createEffect(resolvedTheme, (resolved) => {
162
161
  if (typeof document === "undefined") return;
163
162
 
164
163
  if (skipFirst) {
165
164
  skipFirst = false;
166
165
  /* After Solid hydrates <html> it drops ThemeScript's data-theme.
167
- Re-apply the visual value (storage or system) without waiting for onMount. */
166
+ Re-apply the visual value (storage or system) without waiting for onSettled. */
168
167
  let visual: ResolvedTheme = resolved;
169
168
  if (typeof localStorage !== "undefined") {
170
169
  try {
@@ -180,6 +179,12 @@ export function ThemeProvider(props: { children: JSX.Element; config?: ThemeConf
180
179
 
181
180
  if (cfg.disableTransitionOnChange) {
182
181
  const style = document.createElement("style");
182
+ const script = document.querySelector("script[nonce]") as HTMLElement | null;
183
+ const nonce = script?.nonce || script?.getAttribute("nonce");
184
+ if (nonce) {
185
+ style.setAttribute("nonce", nonce);
186
+ style.nonce = nonce;
187
+ }
183
188
  style.textContent = "* { transition: none !important }";
184
189
  document.head.appendChild(style);
185
190
  applyToDocument(resolved);
@@ -192,17 +197,15 @@ export function ThemeProvider(props: { children: JSX.Element; config?: ThemeConf
192
197
 
193
198
  /* Persist to localStorage on changes (not initial — it was read from there) */
194
199
  createEffect(
195
- on(
196
- theme,
197
- (t) => {
198
- try {
199
- localStorage.setItem(cfg.storageKey, t);
200
- } catch {
201
- /* noop */
202
- }
203
- },
204
- { defer: true },
205
- ),
200
+ theme,
201
+ (t) => {
202
+ try {
203
+ localStorage.setItem(cfg.storageKey, t);
204
+ } catch {
205
+ /* noop */
206
+ }
207
+ },
208
+ { defer: true },
206
209
  );
207
210
 
208
211
  const setTheme = (t: Theme): void => {
@@ -211,7 +214,7 @@ export function ThemeProvider(props: { children: JSX.Element; config?: ThemeConf
211
214
  };
212
215
 
213
216
  const toggleTheme = (): void => {
214
- const current = resolvedTheme();
217
+ const current = untrack(resolvedTheme);
215
218
  setTheme(current === "light" ? "dark" : "light");
216
219
  };
217
220
 
@@ -222,7 +225,7 @@ export function ThemeProvider(props: { children: JSX.Element; config?: ThemeConf
222
225
  toggleTheme,
223
226
  };
224
227
 
225
- return <ThemeCtx.Provider value={value}>{props.children}</ThemeCtx.Provider>;
228
+ return <ThemeCtx value={value}>{props.children}</ThemeCtx>;
226
229
  }
227
230
 
228
231
  export function useTheme(): ThemeContextValue {
@@ -29,6 +29,11 @@ export function createTimingTracer(): TimingTracer {
29
29
  };
30
30
  }
31
31
 
32
+ /* Server-Timing `name` is an RFC 7230 token — no `:`, `/`, or spaces. */
33
+ function timingToken(name: string): string {
34
+ return name.replace(/[^0-9A-Za-z!#$%&'*+\-.^_`|~]/g, ".");
35
+ }
36
+
32
37
  export function buildServerTimingHeader(entries: TimingEntry[]): string {
33
- return entries.map((e) => `${e.name};dur=${e.dur.toFixed(1)}`).join(", ");
38
+ return entries.map((e) => `${timingToken(e.name)};dur=${e.dur.toFixed(1)}`).join(", ");
34
39
  }
package/src/url/index.ts CHANGED
@@ -138,3 +138,12 @@ export function buildUrl(options: BuildUrlOptions): string {
138
138
 
139
139
  return result;
140
140
  }
141
+
142
+ /** Path+search for a same-origin 3xx. Protocol-relative `//host` is not a path. */
143
+ export function sameOriginRedirectPath(url: URL): string {
144
+ const path = url.pathname;
145
+ if (!path.startsWith("/") || path.startsWith("//") || /[\0\r\n]/.test(path) || /[\0\r\n]/.test(url.search)) {
146
+ return "/";
147
+ }
148
+ return `${path}${url.search}`;
149
+ }