@somewhatintelligent/kit 0.0.3-beta.1783714528.6f95113 → 0.0.3-beta.1783732751.3b77dd8
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@somewhatintelligent/kit",
|
|
3
|
-
"version": "0.0.3-beta.
|
|
3
|
+
"version": "0.0.3-beta.1783732751.3b77dd8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"publishConfig": {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./react": "./src/react/index.ts",
|
|
21
21
|
"./react-start": "./src/react-start/index.ts",
|
|
22
22
|
"./react-start/client": "./src/react-start/client.ts",
|
|
23
|
+
"./react-start/mount": "./src/react-start/mount.ts",
|
|
23
24
|
"./execution-context": "./src/execution-context/index.ts"
|
|
24
25
|
},
|
|
25
26
|
"files": [
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
"test": "vitest run"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@somewhatintelligent/auth": "^0.0.
|
|
34
|
+
"@somewhatintelligent/auth": "^0.0.4-beta.1783732751.3b77dd8",
|
|
34
35
|
"ulidx": "^2.4.1"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { createAnalytics, type AnalyticsAdapter } from "../analytics";
|
|
3
|
+
|
|
4
|
+
interface Events extends Record<string, object> {
|
|
5
|
+
signed_in: { method: "email" | "passkey" };
|
|
6
|
+
signed_out: Record<string, never>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("createAnalytics — no adapter (analytics off)", () => {
|
|
10
|
+
const surface = createAnalytics<Events>();
|
|
11
|
+
|
|
12
|
+
test("useCapture returns a callable no-op", () => {
|
|
13
|
+
const capture = surface.useCapture();
|
|
14
|
+
expect(() => capture("signed_in", { method: "email" })).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("useCaptureAsync resolves immediately", async () => {
|
|
18
|
+
const captureAsync = surface.useCaptureAsync();
|
|
19
|
+
await expect(captureAsync("signed_out", {})).resolves.toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("provider renders children as-is", () => {
|
|
23
|
+
// The default provider is a plain function component — call it directly.
|
|
24
|
+
const Provider = surface.AnalyticsProvider as unknown as (props: object) => unknown;
|
|
25
|
+
const out = Provider({
|
|
26
|
+
app: "identity",
|
|
27
|
+
environment: "test",
|
|
28
|
+
session: null,
|
|
29
|
+
children: "kids",
|
|
30
|
+
});
|
|
31
|
+
expect(out).toBe("kids");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("createAnalytics — adapter injected", () => {
|
|
36
|
+
test("capture routes through the adapter hook", () => {
|
|
37
|
+
const spy = vi.fn();
|
|
38
|
+
const adapter: AnalyticsAdapter<Events> = { useCapture: () => spy };
|
|
39
|
+
const { useCapture } = createAnalytics<Events>(adapter);
|
|
40
|
+
useCapture()("signed_in", { method: "passkey" });
|
|
41
|
+
expect(spy).toHaveBeenCalledWith("signed_in", { method: "passkey" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("captureAsync falls back to sync capture when not implemented", async () => {
|
|
45
|
+
const spy = vi.fn();
|
|
46
|
+
const adapter: AnalyticsAdapter<Events> = { useCapture: () => spy };
|
|
47
|
+
const { useCaptureAsync } = createAnalytics<Events>(adapter);
|
|
48
|
+
await useCaptureAsync()("signed_out", {});
|
|
49
|
+
expect(spy).toHaveBeenCalledWith("signed_out", {});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("captureAsync prefers the adapter's async hook", async () => {
|
|
53
|
+
const sync = vi.fn();
|
|
54
|
+
const asy = vi.fn().mockResolvedValue(undefined);
|
|
55
|
+
const adapter: AnalyticsAdapter<Events> = {
|
|
56
|
+
useCapture: () => sync,
|
|
57
|
+
useCaptureAsync: () => asy,
|
|
58
|
+
};
|
|
59
|
+
const { useCaptureAsync } = createAnalytics<Events>(adapter);
|
|
60
|
+
await useCaptureAsync()("signed_in", { method: "email" });
|
|
61
|
+
expect(asy).toHaveBeenCalled();
|
|
62
|
+
expect(sync).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("adapter provider is used verbatim", () => {
|
|
66
|
+
const Provider = vi.fn(({ children }) => children);
|
|
67
|
+
const adapter: AnalyticsAdapter<Events> = { Provider };
|
|
68
|
+
const surface = createAnalytics<Events>(adapter);
|
|
69
|
+
expect(surface.AnalyticsProvider).toBe(Provider);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-independent analytics seam.
|
|
3
|
+
*
|
|
4
|
+
* `createAnalytics<Events, S>(adapter?)` returns the app-facing surface —
|
|
5
|
+
* `{ AnalyticsProvider, useCapture, useCaptureAsync }` — typed against the
|
|
6
|
+
* app's own event registry. The vendor lives entirely inside the injected
|
|
7
|
+
* `AnalyticsAdapter`; omit it and every surface is a no-op (feature absence
|
|
8
|
+
* is a branch): the provider renders children as-is, `useCapture` returns a
|
|
9
|
+
* function that does nothing, `useCaptureAsync` resolves immediately. App
|
|
10
|
+
* call sites never change when a vendor is added, swapped, or removed.
|
|
11
|
+
*
|
|
12
|
+
* The adapter's capture surfaces are HOOKS, not plain functions, because
|
|
13
|
+
* vendor SDKs typically hand out their capture function through React
|
|
14
|
+
* context (e.g. a PostHog provider + usePostHog). `useCaptureAsync` exists
|
|
15
|
+
* for events raced by navigation — sign-out into a redirect, delete-account
|
|
16
|
+
* into a hard reload — where the adapter should flush/await delivery before
|
|
17
|
+
* resolving. When an adapter implements only `useCapture`, the async
|
|
18
|
+
* surface falls back to it (fire-and-forget wrapped in a resolved promise).
|
|
19
|
+
*
|
|
20
|
+
* Apps glue it together once:
|
|
21
|
+
*
|
|
22
|
+
* const adapter: AnalyticsAdapter<MyEvents> | undefined = myVendorAdapter;
|
|
23
|
+
* export const { AnalyticsProvider, useCapture, useCaptureAsync } =
|
|
24
|
+
* createAnalytics<MyEvents>(adapter);
|
|
25
|
+
*/
|
|
26
|
+
import type { ComponentType, ReactNode } from "react";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An app's event registry shape: event name → required properties.
|
|
30
|
+
* A mapped-type constraint so plain interfaces qualify (no index signature).
|
|
31
|
+
*/
|
|
32
|
+
export type AnalyticsEventMap<E = unknown> = { [K in keyof E]: object };
|
|
33
|
+
|
|
34
|
+
/** Fire-and-forget capture, typed against the app's registry. */
|
|
35
|
+
export type CaptureFn<Events extends AnalyticsEventMap<Events>> = <E extends keyof Events>(
|
|
36
|
+
event: E,
|
|
37
|
+
props: Events[E],
|
|
38
|
+
) => void;
|
|
39
|
+
|
|
40
|
+
/** Awaitable capture: resolves once the adapter considers the event delivered. */
|
|
41
|
+
export type CaptureAsyncFn<Events extends AnalyticsEventMap<Events>> = <E extends keyof Events>(
|
|
42
|
+
event: E,
|
|
43
|
+
props: Events[E],
|
|
44
|
+
) => Promise<void>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Context handed to the adapter's provider at the app root. `session` is
|
|
48
|
+
* whatever session shape the app runs on (identify/reset transitions live
|
|
49
|
+
* inside the adapter, keyed off it).
|
|
50
|
+
*/
|
|
51
|
+
export interface AnalyticsProviderProps<S> {
|
|
52
|
+
/** App name stamped on events (multi-app fleets share one project). */
|
|
53
|
+
app: string;
|
|
54
|
+
environment: string | undefined;
|
|
55
|
+
session: S | null;
|
|
56
|
+
children?: ReactNode;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AnalyticsAdapter<Events extends AnalyticsEventMap<Events>, S = unknown> {
|
|
60
|
+
/**
|
|
61
|
+
* Vendor root (SDK init, identity bridge). Omit for vendors that need no
|
|
62
|
+
* provider — capture hooks then run without vendor context.
|
|
63
|
+
*/
|
|
64
|
+
Provider?: ComponentType<AnalyticsProviderProps<S>>;
|
|
65
|
+
/** Hook returning the sync capture function (may read vendor context). */
|
|
66
|
+
useCapture?: () => CaptureFn<Events>;
|
|
67
|
+
/**
|
|
68
|
+
* Hook returning the awaitable capture — implement with the vendor's
|
|
69
|
+
* flush/callback so navigation-raced events survive. Falls back to
|
|
70
|
+
* `useCapture` when omitted.
|
|
71
|
+
*/
|
|
72
|
+
useCaptureAsync?: () => CaptureAsyncFn<Events>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AnalyticsSurface<Events extends AnalyticsEventMap<Events>, S> {
|
|
76
|
+
AnalyticsProvider: ComponentType<AnalyticsProviderProps<S>>;
|
|
77
|
+
useCapture: () => CaptureFn<Events>;
|
|
78
|
+
useCaptureAsync: () => CaptureAsyncFn<Events>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createAnalytics<Events extends AnalyticsEventMap<Events>, S = unknown>(
|
|
82
|
+
adapter?: AnalyticsAdapter<Events, S>,
|
|
83
|
+
): AnalyticsSurface<Events, S> {
|
|
84
|
+
const noopCapture: CaptureFn<Events> = () => {};
|
|
85
|
+
|
|
86
|
+
const AnalyticsProvider: ComponentType<AnalyticsProviderProps<S>> = adapter?.Provider
|
|
87
|
+
? adapter.Provider
|
|
88
|
+
: ({ children }: AnalyticsProviderProps<S>) => children;
|
|
89
|
+
|
|
90
|
+
const useCapture = adapter?.useCapture ?? (() => noopCapture);
|
|
91
|
+
|
|
92
|
+
const useCaptureAsync: () => CaptureAsyncFn<Events> =
|
|
93
|
+
adapter?.useCaptureAsync ??
|
|
94
|
+
(adapter?.useCapture
|
|
95
|
+
? () => {
|
|
96
|
+
const capture = adapter.useCapture!();
|
|
97
|
+
return async (event, props) => capture(event, props);
|
|
98
|
+
}
|
|
99
|
+
: () => async () => {});
|
|
100
|
+
|
|
101
|
+
return { AnalyticsProvider, useCapture, useCaptureAsync };
|
|
102
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -1,2 +1,11 @@
|
|
|
1
1
|
export { createAuthContext } from "./auth";
|
|
2
2
|
export type { AuthContextValue } from "./auth";
|
|
3
|
+
export { createAnalytics } from "./analytics";
|
|
4
|
+
export type {
|
|
5
|
+
AnalyticsAdapter,
|
|
6
|
+
AnalyticsEventMap,
|
|
7
|
+
AnalyticsProviderProps,
|
|
8
|
+
AnalyticsSurface,
|
|
9
|
+
CaptureFn,
|
|
10
|
+
CaptureAsyncFn,
|
|
11
|
+
} from "./analytics";
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { MOUNT_META_NAME, mountRewrite, normalizeBasepath, resolveBasepath } from "../mount";
|
|
3
|
+
|
|
4
|
+
describe("normalizeBasepath", () => {
|
|
5
|
+
test("empty / null / undefined → '/'", () => {
|
|
6
|
+
expect(normalizeBasepath(undefined)).toBe("/");
|
|
7
|
+
expect(normalizeBasepath(null)).toBe("/");
|
|
8
|
+
expect(normalizeBasepath("")).toBe("/");
|
|
9
|
+
expect(normalizeBasepath(" ")).toBe("/");
|
|
10
|
+
expect(normalizeBasepath("/")).toBe("/");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("adds a leading slash and strips trailing slashes", () => {
|
|
14
|
+
expect(normalizeBasepath("shop")).toBe("/shop");
|
|
15
|
+
expect(normalizeBasepath("/shop")).toBe("/shop");
|
|
16
|
+
expect(normalizeBasepath("/shop/")).toBe("/shop");
|
|
17
|
+
expect(normalizeBasepath("shop///")).toBe("/shop");
|
|
18
|
+
expect(normalizeBasepath("/account")).toBe("/account");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("resolveBasepath", () => {
|
|
23
|
+
test("server is always root (bouncer's vmf already stripped the mount)", () => {
|
|
24
|
+
// Even under the mount, the server sees the stripped path — basepath must
|
|
25
|
+
// be '/' server-side or matching fails.
|
|
26
|
+
expect(resolveBasepath({ isServer: true, publicBase: "/shop" })).toBe("/");
|
|
27
|
+
expect(resolveBasepath({ isServer: true, publicBase: "/shop/" })).toBe("/");
|
|
28
|
+
expect(resolveBasepath({ isServer: true, publicBase: undefined })).toBe("/");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("client adopts the public mount so the URL bar keeps the prefix", () => {
|
|
32
|
+
expect(resolveBasepath({ isServer: false, publicBase: "/shop" })).toBe("/shop");
|
|
33
|
+
expect(resolveBasepath({ isServer: false, publicBase: "/shop/" })).toBe("/shop");
|
|
34
|
+
expect(resolveBasepath({ isServer: false, publicBase: "/account" })).toBe("/account");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("client with no configured base (local dev-direct) → '/'", () => {
|
|
38
|
+
expect(resolveBasepath({ isServer: false, publicBase: undefined })).toBe("/");
|
|
39
|
+
expect(resolveBasepath({ isServer: false, publicBase: "/" })).toBe("/");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("resolveBasepath — runtime mount meta", () => {
|
|
44
|
+
test("client: mount meta wins over the build-time fallback", () => {
|
|
45
|
+
expect(resolveBasepath({ isServer: false, publicBase: "/shop", mountMeta: "/other" })).toBe(
|
|
46
|
+
"/other",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
test("client: falls back to publicBase when no meta", () => {
|
|
50
|
+
expect(resolveBasepath({ isServer: false, publicBase: "/shop", mountMeta: null })).toBe(
|
|
51
|
+
"/shop",
|
|
52
|
+
);
|
|
53
|
+
expect(resolveBasepath({ isServer: false, publicBase: "/shop", mountMeta: " " })).toBe(
|
|
54
|
+
"/shop",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
test("server: meta never applies — bouncer already stripped the mount", () => {
|
|
58
|
+
expect(resolveBasepath({ isServer: true, publicBase: "/shop", mountMeta: "/shop" })).toBe("/");
|
|
59
|
+
});
|
|
60
|
+
test("meta is normalized like any base", () => {
|
|
61
|
+
expect(resolveBasepath({ isServer: false, publicBase: null, mountMeta: "shop/" })).toBe(
|
|
62
|
+
"/shop",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("MOUNT_META_NAME", () => {
|
|
68
|
+
test("is the vmf announcement contract shared with bouncer", () => {
|
|
69
|
+
expect(MOUNT_META_NAME).toBe("si-mount");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("mountRewrite", () => {
|
|
74
|
+
// The mount rides the router `rewrite` option, not `basepath`: TanStack
|
|
75
|
+
// Start's server handler AND client bootstrap both call
|
|
76
|
+
// router.update({ basepath: process.env.TSS_ROUTER_BASEPATH }), clobbering
|
|
77
|
+
// any createRouter-level basepath — the regression that unmounted the
|
|
78
|
+
// whole tree on staging the moment hydration finished. `rewrite` survives
|
|
79
|
+
// those updates, so these tests pin the strip/prepend contract.
|
|
80
|
+
const url = (path: string) => new URL(`https://example.com${path}`);
|
|
81
|
+
|
|
82
|
+
test("root mount → no rewrite at all (dev-direct, server)", () => {
|
|
83
|
+
expect(mountRewrite("/")).toBeUndefined();
|
|
84
|
+
expect(mountRewrite("")).toBeUndefined();
|
|
85
|
+
expect(mountRewrite(" ")).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("input strips the mount (browser URL → router URL)", () => {
|
|
89
|
+
const rw = mountRewrite("/shop");
|
|
90
|
+
expect(rw).toBeDefined();
|
|
91
|
+
expect(rw?.input({ url: url("/shop") }).pathname).toBe("/");
|
|
92
|
+
expect(rw?.input({ url: url("/shop/") }).pathname).toBe("/");
|
|
93
|
+
expect(rw?.input({ url: url("/shop/products/tee") }).pathname).toBe("/products/tee");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("input leaves non-mount paths alone", () => {
|
|
97
|
+
const rw = mountRewrite("/shop");
|
|
98
|
+
expect(rw?.input({ url: url("/shopping") }).pathname).toBe("/shopping");
|
|
99
|
+
expect(rw?.input({ url: url("/other") }).pathname).toBe("/other");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("output prepends the mount (router URL → browser URL)", () => {
|
|
103
|
+
const rw = mountRewrite("/shop");
|
|
104
|
+
expect(rw?.output({ url: url("/") }).pathname).toBe("/shop");
|
|
105
|
+
expect(rw?.output({ url: url("/products/tee") }).pathname).toBe("/shop/products/tee");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("input and output round-trip", () => {
|
|
109
|
+
const rw = mountRewrite("/shop");
|
|
110
|
+
for (const p of ["/", "/cart", "/products/tee"]) {
|
|
111
|
+
const out = rw?.output({ url: url(p) });
|
|
112
|
+
expect(rw?.input({ url: out as URL }).pathname).toBe(p);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("mount is normalized before use", () => {
|
|
117
|
+
const rw = mountRewrite("shop/");
|
|
118
|
+
expect(rw?.input({ url: url("/shop/cart") }).pathname).toBe("/cart");
|
|
119
|
+
expect(rw?.output({ url: url("/cart") }).pathname).toBe("/shop/cart");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mount-prefix helpers for TSS apps served behind a vmf mount.
|
|
3
|
+
*
|
|
4
|
+
* A vmf-mounted app (bouncer route mode `"vmf"`) is path-prefix-mounted on a
|
|
5
|
+
* shared host: bouncer STRIPS the mount before the request reaches the
|
|
6
|
+
* worker, so the SERVER always serves at root and every route definition /
|
|
7
|
+
* `<Link>` / redirect in the app stays prefix-free. The one thing bouncer's
|
|
8
|
+
* HTTP-layer rewrite cannot reach is the hydrated client router's
|
|
9
|
+
* history/link state — so the CLIENT router adopts the mount as a TanStack
|
|
10
|
+
* Router `rewrite` pair (`mountRewrite`): strip the mount when parsing the
|
|
11
|
+
* browser URL, prepend it when writing to history. Result: the URL bar keeps
|
|
12
|
+
* the mount across client-side navigation and hard refreshes, with zero
|
|
13
|
+
* prefixes in app code.
|
|
14
|
+
*
|
|
15
|
+
* The mount is announced at RUNTIME by bouncer's vmf HTML transform
|
|
16
|
+
* (`MOUNT_META_NAME` below — see `@somewhatintelligent/bouncer`'s
|
|
17
|
+
* MountMetaInjector). Apps may additionally compile a build-time fallback
|
|
18
|
+
* (e.g. a PUBLIC_BASE client var); the runtime meta always wins. With no
|
|
19
|
+
* bouncer in front (dev-direct) there is no meta and the mount is "/".
|
|
20
|
+
*
|
|
21
|
+
* This module is pure and isomorphic — safe to import from router setup
|
|
22
|
+
* code that TSS evaluates on both server and client.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The `<meta name>` bouncer's vmf transform injects to announce the mount.
|
|
27
|
+
* Contract shared with `@somewhatintelligent/bouncer` — it imports this
|
|
28
|
+
* constant; apps read it via `readMountMeta`.
|
|
29
|
+
*/
|
|
30
|
+
export const MOUNT_META_NAME = "si-mount";
|
|
31
|
+
|
|
32
|
+
/** Normalize a raw base to a leading-slash, no-trailing-slash form ("/" stays "/"). */
|
|
33
|
+
export function normalizeBasepath(raw: string | undefined | null): string {
|
|
34
|
+
if (raw == null) return "/";
|
|
35
|
+
let b = raw.trim();
|
|
36
|
+
if (b === "" || b === "/") return "/";
|
|
37
|
+
if (!b.startsWith("/")) b = `/${b}`;
|
|
38
|
+
while (b.length > 1 && b.endsWith("/")) b = b.slice(0, -1);
|
|
39
|
+
return b;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The mount for the current execution side.
|
|
44
|
+
*
|
|
45
|
+
* - Server: always "/" — bouncer already stripped the mount, so the server
|
|
46
|
+
* router matches the stripped (root) path. A non-root mount here would
|
|
47
|
+
* fail to match the stripped path.
|
|
48
|
+
* - Client: the runtime mount meta wins (correct for whatever mount bouncer
|
|
49
|
+
* actually served this document under, needs no build-time config), then
|
|
50
|
+
* the build-time `publicBase` fallback, then "/".
|
|
51
|
+
*/
|
|
52
|
+
export function resolveBasepath(opts: {
|
|
53
|
+
isServer: boolean;
|
|
54
|
+
publicBase: string | undefined | null;
|
|
55
|
+
mountMeta?: string | undefined | null;
|
|
56
|
+
}): string {
|
|
57
|
+
if (opts.isServer) return "/";
|
|
58
|
+
const meta = opts.mountMeta?.trim();
|
|
59
|
+
if (meta) return normalizeBasepath(meta);
|
|
60
|
+
return normalizeBasepath(opts.publicBase);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Reads bouncer's vmf mount announcement from the served document (client only). */
|
|
64
|
+
export function readMountMeta(): string | null {
|
|
65
|
+
if (typeof document === "undefined") return null;
|
|
66
|
+
return document.querySelector(`meta[name="${MOUNT_META_NAME}"]`)?.getAttribute("content") ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The mount expressed as a TanStack Router `rewrite` pair (browser URL ↔
|
|
71
|
+
* router-internal URL): strip the mount on input, prepend it on output.
|
|
72
|
+
*
|
|
73
|
+
* Uses `rewrite`, not the `basepath` router option: TanStack Start's server
|
|
74
|
+
* handler (start-server-core createStartHandler) and client bootstrap
|
|
75
|
+
* (start-client-core hydrateStart) both call
|
|
76
|
+
* `router.update({ basepath: process.env.TSS_ROUTER_BASEPATH })`, overriding
|
|
77
|
+
* any basepath set in createRouter with that build-time define. `rewrite` is
|
|
78
|
+
* the documented channel for an asymmetric mount: router.update()
|
|
79
|
+
* re-composes `options.rewrite` after the basepath rewrite on every update,
|
|
80
|
+
* and Start never touches it.
|
|
81
|
+
* https://tanstack.com/router/latest/docs/guide/url-rewrites#interaction-with-basepath
|
|
82
|
+
*/
|
|
83
|
+
export function mountRewrite(mount: string):
|
|
84
|
+
| {
|
|
85
|
+
input: (opts: { url: URL }) => URL;
|
|
86
|
+
output: (opts: { url: URL }) => URL;
|
|
87
|
+
}
|
|
88
|
+
| undefined {
|
|
89
|
+
const m = normalizeBasepath(mount);
|
|
90
|
+
if (m === "/") return undefined;
|
|
91
|
+
return {
|
|
92
|
+
input: ({ url }) => {
|
|
93
|
+
if (url.pathname === m) url.pathname = "/";
|
|
94
|
+
else if (url.pathname.startsWith(`${m}/`)) url.pathname = url.pathname.slice(m.length);
|
|
95
|
+
return url;
|
|
96
|
+
},
|
|
97
|
+
output: ({ url }) => {
|
|
98
|
+
url.pathname = url.pathname === "/" ? m : m + url.pathname;
|
|
99
|
+
return url;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/version.gen.ts
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
// values are dev placeholders, identical to the runtime fallbacks.
|
|
3
3
|
export const PKG = {
|
|
4
4
|
name: "@somewhatintelligent/kit",
|
|
5
|
-
version: "0.0.3-beta.
|
|
6
|
-
commit: "
|
|
5
|
+
version: "0.0.3-beta.1783732751.3b77dd8",
|
|
6
|
+
commit: "3b77dd8189861ea843645f9e389cd5f874557345",
|
|
7
7
|
} as const;
|