@excom/fetchable-element 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.
Files changed (29) hide show
  1. package/.rush/temp/chunked-rush-logs/fetchable-element.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/fetchable-element.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/fetchable-element.build_package-metas.chunks.jsonl +1 -0
  4. package/.rush/temp/operation/apply-exports/all.log +1 -0
  5. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  6. package/.rush/temp/operation/apply-exports/state.json +3 -0
  7. package/.rush/temp/operation/build_docs/all.log +1 -0
  8. package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
  9. package/.rush/temp/operation/build_docs/state.json +3 -0
  10. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  11. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  12. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  13. package/.rush/temp/shrinkwrap-deps.json +3 -0
  14. package/config/rig.json +6 -0
  15. package/index.ts +399 -0
  16. package/package.json +48 -0
  17. package/rush-logs/fetchable-element.apply-exports.cache.log +1 -0
  18. package/rush-logs/fetchable-element.apply-exports.log +1 -0
  19. package/rush-logs/fetchable-element.build_docs.cache.log +1 -0
  20. package/rush-logs/fetchable-element.build_docs.log +1 -0
  21. package/rush-logs/fetchable-element.build_package-metas.cache.log +1 -0
  22. package/rush-logs/fetchable-element.build_package-metas.log +1 -0
  23. package/support/custom-elements.json +368 -0
  24. package/support/dist-docs/fetchable-element.md +120 -0
  25. package/support/docs/README.md +67 -0
  26. package/support/package-meta.json +218 -0
  27. package/support/tests/fetch-lifecycle.test.ts +401 -0
  28. package/support/tests/fetchable-element.test.ts +184 -0
  29. package/tsconfig.json +5 -0
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
@@ -0,0 +1 @@
1
+ Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 36.522813999999926
3
+ }
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 43.68495599999994
3
+ }
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 107.00815799999987
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "../../packages/fetchable-element": "../../packages/fetchable-element:PlYwJmLinsQMQ/1C2Ju8BiJrESSWDoE0f+9nAGiuhE8=:"
3
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
3
+ "rigPackageName": "@excom/heft-rig",
4
+ "rigProfile": "default"
5
+ }
6
+
package/index.ts ADDED
@@ -0,0 +1,399 @@
1
+ import { AbortableElement } from "@excom/abortable-element";
2
+ import { KitLogger } from "@excom/kit-logger";
3
+ import {
4
+ deepMerge,
5
+ deleteUndefined,
6
+ formToJson,
7
+ jsonToSearchParams,
8
+ mergeSearchParamsIntoUrl,
9
+ selectOne,
10
+ } from "@excom/kit-utils";
11
+ import { LoadableElement } from "@excom/loadable-element";
12
+ import { ConstructorType, Neutron, TEvent } from "@excom/neutron";
13
+
14
+ export type FetchResponse = {
15
+ bodyUsed: boolean;
16
+ headers: [string, string][];
17
+ ok: boolean;
18
+ redirected: boolean;
19
+ status: number;
20
+ statusText: string;
21
+ type: ResponseType;
22
+ url: string;
23
+ body: unknown;
24
+ };
25
+
26
+ type FetchInit = {
27
+ url: string;
28
+ payload: unknown;
29
+ requestInit: RequestInit;
30
+ };
31
+
32
+ export type FetchError = FetchResponse | { message: string; stack?: string };
33
+
34
+ export type FetchableLoadingEvent = TEvent & {
35
+ type: "{tag}-loading";
36
+ detail: void;
37
+ };
38
+
39
+ export type FetchableSuccessEvent = TEvent & {
40
+ type: "{tag}-success";
41
+ detail: FetchResponse;
42
+ };
43
+
44
+ export type FetchableErrorEvent = TEvent & {
45
+ type: "{tag}-error";
46
+ detail: FetchError;
47
+ };
48
+
49
+ /**
50
+ * Composition base that owns the request lifecycle for `fetch()`-backed
51
+ * elements — building request args (attributes, a referenced `<form>`, or
52
+ * a custom override), tracking loading / success / error state (through
53
+ * `LoadableElement`), and canceling in-flight requests (via
54
+ * `AbortableElement`) when superseded or disconnected. Not a registered element on its own (tag is intentionally
55
+ * `noop-tag`) — compose it via `Neutron.compose([FetchableElement, ...])`
56
+ * and the consumer element inherits every attribute, state, and event
57
+ * declared below. Concrete consumers include `<provider-fetch>`
58
+ * (fetch-on-attribute-change), `<super-form>` (fetch-on-submit), and
59
+ * `<web-authn>` (WebAuthn ceremonies that still round-trip to a server).
60
+ *
61
+ * Subclasses call `doFetch(url, requestInit)` — typically built via
62
+ * `getFetchArgs(customFetchArgs?)`, which deep-merges (lowest → highest
63
+ * priority) the element's own attributes, the `<form>` referenced by
64
+ * `form-ref` (action/method/enctype/fields), and any custom args passed
65
+ * in — to run the request and drive `is-loading` / `is-success` /
66
+ * `is-error` / `provision` automatically. A prior in-flight request is
67
+ * canceled before a new one starts.
68
+ *
69
+ * @fires {tag}-loading - Dispatched immediately before the request is
70
+ * sent.
71
+ * @type FetchableLoadingEvent
72
+ * @fires {tag}-success - Dispatched when the request resolves
73
+ * successfully. `event.detail` is the parsed response (see `provision`).
74
+ * @type FetchableSuccessEvent
75
+ * @fires {tag}-error - Dispatched when the request fails — non-2xx
76
+ * status, network error, or a thrown error. `event.detail` is the
77
+ * error payload (see `provision`). Not dispatched for aborted requests.
78
+ * @type FetchableErrorEvent
79
+ */
80
+ export const FetchableElement = Neutron.compose([
81
+ AbortableElement,
82
+ LoadableElement,
83
+ Neutron({
84
+ tag: "noop-tag",
85
+ /* `provision` and the loading / success / error events come from
86
+ LoadableElement. Props below are redeclared only to document the
87
+ request-specific meaning. */
88
+ props: {
89
+ // options
90
+ /**
91
+ * @option
92
+ * CSS selector for a `<form>` to source the request from — its
93
+ * `action` (URL), `method`, `enctype` (Content-Type), and field
94
+ * values (as the JSON payload) all take priority over the
95
+ * matching attributes below. Omit to build the request entirely
96
+ * from attributes / custom `doFetch()` args.
97
+ * @values <CSS Selector>
98
+ */
99
+ formRef: String,
100
+ /**
101
+ * @option
102
+ * Force a request body even for methods that don't imply one
103
+ * (`GET` / `HEAD`). Already implied for `POST` / `PUT` / `PATCH`.
104
+ */
105
+ hasBody: Boolean,
106
+ /**
107
+ * @option
108
+ * Endpoint URL. When the request has no body, the JSON payload
109
+ * (from `form-ref` or custom `doFetch()` args) is merged in as
110
+ * query params instead.
111
+ * @default ""
112
+ */
113
+ apiUrl: {
114
+ type: String,
115
+ defaultValue: () => "",
116
+ },
117
+ /**
118
+ * @option
119
+ * HTTP method. Always uppercased before the request is sent.
120
+ * @default GET
121
+ */
122
+ apiMethod: {
123
+ type: String,
124
+ defaultValue: () => "GET",
125
+ },
126
+ // headers
127
+ /**
128
+ * @option
129
+ * `Accept` request header.
130
+ * @default application/json
131
+ */
132
+ headerAccept: {
133
+ type: String,
134
+ defaultValue: () => "application/json",
135
+ },
136
+ /**
137
+ * @option
138
+ * `Content-Type` request header. Dropped entirely when the
139
+ * request has no body.
140
+ * @default application/json
141
+ */
142
+ headerContentType: {
143
+ type: String,
144
+ defaultValue: () => "application/json",
145
+ },
146
+ /**
147
+ * @option
148
+ * `Cache-Control` request header. Unset by default (browser
149
+ * default caching applies).
150
+ */
151
+ headerCacheControl: {
152
+ type: String,
153
+ defaultValue: () => null,
154
+ },
155
+ // fetch options
156
+ /**
157
+ * @option
158
+ * `RequestInit.redirect` mode. Unset defers to the browser
159
+ * default (`follow`).
160
+ * @values follow | error | manual
161
+ */
162
+ fetchRedirect: {
163
+ type: String,
164
+ defaultValue: () => null,
165
+ },
166
+ /**
167
+ * @option
168
+ * `RequestInit.credentials` mode.
169
+ * @default include
170
+ * @values omit | same-origin | include
171
+ */
172
+ fetchCredentials: {
173
+ type: String,
174
+ defaultValue: () => "include",
175
+ },
176
+
177
+ // public state
178
+ /**
179
+ * @state
180
+ * A request is currently in flight.
181
+ */
182
+ isLoading: Boolean,
183
+ /**
184
+ * @state
185
+ * The most recent request resolved successfully. Mutually
186
+ * exclusive with `is-error`.
187
+ */
188
+ isSuccess: Boolean,
189
+ /**
190
+ * @state
191
+ * The most recent request failed (non-2xx status, network
192
+ * error, or a thrown error other than `AbortError`). Fires with
193
+ * the `error` event.
194
+ */
195
+ isError: Boolean,
196
+ /**
197
+ * @provision
198
+ * Response payload on success, or error payload on failure.
199
+ * Success shape: `{ status, statusText, ok, headers, url,
200
+ * redirected, bodyUsed, type, body }`. Failure shape is either
201
+ * that same response shape (server responded with an error
202
+ * status) or `{ message, stack }` (request never completed).
203
+ * Not reflected as an attribute.
204
+ * @type FetchResponse
205
+ */
206
+ provision: Object as unknown as ConstructorType<FetchResponse>,
207
+
208
+ // internal state
209
+ fetchPromise: Promise,
210
+ },
211
+ }),
212
+ ])
213
+ .defineMethods({
214
+ doFetch: ({ fetchPromise }, fetchArgs: [string, RequestInit]) =>
215
+ fetchArgs.length === 2 && [
216
+ fetchPromise && { setCanceledState: [] },
217
+ {
218
+ setLoadingState: [fetchArgs[0], fetchArgs[1]],
219
+ },
220
+ ],
221
+ setLoadingState: (
222
+ { abortController },
223
+ url: string,
224
+ reqInit: RequestInit = {}
225
+ ) => [
226
+ {
227
+ fetchPromise: callFetch(url, {
228
+ signal: abortController.signal,
229
+ ...reqInit,
230
+ ...(reqInit.body ? { body: JSON.stringify(reqInit.body) } : {}),
231
+ }),
232
+ },
233
+ { _setLoading: [] },
234
+ ],
235
+ setCanceledState: () => [
236
+ {
237
+ doAbort: [],
238
+ },
239
+ { fetchPromise: null },
240
+ { _resetLoadState: [] },
241
+ ],
242
+ setSuccessState: (_, provision: any) => [
243
+ { fetchPromise: null },
244
+ { _setSuccess: [provision] },
245
+ ],
246
+ setErrorState: ({ localName }, provision: any) => {
247
+ KitLogger.error(`Element error: ${localName} - `, provision);
248
+ return [{ fetchPromise: null }, { _setError: [provision] }];
249
+ },
250
+ getFormElement: (element) => ({
251
+ returns: element.formRef
252
+ ? element.formRef === ":scope form"
253
+ ? // Default `:scope form`: `querySelector` is cheaper than global `selectOne`
254
+ // (`selectOne` would still work)
255
+ element.querySelector("form")
256
+ : selectOne(element.formRef, { scope: element })
257
+ : null,
258
+ }),
259
+ getFetchArgs: (
260
+ {
261
+ apiMethod,
262
+ apiUrl,
263
+ hasBody,
264
+ headerAccept,
265
+ headerContentType,
266
+ headerCacheControl,
267
+ fetchRedirect,
268
+ fetchCredentials,
269
+ // @ts-expect-error
270
+ getFormElement,
271
+ },
272
+ customFetchArgs: [string, RequestInit]
273
+ ): [string, RequestInit] => {
274
+ const form = getFormElement();
275
+ const fetchInit: FetchInit = deepMerge(
276
+ // Defaults (lowest priority): the element's own attributes
277
+ {
278
+ url: apiUrl,
279
+ payload: {},
280
+ requestInit: {
281
+ method: apiMethod,
282
+ headers: {
283
+ Accept: headerAccept || undefined,
284
+ "Content-Type": headerContentType || undefined,
285
+ "Cache-Control": headerCacheControl || undefined,
286
+ },
287
+ credentials: fetchCredentials || undefined,
288
+ redirect: fetchRedirect || undefined,
289
+ },
290
+ },
291
+ // Form data (medium priority)
292
+ form &&
293
+ deleteUndefined(
294
+ {
295
+ // Attrs, not methods: ignore the form's defaults (we have our own)
296
+ url: form.getAttribute("action") || undefined,
297
+ payload: formToJson(form),
298
+ requestInit: {
299
+ method: form.getAttribute("method") || undefined,
300
+ headers: {
301
+ "Content-Type": form.getAttribute("enctype") || undefined,
302
+ },
303
+ },
304
+ },
305
+ { nested: true }
306
+ ),
307
+ // Custom fetch args (highest priority)
308
+ {
309
+ ...(customFetchArgs?.[0] ? { url: customFetchArgs?.[0] } : {}),
310
+ requestInit: customFetchArgs?.[1] || {},
311
+ }
312
+ );
313
+
314
+ // Uppercase the method
315
+ fetchInit.requestInit.method =
316
+ fetchInit.requestInit.method!.toUpperCase();
317
+
318
+ // Does this request have a body?
319
+ const _hasBody =
320
+ hasBody ||
321
+ ["POST", "PUT", "PATCH"].includes(fetchInit.requestInit.method || "");
322
+
323
+ // Merge search params into the URL
324
+ const url = mergeSearchParamsIntoUrl(
325
+ fetchInit.url,
326
+ _hasBody ? new URLSearchParams() : jsonToSearchParams(fetchInit.payload)
327
+ );
328
+
329
+ // No body in args: build it from the payload
330
+ if (!fetchInit.requestInit.body) {
331
+ fetchInit.requestInit.body = fetchInit.payload as BodyInit;
332
+ }
333
+
334
+ if (!_hasBody) {
335
+ delete fetchInit.requestInit.body;
336
+ delete fetchInit.requestInit.headers!["Content-Type"];
337
+ }
338
+
339
+ return {
340
+ // @ts-ignore TODO need to type `returns`
341
+ returns: [
342
+ url,
343
+ deleteUndefined(fetchInit.requestInit, { nested: true }),
344
+ ],
345
+ };
346
+ },
347
+ })
348
+ .onPromiseResolved("fetchPromise", (_, result) => ({
349
+ setSuccessState: [result.fetchPromise],
350
+ }))
351
+ .onPromiseRejected("fetchPromise", (_, result) => ({
352
+ setErrorState: [result.fetchPromise],
353
+ }))
354
+ .onDisconnected(
355
+ ({ fetchPromise, isMoving }) =>
356
+ !isMoving &&
357
+ fetchPromise && {
358
+ setCanceledState: [],
359
+ }
360
+ );
361
+
362
+ async function callFetch(
363
+ url: string,
364
+ options: RequestInit
365
+ ): Promise<FetchResponse | undefined> {
366
+ let responseData;
367
+ try {
368
+ const response = await fetch(url, options);
369
+ responseData = {
370
+ bodyUsed: response.bodyUsed,
371
+ headers: Array.from(response.headers.entries()),
372
+ ok: response.ok,
373
+ redirected: response.redirected,
374
+ status: response.status,
375
+ statusText: response.statusText,
376
+ type: response.type,
377
+ url: response.url,
378
+ body: response.headers.get("content-type")?.includes("application/json")
379
+ ? await response.json()
380
+ : await response.text(),
381
+ };
382
+ if (responseData.status >= 400) {
383
+ throw new Error("Reponse error code: " + responseData.status);
384
+ }
385
+ return responseData;
386
+ } catch (error) {
387
+ if (error?.name !== "AbortError") {
388
+ // Prefer the error response; otherwise it's a thrown / programmatic error
389
+ const errorData = responseData || {
390
+ message: error.message,
391
+ stack: error.stack,
392
+ };
393
+ KitLogger.error("Element error: ", error);
394
+ throw errorData;
395
+ } else {
396
+ KitLogger.debug("Fetch request was aborted");
397
+ }
398
+ }
399
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@excom/fetchable-element",
3
+ "version": "0.1.0",
4
+ "description": "FetchableElement base for Neutron elements",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=24.13.0"
8
+ },
9
+ "type": "module",
10
+ "dependencies": {
11
+ "@excom/abortable-element": "^0.1.0",
12
+ "@excom/kit-logger": "^0.1.0",
13
+ "@excom/kit-utils": "^0.1.0",
14
+ "@excom/loadable-element": "^0.1.0",
15
+ "@excom/neutron": "^0.1.0"
16
+ },
17
+ "peerDependencies": {},
18
+ "devDependencies": {
19
+ "@excom/heft-rig": "^0.1.0"
20
+ },
21
+ "repository": {
22
+ "url": "excom-dev/nucleus",
23
+ "directory": "packages/fetchable-element"
24
+ },
25
+ "homepage": "https://github.com/excom-dev/nucleus/tree/main/packages/fetchable-element/support/docs/README.md",
26
+ "bugs": "https://github.com/excom-dev/nucleus/issues",
27
+ "keywords": [
28
+ "FetchableElement",
29
+ "fetchable-element",
30
+ "neutron",
31
+ "kit-element-base",
32
+ "custom-elements"
33
+ ],
34
+ "excom": {
35
+ "packageType": "element-base"
36
+ },
37
+ "scripts": {
38
+ "build": "node node_modules/@excom/heft-rig/scripts/vite-build.mjs",
39
+ "build:watch": "node node_modules/@excom/heft-rig/scripts/vite-build-watch.mjs",
40
+ "format": "node node_modules/@excom/heft-rig/scripts/format.mjs",
41
+ "test": "node node_modules/@excom/heft-rig/scripts/vitest.mjs",
42
+ "coverage": "node node_modules/@excom/heft-rig/scripts/coverage.mjs",
43
+ "dev": "node node_modules/@excom/heft-rig/scripts/vite-dev.mjs",
44
+ "preview": "node node_modules/@excom/heft-rig/scripts/vite-preview.mjs",
45
+ "build:package-metas": "node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs",
46
+ "build:docs": "node node_modules/@excom/heft-rig/scripts/build-docs.mjs"
47
+ }
48
+ }
@@ -0,0 +1 @@
1
+ Caching has been disabled for this project's "apply-exports" command.
@@ -0,0 +1 @@
1
+ Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
@@ -0,0 +1 @@
1
+ This project does not define the caching behavior of the "build:docs" command, so caching has been disabled.
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs
@@ -0,0 +1 @@
1
+ This project does not define the caching behavior of the "build:package-metas" command, so caching has been disabled.
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs