@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
@@ -18,6 +18,23 @@ interface CdnProxyConfig<TEnv = unknown> {
18
18
  pathPrefix: string;
19
19
  }
20
20
 
21
+ const NOSNIFF = { "X-Content-Type-Options": "nosniff" } as const;
22
+
23
+ function isNavigableExecutable(contentType: string): boolean {
24
+ const mime = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
25
+ return (
26
+ mime === "application/xhtml+xml" ||
27
+ mime === "application/xml" ||
28
+ mime === "image/svg+xml" ||
29
+ mime === "text/html" ||
30
+ mime === "text/xml"
31
+ );
32
+ }
33
+
34
+ function bypassText(ctx: MiddlewareContext, body: string, status: number) {
35
+ return ctx.bypass(new Response(body, { headers: NOSNIFF, status }));
36
+ }
37
+
21
38
  export function cdnProxy<TEnv = unknown>(config: CdnProxyConfig<TEnv>): FlareMiddleware<TEnv> {
22
39
  const defaultCacheControl = config.cacheControl ?? "public, max-age=31536000, immutable";
23
40
 
@@ -30,27 +47,31 @@ export function cdnProxy<TEnv = unknown>(config: CdnProxyConfig<TEnv>): FlareMid
30
47
  try {
31
48
  key = decodeURIComponent(rawKey);
32
49
  } catch {
33
- return ctx.bypass(new Response("Bad Request", { status: 400 }));
50
+ return bypassText(ctx, "Bad Request", 400);
34
51
  }
35
52
  /* Reject path traversal, null bytes, backslashes, and CRLF */
36
53
  if (key.includes("..") || key.includes("\0") || key.includes("\\") || key.includes("\r") || key.includes("\n")) {
37
- return ctx.bypass(new Response("Bad Request", { status: 400 }));
54
+ return bypassText(ctx, "Bad Request", 400);
38
55
  }
39
56
  const bucket = config.bucket({ env: ctx.env });
40
57
  const object = await bucket.get(key);
41
58
 
42
59
  if (!object) {
43
- return ctx.bypass(new Response("Not Found", { status: 404 }));
60
+ return bypassText(ctx, "Not Found", 404);
44
61
  }
45
62
 
46
63
  const headers: Record<string, string> = {
47
64
  "Cache-Control": defaultCacheControl,
48
65
  "Content-Length": String(object.size),
49
66
  ETag: object.etag,
67
+ "X-Content-Type-Options": "nosniff",
50
68
  };
51
69
 
52
70
  if (object.httpMetadata?.contentType) {
53
71
  headers["Content-Type"] = object.httpMetadata.contentType;
72
+ if (isNavigableExecutable(object.httpMetadata.contentType)) {
73
+ headers["Content-Disposition"] = "attachment";
74
+ }
54
75
  }
55
76
 
56
77
  return ctx.bypass(new Response(object.body, { headers }));
@@ -2,6 +2,8 @@ import { match as cldrMatch } from "@formatjs/intl-localematcher";
2
2
  import { isbot } from "isbot";
3
3
  import Negotiator from "negotiator";
4
4
  import type { FlareMiddleware } from "..";
5
+ import { formatLocaleCookie } from "../../locale/cookie.ts";
6
+ import { HEADER_DATA, HEADER_FLAG, HEADER_PRERENDER, HEADER_PREFETCH, INTERNAL_PATH_PREFIX } from "../../protocol.ts";
5
7
  import { matchRoute, toLocaleMatch } from "../../router-primitives/tree.ts";
6
8
 
7
9
  /**
@@ -68,6 +70,13 @@ function getLocaleFromAcceptLanguage(request: Request, locales: readonly string[
68
70
  return defaultLocale;
69
71
  }
70
72
 
73
+ /** Path-only Location so the document applies Set-Cookie on 3xx (SW fetch() with follow does not). */
74
+ function locationForPath(url: URL, pathname: string): string {
75
+ const next = new URL(url);
76
+ next.pathname = pathname;
77
+ return `${next.pathname}${next.search}${next.hash}`;
78
+ }
79
+
71
80
  function buildCookieHeader(
72
81
  locale: string,
73
82
  cookieName: string,
@@ -75,12 +84,7 @@ function buildCookieHeader(
75
84
  isHttps: boolean,
76
85
  secure?: boolean,
77
86
  ): string {
78
- /* Defense-in-depth: strip chars that could enable header/cookie injection.
79
- * Primary gate is localeSet.has() before any call site, but this guards
80
- * against future code paths that might bypass that check. */
81
- const safe = locale.replace(/[\r\n;\0]/g, "");
82
- const secureFlag = (secure ?? isHttps) ? "; Secure" : "";
83
- return `${cookieName}=${safe}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secureFlag}`;
87
+ return formatLocaleCookie(locale, cookieName, { https: isHttps, maxAge, secure });
84
88
  }
85
89
 
86
90
  export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
@@ -102,14 +106,14 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
102
106
  cachedCookieName = options?.cookieName ?? ctx.locale.cookieName ?? "flare.locale";
103
107
  cachedMaxAge = options?.cookie?.maxAge ?? 31536000;
104
108
  const skip = options?.skip ?? ctx.locale.skip ?? [];
105
- cachedSkipPaths = ["/_fn/", ...skip];
109
+ cachedSkipPaths = [INTERNAL_PATH_PREFIX, ...skip];
106
110
  }
107
111
 
108
112
  const { pathname } = ctx.url;
109
113
  const isHttps = ctx.url.protocol === "https:";
110
114
 
111
115
  /* Prerender snapshots the exact expanded URL — do not locale-redirect. */
112
- if (ctx.request.headers.get("x-flare-prerender") === "1") {
116
+ if (ctx.request.headers.get(HEADER_PRERENDER) === HEADER_FLAG) {
113
117
  const prerenderLocale = getLocaleFromPath(pathname, cachedLocaleSet);
114
118
  ctx.serverContext.locale = prerenderLocale ?? defaultLocale;
115
119
  return ctx.next();
@@ -146,9 +150,9 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
146
150
  if (firstSegment && LOCALE_LIKE_RE.test(firstSegment)) {
147
151
  const lowerSegment = firstSegment.toLowerCase();
148
152
  if (firstSegment !== lowerSegment) {
149
- const newUrl = new URL(ctx.url);
150
- newUrl.pathname = pathname.replace(firstSegment, lowerSegment);
151
- const headers = new Headers({ Location: newUrl.href });
153
+ const headers = new Headers({
154
+ Location: locationForPath(ctx.url, pathname.replace(firstSegment, lowerSegment)),
155
+ });
152
156
  if (cachedLocaleSet.has(lowerSegment)) {
153
157
  headers.set(
154
158
  "Set-Cookie",
@@ -165,9 +169,9 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
165
169
 
166
170
  /* Invalid/unsupported locale-like segment in path → strip it */
167
171
  if (hasInvalidLocale) {
168
- const newUrl = new URL(ctx.url);
169
- newUrl.pathname = pathname.replace(`/${firstSegment}`, "") || "/";
170
- const headers = new Headers({ Location: newUrl.href });
172
+ const headers = new Headers({
173
+ Location: locationForPath(ctx.url, pathname.replace(`/${firstSegment}`, "") || "/"),
174
+ });
171
175
  headers.set(
172
176
  "Set-Cookie",
173
177
  buildCookieHeader(defaultLocale, cachedCookieName, cachedMaxAge, isHttps, options?.cookie?.secure),
@@ -182,9 +186,7 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
182
186
  const canStrip =
183
187
  !ctx.routeTree || matchRoute(ctx.routeTree, strippedPath, false, toLocaleMatch(ctx.locale)) !== null;
184
188
  if (canStrip) {
185
- const newUrl = new URL(ctx.url);
186
- newUrl.pathname = strippedPath;
187
- const headers = new Headers({ Location: newUrl.href });
189
+ const headers = new Headers({ Location: locationForPath(ctx.url, strippedPath) });
188
190
  headers.set(
189
191
  "Set-Cookie",
190
192
  buildCookieHeader(defaultLocale, cachedCookieName, cachedMaxAge, isHttps, options?.cookie?.secure),
@@ -200,9 +202,7 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
200
202
  if (!pathLocale && pathname === "/" && !cookieLocale) {
201
203
  const acceptLocale = getLocaleFromAcceptLanguage(ctx.request, locales, defaultLocale);
202
204
  if (acceptLocale !== defaultLocale) {
203
- const newUrl = new URL(ctx.url);
204
- newUrl.pathname = `/${acceptLocale}`;
205
- const headers = new Headers({ Location: newUrl.href });
205
+ const headers = new Headers({ Location: locationForPath(ctx.url, `/${acceptLocale}`) });
206
206
  headers.set(
207
207
  "Set-Cookie",
208
208
  buildCookieHeader(acceptLocale, cachedCookieName, cachedMaxAge, isHttps, options?.cookie?.secure),
@@ -216,13 +216,11 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
216
216
  * Only for full page loads (not NDJSON/prefetch — SPA router handles those).
217
217
  * e.g. /about with cookie=fr → 302 → /fr/about
218
218
  */
219
- const isDataRequest = ctx.request.headers.get("x-d") === "1";
219
+ const isDataRequest = ctx.request.headers.get(HEADER_DATA) === HEADER_FLAG;
220
220
  if (!pathLocale && cookieLocale && cookieLocale !== defaultLocale && !isDataRequest) {
221
- const newUrl = new URL(ctx.url);
222
- newUrl.pathname = `/${cookieLocale}${pathname}`;
223
221
  return ctx.bypass(
224
222
  new Response(null, {
225
- headers: new Headers({ Location: newUrl.href }),
223
+ headers: new Headers({ Location: locationForPath(ctx.url, `/${cookieLocale}${pathname}`) }),
226
224
  status: 302,
227
225
  }),
228
226
  );
@@ -235,7 +233,7 @@ export function i18n(options?: I18nMiddlewareOptions): FlareMiddleware {
235
233
  /* Prefetch requests must not set cookies — SPA prefetches multiple
236
234
  * locale URLs after hydration, and the last one to complete would
237
235
  * overwrite the correct cookie from the actual page load. */
238
- const isPrefetch = ctx.request.headers.get("x-p") === "1";
236
+ const isPrefetch = ctx.request.headers.get(HEADER_PREFETCH) === HEADER_FLAG;
239
237
  const needsCookie = !isPrefetch && currentLocale !== cookieLocale;
240
238
  if (needsCookie) {
241
239
  const setCookieHeader = buildCookieHeader(
@@ -1,15 +1,14 @@
1
1
  import { error } from "../../logger.ts";
2
+ import { KEEPALIVE_PATH } from "../../protocol.ts";
2
3
  import type { FlareMiddleware, MiddlewareContext } from "..";
3
4
 
4
- const PING_PATH = "/_flare/keepalive";
5
-
6
5
  interface KeepaliveConfig<TEnv = unknown> {
7
6
  handler?: (ctx: MiddlewareContext<TEnv>) => Promise<void> | void;
8
7
  }
9
8
 
10
9
  export function keepalive<TEnv = unknown>(config?: KeepaliveConfig<TEnv>): FlareMiddleware<TEnv> {
11
10
  return async (ctx: MiddlewareContext<TEnv>) => {
12
- if (ctx.url.pathname !== PING_PATH || ctx.request.method !== "GET") return ctx.next();
11
+ if (ctx.url.pathname !== KEEPALIVE_PATH || ctx.request.method !== "GET") return ctx.next();
13
12
 
14
13
  if (config?.handler) {
15
14
  try {
@@ -41,8 +41,15 @@ export function matchMount<TEnv>(mounts: MountConfig<TEnv>[], pathname: string):
41
41
  }
42
42
 
43
43
  export function buildMountRequest<TEnv>(request: Request, mount: MountConfig<TEnv>, url: URL): Request {
44
- const strippedPath = url.pathname.slice(mount.prefix.length) || "/";
44
+ let strippedPath = url.pathname.slice(mount.prefix.length) || "/";
45
+ if (!strippedPath.startsWith("/")) strippedPath = `/${strippedPath}`;
46
+ if (strippedPath.startsWith("//") || strippedPath.includes("\\") || /[\0\r\n]/.test(strippedPath)) {
47
+ strippedPath = "/";
48
+ }
45
49
  const target = new URL(strippedPath, url.origin);
50
+ if (target.origin !== url.origin) {
51
+ target.href = `${url.origin}/`;
52
+ }
46
53
  target.search = url.search;
47
54
  target.hash = url.hash;
48
55
  return new Request(target.toString(), {