myjsbook 1.0.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/LICENSE +201 -0
- package/README.md +263 -0
- package/package.json +38 -0
- package/public/app.js +6686 -0
- package/public/components/constants.js +421 -0
- package/public/components/elements.js +118 -0
- package/public/components/state.js +53 -0
- package/public/icons/audio.svg +1 -0
- package/public/icons/azure.svg +1 -0
- package/public/icons/babel.svg +1 -0
- package/public/icons/bun.svg +1 -0
- package/public/icons/bun_light.svg +1 -0
- package/public/icons/c.svg +1 -0
- package/public/icons/chrome.svg +1 -0
- package/public/icons/citation.svg +1 -0
- package/public/icons/claude.svg +1 -0
- package/public/icons/console.svg +1 -0
- package/public/icons/cpp.svg +1 -0
- package/public/icons/css-map.svg +1 -0
- package/public/icons/css.svg +1 -0
- package/public/icons/database.svg +1 -0
- package/public/icons/docker.svg +1 -0
- package/public/icons/document.svg +1 -0
- package/public/icons/ejs.svg +1 -0
- package/public/icons/exe.svg +1 -0
- package/public/icons/favicon.svg +1 -0
- package/public/icons/figma.svg +1 -0
- package/public/icons/firebase.svg +1 -0
- package/public/icons/folder-admin-open.svg +1 -0
- package/public/icons/folder-admin.svg +1 -0
- package/public/icons/folder-api-open.svg +1 -0
- package/public/icons/folder-api.svg +1 -0
- package/public/icons/folder-app-open.svg +1 -0
- package/public/icons/folder-app.svg +1 -0
- package/public/icons/folder-archive-open.svg +1 -0
- package/public/icons/folder-archive.svg +1 -0
- package/public/icons/folder-attachment-open.svg +1 -0
- package/public/icons/folder-attachment.svg +1 -0
- package/public/icons/folder-aws-open.svg +1 -0
- package/public/icons/folder-aws.svg +1 -0
- package/public/icons/folder-backup-open.svg +1 -0
- package/public/icons/folder-backup.svg +1 -0
- package/public/icons/folder-class-open.svg +1 -0
- package/public/icons/folder-class.svg +1 -0
- package/public/icons/folder-claude-open.svg +1 -0
- package/public/icons/folder-claude.svg +1 -0
- package/public/icons/folder-client-open.svg +1 -0
- package/public/icons/folder-client.svg +1 -0
- package/public/icons/folder-command-open.svg +1 -0
- package/public/icons/folder-command.svg +1 -0
- package/public/icons/folder-components-open.svg +1 -0
- package/public/icons/folder-components.svg +1 -0
- package/public/icons/folder-config-open.svg +1 -0
- package/public/icons/folder-config.svg +1 -0
- package/public/icons/folder-connection-open.svg +1 -0
- package/public/icons/folder-connection.svg +1 -0
- package/public/icons/folder-console-open.svg +1 -0
- package/public/icons/folder-console.svg +1 -0
- package/public/icons/folder-container-open.svg +1 -0
- package/public/icons/folder-container.svg +1 -0
- package/public/icons/folder-content-open.svg +1 -0
- package/public/icons/folder-content.svg +1 -0
- package/public/icons/folder-context-open.svg +1 -0
- package/public/icons/folder-context.svg +1 -0
- package/public/icons/folder-controller-open.svg +1 -0
- package/public/icons/folder-controller.svg +1 -0
- package/public/icons/folder-core-open.svg +1 -0
- package/public/icons/folder-core.svg +1 -0
- package/public/icons/folder-css-open.svg +1 -0
- package/public/icons/folder-css.svg +1 -0
- package/public/icons/folder-custom-open.svg +1 -0
- package/public/icons/folder-custom.svg +1 -0
- package/public/icons/folder-database-open.svg +1 -0
- package/public/icons/folder-database.svg +1 -0
- package/public/icons/folder-decorators-open.svg +1 -0
- package/public/icons/folder-decorators.svg +1 -0
- package/public/icons/folder-desktop-open.svg +1 -0
- package/public/icons/folder-desktop.svg +1 -0
- package/public/icons/folder-dist-open.svg +1 -0
- package/public/icons/folder-dist.svg +1 -0
- package/public/icons/folder-docs-open.svg +1 -0
- package/public/icons/folder-docs.svg +1 -0
- package/public/icons/folder-download-open.svg +1 -0
- package/public/icons/folder-download.svg +1 -0
- package/public/icons/folder-dtos-open.svg +1 -0
- package/public/icons/folder-dtos.svg +1 -0
- package/public/icons/folder-element-open.svg +1 -0
- package/public/icons/folder-element.svg +1 -0
- package/public/icons/folder-environment-open.svg +1 -0
- package/public/icons/folder-environment.svg +1 -0
- package/public/icons/folder-error-open.svg +1 -0
- package/public/icons/folder-error.svg +1 -0
- package/public/icons/folder-event-open.svg +1 -0
- package/public/icons/folder-event.svg +1 -0
- package/public/icons/folder-examples-open.svg +1 -0
- package/public/icons/folder-examples.svg +1 -0
- package/public/icons/folder-expo-open.svg +1 -0
- package/public/icons/folder-expo.svg +1 -0
- package/public/icons/folder-export-open.svg +1 -0
- package/public/icons/folder-export.svg +1 -0
- package/public/icons/folder-features-open.svg +1 -0
- package/public/icons/folder-features.svg +1 -0
- package/public/icons/folder-filter-open.svg +1 -0
- package/public/icons/folder-filter.svg +1 -0
- package/public/icons/folder-firebase-open.svg +1 -0
- package/public/icons/folder-firebase.svg +1 -0
- package/public/icons/folder-firestore-open.svg +1 -0
- package/public/icons/folder-firestore.svg +1 -0
- package/public/icons/folder-font-open.svg +1 -0
- package/public/icons/folder-font.svg +1 -0
- package/public/icons/folder-functions-open.svg +1 -0
- package/public/icons/folder-functions.svg +1 -0
- package/public/icons/folder-gemini-ai-open.svg +1 -0
- package/public/icons/folder-gemini-ai.svg +1 -0
- package/public/icons/folder-git-open.svg +1 -0
- package/public/icons/folder-git.svg +1 -0
- package/public/icons/folder-github-open.svg +1 -0
- package/public/icons/folder-github.svg +1 -0
- package/public/icons/folder-helper-open.svg +1 -0
- package/public/icons/folder-helper.svg +1 -0
- package/public/icons/folder-home-open.svg +1 -0
- package/public/icons/folder-home.svg +1 -0
- package/public/icons/folder-icons-open.svg +1 -0
- package/public/icons/folder-icons.svg +1 -0
- package/public/icons/folder-images-open.svg +1 -0
- package/public/icons/folder-images.svg +1 -0
- package/public/icons/folder-interface-open.svg +1 -0
- package/public/icons/folder-interface.svg +1 -0
- package/public/icons/folder-ios-open.svg +1 -0
- package/public/icons/folder-ios.svg +1 -0
- package/public/icons/folder-java-open.svg +1 -0
- package/public/icons/folder-java.svg +1 -0
- package/public/icons/folder-javascript-open.svg +1 -0
- package/public/icons/folder-javascript.svg +1 -0
- package/public/icons/folder-middleware-open.svg +1 -0
- package/public/icons/folder-middleware.svg +1 -0
- package/public/icons/folder-migrations-open.svg +1 -0
- package/public/icons/folder-migrations.svg +1 -0
- package/public/icons/folder-other-open.svg +1 -0
- package/public/icons/folder-other.svg +1 -0
- package/public/icons/folder-packages-open.svg +1 -0
- package/public/icons/folder-packages.svg +1 -0
- package/public/icons/folder-pdf-open.svg +1 -0
- package/public/icons/folder-pdf.svg +1 -0
- package/public/icons/folder-plugin-open.svg +1 -0
- package/public/icons/folder-plugin.svg +1 -0
- package/public/icons/folder-project-open.svg +1 -0
- package/public/icons/folder-project.svg +1 -0
- package/public/icons/folder-public-open.svg +1 -0
- package/public/icons/folder-public.svg +1 -0
- package/public/icons/folder-python-open.svg +1 -0
- package/public/icons/folder-python.svg +1 -0
- package/public/icons/folder-repository-open.svg +1 -0
- package/public/icons/folder-repository.svg +1 -0
- package/public/icons/folder-routes-open.svg +1 -0
- package/public/icons/folder-routes.svg +1 -0
- package/public/icons/folder-rules-open.svg +1 -0
- package/public/icons/folder-rules.svg +1 -0
- package/public/icons/folder-sass-open.svg +1 -0
- package/public/icons/folder-sass.svg +1 -0
- package/public/icons/folder-scripts-open.svg +1 -0
- package/public/icons/folder-scripts.svg +1 -0
- package/public/icons/folder-server-open.svg +1 -0
- package/public/icons/folder-server.svg +1 -0
- package/public/icons/folder-serverless-open.svg +1 -0
- package/public/icons/folder-serverless.svg +1 -0
- package/public/icons/folder-skills-open.svg +1 -0
- package/public/icons/folder-skills.svg +1 -0
- package/public/icons/folder-src-open.svg +1 -0
- package/public/icons/folder-src.svg +1 -0
- package/public/icons/folder-stack-open.svg +1 -0
- package/public/icons/folder-stack.svg +1 -0
- package/public/icons/folder-store-open.svg +1 -0
- package/public/icons/folder-store.svg +1 -0
- package/public/icons/folder-supabase-open.svg +1 -0
- package/public/icons/folder-supabase.svg +1 -0
- package/public/icons/folder-svg-open.svg +1 -0
- package/public/icons/folder-svg.svg +1 -0
- package/public/icons/folder-target-open.svg +1 -0
- package/public/icons/folder-target.svg +1 -0
- package/public/icons/folder-tasks-open.svg +1 -0
- package/public/icons/folder-tasks.svg +1 -0
- package/public/icons/folder-temp-open.svg +1 -0
- package/public/icons/folder-temp.svg +1 -0
- package/public/icons/folder-template-open.svg +1 -0
- package/public/icons/folder-template.svg +1 -0
- package/public/icons/folder-test-open.svg +1 -0
- package/public/icons/folder-test.svg +1 -0
- package/public/icons/folder-tools-open.svg +1 -0
- package/public/icons/folder-tools.svg +1 -0
- package/public/icons/folder-typescript-open.svg +1 -0
- package/public/icons/folder-typescript.svg +1 -0
- package/public/icons/folder-ui-open.svg +1 -0
- package/public/icons/folder-ui.svg +1 -0
- package/public/icons/folder-upload-open.svg +1 -0
- package/public/icons/folder-upload.svg +1 -0
- package/public/icons/folder-utils-open.svg +1 -0
- package/public/icons/folder-utils.svg +1 -0
- package/public/icons/folder-video-open.svg +1 -0
- package/public/icons/folder-video.svg +1 -0
- package/public/icons/folder-views-open.svg +1 -0
- package/public/icons/folder-views.svg +1 -0
- package/public/icons/font.svg +1 -0
- package/public/icons/gemini-ai.svg +1 -0
- package/public/icons/gemini.svg +1 -0
- package/public/icons/git.svg +1 -0
- package/public/icons/google.svg +1 -0
- package/public/icons/graphql.svg +1 -0
- package/public/icons/html.svg +1 -0
- package/public/icons/image.svg +1 -0
- package/public/icons/java.svg +1 -0
- package/public/icons/javaclass.svg +1 -0
- package/public/icons/javascript.svg +1 -0
- package/public/icons/jsconfig.svg +1 -0
- package/public/icons/json.svg +1 -0
- package/public/icons/markdown.svg +1 -0
- package/public/icons/nodejs.svg +1 -0
- package/public/icons/nodejs_alt.svg +1 -0
- package/public/icons/nodemon.svg +1 -0
- package/public/icons/npm.svg +1 -0
- package/public/icons/pdf.svg +1 -0
- package/public/icons/prettier.svg +1 -0
- package/public/icons/prisma.svg +1 -0
- package/public/icons/python.svg +1 -0
- package/public/icons/react.svg +1 -0
- package/public/icons/react_ts.svg +1 -0
- package/public/icons/readme.svg +1 -0
- package/public/icons/remark.svg +1 -0
- package/public/icons/sass.svg +1 -0
- package/public/icons/svg.svg +1 -0
- package/public/icons/tailwindcss.svg +1 -0
- package/public/icons/typescript-def.svg +1 -0
- package/public/icons/typescript.svg +1 -0
- package/public/icons/zip.svg +1 -0
- package/public/index.html +1342 -0
- package/public/styles.css +4736 -0
- package/src/cli.js +175 -0
- package/src/lib/files.js +143 -0
- package/src/lib/notebook.js +141 -0
- package/src/lib/package-exports.js +331 -0
- package/src/lib/session.js +1003 -0
- package/src/server.js +2232 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,2232 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import prettier from "prettier";
|
|
9
|
+
import * as prettierPluginBabel from "prettier/plugins/babel";
|
|
10
|
+
import * as prettierPluginEstree from "prettier/plugins/estree";
|
|
11
|
+
import * as prettierPluginMarkdown from "prettier/plugins/markdown";
|
|
12
|
+
import * as prettierPluginTypeScript from "prettier/plugins/typescript";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
deriveNotebookPathFromTitle,
|
|
16
|
+
getAppPathFromWorkspacePath,
|
|
17
|
+
getOpenableFileKind,
|
|
18
|
+
isOpenableFile,
|
|
19
|
+
resolveWorkspaceOpenPath
|
|
20
|
+
} from "./lib/files.js";
|
|
21
|
+
import { createNotebook, normalizeNotebook, NOTEBOOK_EXTENSION } from "./lib/notebook.js";
|
|
22
|
+
import { collectDeclaredPackageExports } from "./lib/package-exports.js";
|
|
23
|
+
import { KernelSession } from "./lib/session.js";
|
|
24
|
+
|
|
25
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
26
|
+
const __dirname = path.dirname(__filename);
|
|
27
|
+
const packageRoot = path.resolve(__dirname, "..");
|
|
28
|
+
const publicDir = path.join(packageRoot, "public");
|
|
29
|
+
const monacoDir = path.join(packageRoot, "node_modules", "monaco-editor", "min");
|
|
30
|
+
|
|
31
|
+
const MIME_TYPES = {
|
|
32
|
+
".html": "text/html; charset=utf-8",
|
|
33
|
+
".css": "text/css; charset=utf-8",
|
|
34
|
+
".js": "application/javascript; charset=utf-8",
|
|
35
|
+
".json": "application/json; charset=utf-8",
|
|
36
|
+
".svg": "image/svg+xml; charset=utf-8",
|
|
37
|
+
".txt": "text/plain; charset=utf-8",
|
|
38
|
+
".md": "text/markdown; charset=utf-8",
|
|
39
|
+
".ts": "text/plain; charset=utf-8",
|
|
40
|
+
".png": "image/png",
|
|
41
|
+
".jpg": "image/jpeg",
|
|
42
|
+
".jpeg": "image/jpeg",
|
|
43
|
+
".gif": "image/gif",
|
|
44
|
+
".webp": "image/webp",
|
|
45
|
+
".avif": "image/avif",
|
|
46
|
+
".pdf": "application/pdf"
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const GROQ_MODELS_FALLBACK = [
|
|
50
|
+
"llama-3.3-70b-versatile",
|
|
51
|
+
"llama-3.1-8b-instant",
|
|
52
|
+
"openai/gpt-oss-120b",
|
|
53
|
+
"openai/gpt-oss-20b"
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fetch the live model list from Groq, filter to llama/openai models only,
|
|
58
|
+
* return at most 5. Falls back to GROQ_MODELS_FALLBACK if the call fails or
|
|
59
|
+
* no API key is available.
|
|
60
|
+
*/
|
|
61
|
+
async function fetchGroqModels(apiKey) {
|
|
62
|
+
if (!apiKey) return GROQ_MODELS_FALLBACK;
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch("https://api.groq.com/openai/v1/models", {
|
|
65
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) return GROQ_MODELS_FALLBACK;
|
|
68
|
+
const json = await res.json();
|
|
69
|
+
const ids = (json.data ?? [])
|
|
70
|
+
.map((m) => m.id)
|
|
71
|
+
.filter((id) => typeof id === "string" && (id.startsWith("llama") || id.startsWith("openai/")))
|
|
72
|
+
.slice(0, 5);
|
|
73
|
+
return ids.length ? ids : GROQ_MODELS_FALLBACK;
|
|
74
|
+
} catch {
|
|
75
|
+
return GROQ_MODELS_FALLBACK;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function estimateTokens(text = "") {
|
|
80
|
+
return Math.max(0, Math.ceil(String(text ?? "").length / 4));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function json(response, statusCode, body, compact = false) {
|
|
84
|
+
response.writeHead(statusCode, {
|
|
85
|
+
"content-type": "application/json; charset=utf-8"
|
|
86
|
+
});
|
|
87
|
+
// Use compact JSON for large machine-to-machine payloads (e.g. type libraries)
|
|
88
|
+
response.end(compact ? JSON.stringify(body) : JSON.stringify(body, null, 2));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function notFound(response) {
|
|
92
|
+
json(response, 404, { error: "Not found" });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveNotebookPath(workspaceRoot, notebooksDir, requestedPath) {
|
|
96
|
+
if (!requestedPath) {
|
|
97
|
+
// Default: create untitled.ijsnb directly in the workspace root
|
|
98
|
+
return path.join(workspaceRoot, `untitled${NOTEBOOK_EXTENSION}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Strip the /notebooks/ URL prefix if the frontend passed a browser URL path.
|
|
102
|
+
// e.g. "/notebooks/my_notebook.ijsnb" → "my_notebook.ijsnb"
|
|
103
|
+
// "/notebooks/any_folder/basic.ijsnb" → "any_folder/basic.ijsnb"
|
|
104
|
+
let cleanPath = requestedPath;
|
|
105
|
+
if (!path.isAbsolute(cleanPath)) {
|
|
106
|
+
cleanPath = cleanPath.replace(/^\/+/, "");
|
|
107
|
+
if (cleanPath.startsWith("notebooks/")) {
|
|
108
|
+
cleanPath = cleanPath.slice("notebooks/".length);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const absolute = path.isAbsolute(cleanPath)
|
|
113
|
+
? cleanPath
|
|
114
|
+
: path.resolve(workspaceRoot, cleanPath);
|
|
115
|
+
|
|
116
|
+
const withExtension = absolute.endsWith(NOTEBOOK_EXTENSION) ? absolute : `${absolute}${NOTEBOOK_EXTENSION}`;
|
|
117
|
+
|
|
118
|
+
// Security: ensure the resolved path is inside the workspace
|
|
119
|
+
if (!withExtension.startsWith(workspaceRoot)) {
|
|
120
|
+
return path.join(workspaceRoot, path.basename(withExtension));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return withExtension;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveWorkspacePath(workspaceRoot, requestedPath) {
|
|
127
|
+
const absolute = requestedPath
|
|
128
|
+
? path.isAbsolute(requestedPath)
|
|
129
|
+
? requestedPath
|
|
130
|
+
: path.resolve(workspaceRoot, requestedPath)
|
|
131
|
+
: workspaceRoot;
|
|
132
|
+
|
|
133
|
+
if (!absolute.startsWith(workspaceRoot)) {
|
|
134
|
+
throw new Error("Path is outside the workspace");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return absolute;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Folders that should never appear in the workspace file explorer.
|
|
141
|
+
const HIDDEN_DIRECTORY_NAMES = new Set([
|
|
142
|
+
"node_modules",
|
|
143
|
+
".nodebook-cache",
|
|
144
|
+
".git",
|
|
145
|
+
"env",
|
|
146
|
+
"dist",
|
|
147
|
+
"build",
|
|
148
|
+
".next",
|
|
149
|
+
".nuxt"
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
// Files that should never appear in the workspace file explorer.
|
|
153
|
+
const HIDDEN_FILE_NAMES = new Set([
|
|
154
|
+
"package-lock.json",
|
|
155
|
+
"yarn.lock",
|
|
156
|
+
"pnpm-lock.yaml"
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
async function listDirectoryEntries(workspaceRoot, directoryPath) {
|
|
160
|
+
const directoryEntries = await fs.promises.readdir(directoryPath, {
|
|
161
|
+
withFileTypes: true
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return directoryEntries
|
|
165
|
+
.filter((entry) => {
|
|
166
|
+
if (entry.name.startsWith(".")) return false;
|
|
167
|
+
if (entry.isDirectory() && HIDDEN_DIRECTORY_NAMES.has(entry.name)) return false;
|
|
168
|
+
if (!entry.isDirectory() && HIDDEN_FILE_NAMES.has(entry.name)) return false;
|
|
169
|
+
return true;
|
|
170
|
+
})
|
|
171
|
+
.map((entry) => {
|
|
172
|
+
const absolutePath = path.join(directoryPath, entry.name);
|
|
173
|
+
const relativePath = path.relative(workspaceRoot, absolutePath) || ".";
|
|
174
|
+
const isDirectory = entry.isDirectory();
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
name: entry.name,
|
|
178
|
+
path: absolutePath,
|
|
179
|
+
relativePath,
|
|
180
|
+
type: isDirectory ? "directory" : "file",
|
|
181
|
+
openable: !isDirectory && isOpenableFile(absolutePath),
|
|
182
|
+
fileKind: !isDirectory ? getOpenableFileKind(absolutePath) : null,
|
|
183
|
+
expandable: isDirectory
|
|
184
|
+
};
|
|
185
|
+
})
|
|
186
|
+
.sort((left, right) => {
|
|
187
|
+
if (left.type !== right.type) {
|
|
188
|
+
return left.type === "directory" ? -1 : 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return left.name.localeCompare(right.name);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function readRequestBody(request) {
|
|
196
|
+
const chunks = [];
|
|
197
|
+
|
|
198
|
+
for await (const chunk of request) {
|
|
199
|
+
chunks.push(chunk);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (chunks.length === 0) {
|
|
203
|
+
return {};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function ensureNotebook(notebookPath) {
|
|
210
|
+
await fs.promises.mkdir(path.dirname(notebookPath), { recursive: true });
|
|
211
|
+
|
|
212
|
+
if (!fs.existsSync(notebookPath)) {
|
|
213
|
+
const notebook = createNotebook(path.basename(notebookPath, NOTEBOOK_EXTENSION));
|
|
214
|
+
await fs.promises.writeFile(notebookPath, JSON.stringify(notebook, null, 2));
|
|
215
|
+
return notebook;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const raw = await fs.promises.readFile(notebookPath, "utf8");
|
|
219
|
+
|
|
220
|
+
// Handle blank or malformed .ijsnb files created externally (e.g. `touch notebook.ijsnb`).
|
|
221
|
+
// Rather than crashing with "Unexpected end of JSON input", we initialise a fresh
|
|
222
|
+
// notebook with a single empty code cell and write it back so the file is valid.
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = JSON.parse(raw);
|
|
226
|
+
} catch {
|
|
227
|
+
const notebook = createNotebook(path.basename(notebookPath, NOTEBOOK_EXTENSION));
|
|
228
|
+
await fs.promises.writeFile(notebookPath, JSON.stringify(notebook, null, 2));
|
|
229
|
+
return notebook;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return normalizeNotebook(parsed);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function saveNotebook(notebookPath, notebook) {
|
|
236
|
+
const normalized = normalizeNotebook(notebook);
|
|
237
|
+
await fs.promises.mkdir(path.dirname(notebookPath), { recursive: true });
|
|
238
|
+
await fs.promises.writeFile(notebookPath, JSON.stringify(normalized, null, 2));
|
|
239
|
+
return normalized;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function saveNotebookAtPath(currentPath, notebook, nextPath = currentPath) {
|
|
243
|
+
const normalized = normalizeNotebook(notebook);
|
|
244
|
+
const targetPath = nextPath || currentPath;
|
|
245
|
+
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
|
246
|
+
await fs.promises.writeFile(targetPath, JSON.stringify(normalized, null, 2));
|
|
247
|
+
|
|
248
|
+
if (currentPath !== targetPath && fs.existsSync(currentPath)) {
|
|
249
|
+
await fs.promises.unlink(currentPath);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
notebook: normalized,
|
|
254
|
+
notebookPath: targetPath
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function createNotebookRequire(notebookPath) {
|
|
259
|
+
return createRequire(path.join(path.dirname(notebookPath), "__nodebook__.cjs"));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Find the root directory of an @types/ package by walking up the directory tree.
|
|
264
|
+
*
|
|
265
|
+
* `@types/*` packages contain ONLY `.d.ts` declaration files — they have no
|
|
266
|
+
* JavaScript entry point. This means `require.resolve('@types/express')` always
|
|
267
|
+
* throws MODULE_NOT_FOUND, so we cannot use resolvePackageRoot() for them.
|
|
268
|
+
* Instead we look for the package directory directly in every node_modules/@types/
|
|
269
|
+
* folder on the path from the notebook directory up to the filesystem root.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} notebookPath – absolute path to the .ijsnb file
|
|
272
|
+
* @param {string} atTypesName – full scoped name, e.g. "@types/express" or "@types/scope__pkg"
|
|
273
|
+
* @returns {string|null} absolute path to the @types/<name> root, or null if not found
|
|
274
|
+
*/
|
|
275
|
+
function findAtTypesRoot(notebookPath, atTypesName) {
|
|
276
|
+
// Strip the "@types/" prefix to get the directory name inside @types/
|
|
277
|
+
const suffix = atTypesName.startsWith("@types/")
|
|
278
|
+
? atTypesName.slice("@types/".length)
|
|
279
|
+
: atTypesName;
|
|
280
|
+
|
|
281
|
+
let dir = path.dirname(notebookPath);
|
|
282
|
+
const root = path.parse(dir).root;
|
|
283
|
+
|
|
284
|
+
while (dir !== root) {
|
|
285
|
+
const candidate = path.join(dir, "node_modules", "@types", suffix);
|
|
286
|
+
if (fs.existsSync(path.join(candidate, "package.json"))) {
|
|
287
|
+
return candidate;
|
|
288
|
+
}
|
|
289
|
+
dir = path.dirname(dir);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function findPackageRootFromEntry(entryPath) {
|
|
296
|
+
let currentPath = fs.statSync(entryPath).isDirectory() ? entryPath : path.dirname(entryPath);
|
|
297
|
+
|
|
298
|
+
while (currentPath !== path.dirname(currentPath)) {
|
|
299
|
+
if (fs.existsSync(path.join(currentPath, "package.json"))) {
|
|
300
|
+
return currentPath;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
currentPath = path.dirname(currentPath);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function resolvePackageRoot(notebookPath, moduleName) {
|
|
310
|
+
try {
|
|
311
|
+
const notebookRequire = createNotebookRequire(notebookPath);
|
|
312
|
+
const entryPath = notebookRequire.resolve(moduleName);
|
|
313
|
+
return findPackageRootFromEntry(entryPath);
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getNotebookNodeModulesRoot(notebookPath) {
|
|
320
|
+
return path.join(path.dirname(notebookPath), "node_modules");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function toVirtualModuleFile(notebookPath, absolutePath) {
|
|
324
|
+
const nodeModulesRoot = getNotebookNodeModulesRoot(notebookPath);
|
|
325
|
+
|
|
326
|
+
if (absolutePath.startsWith(nodeModulesRoot)) {
|
|
327
|
+
const relativePart = absolutePath.slice(nodeModulesRoot.length);
|
|
328
|
+
return `file:///node_modules${relativePart.replaceAll(path.sep, "/")}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return pathToFileURL(absolutePath).href;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function getDeclarationImportSpecifier(declarationPath, packageRoot) {
|
|
335
|
+
const relativePath = path.relative(packageRoot, declarationPath).replaceAll(path.sep, "/");
|
|
336
|
+
|
|
337
|
+
return `./${relativePath.replace(/\.d\.(cts|mts|ts)$/, "")}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function resolveDeclarationEntry(packageRoot, packageJson) {
|
|
341
|
+
const candidates = [];
|
|
342
|
+
const rootExport = packageJson.exports?.["."];
|
|
343
|
+
|
|
344
|
+
if (typeof rootExport === "object" && rootExport !== null) {
|
|
345
|
+
if (typeof rootExport.types === "string") {
|
|
346
|
+
candidates.push(rootExport.types);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (typeof rootExport.import === "object" && rootExport.import !== null && typeof rootExport.import.types === "string") {
|
|
350
|
+
candidates.push(rootExport.import.types);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (typeof rootExport.require === "object" && rootExport.require !== null && typeof rootExport.require.types === "string") {
|
|
354
|
+
candidates.push(rootExport.require.types);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (typeof packageJson.types === "string") {
|
|
359
|
+
candidates.push(packageJson.types);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (typeof packageJson.typings === "string") {
|
|
363
|
+
candidates.push(packageJson.typings);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (typeof packageJson.module === "string") {
|
|
367
|
+
candidates.push(packageJson.module.replace(/\.js$/, ".d.ts"));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (typeof packageJson.main === "string") {
|
|
371
|
+
candidates.push(packageJson.main.replace(/\.cjs$/, ".d.cts").replace(/\.js$/, ".d.ts"));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
candidates.push("index.d.ts", "index.d.cts", "index.d.mts");
|
|
375
|
+
|
|
376
|
+
for (const candidate of candidates) {
|
|
377
|
+
const absolutePath = path.resolve(packageRoot, candidate);
|
|
378
|
+
|
|
379
|
+
if (fs.existsSync(absolutePath)) {
|
|
380
|
+
return absolutePath;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function collectDeclarationFiles(packageRoot, maxFiles = 660) {
|
|
388
|
+
const results = [];
|
|
389
|
+
const queue = [packageRoot];
|
|
390
|
+
|
|
391
|
+
while (queue.length > 0 && results.length < maxFiles) {
|
|
392
|
+
const currentDirectory = queue.shift();
|
|
393
|
+
const directoryEntries = await fs.promises.readdir(currentDirectory, {
|
|
394
|
+
withFileTypes: true
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
for (const entry of directoryEntries) {
|
|
398
|
+
if (results.length >= maxFiles) {
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (entry.name === "node_modules") {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const absolutePath = path.join(currentDirectory, entry.name);
|
|
407
|
+
|
|
408
|
+
if (entry.isDirectory()) {
|
|
409
|
+
queue.push(absolutePath);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (entry.isFile() && (entry.name.endsWith(".d.ts") || entry.name.endsWith(".d.cts") || entry.name.endsWith(".d.mts"))) {
|
|
414
|
+
results.push(absolutePath);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return results;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function discoverAtTypesPackages(notebookPath) {
|
|
423
|
+
const nodeModulesRoot = getNotebookNodeModulesRoot(notebookPath);
|
|
424
|
+
const atTypesDir = path.join(nodeModulesRoot, "@types");
|
|
425
|
+
const results = [];
|
|
426
|
+
|
|
427
|
+
if (!fs.existsSync(atTypesDir)) {
|
|
428
|
+
return results;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const entries = await fs.promises.readdir(atTypesDir, { withFileTypes: true });
|
|
432
|
+
|
|
433
|
+
for (const entry of entries) {
|
|
434
|
+
if (!entry.isDirectory()) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const scopedName = `@types/${entry.name}`;
|
|
439
|
+
const packageRoot = path.join(atTypesDir, entry.name);
|
|
440
|
+
results.push({ moduleName: entry.name, scopedName, packageRoot });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return results;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Rewrite `.js` / `.cjs` / `.mjs` extension imports inside a `.d.ts` file to their
|
|
448
|
+
* declaration-file equivalents so TypeScript can resolve them inside the virtual FS.
|
|
449
|
+
* e.g. `from "./chat_models.js"` → `from "./chat_models"`
|
|
450
|
+
*/
|
|
451
|
+
function rewriteDeclarationImports(content) {
|
|
452
|
+
return content.replace(
|
|
453
|
+
/(from\s+["'])(\.{1,2}\/[^"']*?)\.(js|cjs|mjs)(["'])/g,
|
|
454
|
+
"$1$2$4"
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Maximum bytes for a single declaration file. Giant auto-generated rollup
|
|
459
|
+
// files (e.g. @google/genai ships three ~480 KB .d.ts files that are identical
|
|
460
|
+
// copies of the full API) provide no extra IntelliSense value and burn through
|
|
461
|
+
// the shared size budget before smaller, more important packages are loaded.
|
|
462
|
+
const MAX_BYTES_PER_FILE = 1000_000; // 1 MB per file
|
|
463
|
+
|
|
464
|
+
// Total byte budget for all declaration content. 10 MB is enough for the
|
|
465
|
+
// packages a typical notebook actually imports; the client now requests only
|
|
466
|
+
// used packages so the budget is rarely approached.
|
|
467
|
+
const MAX_TOTAL_BYTES = 10_000_000; // 10 MB total
|
|
468
|
+
|
|
469
|
+
// ─── Per-package type-library cache ────────────────────────────────────────
|
|
470
|
+
// Keyed by `${packageRoot}:${mtimeMs}` of the package's package.json.
|
|
471
|
+
// Value: Array of { seenKey, file, moduleName, content, contentBytes }
|
|
472
|
+
// seenKey – the key put into `seenFiles` (abs path for .d.ts, virtual path for pkg.json)
|
|
473
|
+
// file – virtual monaco path ("file:///node_modules/…")
|
|
474
|
+
// The cache is per-packageRoot so adding/removing one package only invalidates
|
|
475
|
+
// that package's entry; all others remain warm.
|
|
476
|
+
const packageTypeLibsCache = new Map();
|
|
477
|
+
|
|
478
|
+
// ─── Per-package exports cache ──────────────────────────────────────────────
|
|
479
|
+
// Keyed by `${packageRoot}:${mtimeMs}`. Value: ExportEntry[]
|
|
480
|
+
const packageExportsCache = new Map();
|
|
481
|
+
|
|
482
|
+
async function addPackageTypeLibraries(notebookPath, moduleName, packageRoot, typeLibraries, seenFiles, stats) {
|
|
483
|
+
const pkgJsonPath = path.join(packageRoot, "package.json");
|
|
484
|
+
|
|
485
|
+
// ── Cache lookup ──────────────────────────────────────────────────────────
|
|
486
|
+
let mtimeMs = 0;
|
|
487
|
+
try {
|
|
488
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
489
|
+
mtimeMs = (await fs.promises.stat(pkgJsonPath)).mtimeMs;
|
|
490
|
+
}
|
|
491
|
+
} catch { /* ignore stat errors */ }
|
|
492
|
+
|
|
493
|
+
const cacheKey = `${packageRoot}:${mtimeMs}`;
|
|
494
|
+
|
|
495
|
+
if (packageTypeLibsCache.has(cacheKey)) {
|
|
496
|
+
// Apply cached entries, respecting the shared byte budget and seenFiles.
|
|
497
|
+
const cachedEntries = packageTypeLibsCache.get(cacheKey);
|
|
498
|
+
for (const entry of cachedEntries) {
|
|
499
|
+
if (seenFiles.has(entry.seenKey)) continue;
|
|
500
|
+
if (stats.totalBytes + entry.contentBytes > MAX_TOTAL_BYTES) return false;
|
|
501
|
+
stats.totalBytes += entry.contentBytes;
|
|
502
|
+
seenFiles.add(entry.seenKey);
|
|
503
|
+
typeLibraries.push({ moduleName: entry.moduleName, file: entry.file, content: entry.content });
|
|
504
|
+
}
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── Cache miss — collect fresh and store ──────────────────────────────────
|
|
509
|
+
const freshEntries = []; // will be stored in cache
|
|
510
|
+
|
|
511
|
+
const pkgJson = fs.existsSync(pkgJsonPath)
|
|
512
|
+
? JSON.parse(await fs.promises.readFile(pkgJsonPath, "utf8"))
|
|
513
|
+
: {};
|
|
514
|
+
const declarationEntry = resolveDeclarationEntry(packageRoot, pkgJson);
|
|
515
|
+
const declarationFiles = await collectDeclarationFiles(packageRoot);
|
|
516
|
+
|
|
517
|
+
if (declarationFiles.length === 0 && !declarationEntry) {
|
|
518
|
+
// Package has no type declarations at all — cache the empty result and skip.
|
|
519
|
+
packageTypeLibsCache.set(cacheKey, freshEntries);
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Add a virtual package.json so Monaco's TypeScript service can read the `types` field
|
|
524
|
+
// and resolve the correct entry declaration without guessing.
|
|
525
|
+
if (declarationEntry && pkgJsonPath) {
|
|
526
|
+
const relativeTypes = path.relative(packageRoot, declarationEntry).replaceAll(path.sep, "/");
|
|
527
|
+
const virtualPkgJson = JSON.stringify({ name: moduleName, types: `./${relativeTypes}`, version: pkgJson.version ?? "0.0.0" });
|
|
528
|
+
const virtualPkgJsonPath = toVirtualModuleFile(notebookPath, pkgJsonPath);
|
|
529
|
+
freshEntries.push({
|
|
530
|
+
seenKey: virtualPkgJsonPath,
|
|
531
|
+
moduleName,
|
|
532
|
+
file: virtualPkgJsonPath,
|
|
533
|
+
content: virtualPkgJson,
|
|
534
|
+
contentBytes: Buffer.byteLength(virtualPkgJson)
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
for (const declarationFile of declarationFiles) {
|
|
539
|
+
let content = await fs.promises.readFile(declarationFile, "utf8");
|
|
540
|
+
|
|
541
|
+
// Rewrite `.js` extension imports so they resolve correctly in the virtual FS
|
|
542
|
+
content = rewriteDeclarationImports(content);
|
|
543
|
+
|
|
544
|
+
const contentBytes = Buffer.byteLength(content);
|
|
545
|
+
freshEntries.push({
|
|
546
|
+
seenKey: declarationFile, // absolute path — matches original seenFiles key
|
|
547
|
+
moduleName,
|
|
548
|
+
file: toVirtualModuleFile(notebookPath, declarationFile),
|
|
549
|
+
content,
|
|
550
|
+
contentBytes
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Ensure there is an index.d.ts at the package root so TypeScript's standard
|
|
555
|
+
// node_modules resolution finds the package without needing to read package.json.
|
|
556
|
+
const rootIndexPath = path.join(packageRoot, "index.d.ts");
|
|
557
|
+
if (!fs.existsSync(rootIndexPath)) {
|
|
558
|
+
const entrySpecifier = declarationEntry
|
|
559
|
+
? getDeclarationImportSpecifier(declarationEntry, packageRoot)
|
|
560
|
+
: null;
|
|
561
|
+
|
|
562
|
+
if (entrySpecifier) {
|
|
563
|
+
const aliasSource = `export * from ${JSON.stringify(entrySpecifier)};\nexport { } from ${JSON.stringify(entrySpecifier)};\n`;
|
|
564
|
+
const virtualIndexPath = toVirtualModuleFile(notebookPath, rootIndexPath);
|
|
565
|
+
freshEntries.push({
|
|
566
|
+
seenKey: virtualIndexPath,
|
|
567
|
+
moduleName,
|
|
568
|
+
file: virtualIndexPath,
|
|
569
|
+
content: aliasSource,
|
|
570
|
+
contentBytes: Buffer.byteLength(aliasSource)
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Store in cache before applying (so a budget-exceeded early return still caches)
|
|
576
|
+
packageTypeLibsCache.set(cacheKey, freshEntries);
|
|
577
|
+
|
|
578
|
+
// Apply to output arrays, respecting budget and seenFiles.
|
|
579
|
+
for (const entry of freshEntries) {
|
|
580
|
+
if (seenFiles.has(entry.seenKey)) continue;
|
|
581
|
+
if (stats.totalBytes + entry.contentBytes > MAX_TOTAL_BYTES) return false;
|
|
582
|
+
stats.totalBytes += entry.contentBytes;
|
|
583
|
+
seenFiles.add(entry.seenKey);
|
|
584
|
+
typeLibraries.push({ moduleName: entry.moduleName, file: entry.file, content: entry.content });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Enumerate every package in node_modules (depth-1 + scoped-depth-2).
|
|
592
|
+
* Returns [{moduleName, packageRoot}] for packages that actually have declaration files.
|
|
593
|
+
*/
|
|
594
|
+
async function discoverAllNodeModulesPackages(notebookPath) {
|
|
595
|
+
const nodeModulesRoot = getNotebookNodeModulesRoot(notebookPath);
|
|
596
|
+
|
|
597
|
+
if (!fs.existsSync(nodeModulesRoot)) {
|
|
598
|
+
return [];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const results = [];
|
|
602
|
+
const entries = await fs.promises.readdir(nodeModulesRoot, { withFileTypes: true });
|
|
603
|
+
|
|
604
|
+
for (const entry of entries) {
|
|
605
|
+
if (!entry.isDirectory()) continue;
|
|
606
|
+
|
|
607
|
+
// Scoped package directory (e.g. @langchain)
|
|
608
|
+
if (entry.name.startsWith("@")) {
|
|
609
|
+
const scopedDir = path.join(nodeModulesRoot, entry.name);
|
|
610
|
+
|
|
611
|
+
try {
|
|
612
|
+
const scopedEntries = await fs.promises.readdir(scopedDir, { withFileTypes: true });
|
|
613
|
+
|
|
614
|
+
for (const scopedEntry of scopedEntries) {
|
|
615
|
+
if (!scopedEntry.isDirectory()) continue;
|
|
616
|
+
const moduleName = `${entry.name}/${scopedEntry.name}`;
|
|
617
|
+
const packageRoot = path.join(scopedDir, scopedEntry.name);
|
|
618
|
+
results.push({ moduleName, packageRoot });
|
|
619
|
+
}
|
|
620
|
+
} catch {
|
|
621
|
+
// Ignore unreadable scoped directories
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Skip hidden/internal directories
|
|
628
|
+
if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
|
|
629
|
+
|
|
630
|
+
const packageRoot = path.join(nodeModulesRoot, entry.name);
|
|
631
|
+
results.push({ moduleName: entry.name, packageRoot });
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return results;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Collect TypeScript declaration libraries for Monaco IntelliSense.
|
|
639
|
+
*
|
|
640
|
+
* @param {string} notebookPath
|
|
641
|
+
* @param {Set<string>|null} filterPackages
|
|
642
|
+
* When non-null, only load type libraries for packages whose moduleName is
|
|
643
|
+
* in this set (plus their direct npm dependencies, expanded automatically).
|
|
644
|
+
* Pass null to load types for every package in node_modules (full mode).
|
|
645
|
+
*/
|
|
646
|
+
async function collectTypeLibraries(notebookPath, filterPackages = null) {
|
|
647
|
+
const nodeModulesRoot = getNotebookNodeModulesRoot(notebookPath);
|
|
648
|
+
|
|
649
|
+
// When no filter is active we need a local node_modules to enumerate packages.
|
|
650
|
+
// When a filter IS active we can resolve packages via Node's full module resolution
|
|
651
|
+
// chain (which traverses parent directories), so a local node_modules is not required.
|
|
652
|
+
if (!filterPackages && !fs.existsSync(nodeModulesRoot)) {
|
|
653
|
+
return [];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const typeLibraries = [];
|
|
657
|
+
const seenFiles = new Set();
|
|
658
|
+
const seenModules = new Set();
|
|
659
|
+
const stats = { totalBytes: 0 };
|
|
660
|
+
|
|
661
|
+
// Discover packages that live directly in the workspace node_modules.
|
|
662
|
+
const allPackages = await discoverAllNodeModulesPackages(notebookPath);
|
|
663
|
+
|
|
664
|
+
let effectiveFilter = null;
|
|
665
|
+
if (filterPackages && filterPackages.size > 0) {
|
|
666
|
+
effectiveFilter = new Set(filterPackages);
|
|
667
|
+
|
|
668
|
+
// Also pull in @types/<name> companions for each explicitly-requested package.
|
|
669
|
+
// Many packages (e.g. express, mocha) ship no bundled declarations and rely on
|
|
670
|
+
// DefinitelyTyped (@types/express, etc.) for IntelliSense.
|
|
671
|
+
for (const pkgName of filterPackages) {
|
|
672
|
+
if (pkgName.startsWith("@types/")) continue; // already a @types package
|
|
673
|
+
if (pkgName.startsWith("@")) {
|
|
674
|
+
// @scope/name → @types/scope__name (DefinitelyTyped convention)
|
|
675
|
+
effectiveFilter.add(`@types/${pkgName.slice(1).replace("/", "__")}`);
|
|
676
|
+
} else {
|
|
677
|
+
effectiveFilter.add(`@types/${pkgName}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Expand filter: include direct dependencies of each requested package.
|
|
682
|
+
for (const pkgName of filterPackages) {
|
|
683
|
+
const pkgRoot = resolvePackageRoot(notebookPath, pkgName);
|
|
684
|
+
if (!pkgRoot) continue;
|
|
685
|
+
try {
|
|
686
|
+
const pkgJsonPath = path.join(pkgRoot, "package.json");
|
|
687
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
688
|
+
const pkgJson = JSON.parse(await fs.promises.readFile(pkgJsonPath, "utf8"));
|
|
689
|
+
for (const dep of Object.keys(pkgJson.dependencies ?? {})) {
|
|
690
|
+
effectiveFilter.add(dep);
|
|
691
|
+
}
|
|
692
|
+
for (const dep of Object.keys(pkgJson.peerDependencies ?? {})) {
|
|
693
|
+
effectiveFilter.add(dep);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} catch { /* ignore */ }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// For every package in the effective filter, fall back to Node's full module
|
|
700
|
+
// resolution (require.resolve traverses parent directories) to find packages
|
|
701
|
+
// that are accessible but not in the workspace's own node_modules.
|
|
702
|
+
// This handles: packages in the marsbook server's own node_modules, globally
|
|
703
|
+
// installed packages, and monorepo hoisted packages.
|
|
704
|
+
const localModuleNames = new Set(allPackages.map(p => p.moduleName));
|
|
705
|
+
for (const pkgName of effectiveFilter) {
|
|
706
|
+
if (localModuleNames.has(pkgName)) continue;
|
|
707
|
+
|
|
708
|
+
let pkgRoot = null;
|
|
709
|
+
|
|
710
|
+
if (pkgName.startsWith("@types/")) {
|
|
711
|
+
// @types/* packages contain only .d.ts files — no JavaScript entry point.
|
|
712
|
+
// require.resolve() always throws MODULE_NOT_FOUND for them, so we must
|
|
713
|
+
// locate them via a direct filesystem walk instead.
|
|
714
|
+
pkgRoot = findAtTypesRoot(notebookPath, pkgName);
|
|
715
|
+
} else {
|
|
716
|
+
pkgRoot = resolvePackageRoot(notebookPath, pkgName);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (pkgRoot) {
|
|
720
|
+
allPackages.push({ moduleName: pkgName, packageRoot: pkgRoot });
|
|
721
|
+
localModuleNames.add(pkgName);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Sort packages so that smaller packages come first — this maximises the number
|
|
727
|
+
// of packages that fit within the total byte budget before it is exhausted.
|
|
728
|
+
// We approximate package "size" by the number of declaration files it ships.
|
|
729
|
+
const packageSizes = new Map();
|
|
730
|
+
await Promise.all(
|
|
731
|
+
allPackages.map(async ({ moduleName, packageRoot }) => {
|
|
732
|
+
// Skip size-estimation for packages outside the filter (no need to sort them)
|
|
733
|
+
if (effectiveFilter && !effectiveFilter.has(moduleName)) return;
|
|
734
|
+
try {
|
|
735
|
+
const files = await collectDeclarationFiles(packageRoot);
|
|
736
|
+
packageSizes.set(moduleName, files.length);
|
|
737
|
+
} catch {
|
|
738
|
+
packageSizes.set(moduleName, 0);
|
|
739
|
+
}
|
|
740
|
+
})
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
const priorityPackages = [
|
|
744
|
+
"@langchain/core",
|
|
745
|
+
"@langchain/groq",
|
|
746
|
+
"groq-sdk"
|
|
747
|
+
];
|
|
748
|
+
|
|
749
|
+
allPackages.sort((a, b) => {
|
|
750
|
+
const aPriority = priorityPackages.some(p => a.moduleName.startsWith(p)) ? -1 : 0;
|
|
751
|
+
const bPriority = priorityPackages.some(p => b.moduleName.startsWith(p)) ? -1 : 0;
|
|
752
|
+
|
|
753
|
+
if (aPriority !== bPriority) {
|
|
754
|
+
return aPriority - bPriority;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const sizeA = packageSizes.get(a.moduleName) ?? 0;
|
|
758
|
+
const sizeB = packageSizes.get(b.moduleName) ?? 0;
|
|
759
|
+
|
|
760
|
+
return sizeA - sizeB;
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
for (const { moduleName, packageRoot } of allPackages) {
|
|
764
|
+
// Skip packages outside the effective filter when one is active.
|
|
765
|
+
if (effectiveFilter && !effectiveFilter.has(moduleName)) continue;
|
|
766
|
+
|
|
767
|
+
if (seenModules.has(moduleName)) continue;
|
|
768
|
+
seenModules.add(moduleName);
|
|
769
|
+
|
|
770
|
+
const ok = await addPackageTypeLibraries(notebookPath, moduleName, packageRoot, typeLibraries, seenFiles, stats);
|
|
771
|
+
|
|
772
|
+
if (!ok) {
|
|
773
|
+
// Size budget exhausted — return what we have
|
|
774
|
+
return typeLibraries;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return typeLibraries;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Like collectDeclaredPackageExports but with per-package mtime-based caching
|
|
783
|
+
* so repeated calls (e.g. each time /api/suggestions fires) hit memory instead
|
|
784
|
+
* of parsing TypeScript AST from disk every time.
|
|
785
|
+
*/
|
|
786
|
+
async function collectDeclaredPackageExportsCached(notebookPath, moduleNames) {
|
|
787
|
+
const exportEntries = [];
|
|
788
|
+
|
|
789
|
+
for (const moduleName of moduleNames) {
|
|
790
|
+
// @types/ packages are not directly imported by users; skip them here.
|
|
791
|
+
// The fallback inside collectDeclaredPackageExports already handles them.
|
|
792
|
+
if (moduleName.startsWith("@types/")) continue;
|
|
793
|
+
|
|
794
|
+
const packageRoot = resolvePackageRoot(notebookPath, moduleName);
|
|
795
|
+
|
|
796
|
+
// Some packages (e.g. "express") have no bundled declarations but DO have a
|
|
797
|
+
// companion "@types/express" package. When the package's own root is missing
|
|
798
|
+
// or lacks a package.json, we still want to attempt the @types fallback, so we
|
|
799
|
+
// use the @types package root for the cache key instead.
|
|
800
|
+
//
|
|
801
|
+
// IMPORTANT: @types/* packages have no JavaScript entry, so require.resolve()
|
|
802
|
+
// fails for them. Use findAtTypesRoot() (filesystem walk) instead.
|
|
803
|
+
const atTypesName = moduleName.startsWith("@")
|
|
804
|
+
? `@types/${moduleName.slice(1).replace("/", "__")}`
|
|
805
|
+
: `@types/${moduleName}`;
|
|
806
|
+
const atTypesRoot = !packageRoot ? findAtTypesRoot(notebookPath, atTypesName) : null;
|
|
807
|
+
|
|
808
|
+
// If neither the package nor its @types companion can be found, skip.
|
|
809
|
+
const effectiveRoot = packageRoot ?? atTypesRoot;
|
|
810
|
+
if (!effectiveRoot) continue;
|
|
811
|
+
|
|
812
|
+
const pkgJsonPath = path.join(effectiveRoot, "package.json");
|
|
813
|
+
if (!fs.existsSync(pkgJsonPath)) continue;
|
|
814
|
+
|
|
815
|
+
let mtimeMs = 0;
|
|
816
|
+
try { mtimeMs = (await fs.promises.stat(pkgJsonPath)).mtimeMs; } catch { /* ignore */ }
|
|
817
|
+
|
|
818
|
+
const cacheKey = `${effectiveRoot}:${mtimeMs}`;
|
|
819
|
+
|
|
820
|
+
if (packageExportsCache.has(cacheKey)) {
|
|
821
|
+
exportEntries.push(...packageExportsCache.get(cacheKey));
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Cache miss — parse this single package (with @types fallback) and store result.
|
|
826
|
+
const entries = await collectDeclaredPackageExports(notebookPath, [moduleName]);
|
|
827
|
+
packageExportsCache.set(cacheKey, entries);
|
|
828
|
+
exportEntries.push(...entries);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return exportEntries;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function ensurePackageJson(directoryPath) {
|
|
835
|
+
const packageJsonPath = path.join(directoryPath, "package.json");
|
|
836
|
+
|
|
837
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
838
|
+
const packageJson = {
|
|
839
|
+
name: path.basename(directoryPath).toLowerCase().replace(/[^a-z0-9-]+/g, "-") || "nodebook-project",
|
|
840
|
+
private: true,
|
|
841
|
+
type: "module"
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
await fs.promises.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function installPackages(notebookPath, packages) {
|
|
849
|
+
const workingDirectory = path.dirname(notebookPath);
|
|
850
|
+
await fs.promises.mkdir(workingDirectory, { recursive: true });
|
|
851
|
+
await ensurePackageJson(workingDirectory);
|
|
852
|
+
|
|
853
|
+
return new Promise((resolve) => {
|
|
854
|
+
const child = spawn("npm", ["install", ...packages], {
|
|
855
|
+
cwd: workingDirectory,
|
|
856
|
+
env: process.env
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
let stdout = "";
|
|
860
|
+
let stderr = "";
|
|
861
|
+
|
|
862
|
+
child.stdout.on("data", (chunk) => {
|
|
863
|
+
stdout += chunk.toString("utf8");
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
child.stderr.on("data", (chunk) => {
|
|
867
|
+
stderr += chunk.toString("utf8");
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
child.on("close", (code) => {
|
|
871
|
+
resolve({
|
|
872
|
+
ok: code === 0,
|
|
873
|
+
code,
|
|
874
|
+
stdout,
|
|
875
|
+
stderr
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function runShellCommand(command, cwd) {
|
|
882
|
+
// Ensure a package.json exists so npm commands work correctly in the directory
|
|
883
|
+
await ensurePackageJson(cwd);
|
|
884
|
+
|
|
885
|
+
return new Promise((resolve) => {
|
|
886
|
+
const child = spawn(command, {
|
|
887
|
+
cwd,
|
|
888
|
+
// Disable npm's interactive progress bar so it doesn't hang on non-TTY pipes.
|
|
889
|
+
// Also ensure PATH is inherited so nvm/volta-managed npm binaries are found.
|
|
890
|
+
env: { ...process.env, NPM_CONFIG_PROGRESS: "false", NPM_CONFIG_FUND: "false" },
|
|
891
|
+
shell: true,
|
|
892
|
+
// Explicitly close stdin — prevents npm (and other tools) from blocking
|
|
893
|
+
// waiting for input that will never arrive.
|
|
894
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
let stdout = "";
|
|
898
|
+
let stderr = "";
|
|
899
|
+
|
|
900
|
+
child.stdout.on("data", (chunk) => {
|
|
901
|
+
stdout += chunk.toString("utf8");
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
child.stderr.on("data", (chunk) => {
|
|
905
|
+
stderr += chunk.toString("utf8");
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
child.on("close", (code) => {
|
|
909
|
+
resolve({
|
|
910
|
+
ok: code === 0,
|
|
911
|
+
code,
|
|
912
|
+
stdout,
|
|
913
|
+
stderr
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
child.on("error", (err) => {
|
|
918
|
+
resolve({
|
|
919
|
+
ok: false,
|
|
920
|
+
code: -1,
|
|
921
|
+
stdout,
|
|
922
|
+
stderr: stderr + err.message
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function normalizeRepositoryUrl(repository) {
|
|
929
|
+
const rawValue =
|
|
930
|
+
typeof repository === "string"
|
|
931
|
+
? repository
|
|
932
|
+
: repository && typeof repository === "object" && typeof repository.url === "string"
|
|
933
|
+
? repository.url
|
|
934
|
+
: null;
|
|
935
|
+
|
|
936
|
+
if (!rawValue) {
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return rawValue
|
|
941
|
+
.replace(/^git\+/, "")
|
|
942
|
+
.replace(/^git:\/\//, "https://")
|
|
943
|
+
.replace(/\.git$/, "");
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function fetchPackageDocs(packageName) {
|
|
947
|
+
const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
|
|
948
|
+
const response = await fetch(registryUrl, {
|
|
949
|
+
headers: {
|
|
950
|
+
accept: "application/json"
|
|
951
|
+
},
|
|
952
|
+
signal: AbortSignal.timeout(10_000)
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
if (!response.ok) {
|
|
956
|
+
throw new Error(`Unable to fetch docs for ${packageName}`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const metadata = await response.json();
|
|
960
|
+
const latestVersion = metadata["dist-tags"]?.latest ?? null;
|
|
961
|
+
const latestManifest =
|
|
962
|
+
latestVersion && metadata.versions && typeof metadata.versions === "object"
|
|
963
|
+
? metadata.versions[latestVersion]
|
|
964
|
+
: null;
|
|
965
|
+
|
|
966
|
+
return {
|
|
967
|
+
name: metadata.name ?? packageName,
|
|
968
|
+
version: latestVersion ?? latestManifest?.version ?? null,
|
|
969
|
+
description: latestManifest?.description ?? metadata.description ?? "",
|
|
970
|
+
homepage: latestManifest?.homepage ?? metadata.homepage ?? null,
|
|
971
|
+
repositoryUrl: normalizeRepositoryUrl(latestManifest?.repository ?? metadata.repository),
|
|
972
|
+
npmUrl: `https://www.npmjs.com/package/${packageName}`,
|
|
973
|
+
readme: metadata.readme ?? latestManifest?.readme ?? ""
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function formatNotebookForSave(notebook) {
|
|
978
|
+
const normalized = normalizeNotebook(notebook);
|
|
979
|
+
const formattedCells = await Promise.all(
|
|
980
|
+
normalized.cells.map(async (cell) => {
|
|
981
|
+
if (cell.type === "prompt") {
|
|
982
|
+
return cell;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
let parser = null;
|
|
986
|
+
let plugins = [];
|
|
987
|
+
|
|
988
|
+
if (cell.type === "markdown") {
|
|
989
|
+
parser = "markdown";
|
|
990
|
+
plugins = [prettierPluginMarkdown];
|
|
991
|
+
} else if ((cell.language ?? "typescript") === "javascript") {
|
|
992
|
+
parser = "babel";
|
|
993
|
+
plugins = [prettierPluginBabel, prettierPluginEstree];
|
|
994
|
+
} else {
|
|
995
|
+
parser = "typescript";
|
|
996
|
+
plugins = [prettierPluginTypeScript, prettierPluginEstree];
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
try {
|
|
1000
|
+
const source = await prettier.format(cell.source ?? "", {
|
|
1001
|
+
parser,
|
|
1002
|
+
plugins,
|
|
1003
|
+
printWidth: 100,
|
|
1004
|
+
tabWidth: 2,
|
|
1005
|
+
semi: false,
|
|
1006
|
+
singleQuote: false
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
return {
|
|
1010
|
+
...cell,
|
|
1011
|
+
source
|
|
1012
|
+
};
|
|
1013
|
+
} catch {
|
|
1014
|
+
return cell;
|
|
1015
|
+
}
|
|
1016
|
+
})
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
return {
|
|
1020
|
+
...normalized,
|
|
1021
|
+
cells: formattedCells
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function resolveAiConfig(env = {}, notebookMeta = {}, promptConfig = {}) {
|
|
1026
|
+
const provider = promptConfig.provider ?? notebookMeta.provider ?? "groq";
|
|
1027
|
+
const requestedModel = promptConfig.model ?? notebookMeta.model ?? env.GROQ_MODEL ?? "llama-3.3-70b-versatile";
|
|
1028
|
+
const model = GROQ_MODELS_FALLBACK.includes(requestedModel) ? requestedModel : requestedModel || "llama-3.3-70b-versatile";
|
|
1029
|
+
const temperature = Number.isFinite(promptConfig.temperature) ? Number(promptConfig.temperature) : 0.2;
|
|
1030
|
+
|
|
1031
|
+
if (provider !== "groq") {
|
|
1032
|
+
throw new Error(`Unsupported AI provider: ${provider}`);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const apiKey = env.GROQ_API_KEY || process.env.GROQ_API_KEY;
|
|
1036
|
+
const baseUrl = (env.GROQ_BASE_URL || process.env.GROQ_BASE_URL || "https://api.groq.com/openai/v1").replace(/\/+$/, "");
|
|
1037
|
+
|
|
1038
|
+
if (!apiKey) {
|
|
1039
|
+
throw new Error("Missing GROQ_API_KEY");
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
return {
|
|
1043
|
+
provider,
|
|
1044
|
+
model,
|
|
1045
|
+
temperature,
|
|
1046
|
+
apiKey,
|
|
1047
|
+
baseUrl
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function requestAiCompletion(config, messages) {
|
|
1052
|
+
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
1053
|
+
method: "POST",
|
|
1054
|
+
headers: {
|
|
1055
|
+
"content-type": "application/json",
|
|
1056
|
+
authorization: `Bearer ${config.apiKey}`
|
|
1057
|
+
},
|
|
1058
|
+
body: JSON.stringify({
|
|
1059
|
+
model: config.model,
|
|
1060
|
+
temperature: config.temperature,
|
|
1061
|
+
messages
|
|
1062
|
+
}),
|
|
1063
|
+
signal: AbortSignal.timeout(60_000)
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
if (!response.ok) {
|
|
1067
|
+
const text = await response.text();
|
|
1068
|
+
throw new Error(text || "AI completion failed");
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const data = await response.json();
|
|
1072
|
+
return data.choices?.[0]?.message?.content ?? "";
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function streamAiCompletion(config, messages, response) {
|
|
1076
|
+
const upstream = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
1077
|
+
method: "POST",
|
|
1078
|
+
headers: {
|
|
1079
|
+
"content-type": "application/json",
|
|
1080
|
+
authorization: `Bearer ${config.apiKey}`
|
|
1081
|
+
},
|
|
1082
|
+
body: JSON.stringify({
|
|
1083
|
+
model: config.model,
|
|
1084
|
+
temperature: config.temperature,
|
|
1085
|
+
stream: true,
|
|
1086
|
+
stream_options: { include_usage: true },
|
|
1087
|
+
messages
|
|
1088
|
+
}),
|
|
1089
|
+
signal: AbortSignal.timeout(60_000)
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
if (!upstream.ok || !upstream.body) {
|
|
1093
|
+
const text = await upstream.text();
|
|
1094
|
+
throw new Error(text || "AI stream failed");
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
response.writeHead(200, {
|
|
1098
|
+
"content-type": "text/plain; charset=utf-8",
|
|
1099
|
+
"cache-control": "no-cache",
|
|
1100
|
+
connection: "keep-alive"
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
const decoder = new TextDecoder();
|
|
1104
|
+
let buffer = "";
|
|
1105
|
+
let usageData = null;
|
|
1106
|
+
|
|
1107
|
+
for await (const chunk of upstream.body) {
|
|
1108
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
1109
|
+
const lines = buffer.split("\n");
|
|
1110
|
+
buffer = lines.pop() ?? "";
|
|
1111
|
+
|
|
1112
|
+
for (const line of lines) {
|
|
1113
|
+
const trimmed = line.trim();
|
|
1114
|
+
if (!trimmed.startsWith("data:")) {
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const payload = trimmed.slice(5).trim();
|
|
1119
|
+
if (!payload || payload === "[DONE]") {
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
try {
|
|
1124
|
+
const data = JSON.parse(payload);
|
|
1125
|
+
const content = data.choices?.[0]?.delta?.content;
|
|
1126
|
+
if (content) {
|
|
1127
|
+
response.write(content);
|
|
1128
|
+
}
|
|
1129
|
+
// Capture usage data from any chunk that has it
|
|
1130
|
+
if (data.usage && typeof data.usage.prompt_tokens === "number") {
|
|
1131
|
+
usageData = data.usage;
|
|
1132
|
+
}
|
|
1133
|
+
} catch {
|
|
1134
|
+
// Ignore malformed SSE chunks from upstream
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Send token usage as a special sentinel line so the client can read real counts
|
|
1140
|
+
if (usageData) {
|
|
1141
|
+
response.write(`\n\x02TOKEN_USAGE:${JSON.stringify({ in: usageData.prompt_tokens, out: usageData.completion_tokens })}\n`);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
response.end();
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function resolveOpenablePath(workspaceRoot, notebooksDir, requestedPath, options = {}) {
|
|
1148
|
+
const absolutePath = resolveWorkspaceOpenPath(workspaceRoot, notebooksDir, requestedPath, {
|
|
1149
|
+
allowNotebookCreate: options.allowNotebookCreate ?? false,
|
|
1150
|
+
existsSync: fs.existsSync
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
if (!absolutePath || !absolutePath.startsWith(workspaceRoot)) {
|
|
1154
|
+
throw new Error("Path is outside the workspace");
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
return absolutePath;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async function readOpenableFile(workspaceRoot, notebooksDir, filePath) {
|
|
1161
|
+
const kind = getOpenableFileKind(filePath);
|
|
1162
|
+
|
|
1163
|
+
if (!kind) {
|
|
1164
|
+
throw new Error("File type is not supported");
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (kind === "notebook") {
|
|
1168
|
+
return {
|
|
1169
|
+
kind,
|
|
1170
|
+
path: filePath,
|
|
1171
|
+
appPath: getAppPathFromWorkspacePath(workspaceRoot, notebooksDir, filePath),
|
|
1172
|
+
notebook: await ensureNotebook(filePath)
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (kind === "text") {
|
|
1177
|
+
return {
|
|
1178
|
+
kind,
|
|
1179
|
+
path: filePath,
|
|
1180
|
+
appPath: getAppPathFromWorkspacePath(workspaceRoot, notebooksDir, filePath),
|
|
1181
|
+
name: path.basename(filePath),
|
|
1182
|
+
extension: path.extname(filePath).toLowerCase(),
|
|
1183
|
+
content: await fs.promises.readFile(filePath, "utf8")
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
return {
|
|
1188
|
+
kind,
|
|
1189
|
+
path: filePath,
|
|
1190
|
+
appPath: getAppPathFromWorkspacePath(workspaceRoot, notebooksDir, filePath),
|
|
1191
|
+
name: path.basename(filePath),
|
|
1192
|
+
extension: path.extname(filePath).toLowerCase(),
|
|
1193
|
+
contentUrl: `/api/file/content?path=${encodeURIComponent(filePath)}`
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
async function serveFile(rootDirectory, requestPath, response) {
|
|
1198
|
+
const absolutePath = path.join(rootDirectory, requestPath);
|
|
1199
|
+
|
|
1200
|
+
if (!absolutePath.startsWith(rootDirectory) || !fs.existsSync(absolutePath)) {
|
|
1201
|
+
notFound(response);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const extension = path.extname(absolutePath);
|
|
1206
|
+
response.writeHead(200, {
|
|
1207
|
+
"content-type": MIME_TYPES[extension] ?? "application/octet-stream"
|
|
1208
|
+
});
|
|
1209
|
+
fs.createReadStream(absolutePath).pipe(response);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async function serveStaticAsset(requestPath, response) {
|
|
1213
|
+
const safePath = requestPath === "/" ? "/index.html" : requestPath;
|
|
1214
|
+
const absolutePath = path.join(publicDir, safePath);
|
|
1215
|
+
|
|
1216
|
+
if (absolutePath.startsWith(publicDir) && fs.existsSync(absolutePath)) {
|
|
1217
|
+
await serveFile(publicDir, safePath, response);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
await serveFile(publicDir, "/index.html", response);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
export async function createServer(options = {}) {
|
|
1225
|
+
const workspaceRoot = path.resolve(options.workspaceRoot ?? process.cwd());
|
|
1226
|
+
// The workspace root IS the notebooks directory — users place .ijsnb files
|
|
1227
|
+
// directly in their project folder (or any subfolder), not in a separate
|
|
1228
|
+
// "notebooks/" subdirectory. The /notebooks/ segment only appears in the
|
|
1229
|
+
// browser URL as a cosmetic prefix.
|
|
1230
|
+
const notebooksDir = workspaceRoot;
|
|
1231
|
+
const sessions = new Map();
|
|
1232
|
+
const pendingInputResolvers = new Map(); // runId -> Array<resolve>
|
|
1233
|
+
|
|
1234
|
+
// Ensure the workspace root exists (in case the user pointed to a new dir)
|
|
1235
|
+
await fs.promises.mkdir(workspaceRoot, { recursive: true });
|
|
1236
|
+
|
|
1237
|
+
function getSession(notebookPath) {
|
|
1238
|
+
if (!sessions.has(notebookPath)) {
|
|
1239
|
+
sessions.set(notebookPath, new KernelSession(notebookPath));
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return sessions.get(notebookPath);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const server = http.createServer(async (request, response) => {
|
|
1246
|
+
try {
|
|
1247
|
+
const requestUrl = new URL(request.url, "http://127.0.0.1");
|
|
1248
|
+
|
|
1249
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/notebook") {
|
|
1250
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, requestUrl.searchParams.get("path"));
|
|
1251
|
+
const notebook = await ensureNotebook(notebookPath);
|
|
1252
|
+
|
|
1253
|
+
json(response, 200, {
|
|
1254
|
+
notebookPath,
|
|
1255
|
+
notebook
|
|
1256
|
+
});
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/open") {
|
|
1261
|
+
const requestedPath = requestUrl.searchParams.get("path");
|
|
1262
|
+
const absolutePath = requestedPath
|
|
1263
|
+
? resolveOpenablePath(workspaceRoot, notebooksDir, requestedPath, {
|
|
1264
|
+
allowNotebookCreate: String(requestedPath).endsWith(NOTEBOOK_EXTENSION)
|
|
1265
|
+
})
|
|
1266
|
+
: path.join(workspaceRoot, "startup.ijsnb");
|
|
1267
|
+
const resource = await readOpenableFile(workspaceRoot, notebooksDir, absolutePath);
|
|
1268
|
+
|
|
1269
|
+
json(response, 200, resource);
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Check whether a workspace file exists WITHOUT creating it.
|
|
1274
|
+
// Returns { exists: true|false }
|
|
1275
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/stat") {
|
|
1276
|
+
const rawPath = requestUrl.searchParams.get("path") ?? "";
|
|
1277
|
+
let filePath;
|
|
1278
|
+
try {
|
|
1279
|
+
filePath = resolveNotebookPath(workspaceRoot, notebooksDir, rawPath);
|
|
1280
|
+
} catch {
|
|
1281
|
+
json(response, 200, { exists: false });
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
json(response, 200, { exists: fs.existsSync(filePath) });
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/files") {
|
|
1289
|
+
const directoryPath = resolveWorkspacePath(workspaceRoot, requestUrl.searchParams.get("path"));
|
|
1290
|
+
const entries = await listDirectoryEntries(workspaceRoot, directoryPath);
|
|
1291
|
+
|
|
1292
|
+
json(response, 200, {
|
|
1293
|
+
rootPath: workspaceRoot,
|
|
1294
|
+
directoryPath,
|
|
1295
|
+
entries
|
|
1296
|
+
});
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/notebook/save") {
|
|
1301
|
+
const body = await readRequestBody(request);
|
|
1302
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
|
|
1303
|
+
const formattedNotebook = await formatNotebookForSave(body.notebook);
|
|
1304
|
+
const requestedNextPath = typeof body.nextPath === "string" && body.nextPath.trim() ? body.nextPath : null;
|
|
1305
|
+
const nextPath = requestedNextPath
|
|
1306
|
+
? resolveOpenablePath(workspaceRoot, notebooksDir, requestedNextPath, { allowNotebookCreate: true })
|
|
1307
|
+
: deriveNotebookPathFromTitle(notebookPath, formattedNotebook?.metadata?.title);
|
|
1308
|
+
const saved = await saveNotebookAtPath(notebookPath, formattedNotebook, nextPath);
|
|
1309
|
+
|
|
1310
|
+
if (notebookPath !== saved.notebookPath && sessions.has(notebookPath)) {
|
|
1311
|
+
const session = sessions.get(notebookPath);
|
|
1312
|
+
sessions.delete(notebookPath);
|
|
1313
|
+
session.notebookPath = saved.notebookPath;
|
|
1314
|
+
sessions.set(saved.notebookPath, session);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
json(response, 200, {
|
|
1318
|
+
ok: true,
|
|
1319
|
+
notebookPath: saved.notebookPath,
|
|
1320
|
+
appPath: getAppPathFromWorkspacePath(workspaceRoot, notebooksDir, saved.notebookPath),
|
|
1321
|
+
notebook: saved.notebook
|
|
1322
|
+
});
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/format") {
|
|
1327
|
+
const body = await readRequestBody(request);
|
|
1328
|
+
const notebook = await formatNotebookForSave(body.notebook);
|
|
1329
|
+
json(response, 200, { notebook });
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/file/content") {
|
|
1334
|
+
const filePath = resolveOpenablePath(workspaceRoot, notebooksDir, requestUrl.searchParams.get("path"));
|
|
1335
|
+
const fileKind = getOpenableFileKind(filePath);
|
|
1336
|
+
|
|
1337
|
+
if (fileKind !== "image" && fileKind !== "pdf") {
|
|
1338
|
+
json(response, 400, { error: "Binary content is only available for images and PDFs" });
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
response.writeHead(200, {
|
|
1343
|
+
"content-type": MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"
|
|
1344
|
+
});
|
|
1345
|
+
fs.createReadStream(filePath).pipe(response);
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/suggestions") {
|
|
1350
|
+
const rawPath = requestUrl.searchParams.get("path");
|
|
1351
|
+
if (!rawPath) {
|
|
1352
|
+
// No path → nothing to suggest, and we must NOT auto-create a notebook
|
|
1353
|
+
json(response, 200, { modules: [], installedPackages: [], typeLibraries: [], packageExports: [] });
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, rawPath);
|
|
1357
|
+
|
|
1358
|
+
// Guard: do NOT create a .ijsnb file when suggestions are requested for a
|
|
1359
|
+
// standalone text file (e.g. data_flow.js opened directly in the editor).
|
|
1360
|
+
// resolveNotebookPath always appends NOTEBOOK_EXTENSION, so without this
|
|
1361
|
+
// check we would spuriously create "data_flow.js.ijsnb" next to the JS file.
|
|
1362
|
+
const rawExt = path.extname(String(rawPath)).toLowerCase();
|
|
1363
|
+
const isNonNotebookFile = rawExt !== "" && rawExt !== NOTEBOOK_EXTENSION;
|
|
1364
|
+
if (!isNonNotebookFile) {
|
|
1365
|
+
await ensureNotebook(notebookPath);
|
|
1366
|
+
}
|
|
1367
|
+
const session = getSession(notebookPath);
|
|
1368
|
+
|
|
1369
|
+
// ── Field selection ────────────────────────────────────────────────
|
|
1370
|
+
// ?fields=modules,installedPackages → fast, no type work
|
|
1371
|
+
// ?fields=modules,installedPackages,typeLibraries,packageExports → full (default)
|
|
1372
|
+
const fieldsParam = requestUrl.searchParams.get("fields");
|
|
1373
|
+
const fields = fieldsParam
|
|
1374
|
+
? new Set(fieldsParam.split(",").map(f => f.trim()))
|
|
1375
|
+
: new Set(["modules", "installedPackages", "typeLibraries", "packageExports"]);
|
|
1376
|
+
|
|
1377
|
+
// ── Package filter ─────────────────────────────────────────────────
|
|
1378
|
+
// ?packages=pkg1,pkg2 → only compute type data for these packages
|
|
1379
|
+
// Omit the param entirely → compute for all installed packages (full mode)
|
|
1380
|
+
// ?packages= (empty value) → treat same as omitted (safety net)
|
|
1381
|
+
const packagesParamRaw = requestUrl.searchParams.get("packages");
|
|
1382
|
+
// `has` distinguishes "param present but empty" from "param absent".
|
|
1383
|
+
const packagesParamPresent = requestUrl.searchParams.has("packages");
|
|
1384
|
+
const filterPackages = (packagesParamPresent && packagesParamRaw)
|
|
1385
|
+
? new Set(packagesParamRaw.split(",").map(p => p.trim()).filter(Boolean))
|
|
1386
|
+
: null;
|
|
1387
|
+
|
|
1388
|
+
const result = {};
|
|
1389
|
+
|
|
1390
|
+
// modules + installedPackages are always cheap — compute only when requested
|
|
1391
|
+
let installedPackages = null;
|
|
1392
|
+
if (fields.has("modules")) {
|
|
1393
|
+
result.modules = await session.listInstalledModules();
|
|
1394
|
+
}
|
|
1395
|
+
if (fields.has("installedPackages")) {
|
|
1396
|
+
installedPackages = await session.listDeclaredPackages();
|
|
1397
|
+
result.installedPackages = installedPackages;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// typeLibraries — expensive disk scan, support filtering + caching
|
|
1401
|
+
if (fields.has("typeLibraries")) {
|
|
1402
|
+
result.typeLibraries = await collectTypeLibraries(notebookPath, filterPackages);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// packageExports — TypeScript AST parsing, support filtering + caching
|
|
1406
|
+
if (fields.has("packageExports")) {
|
|
1407
|
+
// Determine which packages to extract exports for.
|
|
1408
|
+
// When a package filter is given, use it (only explicitly-imported pkgs
|
|
1409
|
+
// need auto-complete export lists). Otherwise fall back to all installed packages.
|
|
1410
|
+
let exportPackages;
|
|
1411
|
+
if (filterPackages && filterPackages.size > 0) {
|
|
1412
|
+
exportPackages = [...filterPackages];
|
|
1413
|
+
} else {
|
|
1414
|
+
exportPackages = installedPackages ?? await session.listDeclaredPackages();
|
|
1415
|
+
}
|
|
1416
|
+
result.packageExports = await collectDeclaredPackageExportsCached(notebookPath, exportPackages);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Use compact JSON — typeLibraries can be many MB and pretty-printing
|
|
1420
|
+
// adds 15-20% overhead on a payload that's purely machine-to-machine.
|
|
1421
|
+
json(response, 200, result, /* compact= */ true);
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// ── /api/local-files — list .js/.ts files reachable from the notebook ───
|
|
1426
|
+
// Returns files with their relative import paths so Monaco can resolve both
|
|
1427
|
+
// `import { x } from "./controller/app"` (subdirectory) and
|
|
1428
|
+
// `import { y } from "../utils"` (parent directory).
|
|
1429
|
+
// Response shape: { files: [{ name: "controller/app.js", content: "..." }, ...] }
|
|
1430
|
+
// name is the import-ready relative path (without leading "./")
|
|
1431
|
+
// parent-dir files use "../<filename>" as the name.
|
|
1432
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/local-files") {
|
|
1433
|
+
const rawPath = requestUrl.searchParams.get("path");
|
|
1434
|
+
if (!rawPath) {
|
|
1435
|
+
json(response, 200, { files: [] });
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, rawPath);
|
|
1440
|
+
const notebookDir = path.dirname(notebookPath);
|
|
1441
|
+
const files = [];
|
|
1442
|
+
|
|
1443
|
+
// Directories to never descend into
|
|
1444
|
+
const SKIP_DIRS = new Set([
|
|
1445
|
+
"node_modules", ".git", ".nodebook-cache", ".cache",
|
|
1446
|
+
"dist", "build", "out", ".next", ".nuxt", ".svelte-kit",
|
|
1447
|
+
"coverage", ".turbo", ".vercel"
|
|
1448
|
+
]);
|
|
1449
|
+
const JS_EXTS = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]);
|
|
1450
|
+
const MAX_FILES = 300;
|
|
1451
|
+
const MAX_DEPTH = 5;
|
|
1452
|
+
const MAX_FILE_BYTES = 256_000; // 256 KB per file
|
|
1453
|
+
|
|
1454
|
+
// Read a single file and push it if it's within size limits
|
|
1455
|
+
const pushFile = async (absPath, relName) => {
|
|
1456
|
+
if (files.length >= MAX_FILES) return;
|
|
1457
|
+
try {
|
|
1458
|
+
const stat = await fs.promises.stat(absPath);
|
|
1459
|
+
if (stat.size > MAX_FILE_BYTES) return;
|
|
1460
|
+
const content = await fs.promises.readFile(absPath, "utf8");
|
|
1461
|
+
files.push({ name: relName, content });
|
|
1462
|
+
} catch {
|
|
1463
|
+
// skip unreadable / binary files
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
// Recursively scan a directory. relPrefix is the path relative to notebookDir
|
|
1468
|
+
// (empty string for the root, "controller" for a subdirectory, etc.).
|
|
1469
|
+
const scanDir = async (absDir, relPrefix, depth) => {
|
|
1470
|
+
if (files.length >= MAX_FILES || depth > MAX_DEPTH) return;
|
|
1471
|
+
let entries;
|
|
1472
|
+
try {
|
|
1473
|
+
entries = await fs.promises.readdir(absDir, { withFileTypes: true });
|
|
1474
|
+
} catch {
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
for (const entry of entries) {
|
|
1478
|
+
if (files.length >= MAX_FILES) break;
|
|
1479
|
+
if (entry.isDirectory()) {
|
|
1480
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
1481
|
+
const childRel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
1482
|
+
await scanDir(path.join(absDir, entry.name), childRel, depth + 1);
|
|
1483
|
+
} else if (entry.isFile()) {
|
|
1484
|
+
if (!JS_EXTS.has(path.extname(entry.name).toLowerCase())) continue;
|
|
1485
|
+
const relName = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
1486
|
+
await pushFile(path.join(absDir, entry.name), relName);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
};
|
|
1490
|
+
|
|
1491
|
+
// Scan the notebook's own directory (and all subdirectories)
|
|
1492
|
+
await scanDir(notebookDir, "", 0);
|
|
1493
|
+
|
|
1494
|
+
// Also scan the immediate parent directory (flat, not recursive) so that
|
|
1495
|
+
// `import { x } from "../utils"` works when the notebook is in a subdirectory.
|
|
1496
|
+
// Only do this if the parent is still inside the workspace root.
|
|
1497
|
+
const parentDir = path.dirname(notebookDir);
|
|
1498
|
+
if (parentDir !== notebookDir && parentDir.startsWith(workspaceRoot)) {
|
|
1499
|
+
let parentEntries = [];
|
|
1500
|
+
try { parentEntries = await fs.promises.readdir(parentDir, { withFileTypes: true }); } catch { /* ignore */ }
|
|
1501
|
+
for (const entry of parentEntries) {
|
|
1502
|
+
if (files.length >= MAX_FILES) break;
|
|
1503
|
+
if (!entry.isFile()) continue;
|
|
1504
|
+
if (!JS_EXTS.has(path.extname(entry.name).toLowerCase())) continue;
|
|
1505
|
+
await pushFile(path.join(parentDir, entry.name), `../${entry.name}`);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
json(response, 200, { files }, /* compact= */ true);
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/ai/config") {
|
|
1514
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, requestUrl.searchParams.get("path"));
|
|
1515
|
+
const notebook = await ensureNotebook(notebookPath);
|
|
1516
|
+
const env = notebook.metadata?.env ?? {};
|
|
1517
|
+
const apiKey = env.GROQ_API_KEY || process.env.GROQ_API_KEY;
|
|
1518
|
+
const models = await fetchGroqModels(apiKey);
|
|
1519
|
+
const savedModel = notebook.metadata?.ai?.model;
|
|
1520
|
+
|
|
1521
|
+
json(response, 200, {
|
|
1522
|
+
hasGroqKey: Boolean(apiKey),
|
|
1523
|
+
models,
|
|
1524
|
+
defaultModel: savedModel && models.includes(savedModel) ? savedModel : models[0]
|
|
1525
|
+
});
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/package-docs") {
|
|
1530
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, requestUrl.searchParams.get("path"));
|
|
1531
|
+
const packageName = String(requestUrl.searchParams.get("package") ?? "").trim();
|
|
1532
|
+
|
|
1533
|
+
if (!packageName) {
|
|
1534
|
+
json(response, 400, { error: "No package provided" });
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
await ensureNotebook(notebookPath);
|
|
1539
|
+
const docs = await fetchPackageDocs(packageName);
|
|
1540
|
+
json(response, 200, docs);
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/execute") {
|
|
1545
|
+
const body = await readRequestBody(request);
|
|
1546
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
|
|
1547
|
+
await ensureNotebook(notebookPath);
|
|
1548
|
+
const session = getSession(notebookPath);
|
|
1549
|
+
// Ensure any stray timers from a previous run are cleared before starting a new one
|
|
1550
|
+
session.cancelExecution();
|
|
1551
|
+
const stream = Boolean(body.stream);
|
|
1552
|
+
|
|
1553
|
+
if (stream) {
|
|
1554
|
+
response.writeHead(200, { "content-type": "application/x-ndjson; charset=utf-8" });
|
|
1555
|
+
const runId = randomUUID();
|
|
1556
|
+
let cleanedUp = false;
|
|
1557
|
+
const cleanup = () => {
|
|
1558
|
+
if (cleanedUp) return;
|
|
1559
|
+
cleanedUp = true;
|
|
1560
|
+
pendingInputResolvers.delete(runId);
|
|
1561
|
+
};
|
|
1562
|
+
const cancel = () => {
|
|
1563
|
+
session.cancelExecution();
|
|
1564
|
+
cleanup();
|
|
1565
|
+
};
|
|
1566
|
+
request.on("aborted", cancel);
|
|
1567
|
+
response.on("close", cancel);
|
|
1568
|
+
pendingInputResolvers.set(runId, []);
|
|
1569
|
+
const requestInput = (promptText) => {
|
|
1570
|
+
return new Promise((resolve) => {
|
|
1571
|
+
const queue = pendingInputResolvers.get(runId) ?? [];
|
|
1572
|
+
queue.push(resolve);
|
|
1573
|
+
pendingInputResolvers.set(runId, queue);
|
|
1574
|
+
response.write(JSON.stringify({ kind: "input_request", runId, prompt: promptText ?? "" }) + "\n");
|
|
1575
|
+
});
|
|
1576
|
+
};
|
|
1577
|
+
const result = await session.execute(
|
|
1578
|
+
body.code ?? "",
|
|
1579
|
+
body.cellId,
|
|
1580
|
+
body.env ?? {},
|
|
1581
|
+
body.language ?? "typescript",
|
|
1582
|
+
(output) => {
|
|
1583
|
+
response.write(JSON.stringify({ kind: "output", output }) + "\n");
|
|
1584
|
+
},
|
|
1585
|
+
requestInput
|
|
1586
|
+
);
|
|
1587
|
+
response.write(JSON.stringify({ kind: "result", result }) + "\n");
|
|
1588
|
+
cleanup();
|
|
1589
|
+
response.end();
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const result = await session.execute(
|
|
1594
|
+
body.code ?? "",
|
|
1595
|
+
body.cellId,
|
|
1596
|
+
body.env ?? {},
|
|
1597
|
+
body.language ?? "typescript",
|
|
1598
|
+
null,
|
|
1599
|
+
async () => ""
|
|
1600
|
+
);
|
|
1601
|
+
|
|
1602
|
+
json(response, 200, result);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/execute/input") {
|
|
1607
|
+
const body = await readRequestBody(request);
|
|
1608
|
+
const runId = String(body.runId ?? "");
|
|
1609
|
+
const value = body.value ?? "";
|
|
1610
|
+
const queue = pendingInputResolvers.get(runId);
|
|
1611
|
+
if (!queue || queue.length === 0) {
|
|
1612
|
+
json(response, 404, { ok: false, error: "No pending input for this run" });
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const resolver = queue.shift();
|
|
1616
|
+
resolver(String(value));
|
|
1617
|
+
json(response, 200, { ok: true });
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/execute/cancel") {
|
|
1622
|
+
const body = await readRequestBody(request);
|
|
1623
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
|
|
1624
|
+
const session = getSession(notebookPath);
|
|
1625
|
+
session?.cancelExecution();
|
|
1626
|
+
json(response, 200, { ok: true });
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/ai/assist") {
|
|
1631
|
+
const body = await readRequestBody(request);
|
|
1632
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
|
|
1633
|
+
const notebook = await ensureNotebook(notebookPath);
|
|
1634
|
+
const env = body.env && typeof body.env === "object" ? body.env : notebook.metadata?.env ?? {};
|
|
1635
|
+
const aiConfig = resolveAiConfig(env, notebook.metadata?.ai, body.ai ?? {});
|
|
1636
|
+
const instruction = String(body.instruction ?? "").trim() || "Explain this code and suggest an improved version.";
|
|
1637
|
+
const source = String(body.source ?? "");
|
|
1638
|
+
const responseText = await requestAiCompletion(aiConfig, [
|
|
1639
|
+
{
|
|
1640
|
+
role: "system",
|
|
1641
|
+
content:
|
|
1642
|
+
"You are a JavaScript and TypeScript notebook assistant. Be concise, preserve intent, and return actionable guidance."
|
|
1643
|
+
},
|
|
1644
|
+
{
|
|
1645
|
+
role: "user",
|
|
1646
|
+
content: `Instruction:\n${instruction}\n\nSource:\n${source}`
|
|
1647
|
+
}
|
|
1648
|
+
]);
|
|
1649
|
+
|
|
1650
|
+
json(response, 200, { text: responseText });
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/ai/chat") {
|
|
1655
|
+
const body = await readRequestBody(request);
|
|
1656
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
|
|
1657
|
+
const notebook = await ensureNotebook(notebookPath);
|
|
1658
|
+
const env = body.env && typeof body.env === "object" ? body.env : notebook.metadata?.env ?? {};
|
|
1659
|
+
const aiConfig = resolveAiConfig(env, notebook.metadata?.ai, { model: body.model, provider: "groq" });
|
|
1660
|
+
const messages = Array.isArray(body.messages)
|
|
1661
|
+
? body.messages
|
|
1662
|
+
.map((message) => ({
|
|
1663
|
+
role: message?.role === "assistant" ? "assistant" : "user",
|
|
1664
|
+
content: String(message?.content ?? "")
|
|
1665
|
+
}))
|
|
1666
|
+
.filter((message) => message.content.trim())
|
|
1667
|
+
: [];
|
|
1668
|
+
const source = String(body.source ?? "");
|
|
1669
|
+
const cellType = String(body.cellType ?? "code");
|
|
1670
|
+
|
|
1671
|
+
await streamAiCompletion(aiConfig, [
|
|
1672
|
+
{
|
|
1673
|
+
role: "system",
|
|
1674
|
+
content:
|
|
1675
|
+
"You are a concise JavaScript and TypeScript notebook assistant. Use the current cell as context, answer directly, and provide code when useful."
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
role: "system",
|
|
1679
|
+
content: `Current cell type: ${cellType}\n\nCurrent cell source:\n${source}`
|
|
1680
|
+
},
|
|
1681
|
+
...messages
|
|
1682
|
+
], response);
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/prompt/execute") {
|
|
1687
|
+
const body = await readRequestBody(request);
|
|
1688
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
|
|
1689
|
+
const notebook = await ensureNotebook(notebookPath);
|
|
1690
|
+
const env = body.env && typeof body.env === "object" ? body.env : notebook.metadata?.env ?? {};
|
|
1691
|
+
const aiConfig = resolveAiConfig(env, notebook.metadata?.ai, body.prompt ?? {});
|
|
1692
|
+
const userPrompt = String(body.source ?? "");
|
|
1693
|
+
const systemPrompt = String(body.prompt?.system ?? "");
|
|
1694
|
+
const messages = [];
|
|
1695
|
+
|
|
1696
|
+
if (systemPrompt) {
|
|
1697
|
+
messages.push({ role: "system", content: systemPrompt });
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
messages.push({ role: "user", content: userPrompt });
|
|
1701
|
+
await streamAiCompletion(aiConfig, messages, response);
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/install") {
|
|
1706
|
+
const body = await readRequestBody(request);
|
|
1707
|
+
const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
|
|
1708
|
+
const packages = Array.isArray(body.packages)
|
|
1709
|
+
? body.packages.map((value) => String(value).trim()).filter(Boolean)
|
|
1710
|
+
: [];
|
|
1711
|
+
|
|
1712
|
+
if (packages.length === 0) {
|
|
1713
|
+
json(response, 400, { ok: false, error: "No packages provided" });
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const result = await installPackages(notebookPath, packages);
|
|
1718
|
+
|
|
1719
|
+
if (result.ok) {
|
|
1720
|
+
const session = getSession(notebookPath);
|
|
1721
|
+
// Invalidate the bridge cache so the newly installed packages are
|
|
1722
|
+
// immediately importable without a server restart.
|
|
1723
|
+
session.invalidateImportCache();
|
|
1724
|
+
const modules = await session.listInstalledModules();
|
|
1725
|
+
const installedPackages = await session.listDeclaredPackages();
|
|
1726
|
+
json(response, 200, { ...result, modules, installedPackages });
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
json(response, 500, result);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/shell") {
|
|
1735
|
+
const body = await readRequestBody(request);
|
|
1736
|
+
const command = String(body.command ?? "").trim();
|
|
1737
|
+
|
|
1738
|
+
if (!command) {
|
|
1739
|
+
json(response, 400, { ok: false, error: "No command provided" });
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// ── Determine the working directory ──────────────────────────────────
|
|
1744
|
+
// Prefer the client's persisted CWD (set by previous cd/commands).
|
|
1745
|
+
// Fall back to the workspace root — never to the active notebook's
|
|
1746
|
+
// subdirectory, so creating a notebook inside a folder doesn't
|
|
1747
|
+
// silently change where shell commands and npm install run.
|
|
1748
|
+
let cwd;
|
|
1749
|
+
const clientCwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : null;
|
|
1750
|
+
if (clientCwd && path.isAbsolute(clientCwd) && fs.existsSync(clientCwd)) {
|
|
1751
|
+
cwd = clientCwd;
|
|
1752
|
+
} else {
|
|
1753
|
+
cwd = workspaceRoot;
|
|
1754
|
+
}
|
|
1755
|
+
await fs.promises.mkdir(cwd, { recursive: true });
|
|
1756
|
+
|
|
1757
|
+
// ── Handle `cd` specially — track CWD across commands ────────────────
|
|
1758
|
+
// Each shell invocation is a new process, so `cd` can't persist through
|
|
1759
|
+
// runShellCommand. We intercept it here, resolve the target directory,
|
|
1760
|
+
// and return the new cwd without spawning a shell.
|
|
1761
|
+
const cdMatch = command.match(/^cd(?:\s+(.+?))?$/);
|
|
1762
|
+
if (cdMatch) {
|
|
1763
|
+
const target = (cdMatch[1] ?? "~").trim();
|
|
1764
|
+
let newCwd;
|
|
1765
|
+
if (target === "~" || target === "") {
|
|
1766
|
+
newCwd = process.env.HOME ?? workspaceRoot;
|
|
1767
|
+
} else if (target === "-") {
|
|
1768
|
+
// "cd -" is unsupported in stateless mode; stay in current dir
|
|
1769
|
+
newCwd = cwd;
|
|
1770
|
+
} else if (path.isAbsolute(target)) {
|
|
1771
|
+
newCwd = path.normalize(target);
|
|
1772
|
+
} else {
|
|
1773
|
+
newCwd = path.resolve(cwd, target);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
if (!fs.existsSync(newCwd) || !fs.statSync(newCwd).isDirectory()) {
|
|
1777
|
+
json(response, 200, { ok: false, stdout: "", stderr: `cd: ${target}: No such file or directory`, cwd });
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
json(response, 200, { ok: true, stdout: "", stderr: "", cwd: newCwd });
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// ── Run the command in the resolved CWD ───────────────────────────────
|
|
1786
|
+
const result = await runShellCommand(command, cwd);
|
|
1787
|
+
|
|
1788
|
+
// Detect CWD change from commands that emit "PWD=…" as last line,
|
|
1789
|
+
// or simply keep the same cwd after execution.
|
|
1790
|
+
const resultCwd = result.newCwd ?? cwd;
|
|
1791
|
+
|
|
1792
|
+
const activePath = resolveOpenablePath(workspaceRoot, notebooksDir, body.path, { allowNotebookCreate: true })
|
|
1793
|
+
?? path.join(cwd, "dummy.ijsnb");
|
|
1794
|
+
const activeKind = getOpenableFileKind(activePath);
|
|
1795
|
+
|
|
1796
|
+
if (activeKind === "notebook") {
|
|
1797
|
+
const session = getSession(activePath);
|
|
1798
|
+
|
|
1799
|
+
// Any shell command can change the installed package set (npm install,
|
|
1800
|
+
// npm uninstall, npm update, yarn add/remove, etc.). Clearing the
|
|
1801
|
+
// import bridge cache ensures the next import() creates a fresh bridge
|
|
1802
|
+
// file with a new URL, so Node.js resolves the module from disk
|
|
1803
|
+
// instead of serving a stale entry from its own ESM module cache.
|
|
1804
|
+
// This makes both install (new package usable) and uninstall (removed
|
|
1805
|
+
// package no longer usable) take effect immediately without a restart.
|
|
1806
|
+
session.invalidateImportCache();
|
|
1807
|
+
|
|
1808
|
+
const modules = await session.listInstalledModules();
|
|
1809
|
+
const installedPackages = await session.listDeclaredPackages();
|
|
1810
|
+
json(response, 200, { ...result, cwd: resultCwd, modules, installedPackages });
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
json(response, 200, { ...result, cwd: resultCwd });
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// ── /api/notebooks — list every .ijsnb file in the workspace ──────────
|
|
1819
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/notebooks") {
|
|
1820
|
+
const results = [];
|
|
1821
|
+
|
|
1822
|
+
async function scanDir(dir) {
|
|
1823
|
+
let entries;
|
|
1824
|
+
try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); }
|
|
1825
|
+
catch { return; }
|
|
1826
|
+
for (const entry of entries) {
|
|
1827
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
1828
|
+
const abs = path.join(dir, entry.name);
|
|
1829
|
+
if (entry.isDirectory()) { await scanDir(abs); continue; }
|
|
1830
|
+
if (!entry.name.endsWith(NOTEBOOK_EXTENSION)) continue;
|
|
1831
|
+
try {
|
|
1832
|
+
const stat = await fs.promises.stat(abs);
|
|
1833
|
+
const raw = await fs.promises.readFile(abs, "utf8");
|
|
1834
|
+
const nb = JSON.parse(raw);
|
|
1835
|
+
const codeCells = (nb.cells ?? []).filter(c => c.type === "code");
|
|
1836
|
+
const langs = [...new Set(codeCells.map(c => c.language ?? "typescript"))];
|
|
1837
|
+
results.push({
|
|
1838
|
+
path: abs,
|
|
1839
|
+
relativePath: path.relative(workspaceRoot, abs),
|
|
1840
|
+
appPath: getAppPathFromWorkspacePath(workspaceRoot, notebooksDir, abs),
|
|
1841
|
+
title: nb.metadata?.title ?? path.basename(abs, NOTEBOOK_EXTENSION),
|
|
1842
|
+
cellCount: (nb.cells ?? []).length,
|
|
1843
|
+
language: langs[0] ?? "typescript",
|
|
1844
|
+
updatedAt: nb.metadata?.updatedAt ?? stat.mtime.toISOString(),
|
|
1845
|
+
createdAt: nb.metadata?.createdAt ?? stat.birthtime.toISOString()
|
|
1846
|
+
});
|
|
1847
|
+
} catch { /* skip malformed */ }
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
await scanDir(workspaceRoot);
|
|
1852
|
+
results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
1853
|
+
json(response, 200, { notebooks: results });
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// ── /api/stats — aggregate workspace statistics ──────────────────────
|
|
1858
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/stats") {
|
|
1859
|
+
const allPackages = new Set();
|
|
1860
|
+
let notebookCount = 0;
|
|
1861
|
+
let totalExecutions = 0;
|
|
1862
|
+
let aiTokensUsed = 0;
|
|
1863
|
+
const tokenUsageForCell = (cell) => {
|
|
1864
|
+
const metrics = cell && typeof cell.metrics === "object" ? cell.metrics : null;
|
|
1865
|
+
if (metrics && (Number.isFinite(metrics.aiTokensTotal) || Number.isFinite(metrics.aiTokensOut) || Number.isFinite(metrics.aiTokensIn))) {
|
|
1866
|
+
const total = Number.isFinite(metrics.aiTokensTotal)
|
|
1867
|
+
? metrics.aiTokensTotal
|
|
1868
|
+
: (Number(metrics.aiTokensIn) || 0) + (Number(metrics.aiTokensOut) || 0);
|
|
1869
|
+
return Math.max(0, total);
|
|
1870
|
+
}
|
|
1871
|
+
if (cell?.type === "prompt" && Array.isArray(cell.outputs)) {
|
|
1872
|
+
return cell.outputs.reduce((sum, out) => {
|
|
1873
|
+
const text = out?.data?.markdown ?? out?.text ?? "";
|
|
1874
|
+
return sum + estimateTokens(text);
|
|
1875
|
+
}, 0);
|
|
1876
|
+
}
|
|
1877
|
+
return 0;
|
|
1878
|
+
};
|
|
1879
|
+
|
|
1880
|
+
async function scanStats(dir) {
|
|
1881
|
+
let entries;
|
|
1882
|
+
try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); }
|
|
1883
|
+
catch { return; }
|
|
1884
|
+
for (const entry of entries) {
|
|
1885
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
1886
|
+
const abs = path.join(dir, entry.name);
|
|
1887
|
+
if (entry.isDirectory()) { await scanStats(abs); continue; }
|
|
1888
|
+
if (!entry.name.endsWith(NOTEBOOK_EXTENSION)) continue;
|
|
1889
|
+
notebookCount++;
|
|
1890
|
+
try {
|
|
1891
|
+
const raw = await fs.promises.readFile(abs, "utf8");
|
|
1892
|
+
const nb = JSON.parse(raw);
|
|
1893
|
+
for (const cell of nb.cells ?? []) {
|
|
1894
|
+
if (typeof cell.executionCount === "number" && cell.executionCount > 0) {
|
|
1895
|
+
totalExecutions += cell.executionCount;
|
|
1896
|
+
}
|
|
1897
|
+
aiTokensUsed += tokenUsageForCell(cell);
|
|
1898
|
+
}
|
|
1899
|
+
const pkgPath = path.join(path.dirname(abs), "package.json");
|
|
1900
|
+
if (fs.existsSync(pkgPath)) {
|
|
1901
|
+
const pkg = JSON.parse(await fs.promises.readFile(pkgPath, "utf8"));
|
|
1902
|
+
for (const dep of Object.keys(pkg.dependencies ?? {})) allPackages.add(dep);
|
|
1903
|
+
}
|
|
1904
|
+
} catch { /* skip malformed */ }
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
await scanStats(workspaceRoot);
|
|
1909
|
+
|
|
1910
|
+
// Also count root-level package.json packages
|
|
1911
|
+
const rootPkgPath = path.join(workspaceRoot, "package.json");
|
|
1912
|
+
if (fs.existsSync(rootPkgPath)) {
|
|
1913
|
+
try {
|
|
1914
|
+
const pkg = JSON.parse(await fs.promises.readFile(rootPkgPath, "utf8"));
|
|
1915
|
+
for (const dep of Object.keys(pkg.dependencies ?? {})) allPackages.add(dep);
|
|
1916
|
+
} catch { /* skip */ }
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
json(response, 200, {
|
|
1920
|
+
notebookCount,
|
|
1921
|
+
packageCount: allPackages.size,
|
|
1922
|
+
totalExecutions,
|
|
1923
|
+
aiTokensUsed,
|
|
1924
|
+
packages: [...allPackages]
|
|
1925
|
+
});
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// ── /api/notebook/delete — permanently remove a notebook file ────────
|
|
1930
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/notebook/delete") {
|
|
1931
|
+
const body = await readRequestBody(request);
|
|
1932
|
+
|
|
1933
|
+
// If the client sends an absolute path (e.g. from the explorer), use it
|
|
1934
|
+
// directly after a workspace safety check — don't flatten nested paths.
|
|
1935
|
+
let notebookPath;
|
|
1936
|
+
if (body.path && path.isAbsolute(body.path)) {
|
|
1937
|
+
const resolved = body.path.endsWith(NOTEBOOK_EXTENSION) ? body.path : `${body.path}${NOTEBOOK_EXTENSION}`;
|
|
1938
|
+
const rel = path.relative(workspaceRoot, resolved);
|
|
1939
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
1940
|
+
json(response, 403, { error: "Path is outside workspace" });
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
notebookPath = resolved;
|
|
1944
|
+
} else {
|
|
1945
|
+
notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
if (!fs.existsSync(notebookPath)) {
|
|
1949
|
+
json(response, 404, { error: "Notebook not found" });
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
await fs.promises.unlink(notebookPath);
|
|
1954
|
+
json(response, 200, { ok: true });
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// ── /api/file/save — write or create a text workspace file ─────────────
|
|
1959
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/file/save") {
|
|
1960
|
+
const body = await readRequestBody(request);
|
|
1961
|
+
if (!body.path) {
|
|
1962
|
+
json(response, 400, { error: "Missing path" });
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// Resolve path — support both:
|
|
1967
|
+
// • Real absolute filesystem paths (e.g. "/Users/foo/workspace/test.js")
|
|
1968
|
+
// sent by saveCurrentFile() via state.filePreview.path
|
|
1969
|
+
// • URL-style paths (e.g. "/notebooks/data.js")
|
|
1970
|
+
// sent by createNewNotebook() via the appPath variable
|
|
1971
|
+
//
|
|
1972
|
+
// The key distinction: a real absolute path starts with the workspace root.
|
|
1973
|
+
// URL-style paths like "/notebooks/…" also start with "/" but are NOT real
|
|
1974
|
+
// filesystem paths — path.isAbsolute() returns true for both, so we must
|
|
1975
|
+
// check startsWith(workspaceRoot) instead.
|
|
1976
|
+
const rawStr = String(body.path);
|
|
1977
|
+
let candidate;
|
|
1978
|
+
if (path.isAbsolute(rawStr) && rawStr.startsWith(workspaceRoot)) {
|
|
1979
|
+
// Real absolute filesystem path — normalise and use directly.
|
|
1980
|
+
candidate = path.normalize(rawStr);
|
|
1981
|
+
} else {
|
|
1982
|
+
// URL-style path: strip leading slashes and the /notebooks/ prefix,
|
|
1983
|
+
// then resolve relative to the workspace root.
|
|
1984
|
+
const stripped = rawStr.replace(/^\/+/, "").replace(/^notebooks\//, "");
|
|
1985
|
+
candidate = path.resolve(workspaceRoot, stripped);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// Security: must remain inside workspace
|
|
1989
|
+
const relToWs = path.relative(workspaceRoot, candidate);
|
|
1990
|
+
if (!relToWs || relToWs.startsWith("..") || path.isAbsolute(relToWs)) {
|
|
1991
|
+
json(response, 403, { error: "Path is outside the workspace" });
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const fileKind = getOpenableFileKind(candidate);
|
|
1996
|
+
if (fileKind !== "text") {
|
|
1997
|
+
json(response, 400, { error: "Only text files (.js, .ts, .md, .txt) can be saved via this endpoint" });
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const ext = path.extname(candidate).toLowerCase();
|
|
2002
|
+
let rawContent = String(body.content ?? "");
|
|
2003
|
+
let formattedContent = null;
|
|
2004
|
+
|
|
2005
|
+
// Auto-format .js and .ts files with Prettier (same settings as notebooks).
|
|
2006
|
+
if (ext === ".js" || ext === ".ts") {
|
|
2007
|
+
try {
|
|
2008
|
+
const parser = ext === ".ts" ? "typescript" : "babel";
|
|
2009
|
+
const plugins = ext === ".ts"
|
|
2010
|
+
? [prettierPluginTypeScript, prettierPluginEstree]
|
|
2011
|
+
: [prettierPluginBabel, prettierPluginEstree];
|
|
2012
|
+
|
|
2013
|
+
formattedContent = await prettier.format(rawContent, {
|
|
2014
|
+
parser,
|
|
2015
|
+
plugins,
|
|
2016
|
+
printWidth: 100,
|
|
2017
|
+
tabWidth: 2,
|
|
2018
|
+
semi: false,
|
|
2019
|
+
singleQuote: false
|
|
2020
|
+
});
|
|
2021
|
+
} catch {
|
|
2022
|
+
// Formatting failed (e.g. syntax error) — save the raw content as-is
|
|
2023
|
+
formattedContent = null;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
const contentToWrite = formattedContent ?? rawContent;
|
|
2028
|
+
|
|
2029
|
+
// Create parent directories if needed (supports new files in sub-folders)
|
|
2030
|
+
await fs.promises.mkdir(path.dirname(candidate), { recursive: true });
|
|
2031
|
+
await fs.promises.writeFile(candidate, contentToWrite, "utf8");
|
|
2032
|
+
json(response, 200, { ok: true, path: candidate, formattedContent });
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// ── /api/folder/delete — recursively remove a workspace directory ────
|
|
2037
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/folder/delete") {
|
|
2038
|
+
const body = await readRequestBody(request);
|
|
2039
|
+
if (!body.path) {
|
|
2040
|
+
json(response, 400, { error: "Missing path" });
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
const folderPath = path.isAbsolute(body.path)
|
|
2045
|
+
? path.normalize(body.path)
|
|
2046
|
+
: path.resolve(workspaceRoot, body.path);
|
|
2047
|
+
|
|
2048
|
+
const relToWorkspace = path.relative(workspaceRoot, folderPath);
|
|
2049
|
+
if (!relToWorkspace || relToWorkspace.startsWith("..") || path.isAbsolute(relToWorkspace)) {
|
|
2050
|
+
json(response, 403, { error: "Path is outside workspace" });
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// Safety: never delete the workspace root itself
|
|
2055
|
+
if (folderPath === workspaceRoot) {
|
|
2056
|
+
json(response, 403, { error: "Cannot delete workspace root" });
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
|
|
2061
|
+
json(response, 404, { error: "Folder not found" });
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
await fs.promises.rm(folderPath, { recursive: true, force: true });
|
|
2066
|
+
json(response, 200, { ok: true });
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// ── /api/file/delete — permanently remove any workspace file ─────────
|
|
2071
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/file/delete") {
|
|
2072
|
+
const body = await readRequestBody(request);
|
|
2073
|
+
if (!body.path) {
|
|
2074
|
+
json(response, 400, { error: "Missing path" });
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
const filePath = path.isAbsolute(body.path)
|
|
2079
|
+
? path.normalize(body.path)
|
|
2080
|
+
: path.resolve(workspaceRoot, body.path);
|
|
2081
|
+
|
|
2082
|
+
// Security: use path.relative to verify the file is inside the workspace
|
|
2083
|
+
const relToWorkspace = path.relative(workspaceRoot, filePath);
|
|
2084
|
+
if (!relToWorkspace || relToWorkspace.startsWith("..") || path.isAbsolute(relToWorkspace)) {
|
|
2085
|
+
json(response, 403, { error: "Path is outside workspace" });
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// Block notebook files from this endpoint — use /api/notebook/delete instead
|
|
2090
|
+
if (filePath.endsWith(NOTEBOOK_EXTENSION)) {
|
|
2091
|
+
json(response, 400, { error: "Use /api/notebook/delete for notebooks" });
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
if (!fs.existsSync(filePath)) {
|
|
2096
|
+
json(response, 404, { error: "File not found" });
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
await fs.promises.unlink(filePath);
|
|
2101
|
+
json(response, 200, { ok: true });
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// ── /api/stats/reset-executions — zero out all cell execution counts ──
|
|
2106
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/stats/reset-executions") {
|
|
2107
|
+
async function resetExecutionsInDir(dir) {
|
|
2108
|
+
let entries;
|
|
2109
|
+
try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); }
|
|
2110
|
+
catch { return; }
|
|
2111
|
+
for (const entry of entries) {
|
|
2112
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
2113
|
+
const abs = path.join(dir, entry.name);
|
|
2114
|
+
if (entry.isDirectory()) { await resetExecutionsInDir(abs); continue; }
|
|
2115
|
+
if (!entry.name.endsWith(NOTEBOOK_EXTENSION)) continue;
|
|
2116
|
+
try {
|
|
2117
|
+
const raw = await fs.promises.readFile(abs, "utf8");
|
|
2118
|
+
const nb = JSON.parse(raw);
|
|
2119
|
+
let changed = false;
|
|
2120
|
+
for (const cell of nb.cells ?? []) {
|
|
2121
|
+
if (typeof cell.executionCount === "number" && cell.executionCount !== 0) {
|
|
2122
|
+
cell.executionCount = 0;
|
|
2123
|
+
changed = true;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
if (changed) {
|
|
2127
|
+
await fs.promises.writeFile(abs, JSON.stringify(nb, null, 2));
|
|
2128
|
+
}
|
|
2129
|
+
} catch { /* skip malformed */ }
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
await resetExecutionsInDir(workspaceRoot);
|
|
2133
|
+
json(response, 200, { ok: true });
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// ── /api/stats/reset-ai-tokens — zero out stored AI token usage ───────
|
|
2138
|
+
if (request.method === "POST" && requestUrl.pathname === "/api/stats/reset-ai-tokens") {
|
|
2139
|
+
async function resetTokensInDir(dir) {
|
|
2140
|
+
let entries;
|
|
2141
|
+
try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); }
|
|
2142
|
+
catch { return; }
|
|
2143
|
+
for (const entry of entries) {
|
|
2144
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
2145
|
+
const abs = path.join(dir, entry.name);
|
|
2146
|
+
if (entry.isDirectory()) { await resetTokensInDir(abs); continue; }
|
|
2147
|
+
if (!entry.name.endsWith(NOTEBOOK_EXTENSION)) continue;
|
|
2148
|
+
try {
|
|
2149
|
+
const raw = await fs.promises.readFile(abs, "utf8");
|
|
2150
|
+
const nb = JSON.parse(raw);
|
|
2151
|
+
let changed = false;
|
|
2152
|
+
for (const cell of nb.cells ?? []) {
|
|
2153
|
+
if (cell.type !== "prompt") continue;
|
|
2154
|
+
const metrics = cell.metrics && typeof cell.metrics === "object" ? cell.metrics : {};
|
|
2155
|
+
const nextMetrics = {
|
|
2156
|
+
aiTokensIn: 0,
|
|
2157
|
+
aiTokensOut: 0,
|
|
2158
|
+
aiTokensTotal: 0,
|
|
2159
|
+
aiTokensUpdatedAt: new Date().toISOString()
|
|
2160
|
+
};
|
|
2161
|
+
const merged = { ...metrics, ...nextMetrics };
|
|
2162
|
+
if (JSON.stringify(merged) !== JSON.stringify(metrics)) {
|
|
2163
|
+
cell.metrics = merged;
|
|
2164
|
+
changed = true;
|
|
2165
|
+
} else if (!cell.metrics) {
|
|
2166
|
+
cell.metrics = nextMetrics;
|
|
2167
|
+
changed = true;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
if (changed) {
|
|
2171
|
+
await fs.promises.writeFile(abs, JSON.stringify(nb, null, 2));
|
|
2172
|
+
}
|
|
2173
|
+
} catch { /* skip malformed */ }
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
await resetTokensInDir(workspaceRoot);
|
|
2177
|
+
json(response, 200, { ok: true });
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
if (request.method === "GET" && requestUrl.pathname.startsWith("/vendor/monaco/")) {
|
|
2182
|
+
const vendorPath = requestUrl.pathname.replace("/vendor/monaco", "");
|
|
2183
|
+
await serveFile(monacoDir, vendorPath, response);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
if (request.method === "GET") {
|
|
2188
|
+
await serveStaticAsset(requestUrl.pathname, response);
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
notFound(response);
|
|
2193
|
+
} catch (error) {
|
|
2194
|
+
json(response, 500, {
|
|
2195
|
+
error: error?.message ?? String(error),
|
|
2196
|
+
stack: error?.stack ?? null
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
server.timeout = 0;
|
|
2202
|
+
server.requestTimeout = 0;
|
|
2203
|
+
server.keepAliveTimeout = 0;
|
|
2204
|
+
server.headersTimeout = 0;
|
|
2205
|
+
|
|
2206
|
+
return {
|
|
2207
|
+
server,
|
|
2208
|
+
workspaceRoot,
|
|
2209
|
+
notebooksDir
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
export async function startServer(options = {}) {
|
|
2214
|
+
const port = Number(options.port ?? process.env.PORT ?? 3113);
|
|
2215
|
+
const host = String(options.host ?? process.env.HOST ?? "127.0.0.1");
|
|
2216
|
+
const { server, workspaceRoot } = await createServer(options);
|
|
2217
|
+
|
|
2218
|
+
await new Promise((resolve, reject) => {
|
|
2219
|
+
server.once("error", reject);
|
|
2220
|
+
server.listen(port, host, () => {
|
|
2221
|
+
server.off("error", reject);
|
|
2222
|
+
resolve();
|
|
2223
|
+
});
|
|
2224
|
+
});
|
|
2225
|
+
|
|
2226
|
+
return {
|
|
2227
|
+
server,
|
|
2228
|
+
port,
|
|
2229
|
+
host,
|
|
2230
|
+
workspaceRoot
|
|
2231
|
+
};
|
|
2232
|
+
}
|