penpot/performance/lib/penpot-client.js
2026-06-12 14:08:55 +02:00

580 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Penpot k6 HTTP Client
//
// Shared module that wraps the Penpot backend RPC API using plain JSON.
// The backend supports `application/json` request bodies (kebab-case keys)
// and `application/json` responses (camelCase keys) via Accept header or _fmt=json.
//
// Authentication is cookie-based: login-with-password sets a session cookie,
// and all subsequent requests include it automatically via the k6 cookie jar.
import http from "k6/http";
import { uuidv4 } from "https://jslib.k6.io/k6-utils/1.4.0/index.js";
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
/**
* Creates a new Penpot client instance.
*
* @param {string} baseUrl - The base URL of the Penpot backend (e.g., "http://localhost:6060")
* @returns {object} Client instance with RPC methods
*/
export function createClient(baseUrl) {
// Per-VU session identifiers — consistent across all requests within one VU iteration
const sessionId = uuidv4();
const externalSessionId = uuidv4();
const defaultHeaders = {
"Accept": "application/json",
"x-session-id": sessionId,
"x-external-session-id": externalSessionId,
"x-event-origin": "perf-test",
"x-client": "penpot-perf/1.0",
};
// k6 automatically manages cookies per VU when `cookies` are returned by the server.
// We use the default cookie jar which is per-VU.
/**
* Make an RPC call to the Penpot backend.
*
* GET requests: params go as query parameters, response is JSON via _fmt=json.
* POST requests: params go as JSON body, response is JSON via Accept header.
*
* @param {string} method - HTTP method ("GET" or "POST")
* @param {string} command - RPC command name (e.g., "login-with-password")
* @param {object} params - Parameters for the RPC call
* @param {object} [opts] - Additional options
* @param {string} [opts.tag] - k6 metric tag for this request
* @returns {object} k6 Response object with parsed JSON body
*/
function rpc(method, command, params = {}, opts = {}) {
const url = `${baseUrl}/api/main/methods/${command}`;
const tag = opts.tag || command;
const tags = {
rpc_command: tag,
};
if (method === "GET") {
// GET requests: params go as query string, add _fmt=json for JSON response
const queryParams = { ...params, _fmt: "json" };
const qs = Object.entries(queryParams)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
const fullUrl = qs ? `${url}?${qs}` : url;
return http.get(fullUrl, {
headers: defaultHeaders,
tags,
});
} else {
// POST requests: params go as JSON body
const headers = {
...defaultHeaders,
"Content-Type": "application/json",
};
return http.post(url, JSON.stringify(params), {
headers,
tags,
});
}
}
/**
* Login with email and password.
* Returns the profile data on success. The session cookie is stored
* automatically by k6's cookie jar.
*
* @param {string} email
* @param {string} password
* @returns {object} Parsed response { status, body }
*/
function login(email, password) {
const res = rpc("POST", "login-with-password", {
email,
password,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get the current user's profile (requires prior login).
*
* @returns {object} Parsed response { status, body }
*/
function getProfile() {
const res = rpc("GET", "get-profile");
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get all teams for the current user.
*
* @returns {object} Parsed response { status, body }
*/
function getTeams() {
const res = rpc("GET", "get-teams");
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Create a new team.
*
* @param {string} name - Team name
* @returns {object} Parsed response { status, body }
*/
function createTeam(name) {
const res = rpc("POST", "create-team", { name });
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get projects for a team.
*
* @param {string} teamId - Team UUID
* @returns {object} Parsed response { status, body }
*/
function getProjects(teamId) {
const res = rpc("GET", "get-projects", { "team-id": teamId });
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Create a new project.
*
* @param {string} teamId - Team UUID
* @param {string} name - Project name
* @returns {object} Parsed response { status, body }
*/
function createProject(teamId, name) {
const res = rpc("POST", "create-project", {
"team-id": teamId,
name,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Create a new file in a project.
*
* @param {string} projectId - Project UUID
* @param {string} name - File name
* @returns {object} Parsed response { status, body }
*/
function createFile(projectId, name) {
const res = rpc("POST", "create-file", {
"project-id": projectId,
name,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get a file by ID.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFile(fileId) {
const res = rpc("GET", "get-file", {
id: fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get libraries used by a file.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFileLibraries(fileId) {
const res = rpc("GET", "get-file-libraries", {
"file-id": fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get object thumbnails for a file.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFileObjectThumbnails(fileId) {
const res = rpc("GET", "get-file-object-thumbnails", {
"file-id": fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Get file data for thumbnail generation.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status, body }
*/
function getFileDataForThumbnail(fileId) {
const res = rpc("GET", "get-file-data-for-thumbnail", {
"file-id": fileId,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Update a file with changes.
*
* The backend uses optimistic concurrency control via `revn`.
* If a conflict occurs (status 400 with :revn-conflict), the caller
* should retry with the latest revn from getFile().
*
* @param {string} fileId - File UUID
* @param {number} revn - Current file revision number
* @param {number} vern - Current file version number
* @param {string} sessionId - Client session ID (UUID)
* @param {Array} changes - Array of change objects
* @returns {object} Parsed response { status, body }
*/
function updateFile(fileId, revn, vern, changesSessionId, changes) {
const params = {
id: fileId,
revn: revn,
vern: vern,
"session-id": changesSessionId,
origin: "workspace",
"created-at": new Date().toISOString(),
"commit-id": uuidv4(),
changes: changes,
};
// update-file uses POST with id also as query param (per frontend convention)
const url = `${baseUrl}/api/main/methods/update-file?id=${encodeURIComponent(fileId)}`;
const headers = {
...defaultHeaders,
"Content-Type": "application/json",
};
const res = http.post(url, JSON.stringify(params), {
headers,
tags: { rpc_command: "update-file" },
});
let body = null;
try {
if (res.body && res.body.length > 0) {
body = res.json();
}
} catch (e) {
// body may not be JSON
}
return {
status: res.status,
body: body,
raw: res,
};
}
/**
* Upload a file media object using direct multipart upload.
*
* @param {string} fileId - File UUID
* @param {Uint8Array} fileBytes - The file content
* @param {string} fileName - The file name
* @param {string} mimeType - MIME type (e.g., "image/png")
* @returns {object} Parsed response { status, body }
*/
function uploadFileMediaObjectDirect(fileId, fileBytes, fileName, mimeType) {
const url = `${baseUrl}/api/main/methods/upload-file-media-object`;
const headers = {
...defaultHeaders,
// No Content-Type — k6 sets it automatically for multipart/form-data
};
const formData = {
"file-id": fileId,
"is-local": "true",
name: fileName,
content: http.file(fileBytes, fileName, mimeType),
};
const res = http.post(url, formData, {
headers,
tags: { rpc_command: "upload-file-media-object" },
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
// -----------------------------------------------------------------------
// Chunked upload
// -----------------------------------------------------------------------
/**
* Create an upload session for chunked uploads.
*
* @param {number} totalChunks - Number of chunks
* @returns {object} { status, sessionId }
*/
function createUploadSession(totalChunks) {
const res = rpc("POST", "create-upload-session", {
"total-chunks": totalChunks,
});
const body = res.status === 200 ? res.json() : null;
return {
status: res.status,
sessionId: body ? body.sessionId : null,
raw: res,
};
}
/**
* Upload a single chunk within an upload session.
*
* @param {string} sessionId - Upload session UUID
* @param {number} index - Chunk index (0-based)
* @param {Uint8Array} chunkBytes - The chunk content
* @param {string} fileName - Original file name
* @param {string} mimeType - MIME type
* @returns {object} Parsed response { status, body }
*/
function uploadChunk(sessionId, index, chunkBytes, fileName, mimeType) {
const url = `${baseUrl}/api/main/methods/upload-chunk`;
const headers = {
...defaultHeaders,
};
const formData = {
"session-id": sessionId,
index: String(index),
content: http.file(chunkBytes, fileName, mimeType),
};
const res = http.post(url, formData, {
headers,
tags: { rpc_command: "upload-chunk" },
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
/**
* Assemble all uploaded chunks into a final media object.
*
* @param {string} sessionId - Upload session UUID
* @param {string} fileId - File UUID
* @param {string} name - Media object name
* @param {boolean} isLocal - Whether the object is local to the file
* @param {string} mimeType - MIME type (e.g., "image/png")
* @returns {object} Parsed response { status, body }
*/
function assembleFileMediaObject(sessionId, fileId, name, isLocal, mimeType) {
const res = rpc("POST", "assemble-file-media-object", {
"session-id": sessionId,
"file-id": fileId,
name: name,
"is-local": isLocal,
mtype: mimeType,
});
return {
status: res.status,
body: res.status === 200 ? res.json() : null,
raw: res,
};
}
// -----------------------------------------------------------------------
// Smart upload — picks direct or chunked based on file size
// -----------------------------------------------------------------------
// Chunk size threshold: files larger than this use chunked upload.
// The actual chunk size is irrelevant to the backend; this controls
// which upload path is exercised.
const CHUNK_SIZE = 50 * 1024; // 50 KB
/**
* Upload a file media object, automatically selecting direct or chunked
* upload based on file size.
*
* Files <= CHUNK_SIZE use direct multipart upload.
* Files > CHUNK_SIZE use chunked upload (create-upload-session →
* upload-chunk × N → assemble-file-media-object).
*
* @param {string} fileId - File UUID
* @param {Uint8Array} fileBytes - The file content
* @param {string} fileName - The file name
* @param {string} mimeType - MIME type (e.g., "image/png")
* @returns {object} Parsed response { status, body }
*/
function uploadFileMediaObject(fileId, fileBytes, fileName, mimeType) {
if (fileBytes.byteLength <= CHUNK_SIZE) {
return uploadFileMediaObjectDirect(fileId, fileBytes, fileName, mimeType);
}
// Chunked upload path
const totalChunks = Math.ceil(fileBytes.byteLength / CHUNK_SIZE);
const sessionRes = createUploadSession(totalChunks);
if (sessionRes.status !== 200) {
return { status: sessionRes.status, body: null, raw: sessionRes.raw };
}
const uploadSessionId = sessionRes.sessionId;
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, fileBytes.byteLength);
const chunk = fileBytes.slice(start, end);
const chunkRes = uploadChunk(uploadSessionId, i, chunk, fileName, mimeType);
if (chunkRes.status !== 200) {
return { status: chunkRes.status, body: null, raw: chunkRes.raw };
}
}
return assembleFileMediaObject(uploadSessionId, fileId, fileName, true, mimeType);
}
/**
* Delete a file.
*
* @param {string} fileId - File UUID
* @returns {object} Parsed response { status }
*/
function deleteFile(fileId) {
const res = rpc("POST", "delete-file", { id: fileId });
return {
status: res.status,
raw: res,
};
}
/**
* Delete a project.
*
* @param {string} projectId - Project UUID
* @returns {object} Parsed response { status }
*/
function deleteProject(projectId) {
const res = rpc("POST", "delete-project", { id: projectId });
return {
status: res.status,
raw: res,
};
}
/**
* Delete a team.
*
* @param {string} teamId - Team UUID
* @returns {object} Parsed response { status }
*/
function deleteTeam(teamId) {
const res = rpc("POST", "delete-team", { id: teamId });
return {
status: res.status,
raw: res,
};
}
/**
* Logout the current user.
*
* @param {string} profileId - Profile UUID
* @returns {object} Parsed response { status }
*/
function logout(profileId) {
const res = rpc("POST", "logout", { "profile-id": profileId });
return {
status: res.status,
raw: res,
};
}
// Return the client interface
return {
sessionId,
externalSessionId,
rpc,
login,
getProfile,
getTeams,
createTeam,
getProjects,
createProject,
createFile,
getFile,
getFileLibraries,
getFileObjectThumbnails,
getFileDataForThumbnail,
updateFile,
uploadFileMediaObject,
uploadFileMediaObjectDirect,
createUploadSession,
uploadChunk,
assembleFileMediaObject,
deleteFile,
deleteProject,
deleteTeam,
logout,
};
}