nanocodex-vite 0.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.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # `nanocodex-vite`
2
+
3
+ Vite integration for the `nanocodex` JavaScript package. The plugin builds the
4
+ current Rust/WASM bindings when it runs from a source checkout, installs the
5
+ browser compatibility aliases in page and nested Worker graphs, and exposes a
6
+ same-origin ChatGPT subscription socket during local development.
7
+
8
+ The WASM build normalizes wasm-bindgen byte views for all three JavaScript
9
+ targets. Cloudflare's V8 `subarray` offset check can reject valid strings above
10
+ the 128 MiB address boundary after WASM memory grows. Direct bounded views keep
11
+ those transfers valid without changing the WASM ABI. The build validates the
12
+ generated helper shapes, and changes to the normalization script invalidate
13
+ the binding cache.
14
+
15
+ ```js
16
+ import { nanocodex } from "nanocodex-vite";
17
+ import { defineConfig } from "vite";
18
+
19
+ export default defineConfig({
20
+ plugins: [nanocodex()],
21
+ worker: { format: "es" },
22
+ });
23
+ ```
24
+
25
+ Cloudflare applications use the combined entry instead of installing a second
26
+ Cloudflare plugin:
27
+
28
+ ```js
29
+ import { nanocodex } from "nanocodex-vite/cloudflare";
30
+ import react from "@vitejs/plugin-react";
31
+ import { defineConfig } from "vite";
32
+
33
+ export default defineConfig({
34
+ plugins: [react(), nanocodex()],
35
+ });
36
+ ```
37
+
38
+ In development, the application Worker remains the sole browser credential
39
+ broker. Nanocodex gives workerd only the current access-token snapshot and a
40
+ capability-scoped loopback egress; it never imports the host process
41
+ environment. `vite build` does not read local auth or include those bindings.
42
+ The refresh token, ID token, full auth document, and credentials never enter
43
+ browser code or responses.
44
+
45
+ Managed local applications can pass `oauthRelay: true`; the plugin then owns
46
+ the fixed provider callback relay for the lifetime of the Vite server. The
47
+ relay signing and callback helpers are exported from
48
+ `nanocodex-vite/oauth-relay`.
@@ -0,0 +1,298 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { Readable } from "node:stream";
4
+
5
+ import WebSocket, { WebSocketServer } from "ws";
6
+
7
+ const CHATGPT_HOST = "chatgpt.com";
8
+ const CHATGPT_PATH_PREFIX = "/backend-api/codex/";
9
+ const RELAY_HTTP_PATHS = new Map([
10
+ ["codex-web-search", "/backend-api/codex/alpha/search"],
11
+ ["codex-image-generation", "/backend-api/codex/images/generations"],
12
+ ["codex-image-edit", "/backend-api/codex/images/edits"],
13
+ ]);
14
+ const MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
15
+
16
+ export async function startChatGptWorkerEgress(options = {}) {
17
+ const capability = options.capability ?? randomBytes(32).toString("base64url");
18
+ if (!/^[A-Za-z0-9_-]{43}$/.test(capability)) {
19
+ throw new TypeError("ChatGPT development egress capability must be 43 base64url characters");
20
+ }
21
+ const localPrefix = `/${capability}`;
22
+ const relayPrefix = `/v1/${capability}`;
23
+ const sockets = new Set();
24
+ const upstreams = new Set();
25
+ const downstreamServer = new WebSocketServer({
26
+ noServer: true,
27
+ maxPayload: MAX_BUFFERED_BYTES,
28
+ perMessageDeflate: false,
29
+ });
30
+ const server = createServer((request, response) => {
31
+ void proxyHttpRequest(
32
+ request,
33
+ response,
34
+ options.fetchImpl ?? fetch,
35
+ localPrefix,
36
+ relayPrefix,
37
+ );
38
+ });
39
+ server.on("connection", (socket) => {
40
+ sockets.add(socket);
41
+ socket.once("close", () => sockets.delete(socket));
42
+ });
43
+ server.on("upgrade", (request, socket, head) => {
44
+ proxyWebSocketUpgrade({
45
+ downstreamServer,
46
+ head,
47
+ openUpstream: options.openUpstream,
48
+ localPrefix,
49
+ relayPrefix,
50
+ request,
51
+ socket,
52
+ upstreams,
53
+ });
54
+ });
55
+ server.on("clientError", (_error, socket) => {
56
+ socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
57
+ });
58
+
59
+ await new Promise((resolve, reject) => {
60
+ const onError = (error) => reject(error);
61
+ server.once("error", onError);
62
+ server.listen(options.port ?? 0, "127.0.0.1", () => {
63
+ server.off("error", onError);
64
+ resolve();
65
+ });
66
+ });
67
+ const address = server.address();
68
+ if (!address || typeof address === "string") throw new Error("ChatGPT development egress did not bind TCP");
69
+
70
+ let closed = false;
71
+ return Object.freeze({
72
+ url: `http://127.0.0.1:${address.port}${localPrefix}/`,
73
+ relayUrl: `http://127.0.0.1:${address.port}${relayPrefix}`,
74
+ async close() {
75
+ if (closed) return;
76
+ closed = true;
77
+ for (const socket of downstreamServer.clients) socket.terminate();
78
+ for (const socket of upstreams) socket.terminate();
79
+ for (const socket of sockets) socket.destroy();
80
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
81
+ },
82
+ });
83
+ }
84
+
85
+ async function proxyHttpRequest(request, response, fetchImpl, localPrefix, relayPrefix) {
86
+ const upstreamPath = allowedUpstreamPath(request.url, localPrefix, relayPrefix, false);
87
+ if (!safeLoopbackRequest(request) || upstreamPath === undefined) {
88
+ response.writeHead(404, noStoreHeaders("text/plain; charset=utf-8"));
89
+ response.end("Not found\n");
90
+ return;
91
+ }
92
+ try {
93
+ const method = request.method ?? "GET";
94
+ const upstream = await fetchImpl(`https://${CHATGPT_HOST}${upstreamPath}`, {
95
+ method,
96
+ headers: upstreamHttpHeaders(request),
97
+ body: method === "GET" || method === "HEAD"
98
+ ? undefined
99
+ : Readable.toWeb(request),
100
+ duplex: "half",
101
+ redirect: "manual",
102
+ });
103
+ const headers = new Headers(upstream.headers);
104
+ headers.delete("content-encoding");
105
+ headers.delete("content-length");
106
+ headers.set("cache-control", "no-store");
107
+ response.writeHead(upstream.status, upstream.statusText, Object.fromEntries(headers));
108
+ if (upstream.body) Readable.fromWeb(upstream.body).pipe(response);
109
+ else response.end();
110
+ } catch {
111
+ if (!response.headersSent) response.writeHead(502, noStoreHeaders("text/plain; charset=utf-8"));
112
+ response.end("ChatGPT development egress failed\n");
113
+ }
114
+ }
115
+
116
+ function proxyWebSocketUpgrade({
117
+ downstreamServer,
118
+ head,
119
+ localPrefix,
120
+ openUpstream,
121
+ relayPrefix,
122
+ request,
123
+ socket,
124
+ upstreams,
125
+ }) {
126
+ socket.on("error", () => {});
127
+ const upstreamPath = allowedUpstreamPath(request.url, localPrefix, relayPrefix, true);
128
+ if (!safeLoopbackRequest(request) || upstreamPath === undefined) {
129
+ rejectUpgrade(socket, 404, "Not found");
130
+ return;
131
+ }
132
+ const upstreamOptions = {
133
+ handshakeTimeout: 15_000,
134
+ headers: upstreamWebSocketHeaders(request),
135
+ maxPayload: MAX_BUFFERED_BYTES,
136
+ perMessageDeflate: false,
137
+ };
138
+ const upstream = openUpstream
139
+ ? openUpstream(`wss://${CHATGPT_HOST}${upstreamPath}`, upstreamOptions)
140
+ : new WebSocket(`wss://${CHATGPT_HOST}${upstreamPath}`, upstreamOptions);
141
+ upstreams.add(upstream);
142
+ upstream.once("close", () => upstreams.delete(upstream));
143
+ let settled = false;
144
+ upstream.once("open", () => {
145
+ if (settled || socket.destroyed) {
146
+ upstream.terminate();
147
+ return;
148
+ }
149
+ settled = true;
150
+ try {
151
+ downstreamServer.handleUpgrade(request, socket, head, (downstream) => {
152
+ bridge(downstream, upstream);
153
+ });
154
+ } catch {
155
+ upstream.terminate();
156
+ rejectUpgrade(socket, 502, "WebSocket upgrade failed");
157
+ }
158
+ });
159
+ upstream.once("unexpected-response", (_request, response) => {
160
+ response.resume();
161
+ if (settled || socket.destroyed) return;
162
+ settled = true;
163
+ rejectUpgrade(socket, safeStatus(response.statusCode), "ChatGPT WebSocket rejected");
164
+ upstream.terminate();
165
+ });
166
+ upstream.once("error", () => {
167
+ if (settled || socket.destroyed) return;
168
+ settled = true;
169
+ rejectUpgrade(socket, 502, "ChatGPT WebSocket failed");
170
+ });
171
+ socket.once("close", () => {
172
+ if (!settled && upstream.readyState !== WebSocket.CLOSED) upstream.terminate();
173
+ });
174
+ }
175
+
176
+ function bridge(left, right) {
177
+ let closed = false;
178
+ const relay = (source, destination) => (data, isBinary) => {
179
+ if (closed || destination.readyState !== WebSocket.OPEN) return;
180
+ if (destination.bufferedAmount + data.byteLength > MAX_BUFFERED_BYTES) {
181
+ closed = true;
182
+ source.close(1013, "relay backpressure limit");
183
+ destination.close(1013, "relay backpressure limit");
184
+ return;
185
+ }
186
+ destination.send(data, { binary: isBinary, compress: false }, (error) => {
187
+ if (error && !closed) {
188
+ closed = true;
189
+ source.terminate();
190
+ destination.terminate();
191
+ }
192
+ });
193
+ };
194
+ left.on("message", relay(left, right));
195
+ right.on("message", relay(right, left));
196
+ left.once("close", (code) => closePeer(right, code));
197
+ right.once("close", (code) => closePeer(left, code));
198
+ left.once("error", () => right.terminate());
199
+ right.once("error", () => left.terminate());
200
+ }
201
+
202
+ function upstreamHttpHeaders(request) {
203
+ const headers = new Headers();
204
+ for (const name of [
205
+ "accept",
206
+ "authorization",
207
+ "chatgpt-account-id",
208
+ "content-type",
209
+ "openai-alpha",
210
+ "originator",
211
+ "session-id",
212
+ "thread-id",
213
+ "user-agent",
214
+ "x-oai-attestation",
215
+ "x-openai-fedramp",
216
+ "x-session-id",
217
+ ]) {
218
+ const value = request.headers[name];
219
+ if (typeof value === "string") headers.set(name, value);
220
+ }
221
+ return headers;
222
+ }
223
+
224
+ function upstreamWebSocketHeaders(request) {
225
+ const allowed = new Set([
226
+ "authorization",
227
+ "chatgpt-account-id",
228
+ "openai-beta",
229
+ "originator",
230
+ "session-id",
231
+ "thread-id",
232
+ "user-agent",
233
+ "x-client-request-id",
234
+ "x-openai-fedramp",
235
+ "x-openai-internal-codex-responses-lite",
236
+ "x-responsesapi-include-timing-metrics",
237
+ ]);
238
+ return Object.fromEntries(
239
+ Object.entries(request.headers).flatMap(([name, value]) =>
240
+ value === undefined || !allowed.has(name) ? [] : [[name, value]]),
241
+ );
242
+ }
243
+
244
+ function safeLoopbackRequest(request) {
245
+ const address = request.socket.remoteAddress;
246
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
247
+ }
248
+
249
+ function allowedUpstreamPath(path, localPrefix, relayPrefix, websocket) {
250
+ if (typeof path !== "string") return undefined;
251
+ try {
252
+ const url = new URL(path, "http://127.0.0.1");
253
+ const expectedPrefix = `${localPrefix}${CHATGPT_PATH_PREFIX}`;
254
+ if (url.pathname.startsWith(expectedPrefix)) {
255
+ return `${url.pathname.slice(localPrefix.length)}${url.search}`;
256
+ }
257
+ if (websocket && url.pathname === relayPrefix && !url.search) {
258
+ return "/backend-api/codex/responses";
259
+ }
260
+ if (!websocket) {
261
+ const route = url.pathname.slice(`${relayPrefix}/http/`.length);
262
+ const upstream = url.pathname.startsWith(`${relayPrefix}/http/`)
263
+ ? RELAY_HTTP_PATHS.get(route)
264
+ : undefined;
265
+ if (upstream) return `${upstream}${url.search}`;
266
+ }
267
+ return undefined;
268
+ } catch {
269
+ return undefined;
270
+ }
271
+ }
272
+
273
+ function closePeer(peer, code) {
274
+ if (peer.readyState === WebSocket.OPEN) peer.close(safeCloseCode(code), "relay peer closed");
275
+ else if (peer.readyState === WebSocket.CONNECTING) peer.terminate();
276
+ }
277
+
278
+ function safeCloseCode(code) {
279
+ const standard = code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code);
280
+ return standard || (code >= 3000 && code <= 4999) ? code : 1011;
281
+ }
282
+
283
+ function safeStatus(status) {
284
+ return Number.isInteger(status) && status >= 400 && status <= 599 ? status : 502;
285
+ }
286
+
287
+ function rejectUpgrade(socket, status, message) {
288
+ if (socket.destroyed || socket.writableEnded) return;
289
+ const body = `${message}\n`;
290
+ socket.end(
291
+ `HTTP/1.1 ${status} ${message}\r\nContent-Type: text/plain\r\nCache-Control: no-store\r\n`
292
+ + `Content-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`,
293
+ );
294
+ }
295
+
296
+ function noStoreHeaders(contentType) {
297
+ return { "cache-control": "no-store", "content-type": contentType };
298
+ }