@learncard/vc-plugin 1.5.13 → 1.6.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.
@@ -0,0 +1,159 @@
1
+ export type PinnedAddress = { address: string; family: 4 | 6 };
2
+
3
+ type NodeLookupCallback = (
4
+ error: Error | null,
5
+ address: string | Array<PinnedAddress>,
6
+ family?: number
7
+ ) => void;
8
+
9
+ type NodeResponse = {
10
+ statusCode?: number;
11
+ statusMessage?: string;
12
+ headers: Record<string, string | string[] | undefined>;
13
+ on: (event: string, listener: (...args: unknown[]) => void) => void;
14
+ destroy: (error?: Error) => void;
15
+ };
16
+
17
+ type NodeRequest = {
18
+ on: (event: string, listener: (error: Error) => void) => void;
19
+ end: () => void;
20
+ };
21
+
22
+ type NodeHttpClient = {
23
+ request: (
24
+ url: URL,
25
+ options: Record<string, unknown>,
26
+ onResponse: (response: NodeResponse) => void
27
+ ) => NodeRequest;
28
+ };
29
+
30
+ const isNodeRuntime = typeof process !== 'undefined' && !!process.versions?.node;
31
+
32
+ const toResponseHeaders = (headers: Record<string, string | string[] | undefined>): Headers => {
33
+ const result = new Headers();
34
+
35
+ for (const [name, value] of Object.entries(headers)) {
36
+ if (Array.isArray(value)) value.forEach(entry => result.append(name, entry));
37
+ else if (value !== undefined) result.append(name, value);
38
+ }
39
+
40
+ return result;
41
+ };
42
+
43
+ /**
44
+ * Fetches through an already-validated IP address in Node without changing the URL
45
+ * hostname. Keeping the original URL lets Node perform SNI and certificate hostname
46
+ * verification against the refresh endpoint while the custom lookup prevents a
47
+ * second, potentially rebound DNS answer from selecting the socket destination.
48
+ * Browsers do not expose connection-level DNS controls and therefore use native fetch.
49
+ */
50
+ export const fetchWithPinnedAddress = async (
51
+ url: URL,
52
+ init: RequestInit,
53
+ pinnedAddress?: PinnedAddress
54
+ ): Promise<Response> => {
55
+ if (!isNodeRuntime || !pinnedAddress) return globalThis.fetch(url.href, init);
56
+
57
+ const specifier = url.protocol === 'https:' ? 'node:https' : 'node:http';
58
+ const client = (await import(/* @vite-ignore */ specifier)) as unknown as NodeHttpClient;
59
+
60
+ return new Promise<Response>((resolve, reject) => {
61
+ const lookup = (
62
+ _hostname: string,
63
+ options: { all?: boolean },
64
+ callback: NodeLookupCallback
65
+ ): void => {
66
+ if (options?.all) callback(null, [pinnedAddress]);
67
+ else callback(null, pinnedAddress.address, pinnedAddress.family);
68
+ };
69
+
70
+ let request: NodeRequest;
71
+
72
+ try {
73
+ const headers = Object.fromEntries(new Headers(init.headers).entries());
74
+
75
+ headers['accept-encoding'] = 'identity';
76
+
77
+ request = client.request(
78
+ url,
79
+ {
80
+ method: init.method,
81
+ headers,
82
+ signal: init.signal,
83
+ lookup,
84
+ // A shared agent could reuse a socket created for a different DNS
85
+ // answer and bypass this request's validated pin.
86
+ agent: false,
87
+ ...(url.protocol === 'https:' ? { servername: url.hostname } : {}),
88
+ },
89
+ nodeResponse => {
90
+ try {
91
+ const status = nodeResponse.statusCode;
92
+ if (
93
+ status === undefined ||
94
+ !Number.isInteger(status) ||
95
+ status < 200 ||
96
+ status > 599
97
+ ) {
98
+ reject(new RangeError('Invalid refresh response status'));
99
+ nodeResponse.destroy();
100
+ return;
101
+ }
102
+ const hasBody = status !== 204 && status !== 205 && status !== 304;
103
+ const body = hasBody
104
+ ? new ReadableStream<Uint8Array>({
105
+ start(controller) {
106
+ nodeResponse.on('data', chunk => {
107
+ if (typeof chunk === 'string') {
108
+ controller.enqueue(new TextEncoder().encode(chunk));
109
+ } else if (chunk instanceof Uint8Array) {
110
+ controller.enqueue(chunk);
111
+ } else {
112
+ controller.error(
113
+ new TypeError('Unsupported response chunk')
114
+ );
115
+ }
116
+ });
117
+ nodeResponse.on('end', () => controller.close());
118
+ nodeResponse.on('error', error => controller.error(error));
119
+ nodeResponse.on('aborted', () =>
120
+ controller.error(
121
+ new DOMException(
122
+ 'The operation was aborted',
123
+ 'AbortError'
124
+ )
125
+ )
126
+ );
127
+ },
128
+ cancel(reason) {
129
+ nodeResponse.destroy(
130
+ reason instanceof Error
131
+ ? reason
132
+ : new Error(String(reason))
133
+ );
134
+ },
135
+ })
136
+ : null;
137
+
138
+ resolve(
139
+ new Response(body, {
140
+ status,
141
+ statusText: nodeResponse.statusMessage,
142
+ headers: toResponseHeaders(nodeResponse.headers),
143
+ })
144
+ );
145
+ } catch (error) {
146
+ reject(error);
147
+ nodeResponse.destroy();
148
+ }
149
+ }
150
+ );
151
+ } catch (error) {
152
+ reject(error);
153
+ return;
154
+ }
155
+
156
+ request.on('error', reject);
157
+ request.end();
158
+ });
159
+ };