ink-framework-network 0.0.0-stage → 0.1.0-alpha.10
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/LICENSE +21 -0
- package/ink-native.json +14 -0
- package/package.json +30 -4
- package/runtime.d.ts +53 -0
- package/src/blob.ts +191 -0
- package/src/connectivity.ts +42 -0
- package/src/downloads.ts +111 -0
- package/src/fetch.ts +304 -0
- package/src/http.ts +89 -0
- package/src/index.ts +25 -0
- package/src/web-globals.ts +5 -0
- package/src/websocket.ts +136 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vandam Dinh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/ink-native.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ink-framework-network",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-alpha.10",
|
|
4
|
+
"exports": {
|
|
5
|
+
".": "./src/index.ts",
|
|
6
|
+
"./connectivity": "./src/connectivity.ts",
|
|
7
|
+
"./downloads": "./src/downloads.ts"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"web-streams-polyfill": "4.3.0",
|
|
11
|
+
"whatwg-url": "14.2.0",
|
|
12
|
+
"@types/whatwg-url": "13.0.0"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"ink": "npm:ink-framework@0.1.0-alpha.10",
|
|
16
|
+
"@ink/files": "npm:ink-framework-files@0.1.0-alpha.10"
|
|
17
|
+
},
|
|
18
|
+
"sideEffects": [
|
|
19
|
+
"./src/index.ts"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/No-Scrolling/ink.git",
|
|
25
|
+
"directory": "packages/network"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"*.json",
|
|
30
|
+
"*.ts"
|
|
31
|
+
]
|
|
32
|
+
}
|
package/runtime.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type * as Streams from "web-streams-polyfill";
|
|
2
|
+
import type * as URLs from "whatwg-url";
|
|
3
|
+
import type * as Bodies from "./src/blob";
|
|
4
|
+
import type * as HTTP from "./src/fetch";
|
|
5
|
+
import type * as Sockets from "./src/websocket";
|
|
6
|
+
|
|
7
|
+
declare global {
|
|
8
|
+
const URL: typeof URLs.URL;
|
|
9
|
+
type URL = URLs.URL;
|
|
10
|
+
const URLSearchParams: typeof URLs.URLSearchParams;
|
|
11
|
+
type URLSearchParams = URLs.URLSearchParams;
|
|
12
|
+
const Blob: typeof Bodies.Blob;
|
|
13
|
+
interface Blob extends Bodies.Blob {}
|
|
14
|
+
const File: typeof Bodies.File;
|
|
15
|
+
type File = Bodies.File;
|
|
16
|
+
const FormData: typeof Bodies.FormData;
|
|
17
|
+
interface FormData extends Bodies.FormData {}
|
|
18
|
+
type BlobPart = ConstructorParameters<typeof Blob>[0] extends Iterable<infer P> | undefined ? P : never;
|
|
19
|
+
const Headers: typeof HTTP.Headers;
|
|
20
|
+
type Headers = HTTP.Headers;
|
|
21
|
+
const Request: typeof HTTP.Request;
|
|
22
|
+
type Request = HTTP.Request;
|
|
23
|
+
const Response: typeof HTTP.Response;
|
|
24
|
+
type Response = HTTP.Response;
|
|
25
|
+
const fetch: typeof HTTP.fetch;
|
|
26
|
+
type RequestInit = NonNullable<ConstructorParameters<typeof Request>[1]>;
|
|
27
|
+
type RequestInfo = Parameters<typeof fetch>[0];
|
|
28
|
+
type HeadersInit = NonNullable<ConstructorParameters<typeof Headers>[0]>;
|
|
29
|
+
type ResponseInit = NonNullable<ConstructorParameters<typeof Response>[1]>;
|
|
30
|
+
type BodyInit = NonNullable<ConstructorParameters<typeof Response>[0]>;
|
|
31
|
+
const WebSocket: typeof Sockets.WebSocket;
|
|
32
|
+
type WebSocket = Sockets.WebSocket;
|
|
33
|
+
const MessageEvent: typeof Sockets.MessageEvent;
|
|
34
|
+
type MessageEvent = Sockets.MessageEvent;
|
|
35
|
+
const CloseEvent: typeof Sockets.CloseEvent;
|
|
36
|
+
type CloseEvent = Sockets.CloseEvent;
|
|
37
|
+
|
|
38
|
+
const ReadableStream: typeof Streams.ReadableStream;
|
|
39
|
+
type ReadableStream<R = unknown> = Streams.ReadableStream<R>;
|
|
40
|
+
const WritableStream: typeof Streams.WritableStream;
|
|
41
|
+
type WritableStream<W = unknown> = Streams.WritableStream<W>;
|
|
42
|
+
const TransformStream: typeof Streams.TransformStream;
|
|
43
|
+
type TransformStream<I = unknown, O = unknown> = Streams.TransformStream<I, O>;
|
|
44
|
+
const ByteLengthQueuingStrategy: typeof Streams.ByteLengthQueuingStrategy;
|
|
45
|
+
type ByteLengthQueuingStrategy = Streams.ByteLengthQueuingStrategy;
|
|
46
|
+
const CountQueuingStrategy: typeof Streams.CountQueuingStrategy;
|
|
47
|
+
type CountQueuingStrategy = Streams.CountQueuingStrategy;
|
|
48
|
+
type ReadableStreamDefaultReader<R = unknown> = Streams.ReadableStreamDefaultReader<R>;
|
|
49
|
+
type ReadableStreamDefaultController<R = unknown> = Streams.ReadableStreamDefaultController<R>;
|
|
50
|
+
type WritableStreamDefaultWriter<W = unknown> = Streams.WritableStreamDefaultWriter<W>;
|
|
51
|
+
type WritableStreamDefaultController = Streams.WritableStreamDefaultController;
|
|
52
|
+
type TransformStreamDefaultController<O = unknown> = Streams.TransformStreamDefaultController<O>;
|
|
53
|
+
}
|
package/src/blob.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { URLSearchParams } from "whatwg-url";
|
|
2
|
+
import { ReadableStream } from "web-streams-polyfill";
|
|
3
|
+
import { callNativeBytes } from "ink/native";
|
|
4
|
+
|
|
5
|
+
type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob;
|
|
6
|
+
type NativePart = { src: string; offset: number; size: number };
|
|
7
|
+
type Part = Uint8Array | NativePart;
|
|
8
|
+
const partSize = (part: Part) => part instanceof Uint8Array ? part.length : part.size;
|
|
9
|
+
const blobParts = new WeakMap<Blob, Part[]>();
|
|
10
|
+
function partsOf(blob: Blob): Part[] {
|
|
11
|
+
const parts = blobParts.get(blob);
|
|
12
|
+
if (!parts) throw new TypeError("Invalid Blob receiver");
|
|
13
|
+
return parts;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function nativeBlob(src: string, size: number, type: string): Blob {
|
|
17
|
+
const blob = new Blob([], { type });
|
|
18
|
+
blobParts.set(blob, [{ src, offset: 0, size }]);
|
|
19
|
+
Object.defineProperty(blob, "size", { value: size });
|
|
20
|
+
return blob;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function nativeBlobParts(blob: Blob): Iterable<{ bytes: Uint8Array } | NativePart> | undefined {
|
|
24
|
+
const parts = partsOf(blob);
|
|
25
|
+
if (!parts.some(part => !(part instanceof Uint8Array))) return undefined;
|
|
26
|
+
return (function* () {
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
if (part instanceof Uint8Array) {
|
|
29
|
+
for (let offset = 0; offset < part.length; offset += 32768) yield { bytes: part.subarray(offset, offset + 32768) };
|
|
30
|
+
} else yield part;
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
}
|
|
34
|
+
export class Blob {
|
|
35
|
+
readonly size: number;
|
|
36
|
+
readonly type: string;
|
|
37
|
+
constructor(parts: Iterable<BlobPart> = [], options: { type?: string; endings?: "transparent" | "native" } = {}) {
|
|
38
|
+
const stored: Part[] = [];
|
|
39
|
+
blobParts.set(this, stored);
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
if (part instanceof Blob) stored.push(...partsOf(part));
|
|
42
|
+
else if (part instanceof ArrayBuffer) stored.push(new Uint8Array(part.slice(0)));
|
|
43
|
+
else if (ArrayBuffer.isView(part)) stored.push(new Uint8Array(part.buffer, part.byteOffset, part.byteLength).slice());
|
|
44
|
+
else stored.push(new TextEncoder().encode(options.endings === "native" ? String(part).replace(/\r\n|\r/g, "\n") : String(part)));
|
|
45
|
+
}
|
|
46
|
+
this.size = stored.reduce((size, part) => size + partSize(part), 0);
|
|
47
|
+
const type = String(options.type ?? "");
|
|
48
|
+
this.type = /[^\x20-\x7e]/.test(type) ? "" : type.toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
slice(start = 0, end = this.size, type = "") {
|
|
51
|
+
const normalise = (value: number) => value < 0 ? Math.max(this.size + Math.trunc(value), 0) : Math.min(Math.trunc(value) || 0, this.size);
|
|
52
|
+
let offset = 0;
|
|
53
|
+
const result = new Blob([], { type });
|
|
54
|
+
const resultParts = partsOf(result);
|
|
55
|
+
const from = normalise(start), to = normalise(end);
|
|
56
|
+
for (const part of partsOf(this)) {
|
|
57
|
+
const left = Math.max(0, from - offset), right = Math.min(partSize(part), to - offset);
|
|
58
|
+
if (right > left) {
|
|
59
|
+
resultParts.push(part instanceof Uint8Array ? part.subarray(left, right) : { ...part, offset: part.offset + left, size: right - left });
|
|
60
|
+
}
|
|
61
|
+
offset += partSize(part);
|
|
62
|
+
}
|
|
63
|
+
Object.defineProperty(result, "size", { value: Math.max(to - from, 0) });
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
async bytes() {
|
|
67
|
+
const bytes = new Uint8Array(this.size);
|
|
68
|
+
let offset = 0;
|
|
69
|
+
const reader = this.stream().getReader();
|
|
70
|
+
try {
|
|
71
|
+
for (;;) { const chunk = await reader.read(); if (chunk.done) break; bytes.set(chunk.value, offset); offset += chunk.value.length; }
|
|
72
|
+
} finally { reader.releaseLock(); }
|
|
73
|
+
return bytes;
|
|
74
|
+
}
|
|
75
|
+
async arrayBuffer() { return (await this.bytes()).buffer; }
|
|
76
|
+
async text() { return new TextDecoder().decode(await this.bytes()); }
|
|
77
|
+
stream(): ReadableStream<Uint8Array> {
|
|
78
|
+
let index = 0, offset = 0;
|
|
79
|
+
const parts = partsOf(this);
|
|
80
|
+
return new ReadableStream({
|
|
81
|
+
async pull(controller) {
|
|
82
|
+
while (index < parts.length && offset === partSize(parts[index])) { index++; offset = 0; }
|
|
83
|
+
if (index === parts.length) { controller.close(); return; }
|
|
84
|
+
const part = parts[index];
|
|
85
|
+
const end = Math.min(offset + 32 * 1024, partSize(part));
|
|
86
|
+
const bytes = part instanceof Uint8Array ? part.slice(offset, end)
|
|
87
|
+
: (await callNativeBytes("network", "stream-file-read-bytes", { src: part.src, offset: part.offset + offset, size: end - offset })).bytes;
|
|
88
|
+
if (bytes.length !== end - offset) throw new TypeError("Managed file changed while reading");
|
|
89
|
+
controller.enqueue(bytes);
|
|
90
|
+
offset = end;
|
|
91
|
+
},
|
|
92
|
+
}, { highWaterMark: 0 });
|
|
93
|
+
}
|
|
94
|
+
get [Symbol.toStringTag]() { return "Blob"; }
|
|
95
|
+
}
|
|
96
|
+
export class File extends Blob {
|
|
97
|
+
readonly name: string;
|
|
98
|
+
readonly lastModified: number;
|
|
99
|
+
readonly webkitRelativePath = "";
|
|
100
|
+
constructor(parts: Iterable<BlobPart>, name: string, options: { type?: string; lastModified?: number } = {}) {
|
|
101
|
+
super(parts, options);
|
|
102
|
+
this.name = String(name);
|
|
103
|
+
this.lastModified = options.lastModified ?? Date.now();
|
|
104
|
+
}
|
|
105
|
+
get [Symbol.toStringTag]() { return "File"; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type FormValue = string | File;
|
|
109
|
+
export class FormData implements Iterable<[string, FormValue]> {
|
|
110
|
+
#entries: [string, FormValue][] = [];
|
|
111
|
+
append(name: string, value: string | Blob, filename?: string) {
|
|
112
|
+
this.#entries.push([String(name), value instanceof Blob
|
|
113
|
+
? new File([value], filename ?? (value instanceof File ? value.name : "blob"), { type: value.type, lastModified: value instanceof File ? value.lastModified : undefined })
|
|
114
|
+
: String(value)]);
|
|
115
|
+
}
|
|
116
|
+
delete(name: string) { this.#entries = this.#entries.filter(entry => entry[0] !== String(name)); }
|
|
117
|
+
get(name: string): FormValue | null { return this.#entries.find(entry => entry[0] === String(name))?.[1] ?? null; }
|
|
118
|
+
getAll(name: string): FormValue[] { return this.#entries.filter(entry => entry[0] === String(name)).map(entry => entry[1]); }
|
|
119
|
+
has(name: string) { return this.#entries.some(entry => entry[0] === String(name)); }
|
|
120
|
+
set(name: string, value: string | Blob, filename?: string) {
|
|
121
|
+
const index = this.#entries.findIndex(entry => entry[0] === String(name));
|
|
122
|
+
this.delete(name);
|
|
123
|
+
this.append(name, value, filename);
|
|
124
|
+
const entry = this.#entries.pop();
|
|
125
|
+
if (entry) this.#entries.splice(index < 0 ? this.#entries.length : index, 0, entry);
|
|
126
|
+
}
|
|
127
|
+
*entries(): IterableIterator<[string, FormValue]> { yield* this.#entries; }
|
|
128
|
+
*keys() { for (const [name] of this.#entries) yield name; }
|
|
129
|
+
*values() { for (const [, value] of this.#entries) yield value; }
|
|
130
|
+
[Symbol.iterator]() { return this.entries(); }
|
|
131
|
+
forEach(callback: (value: FormValue, name: string, form: FormData) => void, thisArg?: unknown) {
|
|
132
|
+
for (const [name, value] of this) callback.call(thisArg, value, name, this);
|
|
133
|
+
}
|
|
134
|
+
get [Symbol.toStringTag]() { return "FormData"; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function multipart(form: FormData): Blob {
|
|
138
|
+
const boundary = `----ink-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
|
|
139
|
+
const parts: BlobPart[] = [];
|
|
140
|
+
const escape = (text: string) => text.replace(/\r\n|\r|\n/g, "\r\n").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/"/g, "%22");
|
|
141
|
+
for (const [name, value] of form) {
|
|
142
|
+
let header = `--${boundary}\r\nContent-Disposition: form-data; name="${escape(name)}"`;
|
|
143
|
+
if (value instanceof File) {
|
|
144
|
+
header += `; filename="${escape(value.name)}"\r\nContent-Type: ${value.type || "application/octet-stream"}`;
|
|
145
|
+
}
|
|
146
|
+
parts.push(`${header}\r\n\r\n`, typeof value === "string" ? value.replace(/\r\n|\r|\n/g, "\r\n") : value, "\r\n");
|
|
147
|
+
}
|
|
148
|
+
parts.push(`--${boundary}--\r\n`);
|
|
149
|
+
return new Blob(parts, { type: `multipart/form-data; boundary=${boundary}` });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function parseFormData(bytes: Uint8Array, contentType: string): FormData {
|
|
153
|
+
const form = new FormData();
|
|
154
|
+
if (/^application\/x-www-form-urlencoded(?:;|$)/i.test(contentType)) {
|
|
155
|
+
for (const [name, value] of new URLSearchParams(new TextDecoder().decode(bytes))) form.append(name, value);
|
|
156
|
+
return form;
|
|
157
|
+
}
|
|
158
|
+
const boundary = /^multipart\/form-data\s*;.*?boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
|
|
159
|
+
if (!boundary) throw new TypeError("Response is not form data");
|
|
160
|
+
const encoder = new TextEncoder();
|
|
161
|
+
const delimiter = encoder.encode(`\r\n--${boundary[1] ?? boundary[2]}`);
|
|
162
|
+
const find = (needle: Uint8Array, start: number) => {
|
|
163
|
+
outer: for (let index = start; index <= bytes.length - needle.length; index++) {
|
|
164
|
+
for (let offset = 0; offset < needle.length; offset++) if (bytes[index + offset] !== needle[offset]) continue outer;
|
|
165
|
+
return index;
|
|
166
|
+
}
|
|
167
|
+
return -1;
|
|
168
|
+
};
|
|
169
|
+
let offset = find(delimiter.subarray(2), 0);
|
|
170
|
+
if (offset < 0) throw new TypeError("Invalid multipart boundary");
|
|
171
|
+
offset += delimiter.length - 2;
|
|
172
|
+
while (bytes[offset] !== 45 || bytes[offset + 1] !== 45) {
|
|
173
|
+
while (bytes[offset] === 32 || bytes[offset] === 9) offset++;
|
|
174
|
+
if (bytes[offset] !== 13 || bytes[offset + 1] !== 10) throw new TypeError("Invalid multipart framing");
|
|
175
|
+
const headersEnd = find(encoder.encode("\r\n\r\n"), offset + 2);
|
|
176
|
+
if (headersEnd < 0 || headersEnd - offset > 16 * 1024) throw new TypeError("Invalid multipart headers");
|
|
177
|
+
const headers = new TextDecoder().decode(bytes.subarray(offset + 2, headersEnd));
|
|
178
|
+
const disposition = /^content-disposition:\s*form-data;(.+)$/im.exec(headers)?.[1];
|
|
179
|
+
const name = disposition && /(?:^|;)\s*name="([^"]*)"/i.exec(disposition)?.[1];
|
|
180
|
+
const filename = disposition && /(?:^|;)\s*filename="([^"]*)"/i.exec(disposition)?.[1];
|
|
181
|
+
const end = find(delimiter, headersEnd + 4);
|
|
182
|
+
if (name === undefined || name === null || end < 0) throw new TypeError("Invalid multipart field");
|
|
183
|
+
const value = bytes.subarray(headersEnd + 4, end);
|
|
184
|
+
if (filename !== undefined && filename !== null) {
|
|
185
|
+
const type = /^content-type:\s*([^\r\n]+)/im.exec(headers)?.[1] ?? "application/octet-stream";
|
|
186
|
+
form.append(name, new File([value], filename, { type }));
|
|
187
|
+
} else form.append(name, new TextDecoder().decode(value));
|
|
188
|
+
offset = end + delimiter.length;
|
|
189
|
+
}
|
|
190
|
+
return form;
|
|
191
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Snapshot, SnapshotSource } from "ink";
|
|
2
|
+
import { callNative, onNativeMessage } from "ink/native";
|
|
3
|
+
|
|
4
|
+
export type ConnectionState = Readonly<{
|
|
5
|
+
status: "unknown" | "offline" | "connected";
|
|
6
|
+
transport?: "wifi" | "cellular" | "ethernet" | "vpn" | "other";
|
|
7
|
+
metered?: boolean;
|
|
8
|
+
}>;
|
|
9
|
+
let snapshot: Snapshot<ConnectionState> = { status: "loading" };
|
|
10
|
+
const listeners = new Set<() => void>();
|
|
11
|
+
function publish(data: ConnectionState) {
|
|
12
|
+
snapshot = { status: "ready", data: Object.freeze(data) };
|
|
13
|
+
for (const listener of listeners) listener();
|
|
14
|
+
}
|
|
15
|
+
function decode(value: unknown): ConnectionState {
|
|
16
|
+
if (!value || typeof value !== "object" || !("status" in value) || !["unknown", "offline", "connected"].includes(String(value.status))) throw new Error("Invalid connectivity snapshot");
|
|
17
|
+
return value as ConnectionState;
|
|
18
|
+
}
|
|
19
|
+
onNativeMessage("connectivity-changed", message => { publish(decode(message.data)); });
|
|
20
|
+
async function read(operation: string): Promise<ConnectionState> {
|
|
21
|
+
try {
|
|
22
|
+
const data = decode(JSON.parse(await callNative("connectivity", operation, {})));
|
|
23
|
+
publish(data);
|
|
24
|
+
return data;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
snapshot = { status: "error", error: error instanceof Error ? error : new Error(String(error)) };
|
|
27
|
+
for (const listener of listeners) listener();
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export const connectivity: SnapshotSource<ConnectionState> & { get(): Promise<ConnectionState> } = {
|
|
32
|
+
get: () => read("snapshot"),
|
|
33
|
+
getSnapshot: () => snapshot,
|
|
34
|
+
subscribe(listener) {
|
|
35
|
+
listeners.add(listener);
|
|
36
|
+
if (listeners.size === 1) void read("watch").catch(() => {});
|
|
37
|
+
return () => {
|
|
38
|
+
listeners.delete(listener);
|
|
39
|
+
if (listeners.size === 0) void callNative("connectivity", "unwatch", {}).catch(() => {});
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
package/src/downloads.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Snapshot, SnapshotSource } from "ink";
|
|
2
|
+
import { callNative } from "ink/native";
|
|
3
|
+
import { attachNativeController } from "ink/native/controller";
|
|
4
|
+
import type { FileRef } from "@ink/files";
|
|
5
|
+
|
|
6
|
+
export type DownloadState = {
|
|
7
|
+
id: string;
|
|
8
|
+
state: "queued" | "running" | "paused" | "completed" | "failed" | "cancelled";
|
|
9
|
+
received: number;
|
|
10
|
+
total: number | null;
|
|
11
|
+
file: FileRef | null;
|
|
12
|
+
error: string | null;
|
|
13
|
+
};
|
|
14
|
+
function decode(value: unknown): DownloadState {
|
|
15
|
+
const byteCount = (number: unknown) => typeof number === "number" && Number.isSafeInteger(number) && number >= 0;
|
|
16
|
+
if (typeof value !== "object" || value === null || !("id" in value) || typeof value.id !== "string"
|
|
17
|
+
|| !("state" in value) || !["queued", "running", "paused", "completed", "failed", "cancelled"].includes(String(value.state))
|
|
18
|
+
|| !("received" in value) || !byteCount(value.received)
|
|
19
|
+
|| !("total" in value) || (value.total !== null && !byteCount(value.total))
|
|
20
|
+
|| !("file" in value) || !("error" in value) || (value.error !== null && typeof value.error !== "string")) {
|
|
21
|
+
throw new Error("Invalid download state");
|
|
22
|
+
}
|
|
23
|
+
if (value.file !== null) {
|
|
24
|
+
const file = value.file;
|
|
25
|
+
if (typeof file !== "object" || Array.isArray(file)
|
|
26
|
+
|| !["id", "src", "name", "mimeType"].every(key => typeof Reflect.get(file, key) === "string")
|
|
27
|
+
|| !byteCount(Reflect.get(file, "size"))) throw new Error("Invalid downloaded file");
|
|
28
|
+
for (const key of ["width", "height", "duration"]) {
|
|
29
|
+
const number: unknown = Reflect.get(file, key);
|
|
30
|
+
if (number !== undefined && !byteCount(number)) throw new Error("Invalid downloaded media metadata");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (value.state === "completed" && value.file === null) throw new Error("Completed download has no file");
|
|
34
|
+
return value as DownloadState;
|
|
35
|
+
}
|
|
36
|
+
const sources = new Map<string, { source: SnapshotSource<DownloadState>; listeners: Set<() => void> }>();
|
|
37
|
+
const idleSourceLimit = 128;
|
|
38
|
+
function trimSources() {
|
|
39
|
+
let idle = 0;
|
|
40
|
+
for (const entry of sources.values()) if (!entry.listeners.size) idle++;
|
|
41
|
+
for (const [id, entry] of sources) {
|
|
42
|
+
if (idle <= idleSourceLimit) break;
|
|
43
|
+
if (!entry.listeners.size) { sources.delete(id); idle--; }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function observe(id: string): SnapshotSource<DownloadState> {
|
|
47
|
+
const existing = sources.get(id);
|
|
48
|
+
if (existing) {
|
|
49
|
+
sources.delete(id);
|
|
50
|
+
sources.set(id, existing);
|
|
51
|
+
return existing.source;
|
|
52
|
+
}
|
|
53
|
+
let snapshot: Snapshot<DownloadState> = { status: "loading" };
|
|
54
|
+
const listeners = new Set<() => void>();
|
|
55
|
+
let attachment: ReturnType<typeof attachNativeController> | undefined;
|
|
56
|
+
const publish = (value: Snapshot<DownloadState>) => { snapshot = value; for (const listener of listeners) listener(); };
|
|
57
|
+
const source: SnapshotSource<DownloadState> = {
|
|
58
|
+
getSnapshot: () => {
|
|
59
|
+
const current = sources.get(id)?.source;
|
|
60
|
+
return current && current !== source ? current.getSnapshot() : snapshot;
|
|
61
|
+
},
|
|
62
|
+
subscribe(listener) {
|
|
63
|
+
const current = sources.get(id)?.source;
|
|
64
|
+
if (current && current !== source) return current.subscribe(listener);
|
|
65
|
+
sources.set(id, entry);
|
|
66
|
+
listeners.add(listener);
|
|
67
|
+
trimSources();
|
|
68
|
+
if (!attachment) {
|
|
69
|
+
const current = attachNativeController("downloads", { id }, value => {
|
|
70
|
+
if (attachment !== current) return;
|
|
71
|
+
try { publish({ status: "ready", data: decode(value) }); }
|
|
72
|
+
catch (error) { publish({ status: "error", error: error instanceof Error ? error : new Error(String(error)) }); }
|
|
73
|
+
});
|
|
74
|
+
attachment = current;
|
|
75
|
+
void current.ready.catch(error => {
|
|
76
|
+
if (attachment === current) publish({ status: "error", error });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return () => {
|
|
80
|
+
listeners.delete(listener);
|
|
81
|
+
if (!listeners.size) {
|
|
82
|
+
const current = attachment;
|
|
83
|
+
attachment = undefined;
|
|
84
|
+
snapshot = { status: "loading" };
|
|
85
|
+
void current?.dispose().catch(error => console.error("Could not stop observing download", error));
|
|
86
|
+
trimSources();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
const entry = { source, listeners };
|
|
92
|
+
sources.set(id, entry);
|
|
93
|
+
trimSources();
|
|
94
|
+
return source;
|
|
95
|
+
}
|
|
96
|
+
const command = async (operation: string, id: string): Promise<void> => {
|
|
97
|
+
await callNative("downloads", operation, { id });
|
|
98
|
+
};
|
|
99
|
+
export const downloads = {
|
|
100
|
+
async enqueue(options: { key: string; url: string; name: string; network?: "connected" | "unmetered" }): Promise<DownloadState> {
|
|
101
|
+
return decode(JSON.parse(await callNative("downloads", "enqueue", options)));
|
|
102
|
+
},
|
|
103
|
+
async get(id: string): Promise<DownloadState> {
|
|
104
|
+
return decode(JSON.parse(await callNative("downloads", "get", { id })));
|
|
105
|
+
},
|
|
106
|
+
observe,
|
|
107
|
+
pause: (id: string) => command("pause", id),
|
|
108
|
+
resume: (id: string) => command("resume", id),
|
|
109
|
+
cancel: (id: string) => command("cancel", id),
|
|
110
|
+
remove: (id: string) => command("remove", id),
|
|
111
|
+
};
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { ReadableStream, type ReadableStreamDefaultReader } from "web-streams-polyfill";
|
|
2
|
+
import { Blob, FormData, multipart, parseFormData, nativeBlob, nativeBlobParts } from "./blob";
|
|
3
|
+
import { URL, URLSearchParams } from "whatwg-url";
|
|
4
|
+
import { requestHttp } from "./http";
|
|
5
|
+
import { callNative } from "ink/native";
|
|
6
|
+
|
|
7
|
+
const immutableHeaders = new WeakSet<Headers>();
|
|
8
|
+
const token = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
9
|
+
type HeaderInput = Headers | Iterable<readonly [string, string]> | Record<string, string>;
|
|
10
|
+
function headerName(value: string): string {
|
|
11
|
+
const name = String(value);
|
|
12
|
+
if (!token.test(name)) throw new TypeError("Invalid HTTP header name");
|
|
13
|
+
return name.toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
function headerValue(value: string): string {
|
|
16
|
+
const text = String(value).replace(/^[\t ]+|[\t ]+$/g, "");
|
|
17
|
+
if (/[\u0000\r\n\u0100-\uffff]/.test(text)) throw new TypeError("Invalid HTTP header value");
|
|
18
|
+
return text;
|
|
19
|
+
}
|
|
20
|
+
export class Headers implements Iterable<[string, string]> {
|
|
21
|
+
#values = new Map<string, string[]>();
|
|
22
|
+
constructor(input?: HeaderInput) {
|
|
23
|
+
if (input === undefined) return;
|
|
24
|
+
if (Symbol.iterator in input) {
|
|
25
|
+
for (const pair of input) {
|
|
26
|
+
if (pair.length !== 2) throw new TypeError("Headers require name/value pairs");
|
|
27
|
+
this.append(pair[0], pair[1]);
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
for (const [name, value] of Object.entries(input)) this.append(name, value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
#mutable() { if (immutableHeaders.has(this)) throw new TypeError("Headers are immutable"); }
|
|
34
|
+
append(name: string, value: string) {
|
|
35
|
+
this.#mutable();
|
|
36
|
+
const key = headerName(name);
|
|
37
|
+
const text = headerValue(value);
|
|
38
|
+
this.#values.set(key, [...(this.#values.get(key) ?? []), text]);
|
|
39
|
+
}
|
|
40
|
+
set(name: string, value: string) { this.#mutable(); this.#values.set(headerName(name), [headerValue(value)]); }
|
|
41
|
+
delete(name: string) { this.#mutable(); this.#values.delete(headerName(name)); }
|
|
42
|
+
get(name: string): string | null { return this.#values.get(headerName(name))?.join(", ") ?? null; }
|
|
43
|
+
has(name: string) { return this.#values.has(headerName(name)); }
|
|
44
|
+
getSetCookie() { return [...(this.#values.get("set-cookie") ?? [])]; }
|
|
45
|
+
*entries(): IterableIterator<[string, string]> {
|
|
46
|
+
for (const name of [...this.#values.keys()].sort()) {
|
|
47
|
+
if (name === "set-cookie") { for (const value of this.getSetCookie()) yield [name, value]; }
|
|
48
|
+
else yield [name, this.get(name) ?? ""];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
*keys() { for (const [name] of this) yield name; }
|
|
52
|
+
*values() { for (const [, value] of this) yield value; }
|
|
53
|
+
[Symbol.iterator]() { return this.entries(); }
|
|
54
|
+
forEach(callback: (value: string, name: string, headers: Headers) => void, thisArg?: unknown) {
|
|
55
|
+
for (const [name, value] of this) callback.call(thisArg, value, name, this);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type BodyInput = string | ArrayBuffer | ArrayBufferView | URLSearchParams | Blob | FormData | ReadableStream<Uint8Array>;
|
|
60
|
+
function bodyStream(input: BodyInput | null | undefined): ReadableStream<Uint8Array> | null {
|
|
61
|
+
if (input == null) return null;
|
|
62
|
+
if (input instanceof ReadableStream) return input;
|
|
63
|
+
if (input instanceof FormData) return multipart(input).stream();
|
|
64
|
+
if (input instanceof Blob) return input.stream();
|
|
65
|
+
return new Blob([input instanceof URLSearchParams ? String(input) : input]).stream();
|
|
66
|
+
}
|
|
67
|
+
class Body {
|
|
68
|
+
protected data: ReadableStream<Uint8Array> | null;
|
|
69
|
+
protected bodySignal?: AbortSignal;
|
|
70
|
+
protected nativeBlob?: Blob;
|
|
71
|
+
#disturbed = new WeakSet<ReadableStream<Uint8Array>>();
|
|
72
|
+
protected get contentType(): string { return ""; }
|
|
73
|
+
constructor(input?: BodyInput | null, signal?: AbortSignal) { this.data = this.track(bodyStream(input)); this.bodySignal = signal; this.nativeBlob = input instanceof Blob ? input : undefined; }
|
|
74
|
+
protected track(source: ReadableStream<Uint8Array> | null): ReadableStream<Uint8Array> | null {
|
|
75
|
+
if (!source) return null;
|
|
76
|
+
const disturbed = this.#disturbed;
|
|
77
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
78
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
79
|
+
async pull(controller) {
|
|
80
|
+
disturbed.add(stream);
|
|
81
|
+
reader ??= source.getReader();
|
|
82
|
+
try {
|
|
83
|
+
const chunk = await reader.read();
|
|
84
|
+
if (chunk.done) { reader.releaseLock(); controller.close(); }
|
|
85
|
+
else controller.enqueue(chunk.value);
|
|
86
|
+
} catch (error) { reader.releaseLock(); controller.error(error); }
|
|
87
|
+
},
|
|
88
|
+
cancel(reason) { disturbed.add(stream); return reader ? reader.cancel(reason) : source.cancel(reason); },
|
|
89
|
+
}, { highWaterMark: 0 });
|
|
90
|
+
return stream;
|
|
91
|
+
}
|
|
92
|
+
get body() { return this.data; }
|
|
93
|
+
get bodyUsed() { return this.data !== null && this.#disturbed.has(this.data); }
|
|
94
|
+
protected copyBody() {
|
|
95
|
+
if (this.bodyUsed || this.data?.locked) throw new TypeError("HTTP body has already been consumed");
|
|
96
|
+
if (!this.data) return null;
|
|
97
|
+
const [original, copy] = this.data.tee();
|
|
98
|
+
this.data = this.track(original);
|
|
99
|
+
return copy;
|
|
100
|
+
}
|
|
101
|
+
protected transferBody() {
|
|
102
|
+
if (this.bodyUsed || this.data?.locked) throw new TypeError("HTTP body has already been consumed");
|
|
103
|
+
if (this.data !== null) this.#disturbed.add(this.data);
|
|
104
|
+
}
|
|
105
|
+
protected takeBody(): ReadableStream<Uint8Array> | null {
|
|
106
|
+
this.transferBody();
|
|
107
|
+
if (!this.data) return null;
|
|
108
|
+
const reader = this.data.getReader();
|
|
109
|
+
return new ReadableStream<Uint8Array>({
|
|
110
|
+
async pull(controller) {
|
|
111
|
+
try {
|
|
112
|
+
const chunk = await reader.read();
|
|
113
|
+
if (chunk.done) { reader.releaseLock(); controller.close(); }
|
|
114
|
+
else controller.enqueue(chunk.value);
|
|
115
|
+
} catch (error) { reader.releaseLock(); controller.error(error); }
|
|
116
|
+
},
|
|
117
|
+
async cancel(reason) { try { await reader.cancel(reason); } finally { reader.releaseLock(); } },
|
|
118
|
+
}, { highWaterMark: 0 });
|
|
119
|
+
}
|
|
120
|
+
async bytes(): Promise<Uint8Array<ArrayBuffer>> {
|
|
121
|
+
this.bodySignal?.throwIfAborted();
|
|
122
|
+
this.transferBody();
|
|
123
|
+
if (!this.data) return new Uint8Array();
|
|
124
|
+
const reader = this.data.getReader();
|
|
125
|
+
const chunks: Uint8Array[] = [];
|
|
126
|
+
let size = 0;
|
|
127
|
+
try {
|
|
128
|
+
for (;;) {
|
|
129
|
+
this.bodySignal?.throwIfAborted();
|
|
130
|
+
const { value, done } = await reader.read();
|
|
131
|
+
if (done) break;
|
|
132
|
+
size += value.byteLength;
|
|
133
|
+
if (size > 16 * 1024 * 1024) throw new RangeError("Buffered HTTP body exceeds 16 MiB; consume response.body incrementally");
|
|
134
|
+
chunks.push(value);
|
|
135
|
+
}
|
|
136
|
+
} catch (error) { await reader.cancel(error).catch(() => {}); throw error; }
|
|
137
|
+
finally { reader.releaseLock(); }
|
|
138
|
+
const bytes = new Uint8Array(size);
|
|
139
|
+
let offset = 0;
|
|
140
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
141
|
+
return bytes;
|
|
142
|
+
}
|
|
143
|
+
async arrayBuffer(): Promise<ArrayBuffer> { return (await this.bytes()).buffer; }
|
|
144
|
+
async text(): Promise<string> { return new TextDecoder().decode(await this.bytes()); }
|
|
145
|
+
async json(): Promise<unknown> { return JSON.parse(await this.text()); }
|
|
146
|
+
async blob() {
|
|
147
|
+
if (this.nativeBlob) { this.bodySignal?.throwIfAborted(); this.transferBody(); return this.nativeBlob; }
|
|
148
|
+
return new Blob([await this.bytes()], { type: this.contentType });
|
|
149
|
+
}
|
|
150
|
+
async formData() { return parseFormData(await this.bytes(), this.contentType); }
|
|
151
|
+
}
|
|
152
|
+
function setContentType(headers: Headers, body: BodyInput | null | undefined) {
|
|
153
|
+
if (headers.has("content-type")) return;
|
|
154
|
+
if (typeof body === "string") headers.set("content-type", "text/plain;charset=UTF-8");
|
|
155
|
+
else if (body instanceof Blob && body.type) headers.set("content-type", body.type);
|
|
156
|
+
else if (body instanceof URLSearchParams) headers.set("content-type", "application/x-www-form-urlencoded;charset=UTF-8");
|
|
157
|
+
}
|
|
158
|
+
type Init = {
|
|
159
|
+
method?: string; headers?: HeaderInput; body?: BodyInput | null;
|
|
160
|
+
signal?: AbortSignal | null; redirect?: "follow" | "error" | "manual";
|
|
161
|
+
credentials?: "omit" | "same-origin" | "include";
|
|
162
|
+
};
|
|
163
|
+
const requestBodies = new WeakMap<Request, Blob | undefined>();
|
|
164
|
+
export class Request extends Body {
|
|
165
|
+
readonly url: string;
|
|
166
|
+
readonly method: string;
|
|
167
|
+
readonly headers: Headers;
|
|
168
|
+
readonly signal: AbortSignal;
|
|
169
|
+
readonly redirect: "follow" | "error" | "manual";
|
|
170
|
+
readonly credentials: "omit" | "same-origin" | "include";
|
|
171
|
+
constructor(input: string | URL | Request, init: Init = {}) {
|
|
172
|
+
const original = input instanceof Request ? input : undefined;
|
|
173
|
+
const supplied = init.body ?? original?.body;
|
|
174
|
+
const body = supplied instanceof FormData ? multipart(supplied) : supplied;
|
|
175
|
+
const signal = init.signal ?? original?.signal ?? new AbortController().signal;
|
|
176
|
+
super(body, signal);
|
|
177
|
+
requestBodies.set(this, body instanceof ReadableStream ? (init.body == null && original ? requestBodies.get(original) : undefined)
|
|
178
|
+
: body == null ? undefined : body instanceof Blob ? body : new Blob([body instanceof URLSearchParams ? String(body) : body]));
|
|
179
|
+
const url = new URL(original?.url ?? String(input));
|
|
180
|
+
if (url.username || url.password) throw new TypeError("HTTP URLs cannot contain credentials");
|
|
181
|
+
this.url = url.href;
|
|
182
|
+
let method = String(init.method ?? original?.method ?? "GET");
|
|
183
|
+
if (!token.test(method) || ["CONNECT", "TRACE", "TRACK"].includes(method.toUpperCase())) throw new TypeError("Invalid HTTP method");
|
|
184
|
+
if (["DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT"].includes(method.toUpperCase())) method = method.toUpperCase();
|
|
185
|
+
this.method = method;
|
|
186
|
+
if ((method === "GET" || method === "HEAD") && this.data !== null) throw new TypeError("GET and HEAD requests cannot have a body");
|
|
187
|
+
this.headers = new Headers(init.headers ?? original?.headers);
|
|
188
|
+
setContentType(this.headers, body);
|
|
189
|
+
this.signal = signal;
|
|
190
|
+
this.redirect = init.redirect ?? original?.redirect ?? "follow";
|
|
191
|
+
if (!["follow", "error", "manual"].includes(this.redirect)) throw new TypeError("Invalid redirect mode");
|
|
192
|
+
this.credentials = init.credentials ?? original?.credentials ?? "same-origin";
|
|
193
|
+
if (!["omit", "same-origin", "include"].includes(this.credentials)) throw new TypeError("Invalid credentials mode");
|
|
194
|
+
if (init.body == null && original) this.data = this.track(original.takeBody());
|
|
195
|
+
}
|
|
196
|
+
protected get contentType() { return this.headers.get("content-type") ?? ""; }
|
|
197
|
+
clone() {
|
|
198
|
+
const copy = new Request(this, { body: this.copyBody() });
|
|
199
|
+
requestBodies.set(copy, requestBodies.get(this));
|
|
200
|
+
return copy;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
type ResponseOptions = { status?: number; statusText?: string; headers?: HeaderInput; url?: string; redirected?: boolean; signal?: AbortSignal };
|
|
204
|
+
export class Response extends Body {
|
|
205
|
+
readonly status: number;
|
|
206
|
+
readonly statusText: string;
|
|
207
|
+
readonly headers: Headers;
|
|
208
|
+
readonly url: string;
|
|
209
|
+
readonly redirected: boolean;
|
|
210
|
+
readonly type = "default";
|
|
211
|
+
constructor(body?: BodyInput | null, init: ResponseOptions = {}) {
|
|
212
|
+
body = body instanceof FormData ? multipart(body) : body;
|
|
213
|
+
super(body, init.signal);
|
|
214
|
+
this.status = init.status ?? 200;
|
|
215
|
+
if (!Number.isInteger(this.status) || this.status < 200 || this.status > 599) throw new RangeError("Invalid HTTP response status");
|
|
216
|
+
if ([204, 205, 304].includes(this.status) && this.data !== null) throw new TypeError("This HTTP status cannot have a body");
|
|
217
|
+
this.statusText = init.statusText ?? "";
|
|
218
|
+
if (/[\r\n\u0100-\uffff]/.test(this.statusText)) throw new TypeError("Invalid HTTP status text");
|
|
219
|
+
this.headers = new Headers(init.headers);
|
|
220
|
+
setContentType(this.headers, body);
|
|
221
|
+
this.url = init.url ?? "";
|
|
222
|
+
this.redirected = init.redirected ?? false;
|
|
223
|
+
}
|
|
224
|
+
protected get contentType() { return this.headers.get("content-type") ?? ""; }
|
|
225
|
+
get ok() { return this.status >= 200 && this.status <= 299; }
|
|
226
|
+
clone() {
|
|
227
|
+
const result = new Response(this.copyBody(), { status: this.status, statusText: this.statusText, headers: this.headers, url: this.url, redirected: this.redirected, signal: this.bodySignal });
|
|
228
|
+
Object.defineProperty(result, "type", { value: this.type });
|
|
229
|
+
result.nativeBlob = this.nativeBlob;
|
|
230
|
+
if (immutableHeaders.has(this.headers)) immutableHeaders.add(result.headers);
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
static json(value: unknown, init: ResponseOptions = {}) {
|
|
234
|
+
const body = JSON.stringify(value);
|
|
235
|
+
if (body === undefined) throw new TypeError("Value cannot be serialised as JSON");
|
|
236
|
+
const headers = new Headers(init.headers);
|
|
237
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
238
|
+
return new Response(body, { ...init, headers });
|
|
239
|
+
}
|
|
240
|
+
static redirect(url: string, status = 302) {
|
|
241
|
+
if (![301, 302, 303, 307, 308].includes(status)) throw new RangeError("Invalid redirect status");
|
|
242
|
+
const response = new Response(null, { status, headers: { location: new URL(url).href } });
|
|
243
|
+
immutableHeaders.add(response.headers);
|
|
244
|
+
return response;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function fetch(input: string | URL | Request, init?: Init): Promise<Response> {
|
|
249
|
+
const request = new Request(input, init);
|
|
250
|
+
if (request.url.startsWith("ink-file://")) {
|
|
251
|
+
request.signal.throwIfAborted();
|
|
252
|
+
if (request.method !== "GET" && request.method !== "HEAD") throw new TypeError("Managed files are read-only");
|
|
253
|
+
const file = JSON.parse(await callNative("files", "open", { id: request.url.slice("ink-file://".length) }, { signal: request.signal }));
|
|
254
|
+
if (!file) throw new TypeError("Managed file has been removed");
|
|
255
|
+
return new Response(request.method === "HEAD" ? null : nativeBlob(file.src, file.size, file.mimeType), {
|
|
256
|
+
url: request.url, headers: { "content-type": file.mimeType, "content-length": String(file.size) }, signal: request.signal,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
let url = new URL(request.url);
|
|
260
|
+
let method = request.method;
|
|
261
|
+
const headers = new Headers(request.headers);
|
|
262
|
+
const replay = requestBodies.get(request);
|
|
263
|
+
let body = request.body;
|
|
264
|
+
for (let redirects = 0; ; redirects++) {
|
|
265
|
+
request.signal.throwIfAborted();
|
|
266
|
+
if (!["https:", "http:", "file:"].includes(url.protocol)) throw new TypeError("Unsupported network protocol");
|
|
267
|
+
let result;
|
|
268
|
+
try {
|
|
269
|
+
result = await requestHttp({ url: url.href, method, headers: Object.fromEntries(headers), nativeParts: body && replay ? nativeBlobParts(replay) : undefined }, body, request.signal);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
request.signal.throwIfAborted();
|
|
272
|
+
throw new TypeError("Network request failed", { cause: error });
|
|
273
|
+
}
|
|
274
|
+
request.signal.throwIfAborted();
|
|
275
|
+
const responseHeaders = new Headers(result.headers);
|
|
276
|
+
const location = responseHeaders.get("location");
|
|
277
|
+
if ([301, 302, 303, 307, 308].includes(result.status) && location !== null && request.redirect !== "manual") {
|
|
278
|
+
await result.body?.cancel();
|
|
279
|
+
if (request.redirect === "error" || redirects >= 20) throw new TypeError("HTTP redirect was not allowed");
|
|
280
|
+
const next = new URL(location, url);
|
|
281
|
+
if (next.username || next.password) throw new TypeError("HTTP redirect contains credentials");
|
|
282
|
+
if (next.origin !== url.origin) {
|
|
283
|
+
for (const name of ["authorization", "proxy-authorization", "cookie", "cookie2", "host"]) headers.delete(name);
|
|
284
|
+
}
|
|
285
|
+
if (([301, 302].includes(result.status) && method === "POST") || (result.status === 303 && method !== "GET" && method !== "HEAD")) {
|
|
286
|
+
method = "GET";
|
|
287
|
+
body = null;
|
|
288
|
+
for (const name of ["content-type", "content-length", "content-encoding", "content-language", "content-location"]) headers.delete(name);
|
|
289
|
+
}
|
|
290
|
+
if (body !== null) {
|
|
291
|
+
body = replay?.stream() ?? null;
|
|
292
|
+
if (body === null) throw new TypeError("Cannot replay a streaming upload after an HTTP redirect");
|
|
293
|
+
}
|
|
294
|
+
url = next;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const responseBody = method === "HEAD" || [204, 205, 304].includes(result.status) ? null : result.body;
|
|
298
|
+
if (responseBody === null) await result.body?.cancel();
|
|
299
|
+
const response = new Response(responseBody, { status: result.status, statusText: result.statusText, headers: responseHeaders, url: result.url, redirected: redirects > 0, signal: request.signal });
|
|
300
|
+
Object.defineProperty(response, "type", { value: "basic" });
|
|
301
|
+
immutableHeaders.add(response.headers);
|
|
302
|
+
return response;
|
|
303
|
+
}
|
|
304
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { ReadableStream } from "web-streams-polyfill";
|
|
2
|
+
import { callNative, callNativeBytes, NativeError } from "ink/native";
|
|
3
|
+
|
|
4
|
+
interface HttpRequest { url: string; method: string; headers: Readonly<Record<string, string>>; nativeParts?: Iterable<{ bytes: Uint8Array } | { src: string; offset: number; size: number }> }
|
|
5
|
+
interface HttpResponse { status: number; statusText: string; url: string; headers: [string, string][]; body: ReadableStream<Uint8Array> | null }
|
|
6
|
+
|
|
7
|
+
export async function requestHttp(request: HttpRequest, body: ReadableStream<Uint8Array> | null, signal: AbortSignal): Promise<HttpResponse> {
|
|
8
|
+
let upload: string | undefined;
|
|
9
|
+
if (body) {
|
|
10
|
+
upload = await callNative("network", "stream-upload-open", { managed: request.nativeParts !== undefined }, { signal });
|
|
11
|
+
const reader = request.nativeParts ? undefined : body.getReader();
|
|
12
|
+
let rejectAbort: (reason: unknown) => void = () => {};
|
|
13
|
+
const aborted = new Promise<never>((_, reject) => { rejectAbort = reject; });
|
|
14
|
+
const abort = () => {
|
|
15
|
+
if (reader) rejectAbort(signal.reason);
|
|
16
|
+
void reader?.cancel(signal.reason).catch(() => {});
|
|
17
|
+
};
|
|
18
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
19
|
+
if (signal.aborted) abort();
|
|
20
|
+
try {
|
|
21
|
+
if (request.nativeParts) {
|
|
22
|
+
for (const part of request.nativeParts) {
|
|
23
|
+
if ("bytes" in part) await callNativeBytes("network", "stream-upload-write", { upload }, { signal, bytes: part.bytes });
|
|
24
|
+
else await callNative("network", "stream-upload-file", { upload, src: part.src, offset: part.offset, size: part.size }, { signal });
|
|
25
|
+
}
|
|
26
|
+
} else if (reader) {
|
|
27
|
+
for (;;) {
|
|
28
|
+
const { value, done } = await Promise.race([reader.read(), aborted]);
|
|
29
|
+
if (signal.aborted) throw signal.reason;
|
|
30
|
+
if (done) break;
|
|
31
|
+
if (!(value instanceof Uint8Array)) throw new TypeError("Upload streams must contain Uint8Array chunks");
|
|
32
|
+
for (let offset = 0; offset < value.length; offset += 32 * 1024) {
|
|
33
|
+
await callNativeBytes("network", "stream-upload-write", { upload }, { signal, bytes: value.subarray(offset, offset + 32 * 1024) });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
void reader?.cancel(error).catch(() => {});
|
|
39
|
+
void callNative("network", "stream-upload-close", { upload }).catch(() => {});
|
|
40
|
+
throw error;
|
|
41
|
+
} finally {
|
|
42
|
+
signal.removeEventListener("abort", abort);
|
|
43
|
+
reader?.releaseLock();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let value: unknown;
|
|
47
|
+
try { value = JSON.parse(await callNative("network", "stream-open", { url: request.url, method: request.method, headers: request.headers, upload }, { signal })); }
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (upload) await callNative("network", "stream-upload-close", { upload }).catch(() => {});
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
if (typeof value !== "object" || value === null
|
|
53
|
+
|| !("status" in value) || typeof value.status !== "number"
|
|
54
|
+
|| !("statusText" in value) || typeof value.statusText !== "string"
|
|
55
|
+
|| !("url" in value) || typeof value.url !== "string"
|
|
56
|
+
|| !("headers" in value) || !Array.isArray(value.headers)
|
|
57
|
+
|| !("stream" in value) || typeof value.stream !== "string") throw new NativeError("protocol", "Invalid HTTP response");
|
|
58
|
+
const headers = value.headers.map((entry: unknown): [string, string] => {
|
|
59
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string" || typeof entry[1] !== "string") throw new NativeError("protocol", "Invalid HTTP response header");
|
|
60
|
+
return [entry[0], entry[1]];
|
|
61
|
+
});
|
|
62
|
+
const stream = value.stream;
|
|
63
|
+
let closed = false;
|
|
64
|
+
const close = async () => {
|
|
65
|
+
if (closed) return;
|
|
66
|
+
closed = true;
|
|
67
|
+
signal.removeEventListener("abort", abort);
|
|
68
|
+
await callNative("network", "stream-close", { stream });
|
|
69
|
+
};
|
|
70
|
+
let abort = () => {};
|
|
71
|
+
const responseBody = new ReadableStream<Uint8Array>({
|
|
72
|
+
start(controller) {
|
|
73
|
+
abort = () => { controller.error(signal.reason); void close().catch(() => {}); };
|
|
74
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
75
|
+
if (signal.aborted) abort();
|
|
76
|
+
},
|
|
77
|
+
async pull(controller) {
|
|
78
|
+
try {
|
|
79
|
+
const result = await callNativeBytes("network", "stream-read-bytes", { stream }, { signal });
|
|
80
|
+
const chunk: unknown = JSON.parse(result.value);
|
|
81
|
+
if (typeof chunk !== "object" || chunk === null || !("done" in chunk) || typeof chunk.done !== "boolean") throw new NativeError("protocol", "Invalid HTTP stream chunk");
|
|
82
|
+
if (chunk.done) { await close(); controller.close(); }
|
|
83
|
+
else controller.enqueue(result.bytes);
|
|
84
|
+
} catch (error) { await close().catch(() => {}); controller.error(error); }
|
|
85
|
+
},
|
|
86
|
+
cancel: close,
|
|
87
|
+
}, { highWaterMark: 0 });
|
|
88
|
+
return { status: value.status, statusText: value.statusText, url: value.url, headers, body: responseBody };
|
|
89
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/// <reference path="../runtime.d.ts" />
|
|
2
|
+
export {};
|
|
3
|
+
|
|
4
|
+
// The bundler keeps this synchronous dependency uninitialised until first access.
|
|
5
|
+
declare function require(path: "./web-globals"): typeof import("./web-globals");
|
|
6
|
+
|
|
7
|
+
const names = [
|
|
8
|
+
"Blob", "File", "FormData", "ReadableStream", "WritableStream", "TransformStream",
|
|
9
|
+
"ByteLengthQueuingStrategy", "CountQueuingStrategy", "WebSocket", "MessageEvent",
|
|
10
|
+
"CloseEvent", "URL", "URLSearchParams", "fetch", "Headers", "Request", "Response",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
for (const name of names) {
|
|
14
|
+
Object.defineProperty(globalThis, name, {
|
|
15
|
+
configurable: true,
|
|
16
|
+
get() {
|
|
17
|
+
const value = require("./web-globals")[name];
|
|
18
|
+
Object.defineProperty(globalThis, name, { value, writable: true, configurable: true });
|
|
19
|
+
return value;
|
|
20
|
+
},
|
|
21
|
+
set(value) {
|
|
22
|
+
Object.defineProperty(globalThis, name, { value, writable: true, configurable: true });
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ReadableStream, WritableStream, TransformStream, ByteLengthQueuingStrategy, CountQueuingStrategy } from "web-streams-polyfill";
|
|
2
|
+
export { Blob, File, FormData } from "./blob";
|
|
3
|
+
export { WebSocket, MessageEvent, CloseEvent } from "./websocket";
|
|
4
|
+
export { URL, URLSearchParams } from "whatwg-url";
|
|
5
|
+
export { fetch, Headers, Request, Response } from "./fetch";
|
package/src/websocket.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { URL } from "whatwg-url";
|
|
2
|
+
import { Blob } from "./blob";
|
|
3
|
+
import { callNative, callNativeBytes } from "ink/native";
|
|
4
|
+
|
|
5
|
+
export class MessageEvent extends Event {
|
|
6
|
+
readonly data: unknown;
|
|
7
|
+
readonly origin: string;
|
|
8
|
+
readonly lastEventId: string;
|
|
9
|
+
readonly source = null;
|
|
10
|
+
readonly ports: readonly unknown[] = [];
|
|
11
|
+
constructor(type: string, options: EventInit & { data?: unknown; origin?: string; lastEventId?: string } = {}) {
|
|
12
|
+
super(type, options);
|
|
13
|
+
this.data = options.data ?? null;
|
|
14
|
+
this.origin = options.origin ?? "";
|
|
15
|
+
this.lastEventId = options.lastEventId ?? "";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class CloseEvent extends Event {
|
|
19
|
+
readonly code: number;
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
readonly wasClean: boolean;
|
|
22
|
+
constructor(type: string, options: EventInit & { code?: number; reason?: string; wasClean?: boolean } = {}) {
|
|
23
|
+
super(type, options);
|
|
24
|
+
this.code = options.code ?? 0;
|
|
25
|
+
this.reason = options.reason ?? "";
|
|
26
|
+
this.wasClean = options.wasClean ?? false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class WebSocket extends EventTarget {
|
|
31
|
+
static readonly CONNECTING = 0;
|
|
32
|
+
static readonly OPEN = 1;
|
|
33
|
+
static readonly CLOSING = 2;
|
|
34
|
+
static readonly CLOSED = 3;
|
|
35
|
+
readonly CONNECTING = 0;
|
|
36
|
+
readonly OPEN = 1;
|
|
37
|
+
readonly CLOSING = 2;
|
|
38
|
+
readonly CLOSED = 3;
|
|
39
|
+
readonly url: string;
|
|
40
|
+
readonly extensions = "";
|
|
41
|
+
protocol = "";
|
|
42
|
+
binaryType: "blob" | "arraybuffer" = "blob";
|
|
43
|
+
readyState = WebSocket.CONNECTING;
|
|
44
|
+
onopen: ((event: Event) => void) | null = null;
|
|
45
|
+
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
46
|
+
onerror: ((event: Event) => void) | null = null;
|
|
47
|
+
onclose: ((event: CloseEvent) => void) | null = null;
|
|
48
|
+
#socket: Promise<string>;
|
|
49
|
+
#outgoing = Promise.resolve();
|
|
50
|
+
#queued = 0;
|
|
51
|
+
#nativeQueued = 0;
|
|
52
|
+
constructor(url: string | URL, protocols: string | string[] = []) {
|
|
53
|
+
super();
|
|
54
|
+
const parsed = new URL(String(url));
|
|
55
|
+
if (parsed.protocol === "https:") parsed.protocol = "wss:";
|
|
56
|
+
if ((parsed.protocol !== "wss:" && !(parsed.protocol === "ws:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname))) || parsed.hash || parsed.username || parsed.password) throw new DOMException("WebSocket requires a WSS URL without credentials or fragments", "SyntaxError");
|
|
57
|
+
const names = typeof protocols === "string" ? [protocols] : protocols;
|
|
58
|
+
if (new Set(names).size !== names.length || names.some(name => !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name))) throw new DOMException("Invalid WebSocket protocols", "SyntaxError");
|
|
59
|
+
this.url = parsed.href;
|
|
60
|
+
this.#socket = callNative("network", "socket-open", { url: this.url, protocols: names });
|
|
61
|
+
void this.#receive();
|
|
62
|
+
}
|
|
63
|
+
get bufferedAmount() { return this.#queued + this.#nativeQueued; }
|
|
64
|
+
#dispatch(type: string, properties: Record<string, unknown> = {}) {
|
|
65
|
+
const event = type === "message" ? new MessageEvent(type, { data: properties.data, origin: String(properties.origin ?? "") })
|
|
66
|
+
: type === "close" ? new CloseEvent(type, { code: Number(properties.code), reason: String(properties.reason), wasClean: properties.wasClean === true })
|
|
67
|
+
: new Event(type);
|
|
68
|
+
if (type !== "message" && type !== "close") Object.defineProperties(event, Object.fromEntries(Object.entries(properties).map(([key, value]) => [key, { value }])));
|
|
69
|
+
this.dispatchEvent(event);
|
|
70
|
+
try {
|
|
71
|
+
if (type === "open") this.onopen?.(event);
|
|
72
|
+
else if (type === "error") this.onerror?.(event);
|
|
73
|
+
else if (event instanceof MessageEvent) this.onmessage?.(event);
|
|
74
|
+
else if (event instanceof CloseEvent) this.onclose?.(event);
|
|
75
|
+
} catch (error) { console.error(error); }
|
|
76
|
+
}
|
|
77
|
+
async #receive() {
|
|
78
|
+
try {
|
|
79
|
+
const socket = await this.#socket;
|
|
80
|
+
while (this.readyState !== WebSocket.CLOSED) {
|
|
81
|
+
const result = await callNativeBytes("network", "socket-read-bytes", { socket });
|
|
82
|
+
const batch: { events: Record<string, unknown>[]; bufferedAmount: number } = JSON.parse(result.value);
|
|
83
|
+
this.#nativeQueued = batch.bufferedAmount;
|
|
84
|
+
for (const event of batch.events) {
|
|
85
|
+
if (event.type === "open") {
|
|
86
|
+
if (this.readyState !== WebSocket.CONNECTING) continue;
|
|
87
|
+
this.readyState = WebSocket.OPEN;
|
|
88
|
+
this.protocol = String(event.protocol);
|
|
89
|
+
this.#dispatch("open");
|
|
90
|
+
} else if (event.type === "message" && this.readyState === WebSocket.OPEN) {
|
|
91
|
+
let data: string | Blob | ArrayBuffer;
|
|
92
|
+
if (typeof event.text === "string") data = event.text;
|
|
93
|
+
else {
|
|
94
|
+
const offset = Number(event.offset), length = Number(event.length);
|
|
95
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0 || offset + length > result.bytes.length) throw new TypeError("Invalid WebSocket binary range");
|
|
96
|
+
const bytes = result.bytes.subarray(offset, offset + length);
|
|
97
|
+
data = this.binaryType === "arraybuffer" ? new Uint8Array(bytes).buffer : new Blob([bytes]);
|
|
98
|
+
}
|
|
99
|
+
this.#dispatch("message", { data, origin: new URL(this.url).origin, lastEventId: "", ports: [] });
|
|
100
|
+
} else if (event.type === "error") this.#dispatch("error", { message: event.message });
|
|
101
|
+
else if (event.type === "close") {
|
|
102
|
+
this.readyState = WebSocket.CLOSED;
|
|
103
|
+
this.#dispatch("close", { code: event.code, reason: event.reason, wasClean: event.wasClean });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (this.readyState !== WebSocket.CLOSED) {
|
|
109
|
+
this.readyState = WebSocket.CLOSED;
|
|
110
|
+
this.#dispatch("error", { error });
|
|
111
|
+
this.#dispatch("close", { code: 1006, reason: "", wasClean: false });
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
await this.#socket.then(socket => callNative("network", "socket-dispose", { socket })).catch(() => {});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
send(data: string | ArrayBuffer | ArrayBufferView | Blob) {
|
|
118
|
+
if (this.readyState === WebSocket.CONNECTING) throw new DOMException("WebSocket is connecting", "InvalidStateError");
|
|
119
|
+
const blob = data instanceof Blob ? data : new Blob([data]);
|
|
120
|
+
if (blob.size > 256 * 1024 || this.bufferedAmount + blob.size > 512 * 1024) throw new RangeError("WebSocket send queue exceeds its limit");
|
|
121
|
+
this.#queued += blob.size;
|
|
122
|
+
if (this.readyState !== WebSocket.OPEN) return;
|
|
123
|
+
this.#outgoing = this.#outgoing.then(async () => {
|
|
124
|
+
try {
|
|
125
|
+
this.#nativeQueued = Number((await callNativeBytes("network", "socket-send", { socket: await this.#socket, text: typeof data === "string" }, { bytes: await blob.bytes() })).value);
|
|
126
|
+
} finally { this.#queued -= blob.size; }
|
|
127
|
+
}).catch(error => { this.#dispatch("error", { error }); this.close(); });
|
|
128
|
+
}
|
|
129
|
+
close(code = 1000, reason = "") {
|
|
130
|
+
if (code !== 1000 && (code < 3000 || code > 4999 || !Number.isInteger(code))) throw new DOMException("Invalid WebSocket close code", "InvalidAccessError");
|
|
131
|
+
if (new TextEncoder().encode(reason).length > 123) throw new DOMException("WebSocket close reason exceeds 123 bytes", "SyntaxError");
|
|
132
|
+
if (this.readyState >= WebSocket.CLOSING) return;
|
|
133
|
+
this.readyState = WebSocket.CLOSING;
|
|
134
|
+
void this.#outgoing.then(async () => callNative("network", "socket-close", { socket: await this.#socket, code, reason })).catch(() => {});
|
|
135
|
+
}
|
|
136
|
+
}
|
package/README.md
DELETED