@tschk/moonshine-tanstack 0.3.4

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 ADDED
@@ -0,0 +1,73 @@
1
+ # `@tschk/moonshine-tanstack`
2
+
3
+ The **runtime** client API of TanStack Router / TanStack Start reimplemented on
4
+ moonshine. It imports no TanStack code and lists none as a dependency:
5
+ `Link`, `useNavigate`, `useRouterState` and friends are ordinary React
6
+ components written against `@tschk/moonshine/router`.
7
+
8
+ ```ts
9
+ // bundler alias
10
+ "@tanstack/react-router" -> "@tschk/moonshine-tanstack";
11
+ ```
12
+
13
+ ```tsx
14
+ import {
15
+ Link,
16
+ Outlet,
17
+ useNavigate,
18
+ useSearch,
19
+ } from "@tschk/moonshine-tanstack";
20
+ import { json, createServerRoute } from "@tschk/moonshine-tanstack/server";
21
+ ```
22
+
23
+ | Subpath | Contents |
24
+ | -------------- | ----------------------------------------------- |
25
+ | `.` | client API (components + hooks + search codecs) |
26
+ | `./link` | `Link`, `Navigate` |
27
+ | `./navigation` | `useNavigate`, `useRouter`, `useRouterState`, … |
28
+ | `./outlet` | `Outlet`, `OutletProvider` |
29
+ | `./server` | `json`, `createServerRoute`, moonshine server |
30
+ | `./client` | moonshine signals + resources |
31
+
32
+ ## Supported
33
+
34
+ - `Link` — `to` with `$param` and `$` splat interpolation, `params`, `search`
35
+ (record or updater), `hash`, `replace`, `resetScroll`, `preload`
36
+ (`"intent"` hover / `"viewport"` / `"render"`), `disabled`,
37
+ `activeProps`/`inactiveProps`, `className` and `children` render callbacks,
38
+ `data-status="active"` and `aria-current`. Navigation runs inside
39
+ `startTransition`.
40
+ - `Navigate` — declarative redirect on mount.
41
+ - `Outlet` / `OutletProvider`.
42
+ - `useNavigate`, `useRouter`, `useRouterState` (with `select`), `useLocation`,
43
+ `useParams`, `useSearch` (with `select`), `useCanGoBack`.
44
+ - `redirect`, `notFound`, `isRedirect`, `isNotFound` — same "return an error you
45
+ throw" contract as TanStack.
46
+ - Search codecs: `parseSearch` / `stringifySearch` with TanStack's JSON value
47
+ encoding, plus `buildHref` and `interpolatePath`.
48
+
49
+ ## Not supported
50
+
51
+ These require the TanStack Vite plugin, the generated route tree, or TanStack's
52
+ own loader/RPC runtime, and are **not** provided:
53
+
54
+ - **Route definition and typing**: `createFileRoute`, `createRootRoute`,
55
+ `createRoute`, `createRouter`, `RouterProvider`, `routeTree.gen.ts`, and the
56
+ whole type-level route registry. Every `to`, `from` and `params` here is a
57
+ plain string/record with no route-tree inference — `from` is accepted and
58
+ ignored, `strict` has no effect.
59
+ - **Data loading**: `loader`, `useLoaderData`, `useLoaderDeps`, `beforeLoad`,
60
+ `validateSearch`, `router.invalidate()` (a no-op here), `useMatch`,
61
+ `useMatches`, `useMatchRoute`, `Await`, `defer`, pending/error components.
62
+ - **Server functions**: `createServerFn`, `createMiddleware`,
63
+ `createStartHandler` — these depend on the build plugin's client/server
64
+ splitting. Use `createServerRoute` on a moonshine route instead.
65
+ - **Transitions/blocking**: `useBlocker`, `Block`, `isTransitioning`
66
+ (always `false`), `useRouterState().status` (always `"idle"`),
67
+ `matches` (always empty).
68
+ - **History state**: `location.state` is always `{}`; there is no
69
+ `useHistoryState`.
70
+ - `useSearch` reads `window.location.search`; on the server it is empty because
71
+ moonshine's router signal carries the pathname only.
72
+
73
+ See [Moonshine README](../../README.md).
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@tschk/moonshine-tanstack",
3
+ "version": "0.3.4",
4
+ "description": "TanStack Router / Start client API reimplemented on moonshine — drop @tanstack/react-router, keep the imports",
5
+ "license": "ISC",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./client": "./src/client.ts",
12
+ "./server": "./src/server.ts",
13
+ "./navigation": "./src/navigation.ts",
14
+ "./link": "./src/link.ts",
15
+ "./outlet": "./src/outlet.ts",
16
+ "./aliases": "./src/aliases.ts"
17
+ },
18
+ "dependencies": {
19
+ "@tschk/moonshine": "^0.3.4",
20
+ "@tschk/moonshine-router": "^0.3.4"
21
+ },
22
+ "peerDependencies": {
23
+ "react": ">=18",
24
+ "react-dom": ">=18"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "README.md"
29
+ ],
30
+ "keywords": [
31
+ "moonshine",
32
+ "tanstack",
33
+ "router",
34
+ "adapter"
35
+ ],
36
+ "scripts": {
37
+ "format": "bunx prettier --check .",
38
+ "lint": "bunx oxlint .",
39
+ "typecheck": "bunx tsc --noEmit",
40
+ "test": "bun test"
41
+ }
42
+ }
package/src/aliases.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Drop-in mapping from the TanStack Router specifiers onto this package.
3
+ *
4
+ * ```ts
5
+ * import { moonshineTanstackPlugin } from "@tschk/moonshine-tanstack/aliases";
6
+ * await Bun.build({ entrypoints: ["./app.tsx"], plugins: [moonshineTanstackPlugin()] });
7
+ * ```
8
+ */
9
+ import { join } from "node:path";
10
+ import type { BunPlugin } from "bun";
11
+
12
+ /** Bare specifier each replaced package is served by. */
13
+ export const tanstackAliases: Readonly<Record<string, string>> = {
14
+ "@tanstack/react-router": "@tschk/moonshine-tanstack",
15
+ "@tanstack/router-core": "@tschk/moonshine-tanstack",
16
+ };
17
+
18
+ /** File in this package backing each alias, relative to `src/`. */
19
+ const files: Readonly<Record<string, string>> = {
20
+ "@tanstack/react-router": "index.ts",
21
+ "@tanstack/router-core": "index.ts",
22
+ };
23
+
24
+ /**
25
+ * `compilerOptions.paths` block for an app's tsconfig, so the type checker
26
+ * follows the same mapping the bundler applies.
27
+ */
28
+ export function tsconfigPaths(): Record<string, string[]> {
29
+ return Object.fromEntries(
30
+ Object.entries(tanstackAliases).map(([from, to]) => [from, [to]]),
31
+ );
32
+ }
33
+
34
+ /** Absolute path this package serves `specifier` from, if it is aliased. */
35
+ export function resolveAlias(specifier: string): string | undefined {
36
+ const file = files[specifier];
37
+ return file === undefined ? undefined : join(import.meta.dir, file);
38
+ }
39
+
40
+ const filter = /^@tanstack\/(react-router|router-core)$/;
41
+
42
+ /** Bun bundler/runtime plugin rewriting TanStack imports to this package. */
43
+ export function moonshineTanstackPlugin(): BunPlugin {
44
+ return {
45
+ name: "moonshine-tanstack-aliases",
46
+ setup(build) {
47
+ build.onResolve({ filter }, (args) => {
48
+ const path = resolveAlias(args.path);
49
+ return path === undefined ? undefined : { path };
50
+ });
51
+ },
52
+ };
53
+ }
package/src/client.ts ADDED
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Moonshine signal + resource surface for TanStack Start apps.
5
+ */
6
+ export * from "@tschk/moonshine/host-react";
7
+
8
+ export {
9
+ createIslandSignal,
10
+ createResource,
11
+ useResource,
12
+ } from "@tschk/moonshine/host-react";
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ /**
4
+ * @tschk/moonshine-tanstack
5
+ *
6
+ * The TanStack Router / Start client API reimplemented on moonshine's router.
7
+ * Alias `@tanstack/react-router` to this package to drop the dependency.
8
+ *
9
+ * Route-tree generation (`createFileRoute`, `routeTree.gen.ts`) is not provided
10
+ * — it requires the TanStack Vite plugin. See the README.
11
+ */
12
+ export { Link, Navigate } from "./link";
13
+ export type { LinkProps, LinkRenderState, NavigateProps } from "./link";
14
+
15
+ export {
16
+ NotFound,
17
+ Redirect,
18
+ isNotFound,
19
+ isRedirect,
20
+ notFound,
21
+ redirect,
22
+ useCanGoBack,
23
+ useLocation,
24
+ useNavigate,
25
+ useParams,
26
+ useRouter,
27
+ useRouterState,
28
+ useSearch,
29
+ } from "./navigation";
30
+ export type { NavigateFn, RouterInstance, RouterState } from "./navigation";
31
+
32
+ export { Outlet, OutletProvider } from "./outlet";
33
+ export type { OutletProviderProps } from "./outlet";
34
+
35
+ export {
36
+ buildHref,
37
+ interpolatePath,
38
+ parseSearch,
39
+ stringifySearch,
40
+ } from "./location";
41
+ export type { ParsedLocation, SearchRecord, ToOptions } from "./location";
package/src/link.ts ADDED
@@ -0,0 +1,184 @@
1
+ "use client";
2
+
3
+ /**
4
+ * TanStack Router's `<Link>` on the moonshine router.
5
+ *
6
+ * ```tsx
7
+ * import { Link } from "@tschk/moonshine-tanstack";
8
+ * ```
9
+ */
10
+ import {
11
+ createElement,
12
+ useCallback,
13
+ useEffect,
14
+ useRef,
15
+ useTransition,
16
+ type AnchorHTMLAttributes,
17
+ type MouseEvent,
18
+ type ReactNode,
19
+ } from "react";
20
+ import { navigate } from "@tschk/moonshine/router";
21
+ import { buildHref, type ToOptions } from "./location";
22
+ import { useParsedLocation } from "./location";
23
+
24
+ export type LinkRenderState = { isActive: boolean; isTransitioning: boolean };
25
+
26
+ export type LinkProps = Omit<
27
+ AnchorHTMLAttributes<HTMLAnchorElement>,
28
+ "href" | "className" | "style" | "children"
29
+ > &
30
+ ToOptions & {
31
+ activeOptions?: { exact?: boolean; includeHash?: boolean };
32
+ /** `"intent"` warms the URL on hover, `"viewport"` when it scrolls in. */
33
+ preload?: false | "intent" | "viewport" | "render";
34
+ disabled?: boolean;
35
+ activeProps?: Record<string, unknown>;
36
+ inactiveProps?: Record<string, unknown>;
37
+ className?: string | ((state: LinkRenderState) => string | undefined);
38
+ style?: Record<string, unknown>;
39
+ children?: ReactNode | ((state: LinkRenderState) => ReactNode);
40
+ };
41
+
42
+ function isExternal(href: string, target?: string): boolean {
43
+ if (target && target !== "_self") return true;
44
+ return (
45
+ /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href) ||
46
+ href.startsWith("//") ||
47
+ href.startsWith("#")
48
+ );
49
+ }
50
+
51
+ const preloaded = new Set<string>();
52
+
53
+ function preloadOnce(url: string): void {
54
+ if (preloaded.has(url) || typeof document === "undefined") return;
55
+ preloaded.add(url);
56
+ const tag = document.createElement("link");
57
+ tag.rel = "prefetch";
58
+ tag.href = url;
59
+ document.head.append(tag);
60
+ }
61
+
62
+ function pathnameOf(href: string): string {
63
+ const cut = href.search(/[?#]/);
64
+ return cut === -1 ? href : href.slice(0, cut);
65
+ }
66
+
67
+ export function Link({
68
+ to,
69
+ from: _from,
70
+ params,
71
+ search,
72
+ hash,
73
+ replace,
74
+ resetScroll,
75
+ activeOptions,
76
+ preload = "intent",
77
+ disabled,
78
+ activeProps,
79
+ inactiveProps,
80
+ className,
81
+ style,
82
+ children,
83
+ onClick,
84
+ onMouseEnter,
85
+ ...rest
86
+ }: LinkProps): ReactNode {
87
+ const location = useParsedLocation();
88
+ const href = buildHref({ to, params, search, hash }, location.search);
89
+ const external = isExternal(href, rest.target);
90
+ const ref = useRef<HTMLAnchorElement>(null);
91
+ // Navigation runs in a transition so a slow subtree cannot block the click.
92
+ const [, startTransition] = useTransition();
93
+ const inert = external || disabled === true || preload === false;
94
+
95
+ const warm = useCallback(() => {
96
+ if (!inert) preloadOnce(href);
97
+ }, [inert, href]);
98
+
99
+ useEffect(() => {
100
+ if (inert) return;
101
+ if (preload === "render") {
102
+ warm();
103
+ return;
104
+ }
105
+ const node = ref.current;
106
+ if (
107
+ !node ||
108
+ preload !== "viewport" ||
109
+ typeof IntersectionObserver === "undefined"
110
+ ) {
111
+ return;
112
+ }
113
+ const observer = new IntersectionObserver((entries) => {
114
+ for (const entry of entries) {
115
+ if (entry.isIntersecting) {
116
+ warm();
117
+ observer.disconnect();
118
+ }
119
+ }
120
+ });
121
+ observer.observe(node);
122
+ return () => observer.disconnect();
123
+ }, [warm, preload, inert]);
124
+
125
+ const target = pathnameOf(href);
126
+ const isActive = activeOptions?.exact
127
+ ? location.pathname === target
128
+ : location.pathname === target ||
129
+ location.pathname.startsWith(
130
+ target.endsWith("/") ? target : `${target}/`,
131
+ );
132
+ const state: LinkRenderState = { isActive, isTransitioning: false };
133
+
134
+ return createElement(
135
+ "a",
136
+ {
137
+ ...rest,
138
+ ...(isActive ? activeProps : inactiveProps),
139
+ ref,
140
+ href,
141
+ style,
142
+ className: typeof className === "function" ? className(state) : className,
143
+ "data-status": isActive ? "active" : undefined,
144
+ "aria-current": isActive ? "page" : undefined,
145
+ onMouseEnter: (event: MouseEvent<HTMLAnchorElement>) => {
146
+ onMouseEnter?.(event);
147
+ if (preload === "intent") warm();
148
+ },
149
+ onClick: (event: MouseEvent<HTMLAnchorElement>) => {
150
+ onClick?.(event);
151
+ if (
152
+ event.defaultPrevented ||
153
+ event.button !== 0 ||
154
+ event.metaKey ||
155
+ event.altKey ||
156
+ event.ctrlKey ||
157
+ event.shiftKey ||
158
+ external ||
159
+ disabled === true
160
+ ) {
161
+ return;
162
+ }
163
+ event.preventDefault();
164
+ startTransition(() => {
165
+ navigate(href, { replace });
166
+ if (resetScroll !== false) globalThis.scrollTo?.(0, 0);
167
+ });
168
+ },
169
+ },
170
+ typeof children === "function" ? children(state) : children,
171
+ );
172
+ }
173
+
174
+ export type NavigateProps = ToOptions;
175
+
176
+ /** Redirects on mount. */
177
+ export function Navigate(props: NavigateProps): null {
178
+ const location = useParsedLocation();
179
+ const href = buildHref(props, location.search);
180
+ useEffect(() => {
181
+ navigate(href, { replace: props.replace });
182
+ }, [href, props.replace]);
183
+ return null;
184
+ }
@@ -0,0 +1,117 @@
1
+ "use client";
2
+
3
+ /**
4
+ * TanStack Router's location shape, derived from the moonshine router signal.
5
+ *
6
+ * Moonshine's signal carries the pathname only, so `search` and `hash` are read
7
+ * back from `window.location` on each render.
8
+ */
9
+ import { useMemo, useReducer } from "react";
10
+ import { useLocation as useMoonshineLocation } from "@tschk/moonshine/router";
11
+
12
+ export type SearchRecord = Record<string, unknown>;
13
+
14
+ export type ParsedLocation = {
15
+ href: string;
16
+ pathname: string;
17
+ search: SearchRecord;
18
+ searchStr: string;
19
+ hash: string;
20
+ state: Record<string, unknown>;
21
+ };
22
+
23
+ export type ToOptions = {
24
+ to?: string;
25
+ from?: string;
26
+ params?: Record<string, string>;
27
+ search?: SearchRecord | ((current: SearchRecord) => SearchRecord);
28
+ hash?: string;
29
+ replace?: boolean;
30
+ resetScroll?: boolean;
31
+ };
32
+
33
+ /** `parseSearch` — TanStack decodes JSON values inside query params. */
34
+ export function parseSearch(searchStr: string): SearchRecord {
35
+ const out: SearchRecord = {};
36
+ for (const [key, value] of new URLSearchParams(searchStr)) {
37
+ try {
38
+ out[key] = JSON.parse(value) as unknown;
39
+ } catch {
40
+ out[key] = value;
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** `stringifySearch` — the inverse of {@link parseSearch}. */
47
+ export function stringifySearch(search: SearchRecord): string {
48
+ const params = new URLSearchParams();
49
+ for (const [key, value] of Object.entries(search)) {
50
+ if (value === undefined) continue;
51
+ params.set(key, typeof value === "string" ? value : JSON.stringify(value));
52
+ }
53
+ const query = params.toString();
54
+ return query ? `?${query}` : "";
55
+ }
56
+
57
+ /** Fills `$param` / `$` placeholders in a `to` template. */
58
+ export function interpolatePath(
59
+ to: string,
60
+ params: Record<string, string> = {},
61
+ ): string {
62
+ return to
63
+ .split("/")
64
+ .map((segment) => {
65
+ if (segment === "$") return params["*"] ?? params._splat ?? "";
66
+ if (segment.startsWith("$")) return params[segment.slice(1)] ?? segment;
67
+ return segment;
68
+ })
69
+ .join("/");
70
+ }
71
+
72
+ /** Builds the final URL string for a `ToOptions` navigation descriptor. */
73
+ export function buildHref(
74
+ options: ToOptions,
75
+ currentSearch: SearchRecord = {},
76
+ ): string {
77
+ const pathname = interpolatePath(options.to ?? "", options.params);
78
+ const search =
79
+ typeof options.search === "function"
80
+ ? options.search(currentSearch)
81
+ : (options.search ?? {});
82
+ const hash = options.hash
83
+ ? options.hash.startsWith("#")
84
+ ? options.hash
85
+ : `#${options.hash}`
86
+ : "";
87
+ return `${pathname}${stringifySearch(search)}${hash}`;
88
+ }
89
+
90
+ function browserPart(part: "search" | "hash"): string {
91
+ if (typeof window === "undefined") return "";
92
+ return window.location[part];
93
+ }
94
+
95
+ /** Forces a re-render; used after navigations that only change the query. */
96
+ export function useRerender(): () => void {
97
+ const [, bump] = useReducer((n: number) => n + 1, 0);
98
+ return bump;
99
+ }
100
+
101
+ export function useParsedLocation(): ParsedLocation {
102
+ const pathname = useMoonshineLocation();
103
+ const searchStr = browserPart("search");
104
+ const hash = browserPart("hash");
105
+ return useMemo(
106
+ () => ({
107
+ href: `${pathname}${searchStr}${hash}`,
108
+ pathname,
109
+ search: parseSearch(searchStr),
110
+ searchStr,
111
+ hash,
112
+ // History state is not tracked by the moonshine router.
113
+ state: {},
114
+ }),
115
+ [pathname, searchStr, hash],
116
+ );
117
+ }
@@ -0,0 +1,166 @@
1
+ "use client";
2
+
3
+ /**
4
+ * TanStack Router hooks implemented on the moonshine router.
5
+ *
6
+ * ```ts
7
+ * import { useNavigate, useRouterState } from "@tschk/moonshine-tanstack";
8
+ * ```
9
+ */
10
+ import { useCallback, useMemo } from "react";
11
+ import {
12
+ navigate,
13
+ useParams as useMoonshineParams,
14
+ } from "@tschk/moonshine/router";
15
+ import {
16
+ buildHref,
17
+ useParsedLocation,
18
+ useRerender,
19
+ type ParsedLocation,
20
+ type SearchRecord,
21
+ type ToOptions,
22
+ } from "./location";
23
+
24
+ export type NavigateFn = (options: ToOptions) => void;
25
+
26
+ export type RouterInstance = {
27
+ navigate: NavigateFn;
28
+ buildLocation: (options: ToOptions) => { href: string };
29
+ history: {
30
+ back(): void;
31
+ forward(): void;
32
+ go(delta: number): void;
33
+ };
34
+ invalidate(): void;
35
+ state: { location: ParsedLocation };
36
+ };
37
+
38
+ export function useNavigate(): NavigateFn {
39
+ const location = useParsedLocation();
40
+ const rerender = useRerender();
41
+ return useCallback(
42
+ (options: ToOptions) => {
43
+ const href = buildHref(
44
+ { ...options, to: options.to ?? location.pathname },
45
+ location.search,
46
+ );
47
+ navigate(href, { replace: options.replace });
48
+ if (options.resetScroll !== false) globalThis.scrollTo?.(0, 0);
49
+ // A query-only navigation leaves the pathname signal unchanged.
50
+ rerender();
51
+ },
52
+ [location.pathname, location.search, rerender],
53
+ );
54
+ }
55
+
56
+ export function useRouter(): RouterInstance {
57
+ const location = useParsedLocation();
58
+ const go = useNavigate();
59
+ return useMemo(
60
+ () => ({
61
+ navigate: go,
62
+ buildLocation: (options: ToOptions) => ({
63
+ href: buildHref(options, location.search),
64
+ }),
65
+ history: {
66
+ back: () => globalThis.history?.back(),
67
+ forward: () => globalThis.history?.forward(),
68
+ go: (delta: number) => globalThis.history?.go(delta),
69
+ },
70
+ // Moonshine re-renders from its signal graph; there is nothing to
71
+ // invalidate.
72
+ invalidate: () => {},
73
+ state: { location },
74
+ }),
75
+ [go, location],
76
+ );
77
+ }
78
+
79
+ export type RouterState = {
80
+ location: ParsedLocation;
81
+ status: "idle";
82
+ isLoading: false;
83
+ isTransitioning: false;
84
+ matches: never[];
85
+ };
86
+
87
+ export function useRouterState<T = RouterState>(options?: {
88
+ select?: (state: RouterState) => T;
89
+ }): T {
90
+ const location = useParsedLocation();
91
+ const state = useMemo<RouterState>(
92
+ () => ({
93
+ location,
94
+ status: "idle",
95
+ isLoading: false,
96
+ isTransitioning: false,
97
+ matches: [],
98
+ }),
99
+ [location],
100
+ );
101
+ return (options?.select ? options.select(state) : state) as T;
102
+ }
103
+
104
+ export function useLocation(): ParsedLocation {
105
+ return useParsedLocation();
106
+ }
107
+
108
+ export function useParams<
109
+ T extends Record<string, string> = Record<string, string>,
110
+ >(_options?: { from?: string; strict?: boolean }): T {
111
+ return useMoonshineParams() as T;
112
+ }
113
+
114
+ export function useSearch<T extends SearchRecord = SearchRecord>(_options?: {
115
+ from?: string;
116
+ strict?: boolean;
117
+ select?: (search: SearchRecord) => T;
118
+ }): T {
119
+ const { search } = useParsedLocation();
120
+ return (_options?.select ? _options.select(search) : search) as T;
121
+ }
122
+
123
+ export function useCanGoBack(): boolean {
124
+ return (globalThis.history?.length ?? 0) > 1;
125
+ }
126
+
127
+ /** Thrown to unwind rendering, matching TanStack's control-flow contract. */
128
+ export class Redirect extends Error {
129
+ constructor(readonly options: ToOptions & { statusCode?: number }) {
130
+ super(`redirect: ${options.to ?? ""}`);
131
+ this.name = "Redirect";
132
+ }
133
+ }
134
+
135
+ export class NotFound extends Error {
136
+ constructor(readonly data?: unknown) {
137
+ super("not found");
138
+ this.name = "NotFound";
139
+ }
140
+ }
141
+
142
+ /** Returns the error (TanStack's contract is `throw redirect({ to })`). */
143
+ export function redirect(
144
+ options: ToOptions & { statusCode?: number; throw?: boolean },
145
+ ): Redirect {
146
+ const error = new Redirect(options);
147
+ if (options.throw === true) throw error;
148
+ return error;
149
+ }
150
+
151
+ export function notFound(options?: {
152
+ data?: unknown;
153
+ throw?: boolean;
154
+ }): NotFound {
155
+ const error = new NotFound(options?.data);
156
+ if (options?.throw === true) throw error;
157
+ return error;
158
+ }
159
+
160
+ export function isRedirect(value: unknown): value is Redirect {
161
+ return value instanceof Redirect;
162
+ }
163
+
164
+ export function isNotFound(value: unknown): value is NotFound {
165
+ return value instanceof NotFound;
166
+ }
package/src/outlet.ts ADDED
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ /**
4
+ * `<Outlet>` for TanStack-style nested layouts.
5
+ *
6
+ * Moonshine matches a flat route table, so the child element is supplied
7
+ * explicitly by the layout rather than derived from a generated route tree.
8
+ */
9
+ import {
10
+ createContext,
11
+ createElement,
12
+ useContext,
13
+ type ReactNode,
14
+ } from "react";
15
+
16
+ const OutletContext = createContext<ReactNode>(null);
17
+
18
+ export type OutletProviderProps = {
19
+ /** The child route element rendered where `<Outlet />` appears. */
20
+ value: ReactNode;
21
+ children?: ReactNode;
22
+ };
23
+
24
+ export function OutletProvider({
25
+ value,
26
+ children,
27
+ }: OutletProviderProps): ReactNode {
28
+ return createElement(OutletContext.Provider, { value }, children);
29
+ }
30
+
31
+ export function Outlet(): ReactNode {
32
+ return useContext(OutletContext);
33
+ }
package/src/server.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Server surface for TanStack Start apps on moonshine: response helpers plus
3
+ * moonshine's request handling.
4
+ *
5
+ * `createServerFn` is deliberately absent — see the README.
6
+ */
7
+ export {
8
+ createMoonshineServer,
9
+ definePage,
10
+ handleMoonshineRequest,
11
+ resolvePage,
12
+ toMoonshineRequest,
13
+ } from "@tschk/moonshine/server";
14
+ export type {
15
+ MoonshinePageModule,
16
+ MoonshineRequest,
17
+ MoonshineServer,
18
+ } from "@tschk/moonshine/server";
19
+
20
+ /** `json()` from `@tanstack/react-start`. */
21
+ export function json<T>(data: T, init: ResponseInit = {}): Response {
22
+ const headers = new Headers(init.headers);
23
+ if (!headers.has("content-type")) {
24
+ headers.set("content-type", "application/json; charset=utf-8");
25
+ }
26
+ return new Response(JSON.stringify(data), { ...init, headers });
27
+ }
28
+
29
+ /**
30
+ * Wrap a server route handler.
31
+ *
32
+ * ```ts
33
+ * export const GET = createServerRoute(async (req) => json({ ok: true }));
34
+ * ```
35
+ */
36
+ export function createServerRoute(
37
+ handler: (request: Request) => Response | Promise<Response>,
38
+ ): (request: Request) => Promise<Response> {
39
+ return async (request) => {
40
+ try {
41
+ return await handler(request);
42
+ } catch (e) {
43
+ const message = e instanceof Error ? e.message : String(e);
44
+ return json({ error: message }, { status: 500 });
45
+ }
46
+ };
47
+ }