@lovrozagar/flare 0.3.1 → 0.4.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.
package/README.md CHANGED
@@ -321,7 +321,7 @@ Chain order (typical): `intercept` → `cache` → `authenticate` → `input`
321
321
  | Method | Role |
322
322
  | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
323
323
  | `.cache(config)` | Client / SSR / ISR / SSG / CDN |
324
- | `.authenticate()` / `.authenticate("optional")` / `.authenticate(false)` | Auth gate |
324
+ | `.authenticate()` / `.authenticate("optional")` | Auth gate |
325
325
  | `.authorize(fn)` | Role check; inherits parent unless overridden |
326
326
  | `.input({ params, searchParams })` | Standard Schema or parse fn |
327
327
  | `.effects({ loaderDeps, shouldRefetch })` | When to rerun the loader on search change |
@@ -462,9 +462,10 @@ export const route = createPage("_root_/dashboard")
462
462
  ```
463
463
 
464
464
  - `.authenticate()` — required; null user → `UnauthenticatedError` (401).
465
- - `.authenticate("optional")` — user may be null.
466
- - `.authenticate(false)` — skip even if a parent required it.
467
- - Child inherits parent auth unless it sets its own mode.
465
+ - `.authenticate("optional")` — user may be null; `ctx.auth` is `null` for anonymous requests.
466
+ - Other arguments are callerData for `authenticateFn`: `.authenticate("admin")` is required, `.authenticate("optional", "viewer")` is optional.
467
+ - A required layout gates every route below it; a child cannot loosen it.
468
+ - `.authenticateOptional()` is a deprecated alias of `.authenticate("optional")`.
468
469
  - `createServer(router).authenticateFn(fn)` is the app-wide hook (cookie, header, JWT).
469
470
 
470
471
  Throw helpers: `ctx.notFound()`, `ctx.redirect({ to, params, search, status, replace })`, `ctx.unauthenticated()`, `ctx.unauthorized()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/flare",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Solid meta-framework. Server-driven, NDJSON streaming, renderToStream.",
5
5
  "keywords": [
6
6
  "flare",
@@ -102,6 +102,9 @@
102
102
  "type:check": "tsc --noEmit -p tsconfig.src.json",
103
103
  "type:check:tests": "NODE_OPTIONS='--max-old-space-size=4096' tsc --noEmit"
104
104
  },
105
+ "dependencies": {
106
+ "@types/negotiator": "catalog:"
107
+ },
105
108
  "devDependencies": {
106
109
  "@babel/parser": "catalog:",
107
110
  "@capsizecss/metrics": "catalog:",
@@ -111,7 +114,6 @@
111
114
  "@solidjs/web": "catalog:",
112
115
  "@tanstack/query-broadcast-client-experimental": "catalog:",
113
116
  "@tanstack/solid-query": "catalog:",
114
- "@types/negotiator": "catalog:",
115
117
  "isbot": "catalog:",
116
118
  "jsdom": "catalog:",
117
119
  "magic-string": "catalog:",
@@ -140,6 +142,7 @@
140
142
  "oxc-resolver": ">=11.23.0",
141
143
  "sharp": ">=0.33.0",
142
144
  "solid-js": ">=2.0.0-rc.0",
145
+ "tailwindcss": ">=4.0.0",
143
146
  "vite": ">=8.0.0"
144
147
  },
145
148
  "peerDependenciesMeta": {
@@ -169,6 +172,9 @@
169
172
  },
170
173
  "sharp": {
171
174
  "optional": true
175
+ },
176
+ "tailwindcss": {
177
+ "optional": true
172
178
  }
173
179
  }
174
180
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { dirname, extname, join, posix } from "node:path";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, extname, join, posix, relative, resolve } from "node:path";
4
4
  import { parse as babelParse } from "@babel/parser";
5
5
  import { parseSeconds } from "../duration/index.ts";
6
6
  import type { AuthenticateMode } from "../route-builder/types.ts";
@@ -494,9 +494,7 @@ export function extractRouteDefinitions(src: string, filePath: string): RouteDef
494
494
  const nextExport = NEXT_EXPORT_RE.exec(rest);
495
495
  const chainText = nextExport ? rest.slice(0, nextExport.index) : rest;
496
496
 
