@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.
- package/README.md +44 -19
- package/package.json +10 -7
- package/src/broadcast/index.ts +1 -1
- package/src/broadcast/provider.tsx +2 -2
- package/src/caches/index.ts +26 -0
- package/src/components/await.tsx +36 -39
- package/src/components/dev-error-overlay.tsx +8 -9
- package/src/components/direction-script.tsx +2 -2
- package/src/components/locale-script.tsx +2 -2
- package/src/components/reset-css.tsx +1 -1
- package/src/components/ssr-context.tsx +7 -14
- package/src/components/theme-script.tsx +2 -2
- package/src/components/view-transition-css.tsx +1 -1
- package/src/defer/index.ts +22 -0
- package/src/direction/index.tsx +61 -36
- package/src/errors/index.ts +45 -11
- package/src/fonts/component.tsx +2 -1
- package/src/form/index.tsx +18 -12
- package/src/generators/index.ts +73 -30
- package/src/head-client/index.ts +7 -0
- package/src/history/index.ts +6 -0
- package/src/hydrate/index.tsx +90 -60
- package/src/hydration/index.ts +26 -6
- package/src/i18n/index.ts +11 -2
- package/src/image/index.tsx +7 -4
- package/src/intercept-outlet/index.tsx +6 -2
- package/src/lazy/index.tsx +50 -50
- package/src/link/index.tsx +97 -58
- package/src/loader-pipeline/index.ts +93 -35
- package/src/locale/cookie.ts +15 -0
- package/src/locale/index.tsx +24 -35
- package/src/locale.ts +1 -0
- package/src/middleware/builtins/api-proxy.ts +58 -4
- package/src/middleware/builtins/cdn-proxy.ts +24 -3
- package/src/middleware/builtins/i18n.ts +23 -25
- package/src/middleware/builtins/keepalive.ts +2 -3
- package/src/mount/index.ts +8 -1
- package/src/navigation/index.ts +575 -129
- package/src/navigation/types.ts +11 -1
- package/src/navigation-progress/index.tsx +17 -16
- package/src/ndjson-client/index.ts +42 -17
- package/src/ndjson-server/index.ts +78 -29
- package/src/outlet/index.tsx +192 -97
- package/src/outlet/types.ts +9 -1
- package/src/plugins/dev-cdn-cache.ts +18 -5
- package/src/plugins/dev-dashboard/client.tsx +130 -118
- package/src/plugins/dev-prerender.ts +6 -1
- package/src/plugins/dev-server.ts +12 -3
- package/src/plugins/image-plugin.ts +18 -4
- package/src/plugins/index.ts +10 -7
- package/src/plugins/prerender-plugin.ts +3 -3
- package/src/plugins/server-fn.ts +15 -20
- package/src/plugins/solid-jsx.d.ts +3 -3
- package/src/plugins/sx-ast/atomic.ts +5 -5
- package/src/plugins/sx-ast/index.ts +9 -0
- package/src/plugins/sx.d.ts +4 -5
- package/src/plugins/virtual.ts +11 -6
- package/src/prerender/index.ts +68 -32
- package/src/protocol.ts +65 -0
- package/src/query-client/index.tsx +73 -14
- package/src/rewrite/index.ts +10 -4
- package/src/route-builder/create-layout.ts +1 -1
- package/src/route-builder/create-root-layout.ts +1 -1
- package/src/route-builder/types.ts +5 -1
- package/src/router-primitives/types.ts +2 -0
- package/src/security/index.ts +41 -5
- package/src/server/index.ts +1 -2
- package/src/server-fn/get-input.ts +90 -0
- package/src/server-fn/http-error.ts +13 -0
- package/src/server-fn/index.ts +94 -70
- package/src/server-fn-query.ts +9 -17
- package/src/server-handler/etag.ts +4 -2
- package/src/server-handler/headers.ts +1 -2
- package/src/server-handler/index.ts +164 -56
- package/src/server-handler/types.ts +2 -0
- package/src/service-worker/template.ts +10 -42
- package/src/ssr/head-prefix.ts +2 -1
- package/src/ssr/hoist-head-markers.ts +25 -0
- package/src/ssr/index.tsx +109 -92
- package/src/state-parser/index.ts +38 -26
- package/src/styles/index.ts +89 -60
- package/src/testing/index.ts +8 -3
- package/src/theme/index.tsx +35 -32
- package/src/tracing/timing.ts +6 -1
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = [
|
|
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(
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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(
|
|
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 !==
|
|
11
|
+
if (ctx.url.pathname !== KEEPALIVE_PATH || ctx.request.method !== "GET") return ctx.next();
|
|
13
12
|
|
|
14
13
|
if (config?.handler) {
|
|
15
14
|
try {
|
package/src/mount/index.ts
CHANGED
|
@@ -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
|
-
|
|
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(), {
|