diff --git a/packages/xforms-engine/src/client/EngineConfig.ts b/packages/xforms-engine/src/client/EngineConfig.ts index 79e91e047..68c8c86a6 100644 --- a/packages/xforms-engine/src/client/EngineConfig.ts +++ b/packages/xforms-engine/src/client/EngineConfig.ts @@ -1,43 +1,6 @@ -import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts'; import type { initializeForm } from '../instance/index.ts'; import type { OpaqueReactiveObjectFactory } from './OpaqueReactiveObjectFactory.ts'; - -/** - * @todo this is currently a strict subset of the web standard `Response`. Is it - * sufficient? Ways it might not be: - * - * - No way to convey metadata about the resource - * - Ambiguous if a client supplies an alternative implementation which doesn't - * exhaust the body on access - */ -export interface FetchResourceResponse { - readonly ok?: boolean; - readonly body?: ReadableStream | null; - readonly bodyUsed?: boolean; - - readonly blob: () => Promise; - readonly text: () => Promise; -} - -/** - * @todo this is a strict subset of the web standard `fetch` interface. It - * implicitly assumes that the engine itself will only ever issue `GET`-like - * requests. It also provides no further request-like semantics to the engine. - * This is presumed sufficient for now, but notably doesn't expose any notion of - * content negotiation (e.g. the ability to supply `Accept` headers). - * - * This also completely ignores any notion of mapping - * {@link https://getodk.github.io/xforms-spec/#uris | `jr:` URLs} to their - * actual resources (likely, but not necessarily, accessed at a corresponding - * HTTP URL). - */ -export type FetchResource = ( - resource: Resource -) => Promise; - -export type FormAttachmentURL = JRResourceURL; - -export type FetchFormAttachment = FetchResource; +import type { FetchFormAttachment, FetchResource } from './resources.ts'; /** * Options provided by a client to specify certain aspects of engine runtime diff --git a/packages/xforms-engine/src/client/resources.ts b/packages/xforms-engine/src/client/resources.ts new file mode 100644 index 000000000..288cb7ead --- /dev/null +++ b/packages/xforms-engine/src/client/resources.ts @@ -0,0 +1,118 @@ +import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts'; +import type { initializeForm } from '../instance/index.ts'; + +interface FetchResourceHeadersIterator + extends IteratorObject< + T, + // Note: we use this weird TypeScript intrinsic type so a built-in + // `HeadersIterator` is assignable regardless of a client's configured + // TypeScript or linting strictness. We don't actually care about the type, or + // consume the value it represents. + BuiltinIteratorReturn, + unknown + > { + [Symbol.iterator](): FetchResourceHeadersIterator; +} + +type FetchResourceHeadersForEachCallbackFn = ( + value: string, + key: string, + parent: FetchResourceResponseHeaders +) => void; + +/** + * A read-only strict subset of the web standard {@link Headers}. + * + * Note that the engine may make the following assumptions about + * {@link FetchResourceResponse.headers}: + * + * - If {@link FetchResourceResponse} is an instance of {@link Response}, it + * will be assumed its {@link FetchResourceResponse.headers | headers object} + * _is present_, and that it is an instance of {@link Headers}. In other + * words: for the purposes of resource resolution, we explicitly expect that + * clients using APIs provided by the runtime platform (or polyfills thereof) + * will not monkey-patch properties of values produced by those APIs. + * + * - If the object is an instance of {@link Headers} (whether by inference as a + * property of {@link Response}, or by a direct instance type check), the + * engine will assume it is safe to treat header names as case insensitive for + * any lookups it may perform. In other words: we explicitly expect that + * clients _providing access_ to APIs rovided by the runtime platform (or + * polyfills thereof) will not alter the guarantees of those APIs. + * + * - If the object is not an instance of {@link Headers}, it will be treated as + * a {@link ReadonlyMap}. In other words: we explicitly expect + * that clients, when providing a bespoke implementation of + * {@link FetchResourceResponse} and its constituent parts, will likely + * implement them partially (and in the case of + * {@link FetchResourceResponse.headers}, with the nearest common idiom + * available). In this case, we will favor a best effort at correctness, + * generally at some expense of performance. + */ +export interface FetchResourceResponseHeaders { + [Symbol.iterator](): FetchResourceHeadersIterator<[string, string]>; + + entries(): FetchResourceHeadersIterator<[string, string]>; + keys(): FetchResourceHeadersIterator; + values(): FetchResourceHeadersIterator; + + get(name: string): string | null; + has(name: string): boolean; + forEach(callbackfn: FetchResourceHeadersForEachCallbackFn): void; +} + +/** + * This is a strict subset of the web standard {@link Response}. Clients are + * encouraged to use the global {@link Response} constructor (as provided by the + * runtime platform, or by a global runtime polyfill), but may also provide a + * bespoke implementation if it suits their needs. + * + * Since we cannot assume a client's implementation will always be an instance + * of {@link Response}, we make some assumptions about its {@link headers} + * object (if available, as detailed on {@link FetchResourceResponseHeaders}). + * + * For other properties, we make the following assumptions (all of which are + * assumptions we would make about a platform-provided/polyfilled + * {@link Response}, but are explicitly stated for the benefit of confidence in + * client implementations): + * + * - If we read {@link body} directly, we will assume it is consumed on first + * read, and will not read it again. + * + * - We assume that {@link blob} and {@link text} indirectly consume + * {@link body} on first read as well, and will only ever read one of each of + * these properties, and only ever once. + * + * Furthermore, if the engine intends to read {@link body} (or its indirect + * {@link blob} or {@link text} consumers), it will do so in the course of a + * client's call to {@link initializeForm}, and before the + * {@link Promise} returned by that call is resolved. + */ +export interface FetchResourceResponse { + readonly ok?: boolean; + readonly status?: number; + readonly body?: ReadableStream | null; + readonly bodyUsed?: boolean; + readonly headers?: FetchResourceResponseHeaders; + + readonly blob: () => Promise; + readonly text: () => Promise; +} + +/** + * This is a strict subset of the web standard `fetch` interface. It implicitly + * assumes that the engine itself will only ever perform `GET`-like network/IO + * requests. It also provides no further request-like semantics to the engine. + * + * This is presumed sufficient for now, but notably doesn't expose any notion of + * content negotiation (e.g. the ability for the engine to include `Accept` + * headers in resource requests issued to a client's {@link FetchResource} + * implementation). + */ +export type FetchResource = ( + resource: Resource +) => Promise; + +export type FormAttachmentURL = JRResourceURL; + +export type FetchFormAttachment = FetchResource; diff --git a/packages/xforms-engine/src/index.ts b/packages/xforms-engine/src/index.ts index 767ba29e6..7b9dffdc3 100644 --- a/packages/xforms-engine/src/index.ts +++ b/packages/xforms-engine/src/index.ts @@ -25,6 +25,7 @@ export type * from './client/OpaqueReactiveObjectFactory.ts'; export type * from './client/repeat/RepeatInstanceNode.ts'; export type * from './client/repeat/RepeatRangeControlledNode.ts'; export type * from './client/repeat/RepeatRangeUncontrolledNode.ts'; +export type * from './client/resources.ts'; export type * from './client/RootNode.ts'; export type * from './client/SelectNode.ts'; export type * from './client/StringNode.ts'; diff --git a/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts b/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts index 3609f25a4..d0a2951c5 100644 --- a/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts +++ b/packages/xforms-engine/src/instance/internal-api/InstanceConfig.ts @@ -1,5 +1,5 @@ -import type { FetchFormAttachment, FetchResource } from '../../client/EngineConfig.ts'; import type { OpaqueReactiveObjectFactory } from '../../client/OpaqueReactiveObjectFactory.ts'; +import type { FetchFormAttachment, FetchResource } from '../../client/resources.ts'; import type { CreateUniqueId } from '../../lib/unique-id.ts'; export interface InstanceConfig { diff --git a/packages/xforms-engine/src/instance/resource.ts b/packages/xforms-engine/src/instance/resource.ts index 3c81bc997..89c2f04f6 100644 --- a/packages/xforms-engine/src/instance/resource.ts +++ b/packages/xforms-engine/src/instance/resource.ts @@ -1,7 +1,7 @@ import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts'; import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts'; -import type { FetchResource, FetchResourceResponse } from '../client/EngineConfig.ts'; import type { FormResource } from '../client/index.ts'; +import type { FetchResource, FetchResourceResponse } from '../client/resources.ts'; export type { FetchResource, FetchResourceResponse, FormResource }; diff --git a/packages/xforms-engine/test/instance/PrimaryInstance.test.ts b/packages/xforms-engine/test/instance/PrimaryInstance.test.ts index 4087d3ce7..1d16d8b76 100644 --- a/packages/xforms-engine/test/instance/PrimaryInstance.test.ts +++ b/packages/xforms-engine/test/instance/PrimaryInstance.test.ts @@ -11,10 +11,10 @@ import { title, } from '@getodk/common/test/fixtures/xform-dsl'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { FetchResource } from '../../src/client/EngineConfig.ts'; import type { ActiveLanguage } from '../../src/client/FormLanguage.ts'; import type { OpaqueReactiveObjectFactory } from '../../src/client/OpaqueReactiveObjectFactory.ts'; import type { RootNode } from '../../src/client/RootNode.ts'; +import type { FetchResource } from '../../src/client/resources.ts'; import { PrimaryInstance } from '../../src/instance/PrimaryInstance.ts'; import { Root } from '../../src/instance/Root.ts'; import { InstanceNode } from '../../src/instance/abstract/InstanceNode.ts';