@qwik.dev/router 2.0.0-beta.18 → 2.0.0-beta.19

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/lib/index.d.ts CHANGED
@@ -25,6 +25,7 @@ import type { ResolveSyncValue } from '@qwik.dev/router/middleware/request-handl
25
25
  import type { SerializationStrategy } from '@qwik.dev/core/internal';
26
26
  import type * as v from 'valibot';
27
27
  import type { ValueOrPromise } from '@qwik.dev/core';
28
+ import { ValueOrPromise as ValueOrPromise_2 } from '@qwik.dev/core/internal';
28
29
  import { z } from 'zod';
29
30
  import type * as z_2 from 'zod';
30
31
 
@@ -593,12 +594,6 @@ export declare const QWIK_CITY_SCROLLER = "_qCityScroller";
593
594
  /** @public */
594
595
  export declare const QWIK_ROUTER_SCROLLER = "_qRouterScroller";
595
596
 
596
- /**
597
- * @deprecated Use `QwikRouterMockProps` instead. will be removed in V3
598
- * @public
599
- */
600
- export declare type QwikCityMockProps = QwikRouterMockProps;
601
-
602
597
  /**
603
598
  * @deprecated Use `useQwikMockRouter()` instead. Will be removed in V3
604
599
  * @public
@@ -642,11 +637,62 @@ export declare interface QwikRouterEnvData {
642
637
  loadedRoute: LoadedRoute | null;
643
638
  }
644
639
 
640
+ /** @public */
641
+ export declare interface QwikRouterMockActionProp<T = any> {
642
+ /** The action function to mock. */
643
+ action: Action<T>;
644
+ /** The QRL function that will be called when the action is submitted. */
645
+ handler: QRL<(data: T) => ValueOrPromise_2<RouteActionResolver>>;
646
+ }
647
+
648
+ /** @public */
649
+ export declare interface QwikRouterMockLoaderProp<T = any> {
650
+ /** The loader function to mock. */
651
+ loader: Loader_2<T>;
652
+ /** The data to return when the loader is called. */
653
+ data: T;
654
+ }
655
+
645
656
  /** @public */
646
657
  export declare interface QwikRouterMockProps {
658
+ /**
659
+ * Allow mocking the url returned by `useLocation` hook.
660
+ *
661
+ * Default: `http://localhost/`
662
+ */
647
663
  url?: string;
664
+ /** Allow mocking the route params returned by `useLocation` hook. */
648
665
  params?: Record<string, string>;
666
+ /** Allow mocking the `goto` function returned by `useNavigate` hook. */
649
667
  goto?: RouteNavigate;
668
+ /**
669
+ * Allow mocking data for loaders defined with `routeLoader$` function.
670
+ *
671
+ * ```
672
+ * [
673
+ * {
674
+ * loader: useProductData,
675
+ * data: { product: { name: 'Test Product' } },
676
+ * },
677
+ * ];
678
+ * ```
679
+ */
680
+ loaders?: Array<QwikRouterMockLoaderProp<any>>;
681
+ /**
682
+ * Allow mocking actions defined with `routeAction$` function.
683
+ *
684
+ * ```
685
+ * [
686
+ * {
687
+ * action: useAddUser,
688
+ * handler: $(async (data) => {
689
+ * console.log('useAddUser action called with data:', data);
690
+ * }),
691
+ * },
692
+ * ];
693
+ * ```
694
+ */
695
+ actions?: Array<QwikRouterMockActionProp<any>>;
650
696
  }
651
697
 
652
698
  /** @public */
@@ -706,6 +752,11 @@ export declare const routeAction$: ActionConstructor;
706
752
 
707
753
  /* Excluded from this release type: routeActionQrl */
708
754
 
755
+ declare type RouteActionResolver = {
756
+ status: number;
757
+ result: unknown;
758
+ };
759
+
709
760
  /** @public */
710
761
  export declare type RouteData = [
711
762
  routeName: string,
@@ -65,14 +65,10 @@ const Link = component$((props) => {
65
65
  scroll,
66
66
  ...linkProps
67
67
  } = /* @__PURE__ */ (() => props)();
68
- const clientNavPath = untrack(() => getClientNavPath({ ...linkProps, reload }, loc));
68
+ const clientNavPath = untrack(getClientNavPath, { ...linkProps, reload }, loc);
69
69
  linkProps.href = clientNavPath || originalHref;
70
- const prefetchData = untrack(
71
- () => !!clientNavPath && prefetchProp !== false && prefetchProp !== "js" || void 0
72
- );
73
- const prefetch = untrack(
74
- () => prefetchData || !!clientNavPath && prefetchProp !== false && shouldPreload(clientNavPath, loc)
75
- );
70
+ const prefetchData = !!clientNavPath && prefetchProp !== false && prefetchProp !== "js" || void 0;
71
+ const prefetch = prefetchData || !!clientNavPath && prefetchProp !== false && untrack(shouldPreload, clientNavPath, loc);
76
72
  const handlePrefetch = prefetch ? $((_, elm) => {
77
73
  if (navigator.connection?.saveData) {
78
74
  return;
@@ -550,7 +546,9 @@ const useQwikRouter = (props) => {
550
546
  } = typeof opt === "object" ? opt : { forceReload: opt };
551
547
  internalState.navCount++;
552
548
  if (isBrowser && type === "link" && routeInternal.value.type === "initial") {
553
- routeInternal.value.dest = new URL(window.location.href);
549
+ const url2 = new URL(window.location.href);
550
+ routeInternal.value.dest = url2;
551
+ routeLocation.url = url2;
554
552
  }
555
553
  const lastDest = routeInternal.value.dest;
556
554
  const dest = path === void 0 ? lastDest : typeof path === "number" ? path : toUrl(path, routeLocation.url);
@@ -952,7 +950,14 @@ const useQwikMockRouter = (props) => {
952
950
  },
953
951
  { deep: false }
954
952
  );
955
- const loaderState = {};
953
+ const loadersData = props.loaders?.reduce(
954
+ (acc, { loader, data }) => {
955
+ acc[loader.__id] = data;
956
+ return acc;
957
+ },
958
+ {}
959
+ );
960
+ const loaderState = useStore(loadersData ?? {}, { deep: false });
956
961
  const goto = props.goto ?? $(async () => {
957
962
  console.warn("QwikRouterMockProvider: goto not provided");
958
963
  });
@@ -973,6 +978,24 @@ const useQwikMockRouter = (props) => {
973
978
  useContextProvider(RouteNavigateContext, goto);
974
979
  useContextProvider(RouteStateContext, loaderState);
975
980
  useContextProvider(RouteActionContext, actionState);
981
+ const actionsMocks = props.actions?.reduce(
982
+ (acc, { action, handler }) => {
983
+ acc[action.__id] = handler;
984
+ return acc;
985
+ },
986
+ {}
987
+ );
988
+ useTask$(async ({ track }) => {
989
+ const action = track(actionState);
990
+ if (!action?.resolve) {
991
+ return;
992
+ }
993
+ const mock = actionsMocks?.[action.id];
994
+ if (mock) {
995
+ const actionResult = await mock(action.data);
996
+ action.resolve(actionResult);
997
+ }
998
+ });
976
999
  };
977
1000
  const QwikRouterMockProvider = component$((props) => {
978
1001
  useQwikMockRouter(props);
@@ -29,6 +29,17 @@ export declare interface QwikRouterBunOptions extends ServerRenderOptions {
29
29
  /** Set the Cache-Control header for all static files */
30
30
  cacheControl?: string;
31
31
  };
32
+ /**
33
+ * Provide a function that computes the origin of the server, used to resolve relative URLs and
34
+ * validate the request origin against CSRF attacks.
35
+ *
36
+ * When not specified, it defaults to the `ORIGIN` environment variable (if set).
37
+ *
38
+ * If `ORIGIN` is not set, it's derived from the incoming request, which is not recommended for
39
+ * production use.
40
+ */
41
+ getOrigin?: (request: Request) => string | null;
42
+ /** Provide a function that returns a `ClientConn` for the given request. */
32
43
  getClientConn?: (request: Request) => ClientConn;
33
44
  }
34
45
 
@@ -3,6 +3,14 @@ import { _TextEncoderStream_polyfill, isStaticPath, getNotFound, mergeHeadersCoo
3
3
  import { join, extname } from 'node:path';
4
4
  import { M as MIME_TYPES } from '../../chunks/mime-types.mjs';
5
5
 
6
+ function getRequestUrl(request, opts) {
7
+ const url = new URL(request.url);
8
+ const origin = opts.getOrigin?.(request) ?? Bun.env.ORIGIN;
9
+ if (!origin) {
10
+ return url;
11
+ }
12
+ return new URL(`${url.pathname}${url.search}${url.hash}`, origin);
13
+ }
6
14
  function createQwikRouter(opts) {
7
15
  if (opts.qwikCityPlan && !opts.qwikRouterConfig) {
8
16
  console.warn("qwikCityPlan is deprecated. Simply remove it.");
@@ -15,7 +23,7 @@ function createQwikRouter(opts) {
15
23
  const staticFolder = opts.static?.root ?? join(Bun.fileURLToPath(import.meta.url), "..", "..", "dist");
16
24
  async function router(request) {
17
25
  try {
18
- const url = new URL(request.url);
26
+ const url = getRequestUrl(request, opts);
19
27
  const serverRequestEv = {
20
28
  mode: "server",
21
29
  locale: void 0,
@@ -71,7 +79,7 @@ function createQwikRouter(opts) {
71
79
  }
72
80
  const notFound = async (request) => {
73
81
  try {
74
- const url = new URL(request.url);
82
+ const url = getRequestUrl(request, opts);
75
83
  const notFoundHtml = !request.headers.get("accept")?.includes("text/html") || isStaticPath(request.method || "GET", url) ? "Not Found" : getNotFound(url.pathname);
76
84
  return new Response(notFoundHtml, {
77
85
  status: 404,
@@ -103,7 +111,7 @@ function createQwikRouter(opts) {
103
111
  };
104
112
  const staticFile = async (request) => {
105
113
  try {
106
- const url = new URL(request.url);
114
+ const url = getRequestUrl(request, opts);
107
115
  if (isStaticPath(request.method || "GET", url)) {
108
116
  const { filePath, content } = await openStaticFile(url);
109
117
  if (!await content.exists()) {
@@ -36,6 +36,17 @@ export declare interface QwikRouterDenoOptions extends ServerRenderOptions {
36
36
  /** Set the Cache-Control header for all static files */
37
37
  cacheControl?: string;
38
38
  };
39
+ /**
40
+ * Provide a function that computes the origin of the server, used to resolve relative URLs and
41
+ * validate the request origin against CSRF attacks.
42
+ *
43
+ * When not specified, it defaults to the `ORIGIN` environment variable (if set).
44
+ *
45
+ * If `ORIGIN` is not set, it's derived from the incoming request, which is not recommended for
46
+ * production use.
47
+ */
48
+ getOrigin?: (request: Request, info?: ServeHandlerInfo) => string | null;
49
+ /** Provide a function that returns a `ClientConn` for the given request. */
39
50
  getClientConn?: (request: Request, info: ServeHandlerInfo) => ClientConn;
40
51
  }
41
52
 
@@ -3,6 +3,14 @@ import { isStaticPath, getNotFound, mergeHeadersCookies, requestHandler } from '
3
3
  import { M as MIME_TYPES } from '../../chunks/mime-types.mjs';
4
4
  import { join, fromFileUrl, extname } from 'https://deno.land/std/path/mod.ts';
5
5
 
6
+ function getRequestUrl(request, opts, info) {
7
+ const url = new URL(request.url);
8
+ const origin = opts.getOrigin?.(request, info) ?? Deno.env?.get?.("ORIGIN");
9
+ if (!origin) {
10
+ return url;
11
+ }
12
+ return new URL(`${url.pathname}${url.search}${url.hash}`, origin);
13
+ }
6
14
  function createQwikRouter(opts) {
7
15
  if (opts.qwikCityPlan && !opts.qwikRouterConfig) {
8
16
  console.warn("qwikCityPlan is deprecated. Simply remove it.");
@@ -14,7 +22,7 @@ function createQwikRouter(opts) {
14
22
  const staticFolder = opts.static?.root ?? join(fromFileUrl(import.meta.url), "..", "..", "dist");
15
23
  async function router(request, info) {
16
24
  try {
17
- const url = new URL(request.url);
25
+ const url = getRequestUrl(request, opts, info);
18
26
  const serverRequestEv = {
19
27
  mode: "server",
20
28
  locale: void 0,
@@ -63,7 +71,7 @@ function createQwikRouter(opts) {
63
71
  }
64
72
  const notFound = async (request) => {
65
73
  try {
66
- const url = new URL(request.url);
74
+ const url = getRequestUrl(request, opts);
67
75
  const notFoundHtml = !request.headers.get("accept")?.includes("text/html") || isStaticPath(request.method || "GET", url) ? "Not Found" : getNotFound(url.pathname);
68
76
  return new Response(notFoundHtml, {
69
77
  status: 404,
@@ -96,7 +104,7 @@ function createQwikRouter(opts) {
96
104
  };
97
105
  const staticFile = async (request) => {
98
106
  try {
99
- const url = new URL(request.url);
107
+ const url = getRequestUrl(request, opts);
100
108
  if (isStaticPath(request.method || "GET", url)) {
101
109
  const { filePath, content } = await openStaticFile(url);
102
110
  const ext = extname(filePath).replace(/^\./, "");
@@ -546,11 +546,11 @@ export declare interface RequestEventCommon<PLATFORM = QwikRouterPlatform> exten
546
546
  readonly exit: () => AbortMessage;
547
547
  }
548
548
 
549
- declare interface RequestEventInternal extends RequestEvent, RequestEventLoader {
550
- [RequestEvLoaders]: Record<string, ValueOrPromise<unknown> | undefined>;
551
- [RequestEvLoaderSerializationStrategyMap]: Map<string, SerializationStrategy>;
552
- [RequestEvMode]: ServerRequestMode;
553
- [RequestEvRoute]: LoadedRoute | null;
549
+ declare interface RequestEventInternal extends Readonly<RequestEvent>, Readonly<RequestEventLoader> {
550
+ readonly [RequestEvLoaders]: Record<string, ValueOrPromise<unknown> | undefined>;
551
+ readonly [RequestEvLoaderSerializationStrategyMap]: Map<string, SerializationStrategy>;
552
+ readonly [RequestEvMode]: ServerRequestMode;
553
+ readonly [RequestEvRoute]: LoadedRoute | null;
554
554
  /**
555
555
  * Check if this request is already written to.
556
556
  *
@@ -557,8 +557,12 @@ function createRequestEvent(serverRequestEv, loadedRoute, requestHandlers, baseP
557
557
  check();
558
558
  status = statusCode;
559
559
  if (url2) {
560
- if (/([^:])\/{2,}/.test(url2)) {
561
- const fixedURL = url2.replace(/([^:])\/{2,}/g, "$1/");
560
+ if (
561
+ // //test.com
562
+ /^\/\//.test(url2) || // /test//path
563
+ /([^:])\/\/+/.test(url2)
564
+ ) {
565
+ const fixedURL = url2.replace(/^\/\/+/, "/").replace(/([^:])\/\/+/g, "$1/");
562
566
  console.warn(`Redirect URL ${url2} is invalid, fixing to ${fixedURL}`);
563
567
  url2 = fixedURL;
564
568
  }
@@ -634,7 +638,7 @@ function createRequestEvent(serverRequestEv, loadedRoute, requestHandlers, baseP
634
638
  return writableStream;
635
639
  }
636
640
  };
637
- return Object.freeze(requestEv);
641
+ return requestEv;
638
642
  }
639
643
  function getRequestLoaders(requestEv) {
640
644
  return requestEv[RequestEvLoaders];
@@ -650,7 +654,7 @@ function getRequestMode(requestEv) {
650
654
  }
651
655
  const ABORT_INDEX = Number.MAX_SAFE_INTEGER;
652
656
  const parseRequest = async ({ request, method, query }, sharedMap) => {
653
- const type = request.headers.get("content-type")?.split(/[;,]/, 1)[0].trim() ?? "";
657
+ const type = getContentType(request.headers);
654
658
  if (type === "application/x-www-form-urlencoded" || type === "multipart/form-data") {
655
659
  const formData = await request.formData();
656
660
  sharedMap.set(RequestEvSharedActionFormData, formData);
@@ -672,21 +676,40 @@ const parseRequest = async ({ request, method, query }, sharedMap) => {
672
676
  }
673
677
  return void 0;
674
678
  };
679
+ const isDangerousKey = (k) => k === "__proto__" || k === "constructor" || k === "prototype";
675
680
  const formToObj = (formData) => {
676
- const values = [...formData.entries()].reduce((values2, [name, value]) => {
677
- name.split(".").reduce((object, key, index, keys) => {
681
+ const values = /* @__PURE__ */ Object.create(null);
682
+ for (const [name, value] of formData) {
683
+ const keys = name.split(".");
684
+ let hasDangerousKey = false;
685
+ for (let i = 0; i < keys.length; i++) {
686
+ if (isDangerousKey(keys[i])) {
687
+ hasDangerousKey = true;
688
+ break;
689
+ }
690
+ }
691
+ if (hasDangerousKey) {
692
+ continue;
693
+ }
694
+ let object = values;
695
+ for (let i = 0; i < keys.length; i++) {
696
+ const key = keys[i];
678
697
  if (key.endsWith("[]")) {
679
698
  const arrayKey = key.slice(0, -2);
699
+ if (isDangerousKey(arrayKey)) {
700
+ break;
701
+ }
680
702
  object[arrayKey] = object[arrayKey] || [];
681
- return object[arrayKey] = [...object[arrayKey], value];
703
+ object[arrayKey].push(value);
704
+ break;
682
705
  }
683
- if (index < keys.length - 1) {
684
- return object[key] = object[key] || (Number.isNaN(+keys[index + 1]) ? {} : []);
706
+ if (i < keys.length - 1) {
707
+ object = object[key] = object[key] || (Number.isNaN(+keys[i + 1]) ? /* @__PURE__ */ Object.create(null) : []);
708
+ } else {
709
+ object[key] = value;
685
710
  }
686
- return object[key] = value;
687
- }, values2);
688
- return values2;
689
- }, {});
711
+ }
712
+ }
690
713
  return values;
691
714
  };
692
715
 
@@ -1035,6 +1058,13 @@ function fixTrailingSlash(ev) {
1035
1058
  const { basePathname, originalUrl, sharedMap } = ev;
1036
1059
  const { pathname, search } = originalUrl;
1037
1060
  const isQData = sharedMap.has(IsQData);
1061
+ if (
1062
+ // all valid pathnames must start with a single slash
1063
+ !pathname.startsWith("/") || // protocol-relative URLs are not allowed like: //test.com, ///bad.com
1064
+ pathname.startsWith("//")
1065
+ ) {
1066
+ return;
1067
+ }
1038
1068
  if (!isQData && pathname !== basePathname && !pathname.endsWith(".html")) {
1039
1069
  if (!globalThis.__NO_TRAILING_SLASH__) {
1040
1070
  if (!pathname.endsWith("/")) {
@@ -1089,13 +1119,14 @@ function csrfCheckMiddleware(requestEv) {
1089
1119
  checkCSRF(requestEv);
1090
1120
  }
1091
1121
  function checkCSRF(requestEv, laxProto) {
1092
- const isForm = isContentType(
1122
+ const contentType = requestEv.request.headers.get("content-type");
1123
+ const isSimpleRequest = !contentType || isContentType(
1093
1124
  requestEv.request.headers,
1094
1125
  "application/x-www-form-urlencoded",
1095
1126
  "multipart/form-data",
1096
1127
  "text/plain"
1097
1128
  );
1098
- if (isForm) {
1129
+ if (isSimpleRequest) {
1099
1130
  const inputOrigin = requestEv.request.headers.get("origin");
1100
1131
  const origin = requestEv.url.origin;
1101
1132
  let forbidden = inputOrigin !== origin;
@@ -1253,9 +1284,17 @@ async function measure(requestEv, name, fn) {
1253
1284
  measurements.push([name, duration]);
1254
1285
  }
1255
1286
  }
1287
+ function getContentType(headers) {
1288
+ return (headers.get("content-type")?.split(/[;,]/, 1)[0].trim() ?? "").toLowerCase();
1289
+ }
1256
1290
  function isContentType(headers, ...types) {
1257
- const type = headers.get("content-type")?.split(/;/, 1)[0].trim() ?? "";
1258
- return types.includes(type);
1291
+ const type = getContentType(headers);
1292
+ for (let i = 0; i < types.length; i++) {
1293
+ if (types[i].toLowerCase() === type) {
1294
+ return true;
1295
+ }
1296
+ }
1297
+ return false;
1259
1298
  }
1260
1299
 
1261
1300
  let _asyncRequestStore;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@qwik.dev/router",
3
3
  "description": "The router for Qwik.",
4
- "version": "2.0.0-beta.18",
4
+ "version": "2.0.0-beta.19",
5
5
  "bugs": "https://github.com/QwikDev/qwik/issues",
6
6
  "dependencies": {
7
7
  "@azure/functions": "3.5.1",
@@ -40,7 +40,7 @@
40
40
  "tsm": "2.3.0",
41
41
  "typescript": "5.9.3",
42
42
  "uvu": "0.5.6",
43
- "@qwik.dev/core": "2.0.0-beta.18"
43
+ "@qwik.dev/core": "2.0.0-beta.19"
44
44
  },
45
45
  "engines": {
46
46
  "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -169,7 +169,7 @@
169
169
  "main": "./lib/index.qwik.mjs",
170
170
  "peerDependencies": {
171
171
  "vite": ">=5 <8",
172
- "@qwik.dev/core": "^2.0.0-beta.18"
172
+ "@qwik.dev/core": "^2.0.0-beta.19"
173
173
  },
174
174
  "publishConfig": {
175
175
  "access": "public"