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 +48 -0
- package/chatgpt-egress.mjs +298 -0
- package/chatgpt-subscription.mjs +451 -0
- package/cloudflare-plugin.mjs +80 -0
- package/cloudflare.d.mts +21 -0
- package/cloudflare.mjs +32 -0
- package/codex-auth-file.mjs +158 -0
- package/index.d.mts +36 -0
- package/index.mjs +6 -0
- package/oauth-relay-server.mjs +125 -0
- package/oauth-relay.d.mts +104 -0
- package/oauth-relay.mjs +313 -0
- package/package.json +65 -0
- package/plugin.mjs +525 -0
- package/scripts/build-js-package.sh +147 -0
- package/scripts/wasm-memory-views.mjs +39 -0
- package/tools.d.mts +8 -0
- package/tools.mjs +38 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
|
|
3
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
4
|
+
|
|
5
|
+
import { defaultCodexAuthFile, readCodexSubscription } from "./codex-auth-file.mjs";
|
|
6
|
+
|
|
7
|
+
const CHATGPT_RESPONSES_URL = "wss://chatgpt.com/backend-api/codex/responses";
|
|
8
|
+
const DEFAULT_RESPONSES_PATH = "/api/responses";
|
|
9
|
+
const DEFAULT_STATUS_PATH = "/api/auth/chatgpt";
|
|
10
|
+
const MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
const MAX_HEADER_BYTES = 16 * 1024;
|
|
12
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
13
|
+
const FORBIDDEN_DOWNSTREAM_HEADERS = new Set([
|
|
14
|
+
"authorization",
|
|
15
|
+
"chatgpt-account-id",
|
|
16
|
+
"openai-beta",
|
|
17
|
+
"originator",
|
|
18
|
+
"proxy-authorization",
|
|
19
|
+
"session-id",
|
|
20
|
+
"thread-id",
|
|
21
|
+
"x-client-request-id",
|
|
22
|
+
"x-openai-fedramp",
|
|
23
|
+
"x-openai-internal-codex-responses-lite",
|
|
24
|
+
"x-responsesapi-include-timing-metrics",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export function chatGptSubscription(options = {}) {
|
|
28
|
+
return createChatGptSubscriptionPlugin(options, {
|
|
29
|
+
WebSocketImpl: WebSocket,
|
|
30
|
+
readAuth: readCodexSubscription,
|
|
31
|
+
upstreamUrl: CHATGPT_RESPONSES_URL,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Internal dependency seam used by provider-free tests. Not package-exported. */
|
|
36
|
+
export function createChatGptSubscriptionPlugin(options = {}, dependencies) {
|
|
37
|
+
const responsesPath = exactPath(options.responsesPath ?? DEFAULT_RESPONSES_PATH, "responsesPath");
|
|
38
|
+
const statusPath = options.statusPath === false
|
|
39
|
+
? false
|
|
40
|
+
: exactPath(options.statusPath ?? DEFAULT_STATUS_PATH, "statusPath");
|
|
41
|
+
const authFile = options.authFile === undefined
|
|
42
|
+
? defaultCodexAuthFile()
|
|
43
|
+
: resolveAuthFile(options.authFile);
|
|
44
|
+
const minimumTokenTtlMs = options.minimumTokenTtlMs ?? 5 * 60_000;
|
|
45
|
+
if (!Number.isSafeInteger(minimumTokenTtlMs) || minimumTokenTtlMs < 0) {
|
|
46
|
+
throw new TypeError("minimumTokenTtlMs must be a non-negative integer");
|
|
47
|
+
}
|
|
48
|
+
if (options.onEvent !== undefined && typeof options.onEvent !== "function") {
|
|
49
|
+
throw new TypeError("onEvent must be a function");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
name: "nanocodex-chatgpt-subscription",
|
|
54
|
+
apply: "serve",
|
|
55
|
+
enforce: "pre",
|
|
56
|
+
async configureServer(vite) {
|
|
57
|
+
const server = vite.httpServer;
|
|
58
|
+
if (!server) throw new Error("Nanocodex local ChatGPT auth requires a Vite HTTP server");
|
|
59
|
+
const downstreamServer = new WebSocketServer({
|
|
60
|
+
noServer: true,
|
|
61
|
+
maxPayload: MAX_BUFFERED_BYTES,
|
|
62
|
+
perMessageDeflate: false,
|
|
63
|
+
});
|
|
64
|
+
const downstreams = new Set();
|
|
65
|
+
const upstreams = new Set();
|
|
66
|
+
let closed = false;
|
|
67
|
+
const emit = (type, detail = {}) => {
|
|
68
|
+
try { options.onEvent?.(Object.freeze({ type, ...detail })); } catch (error) {
|
|
69
|
+
vite.config.logger.warn(`[nanocodex] local ChatGPT observer failed: ${errorMessage(error)}`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const loadAuth = () => dependencies.readAuth(authFile, { minimumTtlMs: minimumTokenTtlMs });
|
|
73
|
+
|
|
74
|
+
if (statusPath !== false) {
|
|
75
|
+
vite.middlewares.use((request, response, next) => {
|
|
76
|
+
let url;
|
|
77
|
+
try {
|
|
78
|
+
url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
79
|
+
} catch {
|
|
80
|
+
next();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (url.pathname !== statusPath || url.search || request.method !== "GET") {
|
|
84
|
+
next();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!safeSameOriginRequest(request)) {
|
|
88
|
+
response.writeHead(403, noStoreHeaders("text/plain; charset=utf-8"));
|
|
89
|
+
response.end("Forbidden\n");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
void loadAuth().then(
|
|
93
|
+
(auth) => {
|
|
94
|
+
const body = JSON.stringify({
|
|
95
|
+
state: "authenticated",
|
|
96
|
+
expiresAt: auth.expiresAt,
|
|
97
|
+
managedLocally: true,
|
|
98
|
+
});
|
|
99
|
+
response.writeHead(200, {
|
|
100
|
+
...noStoreHeaders("application/json; charset=utf-8"),
|
|
101
|
+
"content-length": Buffer.byteLength(body),
|
|
102
|
+
});
|
|
103
|
+
response.end(body);
|
|
104
|
+
},
|
|
105
|
+
() => next(),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const onUpgrade = (request, socket, head) => {
|
|
111
|
+
const parsed = parseUpgrade(request, responsesPath);
|
|
112
|
+
if (parsed === undefined) return;
|
|
113
|
+
if (parsed instanceof Error) {
|
|
114
|
+
rejectHttpUpgrade(socket, errorStatus(parsed), errorMessage(parsed));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (closed) {
|
|
118
|
+
rejectHttpUpgrade(socket, 503, "Development server is closing");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
downstreamServer.handleUpgrade(request, socket, head, (downstream) => {
|
|
123
|
+
downstreams.add(downstream);
|
|
124
|
+
downstream.once("close", () => downstreams.delete(downstream));
|
|
125
|
+
void connectUpstream({
|
|
126
|
+
authFile,
|
|
127
|
+
dependencies,
|
|
128
|
+
downstream,
|
|
129
|
+
emit,
|
|
130
|
+
loadAuth,
|
|
131
|
+
sessionId: parsed.sessionId,
|
|
132
|
+
upstreams,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
} catch {
|
|
136
|
+
rejectHttpUpgrade(socket, 502, "WebSocket upgrade failed");
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
server.prependListener("upgrade", onUpgrade);
|
|
140
|
+
|
|
141
|
+
const close = () => {
|
|
142
|
+
if (closed) return;
|
|
143
|
+
closed = true;
|
|
144
|
+
server.removeListener("upgrade", onUpgrade);
|
|
145
|
+
for (const socket of downstreams) socket.terminate();
|
|
146
|
+
for (const socket of upstreams) socket.terminate();
|
|
147
|
+
downstreamServer.close();
|
|
148
|
+
};
|
|
149
|
+
server.once("close", close);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const auth = await loadAuth();
|
|
153
|
+
emit("auth.ready", { expiresAt: auth.expiresAt });
|
|
154
|
+
vite.config.logger.info(
|
|
155
|
+
`[nanocodex] local ChatGPT subscription ready from ${authFile}`,
|
|
156
|
+
);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
emit("auth.unavailable", { kind: authErrorKind(error) });
|
|
159
|
+
vite.config.logger.info(
|
|
160
|
+
`[nanocodex] local ChatGPT subscription unavailable; run \`codex login\` (${errorMessage(error)})`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function connectUpstream({ dependencies, downstream, emit, loadAuth, sessionId, upstreams }) {
|
|
168
|
+
let auth;
|
|
169
|
+
try {
|
|
170
|
+
auth = await loadAuth();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
emit("auth.unavailable", { kind: authErrorKind(error) });
|
|
173
|
+
rejectWebSocket(downstream, authErrorKind(error) === "expired" ? 401 : 503,
|
|
174
|
+
"Local ChatGPT login unavailable; run `codex login` and retry");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (downstream.readyState !== WebSocket.OPEN) return;
|
|
178
|
+
|
|
179
|
+
const upstream = new dependencies.WebSocketImpl(dependencies.upstreamUrl, {
|
|
180
|
+
handshakeTimeout: 8_000,
|
|
181
|
+
headers: upstreamHeaders(auth, sessionId),
|
|
182
|
+
maxPayload: MAX_BUFFERED_BYTES,
|
|
183
|
+
perMessageDeflate: false,
|
|
184
|
+
});
|
|
185
|
+
upstreams.add(upstream);
|
|
186
|
+
upstream.once("close", () => upstreams.delete(upstream));
|
|
187
|
+
let opened = false;
|
|
188
|
+
let readySent = false;
|
|
189
|
+
let settled = false;
|
|
190
|
+
|
|
191
|
+
const fail = (status, message) => {
|
|
192
|
+
if (settled) return;
|
|
193
|
+
settled = true;
|
|
194
|
+
emit("upstream.rejected", { status });
|
|
195
|
+
rejectWebSocket(downstream, status, message);
|
|
196
|
+
if (upstream.readyState !== WebSocket.CLOSED) upstream.terminate();
|
|
197
|
+
};
|
|
198
|
+
upstream.once("unexpected-response", (_request, response) => {
|
|
199
|
+
response.resume();
|
|
200
|
+
fail(normalizeProviderStatus(response.statusCode), providerFailure(response.statusCode));
|
|
201
|
+
});
|
|
202
|
+
upstream.once("error", () => {
|
|
203
|
+
if (!opened) fail(502, "ChatGPT WebSocket connection failed");
|
|
204
|
+
});
|
|
205
|
+
upstream.once("open", () => {
|
|
206
|
+
if (settled || downstream.readyState !== WebSocket.OPEN) {
|
|
207
|
+
upstream.terminate();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
opened = true;
|
|
211
|
+
settled = true;
|
|
212
|
+
const pending = [];
|
|
213
|
+
let pendingBytes = 0;
|
|
214
|
+
const bufferUntilReady = (data, isBinary) => {
|
|
215
|
+
pendingBytes += data.byteLength;
|
|
216
|
+
if (pendingBytes > MAX_BUFFERED_BYTES) {
|
|
217
|
+
downstream.close(1013, "relay backpressure limit");
|
|
218
|
+
upstream.close(1013, "relay backpressure limit");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
pending.push([data, isBinary]);
|
|
222
|
+
};
|
|
223
|
+
upstream.on("message", bufferUntilReady);
|
|
224
|
+
downstream.send(JSON.stringify({ type: "nanocodex.proxy.ready" }), { compress: false }, (error) => {
|
|
225
|
+
if (error) {
|
|
226
|
+
downstream.terminate();
|
|
227
|
+
upstream.terminate();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
readySent = true;
|
|
231
|
+
upstream.off("message", bufferUntilReady);
|
|
232
|
+
bridge(downstream, upstream, emit, pending);
|
|
233
|
+
emit("connection.ready");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
downstream.once("close", () => {
|
|
237
|
+
if (!readySent && upstream.readyState !== WebSocket.CLOSED) upstream.terminate();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function bridge(downstream, upstream, emit, pendingUpstreamMessages = []) {
|
|
242
|
+
let closed = false;
|
|
243
|
+
const relay = (source, destination, type) => (data, isBinary) => {
|
|
244
|
+
if (closed || destination.readyState !== WebSocket.OPEN) return;
|
|
245
|
+
if (isBinary) {
|
|
246
|
+
closed = true;
|
|
247
|
+
source.close(1003, "text frames required");
|
|
248
|
+
destination.close(1003, "text frames required");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const byteLength = data.byteLength;
|
|
252
|
+
if (destination.bufferedAmount + byteLength > MAX_BUFFERED_BYTES) {
|
|
253
|
+
closed = true;
|
|
254
|
+
source.close(1013, "relay backpressure limit");
|
|
255
|
+
destination.close(1013, "relay backpressure limit");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
destination.send(data, { binary: false, compress: false }, (error) => {
|
|
259
|
+
if (error && !closed) {
|
|
260
|
+
closed = true;
|
|
261
|
+
source.terminate();
|
|
262
|
+
destination.terminate();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
emit(type, { byteLength });
|
|
266
|
+
};
|
|
267
|
+
const relayDownstream = relay(downstream, upstream, "request.forwarded");
|
|
268
|
+
const relayUpstream = relay(upstream, downstream, "response.forwarded");
|
|
269
|
+
downstream.on("message", relayDownstream);
|
|
270
|
+
upstream.on("message", relayUpstream);
|
|
271
|
+
for (const [data, isBinary] of pendingUpstreamMessages) relayUpstream(data, isBinary);
|
|
272
|
+
downstream.once("close", (code) => {
|
|
273
|
+
emit("connection.closed", { side: "browser", code });
|
|
274
|
+
closePeer(upstream, code);
|
|
275
|
+
});
|
|
276
|
+
upstream.once("close", (code) => {
|
|
277
|
+
emit("connection.closed", { side: "provider", code });
|
|
278
|
+
closePeer(downstream, code);
|
|
279
|
+
});
|
|
280
|
+
downstream.once("error", () => upstream.terminate());
|
|
281
|
+
upstream.once("error", () => downstream.terminate());
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseUpgrade(request, responsesPath) {
|
|
285
|
+
let url;
|
|
286
|
+
try {
|
|
287
|
+
url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
288
|
+
} catch {
|
|
289
|
+
return new Error("Bad request");
|
|
290
|
+
}
|
|
291
|
+
if (url.pathname !== responsesPath) return undefined;
|
|
292
|
+
if (request.method !== "GET") return statusError(405, "Method not allowed");
|
|
293
|
+
if (!safeSameOriginRequest(request)) return statusError(403, "Forbidden");
|
|
294
|
+
if (headerBytes(request) > MAX_HEADER_BYTES) return statusError(431, "Request headers too large");
|
|
295
|
+
if (request.headers["sec-websocket-protocol"] !== undefined) {
|
|
296
|
+
return statusError(400, "WebSocket subprotocols are not supported");
|
|
297
|
+
}
|
|
298
|
+
for (const name of FORBIDDEN_DOWNSTREAM_HEADERS) {
|
|
299
|
+
if (request.headers[name] !== undefined) return statusError(400, "Credential headers are not accepted");
|
|
300
|
+
}
|
|
301
|
+
const entries = [...url.searchParams.entries()];
|
|
302
|
+
if (entries.length !== 1 || entries[0][0] !== "session_id" || !SESSION_ID_PATTERN.test(entries[0][1])) {
|
|
303
|
+
return statusError(400, "Invalid session");
|
|
304
|
+
}
|
|
305
|
+
return { sessionId: entries[0][1] };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function safeSameOriginRequest(request) {
|
|
309
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
|
|
310
|
+
const hosts = rawHeaderValues(request, "host");
|
|
311
|
+
const origins = rawHeaderValues(request, "origin");
|
|
312
|
+
if (hosts.length !== 1 || origins.length !== 1) return false;
|
|
313
|
+
let origin;
|
|
314
|
+
try {
|
|
315
|
+
origin = new URL(origins[0]);
|
|
316
|
+
} catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
const protocol = request.socket.encrypted === true ? "https:" : "http:";
|
|
320
|
+
return origin.protocol === protocol
|
|
321
|
+
&& origin.host === hosts[0]
|
|
322
|
+
&& origin.pathname === "/"
|
|
323
|
+
&& !origin.search
|
|
324
|
+
&& !origin.hash
|
|
325
|
+
&& isLoopbackHostname(origin.hostname);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function rawHeaderValues(request, expectedName) {
|
|
329
|
+
const values = [];
|
|
330
|
+
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
|
331
|
+
if (request.rawHeaders[index]?.toLowerCase() === expectedName) {
|
|
332
|
+
values.push(request.rawHeaders[index + 1] ?? "");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return values;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function headerBytes(request) {
|
|
339
|
+
return request.rawHeaders.reduce((total, value) => total + Buffer.byteLength(value), 0);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function upstreamHeaders(auth, sessionId) {
|
|
343
|
+
return {
|
|
344
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
345
|
+
"ChatGPT-Account-ID": auth.accountId,
|
|
346
|
+
"OpenAI-Beta": "responses_websockets=2026-02-06",
|
|
347
|
+
originator: "codex_cli_rs",
|
|
348
|
+
"x-openai-internal-codex-responses-lite": "true",
|
|
349
|
+
"session-id": sessionId,
|
|
350
|
+
"thread-id": sessionId,
|
|
351
|
+
"x-client-request-id": sessionId,
|
|
352
|
+
"x-responsesapi-include-timing-metrics": "true",
|
|
353
|
+
"User-Agent": "codex_cli_rs/0.0.0",
|
|
354
|
+
...(auth.fedramp ? { "X-OpenAI-Fedramp": "true" } : {}),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function providerFailure(status) {
|
|
359
|
+
if (status === 401 || status === 403) {
|
|
360
|
+
return "ChatGPT rejected the local login; run `codex login` and retry";
|
|
361
|
+
}
|
|
362
|
+
if (status === 429) return "ChatGPT rate limit reached";
|
|
363
|
+
return "ChatGPT WebSocket upgrade failed";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function normalizeProviderStatus(status) {
|
|
367
|
+
return Number.isInteger(status) && status >= 400 && status <= 599 ? status : 502;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function rejectWebSocket(socket, status, error) {
|
|
371
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
372
|
+
socket.send(JSON.stringify({ type: "nanocodex.proxy.rejected", status, error }), { compress: false }, () => {
|
|
373
|
+
if (socket.readyState === WebSocket.OPEN) socket.close(status === 429 ? 1013 : 1011, "connection rejected");
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function closePeer(peer, code) {
|
|
378
|
+
if (peer.readyState === WebSocket.OPEN) peer.close(safeCloseCode(code), "relay peer closed");
|
|
379
|
+
else if (peer.readyState === WebSocket.CONNECTING) peer.terminate();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function safeCloseCode(code) {
|
|
383
|
+
const standard = code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code);
|
|
384
|
+
return standard || (code >= 3000 && code <= 4999) ? code : 1011;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function rejectHttpUpgrade(socket, status, message) {
|
|
388
|
+
if (socket.destroyed || socket.writableEnded) return;
|
|
389
|
+
const body = `${message}\n`;
|
|
390
|
+
socket.once("error", () => {});
|
|
391
|
+
socket.end(
|
|
392
|
+
`HTTP/1.1 ${status} ${message}\r\nContent-Type: text/plain\r\nCache-Control: no-store\r\n`
|
|
393
|
+
+ `Content-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function noStoreHeaders(contentType) {
|
|
398
|
+
return {
|
|
399
|
+
"cache-control": "no-store",
|
|
400
|
+
"content-type": contentType,
|
|
401
|
+
"x-content-type-options": "nosniff",
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function exactPath(value, name) {
|
|
406
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) {
|
|
407
|
+
throw new TypeError(`${name} must be an absolute URL path`);
|
|
408
|
+
}
|
|
409
|
+
const url = new URL(value, "http://nanocodex.invalid");
|
|
410
|
+
if (url.pathname !== value || url.search || url.hash) {
|
|
411
|
+
throw new TypeError(`${name} must not contain a query or fragment`);
|
|
412
|
+
}
|
|
413
|
+
return value;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function resolveAuthFile(value) {
|
|
417
|
+
if (value instanceof URL) {
|
|
418
|
+
if (value.protocol !== "file:") throw new TypeError("authFile URL must use file:");
|
|
419
|
+
return decodeURIComponent(value.pathname);
|
|
420
|
+
}
|
|
421
|
+
if (typeof value !== "string" || !value.trim()) throw new TypeError("authFile must be a path or file URL");
|
|
422
|
+
return value;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isLoopbackAddress(address) {
|
|
426
|
+
if (address === "::1") return true;
|
|
427
|
+
const normalized = address?.startsWith("::ffff:") ? address.slice(7) : address;
|
|
428
|
+
return isLoopbackHostname(normalized ?? "");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function isLoopbackHostname(hostname) {
|
|
432
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
433
|
+
if (normalized === "localhost" || normalized === "::1") return true;
|
|
434
|
+
return isIP(normalized) === 4 && normalized.split(".", 1)[0] === "127";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function statusError(status, message) {
|
|
438
|
+
return Object.assign(new Error(message), { status });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function errorStatus(error) {
|
|
442
|
+
return Number.isInteger(error.status) ? error.status : 400;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function authErrorKind(error) {
|
|
446
|
+
return typeof error?.kind === "string" ? error.kind : "invalid";
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function errorMessage(error) {
|
|
450
|
+
return error instanceof Error ? error.message : String(error);
|
|
451
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createNanocodexVitePlugin } from "./plugin.mjs";
|
|
2
|
+
|
|
3
|
+
/** Internal dependency seam used by provider-free tests. Not package-exported. */
|
|
4
|
+
export function createNanocodexCloudflarePlugins(options, cloudflare, integration = {}) {
|
|
5
|
+
let devBindings;
|
|
6
|
+
const credentialBrokerWorker = options.chatGpt === false
|
|
7
|
+
? undefined
|
|
8
|
+
: options.chatGpt?.credentialBrokerWorker;
|
|
9
|
+
const core = createNanocodexVitePlugin(
|
|
10
|
+
{
|
|
11
|
+
chatGpt: options.chatGpt,
|
|
12
|
+
devApplications: options.devApplications,
|
|
13
|
+
oauthRelay: options.oauthRelay,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
...integration,
|
|
17
|
+
target: "cloudflare",
|
|
18
|
+
setDevBindings(value) {
|
|
19
|
+
devBindings = value;
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
return [
|
|
24
|
+
core,
|
|
25
|
+
...cloudflare(withDevBindings(
|
|
26
|
+
options.cloudflare ?? {},
|
|
27
|
+
() => devBindings,
|
|
28
|
+
credentialBrokerWorker,
|
|
29
|
+
)),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withDevBindings(options, getDevBindings, credentialBrokerWorker) {
|
|
34
|
+
if (credentialBrokerWorker) {
|
|
35
|
+
return {
|
|
36
|
+
...options,
|
|
37
|
+
auxiliaryWorkers: options.auxiliaryWorkers?.map((worker) => ({
|
|
38
|
+
...worker,
|
|
39
|
+
config(workerConfig, context) {
|
|
40
|
+
const customized = typeof worker.config === "function"
|
|
41
|
+
? worker.config(workerConfig, context)
|
|
42
|
+
: worker.config;
|
|
43
|
+
if (
|
|
44
|
+
workerConfig.name !== credentialBrokerWorker
|
|
45
|
+
&& workerConfig.topLevelName !== credentialBrokerWorker
|
|
46
|
+
) return customized;
|
|
47
|
+
const bindings = getDevBindings();
|
|
48
|
+
if (!bindings) return customized;
|
|
49
|
+
return {
|
|
50
|
+
...(customized ?? {}),
|
|
51
|
+
vars: {
|
|
52
|
+
...(workerConfig.vars ?? {}),
|
|
53
|
+
...(customized?.vars ?? {}),
|
|
54
|
+
...bindings,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const userConfig = options.config;
|
|
62
|
+
return {
|
|
63
|
+
...options,
|
|
64
|
+
config(workerConfig) {
|
|
65
|
+
const customized = typeof userConfig === "function"
|
|
66
|
+
? userConfig(workerConfig)
|
|
67
|
+
: userConfig;
|
|
68
|
+
const bindings = getDevBindings();
|
|
69
|
+
if (!bindings) return customized;
|
|
70
|
+
return {
|
|
71
|
+
...(customized ?? {}),
|
|
72
|
+
vars: {
|
|
73
|
+
...(workerConfig.vars ?? {}),
|
|
74
|
+
...(customized?.vars ?? {}),
|
|
75
|
+
...bindings,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
package/cloudflare.d.mts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PluginConfig } from "@cloudflare/vite-plugin";
|
|
2
|
+
import type { PluginOption } from "vite";
|
|
3
|
+
|
|
4
|
+
import type { NanocodexChatGptViteOptions, NanocodexDevApplication } from "./index.mjs";
|
|
5
|
+
|
|
6
|
+
export type NanocodexCloudflareViteOptions = Readonly<{
|
|
7
|
+
/** Cloudflare Vite plugin options. Nanocodex adds only exact development credential bindings. */
|
|
8
|
+
cloudflare?: PluginConfig | undefined;
|
|
9
|
+
/** Local ChatGPT subscription support is on by default; pass false to disable it. */
|
|
10
|
+
chatGpt?: (Pick<NanocodexChatGptViteOptions, "authFile"> & Readonly<{
|
|
11
|
+
/** Put the local Codex credential only in this auxiliary broker Worker. */
|
|
12
|
+
credentialBrokerWorker?: string | undefined;
|
|
13
|
+
}>) | false | undefined;
|
|
14
|
+
/** Sibling Vite applications mounted into this development server. */
|
|
15
|
+
devApplications?: readonly NanocodexDevApplication[] | undefined;
|
|
16
|
+
/** Start the fixed local OAuth callback relay while serving. */
|
|
17
|
+
oauthRelay?: boolean | undefined;
|
|
18
|
+
}>;
|
|
19
|
+
|
|
20
|
+
/** One call installs browser shims, local subscription brokering, and the Cloudflare Worker plugin. */
|
|
21
|
+
export function nanocodex(options?: NanocodexCloudflareViteOptions): PluginOption[];
|
package/cloudflare.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { createNanocodexCloudflarePlugins } from "./cloudflare-plugin.mjs";
|
|
7
|
+
|
|
8
|
+
const projectUrl = pathToFileURL(resolve(process.cwd(), "package.json"));
|
|
9
|
+
const cloudflareModule = await import(await resolveProjectImport("@cloudflare/vite-plugin"));
|
|
10
|
+
const { cloudflare } = cloudflareModule;
|
|
11
|
+
|
|
12
|
+
/** One-call Nanocodex and Cloudflare integration for development and deployment. */
|
|
13
|
+
export function nanocodex(options = {}) {
|
|
14
|
+
return createNanocodexCloudflarePlugins(options, cloudflare);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function resolveProjectImport(packageName) {
|
|
18
|
+
const projectRequire = createRequire(projectUrl);
|
|
19
|
+
for (const modulesDirectory of projectRequire.resolve.paths(packageName) ?? []) {
|
|
20
|
+
const packageFile = resolve(modulesDirectory, packageName, "package.json");
|
|
21
|
+
try {
|
|
22
|
+
const manifest = JSON.parse(await readFile(packageFile, "utf8"));
|
|
23
|
+
const target = manifest.exports?.["."]?.import ?? manifest.module;
|
|
24
|
+
if (typeof target === "string") {
|
|
25
|
+
return pathToFileURL(resolve(packageFile, "..", target)).href;
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code !== "ENOENT") throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`${packageName} must be installed by the Vite application`);
|
|
32
|
+
}
|