497
- const hasAuthenticateOptional = /\.authenticateOptional\s*\(/.test(chainText);
498
- const hasAuthenticate = /\.authenticate\s*\(/.test(chainText);
499
- const authenticateMode: AuthenticateMode = hasAuthenticateOptional ? "optional" : !!hasAuthenticate;
497
+ const authenticateMode = detectAuthenticateMode(chainText);
500
498
  const hasAuthorize = /\.authorize\s*\(/.test(chainText);
501
499
  const hasInput = /\.input\s*(?:<[^>]*>)?\s*\(/.test(chainText);
502
500
  const responseRoute = /\.response\s*\(/.test(chainText);
@@ -640,6 +638,13 @@ function relativeImportPath(from: string, to: string): string {
640
638
  return `${prefix}/${downs.join("/")}`;
641
639
  }
642
640
 
641
+ /** Auth mode of a builder chain; mirrors `resolveAuthenticateArgs` in the route builder. */
642
+ export function detectAuthenticateMode(chainText: string): AuthenticateMode {
643
+ if (/\.authenticateOptional\s*\(/.test(chainText)) return "optional";
644
+ if (/\.authenticate\s*\(\s*(["'`])optional\1\s*[,)]/.test(chainText)) return "optional";
645
+ return /\.authenticate\s*\(/.test(chainText);
646
+ }
647
+
643
648
  function formatRouteMeta(def: RouteDefinition): string {
644
649
  const parts: string[] = [];
645
650
  if (def.authenticateMode === true) parts.push("authenticate: true");
@@ -846,7 +851,11 @@ function serializeRouteMeta(def: RouteDefinition): string {
846
851
  return `{${meta}}`;
847
852
  }
848
853
 
849
- export function generateRoutesFile(defs: RouteDefinition[], outputDir: string): string {
854
+ export function generateRoutesFile(
855
+ defs: RouteDefinition[],
856
+ outputDir: string,
857
+ app: AppEntries = resolveAppEntries(".", outputDir),
858
+ ): string {
850
859
  const pages = defs.filter((d) => d.type === "page");
851
860
  const layouts = generateLayoutsRecord(defs, outputDir);
852
861
 
@@ -858,22 +867,7 @@ export function generateRoutesFile(defs: RouteDefinition[], outputDir: string):
858
867
  /* Detect whether any pages have .input() for search params typing */
859
868
  const hasSearchParams = pages.some((d) => !d.responseRoute && d.hasInput);
860
869
 
861
- /* Detect queryClientGetter in router source */
862
- const routerFilePath = posix.join(posix.dirname(outputDir), "router");
863
- let hasQueryClient = false;
864
- for (const ext of [".ts", ".tsx"]) {
865
- try {
866
- const routerSource = readFileSync(`${routerFilePath}${ext}`, "utf-8");
867
- if (routerSource.includes("queryClientGetter")) {
868
- hasQueryClient = true;
869
- }
870
- break;
871
- } catch {
872
- /* file doesn't exist with this extension */
873
- }
874
- }
875
-
876
- const serverHandler = resolveCreateServerExport(outputDir);
870
+ const { hasQueryClient, serverHandler } = app;
877
871
  const registry = generateRouteRegistry(defs, outputDir, hasAuth, hasQueryClient, serverHandler !== null);
878
872
 
879
873
  /* Deduplicate meta objects */
@@ -1008,25 +1002,84 @@ export function generateVirtualModuleTypes(): string {
1008
1002
  return `/// <reference types="vite/client" />\n/// <reference types="@lovrozagar/flare/virtual-types" />\n`;
1009
1003
  }
1010
1004
 
1011
- function resolveCreateServerExport(outputDir: string): { exportName: string; importPath: string } | null {
1012
- const serverFilePath = posix.join(posix.dirname(outputDir), "server");
1013
- for (const ext of [".ts", ".tsx"]) {
1014
- try {
1015
- const serverSource = readFileSync(`${serverFilePath}${ext}`, "utf-8");
1016
- const match = serverSource.match(/export\s+const\s+(\w+)\s*=\s*createServer/);
1017
- if (match?.[1]) {
1018
- return {
1019
- exportName: match[1],
1020
- importPath: relativeImportPath(`${outputDir}/routes.gen.ts`, serverFilePath),
1021
- };
1022
- }
1023
- } catch {
1024
- /* missing extension */
1025
- }
1005
+ export interface AppEntries {
1006
+ hasQueryClient: boolean;
1007
+ serverHandler: { exportName: string; importPath: string } | null;
1008
+ warnings: string[];
1009
+ }
1010
+
1011
+ const ENTRY_EXTS = [".ts", ".tsx"];
1012
+
1013
+ /** Root-relative posix path of the first existing candidate (`base` as given, then with each extension). */
1014
+ function findSourceFile(rootDir: string, base: string): string | null {
1015
+ for (const candidate of [
1016
+ base,
1017
+ ...ENTRY_EXTS.map((ext) => `${base}${ext}`),
1018
+ ...ENTRY_EXTS.map((ext) => `${base}/index${ext}`),
1019
+ ]) {
1020
+ const abs = resolve(rootDir, candidate);
1021
+ if (existsSync(abs) && statSync(abs).isFile()) return relative(resolve(rootDir), abs).replaceAll("\\", "/");
1026
1022
  }
1027
1023
  return null;
1028
1024
  }
1029
1025
 
1026
+ function readSource(rootDir: string, rel: string | null): string | null {
1027
+ if (!rel) return null;
1028
+ try {
1029
+ return readFileSync(resolve(rootDir, rel), "utf-8");
1030
+ } catch {
1031
+ return null;
1032
+ }
1033
+ }
1034
+
1035
+ /** Relative module the server entry imports the router from: `createServer(x)` + `import { x } from "./..."`. */
1036
+ function routerImportOf(serverSource: string): string | null {
1037
+ const arg = serverSource.match(/createServer\s*(?:<[^>]*>)?\s*\(\s*(\w+)/)?.[1];
1038
+ if (!arg) return null;
1039
+ for (const m of serverSource.matchAll(/import\s*\{([^}]*)\}\s*from\s*["'](\.{1,2}\/[^"']+)["']/g)) {
1040
+ const names = (m[1] ?? "").split(",").map((n) =>
1041
+ n
1042
+ .trim()
1043
+ .split(/\s+as\s+/)
1044
+ .pop(),
1045
+ );
1046
+ if (names.includes(arg)) return m[2] ?? null;
1047
+ }
1048
+ return null;
1049
+ }
1050
+
1051
+ /**
1052
+ * Server handler export and router facts for the registry. The server entry
1053
+ * defaults to `server.ts(x)` beside the output dir; the router is the module
1054
+ * the server passes to `createServer()`, else `router.ts(x)` beside it.
1055
+ */
1056
+ export function resolveAppEntries(rootDir: string, outputDir: string, serverEntry?: string): AppEntries {
1057
+ const warnings: string[] = [];
1058
+ const srcDir = posix.dirname(outputDir);
1059
+ const serverFile = serverEntry ? findSourceFile(rootDir, serverEntry) : findSourceFile(rootDir, `${srcDir}/server`);
1060
+ const serverSource = readSource(rootDir, serverFile);
1061
+
1062
+ const routerSpec = serverSource && serverFile ? routerImportOf(serverSource) : null;
1063
+ const routerFile =
1064
+ (routerSpec && serverFile ? findSourceFile(rootDir, posix.join(posix.dirname(serverFile), routerSpec)) : null) ??
1065
+ findSourceFile(rootDir, `${srcDir}/router`);
1066
+ const hasQueryClient = readSource(rootDir, routerFile)?.includes("queryClientGetter") ?? false;
1067
+
1068
+ let serverHandler: AppEntries["serverHandler"] = null;
1069
+ if (serverSource && serverFile) {
1070
+ const exportName = serverSource.match(/export\s+const\s+(\w+)\s*=\s*createServer/)?.[1];
1071
+ if (exportName) {
1072
+ serverHandler = { exportName, importPath: relativeImportPath(`${outputDir}/routes.gen.ts`, serverFile) };
1073
+ } else if (/createServer\s*(?:<[^>]*>)?\s*\(/.test(serverSource)) {
1074
+ warnings.push(
1075
+ `${serverFile} calls createServer() without exporting it; export the chain (export const server = createServer(router)...) so routes.gen.ts can infer auth, env, and serverContext`,
1076
+ );
1077
+ }
1078
+ }
1079
+
1080
+ return { hasQueryClient, serverHandler, warnings };
1081
+ }
1082
+
1030
1083
  /** Generate TypeScript declaration merging block for route type registry. */
1031
1084
  export function generateRouteRegistry(
1032
1085
  defs: RouteDefinition[],
@@ -1148,12 +1201,12 @@ export function generateRouteRegistry(
1148
1201
  }
1149
1202
 
1150
1203
  if (hasServer && hasAuth) {
1151
- lines.push(`\t\tauth: NonNullable<Awaited<ReturnType<NonNullable<typeof _FlareHandler["authenticateFn"]>>>>`);
1204
+ lines.push(`\t\tauth: NonNullable<NonNullable<(typeof _FlareHandler)["~flare"]>["auth"]>`);
1152
1205
  }
1153
1206
 
1154
1207
  if (hasServer) {
1155
1208
  lines.push(`\t\tenv: Parameters<typeof _FlareHandler["fetch"]>[1]`);
1156
- lines.push(`\t\tserverContext: Awaited<ReturnType<NonNullable<typeof _FlareHandler["serverContext"]>>>`);
1209
+ lines.push(`\t\tserverContext: NonNullable<(typeof _FlareHandler)["~flare"]>["serverContext"]`);
1157
1210
  }
1158
1211
  if (hasQueryClient) {
1159
1212
  lines.push("\t\thasQueryClient: true");
@@ -1502,9 +1555,7 @@ export function scanSourceFilesFsCodegen(options: ScanOptions): RouteDefinition[
1502
1555
  }
1503
1556
 
1504
1557
  /* Extract chain metadata from file content */
1505
- const hasAuthenticateOptional = /\.authenticateOptional\s*\(/.test(content);
1506
- const hasAuthenticate = /\.authenticate\s*\(/.test(content);
1507
- const authenticateMode: AuthenticateMode = hasAuthenticateOptional ? "optional" : !!hasAuthenticate;
1558
+ const authenticateMode = detectAuthenticateMode(content);
1508
1559
  const hasAuthorize = /\.authorize\s*\(/.test(content);
1509
1560
  const hasInput = /\.input\s*(?:<[^>]*>)?\s*\(/.test(content);
1510
1561
  const responseRoute = /\.response\s*\(/.test(content);
@@ -1533,6 +1584,8 @@ export interface RunGenerateOptions {
1533
1584
  ignorePrefix?: string;
1534
1585
  outputPath?: string;
1535
1586
  rootDir: string;
1587
+ /** Server entry (root-relative or absolute); defaults to `server.ts(x)` beside the output dir. */
1588
+ serverEntry?: string;
1536
1589
  srcDir?: string;
1537
1590
  typesOutputPath?: string;
1538
1591
  }
@@ -1617,7 +1670,12 @@ export function runGenerate(options: RunGenerateOptions): GenerateResult {
1617
1670
  return { layouts: 0, routes: 0, warnings };
1618
1671
  }
1619
1672
 
1620
- const content = generateRoutesFile(defs, outputDir);
1673
+ const app = resolveAppEntries(options.rootDir, outputDir, options.serverEntry);
1674
+ for (const warning of app.warnings) {
1675
+ warnings.push(warning);
1676
+ console.warn(`[flare:generate] ${warning}`);
1677
+ }
1678
+ const content = generateRoutesFile(defs, outputDir, app);
1621
1679
  writeGenFile(fullOutputPath, content, "flare");
1622
1680
 
1623
1681
  /* Write virtual module type declarations as a .d.ts file (must be ambient, not in a module) */
@@ -331,7 +331,7 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
331
331
  };
332
332
  },
333
333
  ({ disabled, el, external, hrefOnly, strategy }) => {
334
- if (!el || disabled || hrefOnly || external) return;
334
+ if (!el || disabled || hrefOnly || external) return undefined;
335
335
 
336
336
  if (strategy === "intent") {
337
337
  el.addEventListener("focus", handleIntent);
@@ -368,6 +368,7 @@ export function Link<TPath extends RoutePaths>(props: LinkProps<TPath>): JSX.Ele
368
368
  if (strategy === "render") {
369
369
  return scheduleAfterLoad(() => triggerPrefetch());
370
370
  }
371
+ return undefined;
371
372
  },
372
373
  );
373
374
 
@@ -585,12 +585,12 @@ function hydrateCachedDeferred(cached: CachedMatch): boolean {
585
585
  function commitCachedShell(
586
586
  c: FlareProviderContext,
587
587
  allModules: LoadedRouteModule[],
588
+ rootLayout: LoadedRouteModule | undefined,
588
589
  search: SearchParams,
589
590
  params: Record<string, string | string[]>,
590
591
  ): { hadShell: boolean; keepMatchIds: string[] } {
591
592
  if (!ctx) return { hadShell: false, keepMatchIds: [] };
592
593
  let found = false;
593
- const heads: PerRouteHead[] = [];
594
594
  const keepMatchIds: string[] = [];
595
595
  for (const mod of allModules) {
596
596
  const matchId = matchIdForModule(mod, search, params);
@@ -600,10 +600,23 @@ function commitCachedShell(
600
600
  if (cached.hasDeferred && hydrateCachedDeferred(cached)) {
601
601
  keepMatchIds.push(matchId);
602
602
  }
603
- if (cached.headConfig) heads.push({ head: cached.headConfig, matchId });
604
603
  }
605
604
  if (!found) return { hadShell: false, keepMatchIds: [] };
606
605
 
606
+ /* applyPerRouteHeads replaces the whole hierarchy, so a partial set would
607
+ * drop the root layout's CSS. Apply only when every route has a cached head;
608
+ * otherwise the post-fetch update applies the full set. */
609
+ const heads: PerRouteHead[] = [];
610
+ for (const mod of rootLayout ? [rootLayout, ...allModules] : allModules) {
611
+ const matchId = matchIdForModule(mod, search, params);
612
+ const head = ctx.matchCache.get(matchId)?.headConfig;
613
+ if (!head) {
614
+ heads.length = 0;
615
+ break;
616
+ }
617
+ heads.push({ head, matchId });
618
+ }
619
+
607
620
  c.setIntercepted(null);
608
621
  c.setNotFound(false);
609
622
  assignMatches(c, buildClientMatches(allModules, search, params));
@@ -1141,6 +1154,9 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
1141
1154
  */
1142
1155
  const nonRootLayouts = modules.layouts.filter((m) => m._type !== "root-layout");
1143
1156
  const allModules: LoadedRouteModule[] = [...nonRootLayouts, modules.page];
1157
+ /* The root layout still owns head state (title, CSS) on every navigation. */
1158
+ const rootLayout = modules.layouts.find((m) => m._type === "root-layout");
1159
+ const headModules = rootLayout ? [rootLayout, ...allModules] : allModules;
1144
1160
 
1145
1161
  /* Instant navigation: reuse in-flight prefetch and paint a cached shell
1146
1162
  * before the enter NDJSON hop. Skipped when this nav already fetched in
@@ -1160,7 +1176,7 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
1160
1176
  }
1161
1177
  }
1162
1178
  if (controller.signal.aborted || myVersion !== navigationVersion) return;
1163
- const shell = commitCachedShell(c, allModules, search, modules.params);
1179
+ const shell = commitCachedShell(c, allModules, rootLayout, search, modules.params);
1164
1180
  hadShell = shell.hadShell;
1165
1181
  paintedShell = shell.hadShell;
1166
1182
  if (shell.hadShell) {
@@ -1387,25 +1403,21 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
1387
1403
  const clientMatches = buildClientMatches(allModules, search, modules.params);
1388
1404
 
1389
1405
  /* Step 11: Compute head data before update — used inside VT callback */
1390
- const freshHeads = fetchResult?.perRouteHeads ?? [];
1391
- const fetchedHeadIds = new Set<string>();
1392
- for (const h of freshHeads) fetchedHeadIds.add(h.matchId);
1393
- const perRouteHeads: PerRouteHead[] = [...freshHeads];
1394
- for (const mod of allModules) {
1395
- const deps = mod.effectsConfig?.loaderDeps?.({ search }) ?? [];
1396
- const matchId = computeMatchId({
1397
- loaderDeps: () => deps,
1398
- params: modules.params,
1399
- routeId: mod.virtualPath,
1400
- search,
1401
- });
1402
- if (!fetchedHeadIds.has(matchId)) {
1403
- const cached = ctx.matchCache.get(matchId);
1404
- if (cached?.headConfig) {
1405
- perRouteHeads.push({ head: cached.headConfig, matchId });
1406
- }
1406
+ const freshHeads = new Map<string, HeadConfig>();
1407
+ for (const h of fetchResult?.perRouteHeads ?? []) freshHeads.set(h.matchId, h.head);
1408
+ const fetchedIds = new Set(fetchResult?.matches.map((m) => m.matchId));
1409
+ /* Hierarchy order, root first. A fetched route uses its fresh head (none
1410
+ * under headReplace); an unfetched one falls back to the cache. */
1411
+ const perRouteHeads: PerRouteHead[] = [];
1412
+ for (const mod of headModules) {
1413
+ const matchId = matchIdForModule(mod, search, modules.params);
1414
+ const head = fetchedIds.has(matchId) ? freshHeads.get(matchId) : ctx.matchCache.get(matchId)?.headConfig;
1415
+ if (head) {
1416
+ perRouteHeads.push({ head, matchId });
1417
+ freshHeads.delete(matchId);
1407
1418
  }
1408
1419
  }
1420
+ for (const [matchId, head] of freshHeads) perRouteHeads.push({ head, matchId });
1409
1421
 
1410
1422
  /* Step 12: Update state + scroll + head + cleanup.
1411
1423
  * Everything inside update() so VT captures the complete state transition. */
@@ -153,7 +153,7 @@ const ENTRY_EXTENSIONS = [".tsx", ".ts"];
153
153
 
154
154
  function resolveEntry(root: string, base: string, override?: string): string {
155
155
  if (override) {
156
- if (!existsSync(join(root, override))) {
156
+ if (!existsSync(resolve(root, override))) {
157
157
  throw new Error(`Flare: entry.${base} path "${override}" not found`);
158
158
  }
159
159
  return override;
@@ -189,7 +189,11 @@ function createResolverPlugin(): VitePlugin {
189
189
 
190
190
  /* ── Generate ────────────────────────────────────────────────────────── */
191
191
 
192
- function createGeneratePlugin(config: FlarePluginConfig, codegen: ReturnType<typeof resolveCodegenConfig>): VitePlugin {
192
+ function createGeneratePlugin(
193
+ config: FlarePluginConfig,
194
+ codegen: ReturnType<typeof resolveCodegenConfig>,
195
+ serverEntry: string,
196
+ ): VitePlugin {
193
197
  const ignorePrefix = config.ignorePrefix ?? "_";
194
198
 
195
199
  const generateOpts = {
@@ -197,6 +201,7 @@ function createGeneratePlugin(config: FlarePluginConfig, codegen: ReturnType<typ
197
201
  ignorePrefix,
198
202
  outputPath: codegen.routesFilePath,
199
203
  rootDir: "",
204
+ serverEntry,
200
205
  typesOutputPath: codegen.typesFilePath,
201
206
  };
202
207
 
@@ -235,12 +240,7 @@ function createGeneratePlugin(config: FlarePluginConfig, codegen: ReturnType<typ
235
240
 
236
241
  /* ── SSR Build ───────────────────────────────────────────────────────── */
237
242
 
238
- function createSsrBuildPlugin(
239
- entries: ResolvedEntries,
240
- config: FlarePluginConfig,
241
- assetsBase: string,
242
- assetsDir: string,
243
- ): VitePlugin {
243
+ function createSsrBuildPlugin(entries: ResolvedEntries, config: FlarePluginConfig, assetsDir: string): VitePlugin {
244
244
  return {
245
245
  config() {
246
246
  return {
@@ -411,8 +411,8 @@ export function flare(config: FlarePluginConfig = EMPTY_OBJ): VitePlugin[] {
411
411
  solid(solidConfig) as unknown as VitePlugin,
412
412
  createImagePlugin(config, resolvedOptions.assetsBase, resolvedOptions.assetsDir),
413
413
  createResolverPlugin(),
414
- createGeneratePlugin(config, resolvedCodegen),
415
- createSsrBuildPlugin(entries, config, resolvedOptions.assetsBase, resolvedOptions.assetsDir),
414
+ createGeneratePlugin(config, resolvedCodegen, resolve(root, entries.server)),
415
+ createSsrBuildPlugin(entries, config, resolvedOptions.assetsDir),
416
416
  createDevServerPlugin(entries, resolvedOptions.assetsBase),
417
417
  createPreviewServerPlugin(resolvedOptions.assetsBase),
418
418
  createVirtualPlugin(config, entries, resolvedCodegen),
@@ -177,10 +177,13 @@ export function extractDeclarations(
177
177
  return raw.replace(/\s+/g, " ").trim();
178
178
  }
179
179
 
180
+ const TAILWIND_MODULE: string = "tailwindcss";
181
+
180
182
  /** Initialize a Tailwind v4 compiler from an optional CSS entry file. */
181
183
  export async function initTailwindCompiler(cssPath?: string): Promise<TailwindCompiler> {
182
184
  try {
183
- const tw = await import("tailwindcss");
185
+ /* Optional peer: a non-literal specifier keeps consumers without tailwindcss typechecking. */
186
+ const tw = (await import(/* @vite-ignore */ TAILWIND_MODULE)) as { compile?: unknown; default?: unknown };
184
187
  const compileFn = tw.compile ?? (tw.default as { compile?: unknown })?.compile;
185
188
  if (typeof compileFn !== "function") {
186
189
  throw new Error(
@@ -12,6 +12,7 @@ import type {
12
12
  } from "./register.ts";
13
13
  import {
14
14
  type AuthenticateMode,
15
+ type AuthenticateModeFromArgs,
15
16
  BUILDER_MARKER,
16
17
  type CacheConfig,
17
18
  type DeferFn,
@@ -25,6 +26,7 @@ import {
25
26
  type SearchParamsValidator,
26
27
  type ServerThrowHelpers,
27
28
  type ServerUrlHelpers,
29
+ resolveAuthenticateArgs,
28
30
  } from "./types.ts";
29
31
 
30
32
  /* ── callback context types ─────────────────────────────────────── */
@@ -315,7 +317,11 @@ interface LayoutBuilderAfterInput<
315
317
  TAuth extends AuthenticateMode,
316
318
  TPreloaderContext,
317
319
  > extends LayoutBuilderAfterAuthenticate<TPath, TParams, TSearch, TAuth, TPreloaderContext> {
318
- authenticate(...args: unknown[]): LayoutBuilderAfterAuthenticate<TPath, TParams, TSearch, true, TPreloaderContext>;
320
+ /** Required by default; `.authenticate("optional")` lets anonymous requests through with `ctx.auth === null`. Other arguments are callerData for `authenticateFn`. */
321
+ authenticate<const TArgs extends readonly unknown[]>(
322
+ ...args: TArgs
323
+ ): LayoutBuilderAfterAuthenticate<TPath, TParams, TSearch, AuthenticateModeFromArgs<TArgs>, TPreloaderContext>;
324
+ /** @deprecated Use `.authenticate("optional", ...callerData)`. */
319
325
  authenticateOptional(
320
326
  ...args: unknown[]
321
327
  ): LayoutBuilderAfterAuthenticate<TPath, TParams, TSearch, "optional", TPreloaderContext>;
@@ -601,12 +607,14 @@ function createBuilderAfterInput<
601
607
  >(state: BuilderStateInternal): LayoutBuilderAfterInput<TPath, TParams, TSearch, TAuth, TPreloaderContext> {
602
608
  return {
603
609
  ...createBuilderAfterAuthenticate<TPath, TParams, TSearch, TAuth, TPreloaderContext>(state),
604
- authenticate(...args: unknown[]) {
605
- return createBuilderAfterAuthenticate<TPath, TParams, TSearch, true, TPreloaderContext>({
606
- ...state,
607
- authenticate: args,
608
- authenticateMode: true,
609
- });
610
+ authenticate<const TArgs extends readonly unknown[]>(...args: TArgs) {
611
+ return createBuilderAfterAuthenticate<
612
+ TPath,
613
+ TParams,
614
+ TSearch,
615
+ AuthenticateModeFromArgs<TArgs>,
616
+ TPreloaderContext
617
+ >({ ...state, ...resolveAuthenticateArgs(args) });
610
618
  },
611
619
  authenticateOptional(...args: unknown[]) {
612
620
  return createBuilderAfterAuthenticate<TPath, TParams, TSearch, "optional", TPreloaderContext>({
@@ -11,6 +11,7 @@ import type {
11
11
  } from "./register.ts";
12
12
  import {
13
13
  type AuthenticateMode,
14
+ type AuthenticateModeFromArgs,
14
15
  BUILDER_MARKER,
15
16
  type CacheConfig,
16
17
  type DeferFn,
@@ -24,6 +25,7 @@ import {
24
25
  type SearchParamsValidator,
25
26
  type ServerThrowHelpers,
26
27
  type ServerUrlHelpers,
28
+ resolveAuthenticateArgs,
27
29
  } from "./types.ts";
28
30
 
29
31
  /* ── callback context types ─────────────────────────────────────── */
@@ -385,7 +387,11 @@ interface PageBuilderAfterInput<
385
387
  TAuth extends AuthenticateMode,
386
388
  TPreloaderContext,
387
389
  > extends PageBuilderAfterAuthenticate<TPath, TParams, TSearch, TAuth, TPreloaderContext> {
388
- authenticate(...args: unknown[]): PageBuilderAfterAuthenticate<TPath, TParams, TSearch, true, TPreloaderContext>;
390
+ /** Required by default; `.authenticate("optional")` lets anonymous requests through with `ctx.auth === null`. Other arguments are callerData for `authenticateFn`. */
391
+ authenticate<const TArgs extends readonly unknown[]>(
392
+ ...args: TArgs
393
+ ): PageBuilderAfterAuthenticate<TPath, TParams, TSearch, AuthenticateModeFromArgs<TArgs>, TPreloaderContext>;
394
+ /** @deprecated Use `.authenticate("optional", ...callerData)`. */
389
395
  authenticateOptional(
390
396
  ...args: unknown[]
391
397
  ): PageBuilderAfterAuthenticate<TPath, TParams, TSearch, "optional", TPreloaderContext>;
@@ -726,12 +732,14 @@ function createBuilderAfterInput<
726
732
  >(state: BuilderStateInternal): PageBuilderAfterInput<TPath, TParams, TSearch, TAuth, TPreloaderContext> {
727
733
  return {
728
734
  ...createBuilderAfterAuthenticate<TPath, TParams, TSearch, TAuth, TPreloaderContext>(state),
729
- authenticate(...args: unknown[]) {
730
- return createBuilderAfterAuthenticate<TPath, TParams, TSearch, true, TPreloaderContext>({
731
- ...state,
732
- authenticate: args,
733
- authenticateMode: true,
734
- });
735
+ authenticate<const TArgs extends readonly unknown[]>(...args: TArgs) {
736
+ return createBuilderAfterAuthenticate<
737
+ TPath,
738
+ TParams,
739
+ TSearch,
740
+ AuthenticateModeFromArgs<TArgs>,
741
+ TPreloaderContext
742
+ >({ ...state, ...resolveAuthenticateArgs(args) });
735
743
  },
736
744
  authenticateOptional(...args: unknown[]) {
737
745
  return createBuilderAfterAuthenticate<TPath, TParams, TSearch, "optional", TPreloaderContext>({
@@ -4,6 +4,7 @@ import type { Location } from "../router-primitives/index.ts";
4
4
  import type { ResolvedAuth, ResolvedEnv, ResolvedQueryClient, ResolvedServerContext } from "./register.ts";
5
5
  import {
6
6
  type AuthenticateMode,
7
+ type AuthenticateModeFromArgs,
7
8
  BUILDER_MARKER,
8
9
  type CacheConfig,
9
10
  type DeferFn,
@@ -17,6 +18,7 @@ import {
17
18
  type SearchParamsValidator,
18
19
  type ServerThrowHelpers,
19
20
  type ServerUrlHelpers,
21
+ resolveAuthenticateArgs,
20
22
  } from "./types.ts";
21
23
 
22
24
  /* ── callback context types (root-specific) ─────────────────────── */
@@ -302,7 +304,11 @@ interface RootBuilderAfterInput<TPath extends string, TParams, TSearch> extends
302
304
  TSearch,
303
305
  false
304
306
  > {
305
- authenticate(...args: unknown[]): RootBuilderAfterAuthenticate<TPath, TParams, TSearch, true>;
307
+ /** Required by default; `.authenticate("optional")` lets anonymous requests through with `ctx.auth === null`. Other arguments are callerData for `authenticateFn`. */
308
+ authenticate<const TArgs extends readonly unknown[]>(
309
+ ...args: TArgs
310
+ ): RootBuilderAfterAuthenticate<TPath, TParams, TSearch, AuthenticateModeFromArgs<TArgs>>;
311
+ /** @deprecated Use `.authenticate("optional", ...callerData)`. */
306
312
  authenticateOptional(...args: unknown[]): RootBuilderAfterAuthenticate<TPath, TParams, TSearch, "optional">;
307
313
  }
308
314
 
@@ -569,11 +575,10 @@ function createBuilderAfterInput<TPath extends string, TParams, TSearch>(
569
575
  ): RootBuilderAfterInput<TPath, TParams, TSearch> {
570
576
  return {
571
577
  ...createBuilderAfterAuthenticate<TPath, TParams, TSearch, false>(state),
572
- authenticate(...args: unknown[]) {
573
- return createBuilderAfterAuthenticate<TPath, TParams, TSearch, true>({
578
+ authenticate<const TArgs extends readonly unknown[]>(...args: TArgs) {
579
+ return createBuilderAfterAuthenticate<TPath, TParams, TSearch, AuthenticateModeFromArgs<TArgs>>({
574
580
  ...state,
575
- authenticate: args,
576
- authenticateMode: true,
581
+ ...resolveAuthenticateArgs(args),
577
582
  });
578
583
  },
579
584
  authenticateOptional(...args: unknown[]) {
@@ -8,6 +8,24 @@ export type { FlareStore, FlareStoreEntry, StaticEntryData };
8
8
 
9
9
  export type AuthenticateMode = false | "optional" | true;
10
10
 
11
+ /** `.authenticate("optional", ...callerData)` is optional; any other argument list is callerData for a required gate. */
12
+ export type AuthenticateModeFromArgs<TArgs extends readonly unknown[]> = TArgs extends readonly [
13
+ "optional",
14
+ ...unknown[],
15
+ ]
16
+ ? "optional"
17
+ : true;
18
+
19
+ /** Runtime half of `AuthenticateModeFromArgs`: splits the mode from the callerData forwarded to `authenticateFn`. */
20
+ export function resolveAuthenticateArgs(args: readonly unknown[]): {
21
+ authenticate: unknown[];
22
+ authenticateMode: "optional" | true;
23
+ } {
24
+ return args[0] === "optional"
25
+ ? { authenticate: args.slice(1), authenticateMode: "optional" }
26
+ : { authenticate: [...args], authenticateMode: true };
27
+ }
28
+
11
29
  /**
12
30
  * Symbol marker for route builder chain methods that look like render fns
13
31
  * but return builder objects. Used to filter them out in SSR/hydration/navigation
@@ -1,3 +1,4 @@
1
+ import type { AuthenticateMode } from "../route-builder/types.ts";
1
2
  import type { SearchParams } from "../url/index.ts";
2
3
 
3
4
  export type PrefetchStrategy = false | "intent" | "render" | "viewport";
@@ -19,7 +20,7 @@ export type RouteMetaStatic = {
19
20
  };
20
21
 
21
22
  export type RouteMeta = {
22
- authenticate?: boolean;
23
+ authenticate?: AuthenticateMode;
23
24
  authorize?: boolean;
24
25
  client?: RouteMetaClient;
25
26
  intercept?: { from: readonly string[]; render: string };
@@ -39,25 +39,33 @@ export type MountTarget =
39
39
  }
40
40
  | ((request: Request, env: unknown, ctx: { waitUntil(p: Promise<unknown>): void }) => Response | Promise<Response>);
41
41
 
42
- export interface ServerBuilder<TExcluded extends string = never> {
42
+ export interface ServerBuilder<
43
+ TExcluded extends string = never,
44
+ TAuth = unknown,
45
+ TServerContext = Record<string, unknown>,
46
+ > {
47
+ /** Type-only: what `.authenticateFn()` and `.serverContext()` resolve to. Generated route types read it. */
48
+ readonly "~flare"?: { auth: TAuth; serverContext: TServerContext };
43
49
  authenticateFn: "authenticateFn" extends TExcluded
44
50
  ? never
45
- : <TAuth>(
51
+ : <TNewAuth>(
46
52
  fn: (ctx: {
47
53
  callerData?: unknown[];
48
54
  env: unknown;
49
55
  request: Request;
50
56
  serverContext: Record<string, unknown>;
51
57
  url: URL;
52
- }) => TAuth | null | Promise<TAuth | null>,
53
- ) => ServerBuilder<TExcluded | "authenticateFn">;
54
- cache: "cache" extends TExcluded ? never : (config: HandlerCacheConfig) => ServerBuilder<TExcluded | "cache">;
58
+ }) => TNewAuth | null | Promise<TNewAuth | null>,
59
+ ) => ServerBuilder<TExcluded | "authenticateFn", TNewAuth, TServerContext>;
60
+ cache: "cache" extends TExcluded
61
+ ? never
62
+ : (config: HandlerCacheConfig) => ServerBuilder<TExcluded | "cache", TAuth, TServerContext>;
55
63
  fetch(request: Request, env?: unknown, ctx?: { waitUntil?: (p: Promise<unknown>) => void }): Promise<Response>;
56
64
  getStaticParams(): Promise<StaticParamsMap>;
57
65
  keepalive: "keepalive" extends TExcluded
58
66
  ? never
59
- : (config: KeepaliveConfig) => ServerBuilder<TExcluded | "keepalive">;
60
- mount(prefix: string, target: MountTarget): ServerBuilder<TExcluded>;
67
+ : (config: KeepaliveConfig) => ServerBuilder<TExcluded | "keepalive", TAuth, TServerContext>;
68
+ mount(prefix: string, target: MountTarget): ServerBuilder<TExcluded, TAuth, TServerContext>;
61
69
  security: "security" extends TExcluded
62
70
  ? never
63
71
  : (
@@ -69,15 +77,19 @@ export interface ServerBuilder<TExcluded extends string = never> {
69
77
  request: Request;
70
78
  serverContext: Record<string, unknown>;
71
79
  }) => SecurityConfig),
72
- ) => ServerBuilder<TExcluded | "security">;
80
+ ) => ServerBuilder<TExcluded | "security", TAuth, TServerContext>;
73
81
  serverContext: "serverContext" extends TExcluded
74
82
  ? never
75
83
  : <T extends Record<string, unknown>>(
76
84
  fn: (ctx: { env: unknown; request: Request }) => T | Promise<T>,
77
- ) => ServerBuilder<TExcluded | "serverContext">;
78
- sitemap: "sitemap" extends TExcluded ? never : (config: SitemapSubmitConfig) => ServerBuilder<TExcluded | "sitemap">;
79
- tracing: "tracing" extends TExcluded ? never : (config: TracingConfig) => ServerBuilder<TExcluded | "tracing">;
80
- use(...args: (FlareMiddleware | PathMatcher | string)[]): ServerBuilder<TExcluded>;
85
+ ) => ServerBuilder<TExcluded | "serverContext", TAuth, T>;
86
+ sitemap: "sitemap" extends TExcluded
87
+ ? never
88
+ : (config: SitemapSubmitConfig) => ServerBuilder<TExcluded | "sitemap", TAuth, TServerContext>;
89
+ tracing: "tracing" extends TExcluded
90
+ ? never
91
+ : (config: TracingConfig) => ServerBuilder<TExcluded | "tracing", TAuth, TServerContext>;
92
+ use(...args: (FlareMiddleware | PathMatcher | string)[]): ServerBuilder<TExcluded, TAuth, TServerContext>;
81
93
  }
82
94
 
83
95
  /* Internal implementation type — no exclusion tracking */
@@ -640,7 +640,6 @@ interface EngineResult {
640
640
 
641
641
  async function handleSitemapSubmitRequest<TEnv>(
642
642
  request: Request,
643
- url: URL,
644
643
  env: TEnv,
645
644
  sitemapConfig: SitemapSubmitConfig<TEnv>,
646
645
  ): Promise<Response> {
@@ -1154,7 +1153,7 @@ export function createServerHandler<
1154
1153
 
1155
1154
  if (url.pathname === "/_flare/sitemap/submit" && config.sitemap) {
1156
1155
  /* Internal endpoints bypass middleware response handlers — only security headers */
1157
- let sitemapResponse = await handleSitemapSubmitRequest(request, url, env, config.sitemap);
1156
+ let sitemapResponse = await handleSitemapSubmitRequest(request, env, config.sitemap);
1158
1157
  sitemapResponse = addSecurityHeaders(sitemapResponse, secHeaders);
1159
1158
  return sitemapResponse;
1160
1159
  }
@@ -1288,7 +1287,7 @@ export function createServerHandler<
1288
1287
  client hashes into the new artifacts. */
1289
1288
  if (
1290
1289
  staticMeta &&
1291
- match.route.o.authenticate !== true &&
1290
+ !match.route.o.authenticate &&
1292
1291
  match.route.o.authorize !== true &&
1293
1292
  resolvedStore &&
1294
1293
  !isISRBgRequest &&
@@ -1622,7 +1621,7 @@ export function createServerHandler<
1622
1621
  */
1623
1622
  if (
1624
1623
  staticMeta?.mode === "isr" &&
1625
- match.route.o.authenticate !== true &&
1624
+ !match.route.o.authenticate &&
1626
1625
  match.route.o.authorize !== true &&
1627
1626
  resolvedStore &&
1628
1627
  !isDataRequest &&