Path: blob/master/src/packages/frontend/client/project.ts
1503 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Functionality that mainly involves working with a specific project.7*/89import { join } from "path";10import { redux } from "@cocalc/frontend/app-framework";11import { appBasePath } from "@cocalc/frontend/customize/app-base-path";12import { dialogs } from "@cocalc/frontend/i18n";13import { getIntl } from "@cocalc/frontend/i18n/get-intl";14import { allow_project_to_run } from "@cocalc/frontend/project/client-side-throttle";15import { ensure_project_running } from "@cocalc/frontend/project/project-start-warning";16import { API } from "@cocalc/frontend/project/websocket/api";17import { connection_to_project } from "@cocalc/frontend/project/websocket/connect";18import {19Configuration,20ConfigurationAspect,21} from "@cocalc/frontend/project_configuration";22import { HOME_ROOT } from "@cocalc/util/consts/files";23import type { ApiKey } from "@cocalc/util/db-schema/api-keys";24import {25isExecOptsBlocking,26type ExecOpts,27type ExecOutput,28} from "@cocalc/util/db-schema/projects";29import {30coerce_codomain_to_numbers,31copy_without,32defaults,33encode_path,34is_valid_uuid_string,35required,36} from "@cocalc/util/misc";37import { reuseInFlight } from "@cocalc/util/reuse-in-flight";38import { DirectoryListingEntry } from "@cocalc/util/types";39import { WebappClient } from "./client";40import { throttle } from "lodash";41import { writeFile, type WriteFileOptions } from "@cocalc/conat/files/write";42import { readFile, type ReadFileOptions } from "@cocalc/conat/files/read";4344export class ProjectClient {45private client: WebappClient;46private touch_throttle: { [project_id: string]: number } = {};4748constructor(client: WebappClient) {49this.client = client;50}5152private conatApi = (project_id: string) => {53return this.client.conat_client.projectApi({ project_id });54};5556// This can write small text files in one message.57write_text_file = async (opts): Promise<void> => {58await this.writeFile(opts);59};6061// writeFile -- easily write **arbitrarily large text or binary files**62// to a project from a readable stream or a string!63writeFile = async (64opts: WriteFileOptions & { content?: string },65): Promise<{ bytes: number; chunks: number }> => {66if (opts.content != null) {67// @ts-ignore -- typescript doesn't like this at all, but it works fine.68opts.stream = new Blob([opts.content], { type: "text/plain" }).stream();69}70return await writeFile(opts);71};7273// readFile -- read **arbitrarily large text or binary files**74// from a project via a readable stream.75// Look at the code below if you want to stream a file for memory76// efficiency...77readFile = async (opts: ReadFileOptions): Promise<Buffer> => {78const chunks: Uint8Array[] = [];79for await (const chunk of await readFile(opts)) {80chunks.push(chunk);81}82return Buffer.concat(chunks);83};8485read_text_file = async ({86project_id,87path,88}: {89project_id: string; // string or array of strings90path: string; // string or array of strings91}): Promise<string> => {92return await this.conatApi(project_id).system.readTextFileFromProject({93path,94});95};9697// Like "read_text_file" above, except the callback98// message gives a url from which the file can be99// downloaded using standard AJAX.100read_file = (opts: {101project_id: string; // string or array of strings102path: string; // string or array of strings103compute_server_id?: number;104}): string => {105const base_path = appBasePath;106if (opts.path[0] === "/") {107// absolute path to the root108opts.path = HOME_ROOT + opts.path; // use root symlink, which is created by start_smc109}110let url = join(111base_path,112`${opts.project_id}/files/${encode_path(opts.path)}`,113);114if (opts.compute_server_id) {115url += `?id=${opts.compute_server_id}`;116}117return url;118};119120copy_path_between_projects = async (opts: {121src_project_id: string; // id of source project122src_path: string; // relative path of director or file in the source project123target_project_id: string; // if of target project124target_path?: string; // defaults to src_path125overwrite_newer?: boolean; // overwrite newer versions of file at destination (destructive)126delete_missing?: boolean; // delete files in dest that are missing from source (destructive)127backup?: boolean; // make ~ backup files instead of overwriting changed files128timeout?: number; // **timeout in milliseconds** -- how long to wait for the copy to complete before reporting "error" (though it could still succeed)129exclude?: string[]; // list of patterns to exclude; this uses exactly the (confusing) rsync patterns130}): Promise<void> => {131await this.client.conat_client.hub.projects.copyPathBetweenProjects(opts);132};133134// Set a quota parameter for a given project.135// As of now, only user in the admin group can make these changes.136set_quotas = async (opts: {137project_id: string;138memory?: number;139memory_request?: number;140cpu_shares?: number;141cores?: number;142disk_quota?: number;143mintime?: number;144network?: number;145member_host?: number;146always_running?: number;147}): Promise<void> => {148// we do some extra work to ensure all the quotas are numbers (typescript isn't149// enough; sometimes client code provides strings, which can cause lots of trouble).150const x = coerce_codomain_to_numbers(copy_without(opts, ["project_id"]));151await this.client.conat_client.hub.projects.setQuotas({152...x,153project_id: opts.project_id,154});155};156157websocket = async (project_id: string): Promise<any> => {158const store = redux.getStore("projects");159// Wait until project is running (or admin and not on project)160await store.async_wait({161until: () => {162const state = store.get_state(project_id);163if (state == null && redux.getStore("account")?.get("is_admin")) {164// is admin so doesn't know project state -- just immediately165// try, which will cause project to run166return true;167}168return state == "running";169},170});171172// get_my_group returns undefined when the various info to173// determine this isn't yet loaded. For some connections174// this websocket function gets called before that info is175// loaded, which can cause trouble.176let group: string | undefined;177await store.async_wait({178until: () => (group = store.get_my_group(project_id)) != null,179});180if (group == "public") {181throw Error("no access to project websocket");182}183return await connection_to_project(project_id);184};185186api = async (project_id: string): Promise<API> => {187return (await this.websocket(project_id)).api;188};189190/*191Execute code in a given project or associated compute server.192193Aggregate option -- use like this:194195webapp.exec196aggregate: timestamp (or something else sequential)197198means: if there are multiple attempts to run the given command with the same199time, they are all aggregated and run only one time by the project. If requests200comes in with a newer time, they all run in another group after the first201one finishes. The timestamp will usually come from something like the "last save202time" (which is stored in the db), which they client will know. This is used, e.g.,203for operations like "run rst2html on this file whenever it is saved."204*/205exec = async (opts: ExecOpts & { post?: boolean }): Promise<ExecOutput> => {206if ("async_get" in opts) {207opts = defaults(opts, {208project_id: required,209compute_server_id: undefined,210async_get: required,211async_stats: undefined,212async_await: undefined,213post: false, // if true, uses the POST api through nextjs instead of the websocket api.214timeout: 30,215cb: undefined,216});217} else {218opts = defaults(opts, {219project_id: required,220compute_server_id: undefined,221filesystem: undefined,222path: "",223command: required,224args: [],225max_output: undefined,226bash: false,227aggregate: undefined,228err_on_exit: true,229env: undefined,230post: false, // if true, uses the POST api through nextjs instead of the websocket api.231async_call: undefined, // if given use a callback interface instead of async232timeout: 30,233cb: undefined,234});235}236237const intl = await getIntl();238const msg = intl.formatMessage(dialogs.client_project_exec_msg, {239blocking: isExecOptsBlocking(opts),240arg: isExecOptsBlocking(opts) ? opts.command : opts.async_get,241});242243if (!(await ensure_project_running(opts.project_id, msg))) {244return {245type: "blocking",246stdout: "",247stderr: intl.formatMessage(dialogs.client_project_exec_start_first),248exit_code: 1,249time: 0,250};251}252253try {254const ws = await this.websocket(opts.project_id);255const exec_opts = copy_without(opts, ["project_id", "cb"]);256const msg = await ws.api.exec(exec_opts);257if (msg.status && msg.status == "error") {258throw new Error(msg.error);259}260if (msg.type === "blocking") {261delete msg.status;262}263delete msg.error;264if (opts.cb == null) {265return msg;266} else {267opts.cb(undefined, msg);268return msg;269}270} catch (err) {271if (opts.cb == null) {272throw err;273} else {274if (!err.message) {275// Important since err.message can be falsey, e.g., for Error(''), but toString will never be falsey.276opts.cb(err.toString());277} else {278opts.cb(err.message);279}280return {281type: "blocking",282stdout: "",283stderr: err.message,284exit_code: 1,285time: 0, // should be ignored; this is just to make typescript happy.286};287}288}289};290291// Directly compute the directory listing. No caching or other information292// is used -- this just sends a message over the websocket requesting293// the backend node.js project process to compute the listing.294directory_listing = async (opts: {295project_id: string;296path: string;297compute_server_id: number;298timeout?: number;299hidden?: boolean;300}): Promise<{ files: DirectoryListingEntry[] }> => {301if (opts.timeout == null) opts.timeout = 15;302const api = await this.api(opts.project_id);303const listing = await api.listing(304opts.path,305opts.hidden,306opts.timeout * 1000,307opts.compute_server_id,308);309return { files: listing };310};311312find_directories = async (opts: {313project_id: string;314query?: string; // see the -iwholename option to the UNIX find command.315path?: string; // Root path to find directories from316exclusions?: string[]; // paths relative to `opts.path`. Skips whole sub-trees317include_hidden?: boolean;318}): Promise<{319query: string;320path: string;321project_id: string;322directories: string[];323}> => {324opts = defaults(opts, {325project_id: required,326query: "*", // see the -iwholename option to the UNIX find command.327path: ".", // Root path to find directories from328exclusions: undefined, // Array<String> Paths relative to `opts.path`. Skips whole sub-trees329include_hidden: false,330});331if (opts.path == null || opts.query == null)332throw Error("bug -- cannot happen");333334const args: string[] = [335opts.path,336"-xdev",337"!",338"-readable",339"-prune",340"-o",341"-type",342"d",343"-iwholename", // See https://github.com/sagemathinc/cocalc/issues/5502344`'${opts.query}'`,345"-readable",346];347if (opts.exclusions != null) {348for (const excluded_path of opts.exclusions) {349args.push(350`-a -not \\( -path '${opts.path}/${excluded_path}' -prune \\)`,351);352}353}354355args.push("-print");356const command = `find ${args.join(" ")}`;357358const result = await this.exec({359// err_on_exit = false: because want this to still work even if there's a nonzero exit code,360// which might happen if find hits a directory it can't read, e.g., a broken ~/.snapshots.361err_on_exit: false,362project_id: opts.project_id,363command,364timeout: 60,365aggregate: Math.round(Date.now() / 5000), // aggregate calls into 5s windows, in case multiple clients ask for same find at once...366});367const n = opts.path.length + 1;368let v = result.stdout.split("\n");369if (!opts.include_hidden) {370v = v.filter((x) => x.indexOf("/.") === -1);371}372v = v.filter((x) => x.length > n).map((x) => x.slice(n));373return {374query: opts.query,375path: opts.path,376project_id: opts.project_id,377directories: v,378};379};380381// This is async, so do "await smc_webapp.configuration(...project_id...)".382// for reuseInFlight, see https://github.com/sagemathinc/cocalc/issues/7806383configuration = reuseInFlight(384async (385project_id: string,386aspect: ConfigurationAspect,387no_cache: boolean,388): Promise<Configuration> => {389if (!is_valid_uuid_string(project_id)) {390throw Error("project_id must be a valid uuid");391}392return (await this.api(project_id)).configuration(aspect, no_cache);393},394);395396touch_project = async (397// project_id where activity occured398project_id: string,399// optional global id of a compute server (in the given project), in which case we also mark400// that compute server as active, which keeps it running in case it has idle timeout configured.401compute_server_id?: number,402): Promise<void> => {403if (compute_server_id) {404// this is throttled, etc. and is independent of everything below.405touchComputeServer({406project_id,407compute_server_id,408client: this.client,409});410// that said, we do still touch the project, since if a user is actively411// using a compute server, the project should also be considered active.412}413414const state = redux.getStore("projects")?.get_state(project_id);415if (!(state == null && redux.getStore("account")?.get("is_admin"))) {416// not trying to view project as admin so do some checks417if (!(await allow_project_to_run(project_id))) return;418if (!this.client.is_signed_in()) {419// silently ignore if not signed in420return;421}422if (state != "running") {423// not running so don't touch (user must explicitly start first)424return;425}426}427428// Throttle -- so if this function is called with the same project_id429// twice in 3s, it's ignored (to avoid unnecessary network traffic).430// Do not make the timeout long, since that can mess up431// getting the hub-websocket to connect to the project.432const last = this.touch_throttle[project_id];433if (last != null && Date.now() - last <= 3000) {434return;435}436this.touch_throttle[project_id] = Date.now();437try {438await this.client.conat_client.hub.db.touch({ project_id });439} catch (err) {440// silently ignore; this happens, e.g., if you touch too frequently,441// and shouldn't be fatal and break other things.442// NOTE: this is a bit ugly for now -- basically the443// hub returns an error regarding actually touching444// the project (updating the db), but it still *does*445// ensure there is a TCP connection to the project.446}447};448449// Print sagews to pdf450// The printed version of the file will be created in the same directory451// as path, but with extension replaced by ".pdf".452// Only used for sagews.453print_to_pdf = async ({454project_id,455path,456options,457timeout,458}: {459project_id: string;460path: string;461timeout?: number; // client timeout -- some things can take a long time to print!462options?: any; // optional options that get passed to the specific backend for this file type463}): Promise<string> => {464return await this.client.conat_client465.projectApi({ project_id })466.editor.printSageWS({ path, timeout, options });467};468469create = async (opts: {470title: string;471description: string;472image?: string;473start?: boolean;474// "license_id1,license_id2,..." -- if given, create project with these licenses applied475license?: string;476// never use pool477noPool?: boolean;478}): Promise<string> => {479const project_id =480await this.client.conat_client.hub.projects.createProject(opts);481this.client.tracking_client.user_tracking("create_project", {482project_id,483title: opts.title,484});485return project_id;486};487488realpath = async (opts: {489project_id: string;490path: string;491}): Promise<string> => {492return (await this.api(opts.project_id)).realpath(opts.path);493};494495isdir = async ({496project_id,497path,498}: {499project_id: string;500path: string;501}): Promise<boolean> => {502const { stdout, exit_code } = await this.exec({503project_id,504command: "file",505args: ["-Eb", path],506err_on_exit: false,507});508return !exit_code && stdout.trim() == "directory";509};510511ipywidgetsGetBuffer = reuseInFlight(512async (513project_id: string,514path: string,515model_id: string,516buffer_path: string,517): Promise<ArrayBuffer> => {518const actions = redux.getEditorActions(project_id, path);519return await actions.jupyter_actions.ipywidgetsGetBuffer(520model_id,521buffer_path,522);523},524);525526// getting, setting, editing, deleting, etc., the api keys for a project527api_keys = async (opts: {528project_id: string;529action: "get" | "delete" | "create" | "edit";530password?: string;531name?: string;532id?: number;533expire?: Date;534}): Promise<ApiKey[] | undefined> => {535return await this.client.conat_client.hub.system.manageApiKeys(opts);536};537538computeServers = (project_id) => {539const cs = redux.getProjectActions(project_id)?.computeServers();540if (cs == null) {541// this happens if something tries to access the compute server info after the project542// tab is closed. It shouldn't do that.543throw Error("compute server information not available");544}545return cs;546};547548getServerIdForPath = async ({549project_id,550path,551}): Promise<number | undefined> => {552return await this.computeServers(project_id)?.getServerIdForPath(path);553};554555// will return undefined if compute servers not yet initialized556getServerIdForPathSync = ({ project_id, path }): number | undefined => {557const cs = this.computeServers(project_id);558if (cs?.state != "connected") {559return undefined;560}561return cs.get(path);562};563}564565// (NOTE: this won't throw an exception)566const touchComputeServer = throttle(567async ({ project_id, compute_server_id, client }) => {568if (!compute_server_id) {569// nothing to do570return;571}572try {573await client.async_query({574query: {575compute_servers: {576project_id,577id: compute_server_id,578last_edited_user: client.server_time(),579},580},581});582} catch (err) {583// just a warning -- if we can't connect then touching isn't something we should be doing anyways.584console.log(585"WARNING: failed to touch compute server -- ",586{ compute_server_id },587err,588);589}590},59130000,592);593594// Polyfill for Safari: Add async iterator support to ReadableStream if missing.595// E.g., this is missing in all versions of Safari as of May 2025 according to596// https://caniuse.com/?search=ReadableStream%20async597// This breaks reading and writing files to projects, which is why this598// is here (e.g., the writeFile and readFile functions above).599// This might also matter for Jupyter.600// https://chatgpt.com/share/6827a476-dbe8-800e-9156-3326eb41baae601if (602typeof ReadableStream !== "undefined" &&603!ReadableStream.prototype[Symbol.asyncIterator]604) {605ReadableStream.prototype[Symbol.asyncIterator] = function () {606const reader = this.getReader();607return {608async next() {609return reader.read();610},611async return() {612reader.releaseLock();613return { done: true };614},615[Symbol.asyncIterator]() {616return this;617},618};619};620}621622623