@pithy-sh/ui-react 0.1.0
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/LICENSE +21 -0
- package/package.json +47 -0
- package/src/templates.ts +147 -0
- package/src/testing/virtualAuth.ts +12 -0
- package/src/testing/virtualI18n.ts +16 -0
- package/src/testing/virtualPayments.ts +5 -0
- package/src/testing/virtualTurnstile.ts +5 -0
- package/templates/client-env.d.ts +299 -0
- package/templates/index.html +14 -0
- package/templates/src/client.test.tsx +98 -0
- package/templates/src/client.tsx +51 -0
- package/templates/src/payments.tsx +147 -0
- package/templates/src/pithy-config.tsx +93 -0
- package/templates/src/pithy-locale.test.tsx +140 -0
- package/templates/src/pithy-locale.tsx +134 -0
- package/templates/src/pithy-screens.css +379 -0
- package/templates/src/router.test.tsx +63 -0
- package/templates/src/router.tsx +618 -0
- package/templates/src/routes/app/home.bare.tsx +96 -0
- package/templates/src/routes/app/home.tsx +41 -0
- package/templates/src/routes/pithy/callback.tsx +42 -0
- package/templates/src/routes/pithy/otp.tsx +127 -0
- package/templates/src/routes/pithy/paywall.tsx +160 -0
- package/templates/src/routes/pithy/pricing.tsx +312 -0
- package/templates/src/routes/pithy/sign-in.test.tsx +116 -0
- package/templates/src/routes/pithy/sign-in.tsx +470 -0
- package/templates/src/routes/pithy/subscription.tsx +183 -0
- package/templates/src/session.tsx +78 -0
- package/templates/src/styles.css +53 -0
- package/templates/src/turnstile.test.tsx +119 -0
- package/templates/src/turnstile.tsx +105 -0
- package/templates/tsconfig.client.json +28 -0
- package/templates/tsconfig.node.json +22 -0
- package/templates/vite.config.ts +45 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { AuthSession, AuthUser } from "@pithy-sh/auth/src/client/api";
|
|
2
|
+
import { signOut as endSession, getSession as readSession } from "@pithy-sh/auth/src/client/api";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { authConfig } from "./pithy-config";
|
|
5
|
+
import { navigate, routeTable, screenPath } from "./router";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Session state, read from the server.
|
|
9
|
+
*
|
|
10
|
+
* COOKIE/SESSION, NOT BEARER. The SPA is same-origin with its worker, so the session rides an
|
|
11
|
+
* httpOnly cookie: no access token, no refresh token, nothing in localStorage or sessionStorage, and
|
|
12
|
+
* no rotation logic here. JavaScript cannot read the cookie, so XSS has nothing to steal.
|
|
13
|
+
*
|
|
14
|
+
* **The requests themselves are not in this file, and that is deliberate.** Pithy wrote this file once
|
|
15
|
+
* and may never rewrite it — it is yours now — so a `fetch` written here would freeze the base-path
|
|
16
|
+
* join, the cookie mode and the failure handling into your repository on the day you scaffolded. They
|
|
17
|
+
* live in `@pithy-sh/auth/src/client/api`, which is the one place that knows how a browser program
|
|
18
|
+
* asks this worker an auth question, and which upgrades with a minor release. Same-origin and CSRF are
|
|
19
|
+
* its problem, not this screen's.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The signed-in user, as the session route returns them. */
|
|
23
|
+
export type SessionUser = AuthUser;
|
|
24
|
+
|
|
25
|
+
/** A live session, or `null` when nobody is signed in. */
|
|
26
|
+
export type Session = AuthSession;
|
|
27
|
+
|
|
28
|
+
/** Where the auth routes are, for every call this file makes. */
|
|
29
|
+
const client = { basePath: authConfig.basePath };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The current session, or `null`.
|
|
33
|
+
*
|
|
34
|
+
* Never throws. A worker that could not be reached, or answered with something unreadable, reads as
|
|
35
|
+
* signed out here — which is a decision this file makes rather than one the package makes for it. If
|
|
36
|
+
* you would rather show "we couldn't check" than a sign-in form, read `result.failure` instead.
|
|
37
|
+
*/
|
|
38
|
+
export async function getSession(): Promise<Session> {
|
|
39
|
+
if (!authConfig.enabled) return null;
|
|
40
|
+
const result = await readSession(client);
|
|
41
|
+
return result.ok ? result.value : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** End the session server-side and return to the sign-in screen. */
|
|
45
|
+
export async function signOut(): Promise<void> {
|
|
46
|
+
await endSession(client);
|
|
47
|
+
// Where "the sign-in screen" is comes from the screen itself, never a literal here. Renaming its
|
|
48
|
+
// path is one edit and this follows it (#393).
|
|
49
|
+
navigate(screenPath(await routeTable(), "sign-in"));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The session as component state, plus a `refresh` for after a sign-in completes. */
|
|
53
|
+
export function useSession(): { session: Session; loading: boolean; refresh: () => void } {
|
|
54
|
+
const [current, setCurrent] = useState<Session>(null);
|
|
55
|
+
const [loading, setLoading] = useState(true);
|
|
56
|
+
// Guards against setting state after unmount. A ref rather than a local, because `refresh` is
|
|
57
|
+
// callable from an event handler long after the effect that created it has been cleaned up.
|
|
58
|
+
const live = useRef(true);
|
|
59
|
+
|
|
60
|
+
const refresh = useCallback(() => {
|
|
61
|
+
setLoading(true);
|
|
62
|
+
void getSession().then((value) => {
|
|
63
|
+
if (!live.current) return;
|
|
64
|
+
setCurrent(value);
|
|
65
|
+
setLoading(false);
|
|
66
|
+
});
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
live.current = true;
|
|
71
|
+
refresh();
|
|
72
|
+
return () => {
|
|
73
|
+
live.current = false;
|
|
74
|
+
};
|
|
75
|
+
}, [refresh]);
|
|
76
|
+
|
|
77
|
+
return { session: current, loading, refresh };
|
|
78
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Your stylesheet. Pithy writes it once, when there is none, and never touches it again.
|
|
3
|
+
*
|
|
4
|
+
* The classes Pithy's own screens render are NOT here — they live in `pithy-screens.css`, which those
|
|
5
|
+
* screens import themselves. So you can replace everything below without unstyling the sign-in screen,
|
|
6
|
+
* and a project that adds the auth screens years later gets them styled without this file being
|
|
7
|
+
* touched.
|
|
8
|
+
*
|
|
9
|
+
* The custom properties are the seam between the two. `pithy-screens.css` reads each of them with a
|
|
10
|
+
* fallback, so editing a value here restyles Pithy's screens along with your own, and deleting one
|
|
11
|
+
* leaves them standing.
|
|
12
|
+
*
|
|
13
|
+
* Declare them as a set, or declare none. Half a set is the one arrangement no fallback can catch —
|
|
14
|
+
* your background under Pithy's text — and `pithy-screens.test.tsx` beside this file is what notices.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
:root {
|
|
18
|
+
--bg: #fafaf6;
|
|
19
|
+
--surface: #ffffff;
|
|
20
|
+
--fg: #111111;
|
|
21
|
+
--fg-muted: #5f5d57;
|
|
22
|
+
--border: #e5e3db;
|
|
23
|
+
--accent: #d4a017;
|
|
24
|
+
--danger: #b3261e;
|
|
25
|
+
color-scheme: light dark;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@media (prefers-color-scheme: dark) {
|
|
29
|
+
:root {
|
|
30
|
+
--bg: #111111;
|
|
31
|
+
--surface: #1a1a19;
|
|
32
|
+
--fg: #fafaf6;
|
|
33
|
+
--fg-muted: #9a988f;
|
|
34
|
+
--border: #2e2e2b;
|
|
35
|
+
--danger: #f2b8b5;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
* {
|
|
40
|
+
box-sizing: border-box;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
body {
|
|
44
|
+
margin: 0;
|
|
45
|
+
background: var(--bg);
|
|
46
|
+
color: var(--fg);
|
|
47
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
48
|
+
line-height: 1.5;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
a {
|
|
52
|
+
color: var(--accent);
|
|
53
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* **The widget is solved for the action the projection names, and states none of its own.**
|
|
9
|
+
*
|
|
10
|
+
* Turnstile bakes an action label into the token when the widget renders, and siteverify echoes it
|
|
11
|
+
* back; the sign-in route asserts it. One contract, two ends. `src/turnstile.tsx` is yours to edit,
|
|
12
|
+
* and the one edit it must never take is retyping that label as a literal.
|
|
13
|
+
*
|
|
14
|
+
* ## Why nothing you run before production can catch the two drifting apart
|
|
15
|
+
*
|
|
16
|
+
* `pithy turnstile provision` wires Cloudflare's documented **always-pass test secret** into dev and
|
|
17
|
+
* staging, and that secret's siteverify answer carries **no `action` field at all**. The gate accepts
|
|
18
|
+
* exactly that answer in exactly those environments. So in every environment you develop or test in,
|
|
19
|
+
* there is no action coming back to compare, and two strings that disagree behave precisely like two
|
|
20
|
+
* that agree.
|
|
21
|
+
*
|
|
22
|
+
* The first environment that can tell is production. There a real widget echoes what it was solved
|
|
23
|
+
* for, and a mismatch refuses **every** sign-in with a 403 that truthfully says the challenge failed
|
|
24
|
+
* — which sends whoever is paged to the user, the sitekey and the secret, in that order, and to the
|
|
25
|
+
* mismatch last. That is the whole reason this file exists rather than a comment asking nicely.
|
|
26
|
+
*
|
|
27
|
+
* ## What this proves, and how it goes red
|
|
28
|
+
*
|
|
29
|
+
* The projection is mocked with an action that is **deliberately not** the real one. A widget that
|
|
30
|
+
* read a literal of its own would render that literal, and this goes red; only a widget that renders
|
|
31
|
+
* what it was handed can pass. The expected value is invented here and reachable from nowhere else,
|
|
32
|
+
* which is what a canary is for — asserting the real action would pass against the very bug this
|
|
33
|
+
* catches.
|
|
34
|
+
*
|
|
35
|
+
* This is the widget half. That the *route* asserts the action the projection carries is the kit's
|
|
36
|
+
* own gate, in `@pithy-sh/auth`. Neither is sufficient alone: this one says the widget carries what
|
|
37
|
+
* it is told, that one says the server expects what is told.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The action the mocked projection carries. Not the real one, on purpose: it is the difference
|
|
42
|
+
* between proving the widget *reads* the projection and proving only that two literals happen to
|
|
43
|
+
* match.
|
|
44
|
+
*/
|
|
45
|
+
const CANARY_ACTION = "pithy-gate-canary-not-a-real-action";
|
|
46
|
+
|
|
47
|
+
/** A sitekey to match, so a mock that never took effect fails as loudly as a widget that ignored it. */
|
|
48
|
+
const CANARY_SITEKEY = "0xCANARY";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The projection, stubbed at the module the widget reads it through.
|
|
52
|
+
*
|
|
53
|
+
* `src/pithy-config.tsx` is mocked rather than `virtual:pithy/turnstile` because the virtual modules
|
|
54
|
+
* are served by `@pithy-sh/vite` during a build and resolve nowhere else — mocking the config module
|
|
55
|
+
* keeps this gate running under the plain `vitest run` a scaffolded project already has.
|
|
56
|
+
*/
|
|
57
|
+
vi.mock("./pithy-config", () => ({
|
|
58
|
+
turnstileConfig: {
|
|
59
|
+
enabled: true,
|
|
60
|
+
sitekey: CANARY_SITEKEY,
|
|
61
|
+
mode: "visible",
|
|
62
|
+
action: CANARY_ACTION,
|
|
63
|
+
token: { field: "cf-turnstile-response", header: null },
|
|
64
|
+
},
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// React refuses to run `act` unless the environment says it is a test one.
|
|
68
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
69
|
+
|
|
70
|
+
/** The options the component handed `turnstile.render`, or null if it never rendered a widget. */
|
|
71
|
+
let rendered: { sitekey: string; action: string } | null = null;
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
rendered = null;
|
|
75
|
+
// The component loads Cloudflare's script by appending it to `<head>` and awaiting `onload`. Nothing
|
|
76
|
+
// in a test environment can serve it, so the append is intercepted: no element is added, no request
|
|
77
|
+
// is made, and the handler the component just assigned is called as if the script had arrived.
|
|
78
|
+
vi.spyOn(document.head, "appendChild").mockImplementation(<T extends Node>(node: T): T => {
|
|
79
|
+
queueMicrotask(() => {
|
|
80
|
+
(node as unknown as HTMLScriptElement).onload?.(new Event("load"));
|
|
81
|
+
});
|
|
82
|
+
return node;
|
|
83
|
+
});
|
|
84
|
+
window.turnstile = {
|
|
85
|
+
render: (_element, options) => {
|
|
86
|
+
rendered = { sitekey: options.sitekey, action: options.action };
|
|
87
|
+
return "widget-id";
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
vi.restoreAllMocks();
|
|
94
|
+
window.turnstile = undefined;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("the widget is solved for the action the projection carries, not one of its own", async () => {
|
|
98
|
+
// The one place the kit's default action may appear: as the value the expectation must NOT be. A
|
|
99
|
+
// canary that drifted onto a real action would pass against the drift it exists to catch.
|
|
100
|
+
expect(CANARY_ACTION).not.toBe("login");
|
|
101
|
+
|
|
102
|
+
// Imported inside the case so the mocked config module is in place before the widget's module scope
|
|
103
|
+
// reads it.
|
|
104
|
+
const { Turnstile } = await import("./turnstile");
|
|
105
|
+
const host = document.createElement("div");
|
|
106
|
+
document.body.appendChild(host);
|
|
107
|
+
|
|
108
|
+
await act(async () => {
|
|
109
|
+
createRoot(host).render(<Turnstile onToken={() => {}} />);
|
|
110
|
+
});
|
|
111
|
+
// One more turn for the script promise's continuation, which is where `render` is called.
|
|
112
|
+
await act(async () => {
|
|
113
|
+
await Promise.resolve();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(rendered, "the widget never rendered — the config mock or the script stub did not take").not.toBeNull();
|
|
117
|
+
expect(rendered?.sitekey).toBe(CANARY_SITEKEY);
|
|
118
|
+
expect(rendered?.action).toBe(CANARY_ACTION);
|
|
119
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useRef } from "react";
|
|
2
|
+
import { turnstileConfig } from "./pithy-config";
|
|
3
|
+
|
|
4
|
+
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
5
|
+
|
|
6
|
+
interface TurnstileApi {
|
|
7
|
+
render: (
|
|
8
|
+
element: HTMLElement,
|
|
9
|
+
options: {
|
|
10
|
+
sitekey: string;
|
|
11
|
+
action: string;
|
|
12
|
+
appearance: "always" | "execute";
|
|
13
|
+
/** The widget is a cross-origin iframe we cannot style, so its palette is an argument, not CSS. */
|
|
14
|
+
theme: "light" | "dark" | "auto";
|
|
15
|
+
/**
|
|
16
|
+
* Its width is an argument too, and this is the half that is easy to miss. `normal` is a fixed
|
|
17
|
+
* 300px box — which is why a widget styled `width: 100%` still sat narrower than the email field
|
|
18
|
+
* beside it. `flexible` fills its container instead, with a 300px floor `pithy-screens.css`
|
|
19
|
+
* keeps the form's column above.
|
|
20
|
+
*/
|
|
21
|
+
size: "normal" | "flexible" | "compact";
|
|
22
|
+
callback: (value: string) => void;
|
|
23
|
+
"expired-callback": () => void;
|
|
24
|
+
},
|
|
25
|
+
) => string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
declare global {
|
|
29
|
+
interface Window {
|
|
30
|
+
turnstile?: TurnstileApi;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let script: Promise<void> | undefined;
|
|
35
|
+
|
|
36
|
+
function loadScript(): Promise<void> {
|
|
37
|
+
script ??= new Promise<void>((resolve, reject) => {
|
|
38
|
+
const element = document.createElement("script");
|
|
39
|
+
element.src = SCRIPT_SRC;
|
|
40
|
+
element.async = true;
|
|
41
|
+
element.onload = () => resolve();
|
|
42
|
+
element.onerror = () => reject(new Error("Turnstile failed to load."));
|
|
43
|
+
document.head.appendChild(element);
|
|
44
|
+
});
|
|
45
|
+
return script;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Where the response token goes on a gated request. The server reads `token.header` when one is
|
|
50
|
+
* configured and `token.field` in the body otherwise — this returns both halves already decided.
|
|
51
|
+
*/
|
|
52
|
+
export function turnstileRequest(
|
|
53
|
+
body: Record<string, unknown>,
|
|
54
|
+
value: string | null,
|
|
55
|
+
): { body: Record<string, unknown>; headers: Record<string, string> } {
|
|
56
|
+
if (!turnstileConfig.enabled || !value) return { body, headers: {} };
|
|
57
|
+
if (turnstileConfig.token.header) return { body, headers: { [turnstileConfig.token.header]: value } };
|
|
58
|
+
return { body: { ...body, [turnstileConfig.token.field]: value }, headers: {} };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when a gated form still needs a token before it can submit. */
|
|
62
|
+
export function turnstilePending(value: string | null): boolean {
|
|
63
|
+
return turnstileConfig.enabled && !value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The widget. Renders nothing at all when the capability is not composed. */
|
|
67
|
+
export function Turnstile(props: { onToken: (value: string | null) => void }): ReactNode {
|
|
68
|
+
const host = useRef<HTMLDivElement>(null);
|
|
69
|
+
const onToken = props.onToken;
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!turnstileConfig.enabled) return;
|
|
73
|
+
let live = true;
|
|
74
|
+
void loadScript()
|
|
75
|
+
.then(() => {
|
|
76
|
+
if (!live || !host.current || !window.turnstile) return;
|
|
77
|
+
window.turnstile.render(host.current, {
|
|
78
|
+
sitekey: turnstileConfig.sitekey,
|
|
79
|
+
// The action comes from the projection, and writing it out here again would be a bug nothing
|
|
80
|
+
// short of production could see: the server asserts this exact string against the token, and
|
|
81
|
+
// dev and staging run Cloudflare test keys, whose answer carries no action to compare. A
|
|
82
|
+
// drifted copy is silent everywhere until it refuses every sign-in in prod. #377.
|
|
83
|
+
action: turnstileConfig.action,
|
|
84
|
+
appearance: turnstileConfig.mode === "invisible" ? "execute" : "always",
|
|
85
|
+
// `auto` is Turnstile's own name for `prefers-color-scheme`, which is what `pithy-screens.css`
|
|
86
|
+
// answers too. The widget is a cross-origin iframe, so this argument is the only lever — and
|
|
87
|
+
// it resolves the same question from the same source, with nothing to keep in step.
|
|
88
|
+
theme: "auto",
|
|
89
|
+
// Fill the column, so the check matches the field and the button above and below it. The host
|
|
90
|
+
// element supplies the width; see `.auth__check` in pithy-screens.css. Both halves are
|
|
91
|
+
// required — either one alone leaves a ragged edge beside a full-width input.
|
|
92
|
+
size: "flexible",
|
|
93
|
+
callback: (value) => onToken(value),
|
|
94
|
+
"expired-callback": () => onToken(null),
|
|
95
|
+
});
|
|
96
|
+
})
|
|
97
|
+
.catch(() => onToken(null));
|
|
98
|
+
return () => {
|
|
99
|
+
live = false;
|
|
100
|
+
};
|
|
101
|
+
}, [onToken]);
|
|
102
|
+
|
|
103
|
+
if (!turnstileConfig.enabled) return null;
|
|
104
|
+
return <div ref={host} />;
|
|
105
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
// The CLIENT program. Deliberately separate from tsconfig.json (the Worker's): the client needs JSX
|
|
3
|
+
// and the DOM, the Worker needs the Workers globals, and nothing may import across the two.
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"target": "ES2022",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"moduleResolution": "Bundler",
|
|
9
|
+
"moduleDetection": "force",
|
|
10
|
+
"jsx": "react-jsx",
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"strict": true,
|
|
15
|
+
"noImplicitOverride": true,
|
|
16
|
+
"noUncheckedIndexedAccess": true,
|
|
17
|
+
"skipLibCheck": true,
|
|
18
|
+
"types": ["vite/client"],
|
|
19
|
+
// Referenced from the project's root tsconfig.json, which requires `composite` — and `composite`
|
|
20
|
+
// makes tsc write build state. It goes under the PROJECT's `dist/`, already gitignored, never under
|
|
21
|
+
// this Worker's: Vite owns `apps/<worker>/dist` and empties it on every build, which would throw the
|
|
22
|
+
// incremental state away each time.
|
|
23
|
+
"composite": true,
|
|
24
|
+
"tsBuildInfoFile": "../../dist/__PITHY_WORKER__.client.tsbuildinfo",
|
|
25
|
+
"noEmit": true
|
|
26
|
+
},
|
|
27
|
+
"include": ["src/**/*.tsx", "client-env.d.ts"]
|
|
28
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
// The BUILD program: vite.config.ts runs on Node.
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "Bundler",
|
|
8
|
+
"moduleDetection": "force",
|
|
9
|
+
"verbatimModuleSyntax": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"types": ["node"],
|
|
15
|
+
// Referenced from the project's root tsconfig.json — see tsconfig.client.json for why the build
|
|
16
|
+
// state lands in the project's `dist/` and not this Worker's.
|
|
17
|
+
"composite": true,
|
|
18
|
+
"tsBuildInfoFile": "../../dist/__PITHY_WORKER__.node.tsbuildinfo",
|
|
19
|
+
"noEmit": true
|
|
20
|
+
},
|
|
21
|
+
"include": ["vite.config.ts"]
|
|
22
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cloudflare } from "@cloudflare/vite-plugin";
|
|
2
|
+
import { devWorkerConfig } from "@pithy-sh/vite/src/devOrigin";
|
|
3
|
+
import { pithy } from "@pithy-sh/vite/src/plugin";
|
|
4
|
+
import react from "@vitejs/plugin-react";
|
|
5
|
+
import { defineConfig } from "vite";
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
/**
|
|
9
|
+
* One React, however many checkouts the packages live in.
|
|
10
|
+
*
|
|
11
|
+
* Vite resolves a symlinked package from its realpath, so a package linked in from somewhere else —
|
|
12
|
+
* the Pithy kit, a design system, any workspace you point at by path — imports `react` out of *its*
|
|
13
|
+
* tree rather than out of this Worker's. Two copies of React is `invalid hook call` on the first
|
|
14
|
+
* component that package renders, and the stack blames the component rather than the resolution.
|
|
15
|
+
*
|
|
16
|
+
* `dedupe` resolves both names from Vite's root no matter who asked, and the root here is this
|
|
17
|
+
* Worker's own directory, where `react` is a dependency and there is therefore something to resolve.
|
|
18
|
+
* That is not true one level up: the project's `vitest.config.ts` is rooted at the repository, which
|
|
19
|
+
* has no React, so it states the same rule as an explicit alias instead. Read the note there before
|
|
20
|
+
* moving either.
|
|
21
|
+
*
|
|
22
|
+
* It is not a workaround for a symlink. It is what every linked-package setup needs, it costs nothing
|
|
23
|
+
* when nothing is linked, and it goes on costing nothing the day `@pithy-sh/*` is published.
|
|
24
|
+
*/
|
|
25
|
+
resolve: { dedupe: ["react", "react-dom"] },
|
|
26
|
+
plugins: [
|
|
27
|
+
react(),
|
|
28
|
+
cloudflare({
|
|
29
|
+
// `BASE_URL` from the port block `pithy dev` allocated *this checkout*, overriding the one in
|
|
30
|
+
// wrangler.jsonc while dev is running. A dev port is allocated rather than configured, so a
|
|
31
|
+
// literal there is right in the first checkout on a machine and wrong in every other one — and
|
|
32
|
+
// `BASE_URL` is the `iss` on every control-plane token this Worker signs and the origin its
|
|
33
|
+
// callback links are built against. Outside `pithy dev` it does nothing and the declared value
|
|
34
|
+
// stands, which is what a deployed environment wants. See `devWorkerConfig`.
|
|
35
|
+
config: devWorkerConfig(),
|
|
36
|
+
// Local state lives at the PROJECT root, not here — the same store pithy dev, migrate, and seed
|
|
37
|
+
// use. Per-worker state would silently give two workers separate copies of a shared database.
|
|
38
|
+
persistState: { path: "../../.wrangler/state" },
|
|
39
|
+
// Pinned off. The inspector defaults to 9229 and silently advances on a collision, so two
|
|
40
|
+
// UI-bearing workers under one pithy dev would drift onto ports nobody assigned them.
|
|
41
|
+
inspectorPort: false,
|
|
42
|
+
}),
|
|
43
|
+
pithy(),
|
|
44
|
+
],
|
|
45
|
+
});
|