@deepnote/cloud-mock 0.0.0-stage → 0.1.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 +71 -2
- package/dist/bin.cjs +9 -0
- package/dist/bin.d.cts +1 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +11 -0
- package/dist/index.cjs +8 -0
- package/dist/index.d.cts +7596 -0
- package/dist/index.d.ts +7596 -0
- package/dist/index.js +2 -0
- package/dist/server-BFi0ADBB.cjs +588 -0
- package/dist/server-CcG0_Okb.js +528 -0
- package/package.json +56 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
10
|
+
key = keys[i];
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
12
|
+
get: ((k) => from[k]).bind(null, key),
|
|
13
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
19
|
+
value: mod,
|
|
20
|
+
enumerable: true
|
|
21
|
+
}) : target, mod));
|
|
22
|
+
//#endregion
|
|
23
|
+
let node_crypto = require("node:crypto");
|
|
24
|
+
let node_fs_promises = require("node:fs/promises");
|
|
25
|
+
node_fs_promises = __toESM(node_fs_promises, 1);
|
|
26
|
+
let node_path = require("node:path");
|
|
27
|
+
node_path = __toESM(node_path, 1);
|
|
28
|
+
let node_stream_consumers = require("node:stream/consumers");
|
|
29
|
+
let _deepnote_blocks = require("@deepnote/blocks");
|
|
30
|
+
let fflate = require("fflate");
|
|
31
|
+
let zod = require("zod");
|
|
32
|
+
let node_events = require("node:events");
|
|
33
|
+
let node_http = require("node:http");
|
|
34
|
+
let node_os = require("node:os");
|
|
35
|
+
node_os = __toESM(node_os, 1);
|
|
36
|
+
//#region src/http.ts
|
|
37
|
+
/** Thrown from a route, `onRequest` or `onNotFound`, answers the request with `status` and an `ErrorResponse` body. */
|
|
38
|
+
var HttpError = class extends Error {
|
|
39
|
+
status;
|
|
40
|
+
constructor(status, message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.status = status;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** Answers with `body` as JSON. */
|
|
46
|
+
function sendJson(res, status, body) {
|
|
47
|
+
const text = JSON.stringify(body);
|
|
48
|
+
res.writeHead(status, { "Content-Type": "application/json" }).end(text);
|
|
49
|
+
}
|
|
50
|
+
/** Answers with `status` and the `ErrorResponse` body `{ message }`. */
|
|
51
|
+
function sendError(res, status, message) {
|
|
52
|
+
sendJson(res, status, { message });
|
|
53
|
+
}
|
|
54
|
+
/** Answers with a 200 of `bytes` as `contentType`. */
|
|
55
|
+
function sendBytes(res, contentType, bytes) {
|
|
56
|
+
res.writeHead(200, { "Content-Type": contentType }).end(bytes);
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/utils.ts
|
|
60
|
+
const EPOCH = (/* @__PURE__ */ new Date(0)).toISOString();
|
|
61
|
+
const decoder = new TextDecoder();
|
|
62
|
+
function parseDocument(content) {
|
|
63
|
+
return (0, _deepnote_blocks.deserializeDeepnoteFile)(decoder.decode(content));
|
|
64
|
+
}
|
|
65
|
+
function sha256(data) {
|
|
66
|
+
return (0, node_crypto.createHash)("sha256").update(data).digest("hex");
|
|
67
|
+
}
|
|
68
|
+
/** The content hash from `packages/cloud/docs/project-import-contract.md`. */
|
|
69
|
+
function canonicalHash(files) {
|
|
70
|
+
return sha256(files.map(({ filename, content }) => `${filename}\n${sha256(content)}`).sort().join("\n"));
|
|
71
|
+
}
|
|
72
|
+
function projectModifiedAt(files) {
|
|
73
|
+
const first = files.at(0);
|
|
74
|
+
return first ? parseDocument(first.content).metadata.modifiedAt ?? EPOCH : EPOCH;
|
|
75
|
+
}
|
|
76
|
+
function importedNotebooks(document, action) {
|
|
77
|
+
return document.project.notebooks.map(({ id, name }) => ({
|
|
78
|
+
id,
|
|
79
|
+
name,
|
|
80
|
+
action
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
function dataAppSettings({ url, publishedDataApps }, { id, projectType }) {
|
|
84
|
+
return projectType === "data_app" ? {
|
|
85
|
+
published: publishedDataApps.has(id),
|
|
86
|
+
url: `${url}/apps/${id}/`
|
|
87
|
+
} : null;
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/file-utils.ts
|
|
91
|
+
const NOTEBOOK_FILE = /^[^/\\]+\.deepnote$/;
|
|
92
|
+
const FILES_DIR = ".files";
|
|
93
|
+
const DATA_APP_MARKER = ".data-app";
|
|
94
|
+
async function statIfExists(target) {
|
|
95
|
+
try {
|
|
96
|
+
return await node_fs_promises.default.stat(target);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
const { code } = error;
|
|
99
|
+
if (code === "ENOENT" || code === "ENOTDIR") return;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Sorted the way `@deepnote/cloud`'s `exportProject` sorts them, so the first one is the one
|
|
104
|
+
* sync reads `modifiedAt` from. */
|
|
105
|
+
async function readNotebookFiles(projectDir) {
|
|
106
|
+
const filenames = (await node_fs_promises.default.readdir(projectDir, { withFileTypes: true })).filter((entry) => entry.isFile() && NOTEBOOK_FILE.test(entry.name)).map(({ name }) => name).sort((a, b) => a.localeCompare(b));
|
|
107
|
+
return Promise.all(filenames.map(async (filename) => ({
|
|
108
|
+
filename,
|
|
109
|
+
content: await node_fs_promises.default.readFile(node_path.default.join(projectDir, filename))
|
|
110
|
+
})));
|
|
111
|
+
}
|
|
112
|
+
async function projectDir(rootDir, projectId) {
|
|
113
|
+
const dir = node_path.default.join(rootDir, projectId);
|
|
114
|
+
if (!(await statIfExists(dir))?.isDirectory()) throw new HttpError(404, "Project not found");
|
|
115
|
+
return dir;
|
|
116
|
+
}
|
|
117
|
+
function normalizeFilePath(rawPath) {
|
|
118
|
+
const trimmedPath = rawPath.trim();
|
|
119
|
+
if (!trimmedPath) throw new HttpError(400, "path is required");
|
|
120
|
+
if (trimmedPath.includes("\0")) throw new HttpError(400, "path must not contain null bytes");
|
|
121
|
+
if (trimmedPath.split("/").filter((segment) => segment.length > 0).includes("..")) throw new HttpError(400, "path must not contain directory traversal");
|
|
122
|
+
if (trimmedPath.endsWith("/")) throw new HttpError(400, "path must reference a file, not a directory");
|
|
123
|
+
const normalizedPath = node_path.default.posix.normalize(trimmedPath).replace(/^\/+/, "");
|
|
124
|
+
if (!normalizedPath || normalizedPath === ".") throw new HttpError(400, "path is required");
|
|
125
|
+
return normalizedPath;
|
|
126
|
+
}
|
|
127
|
+
function workingFile(filesDir, value) {
|
|
128
|
+
const target = node_path.default.resolve(filesDir, normalizeFilePath(value));
|
|
129
|
+
if (!target.startsWith(filesDir + node_path.default.sep)) throw new HttpError(400, "Invalid path");
|
|
130
|
+
return target;
|
|
131
|
+
}
|
|
132
|
+
async function freeWorkingFile(target) {
|
|
133
|
+
const { dir, name, ext } = node_path.default.parse(target);
|
|
134
|
+
for (let n = 1;; n++) {
|
|
135
|
+
const candidate = node_path.default.join(dir, `${name} (${n})${ext}`);
|
|
136
|
+
if (!await statIfExists(candidate)) return candidate;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function listWorkingFiles(filesDir) {
|
|
140
|
+
if (!(await statIfExists(filesDir))?.isDirectory()) return [];
|
|
141
|
+
const filePaths = (await node_fs_promises.default.readdir(filesDir, {
|
|
142
|
+
recursive: true,
|
|
143
|
+
withFileTypes: true
|
|
144
|
+
})).filter((entry) => entry.isFile()).map((entry) => node_path.default.relative(filesDir, node_path.default.join(entry.parentPath, entry.name)).split(node_path.default.sep).join("/")).sort();
|
|
145
|
+
return Promise.all(filePaths.map(async (filePath) => {
|
|
146
|
+
const { size, mtime } = await node_fs_promises.default.stat(node_path.default.join(filesDir, filePath));
|
|
147
|
+
return {
|
|
148
|
+
name: node_path.default.posix.basename(filePath),
|
|
149
|
+
path: filePath,
|
|
150
|
+
type: "file",
|
|
151
|
+
size,
|
|
152
|
+
updatedAt: mtime.toISOString()
|
|
153
|
+
};
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
async function projectSummary(dir) {
|
|
157
|
+
const id = node_path.default.basename(dir);
|
|
158
|
+
const first = (await readNotebookFiles(dir)).at(0);
|
|
159
|
+
const { birthtime, mtime } = await node_fs_promises.default.stat(dir);
|
|
160
|
+
return {
|
|
161
|
+
id,
|
|
162
|
+
name: first ? parseDocument(first.content).project.name : id,
|
|
163
|
+
projectType: (await statIfExists(node_path.default.join(dir, ".data-app")))?.isFile() ? "data_app" : "standard",
|
|
164
|
+
createdAt: birthtime.toISOString(),
|
|
165
|
+
updatedAt: mtime.toISOString(),
|
|
166
|
+
folder: null
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/schemas.ts
|
|
171
|
+
const BooleanQueryParamSchema = zod.z.enum(["true", "false"]).transform((value) => value === "true");
|
|
172
|
+
const ProjectIdSchema = zod.z.uuid().brand();
|
|
173
|
+
const ProjectIdParamSchema = zod.z.object({ projectId: ProjectIdSchema });
|
|
174
|
+
const FileReferenceQuerySchema = zod.z.strictObject({
|
|
175
|
+
projectId: ProjectIdSchema,
|
|
176
|
+
path: zod.z.string().min(1)
|
|
177
|
+
});
|
|
178
|
+
const CreateFileMultipartFieldsSchema = zod.z.strictObject({
|
|
179
|
+
projectId: ProjectIdSchema,
|
|
180
|
+
path: zod.z.string().min(1),
|
|
181
|
+
overwrite: BooleanQueryParamSchema.optional().default(false)
|
|
182
|
+
});
|
|
183
|
+
const CreateProjectBodySchema = zod.z.strictObject({
|
|
184
|
+
name: zod.z.string().min(1),
|
|
185
|
+
folderId: zod.z.uuid().optional(),
|
|
186
|
+
projectType: zod.z.enum([
|
|
187
|
+
"standard",
|
|
188
|
+
"notebook",
|
|
189
|
+
"agent",
|
|
190
|
+
"data_app",
|
|
191
|
+
"streamlit_app"
|
|
192
|
+
]).optional(),
|
|
193
|
+
environmentId: zod.z.uuid().optional()
|
|
194
|
+
});
|
|
195
|
+
const UpdateProjectBodySchema = zod.z.strictObject({
|
|
196
|
+
dataApp: zod.z.strictObject({ published: zod.z.boolean() }).optional(),
|
|
197
|
+
environmentId: zod.z.uuid().optional(),
|
|
198
|
+
folderId: zod.z.uuid().nullable().optional()
|
|
199
|
+
}).refine((update) => update.dataApp !== void 0 || update.environmentId !== void 0 || update.folderId !== void 0, { message: "At least one project setting must be provided." });
|
|
200
|
+
const ImportProjectQuerySchema = zod.z.strictObject({
|
|
201
|
+
baseModifiedAt: zod.z.iso.datetime().optional(),
|
|
202
|
+
baseContentHash: zod.z.string().regex(/^[0-9a-f]{64}$/, "Must be a 64-character lowercase SHA-256 hex digest.").optional(),
|
|
203
|
+
deleteMissingNotebooks: BooleanQueryParamSchema.optional().default(false),
|
|
204
|
+
force: BooleanQueryParamSchema.optional().default(false)
|
|
205
|
+
});
|
|
206
|
+
/** Parses `input`, throwing a 400 {@link HttpError} that names each invalid field. */
|
|
207
|
+
function parseRequestInput(schema, input) {
|
|
208
|
+
const result = schema.safeParse(input, { error: (issue) => issue.code === "invalid_type" && issue.input === void 0 ? "is required" : void 0 });
|
|
209
|
+
if (!result.success) throw new HttpError(400, readableErrors(result.error));
|
|
210
|
+
return result.data;
|
|
211
|
+
}
|
|
212
|
+
function readableErrors(error) {
|
|
213
|
+
return error.issues.map((issue) => {
|
|
214
|
+
const field = issue.path.map(String).join(".");
|
|
215
|
+
return field ? `${field} ${issue.message.toLowerCase()}` : issue.message;
|
|
216
|
+
}).join("; ");
|
|
217
|
+
}
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/mock-deepnote-cloud.ts
|
|
220
|
+
const MOCK_WORKSPACE_NAME = "Mock Workspace";
|
|
221
|
+
const DATA_APP_PLACEHOLDER_HTML = "<!doctype html>\n<title>Untitled data app</title>\n";
|
|
222
|
+
const ME = {
|
|
223
|
+
apiKey: {
|
|
224
|
+
id: "3a9e5c1d-7f2b-4d6e-8c0a-1b4f6d8e2a5c",
|
|
225
|
+
name: "Mock API key",
|
|
226
|
+
type: "public-api",
|
|
227
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
228
|
+
lastUsedAt: null,
|
|
229
|
+
expiresAt: null
|
|
230
|
+
},
|
|
231
|
+
user: {
|
|
232
|
+
id: "0b3c6f1e-2d4a-4e8b-9c7d-5a1f3e2b8c4d",
|
|
233
|
+
email: null,
|
|
234
|
+
name: null
|
|
235
|
+
},
|
|
236
|
+
workspace: {
|
|
237
|
+
id: "7e2d9a4c-1b5f-4c3e-8a6d-9f0b2c4e6a8d",
|
|
238
|
+
name: MOCK_WORKSPACE_NAME,
|
|
239
|
+
slug: "mock-workspace"
|
|
240
|
+
},
|
|
241
|
+
accessLevel: "admin"
|
|
242
|
+
};
|
|
243
|
+
async function readJsonBody(req) {
|
|
244
|
+
if (req.headers["content-type"]?.split(";")[0]?.trim().toLowerCase() !== "application/json") return {};
|
|
245
|
+
const body = await (0, node_stream_consumers.text)(req);
|
|
246
|
+
if (body === "") return {};
|
|
247
|
+
const value = JSON.parse(body);
|
|
248
|
+
if (typeof value !== "object" || value === null) throw new SyntaxError("The JSON body is not an object or array");
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
async function listProjects({ rootDir, res }) {
|
|
252
|
+
const ids = (await node_fs_promises.default.readdir(rootDir, { withFileTypes: true })).filter((entry) => entry.isDirectory() && ProjectIdSchema.safeParse(entry.name).success).map(({ name }) => name).sort();
|
|
253
|
+
const projects = await Promise.all(ids.map(async (id) => ({
|
|
254
|
+
...await projectSummary(node_path.default.join(rootDir, id)),
|
|
255
|
+
notebooks: []
|
|
256
|
+
})));
|
|
257
|
+
sendJson(res, 200, {
|
|
258
|
+
projects,
|
|
259
|
+
pagination: {
|
|
260
|
+
pageSize: projects.length,
|
|
261
|
+
nextPageToken: null,
|
|
262
|
+
hasMore: false
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async function createProject({ rootDir, req, res }) {
|
|
267
|
+
const { name, folderId, projectType, environmentId } = parseRequestInput(CreateProjectBodySchema, await readJsonBody(req));
|
|
268
|
+
if (projectType !== void 0 && projectType !== "standard" && projectType !== "data_app") throw new HttpError(400, "The mock creates only standard and data_app projects");
|
|
269
|
+
if (folderId !== void 0) throw new HttpError(404, "Folder not found");
|
|
270
|
+
if (environmentId !== void 0) throw new HttpError(404, "Environment not found");
|
|
271
|
+
const projectId = (0, node_crypto.randomUUID)();
|
|
272
|
+
const dir = node_path.default.join(rootDir, projectId);
|
|
273
|
+
await node_fs_promises.default.mkdir(dir);
|
|
274
|
+
if (projectType === "data_app") {
|
|
275
|
+
await node_fs_promises.default.writeFile(node_path.default.join(dir, DATA_APP_MARKER), "");
|
|
276
|
+
await node_fs_promises.default.mkdir(node_path.default.join(dir, FILES_DIR, "_deepnote_static"), { recursive: true });
|
|
277
|
+
await node_fs_promises.default.writeFile(node_path.default.join(dir, FILES_DIR, "_deepnote_static/index.html"), DATA_APP_PLACEHOLDER_HTML);
|
|
278
|
+
}
|
|
279
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
280
|
+
const notebook = {
|
|
281
|
+
id: (0, node_crypto.randomUUID)(),
|
|
282
|
+
name: "Notebook 1",
|
|
283
|
+
blocks: []
|
|
284
|
+
};
|
|
285
|
+
await node_fs_promises.default.writeFile(node_path.default.join(dir, "notebook.deepnote"), (0, _deepnote_blocks.serializeDeepnoteFile)({
|
|
286
|
+
version: "1.0.0",
|
|
287
|
+
metadata: {
|
|
288
|
+
createdAt: now,
|
|
289
|
+
modifiedAt: now
|
|
290
|
+
},
|
|
291
|
+
project: {
|
|
292
|
+
id: projectId,
|
|
293
|
+
name,
|
|
294
|
+
notebooks: [notebook]
|
|
295
|
+
}
|
|
296
|
+
}));
|
|
297
|
+
const summary = await projectSummary(dir);
|
|
298
|
+
sendJson(res, 201, { project: {
|
|
299
|
+
...summary,
|
|
300
|
+
notebooks: [{
|
|
301
|
+
id: notebook.id,
|
|
302
|
+
projectId,
|
|
303
|
+
name: notebook.name,
|
|
304
|
+
createdAt: summary.createdAt,
|
|
305
|
+
isInit: false,
|
|
306
|
+
isScheduled: false,
|
|
307
|
+
lastRunAt: null
|
|
308
|
+
}],
|
|
309
|
+
environment: null
|
|
310
|
+
} });
|
|
311
|
+
}
|
|
312
|
+
async function getProject(request) {
|
|
313
|
+
const { projectId } = parseRequestInput(ProjectIdParamSchema, { projectId: request.pathProjectId });
|
|
314
|
+
const dir = await projectDir(request.rootDir, projectId);
|
|
315
|
+
const summary = await projectSummary(dir);
|
|
316
|
+
const body = { project: {
|
|
317
|
+
...summary,
|
|
318
|
+
notebooks: [],
|
|
319
|
+
integrations: [],
|
|
320
|
+
files: await listWorkingFiles(node_path.default.join(dir, FILES_DIR)),
|
|
321
|
+
dataApp: dataAppSettings(request, summary),
|
|
322
|
+
environment: null
|
|
323
|
+
} };
|
|
324
|
+
sendJson(request.res, 200, body);
|
|
325
|
+
}
|
|
326
|
+
async function updateProject(request) {
|
|
327
|
+
const { projectId } = parseRequestInput(ProjectIdParamSchema, { projectId: request.pathProjectId });
|
|
328
|
+
const { dataApp: update } = parseRequestInput(UpdateProjectBodySchema, await readJsonBody(request.req));
|
|
329
|
+
const summary = await projectSummary(await projectDir(request.rootDir, projectId));
|
|
330
|
+
if (update) {
|
|
331
|
+
if (summary.projectType !== "data_app") throw new HttpError(400, "Only data apps can be published.");
|
|
332
|
+
if (update.published) request.publishedDataApps.add(summary.id);
|
|
333
|
+
else request.publishedDataApps.delete(summary.id);
|
|
334
|
+
}
|
|
335
|
+
const body = { project: {
|
|
336
|
+
...summary,
|
|
337
|
+
dataApp: dataAppSettings(request, summary),
|
|
338
|
+
environment: null
|
|
339
|
+
} };
|
|
340
|
+
sendJson(request.res, 200, body);
|
|
341
|
+
}
|
|
342
|
+
async function exportProject({ rootDir, res, pathProjectId }) {
|
|
343
|
+
const { projectId } = parseRequestInput(ProjectIdParamSchema, { projectId: pathProjectId });
|
|
344
|
+
const files = await readNotebookFiles(await projectDir(rootDir, projectId));
|
|
345
|
+
sendBytes(res, "application/zip", (0, fflate.zipSync)(Object.fromEntries(files.map((file) => [file.filename, file.content]))));
|
|
346
|
+
}
|
|
347
|
+
async function importProject({ rootDir, req, res, requestUrl, pathProjectId }) {
|
|
348
|
+
const { projectId } = parseRequestInput(ProjectIdParamSchema, { projectId: pathProjectId });
|
|
349
|
+
const options = parseRequestInput(ImportProjectQuerySchema, Object.fromEntries(requestUrl.searchParams));
|
|
350
|
+
if (req.headers["content-type"]?.split(";")[0]?.trim().toLowerCase() !== "application/zip") throw new HttpError(400, "Content-Type must be application/zip");
|
|
351
|
+
const dir = await projectDir(rootDir, projectId);
|
|
352
|
+
const body = await (0, node_stream_consumers.buffer)(req);
|
|
353
|
+
const archive = Object.entries((0, fflate.unzipSync)(body)).map(([filename, content]) => ({
|
|
354
|
+
filename,
|
|
355
|
+
content
|
|
356
|
+
}));
|
|
357
|
+
if (!archive.every(({ filename }) => NOTEBOOK_FILE.test(filename))) throw new HttpError(422, "The archive may contain only .deepnote files");
|
|
358
|
+
const documents = archive.map((file) => ({
|
|
359
|
+
...file,
|
|
360
|
+
document: parseDocument(file.content)
|
|
361
|
+
}));
|
|
362
|
+
const projects = documents.map(({ document }) => document.project);
|
|
363
|
+
if (projects.some((project) => project.id !== projectId)) throw new HttpError(422, "A document belongs to another project");
|
|
364
|
+
if (projects.some((project) => project.notebooks.length !== 1)) throw new HttpError(422, "A document must contain exactly one notebook");
|
|
365
|
+
const current = await readNotebookFiles(dir);
|
|
366
|
+
if (!options.force) {
|
|
367
|
+
const { baseModifiedAt, baseContentHash } = options;
|
|
368
|
+
if (baseModifiedAt && new Date(baseModifiedAt).getTime() !== new Date(projectModifiedAt(current)).getTime()) throw new HttpError(409, "Project structure changed since it was exported.");
|
|
369
|
+
if (baseContentHash && baseContentHash !== canonicalHash(current)) throw new HttpError(409, "Project content changed since it was exported.");
|
|
370
|
+
}
|
|
371
|
+
const imported = new Set(archive.map(({ filename }) => filename));
|
|
372
|
+
const missing = options.deleteMissingNotebooks ? current.filter(({ filename }) => !imported.has(filename)) : [];
|
|
373
|
+
const deletedNotebooks = missing.flatMap(({ content }) => importedNotebooks(parseDocument(content), "deleted"));
|
|
374
|
+
const existing = new Set(current.map(({ filename }) => filename));
|
|
375
|
+
const notebooks = [];
|
|
376
|
+
for (const { filename, content, document } of documents) {
|
|
377
|
+
await node_fs_promises.default.writeFile(node_path.default.join(dir, filename), content);
|
|
378
|
+
notebooks.push(...importedNotebooks(document, existing.has(filename) ? "overwritten" : "created"));
|
|
379
|
+
}
|
|
380
|
+
for (const { filename } of missing) await node_fs_promises.default.rm(node_path.default.join(dir, filename));
|
|
381
|
+
notebooks.push(...deletedNotebooks);
|
|
382
|
+
const after = await readNotebookFiles(dir);
|
|
383
|
+
sendJson(res, 200, {
|
|
384
|
+
project: {
|
|
385
|
+
id: projectId,
|
|
386
|
+
modifiedAt: projectModifiedAt(after),
|
|
387
|
+
contentHash: canonicalHash(after)
|
|
388
|
+
},
|
|
389
|
+
notebooks
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
async function downloadFile({ rootDir, res, requestUrl }) {
|
|
393
|
+
const { projectId, path: filePath } = parseRequestInput(FileReferenceQuerySchema, Object.fromEntries(requestUrl.searchParams));
|
|
394
|
+
const target = workingFile(node_path.default.join(rootDir, projectId, FILES_DIR), filePath);
|
|
395
|
+
const stats = await statIfExists(target);
|
|
396
|
+
if (!stats) throw new HttpError(404, "File not found");
|
|
397
|
+
if (!stats.isFile()) throw new HttpError(400, "Path is not a file");
|
|
398
|
+
sendBytes(res, "application/octet-stream", await node_fs_promises.default.readFile(target));
|
|
399
|
+
}
|
|
400
|
+
async function uploadFile({ rootDir, req, res, requestUrl }) {
|
|
401
|
+
const body = await (0, node_stream_consumers.buffer)(req);
|
|
402
|
+
let form;
|
|
403
|
+
try {
|
|
404
|
+
form = await new Request(requestUrl, {
|
|
405
|
+
method: "POST",
|
|
406
|
+
headers: { "Content-Type": req.headers["content-type"] ?? "" },
|
|
407
|
+
body
|
|
408
|
+
}).formData();
|
|
409
|
+
} catch {
|
|
410
|
+
throw new HttpError(400, "Expected a multipart upload");
|
|
411
|
+
}
|
|
412
|
+
const { projectId, path: requestedPath, overwrite } = parseRequestInput(CreateFileMultipartFieldsSchema, Object.fromEntries([...form].filter(([, value]) => typeof value === "string")));
|
|
413
|
+
const file = form.get("file");
|
|
414
|
+
if (file === null || typeof file === "string") throw new HttpError(400, "file is required");
|
|
415
|
+
const filesDir = node_path.default.join(rootDir, projectId, FILES_DIR);
|
|
416
|
+
const requested = workingFile(filesDir, requestedPath);
|
|
417
|
+
await projectDir(rootDir, projectId);
|
|
418
|
+
const stored = !overwrite && await statIfExists(requested) ? await freeWorkingFile(requested) : requested;
|
|
419
|
+
await node_fs_promises.default.mkdir(node_path.default.dirname(stored), { recursive: true });
|
|
420
|
+
await node_fs_promises.default.writeFile(stored, new Uint8Array(await file.arrayBuffer()));
|
|
421
|
+
const { size, mtime } = await node_fs_promises.default.stat(stored);
|
|
422
|
+
sendJson(res, 201, { file: {
|
|
423
|
+
projectId,
|
|
424
|
+
path: node_path.default.relative(filesDir, stored).split(node_path.default.sep).join("/"),
|
|
425
|
+
size,
|
|
426
|
+
updatedAt: mtime.toISOString()
|
|
427
|
+
} });
|
|
428
|
+
}
|
|
429
|
+
async function deleteFile({ rootDir, res, requestUrl }) {
|
|
430
|
+
const { projectId, path: filePath } = parseRequestInput(FileReferenceQuerySchema, Object.fromEntries(requestUrl.searchParams));
|
|
431
|
+
const target = workingFile(node_path.default.join(rootDir, projectId, FILES_DIR), filePath);
|
|
432
|
+
const stats = await statIfExists(target);
|
|
433
|
+
if (!stats) throw new HttpError(404, "File not found");
|
|
434
|
+
if (!stats.isFile()) throw new HttpError(400, "Path is not a file");
|
|
435
|
+
await node_fs_promises.default.rm(target);
|
|
436
|
+
res.writeHead(204).end();
|
|
437
|
+
}
|
|
438
|
+
const ROUTES = [
|
|
439
|
+
{
|
|
440
|
+
method: "GET",
|
|
441
|
+
pattern: /^\/v2\/me$/,
|
|
442
|
+
handle: async ({ res }) => sendJson(res, 200, ME)
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
method: "GET",
|
|
446
|
+
pattern: /^\/v2\/projects$/,
|
|
447
|
+
handle: listProjects
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
method: "POST",
|
|
451
|
+
pattern: /^\/v2\/projects$/,
|
|
452
|
+
handle: createProject
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
method: "GET",
|
|
456
|
+
pattern: /^\/v2\/projects\/([^/]+)$/,
|
|
457
|
+
handle: getProject
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
method: "PATCH",
|
|
461
|
+
pattern: /^\/v2\/projects\/([^/]+)$/,
|
|
462
|
+
handle: updateProject
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
method: "GET",
|
|
466
|
+
pattern: /^\/v2\/projects\/([^/]+)\/export$/,
|
|
467
|
+
handle: exportProject
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
method: "POST",
|
|
471
|
+
pattern: /^\/v2\/projects\/([^/]+)\/import$/,
|
|
472
|
+
handle: importProject
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
method: "GET",
|
|
476
|
+
pattern: /^\/v2\/files\/download$/,
|
|
477
|
+
handle: downloadFile
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
method: "POST",
|
|
481
|
+
pattern: /^\/v2\/files$/,
|
|
482
|
+
handle: uploadFile
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
method: "DELETE",
|
|
486
|
+
pattern: /^\/v2\/files$/,
|
|
487
|
+
handle: deleteFile
|
|
488
|
+
}
|
|
489
|
+
];
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region src/server.ts
|
|
492
|
+
async function handleRequest(context, { onRequest, onNotFound }, req, res) {
|
|
493
|
+
if (!req.headers.authorization?.startsWith("Bearer ")) throw new HttpError(401, "Missing API token");
|
|
494
|
+
const method = req.method ?? "GET";
|
|
495
|
+
const requestUrl = new URL(req.url ?? "/", context.url);
|
|
496
|
+
await onRequest?.({
|
|
497
|
+
method,
|
|
498
|
+
url: requestUrl
|
|
499
|
+
});
|
|
500
|
+
for (const route of ROUTES) {
|
|
501
|
+
const match = route.method === method ? route.pattern.exec(requestUrl.pathname) : null;
|
|
502
|
+
if (match) {
|
|
503
|
+
await route.handle({
|
|
504
|
+
...context,
|
|
505
|
+
req,
|
|
506
|
+
res,
|
|
507
|
+
requestUrl,
|
|
508
|
+
pathProjectId: match[1]
|
|
509
|
+
});
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
await onNotFound?.(req, res);
|
|
514
|
+
if (!res.headersSent) throw new HttpError(404, "Not found");
|
|
515
|
+
}
|
|
516
|
+
/** Starts a mock of the Deepnote Cloud API v2 on a free port of `127.0.0.1`. Any Bearer token is accepted. */
|
|
517
|
+
async function startMockDeepnoteCloud(options = {}) {
|
|
518
|
+
const rootDir = options.rootDir === void 0 ? await node_fs_promises.default.mkdtemp(node_path.default.join(node_os.default.tmpdir(), "deepnote-cloud-")) : node_path.default.resolve(options.rootDir);
|
|
519
|
+
await node_fs_promises.default.mkdir(rootDir, { recursive: true });
|
|
520
|
+
const server = (0, node_http.createServer)();
|
|
521
|
+
server.listen(0, "127.0.0.1");
|
|
522
|
+
await (0, node_events.once)(server, "listening");
|
|
523
|
+
const url = `http://127.0.0.1:${server.address().port}`;
|
|
524
|
+
const context = {
|
|
525
|
+
rootDir,
|
|
526
|
+
url,
|
|
527
|
+
publishedDataApps: /* @__PURE__ */ new Set()
|
|
528
|
+
};
|
|
529
|
+
const handling = /* @__PURE__ */ new Set();
|
|
530
|
+
server.on("request", (req, res) => {
|
|
531
|
+
res.setHeader("Connection", "close");
|
|
532
|
+
const handled = handleRequest(context, options, req, res);
|
|
533
|
+
handling.add(handled);
|
|
534
|
+
handled.catch((error) => {
|
|
535
|
+
if (res.headersSent) throw error;
|
|
536
|
+
sendError(res, error instanceof HttpError ? error.status : 500, error instanceof Error ? error.message : String(error));
|
|
537
|
+
}).finally(() => handling.delete(handled));
|
|
538
|
+
});
|
|
539
|
+
return {
|
|
540
|
+
rootDir,
|
|
541
|
+
url,
|
|
542
|
+
stop: async () => {
|
|
543
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
544
|
+
await Promise.allSettled(handling);
|
|
545
|
+
if (options.rootDir === void 0) await node_fs_promises.default.rm(rootDir, {
|
|
546
|
+
recursive: true,
|
|
547
|
+
force: true
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
//#endregion
|
|
553
|
+
Object.defineProperty(exports, "HttpError", {
|
|
554
|
+
enumerable: true,
|
|
555
|
+
get: function() {
|
|
556
|
+
return HttpError;
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
Object.defineProperty(exports, "MOCK_WORKSPACE_NAME", {
|
|
560
|
+
enumerable: true,
|
|
561
|
+
get: function() {
|
|
562
|
+
return MOCK_WORKSPACE_NAME;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
Object.defineProperty(exports, "sendBytes", {
|
|
566
|
+
enumerable: true,
|
|
567
|
+
get: function() {
|
|
568
|
+
return sendBytes;
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
Object.defineProperty(exports, "sendError", {
|
|
572
|
+
enumerable: true,
|
|
573
|
+
get: function() {
|
|
574
|
+
return sendError;
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
Object.defineProperty(exports, "sendJson", {
|
|
578
|
+
enumerable: true,
|
|
579
|
+
get: function() {
|
|
580
|
+
return sendJson;
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
Object.defineProperty(exports, "startMockDeepnoteCloud", {
|
|
584
|
+
enumerable: true,
|
|
585
|
+
get: function() {
|
|
586
|
+
return startMockDeepnoteCloud;
|
|
587
|
+
}
|
|
588
|
+
});
|