@trypattern/sdk 0.1.31 → 0.1.33

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.
@@ -1,5 +1,8 @@
1
+ import { pickedFile, pickedFileOf } from "./files.js";
1
2
  import { MemoryServer } from "./memoryApi.js";
2
3
  import { ServiceRefused, paramNamesOf } from "./services.js";
4
+ const FILE_GONE = 'Choose the file again.';
5
+ const NOT_SENT = 'Couldn’t send it. Check your connection and try again.';
3
6
  function buildClient(services, make) {
4
7
  return Object.fromEntries(Object.entries(services).map(([group, calls]) => [
5
8
  group,
@@ -21,38 +24,109 @@ function errorOf(body, status) {
21
24
  : undefined;
22
25
  return typeof error === 'string' ? error : `HTTP ${status}`;
23
26
  }
24
- export function httpServices(services, gate, onSignedOut) {
25
- return buildClient(services, (group, name, call) => (input = {}) => gate.pass(`${group}.${name}`, async () => {
26
- const { path, rest } = splitInput(call, input);
27
- const sent = call.input.parse(rest);
28
- const fields = Object.entries(sent).filter(([, value]) => value !== undefined);
29
- const query = call.method === 'get' && fields.length > 0
30
- ? `?${new URLSearchParams(fields.map(([key, value]) => [key, String(value)])).toString()}`
31
- : '';
32
- const response = await fetch(`/api${path}${query}`, {
33
- method: call.method.toUpperCase(),
34
- ...(call.method === 'post'
35
- ? {
36
- headers: { 'content-type': 'application/json' },
37
- body: JSON.stringify(sent)
38
- }
39
- : {})
27
+ function fileIdOf(input) {
28
+ const file = input['file'];
29
+ const id = typeof file === 'object' && file !== null
30
+ ? Reflect.get(file, 'id')
31
+ : undefined;
32
+ return typeof id === 'string' ? id : '';
33
+ }
34
+ function formOf(fields, file) {
35
+ const form = new FormData();
36
+ for (const [key, value] of Object.entries(fields)) {
37
+ if (value !== undefined)
38
+ form.set(key, String(value));
39
+ }
40
+ form.set('file', file, file.name);
41
+ return form;
42
+ }
43
+ function bodyOf(text) {
44
+ try {
45
+ return JSON.parse(text);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function sendForm(url, form, { onProgress, signal }) {
52
+ return new Promise((resolve, reject) => {
53
+ const request = new XMLHttpRequest();
54
+ const abort = () => request.abort();
55
+ request.open('POST', url);
56
+ request.upload.addEventListener('progress', (event) => {
57
+ if (event.lengthComputable)
58
+ onProgress?.(event.loaded, event.total);
40
59
  });
41
- const body = await response.json().catch(() => null);
42
- if (response.status === 401)
60
+ request.addEventListener('load', () => {
61
+ signal?.removeEventListener('abort', abort);
62
+ resolve({ status: request.status, body: bodyOf(request.responseText) });
63
+ });
64
+ request.addEventListener('error', () => {
65
+ signal?.removeEventListener('abort', abort);
66
+ reject(new ServiceRefused(0, NOT_SENT));
67
+ });
68
+ request.addEventListener('abort', () => {
69
+ reject(new DOMException('The upload was cancelled', 'AbortError'));
70
+ });
71
+ if (signal?.aborted === true) {
72
+ reject(new DOMException('The upload was cancelled', 'AbortError'));
73
+ return;
74
+ }
75
+ signal?.addEventListener('abort', abort);
76
+ request.send(form);
77
+ });
78
+ }
79
+ async function upload(call, input, options) {
80
+ const { path, rest } = splitInput(call, input);
81
+ const file = pickedFileOf(fileIdOf(input));
82
+ if (file === undefined)
83
+ throw new ServiceRefused(400, FILE_GONE);
84
+ const fields = call.input.parse(Object.fromEntries(Object.entries(rest).filter(([key]) => key !== 'file')));
85
+ return sendForm(`/api${path}`, formOf(fields, file), options);
86
+ }
87
+ async function request(call, input) {
88
+ const { path, rest } = splitInput(call, input);
89
+ const sent = call.input.parse(rest);
90
+ const fields = Object.entries(sent).filter(([, value]) => value !== undefined);
91
+ const query = call.method === 'get' && fields.length > 0
92
+ ? `?${new URLSearchParams(fields.map(([key, value]) => [key, String(value)])).toString()}`
93
+ : '';
94
+ const response = await fetch(`/api${path}${query}`, {
95
+ method: call.method.toUpperCase(),
96
+ ...(call.method === 'post'
97
+ ? {
98
+ headers: { 'content-type': 'application/json' },
99
+ body: JSON.stringify(sent)
100
+ }
101
+ : {})
102
+ });
103
+ return {
104
+ status: response.status,
105
+ body: await response.json().catch(() => null)
106
+ };
107
+ }
108
+ export function httpServices(services, gate, onSignedOut) {
109
+ return buildClient(services, (group, name, call) => (input = {}, options = {}) => gate.pass(`${group}.${name}`, async () => {
110
+ const { status, body } = call.isUpload
111
+ ? await upload(call, input, options)
112
+ : await request(call, input);
113
+ if (status === 401)
43
114
  onSignedOut();
44
- if (!response.ok) {
45
- throw new ServiceRefused(response.status, errorOf(body, response.status));
115
+ if (status < 200 || status >= 300) {
116
+ throw new ServiceRefused(status, errorOf(body, status));
46
117
  }
47
118
  return call.reply.parse(body);
48
119
  }));
49
120
  }
50
121
  export function memoryServices(services, memory, world, options) {
51
122
  const servers = new Map();
52
- return buildClient(services, (group, name, call) => (input = {}) => {
123
+ return buildClient(services, (group, name, call) => (input = {}, upload = {}) => {
53
124
  const server = servers.get(group) ?? new MemoryServer(group, options);
54
125
  servers.set(group, server);
55
126
  return server.reply(name, () => {
127
+ if (upload.signal?.aborted === true) {
128
+ throw new DOMException('The upload was cancelled', 'AbortError');
129
+ }
56
130
  const handlers = Reflect.get(memory, group);
57
131
  const handler = typeof handlers === 'object' && handlers !== null
58
132
  ? Reflect.get(handlers, name)
@@ -61,8 +135,18 @@ export function memoryServices(services, memory, world, options) {
61
135
  throw new Error(`The memory fake has no ${group}.${name}`);
62
136
  }
63
137
  const { rest } = splitInput(call, input);
64
- const params = Object.fromEntries(paramNamesOf(call.path).map((key) => [key, String(input[key] ?? '')]));
65
- return call.reply.parse(handler(world, { ...params, ...call.input.parse(rest) }));
138
+ const params = Object.fromEntries(paramNamesOf(call.path).map((key) => [
139
+ key,
140
+ String(input[key] ?? '')
141
+ ]));
142
+ const file = call.isUpload
143
+ ? pickedFile.parse(input['file'])
144
+ : undefined;
145
+ return call.reply.parse(handler(world, {
146
+ ...params,
147
+ ...call.input.parse(rest),
148
+ ...(file === undefined ? {} : { file })
149
+ }));
66
150
  });
67
151
  });
68
152
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import type { PickedFile } from './files.ts';
2
3
  import type { SameOnTheWire } from './wire.ts';
3
4
  type Shape = z.ZodRawShape;
4
5
  type NoShape = Record<never, never>;
@@ -6,11 +7,16 @@ type ParamNames<Path extends string> = Path extends `${string}:${infer Param}/${
6
7
  export type ParamsOf<Path extends string> = {
7
8
  readonly [Name in ParamNames<Path>]: string;
8
9
  };
9
- export type ServiceCall<Path extends string = string, S extends Shape = Shape, Reply extends z.ZodType = z.ZodType> = {
10
+ export type ServiceCall<Path extends string = string, S extends Shape = Shape, Reply extends z.ZodType = z.ZodType, Upload extends boolean = boolean> = {
10
11
  readonly method: 'get' | 'post';
11
12
  readonly path: Path;
12
13
  readonly input: z.ZodObject<S>;
13
14
  readonly reply: Reply;
15
+ readonly isUpload: Upload;
16
+ };
17
+ export type UploadOptions = {
18
+ readonly onProgress?: (sent: number, total: number) => void;
19
+ readonly signal?: AbortSignal;
14
20
  };
15
21
  type StaticTypeError<Message extends string> = Message;
16
22
  type NotText = StaticTypeError<'A get call sends its input in the address, where everything is text: use z.string() or z.enum() here, or make it a post call'>;
@@ -26,12 +32,30 @@ type CallOptions<Path extends string, S extends Shape, Reply extends z.ZodType>
26
32
  input?: SameOnTheWire<S>;
27
33
  reply: Reply;
28
34
  };
29
- export declare function call<Path extends string, Reply extends z.ZodType, S extends Shape = NoShape>(options: CallOptions<Path, S, Reply>): NoInfer<ServiceCall<Path, S, Reply>>;
35
+ type UploadCallOptions<Path extends string, S extends Shape, Reply extends z.ZodType> = {
36
+ upload: Path;
37
+ input?: TextOnly<S> & SameOnTheWire<S>;
38
+ reply: Reply;
39
+ };
40
+ export declare function call<Path extends string, Reply extends z.ZodType, S extends Shape = NoShape>(options: UploadCallOptions<Path, S, Reply>): NoInfer<ServiceCall<Path, S, Reply, true>>;
41
+ export declare function call<Path extends string, Reply extends z.ZodType, S extends Shape = NoShape>(options: CallOptions<Path, S, Reply>): NoInfer<ServiceCall<Path, S, Reply, false>>;
30
42
  export type Services = Readonly<Record<string, Readonly<Record<string, ServiceCall>>>>;
31
43
  export declare function defineServices<const Group extends Services>(services: Group): Group;
32
- export type CallInput<Call> = Call extends ServiceCall<infer Path, infer S, z.ZodType> ? ParamsOf<Path> & (keyof S extends never ? NoShape : z.output<z.ZodObject<S>>) : never;
44
+ type FieldsOf<Call> = Call extends ServiceCall<infer Path, infer S, z.ZodType> ? ParamsOf<Path> & (keyof S extends never ? NoShape : z.output<z.ZodObject<S>>) : never;
45
+ export type CallInput<Call> = FieldsOf<Call> & (Call extends {
46
+ readonly isUpload: true;
47
+ } ? {
48
+ file: PickedFile;
49
+ } : NoShape);
50
+ export type HandlerInput<Call> = FieldsOf<Call> & (Call extends {
51
+ readonly isUpload: true;
52
+ } ? {
53
+ file: File;
54
+ } : NoShape);
33
55
  export type CallReply<Call> = Call extends ServiceCall<string, Shape, infer Reply> ? z.output<Reply> : never;
34
- type ClientCall<Call> = keyof CallInput<Call> extends never ? (input?: CallInput<Call>) => Promise<CallReply<Call>> : (input: CallInput<Call>) => Promise<CallReply<Call>>;
56
+ type ClientCall<Call> = Call extends {
57
+ readonly isUpload: true;
58
+ } ? (input: CallInput<Call>, options?: UploadOptions) => Promise<CallReply<Call>> : keyof CallInput<Call> extends never ? (input?: CallInput<Call>) => Promise<CallReply<Call>> : (input: CallInput<Call>) => Promise<CallReply<Call>>;
35
59
  export type ServiceClient<Group extends Services> = {
36
60
  readonly [Name in keyof Group]: {
37
61
  readonly [Call in keyof Group[Name]]: ClientCall<Group[Name][Call]>;
package/dist/services.js CHANGED
@@ -1,12 +1,16 @@
1
1
  import { z } from 'zod';
2
2
  export function call(options) {
3
- const method = 'get' in options ? 'get' : 'post';
4
- const path = 'get' in options ? options.get : options.post;
3
+ const path = 'get' in options
4
+ ? options.get
5
+ : 'post' in options
6
+ ? options.post
7
+ : options.upload;
5
8
  return {
6
- method,
9
+ method: 'get' in options ? 'get' : 'post',
7
10
  path,
8
11
  input: z.object((options.input ?? {})),
9
- reply: options.reply
12
+ reply: options.reply,
13
+ isUpload: 'upload' in options
10
14
  };
11
15
  }
12
16
  export function defineServices(services) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trypattern/sdk",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",