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