@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.
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }