@iterant/site-runtime 3.6.1 → 3.7.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 +44 -1
- package/package.json +6 -2
- package/src/components/forms.tsx +155 -0
- package/src/lib/markdown.ts +10 -1
- package/src/lib/submit-form.ts +123 -0
package/docs/runtime-contract.md
CHANGED
|
@@ -50,7 +50,7 @@ runtime and says so.
|
|
|
50
50
|
|
|
51
51
|
<!-- generated: available libraries -->
|
|
52
52
|
|
|
53
|
-
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.
|
|
53
|
+
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.7.0._
|
|
54
54
|
|
|
55
55
|
**Toolchain** (this package owns the version; do NOT declare these):
|
|
56
56
|
|
|
@@ -81,6 +81,7 @@ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.6.1._
|
|
|
81
81
|
| `tw-animate-css` | 1.4.0 | the animation utilities Tailwind v4 dropped |
|
|
82
82
|
| `github-slugger` | 2.0.0 | heading and anchor slugs |
|
|
83
83
|
| `micromark` | 4.0.2 | the CommonMark compiler markdown wrappers render with |
|
|
84
|
+
| `micromark-extension-gfm` | 3.0.0 | tables, task lists and strikethrough on top of it |
|
|
84
85
|
|
|
85
86
|
<!-- /generated -->
|
|
86
87
|
|
|
@@ -351,6 +352,48 @@ is what puts the component in the client bundle siblings hydrate from. The
|
|
|
351
352
|
bespoke-sibling gate enforces both on every non-draft bespoke page, after the
|
|
352
353
|
build.
|
|
353
354
|
|
|
355
|
+
## Forms (3.7.0)
|
|
356
|
+
|
|
357
|
+
A capturing form posts to the platform, never to a backend of its own. The
|
|
358
|
+
authored convention (an action-less `<form data-iterant-form="<key>">`, the
|
|
359
|
+
`_hp` honeypot, a hidden `data-iterant-form-success` sibling) is what the
|
|
360
|
+
injected runtime and the edge's native fallback act on, and `IterantForm`
|
|
361
|
+
renders exactly that markup so a page stops hand-writing it. Rendered
|
|
362
|
+
statically it behaves as the authored markup does: the runtime intercepts the
|
|
363
|
+
submit and reveals the success element. Hydrated (`client:load`, or inside a
|
|
364
|
+
bespoke page) it owns the submit: it marks the form `data-iterant-form-managed`
|
|
365
|
+
on mount, which is how the runtime knows to stand down, posts the same body to
|
|
366
|
+
the same endpoint, disables the submit button with `aria-busy` while in flight,
|
|
367
|
+
shows the success or the error message, resets the fields on success and never
|
|
368
|
+
navigates. `name` is required and is the form key the submissions dashboard
|
|
369
|
+
aggregates on. `successMessage` and `errorMessage` take a wrapped text value or
|
|
370
|
+
a plain string, so a section passes its entry copy straight through, and an
|
|
371
|
+
absent value falls back to the package default. It ships no utility classes:
|
|
372
|
+
the page styles the form through `className`, the state through
|
|
373
|
+
`data-iterant-form-state` (`idle`, `pending`, `success`, `error`) and the two
|
|
374
|
+
message elements through their attributes.
|
|
375
|
+
|
|
376
|
+
```tsx
|
|
377
|
+
import { readText } from "@iterant/site-runtime/content-values";
|
|
378
|
+
import { IterantForm } from "@iterant/site-runtime/forms";
|
|
379
|
+
|
|
380
|
+
<IterantForm name={readText(data.formKey)} successMessage={data.successCopy}>
|
|
381
|
+
<label htmlFor="email">{readText(data.emailLabel)}</label>
|
|
382
|
+
<input id="email" name="email" type="email" required />
|
|
383
|
+
<button type="submit">{readText(data.submitLabel)}</button>
|
|
384
|
+
</IterantForm>;
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`submitForm(name, fields)` is the same post without the component, for a
|
|
388
|
+
multi-step form or a custom handler. It resolves to `{ ok: true }` or
|
|
389
|
+
`{ ok: false, error }` and never throws, whether the platform accepted,
|
|
390
|
+
refused (`rate_limited`, `turnstile_failed`) or was unreachable (`network`).
|
|
391
|
+
Values are strings only: a `File` is refused as `unsupported_field:<name>`, and
|
|
392
|
+
`readFormFields` turns a form's `FormData` into the record it takes. Pass one
|
|
393
|
+
`idempotencyKey` across retries of a submission and the platform collapses them
|
|
394
|
+
to one row. Submissions are captured on a published site; the dev server has no
|
|
395
|
+
endpoint, so a preview submit settles as an error and captures nothing.
|
|
396
|
+
|
|
354
397
|
## Structured data is derived, never authored
|
|
355
398
|
|
|
356
399
|
Every indexable page emits a schema.org graph (Organization, WebSite, the page
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iterant/site-runtime",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The platform layer every Iterant brand site runs on: content grammar, collection schemas, SEO head and JSON-LD, layout core, Astro config preset, dev integrations and the verify gates.",
|
|
6
6
|
"scripts": {
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"./sitemap": "./src/lib/sitemap/index.ts",
|
|
50
50
|
"./seo": "./src/components/seo.tsx",
|
|
51
51
|
"./seo-json": "./src/components/seo-json.tsx",
|
|
52
|
+
"./forms": "./src/components/forms.tsx",
|
|
52
53
|
"./layout": "./src/layouts/LayoutCore.astro",
|
|
53
54
|
"./layout-core": "./src/layouts/layout-core.ts",
|
|
54
55
|
"./layout-contract": "./src/layouts/layout-contract.ts",
|
|
@@ -81,7 +82,8 @@
|
|
|
81
82
|
"class-variance-authority",
|
|
82
83
|
"tw-animate-css",
|
|
83
84
|
"github-slugger",
|
|
84
|
-
"micromark"
|
|
85
|
+
"micromark",
|
|
86
|
+
"micromark-extension-gfm"
|
|
85
87
|
]
|
|
86
88
|
},
|
|
87
89
|
"dependencies": {
|
|
@@ -98,6 +100,7 @@
|
|
|
98
100
|
"github-slugger": "2.0.0",
|
|
99
101
|
"lucide-react": "1.31.0",
|
|
100
102
|
"micromark": "4.0.2",
|
|
103
|
+
"micromark-extension-gfm": "3.0.0",
|
|
101
104
|
"motion": "13.0.0",
|
|
102
105
|
"radix-ui": "1.6.7",
|
|
103
106
|
"react": "19.2.8",
|
|
@@ -114,6 +117,7 @@
|
|
|
114
117
|
"@types/node": "24.3.1",
|
|
115
118
|
"@types/react": "19.2.14",
|
|
116
119
|
"@types/react-dom": "19.2.3",
|
|
120
|
+
"jsdom": "^26.0.0",
|
|
117
121
|
"typescript": "5.9.2",
|
|
118
122
|
"vitest": "^4.0.13",
|
|
119
123
|
"wrangler": "^4.107.0"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type FormEvent,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react";
|
|
8
|
+
|
|
9
|
+
import { type ContentLeaf, readText } from "../lib/content-values";
|
|
10
|
+
import {
|
|
11
|
+
FORM_HONEYPOT_FIELD,
|
|
12
|
+
mintIdempotencyKey,
|
|
13
|
+
readFormFields,
|
|
14
|
+
submitForm,
|
|
15
|
+
} from "../lib/submit-form";
|
|
16
|
+
|
|
17
|
+
export { readFormFields, submitForm } from "../lib/submit-form";
|
|
18
|
+
export type { SubmitFormOptions, SubmitFormResult } from "../lib/submit-form";
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_SUCCESS_MESSAGE = "Thanks for your submission!";
|
|
21
|
+
export const DEFAULT_ERROR_MESSAGE = "Something went wrong. Please try again.";
|
|
22
|
+
|
|
23
|
+
export interface IterantFormProps {
|
|
24
|
+
/** The form key: `data-iterant-form`, and what the submissions dashboard
|
|
25
|
+
* aggregates on. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** A wrapped text value or a plain string; anything else, or an empty one,
|
|
28
|
+
* falls back to the default. */
|
|
29
|
+
successMessage?: ContentLeaf;
|
|
30
|
+
errorMessage?: ContentLeaf;
|
|
31
|
+
className?: string;
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
onSuccess?(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type FormState = "idle" | "pending" | "success" | "error";
|
|
37
|
+
|
|
38
|
+
// The capturing form of the forms convention (an action-less
|
|
39
|
+
// `data-iterant-form`, the `_hp` honeypot, a hidden success sibling), rendered
|
|
40
|
+
// once so pages stop hand-writing it. Rendered statically it IS that markup and
|
|
41
|
+
// the injected runtime submits it. Hydrated, it owns the submit: same body,
|
|
42
|
+
// same endpoint, its own pending, success and error states, no navigation.
|
|
43
|
+
export function IterantForm({
|
|
44
|
+
name,
|
|
45
|
+
successMessage,
|
|
46
|
+
errorMessage,
|
|
47
|
+
className,
|
|
48
|
+
children,
|
|
49
|
+
onSuccess,
|
|
50
|
+
}: IterantFormProps) {
|
|
51
|
+
const [state, setState] = useState<FormState>("idle");
|
|
52
|
+
// Set after mount, never in the static render: the mark is what tells the
|
|
53
|
+
// injected runtime this form's submit has an owner and to stand down.
|
|
54
|
+
const [managed, setManaged] = useState(false);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
setManaged(true);
|
|
57
|
+
}, []);
|
|
58
|
+
const inFlight = useRef(false);
|
|
59
|
+
// One key per submission, reused on retry so the platform collapses a
|
|
60
|
+
// retried post to one row, and dropped once the submission is accepted.
|
|
61
|
+
const idempotencyKey = useRef<string | null>(null);
|
|
62
|
+
|
|
63
|
+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
64
|
+
// A runtime from before the managed mark took this submit in its
|
|
65
|
+
// capture-phase listener and posts it itself.
|
|
66
|
+
if (event.isDefaultPrevented()) return;
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
if (inFlight.current) return;
|
|
69
|
+
const form = event.currentTarget;
|
|
70
|
+
// A runtime from before the managed mark may have posted this form while
|
|
71
|
+
// the page was still static and be waiting on the edge: its in-flight mark
|
|
72
|
+
// stands down this submit, and its key is the one a retry reuses, so the
|
|
73
|
+
// platform collapses both posts to one row.
|
|
74
|
+
if (form.dataset.iterantSubmitting === "1") return;
|
|
75
|
+
inFlight.current = true;
|
|
76
|
+
const release = markBusy(form);
|
|
77
|
+
setState("pending");
|
|
78
|
+
idempotencyKey.current ??= form.dataset.iterantIk || mintIdempotencyKey();
|
|
79
|
+
const result = await submitForm(name, readFormFields(form), {
|
|
80
|
+
idempotencyKey: idempotencyKey.current,
|
|
81
|
+
});
|
|
82
|
+
release();
|
|
83
|
+
inFlight.current = false;
|
|
84
|
+
if (!result.ok) {
|
|
85
|
+
setState("error");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
idempotencyKey.current = null;
|
|
89
|
+
form.reset();
|
|
90
|
+
setState("success");
|
|
91
|
+
onSuccess?.();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div>
|
|
96
|
+
<form
|
|
97
|
+
data-iterant-form={name}
|
|
98
|
+
data-iterant-form-managed={managed ? "" : undefined}
|
|
99
|
+
data-iterant-form-state={state}
|
|
100
|
+
className={className}
|
|
101
|
+
onSubmit={handleSubmit}
|
|
102
|
+
>
|
|
103
|
+
<input
|
|
104
|
+
type="text"
|
|
105
|
+
name={FORM_HONEYPOT_FIELD}
|
|
106
|
+
tabIndex={-1}
|
|
107
|
+
autoComplete="off"
|
|
108
|
+
aria-hidden="true"
|
|
109
|
+
style={{ position: "absolute", left: "-9999px" }}
|
|
110
|
+
/>
|
|
111
|
+
{children}
|
|
112
|
+
</form>
|
|
113
|
+
<p
|
|
114
|
+
data-iterant-form-success=""
|
|
115
|
+
role="status"
|
|
116
|
+
hidden={state !== "success"}
|
|
117
|
+
>
|
|
118
|
+
{readText(successMessage) || DEFAULT_SUCCESS_MESSAGE}
|
|
119
|
+
</p>
|
|
120
|
+
<p data-iterant-form-error="" role="alert" hidden={state !== "error"}>
|
|
121
|
+
{readText(errorMessage) || DEFAULT_ERROR_MESSAGE}
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The submit controls are the caller's children, so the pending state goes on
|
|
128
|
+
// the live elements: `aria-busy` for assistive tech and the page's styles,
|
|
129
|
+
// `disabled` so a second click cannot start a second submission.
|
|
130
|
+
function markBusy(form: HTMLFormElement): () => void {
|
|
131
|
+
const restore = Array.from(form.elements)
|
|
132
|
+
.filter(isSubmitControl)
|
|
133
|
+
.map((control) => {
|
|
134
|
+
const wasDisabled = control.disabled;
|
|
135
|
+
control.disabled = true;
|
|
136
|
+
control.setAttribute("aria-busy", "true");
|
|
137
|
+
return () => {
|
|
138
|
+
control.disabled = wasDisabled;
|
|
139
|
+
control.removeAttribute("aria-busy");
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
return () => {
|
|
143
|
+
for (const fn of restore) fn();
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isSubmitControl(
|
|
148
|
+
element: Element,
|
|
149
|
+
): element is HTMLButtonElement | HTMLInputElement {
|
|
150
|
+
return (
|
|
151
|
+
(element instanceof HTMLButtonElement ||
|
|
152
|
+
element instanceof HTMLInputElement) &&
|
|
153
|
+
element.type === "submit"
|
|
154
|
+
);
|
|
155
|
+
}
|
package/src/lib/markdown.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { micromark } from "micromark";
|
|
2
|
+
import { gfm, gfmHtml } from "micromark-extension-gfm";
|
|
2
3
|
|
|
3
4
|
// The one compiler for {type:"markdown"} content values (content-values.ts).
|
|
4
5
|
// Runs at build time inside Astro's static render, so the browser ships HTML,
|
|
@@ -8,10 +9,18 @@ import { micromark } from "micromark";
|
|
|
8
9
|
// tags up front, and this default is the backstop for anything that predates
|
|
9
10
|
// or evades the schema. Do not pass allowDangerousHtml here, ever.
|
|
10
11
|
//
|
|
12
|
+
// GFM is on because the dashboard's body toolbar emits it: tables and task
|
|
13
|
+
// lists are buttons a customer can press, and CommonMark alone would render
|
|
14
|
+
// what they wrote as pipe soup. It adds syntax only; the escaping contract
|
|
15
|
+
// above is untouched, and gfmHtml writes task checkboxes disabled.
|
|
16
|
+
//
|
|
11
17
|
// Headings: a markdown body renders inside a section, below the page's own
|
|
12
18
|
// h1, so authors start at `##`. The compiler does not rewrite heading levels;
|
|
13
19
|
// a body that opens with `#` is an authoring error the review pass catches,
|
|
14
20
|
// not something to silently demote.
|
|
15
21
|
export function renderMarkdown(value: string): string {
|
|
16
|
-
return micromark(value
|
|
22
|
+
return micromark(value, {
|
|
23
|
+
extensions: [gfm()],
|
|
24
|
+
htmlExtensions: [gfmHtml()],
|
|
25
|
+
});
|
|
17
26
|
}
|
|
@@ -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
|
+
}
|