diff --git a/README.md b/README.md
index f5d7c57..740782e 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,10 @@ ZibStack is designed so you can adopt as little or as much as you want. Start at
## Packages
+TypeGen's TanStack Query client supports reverse-proxy service prefixes via
+`q.PreserveBaseUrlPath = true`; see the
+[URL resolution guide](https://mistykuu.github.io/ZibStack.NET/packages/typegen/emitters/tanstack-query/#reverse-proxy-service-prefixes).
+
| Package | NuGet | Description |
|---|---|---|
| [**ZibStack.NET.Aop**](packages/ZibStack.NET.Aop/) | `dotnet add package ZibStack.NET.Aop` | AOP framework with C# interceptors. Built-in: `[Log]`, `[Trace]`, `[Retry]`, `[Cache]`, `[Metrics]`, `[Timeout]`, `[Authorize]`, `[Validate]`, `[Transaction]`. Custom aspects via `IAspectHandler`. Global `Apply<>()` rules. |
diff --git a/docs/src/content/docs/packages/typegen/configuration.md b/docs/src/content/docs/packages/typegen/configuration.md
index c71a81e..ce6f3c0 100644
--- a/docs/src/content/docs/packages/typegen/configuration.md
+++ b/docs/src/content/docs/packages/typegen/configuration.md
@@ -44,6 +44,7 @@ public sealed class TypeGenConfig : ITypeGenConfigurator
q.OutputDir = "../client/src/api";
q.SingleFileName = "api.gen.ts";
q.BaseUrlExpression = "import.meta.env.VITE_API_URL";
+ // q.PreserveBaseUrlPath = true; // retain reverse-proxy prefixes
// q.FileLayout = QueryFileLayout.SplitByTag;
// q.ApiClientImportPath = "./http-client";
// q.ApiClientName = "request";
diff --git a/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md b/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md
index 25d8888..17ee3cb 100644
--- a/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md
+++ b/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md
@@ -88,6 +88,7 @@ public sealed class TypeGenConfig : ITypeGenConfigurator
q.OutputDir = "../client/src/api";
q.SingleFileName = "api.gen.ts";
q.BaseUrlExpression = "import.meta.env.VITE_API_URL";
+ // q.PreserveBaseUrlPath = true; // reverse-proxy service prefix
// q.FileLayout = QueryFileLayout.SplitByTag;
// q.ApiClientImportPath = "./http-client";
// q.ApiClientName = "request";
@@ -102,6 +103,52 @@ configured expression is missing, empty, or throws, it falls back to
non-browser execution. You can still set `BaseUrlExpression` to a literal origin
or import a custom client when you want stricter environment handling.
+### Reverse-proxy service prefixes
+
+Set `PreserveBaseUrlPath = true` when the configured base URL includes the
+service's externally visible prefix:
+
+```csharp
+b.TanStackQuery(q =>
+{
+ q.BaseUrlExpression = "import.meta.env.VITE_API_URL";
+ q.PreserveBaseUrlPath = true;
+});
+```
+
+For example, set `VITE_API_URL=http://localhost:3100/aoi`. The built-in client
+then resolves `/asdf` to `http://localhost:3100/aoi/asdf`. No custom client or
+generated-file edits are needed.
+
+The default is `false` to preserve existing consumers' standard URL resolution.
+In that mode, `/asdf` resolves from the origin and a base's final pathname
+segment without a trailing slash is treated as a file:
+
+| Base URL | Route | Default (`false`) | Preserve path (`true`) |
+|---|---|---|---|
+| `https://host/aoi` | `/asdf` | `https://host/asdf` | `https://host/aoi/asdf` |
+| `https://host/aoi` | `asdf` | `https://host/asdf` | `https://host/aoi/asdf` |
+| `https://host/proxy/aoi/` | `/asdf` | `https://host/asdf` | `https://host/proxy/aoi/asdf` |
+| `https://host` | `/asdf` | `https://host/asdf` | `https://host/asdf` |
+
+With preservation enabled, the base pathname is treated as a directory, with
+or without a trailing slash. A root (`/`) or empty route resolves to that
+directory. Endpoint routes should contain the backend route only; including
+the proxy prefix in both the base and route duplicates it.
+
+Absolute URLs (`https://other/path`) and protocol-relative paths (`//other/path`)
+retain standard URL semantics in both modes: they can select another host and
+bypass the service prefix. Dot segments still follow URL normalization rules;
+this setting is not a restriction on where requests may be sent.
+
+Route encoding and query handling remain unchanged. Encoded route values stay
+encoded, route query strings are retained, and `options.query` sets scalar
+values or appends array values using `URLSearchParams`. Base query strings and
+fragments follow standard URL rules (an empty route inherits the base query).
+The existing base URL fallback still applies. `ApiClientImportPath` continues
+to replace the built-in client entirely; this option does not alter imported
+clients.
+
## Generated shape
```typescript
diff --git a/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs b/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs
index 8530fa9..2aca609 100644
--- a/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs
+++ b/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs
@@ -32,6 +32,9 @@ public void Configure(ITypeGenBuilder b)
// The default is import.meta.env.VITE_API_URL. The sample uses the
// current origin so api.gen.ts type-checks in non-Vite clients too.
q.BaseUrlExpression = "window.location.origin";
+ // When the configured base includes a reverse-proxy service prefix,
+ // resolve backend routes beneath it (opt-in for compatibility):
+ // q.PreserveBaseUrlPath = true;
});
b.OpenApi(oa =>
diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs
index 1694f8e..46bcead 100644
--- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs
+++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs
@@ -338,6 +338,14 @@ public sealed class TanStackQuerySettings
///
public string BaseUrlExpression { get; set; } = "import.meta.env.VITE_API_URL";
+ ///
+ /// Resolve endpoint routes beneath the base URL pathname, treating it as a
+ /// directory even without a trailing slash. Default false preserves
+ /// standard URL resolution. Absolute URLs and protocol-relative paths retain
+ /// standard URL semantics in either mode. Applies only to the default client.
+ ///
+ public bool PreserveBaseUrlPath { get; set; }
+
///
/// Optional module specifier for a user-supplied API client. When set, the
/// emitter imports from this module and does not
diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs
index 4a824c4..8734c26 100644
--- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs
+++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs
@@ -606,7 +606,17 @@ private static void EmitDefaultClient(StringBuilder sb, TanStackQuerySettings qu
sb.AppendLine($" try {{ return {query.BaseUrlExpression}; }} catch {{ return undefined; }}");
sb.AppendLine(" })();");
sb.AppendLine(" const baseUrl = configuredBaseUrl || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost');");
- sb.AppendLine(" const url = new URL(path, baseUrl);");
+ if (query.PreserveBaseUrlPath)
+ {
+ sb.AppendLine(" const serviceBaseUrl = new URL(baseUrl);");
+ sb.AppendLine(" if (!serviceBaseUrl.pathname.endsWith('/')) serviceBaseUrl.pathname += '/';");
+ sb.AppendLine(" const isAbsoluteUrl = /^[a-z][a-z\\d+.-]*:/i.test(path) || path.startsWith('//');");
+ sb.AppendLine(" const url = new URL(isAbsoluteUrl ? path : path.replace(/^\\/+/, ''), serviceBaseUrl);");
+ }
+ else
+ {
+ sb.AppendLine(" const url = new URL(path, baseUrl);");
+ }
sb.AppendLine(" if (options.query) {");
sb.AppendLine(" for (const [key, value] of Object.entries(options.query)) {");
sb.AppendLine(" if (value === undefined || value === null) continue;");
diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs
index cefff18..81ab87e 100644
--- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs
+++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs
@@ -107,6 +107,7 @@ internal sealed class TanStackQuerySettings
public QueryFileLayout FileLayout { get; set; } = QueryFileLayout.SingleFile;
public string SingleFileName { get; set; } = "api.gen.ts";
public string BaseUrlExpression { get; set; } = "import.meta.env.VITE_API_URL";
+ public bool PreserveBaseUrlPath { get; set; }
public string? ApiClientImportPath { get; set; }
public string ApiClientName { get; set; } = "apiFetch";
public string? ModelsImportPath { get; set; }
diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs
index d79e961..ecf4416 100644
--- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs
+++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs
@@ -422,6 +422,7 @@ private static void AssignTanStackQuery(TanStackQuerySettings s, string prop, ob
case "FileLayout": if (val is int fl) s.FileLayout = (QueryFileLayout)fl; break;
case "SingleFileName": if (val is string sf) s.SingleFileName = sf; break;
case "BaseUrlExpression": if (val is string bu) s.BaseUrlExpression = bu; break;
+ case "PreserveBaseUrlPath": if (val is bool pb) s.PreserveBaseUrlPath = pb; break;
case "ApiClientImportPath": s.ApiClientImportPath = val as string; break;
case "ApiClientName": if (val is string ac) s.ApiClientName = ac; break;
case "ModelsImportPath": s.ModelsImportPath = val as string; break;
diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs
index 07cfcd6..20a8dae 100644
--- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs
+++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs
@@ -86,6 +86,7 @@ public void Configure(ITypeGenBuilder b) {
q.FileLayout = QueryFileLayout.SplitByTag;
q.SingleFileName = "query.gen.ts";
q.BaseUrlExpression = "window.location.origin";
+ q.PreserveBaseUrlPath = true;
q.ApiClientImportPath = "./client";
q.ApiClientName = "request";
q.ModelsImportPath = "../models";
@@ -104,6 +105,7 @@ public void Configure(ITypeGenBuilder b) {
Assert.Equal(QueryFileLayout.SplitByTag, parsed.Settings.TanStackQuery.FileLayout);
Assert.Equal("query.gen.ts", parsed.Settings.TanStackQuery.SingleFileName);
Assert.Equal("window.location.origin", parsed.Settings.TanStackQuery.BaseUrlExpression);
+ Assert.True(parsed.Settings.TanStackQuery.PreserveBaseUrlPath);
Assert.Equal("./client", parsed.Settings.TanStackQuery.ApiClientImportPath);
Assert.Equal("request", parsed.Settings.TanStackQuery.ApiClientName);
Assert.Equal("../models", parsed.Settings.TanStackQuery.ModelsImportPath);
@@ -517,6 +519,7 @@ public sealed class TanStackQuerySettings {
public QueryFileLayout FileLayout { get; set; }
public string SingleFileName { get; set; } = "api.gen.ts";
public string BaseUrlExpression { get; set; } = "import.meta.env.VITE_API_URL";
+ public bool PreserveBaseUrlPath { get; set; }
public string? ApiClientImportPath { get; set; }
public string ApiClientName { get; set; } = "apiFetch";
public string? ModelsImportPath { get; set; }
diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs
index c0279cf..6d840f6 100644
--- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs
+++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs
@@ -140,6 +140,7 @@ public void SplitByTag_WithCustomClient_EmitsOneFilePerTagWithoutDefaultClient()
var settings = new GlobalSettings();
settings.TanStackQuery.FileLayout = QueryFileLayout.SplitByTag;
settings.TanStackQuery.ApiClientImportPath = "./http-client";
+ settings.TanStackQuery.PreserveBaseUrlPath = true;
settings.TanStackQuery.ApiClientName = "request";
settings.TanStackQuery.EmitHooks = false;
settings.TanStackQuery.EmitCacheHelpers = false;
diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryUrlTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryUrlTests.cs
new file mode 100644
index 0000000..53d60fe
--- /dev/null
+++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryUrlTests.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Threading.Tasks;
+using Xunit;
+using ZibStack.NET.TypeGen.Generator;
+
+namespace TypeGenTests;
+
+public class TanStackQueryUrlTests
+{
+ // Execute the complete emitted TypeScript client, including a generated
+ // endpoint, against a fetch stub. Node 22+ is installed by CI.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task GeneratedClient_ResolvesActualRequestUrls(bool preserveBaseUrlPath)
+ {
+ var model = new SchemaModel();
+ model.Endpoints.Add(new EndpointInfo
+ {
+ Verb = "get", Pattern = "/items/{id}", OperationId = "getItem",
+ Parameters =
+ {
+ new EndpointParameter { Name = "id", Location = ParamLocation.Route, CSharpType = "string", Required = true },
+ },
+ });
+ var settings = new GlobalSettings();
+ settings.TanStackQuery.PreserveBaseUrlPath = preserveBaseUrlPath;
+ settings.TanStackQuery.BaseUrlExpression = "configuredBase()";
+ settings.TanStackQuery.EmitHooks = false;
+ settings.TanStackQuery.EmitQueryOptions = false;
+ settings.TanStackQuery.EmitMutationOptions = false;
+ settings.TanStackQuery.EmitCacheHelpers = false;
+ var client = Assert.Single(TanStackQueryEmitter.Emit(model, settings)).Content;
+ var script = "const preserve = " + (preserveBaseUrlPath ? "true" : "false") + ";\n" + client + "\n" + """
+ import assert from 'node:assert/strict';
+ let base: string | undefined = undefined;
+ let throwBase = false;
+ function configuredBase() {
+ if (throwBase) throw new Error('missing configuration');
+ return base;
+ }
+ let actual = '';
+ globalThis.fetch = async (url) => {
+ actual = String(url);
+ return new Response(null, { status: 204 });
+ };
+ let checks = 0;
+ async function check(path: string, expected: string, query?: Record) {
+ await apiFetch(path, { query });
+ assert.equal(actual, expected, `base=${base}, path=${path}, preserve=${preserve}`);
+ checks++;
+ }
+ for (const prefix of ['', '/aoi', '/proxy/services/aoi']) {
+ for (const trailing of ['', '/']) {
+ base = 'https://example.test' + prefix + trailing;
+ const service = 'https://example.test' + prefix + '/';
+ for (const route of ['asdf', '/asdf', '', '/']) {
+ const expected = preserve
+ ? service + route.replace(/^\/+/, '')
+ : new URL(route, base).toString();
+ await check(route, expected);
+ }
+ await check('https://other.test/remote?x=1', 'https://other.test/remote?x=1');
+ await check('//other.test/remote', 'https://other.test/remote');
+ const target = preserve ? service : 'https://example.test/';
+ await check('/asdf?keep=yes&replace=old', target + 'asdf?keep=yes&replace=new&tag=a&tag=b', {
+ replace: 'new', tag: ['a', 'b'], omitted: undefined, nil: null,
+ });
+ await getItem({ id: 'a/b ?#%ΓΌ' });
+ assert.equal(actual, target + 'items/a%2Fb%20%3F%23%25%C3%BC');
+ checks++;
+ }
+ }
+ // Base queries/fragments retain URL semantics: route queries win;
+ // an empty route inherits the base query but drops the fragment.
+ base = 'https://example.test/aoi?base=1#fragment';
+ await check('/asdf?route=2', preserve ? 'https://example.test/aoi/asdf?route=2' : 'https://example.test/asdf?route=2');
+ await check('', preserve ? 'https://example.test/aoi/?base=1' : 'https://example.test/aoi?base=1');
+ for (const fallback of [undefined, '']) {
+ base = fallback;
+ await check('/asdf', 'http://localhost/asdf');
+ (globalThis as any).window = { location: { origin: 'https://browser.test' } };
+ await check('/asdf', 'https://browser.test/asdf');
+ delete (globalThis as any).window;
+ }
+ throwBase = true;
+ await check('/asdf', 'http://localhost/asdf');
+ (globalThis as any).window = { location: { origin: 'https://browser.test' } };
+ await check('/asdf', 'https://browser.test/asdf');
+ delete (globalThis as any).window;
+ console.log(`Verified ${checks} request URLs`);
+ """;
+ var dir = Path.Combine(Path.GetTempPath(), "typegen-url-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ try
+ {
+ var path = Path.Combine(dir, "client.mts");
+ await File.WriteAllTextAsync(path, script);
+ var start = new ProcessStartInfo("node")
+ {
+ RedirectStandardOutput = true, RedirectStandardError = true,
+ UseShellExecute = false, CreateNoWindow = true,
+ };
+ start.ArgumentList.Add("--experimental-transform-types");
+ start.ArgumentList.Add(path);
+ using var process = Process.Start(start)!;
+ var stdout = process.StandardOutput.ReadToEndAsync();
+ var stderr = process.StandardError.ReadToEndAsync();
+ if (!process.WaitForExit(30_000))
+ {
+ process.Kill(entireProcessTree: true);
+ Assert.Fail("Generated-client URL test timed out.");
+ }
+ Assert.True(process.ExitCode == 0, $"{await stdout}\n{await stderr}\n{client}");
+ Assert.Contains("Verified 56 request URLs", await stdout);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+}