@myphp-plugins/fetch 0.0.0-stage → 1.28.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/index.ts +191 -0
- package/package.json +19 -4
- package/README.md +0 -3
package/index.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fetch plugin of MyPHP (mygo's @mygo-plugins/fetch): a `fetch` that
|
|
3
|
+
* makes HTTP requests from PHP, free of CORS and of the webview's
|
|
4
|
+
* restrictions on headers. It takes the standard options and returns a
|
|
5
|
+
* standard `Response`, whose body streams as PHP reads it. The PHP side
|
|
6
|
+
* must use the plugin:
|
|
7
|
+
*
|
|
8
|
+
* ```php
|
|
9
|
+
* App::use(Fetch::plugin()); // MyPHP\Plugins\Fetch\Fetch
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { fetch } from "@myphp-plugins/fetch";
|
|
14
|
+
*
|
|
15
|
+
* const res = await fetch("https://api.example.com/items", {
|
|
16
|
+
* headers: { authorization: `Bearer ${token}` },
|
|
17
|
+
* });
|
|
18
|
+
* const items = await res.json();
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* URLs that are not http or https, such as the app's own pages, `data:` and
|
|
22
|
+
* `blob:` URLs, go to the webview's `fetch`.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
import { Channel, call, isCallError } from "myphp-runtime";
|
|
27
|
+
|
|
28
|
+
/** What PHP streams back: the response's head, then chunks of its body. */
|
|
29
|
+
interface Part {
|
|
30
|
+
status?: number;
|
|
31
|
+
statusText?: string;
|
|
32
|
+
url?: string;
|
|
33
|
+
redirected?: boolean;
|
|
34
|
+
headers?: [string, string][];
|
|
35
|
+
/** Base64. */
|
|
36
|
+
chunk?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Statuses whose responses have no body. */
|
|
40
|
+
const NULL_BODY = new Set([101, 103, 204, 205, 304]);
|
|
41
|
+
|
|
42
|
+
/** Methods whose names are normalized to upper case, as `fetch` does. */
|
|
43
|
+
const NORMALIZED = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT"]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Makes an HTTP request from PHP, with the options and result of the
|
|
47
|
+
* standard `fetch`. `mode`, `credentials`, `cache` and the like have no
|
|
48
|
+
* meaning there and are ignored; no cookies are kept. Response headers are all there but `Set-Cookie`.
|
|
49
|
+
*/
|
|
50
|
+
export async function fetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
|
51
|
+
const req = typeof Request !== "undefined" && input instanceof Request ? input : null;
|
|
52
|
+
const base = typeof location !== "undefined" ? location.href : undefined;
|
|
53
|
+
const url = new URL(req ? req.url : input instanceof URL ? input.href : String(input), base);
|
|
54
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return globalThis.fetch(input, init);
|
|
55
|
+
|
|
56
|
+
const signal = init.signal ?? req?.signal ?? null;
|
|
57
|
+
signal?.throwIfAborted();
|
|
58
|
+
let method = init.method ?? req?.method ?? "GET";
|
|
59
|
+
if (NORMALIZED.has(method.toUpperCase())) method = method.toUpperCase();
|
|
60
|
+
// Plain Headers keep the names a Request would drop, such as Origin.
|
|
61
|
+
const headers = new Headers(init.headers ?? req?.headers);
|
|
62
|
+
let body: Uint8Array | null = null;
|
|
63
|
+
if (init.body != null) {
|
|
64
|
+
const r = new Response(init.body);
|
|
65
|
+
const type = r.headers.get("content-type");
|
|
66
|
+
if (type && !headers.has("content-type")) headers.set("content-type", type);
|
|
67
|
+
body = new Uint8Array(await r.arrayBuffer());
|
|
68
|
+
} else if (init.body === undefined && req?.body) {
|
|
69
|
+
body = new Uint8Array(await req.arrayBuffer());
|
|
70
|
+
}
|
|
71
|
+
if (body && (method === "GET" || method === "HEAD")) {
|
|
72
|
+
throw new TypeError(`fetch: a ${method} request cannot have a body`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const parts = new Channel<Part>();
|
|
76
|
+
const done = call<void>("plugin:fetch.fetch", {
|
|
77
|
+
url: url.href,
|
|
78
|
+
method,
|
|
79
|
+
headers: [...headers],
|
|
80
|
+
body: body && toBase64(body),
|
|
81
|
+
redirect: init.redirect ?? req?.redirect ?? "follow",
|
|
82
|
+
}, parts);
|
|
83
|
+
// Rejections are taken where they matter; this one must not go unhandled.
|
|
84
|
+
const failed = done.then(
|
|
85
|
+
() => null,
|
|
86
|
+
(err: unknown) => failure(err),
|
|
87
|
+
);
|
|
88
|
+
const it = parts[Symbol.asyncIterator]();
|
|
89
|
+
|
|
90
|
+
let stream: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
91
|
+
let rejectHead: ((reason: unknown) => void) | undefined;
|
|
92
|
+
const onAbort = () => {
|
|
93
|
+
parts.close();
|
|
94
|
+
try {
|
|
95
|
+
stream?.error(signal!.reason);
|
|
96
|
+
} catch {}
|
|
97
|
+
rejectHead?.(signal!.reason);
|
|
98
|
+
};
|
|
99
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
100
|
+
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
101
|
+
|
|
102
|
+
let head: Part;
|
|
103
|
+
try {
|
|
104
|
+
const first = await new Promise<IteratorResult<Part>>((resolve, reject) => {
|
|
105
|
+
rejectHead = reject;
|
|
106
|
+
it.next().then(resolve, reject);
|
|
107
|
+
});
|
|
108
|
+
rejectHead = undefined;
|
|
109
|
+
if (first.done) throw (await failed) ?? new TypeError("fetch failed: no response");
|
|
110
|
+
head = first.value;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
cleanup();
|
|
113
|
+
parts.close();
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const status = head.status ?? 200;
|
|
118
|
+
const nullBody = NULL_BODY.has(status) || method === "HEAD";
|
|
119
|
+
const readable = nullBody
|
|
120
|
+
? null
|
|
121
|
+
: new ReadableStream<Uint8Array>(
|
|
122
|
+
{
|
|
123
|
+
start(controller) {
|
|
124
|
+
stream = controller;
|
|
125
|
+
},
|
|
126
|
+
async pull(controller) {
|
|
127
|
+
const next = await it.next();
|
|
128
|
+
if (signal?.aborted) return;
|
|
129
|
+
if (!next.done) {
|
|
130
|
+
if (next.value.chunk) controller.enqueue(fromBase64(next.value.chunk));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
cleanup();
|
|
134
|
+
const err = await failed;
|
|
135
|
+
if (err) controller.error(err);
|
|
136
|
+
else controller.close();
|
|
137
|
+
},
|
|
138
|
+
cancel() {
|
|
139
|
+
cleanup();
|
|
140
|
+
parts.close();
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{ highWaterMark: 0 },
|
|
144
|
+
);
|
|
145
|
+
if (nullBody) {
|
|
146
|
+
parts.close();
|
|
147
|
+
cleanup();
|
|
148
|
+
}
|
|
149
|
+
// Response takes statuses from 200 to 599 only.
|
|
150
|
+
const valid = status >= 200 && status <= 599;
|
|
151
|
+
const res = new Response(readable, {
|
|
152
|
+
status: valid ? status : 200,
|
|
153
|
+
statusText: head.statusText ?? "",
|
|
154
|
+
headers: head.headers ?? [],
|
|
155
|
+
});
|
|
156
|
+
const own = (name: string, value: unknown) => Object.defineProperty(res, name, { value, enumerable: true });
|
|
157
|
+
own("url", head.url ?? url.href);
|
|
158
|
+
own("redirected", head.redirected ?? false);
|
|
159
|
+
if (!valid) own("status", status);
|
|
160
|
+
return res;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default fetch;
|
|
164
|
+
|
|
165
|
+
/** The error a failed request rejects with. */
|
|
166
|
+
function failure(err: unknown): Error {
|
|
167
|
+
if (!isCallError(err)) return err instanceof Error ? err : new TypeError(String(err));
|
|
168
|
+
if (err.message.includes("plugin is not used")) {
|
|
169
|
+
return new Error("@myphp-plugins/fetch: the PHP side does not use the plugin: call App::use(Fetch::plugin()) before App::run");
|
|
170
|
+
}
|
|
171
|
+
return new TypeError(`fetch failed: ${err.message}`, { cause: err });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toBase64(bytes: Uint8Array): string {
|
|
175
|
+
const native = (bytes as { toBase64?: () => string }).toBase64;
|
|
176
|
+
if (native) return native.call(bytes);
|
|
177
|
+
let s = "";
|
|
178
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
179
|
+
s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000) as unknown as number[]);
|
|
180
|
+
}
|
|
181
|
+
return btoa(s);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function fromBase64(s: string): Uint8Array {
|
|
185
|
+
const native = (Uint8Array as { fromBase64?: (s: string) => Uint8Array }).fromBase64;
|
|
186
|
+
if (native) return native(s);
|
|
187
|
+
const bin = atob(s);
|
|
188
|
+
const bytes = new Uint8Array(bin.length);
|
|
189
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
190
|
+
return bytes;
|
|
191
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myphp-plugins/fetch",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.28.0",
|
|
4
|
+
"description": "The page side of MyPHP's fetch plugin: a fetch that makes HTTP requests from PHP, over myphp-runtime",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": "./index.ts",
|
|
7
|
+
"types": "./index.ts",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/owner888/myphp.git",
|
|
11
|
+
"directory": "src/Plugins/Fetch/client"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"files": ["index.ts"],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"myphp-runtime": "^1.28.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/README.md
DELETED