@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.1783714528.6f95113",
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.3-beta.1783714528.6f95113",
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
+ }
@@ -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
+ }
@@ -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.1783714528.6f95113",
6
- commit: "6f95113c5b404d3f046d97e1e51471dca322a469",
5
+ version: "0.0.3-beta.1783732751.3b77dd8",
6
+ commit: "3b77dd8189861ea843645f9e389cd5f874557345",
7
7
  } as const;