dixous 0.1.1 → 0.2.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/src/index.ts CHANGED
@@ -1,132 +1,50 @@
1
- import { defaultResponseMethods, type DefaultResponseMethods } from "./response-methods.ts";
1
+ import { ConcurrentNextError, UnexpectedResponseError } from "./errors.ts";
2
+ import { defaultOperationApi } from "./response-methods.ts";
2
3
  import type {
3
- ClientOptions,
4
- Context,
5
- ContextKey,
6
- Dixous,
7
- Extension,
8
- ExtensionClientOptions,
9
- ExtensionDefinition,
10
- ExtensionMeta,
11
- ExtensionMethods,
12
- ExtensionRequestOptions,
13
- Fetcher,
14
- FetchResponse,
15
- Middleware,
16
- NoClientOptionOverrides,
17
- NoRequestInitOverrides,
18
- RequestContext,
19
- RequestOptions,
20
- ResponseMethods,
4
+ AnyExtension, ApplyExtensionApi, ApplyExtensionOptions, CoreOptions, CreateOptions,
5
+ DefaultOperationApi, Dixous as DixousClient, Extension,
6
+ ExtensionDefinition, OperationContext, RequestContext, RequestInput,
7
+ MatchedOperation, RequestMiddleware, RequestOptions, ReservedOperationKey,
21
8
  } from "./types.ts";
22
9
 
23
- export { SchemaValidationError } from "./response-methods.ts";
24
- export type { DefaultResponseMethods, InferOutput } from "./response-methods.ts";
25
-
10
+ export { ConcurrentNextError, ResponseValidationError, UnexpectedResponseError } from "./errors.ts";
11
+ export type { InferOutput, StandardSchemaIssue, StandardSchemaResult, StandardSchemaV1 } from "./standard-schema.ts";
26
12
  export type {
27
- BaseClientOptions,
28
- ClientOptions,
29
- Context,
30
- ContextKey,
31
- Dixous,
32
- Extension,
33
- Fetcher,
34
- FetchResponse,
35
- Middleware,
36
- Next,
37
- RequestContext,
38
- RequestOptions,
13
+ AnyExtension, CoreOptions, CreateOptions, DefaultOperationApi, Extension,
14
+ ExtensionDefinition, Match, MatchedOperation, MatchResult, Next, OperationContext,
15
+ RequestContext, RequestInput, RequestMiddleware, RequestOperation, RequestOptions,
16
+ ReservedOperationKey, StatusHandlers,
39
17
  } from "./types.ts";
40
18
 
