@iterant/site-runtime 3.6.1 → 3.8.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/docs/runtime-contract.md +200 -4
- package/package.json +8 -2
- package/src/components/forms.tsx +155 -0
- package/src/config/preset.ts +108 -2
- package/src/fonts/catalog.json +361 -0
- package/src/fonts/catalog.ts +88 -0
- package/src/index.ts +1 -0
- package/src/integrations/dev-server-signals.mjs +392 -0
- package/src/integrations/site-config-watch.mjs +38 -0
- package/src/layouts/LayoutCore.astro +43 -0
- package/src/layouts/layout-core.ts +56 -0
- package/src/lib/markdown.ts +10 -1
- package/src/lib/submit-form.ts +123 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The page-side half of platform form capture. A submission posts to the same
|
|
2
|
+
// endpoint, in the same body, that the injected runtime and the edge-rewritten
|
|
3
|
+
// native form already use (apps/site-dispatch, iterant-endpoints.ts). There is
|
|
4
|
+
// one protocol: this module reuses its field names and never adds one.
|
|
5
|
+
|
|
6
|
+
/** The endpoint's own control fields. Everything else in a body is visitor data. */
|
|
7
|
+
export const FORM_HONEYPOT_FIELD = "_hp";
|
|
8
|
+
const FORM_IDEMPOTENCY_FIELD = "_ik";
|
|
9
|
+
const FORM_SID_FIELD = "_sid";
|
|
10
|
+
|
|
11
|
+
/** The opaque form-key grammar the endpoint routes on; a key outside it is a 404 there. */
|
|
12
|
+
export const FORM_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
13
|
+
|
|
14
|
+
// The direct-origin base. A page served through the platform edge carries the
|
|
15
|
+
// prefix-resolved base in the injected config, which wins.
|
|
16
|
+
const DEFAULT_FORMS_BASE = "/_iterant/forms";
|
|
17
|
+
|
|
18
|
+
export type SubmitFormResult = { ok: true } | { ok: false; error: string };
|
|
19
|
+
|
|
20
|
+
export interface SubmitFormOptions {
|
|
21
|
+
/** Reuse one key across retries of one submission and the platform collapses
|
|
22
|
+
* them to a single row. Minted per call when absent. */
|
|
23
|
+
idempotencyKey?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// What the injected runtime leaves on the window: its config, and the session
|
|
27
|
+
// id it minted, so a lead submitted from page code still joins the visitor's
|
|
28
|
+
// journey without this package learning where the id is stored.
|
|
29
|
+
type SignalsWindow = {
|
|
30
|
+
__ITERANT_SIGNALS__?: { formsBase?: string };
|
|
31
|
+
iterantSignals?: { sid?(): string };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function signals(): SignalsWindow {
|
|
35
|
+
return typeof window === "undefined"
|
|
36
|
+
? {}
|
|
37
|
+
: (window as unknown as SignalsWindow);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formsBase(): string {
|
|
41
|
+
return signals().__ITERANT_SIGNALS__?.formsBase || DEFAULT_FORMS_BASE;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readSid(): string {
|
|
45
|
+
try {
|
|
46
|
+
return signals().iterantSignals?.sid?.() ?? "";
|
|
47
|
+
} catch {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function mintIdempotencyKey(): string {
|
|
53
|
+
const c = globalThis.crypto;
|
|
54
|
+
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
|
55
|
+
return `ik-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A form's fields as the record `submitForm` takes: strings only, so a file
|
|
59
|
+
* input contributes nothing, the same read the injected runtime performs. */
|
|
60
|
+
export function readFormFields(form: HTMLFormElement): Record<string, string> {
|
|
61
|
+
const fields: Record<string, string> = {};
|
|
62
|
+
new FormData(form).forEach((value, name) => {
|
|
63
|
+
if (typeof value === "string") fields[name] = value;
|
|
64
|
+
});
|
|
65
|
+
return fields;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Post one submission for the form key `name`. Resolves to `{ ok: true }` on
|
|
69
|
+
* a durable accept and `{ ok: false, error }` otherwise, and never throws:
|
|
70
|
+
* `error` is the endpoint's own token (`rate_limited`, `turnstile_failed`,
|
|
71
|
+
* `drain_failed`), `http_<status>` for an answer without one, `network` when
|
|
72
|
+
* nothing answered, `invalid_name` for a key the endpoint would not route and
|
|
73
|
+
* `unsupported_field:<name>` for a value that is not a string. */
|
|
74
|
+
export async function submitForm(
|
|
75
|
+
name: string,
|
|
76
|
+
fields: Record<string, string>,
|
|
77
|
+
options: SubmitFormOptions = {},
|
|
78
|
+
): Promise<SubmitFormResult> {
|
|
79
|
+
if (!FORM_KEY_PATTERN.test(name)) return { ok: false, error: "invalid_name" };
|
|
80
|
+
const body: Record<string, string> = {};
|
|
81
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
82
|
+
// File inputs are unsupported: only string values are captured.
|
|
83
|
+
if (typeof value !== "string") {
|
|
84
|
+
return { ok: false, error: `unsupported_field:${key}` };
|
|
85
|
+
}
|
|
86
|
+
body[key] = value;
|
|
87
|
+
}
|
|
88
|
+
body[FORM_IDEMPOTENCY_FIELD] = options.idempotencyKey ?? mintIdempotencyKey();
|
|
89
|
+
const sid = readSid();
|
|
90
|
+
if (sid) body[FORM_SID_FIELD] = sid;
|
|
91
|
+
|
|
92
|
+
let response: Response;
|
|
93
|
+
try {
|
|
94
|
+
response = await fetch(`${formsBase()}/${encodeURIComponent(name)}`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "content-type": "application/json" },
|
|
97
|
+
body: JSON.stringify(body),
|
|
98
|
+
credentials: "omit",
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
return { ok: false, error: "network" };
|
|
102
|
+
}
|
|
103
|
+
if (response.ok) return { ok: true };
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: (await errorToken(response)) ?? `http_${response.status}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The endpoint refuses with a JSON envelope carrying a token. Anything else
|
|
111
|
+
// (a dev server's HTML 404, a proxy's error page) has no token to read.
|
|
112
|
+
async function errorToken(response: Response): Promise<string | null> {
|
|
113
|
+
try {
|
|
114
|
+
const parsed: unknown = await response.json();
|
|
115
|
+
if (parsed && typeof parsed === "object") {
|
|
116
|
+
const error = (parsed as { error?: unknown }).error;
|
|
117
|
+
if (typeof error === "string" && error !== "") return error;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// not the endpoint's envelope
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|