41
- export function createContextKey<T>(): ContextKey<T> {
42
- return Symbol() as ContextKey<T>;
43
- }
44
-
45
- function createContext(): Context {
46
- const values = new Map<symbol, unknown>();
47
- return {
48
- get<T>(key: ContextKey<T>) {
49
- return values.get(key) as T | undefined;
50
- },
51
- set<T>(key: ContextKey<T>, value: T) {
52
- values.set(key, value);
53
- },
54
- };
55
- }
56
-
57
- export function defineExtension<const Methods extends ResponseMethods = {}>(
58
- extension: ExtensionDefinition<{}, {}, Methods>,
59
- ): Extension<{}, {}, Methods>;
60
- export function defineExtension<
61
- RequestExtra extends object & NoRequestInitOverrides = {},
62
- ClientExtra extends object & NoClientOptionOverrides = {},
63
- >(): <const Methods extends ResponseMethods = {}>(
64
- extension: ExtensionDefinition<RequestExtra, ClientExtra, Methods>,
65
- ) => Extension<RequestExtra, ClientExtra, Methods>;
66
- export function defineExtension(extension?: object): unknown {
67
- // Extension metadata is a type-only brand; composition uses the captured values.
68
- return extension === undefined ? (definition: object) => definition : extension;
69
- }
70
-
71
- export class HttpError extends Error {
72
- readonly request: Request;
73
- readonly response: Response;
74
- readonly status: number;
75
-
76
- constructor(request: Request, response: Response) {
77
- super(`HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`);
78
- this.name = "HttpError";
79
- this.request = request;
80
- this.response = response;
81
- this.status = response.status;
82
- }
83
- }
84
-
85
- function snapshotClient(options: ClientOptions = {}): Readonly<ClientOptions> {
86
- const client = { ...options };
87
- if (client.baseUrl !== undefined) client.baseUrl = client.baseUrl.toString();
88
- if (client.headers !== undefined) client.headers = new Headers(client.headers);
89
- return Object.freeze(client);
19
+ export function defineExtension<const OperationApi extends object = {}>(
20
+ definition: ExtensionDefinition<{}, OperationApi>,
21
+ ): Extension<{}, OperationApi>;
22
+ export function defineExtension<Options extends object>(): <const OperationApi extends object = {}>(
23
+ definition: ExtensionDefinition<Options, OperationApi>,
24
+ ) => Extension<Options, OperationApi>;
25
+ export function defineExtension(definition?: object): unknown {
26
+ return definition === undefined ? (entry: object) => entry : definition;
90
27
  }
91
28
 
92
- function snapshotOptions(options: RequestOptions = {}): Readonly<RequestOptions> {
93
- const snapshot = { ...options };
94
- if (snapshot.headers !== undefined) snapshot.headers = new Headers(snapshot.headers);
95
- return Object.freeze(snapshot);
96
- }
97
-
98
- function createTemplate(
99
- input: string | URL | Request,
100
- options: Readonly<RequestOptions>,
101
- client: Readonly<ClientOptions>,
102
- ): Request {
103
- const headers = new Headers(client.headers);
104
- if (input instanceof Request) {
105
- input.headers.forEach((value, name) => headers.set(name, value));
29
+ function mergeHeaders(...sources: (HeadersInit | undefined)[]): Headers {
30
+ const headers = new Headers();
31
+ for (const source of sources) {
32
+ if (source !== undefined) new Headers(source).forEach((value, name) => headers.set(name, value));
106
33
  }
107
- if (options.headers !== undefined) {
108
- new Headers(options.headers).forEach((value, name) => headers.set(name, value));
109
- }
110
- const source = !(input instanceof Request) && client.baseUrl !== undefined
111
- ? new URL(input.toString(), client.baseUrl)
112
- : input;
113
- return new Request(source, { ...options, headers });
34
+ return headers;
114
35
  }
115
36
 
116
37
  function runMiddleware(
117
- middleware: readonly Middleware[],
38
+ middleware: readonly RequestMiddleware[],
118
39
  context: RequestContext,
119
- fetchImpl: typeof globalThis.fetch,
40
+ transport: typeof globalThis.fetch,
120
41
  ): Promise<Response> {
121
42
  async function dispatch(index: number): Promise<Response> {
122
43
  const current = middleware[index];
123
- if (current === undefined) return fetchImpl(context.request.clone());
124
-
125
- // Each middleware invocation owns its guard. Sequential retries re-enter
126
- // the downstream chain with the same context and new downstream guards.
44
+ if (current === undefined) return transport(context.request);
127
45
  let running = false;
128
46
  return current(context, async () => {
129
- if (running) throw new Error("Overlapping next() calls are not allowed");
47
+ if (running) throw new ConcurrentNextError();
130
48
  running = true;
131
49
  try {
132
50
  return await dispatch(index + 1);
@@ -138,82 +56,94 @@ function runMiddleware(
138
56
  return dispatch(0);
139
57
  }
140
58
 
141
- function createFetchResponse(
142
- template: Request,
143
- options: Readonly<RequestOptions>,
144
- client: Readonly<ClientOptions>,
145
- middleware: readonly Middleware[],
146
- fetchImpl: typeof globalThis.fetch,
147
- ): FetchResponse {
148
- let execution: Promise<Response> | undefined;
149
- return () => {
150
- // Defer execution until after storing the promise, including when a
151
- // synchronous middleware re-enters its operation's FetchResponse.
152
- execution ??= Promise.resolve().then(async () => {
153
- const context: RequestContext = {
154
- request: template.clone(),
155
- options,
156
- client,
157
- state: createContext(),
158
- };
159
- const response = await runMiddleware(middleware, context, fetchImpl);
160
- if (!response.ok) throw new HttpError(context.request, response);
161
- return response;
59
+ type Configuration = CoreOptions & RequestOptions & { readonly extensions?: readonly AnyExtension[] };
60
+
61
+ const reservedOperationKeys = ["response", "then"] as const satisfies readonly ReservedOperationKey[];
62
+
63
+ function createOperation(
64
+ context: RequestContext,
65
+ execute: () => Promise<Response>,
66
+ extensions: readonly AnyExtension[],
67
+ ) {
68
+ const build = (response: () => Promise<Response>): MatchedOperation<{}> => {
69
+ const scoped: OperationContext = Object.create(context, {
70
+ response: { value: response, enumerable: true },
71
+ execute: { value: execute, enumerable: true },
72
+ api: { value: (matched: Response) => build(async () => matched), enumerable: true },
73
+ });
74
+ const operation = Object.assign(Object.create(null), defaultOperationApi(scoped));
75
+ for (const extension of extensions) {
76
+ const contribution = extension.operation?.(scoped);
77
+ if (contribution !== undefined) {
78
+ for (const key of reservedOperationKeys) {
79
+ if (key in contribution) throw new TypeError(`Extension operation cannot replace ${key}`);
80
+ }
81
+ Object.assign(operation, contribution);
82
+ }
83
+ }
84
+ return Object.defineProperties(operation, {
85
+ response: { value: execute, enumerable: true },
86
+ then: { value: undefined },
162
87
  });
163
- return execution;
164
88
  };
89
+ return build(async () => {
90
+ const response = await execute();
91
+ if (!response.ok) throw new UnexpectedResponseError(context.request, response);
92
+ return response;
93
+ });
165
94
  }
166
95
 
167
- export function createDixous<
168
- const Extensions extends readonly ExtensionMeta[] = [],
169
- >(options?: {
170
- extensions?: Extensions;
171
- fetch?: typeof globalThis.fetch;
172
- }): Dixous<
173
- ExtensionRequestOptions<Extensions>,
174
- ExtensionClientOptions<Extensions>,
175
- DefaultResponseMethods & ExtensionMethods<Extensions>
176
- > {
177
- const fetchImpl = options?.fetch ?? globalThis.fetch;
178
- const middleware: Middleware[] = [];
179
- const methods = new Map<string, ResponseMethods[string]>(Object.entries(defaultResponseMethods));
180
-
181
- for (const entry of options?.extensions ?? []) {
182
- // Contributions are erased only inside the kernel; the public signature
183
- // intersects their exact types when constructing the resulting client.
184
- const extension = entry as Extension<{}, {}, ResponseMethods>;
185
- if (extension.request !== undefined) middleware.push(extension.request);
186
- for (const [name, factory] of Object.entries(extension.methods ?? {})) {
187
- if (methods.has(name)) throw new Error(`Duplicate response method: ${name}`);
188
- methods.set(name, factory);
189
- }
190
- }
96
+ function createClient(parent: Configuration = {}, supplied: Configuration = {}): DixousClient {
97
+ const { extensions: inherited = [], ...defaults } = parent;
98
+ const { extensions: appended = [], ...overrides } = supplied;
99
+ const configuration = Object.freeze({
100
+ ...defaults,
101
+ ...overrides,
102
+ ...(overrides.baseUrl !== undefined ? { baseUrl: overrides.baseUrl.toString() } : {}),
103
+ headers: mergeHeaders(defaults.headers, overrides.headers),
104
+ // Capture contributions so later mutations cannot alter an immutable client.
105
+ extensions: Object.freeze([...inherited, ...appended].map(entry => Object.freeze({ ...entry }))),
106
+ });
107
+ const { extensions, ...clientOptions } = configuration;
108
+ const middleware = extensions.flatMap(entry => entry.request ? [entry.request] : []);
109
+ const transport = configuration.fetch ?? globalThis.fetch;
110
+
111
+ return Object.freeze({
112
+ create(options?: Configuration) { return createClient(configuration, options); },
113
+ request(input: RequestInput, suppliedOptions: RequestOptions = {}) {
114
+ const options = Object.freeze({
115
+ ...clientOptions,
116
+ ...suppliedOptions,
117
+ headers: mergeHeaders(
118
+ configuration.headers,
119
+ input instanceof Request ? input.headers : undefined,
120
+ suppliedOptions.headers,
121
+ ),
122
+ });
123
+ const source = !(input instanceof Request) && configuration.baseUrl !== undefined
124
+ ? new URL(input.toString(), configuration.baseUrl)
125
+ : input;
126
+ const request = new Request(source, options);
127
+ let execution: Promise<Response> | undefined;
128
+ const execute = () => {
129
+ // Store the promise before middleware can synchronously re-enter execute().
130
+ execution ??= Promise.resolve().then(() => runMiddleware(middleware, context, transport));
131
+ return execution;
132
+ };
133
+ const context: RequestContext = { input, request, options };
134
+ Object.defineProperties(context, { input: { writable: false }, options: { writable: false } });
135
+ return createOperation(context, execute, extensions);
136
+ },
137
+ }) as DixousClient;
138
+ }
191
139
 
192
- function configured(clientOptions?: ClientOptions): Fetcher<{}, ResponseMethods> {
193
- const client = snapshotClient(clientOptions);
194
- return {
195
- fetch(input, requestOptions) {
196
- const snapshot = snapshotOptions(requestOptions);
197
- const template = createTemplate(input, snapshot, client);
198
- const pending: Record<string, (...args: never[]) => Promise<unknown>> =
199
- Object.create(null);
200
- for (const [name, factory] of methods) {
201
- pending[name] = (...args) => {
202
- const fetchResponse = createFetchResponse(
203
- template, snapshot, client, middleware, fetchImpl,
204
- );
205
- // Factories and method work are lazy too, and run once per call.
206
- return factory(fetchResponse)(...args);
207
- };
208
- }
209
- return Object.freeze(pending);
210
- },
211
- };
212
- }
140
+ export interface Dixous<Options extends object = {}, OperationApi extends object = DefaultOperationApi>
141
+ extends DixousClient<Options, OperationApi> {}
213
142
 
214
- return Object.assign(configured, { fetch: configured().fetch }) as Dixous<
215
- ExtensionRequestOptions<Extensions>,
216
- ExtensionClientOptions<Extensions>,
217
- DefaultResponseMethods & ExtensionMethods<Extensions>
218
- >;
219
- }
143
+ export const Dixous: {
144
+ create<const Extensions extends readonly AnyExtension[] = []>(
145
+ options?: CreateOptions<{}, Extensions>,
146
+ ): Dixous<ApplyExtensionOptions<{}, Extensions>, ApplyExtensionApi<DefaultOperationApi, Extensions>>;
147
+ } = Object.freeze({
148
+ create: createClient.bind(undefined, {}) as DixousClient["create"],
149
+ });
@@ -1,36 +1,25 @@
1
- import type { StandardSchemaV1 } from "@standard-schema/spec";
2
- import type { FetchResponse, ResponseMethods } from "./types.ts";
1
+ import { ResponseValidationError, UnexpectedResponseError } from "./errors.ts";
2
+ import type { InferOutput, StandardSchemaV1 } from "./standard-schema.ts";
3
+ import type { DefaultOperationApi, MatchResult, OperationContext, StatusHandlers } from "./types.ts";
3
4
 
4
- export type InferOutput<Schema extends StandardSchemaV1> =
5
- StandardSchemaV1.InferOutput<Schema>;
6
-
7
- export class SchemaValidationError extends Error {
8
- readonly issues: readonly StandardSchemaV1.Issue[];
9
-
10
- constructor(issues: readonly StandardSchemaV1.Issue[]) {
11
- super("Response failed schema validation");
12
- this.name = "SchemaValidationError";
13
- this.issues = issues;
14
- }
15
- }
16
-
17
- // Built-ins use the same per-operation factories as extension methods.
18
- export const defaultResponseMethods = {
19
- json: (fetchResponse: FetchResponse) =>
20
- async <Schema extends StandardSchemaV1>(schema: Schema): Promise<InferOutput<Schema>> => {
21
- const response = await fetchResponse();
5
+ export function defaultOperationApi(operation: OperationContext): DefaultOperationApi {
6
+ return {
7
+ async json<Schema extends StandardSchemaV1>(schema: Schema): Promise<InferOutput<Schema>> {
8
+ const response = await operation.response();
22
9
  const result = await schema["~standard"].validate(await response.json());
23
- if (result.issues) throw new SchemaValidationError(result.issues);
10
+ if (result.issues !== undefined) {
11
+ throw new ResponseValidationError(operation.request, response, result.issues);
12
+ }
24
13
  return result.value as InferOutput<Schema>;
25
14
  },
26
- text: (fetchResponse: FetchResponse) => async (): Promise<string> =>
27
- (await fetchResponse()).text(),
28
- blob: (fetchResponse: FetchResponse) => async (): Promise<Blob> =>
29
- (await fetchResponse()).blob(),
30
- arrayBuffer: (fetchResponse: FetchResponse) => async (): Promise<ArrayBuffer> =>
31
- (await fetchResponse()).arrayBuffer(),
32
- response: (fetchResponse: FetchResponse) => (): Promise<Response> =>
33
- fetchResponse(),
34
- } satisfies ResponseMethods;
35
-
36
- export type DefaultResponseMethods = typeof defaultResponseMethods;
15
+ async text() { return (await operation.response()).text(); },
16
+ async blob() { return (await operation.response()).blob(); },
17
+ async arrayBuffer() { return (await operation.response()).arrayBuffer(); },
18
+ async match<Handlers extends StatusHandlers<{}>>(handlers: Handlers): Promise<MatchResult<Handlers>> {
19
+ const response = await operation.execute();
20
+ const handler = handlers[response.status];
21
+ if (handler === undefined) throw new UnexpectedResponseError(operation.request, response);
22
+ return (await handler(operation.api(response))) as MatchResult<Handlers>;
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Standard Schema v1 types, vendored from @standard-schema/spec 1.1.0.
3
+ * https://standardschema.dev/schema
4
+ *
5
+ * MIT License
6
+ *
7
+ * Copyright (c) 2024 Colin McDonnell
8
+ *
9
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ * of this software and associated documentation files (the "Software"), to deal
11
+ * in the Software without restriction, including without limitation the rights
12
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ * copies of the Software, and to permit persons to whom the Software is
14
+ * furnished to do so, subject to the following conditions:
15
+ *
16
+ * The above copyright notice and this permission notice shall be included in all
17
+ * copies or substantial portions of the Software.
18
+ *
19
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ * SOFTWARE.
26
+ */
27
+
28
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
29
+ readonly "~standard": {
30
+ readonly version: 1;
31
+ readonly vendor: string;
32
+ readonly validate: (value: unknown) =>
33
+ StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
34
+ readonly types?: {
35
+ readonly input: Input;
36
+ readonly output: Output;
37
+ };
38
+ };
39
+ }
40
+
41
+ export type StandardSchemaResult<Output> =
42
+ | { readonly value: Output; readonly issues?: undefined }
43
+ | { readonly issues: readonly StandardSchemaIssue[] };
44
+
45
+ export interface StandardSchemaIssue {
46
+ readonly message: string;
47
+ readonly path?: readonly (PropertyKey | { readonly key: PropertyKey })[];
48
+ }
49
+
50
+ export type InferOutput<Schema extends StandardSchemaV1> =
51
+ NonNullable<Schema["~standard"]["types"]>["output"];