mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-17 09:09:43 +00:00
1350 lines
52 KiB
TypeScript
1350 lines
52 KiB
TypeScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify it under the
|
|
* terms of the GNU Affero General Public License as published by the Free
|
|
* Software Foundation, either version 3 of the License, or (at your option) any
|
|
* later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
* details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see
|
|
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
|
*/
|
|
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import type { EventMetadata } from '../../clients/event/types.js';
|
|
import { makeActor, type Actor } from '../../core/actor.js';
|
|
import { Context } from '../../core/context.js';
|
|
import { HttpError, type LegacyErrorCodes } from '../../core/http/HttpError.js';
|
|
import { assertVerifiedEmail } from '../../core/http/verifiedEmail.js';
|
|
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
|
import {
|
|
WORKER_SUBDOMAIN_PREFIX,
|
|
type SubdomainRow,
|
|
} from '../../stores/subdomain/SubdomainStore.js';
|
|
import {
|
|
DEFAULT_FREE_SUBSCRIPTION,
|
|
DEFAULT_TEMP_SUBSCRIPTION,
|
|
} from '../../services/metering/consts.js';
|
|
import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js';
|
|
import { PuterDriver } from '../types.js';
|
|
import { isUniqueViolation } from '../../util/dbError.js';
|
|
import { loadFileInput } from '../util/fileInput.js';
|
|
import {
|
|
decodeCursor,
|
|
encodeCursor,
|
|
normalizeLimit,
|
|
normalizeOffset,
|
|
} from '../../util/pagination.js';
|
|
|
|
const CF_BASE_URL = 'https://api.cloudflare.com/client/v4/accounts';
|
|
const WORKER_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
const MAX_WORKERS_PER_USER = 100;
|
|
|
|
/**
|
|
* Opt-in key for `create()` that skips the admission checks a _new_ worker has
|
|
* to clear — the per-user limit and the verified-email gate — for a worker that
|
|
* already exists and cleared them when it was first created. Re-running
|
|
* admission on an existing worker can only take a working worker away from its
|
|
* owner.
|
|
*
|
|
* Deliberately a symbol, not a named field: driver arguments arrive as parsed
|
|
* JSON from the caller and are passed through untouched, and `JSON.parse` can
|
|
* never produce a symbol-keyed property. Only in-process code holding this
|
|
* import can set it. A string key here would be a privilege-escalation hole.
|
|
*/
|
|
export const INTERNAL_ADMISSION_BYPASS = Symbol(
|
|
'workers.internalAdmissionBypass',
|
|
);
|
|
|
|
/**
|
|
* Opt-in key for `create()` that deploys under a caller-chosen script name and
|
|
* grouping instead of the default one-script-per-`workerName` layout, and skips
|
|
* the `subdomains` bookkeeping entirely.
|
|
*
|
|
* It exists for callers that derive a script's identity from something they
|
|
* already own — where its source file lives, say — and so have nothing to look
|
|
* up when a request arrives and no row to keep in sync with the filesystem.
|
|
* Everything else about the deploy is deliberately unchanged: same source read,
|
|
* same preamble, same token minting, so a second deploy path cannot drift from
|
|
* this one.
|
|
*
|
|
* A symbol for the same reason as `INTERNAL_ADMISSION_BYPASS`: `JSON.parse` can
|
|
* never produce one, so a request body cannot reach it. Choosing a script name
|
|
* is choosing what a deploy overwrites, and that has to stay in-process.
|
|
*/
|
|
export const INTERNAL_DEPLOY_TARGET = Symbol('workers.internalDeployTarget');
|
|
|
|
export interface InternalDeployTarget {
|
|
/** Script name to deploy under, in place of `workerName`. */
|
|
scriptName: string;
|
|
/** Grouping to deploy into, in place of the configured default. */
|
|
namespace?: string;
|
|
/**
|
|
* Which runtime to prepend. The default `router` one gives user code
|
|
* `router` and a `me` built from the worker's own token; `events` gives it
|
|
* neither and answers one platform route.
|
|
*/
|
|
runtime?: WorkerRuntime;
|
|
/**
|
|
* Source to deploy, in place of reading `filePath` out of the caller's FS.
|
|
* For callers whose source is generated rather than stored, so there is no
|
|
* file to keep in step with what is deployed.
|
|
*/
|
|
source?: string;
|
|
/**
|
|
* Deploy without a worker token. The script gets no `puter_auth` binding
|
|
* and so nothing to act as on its own behalf.
|
|
*/
|
|
omitOwnerToken?: true;
|
|
/** Extra secret bindings for the deployed script. */
|
|
secrets?: Record<string, string>;
|
|
/**
|
|
* Labels stored alongside the script. A name derived from a hash cannot be
|
|
* read backwards, so labels are the only way to enumerate afterwards which
|
|
* scripts belong to what.
|
|
*/
|
|
tags?: string[];
|
|
}
|
|
|
|
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
// How far to scan an app's child apps when resolving which workers it may
|
|
// see. A user is capped at MAX_WORKERS_PER_USER workers, so only that many
|
|
// child apps can actually own one; the ceiling is generous slack over that.
|
|
const CHILD_APP_SCAN_LIMIT = 1000;
|
|
let USE_LOCAL_WORKERD = false;
|
|
|
|
// -- Preamble --------------------------------------------------------
|
|
//
|
|
// A preamble is a built JS bundle providing puter.js and the runtime user code
|
|
// runs inside. It's baked into the source sent to Cloudflare Workers. One per
|
|
// runtime, built by `src/worker`; without the file, that runtime cannot deploy.
|
|
|
|
export type WorkerRuntime = 'router' | 'events';
|
|
|
|
interface LoadedPreamble {
|
|
source: string;
|
|
/** Lines of preamble, so a stack trace can be reported against user code. */
|
|
lineCount: number;
|
|
}
|
|
|
|
const PREAMBLE_FILES: Record<WorkerRuntime, string> = {
|
|
router: 'workerPreamble.js',
|
|
events: 'eventsWorkerPreamble.js',
|
|
};
|
|
|
|
let preambleError = false;
|
|
let preambleVersion: string | null = null;
|
|
|
|
const loadPreamble = (
|
|
file: string,
|
|
captureVersion: boolean,
|
|
): LoadedPreamble | null => {
|
|
try {
|
|
// Five levels up from `dist/src/backend/drivers/workers` (compiled
|
|
// runtime); four when running from `src/backend/drivers/workers`
|
|
// directly (vitest transforms the TS sources in place).
|
|
const preamblePath = [
|
|
path.join(__dirname, `../../../../../src/worker/dist/${file}`),
|
|
path.join(__dirname, `../../../../src/worker/dist/${file}`),
|
|
].find((candidate) => existsSync(candidate));
|
|
if (!preamblePath) throw new Error(`${file} not found`);
|
|
console.log('reading: ' + preamblePath);
|
|
const source = readFileSync(preamblePath, 'utf-8');
|
|
|
|
// Only the router preamble's version is tracked — it is the one every
|
|
// ordinary worker carries, and reported via `currentPreambleVersion()`.
|
|
if (captureVersion) {
|
|
const versionMatch =
|
|
/^var __PUTER_PREAMBLE_VERSION__\s*=\s*"([^"]+)"/.exec(source);
|
|
if (versionMatch) preambleVersion = versionMatch[1];
|
|
}
|
|
|
|
return { source, lineCount: source.split('\n').length - 1 };
|
|
} catch {
|
|
console.warn(
|
|
`[workers] preamble ${file} not built — workers using that runtime will not deploy.`,
|
|
);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const preambles: Partial<Record<WorkerRuntime, LoadedPreamble>> = {};
|
|
for (const [runtime, file] of Object.entries(PREAMBLE_FILES)) {
|
|
const loaded = loadPreamble(file, runtime === 'router');
|
|
if (loaded) preambles[runtime as WorkerRuntime] = loaded;
|
|
// Only the ordinary runtime is a boot requirement: an install with a stale
|
|
// build should refuse to start rather than serve workers without puter.js,
|
|
// but it should not refuse to start over a runtime nothing has deployed.
|
|
else if (runtime === 'router') preambleError = true;
|
|
}
|
|
|
|
/**
|
|
* The puter.js/router preamble prepended to every worker's source before
|
|
* deploy. Exposed so the local-workerd path (`LocalWorkerService`) can build
|
|
* the same `preamble + sourceCode` script when it lazily re-deploys a worker
|
|
* into Miniflare after a server restart.
|
|
*/
|
|
export function getWorkerPreamble(runtime: WorkerRuntime = 'router'): string {
|
|
return preambles[runtime]?.source ?? '';
|
|
}
|
|
|
|
/**
|
|
* Driver exposing the `workers` interface — Cloudflare Workers deployment,
|
|
* lifecycle, and file-path queries.
|
|
*
|
|
* Each "worker" is a JS file in the user's Puter FS, deployed to Cloudflare
|
|
* Workers. A corresponding `subdomains` row with subdomain
|
|
* `workers.puter.<name>` ties the worker to its source file.
|
|
*
|
|
* Config: `config.workers.{XAUTHKEY, ACCOUNTID, namespace?,
|
|
* internetExposedUrl?, loggingUrl?}`.
|
|
*/
|
|
export class WorkerDriver extends PuterDriver {
|
|
readonly driverInterface = 'workers';
|
|
// puter-js calls this as `workers:worker-service` (see Workers.js). Keep the name aligned.
|
|
readonly driverName = 'worker-service';
|
|
readonly isDefault = true;
|
|
|
|
// Without this the driver falls back to the generic 600/minute default,
|
|
// which is far too loose for `create` — every call reads the source out
|
|
// of the user's FS, bundles it, and provisions upstream.
|
|
//
|
|
// Deploys still get their own tighter budget, but not a single-digit one:
|
|
// developing against workers means redeploying on every change, and a
|
|
// tooling client that deploys a set of them does it back to back. The
|
|
// in-flight cap below is what bounds the concurrent bundling work; this
|
|
// window is only here to stop a loop.
|
|
readonly rateLimit: DriverRateLimitConfig = {
|
|
// Everything that isn't `create`/`destroy` is a metadata read —
|
|
// listing workers, resolving one by name, enumerating its files — and
|
|
// a client walks several of those per deploy and again per page of a
|
|
// listing. Cheap to serve, so the budget only catches a loop.
|
|
default: {
|
|
limit: 600,
|
|
window: 60_000,
|
|
bySubscription: {
|
|
[DEFAULT_FREE_SUBSCRIPTION]: 300,
|
|
[DEFAULT_TEMP_SUBSCRIPTION]: 150,
|
|
},
|
|
},
|
|
methods: {
|
|
create: {
|
|
limit: 120,
|
|
window: 60_000,
|
|
bySubscription: {
|
|
[DEFAULT_FREE_SUBSCRIPTION]: 80,
|
|
[DEFAULT_TEMP_SUBSCRIPTION]: 40,
|
|
},
|
|
},
|
|
destroy: {
|
|
limit: 30,
|
|
window: 60_000,
|
|
bySubscription: {
|
|
[DEFAULT_FREE_SUBSCRIPTION]: 20,
|
|
[DEFAULT_TEMP_SUBSCRIPTION]: 10,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
readonly concurrent: DriverConcurrentConfig = {
|
|
default: {
|
|
limit: 10,
|
|
bySubscription: {
|
|
[DEFAULT_FREE_SUBSCRIPTION]: 5,
|
|
[DEFAULT_TEMP_SUBSCRIPTION]: 3,
|
|
},
|
|
},
|
|
methods: {
|
|
// Deploys are the expensive path; the floor stays at 2 so a
|
|
// client that kicks off a second deploy while the first is
|
|
// still settling doesn't get a spurious rejection.
|
|
create: {
|
|
limit: 5,
|
|
bySubscription: {
|
|
[DEFAULT_FREE_SUBSCRIPTION]: 2,
|
|
[DEFAULT_TEMP_SUBSCRIPTION]: 2,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
#cfBaseUrl = '';
|
|
#hotReloadSubscribed = false;
|
|
|
|
static currentPreambleVersion(): string | null {
|
|
return preambleVersion;
|
|
}
|
|
|
|
override onServerStart(): void {
|
|
const cfg = this.#workerConfig();
|
|
if (cfg.ACCOUNTID) {
|
|
this.#cfBaseUrl = `${CF_BASE_URL}/${cfg.ACCOUNTID}/workers`;
|
|
if (cfg.namespace) {
|
|
this.#cfBaseUrl += `/dispatch/namespaces/${cfg.namespace}`;
|
|
}
|
|
// Missing the events preamble is the same boot failure as the
|
|
// router one, but only when events workers are actually
|
|
// configured to deploy — an install that has never turned that on
|
|
// must not refuse to start over a runtime nothing uses.
|
|
const missingEvents =
|
|
this.config.events?.workerRuntime === true && !preambles.events;
|
|
if (preambleError || missingEvents) {
|
|
const missing = [
|
|
preambleError ? 'router' : null,
|
|
missingEvents ? 'events' : null,
|
|
].filter((name): name is string => name !== null);
|
|
throw new Error(
|
|
`[workers] preamble(s) not built for: ${missing.join(', ')} — workers configured to be enabled. Halting start`,
|
|
);
|
|
}
|
|
} else if (cfg.localServer) {
|
|
USE_LOCAL_WORKERD = true;
|
|
}
|
|
this.#subscribeHotReload();
|
|
}
|
|
|
|
// -- Driver methods ----------------------------------------------
|
|
|
|
async create(args: {
|
|
appId: string;
|
|
workerName: string;
|
|
filePath: string;
|
|
authorization?: string;
|
|
[INTERNAL_ADMISSION_BYPASS]?: boolean;
|
|
[INTERNAL_DEPLOY_TARGET]?: InternalDeployTarget;
|
|
}): Promise<unknown> {
|
|
const actor = this.#requireActor();
|
|
const skipAdmission = args[INTERNAL_ADMISSION_BYPASS] === true;
|
|
const deployTarget = this.#validateDeployTarget(
|
|
args[INTERNAL_DEPLOY_TARGET],
|
|
);
|
|
if (!skipAdmission) this.#requireVerified(actor);
|
|
const workerName = String(args.workerName ?? '').toLowerCase();
|
|
const filePath = String(args.filePath ?? '');
|
|
// `effectiveApp`, not `app`: a token an app issued carries no app of
|
|
// its own, and reading it as "no app" is what drops the deploy into
|
|
// the account-scoped branch below.
|
|
const appId = args.appId || actor.effectiveApp?.uid;
|
|
if (!workerName)
|
|
throw new HttpError(400, 'Missing `workerName`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
// A deploy target may carry its source instead, in which case there
|
|
// is no file to name.
|
|
if (!filePath && deployTarget?.source === undefined)
|
|
throw new HttpError(400, 'Missing `filePath`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
if (!WORKER_NAME_REGEX.test(workerName)) {
|
|
throw new HttpError(
|
|
400,
|
|
'Worker name must be alphanumeric (plus _ and -)',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
this.#rejectReserved(workerName);
|
|
const subdomainName = `${WORKER_SUBDOMAIN_PREFIX}${workerName}`;
|
|
|
|
// Authorization runs ahead of the infrastructure check so "you may not
|
|
// target that app" / "that name is taken" don't hide behind a 503 on
|
|
// installs without Cloudflare configured. Both are reads — nothing is
|
|
// written before #requireCfConfig.
|
|
const boundApp = await this.#resolveWorkerAppBinding(actor, appId);
|
|
// A deploy target names its own script, so `workerName` never becomes a
|
|
// subdomain and cannot collide with one somebody else owns.
|
|
const existingSub = deployTarget
|
|
? null
|
|
: await this.stores.subdomain.getBySubdomain(subdomainName);
|
|
if (existingSub) {
|
|
await this.#checkWorkerWriteAccess(
|
|
existingSub,
|
|
actor,
|
|
409,
|
|
'Worker name is already in use',
|
|
'conflict',
|
|
);
|
|
}
|
|
|
|
this.#requireCfConfig();
|
|
|
|
// Quota check — count existing workers.puter.* subdomains owned by user
|
|
//
|
|
// A deploy target creates no such row, so this count can neither see
|
|
// its scripts nor be grown by them; running it would only make an
|
|
// unrelated worker quota decide whether these deploy. Whatever bounds
|
|
// them has to be counted where they actually live, and belongs to the
|
|
// caller that owns their lifecycle.
|
|
const existingWorkers =
|
|
skipAdmission || deployTarget
|
|
? []
|
|
: await this.stores.subdomain.listByUserIdAndPrefix(
|
|
actor.user.id,
|
|
WORKER_SUBDOMAIN_PREFIX,
|
|
);
|
|
if (existingWorkers.length >= MAX_WORKERS_PER_USER) {
|
|
throw new HttpError(
|
|
403,
|
|
`Worker limit reached (max ${MAX_WORKERS_PER_USER})`,
|
|
{ legacyCode: 'forbidden' },
|
|
);
|
|
}
|
|
|
|
// If tied to an app, get an app-scoped worker token. Worker tokens
|
|
// use `kind='worker'` so they don't collide with any interactive
|
|
// `kind='app'` session for the same (user, app); the long expiry
|
|
// (WORKER_WINDOW_SECONDS) means the worker doesn't have to re-mint
|
|
// on a clock cadence.
|
|
let authorization: string | undefined = undefined;
|
|
const appOwnerId = boundApp?.id;
|
|
const omitOwnerToken = deployTarget?.omitOwnerToken === true;
|
|
if (boundApp && !omitOwnerToken) {
|
|
authorization = await this.services.auth.createWorkerAppToken(
|
|
actor,
|
|
boundApp.uid,
|
|
workerName,
|
|
);
|
|
}
|
|
if (!authorization && !omitOwnerToken) {
|
|
// Fall back to a user-scoped worker token (no app binding).
|
|
// Same kind='worker' row + long expiry as the app-scoped
|
|
// branch above; (user, worker_name) is the unique key.
|
|
const userRow = await this.stores.user.getById(actor.user.id!);
|
|
if (!userRow)
|
|
throw new HttpError(500, 'User not found', {
|
|
legacyCode: 'internal_error',
|
|
});
|
|
const session = await this.services.auth.createWorkerSessionToken(
|
|
actor,
|
|
userRow,
|
|
workerName,
|
|
);
|
|
authorization = session.token;
|
|
}
|
|
|
|
// Read source file. loadFileInput runs the read-ACL check internally
|
|
// before pulling bytes from S3. Generated source skips all of it:
|
|
// there is no file, so there is nothing to authorize a read of.
|
|
const loaded =
|
|
deployTarget?.source === undefined
|
|
? await loadFileInput(
|
|
{
|
|
fsEntry: this.stores.fsEntry,
|
|
s3Object: this.stores.s3Object,
|
|
},
|
|
this.services.fs,
|
|
actor,
|
|
filePath,
|
|
{ maxBytes: MAX_SOURCE_SIZE },
|
|
)
|
|
: null;
|
|
const sourceCode =
|
|
deployTarget?.source ?? loaded!.buffer.toString('utf-8');
|
|
|
|
// Create subdomain entry
|
|
if (deployTarget) {
|
|
// Deliberately no row: the script's identity comes from the caller,
|
|
// which can recompute it whenever it needs to. A row here would be
|
|
// a second copy of that identity to keep in sync with the source
|
|
// file, which is the thing this path exists to avoid.
|
|
} else if (existingSub) {
|
|
// Update root_dir if worker already exists
|
|
const updated = await this.stores.subdomain.update(
|
|
String(existingSub.uuid),
|
|
{
|
|
root_dir_id: loaded?.fsEntry?.sqlId ?? null,
|
|
preamble_version: preambleVersion,
|
|
},
|
|
{ userId: actor.user.id },
|
|
);
|
|
if (!updated) {
|
|
throw new HttpError(409, 'Worker name is already in use', {
|
|
legacyCode: 'conflict',
|
|
});
|
|
}
|
|
} else {
|
|
if (!loaded?.fsEntry?.sqlId)
|
|
throw new HttpError(400, `Invalid file recieved!`, {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
// The name check above is a check-then-insert, and its read can
|
|
// come from cache or a replica, so a name claimed in between only
|
|
// gets caught by the unique index. Same conflict as the check
|
|
// answers, learned a moment later - report it the same way rather
|
|
// than letting the driver error escape as a 500.
|
|
try {
|
|
await this.stores.subdomain.create({
|
|
userId: actor.user.id!,
|
|
subdomain: subdomainName,
|
|
rootDirId: loaded.fsEntry.sqlId,
|
|
appOwner: appOwnerId,
|
|
preambleVersion,
|
|
});
|
|
} catch (err) {
|
|
if (!isUniqueViolation(err)) throw err;
|
|
throw new HttpError(409, 'Worker name is already in use', {
|
|
legacyCode: 'conflict',
|
|
});
|
|
}
|
|
// Announced against the row rather than the deploy: the row is
|
|
// what makes the worker ours to keep, and it outlives a failed
|
|
// deploy. Awaited so a listener has settled before the caller is
|
|
// told the worker exists; one that throws is logged and ignored.
|
|
await this.clients.event.emitAndWait(
|
|
'worker.create',
|
|
{ actor, workerName },
|
|
{},
|
|
);
|
|
}
|
|
|
|
// AppData is keyed by the app the worker authenticates as, so the
|
|
// directory has to follow the binding rather than the caller. Nothing
|
|
// to hold when the source did not come from a file.
|
|
if (boundApp && loaded) {
|
|
await this.services.fs.mkdir(actor.user.id!, {
|
|
path: `/${actor.user.username}/AppData/${boundApp.uid}`,
|
|
createMissingParents: true,
|
|
});
|
|
}
|
|
|
|
// Deploy to Cloudflare
|
|
const runtime = deployTarget?.runtime ?? 'router';
|
|
const runtimePreamble = preambles[runtime];
|
|
if (!runtimePreamble)
|
|
throw new HttpError(
|
|
503,
|
|
`Worker runtime '${runtime}' is not built`,
|
|
{ legacyCode: 'response_timeout' },
|
|
);
|
|
const cfResult = await this.#cfDeploy(
|
|
deployTarget?.scriptName ?? workerName,
|
|
authorization,
|
|
runtimePreamble.source + sourceCode,
|
|
deployTarget,
|
|
);
|
|
return cfResult;
|
|
}
|
|
|
|
async destroy(args: Record<string, unknown>): Promise<unknown> {
|
|
const actor = this.#requireActor();
|
|
this.#requireVerified(actor);
|
|
const workerName = String(args.workerName ?? '').toLowerCase();
|
|
if (!workerName)
|
|
throw new HttpError(400, 'Missing `workerName`', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
|
|
// Same ordering as create: resolve who owns the worker before
|
|
// reporting on the deploy backend.
|
|
const subdomainName = `${WORKER_SUBDOMAIN_PREFIX}${workerName}`;
|
|
const row = await this.stores.subdomain.getBySubdomain(subdomainName);
|
|
if (!row)
|
|
throw new HttpError(404, 'Worker not found', {
|
|
legacyCode: 'not_found',
|
|
});
|
|
await this.#checkWorkerWriteAccess(
|
|
row,
|
|
actor,
|
|
403,
|
|
'This is not your worker',
|
|
'forbidden',
|
|
);
|
|
|
|
this.#requireCfConfig();
|
|
|
|
const cfResult = await this.#cfDelete(workerName);
|
|
await this.stores.subdomain.deleteByUuid(row.uuid, {
|
|
userId: actor.user.id,
|
|
});
|
|
return cfResult;
|
|
}
|
|
|
|
async getFilePaths(args: Record<string, unknown>): Promise<unknown> {
|
|
const actor = this.#requireActor();
|
|
const workerName = args.workerName as string | undefined;
|
|
|
|
const limit = normalizeLimit(args.limit, { cap: 5000 });
|
|
const offset = normalizeOffset(args.offset);
|
|
const hasCursor = Object.prototype.hasOwnProperty.call(args, 'cursor');
|
|
const payload = decodeCursor(
|
|
args.cursor as string | null | undefined,
|
|
) as { id?: number } | undefined;
|
|
if (payload && offset !== undefined) {
|
|
throw new HttpError(400, 'cursor and offset cannot be combined', {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
const includeTotal = args.includeTotal === true;
|
|
const paginated =
|
|
hasCursor ||
|
|
limit !== undefined ||
|
|
offset !== undefined ||
|
|
includeTotal;
|
|
const pageSize = limit ?? 500;
|
|
|
|
// An app sees the workers it deployed under itself and under the apps
|
|
// it created (the sandboxed ones) — the same set it may manage.
|
|
const managedAppIds = actor.effectiveApp
|
|
? await this.#managedAppIds(actor)
|
|
: undefined;
|
|
|
|
let rows: SubdomainRow[];
|
|
let cursor: string | undefined;
|
|
if (typeof workerName === 'string' && workerName.length > 0) {
|
|
const sub = await this.stores.subdomain.getBySubdomain(
|
|
`${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
|
|
);
|
|
rows = sub ? [sub] : [];
|
|
} else {
|
|
rows = await this.stores.subdomain.listByUserIdAndPrefix(
|
|
actor.user.id,
|
|
WORKER_SUBDOMAIN_PREFIX,
|
|
{
|
|
...(managedAppIds ? { appIds: managedAppIds } : {}),
|
|
...(paginated
|
|
? {
|
|
limit: pageSize + 1,
|
|
offset,
|
|
afterId:
|
|
payload?.id !== undefined
|
|
? Number(payload.id)
|
|
: undefined,
|
|
}
|
|
: {}),
|
|
},
|
|
);
|
|
if (paginated && rows.length > pageSize) {
|
|
rows = rows.slice(0, pageSize);
|
|
cursor = encodeCursor({
|
|
id: Number(rows[rows.length - 1]!.id),
|
|
});
|
|
}
|
|
}
|
|
|
|
const rootDirIds = rows
|
|
.map((r) => r.root_dir_id)
|
|
.filter((id): id is number => typeof id === 'number');
|
|
const entriesById =
|
|
await this.stores.fsEntry.getEntriesByIds(rootDirIds);
|
|
|
|
// Make sure the user only sees their own workers
|
|
rows = rows.filter((r) => {
|
|
return r.user_id === actor.user.id;
|
|
});
|
|
if (managedAppIds) {
|
|
rows = rows.filter((r) => {
|
|
return (
|
|
r.app_owner !== null &&
|
|
r.app_owner !== undefined &&
|
|
managedAppIds.includes(Number(r.app_owner))
|
|
);
|
|
});
|
|
}
|
|
|
|
const appOwnerIds = rows
|
|
.map((r) => r.app_owner)
|
|
.filter((id): id is number => typeof id === 'number');
|
|
const appsById = await this.stores.app.getByIds(appOwnerIds);
|
|
|
|
const items = rows.map((r) => {
|
|
const name =
|
|
String(r.subdomain ?? '')
|
|
.split('.')
|
|
.pop() ?? '';
|
|
let file_path = null;
|
|
let file_uid = null;
|
|
if (typeof r.root_dir_id === 'number') {
|
|
const loaded = entriesById.get(r.root_dir_id);
|
|
file_path = loaded?.path;
|
|
file_uid = loaded?.uuid;
|
|
}
|
|
let app_uid = null;
|
|
if (typeof r.app_owner === 'number') {
|
|
const loadedApp = appsById.get(r.app_owner);
|
|
app_uid = loadedApp?.uid ?? null;
|
|
}
|
|
return {
|
|
name,
|
|
url: `https://${name}.puter.work`,
|
|
file_path,
|
|
file_uid,
|
|
app_uid,
|
|
created_at: r.ts
|
|
? new Date(r.ts as string).toISOString()
|
|
: null,
|
|
};
|
|
});
|
|
|
|
if (!paginated) return items;
|
|
|
|
let total: number | undefined;
|
|
if (includeTotal) {
|
|
total = await this.stores.subdomain.countByUserIdAndPrefix(
|
|
actor.user.id,
|
|
WORKER_SUBDOMAIN_PREFIX,
|
|
managedAppIds ? { appIds: managedAppIds } : {},
|
|
);
|
|
}
|
|
|
|
return {
|
|
items,
|
|
...(cursor ? { cursor } : {}),
|
|
...(total !== undefined ? { total } : {}),
|
|
};
|
|
}
|
|
|
|
async getLoggingUrl(): Promise<string | null> {
|
|
return this.#workerConfig().loggingUrl ?? null;
|
|
}
|
|
|
|
// -- Cloudflare API ----------------------------------------------
|
|
|
|
async #cfDeploy(
|
|
workerName: string,
|
|
authorization: string | undefined,
|
|
code: string,
|
|
target?: InternalDeployTarget,
|
|
): Promise<Record<string, unknown>> {
|
|
const secrets = Object.entries(target?.secrets ?? {});
|
|
if (USE_LOCAL_WORKERD) {
|
|
// Events workers get their own registry key so they can never
|
|
// resolve on the public local-worker host or collide with an
|
|
// ordinary worker's name — see `cfCallLocal`.
|
|
if (target?.runtime === 'events') {
|
|
return this.services.localworkerservice.cfDeployLocalEvents(
|
|
workerName,
|
|
authorization,
|
|
code,
|
|
Object.fromEntries(secrets),
|
|
);
|
|
}
|
|
return this.services.localworkerservice.cfDeployLocal(
|
|
workerName,
|
|
authorization,
|
|
code,
|
|
Object.fromEntries(secrets),
|
|
);
|
|
}
|
|
const cfg = this.#workerConfig();
|
|
const metadata = JSON.stringify({
|
|
body_part: 'swCode',
|
|
compatibility_flags: ['global_fetch_strictly_public'],
|
|
compatibility_date: '2025-07-15',
|
|
...(target?.tags ? { tags: target.tags } : {}),
|
|
bindings: [
|
|
// A script deployed without a token gets no binding at all,
|
|
// rather than an empty one for its code to find.
|
|
...(authorization === undefined
|
|
? []
|
|
: [
|
|
{
|
|
type: 'secret_text',
|
|
name: 'puter_auth',
|
|
text: authorization,
|
|
},
|
|
]),
|
|
...secrets.map(([name, text]) => ({
|
|
type: 'secret_text',
|
|
name,
|
|
text,
|
|
})),
|
|
{
|
|
type: 'plain_text',
|
|
name: 'puter_endpoint',
|
|
text: cfg.internetExposedUrl ?? 'https://api.puter.com',
|
|
},
|
|
],
|
|
});
|
|
|
|
const form = new FormData();
|
|
form.append('metadata', metadata);
|
|
form.append(
|
|
'swCode',
|
|
new Blob([code], { type: 'application/javascript' }),
|
|
);
|
|
|
|
const res = await fetch(
|
|
`${this.#deployBaseUrl(target)}/scripts/${workerName}/`,
|
|
{
|
|
method: 'PUT',
|
|
headers: { Authorization: `Bearer ${cfg.XAUTHKEY}` },
|
|
body: form,
|
|
// A hung deploy backend must never pin `#inFlight` forever —
|
|
// this is the ceiling on how long that can hold a script name.
|
|
signal: AbortSignal.timeout(30_000),
|
|
},
|
|
);
|
|
const json = (await res.json()) as {
|
|
success?: boolean;
|
|
errors?: Array<{ message: string }>;
|
|
};
|
|
|
|
if (json.success) {
|
|
return {
|
|
success: true,
|
|
errors: [],
|
|
// A caller that named the script also owns how it is reached;
|
|
// the per-worker subdomain below is not where it answers.
|
|
url: target ? null : `https://${workerName}.puter.work`,
|
|
};
|
|
}
|
|
|
|
// Parse Cloudflare error stack traces to adjust for preamble offset
|
|
const errors = (json.errors ?? []).map((e) => {
|
|
const lines = e.message.split('\n');
|
|
const header = lines.shift() ?? '';
|
|
const adjusted = lines.map((line) => {
|
|
if (line.includes('at worker.js:')) {
|
|
const [before, after] = line.split('at worker.js:');
|
|
const positions = after.split(':');
|
|
positions[0] = String(
|
|
Number(positions[0]) -
|
|
(preambles[target?.runtime ?? 'router']
|
|
?.lineCount ?? 0),
|
|
);
|
|
return `${before}at worker.js:${positions.join(':')}`;
|
|
}
|
|
return line;
|
|
});
|
|
return `${header}\n${adjusted.join('\n')}`;
|
|
});
|
|
return { success: false, errors, url: null };
|
|
}
|
|
|
|
async #cfDelete(workerName: string): Promise<Record<string, unknown>> {
|
|
if (USE_LOCAL_WORKERD) {
|
|
return this.services.localworkerservice.cfDeleteLocal(workerName);
|
|
}
|
|
const cfg = this.#workerConfig();
|
|
const res = await fetch(`${this.#cfBaseUrl}/scripts/${workerName}/`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${cfg.XAUTHKEY}` },
|
|
});
|
|
return (await res.json()) as Record<string, unknown>;
|
|
}
|
|
|
|
// -- Helpers ------------------------------------------------------
|
|
|
|
#requireActor(): Actor & {
|
|
user: { id: number; uuid: string; username: string };
|
|
} {
|
|
const actor = Context.get('actor') as Actor | undefined;
|
|
if (!actor?.user?.id)
|
|
throw new HttpError(401, 'Authentication required', {
|
|
legacyCode: 'unauthorized',
|
|
});
|
|
return actor as Actor & {
|
|
user: { id: number; uuid: string; username: string };
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Only in-process code can supply a deploy target, so this is not a trust
|
|
* boundary — it is a guard against a caller putting a path separator or a
|
|
* space into a name that is about to be interpolated into a request URL,
|
|
* where it would silently address something other than what was meant.
|
|
*/
|
|
#validateDeployTarget(
|
|
target: InternalDeployTarget | undefined,
|
|
): InternalDeployTarget | undefined {
|
|
if (!target) return undefined;
|
|
const invalid =
|
|
!WORKER_NAME_REGEX.test(target.scriptName) ||
|
|
(target.namespace !== undefined &&
|
|
!WORKER_NAME_REGEX.test(target.namespace)) ||
|
|
Object.keys(target.secrets ?? {}).some(
|
|
(name) => !WORKER_NAME_REGEX.test(name),
|
|
);
|
|
if (invalid) {
|
|
throw new HttpError(500, 'Invalid internal deploy target', {
|
|
legacyCode: 'internal_error',
|
|
});
|
|
}
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* Where scripts are written. The configured default is resolved once at
|
|
* startup; a deploy target picks its own grouping per call, which is what
|
|
* keeps its scripts out of the default one entirely.
|
|
*/
|
|
#deployBaseUrl(target?: InternalDeployTarget): string {
|
|
if (!target?.namespace) return this.#cfBaseUrl;
|
|
const cfg = this.#workerConfig();
|
|
return `${CF_BASE_URL}/${cfg.ACCOUNTID}/workers/dispatch/namespaces/${target.namespace}`;
|
|
}
|
|
|
|
#requireCfConfig(): void {
|
|
const cfg = this.#workerConfig();
|
|
if ((!cfg.XAUTHKEY || !cfg.ACCOUNTID) && !cfg.localServer) {
|
|
throw new HttpError(503, 'Cloudflare Workers not configured', {
|
|
legacyCode: 'response_timeout',
|
|
});
|
|
}
|
|
}
|
|
|
|
#rejectReserved(name: string): void {
|
|
const reserved = this.config.reserved_words ?? [];
|
|
if (reserved.includes(name)) {
|
|
throw new HttpError(400, `Worker name '${name}' is reserved`, {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
}
|
|
|
|
async #checkWorkerWriteAccess(
|
|
row: SubdomainRow,
|
|
actor: Actor & { user: { id: number } },
|
|
errorStatus: number,
|
|
errorMessage: string,
|
|
errorLegacyCode: LegacyErrorCodes,
|
|
): Promise<void> {
|
|
const deny = () =>
|
|
new HttpError(errorStatus, errorMessage, {
|
|
legacyCode: errorLegacyCode,
|
|
});
|
|
|
|
if (Number(row.user_id) !== actor.user.id) throw deny();
|
|
|
|
const app = actor.effectiveApp;
|
|
if (!app) return;
|
|
|
|
const actorAppId = app.id;
|
|
const workerAppOwnerId =
|
|
row.app_owner === null || row.app_owner === undefined
|
|
? null
|
|
: Number(row.app_owner);
|
|
if (!actorAppId || workerAppOwnerId === null) throw deny();
|
|
if (workerAppOwnerId === actorAppId) return;
|
|
|
|
// A worker bound to an app the caller created stays manageable by its
|
|
// creator — otherwise an app could deploy a sandboxed worker and then
|
|
// be locked out of redeploying or destroying it.
|
|
if (!(await this.#appIsOwnedByActorApp(workerAppOwnerId, actor)))
|
|
throw deny();
|
|
}
|
|
|
|
/**
|
|
* The app a worker deployed by `actor` should authenticate as, or null for
|
|
* a user-scoped worker. `requestedAppUid` is the caller-supplied binding
|
|
* (`appId`), which defaults to the caller's own app.
|
|
*
|
|
* An app actor may name its own app, or an app it created for this user
|
|
* (`apps.app_owner`) — the sandbox case, which gives each generated project
|
|
* its own KV/AppData namespace. Root sessions keep their existing reach
|
|
* over any app.
|
|
*/
|
|
async #resolveWorkerAppBinding(
|
|
actor: Actor & { user: { id: number } },
|
|
requestedAppUid?: string,
|
|
): Promise<{ uid: string; id?: number } | null> {
|
|
// Self-binding, either implicit or named. Not every actor shape carries
|
|
// `app.id`, so fall back to a lookup rather than leaving the subdomain
|
|
// row unowned.
|
|
const ownApp = actor.effectiveApp;
|
|
const selfBinding = async () => ({
|
|
uid: ownApp!.uid,
|
|
id: ownApp!.id ?? (await this.stores.app.getByUid(ownApp!.uid))?.id,
|
|
});
|
|
if (!requestedAppUid) {
|
|
return ownApp ? await selfBinding() : null;
|
|
}
|
|
if (ownApp?.uid === requestedAppUid) {
|
|
return await selfBinding();
|
|
}
|
|
|
|
const app = await this.stores.app.getByUid(requestedAppUid);
|
|
if (!ownApp) {
|
|
// Root session: unchanged: bind to whatever it names. An unknown
|
|
// uid still mints a token, as it did before, and simply leaves the
|
|
// subdomain row unowned by any app.
|
|
return { uid: requestedAppUid, id: app?.id };
|
|
}
|
|
|
|
if (!app || !(await this.#appIsOwnedByActorApp(app.id, actor))) {
|
|
throw new HttpError(403, 'Cannot deploy worker for another app', {
|
|
legacyCode: 'forbidden',
|
|
});
|
|
}
|
|
return { uid: app.uid, id: app.id };
|
|
}
|
|
|
|
/**
|
|
* Whether `appId` is an app the actor's app created for this same user.
|
|
* Mirrors `AppDriver.#checkWriteAccess` — both halves matter: `app_owner`
|
|
* alone would let an app reach a namesake row owned by a different user.
|
|
*/
|
|
async #appIsOwnedByActorApp(
|
|
appId: number,
|
|
actor: Actor & { user: { id: number } },
|
|
): Promise<boolean> {
|
|
const ownApp = actor.effectiveApp;
|
|
if (!ownApp?.id) return false;
|
|
const app = await this.stores.app.getById(appId);
|
|
if (!app) return false;
|
|
return (
|
|
Number(app.app_owner) === Number(ownApp.id) &&
|
|
Number(app.owner_user_id) === Number(actor.user.id)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* App ids whose workers the caller may see and manage: its own, plus every
|
|
* app it created for this user. Only meaningful for app actors — a root
|
|
* session sees all of its own workers.
|
|
*/
|
|
async #managedAppIds(
|
|
actor: Actor & { user: { id: number } },
|
|
): Promise<number[]> {
|
|
const ownApp = actor.effectiveApp;
|
|
if (!ownApp?.id) return [];
|
|
const children = await this.stores.app.list({
|
|
appOwner: ownApp.id,
|
|
ownerUserId: actor.user.id,
|
|
limit: CHILD_APP_SCAN_LIMIT,
|
|
});
|
|
return [
|
|
ownApp.id,
|
|
...children.map((app: { id: number }) => Number(app.id)),
|
|
];
|
|
}
|
|
|
|
#workerConfig(): NonNullable<typeof this.config.workers> {
|
|
return this.config.workers ?? {};
|
|
}
|
|
|
|
/**
|
|
* Mirror of the HTTP-layer `requireVerifiedGate` on /delete-site — only
|
|
* active when `strict_email_verification_required` is truthy, so self-
|
|
* hosted installs without SMTP aren't bricked. Applied at the driver level
|
|
* so /drivers/call can't bypass the gate the HTTP route enforces.
|
|
*/
|
|
#requireVerified(actor: Actor): void {
|
|
assertVerifiedEmail(
|
|
Boolean(this.config.strict_email_verification_required),
|
|
actor.user,
|
|
400,
|
|
);
|
|
}
|
|
|
|
// -- Hot-reload: auto-redeploy on source file write --------------
|
|
//
|
|
// When a user saves a JS file that's tied to a worker subdomain,
|
|
// we redeploy it to Cloudflare automatically. This is what makes
|
|
// "save file → live in prod" instant.
|
|
//
|
|
// This listens to backend FS lifecycle events rather than `outer.gui.*`
|
|
// socket events. GUI events intentionally expose public UUID-shaped ids,
|
|
// while worker subdomains are keyed to the numeric fsentries.id.
|
|
|
|
#subscribeHotReload(): void {
|
|
if (!this.#cfBaseUrl && !USE_LOCAL_WORKERD) return;
|
|
// Idempotent: re-entry (e.g. a second onServerStart) must not stack
|
|
// duplicate listeners — each duplicate would multiply redeploys on
|
|
// every user's worker-source save.
|
|
if (this.#hotReloadSubscribed) return;
|
|
this.#hotReloadSubscribed = true;
|
|
|
|
this.clients.event.on(
|
|
'fs.write.file',
|
|
(_key: string, data: unknown, meta: EventMetadata) => {
|
|
void this.#handleSourceWrite(data, meta).catch((err) => {
|
|
console.error('[workers] hot-reload error', err);
|
|
});
|
|
},
|
|
);
|
|
this.clients.event.on(
|
|
'fs.remove.node',
|
|
(_key: string, data: unknown, meta: EventMetadata) => {
|
|
void this.#handleSourceRemove(data, meta).catch((err) => {
|
|
console.error('[workers] source remove error', err);
|
|
});
|
|
},
|
|
);
|
|
this.clients.event.on(
|
|
'fs.move.node',
|
|
(_key: string, data: unknown, meta: EventMetadata) => {
|
|
void this.#handleSourceMove(data, meta).catch((err) => {
|
|
console.error('[workers] source move error', err);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
async #handleSourceWrite(
|
|
data: unknown,
|
|
meta: EventMetadata,
|
|
): Promise<void> {
|
|
const metaObj =
|
|
meta && typeof meta === 'object'
|
|
? (meta as Record<string, unknown>)
|
|
: {};
|
|
// Only run on the local node — incoming broadcast writes shouldn't trigger a re-deploy
|
|
if (metaObj.from_outside) return;
|
|
|
|
const entry = this.#extractFsEntryFromEvent(data);
|
|
if (!entry || entry.isDir) return;
|
|
|
|
const matched = await this.#listWorkerRowsForEntry(entry);
|
|
if (matched.length === 0) return;
|
|
|
|
for (const row of matched) {
|
|
const workerFullName = String(row.subdomain ?? '');
|
|
if (!workerFullName.startsWith(WORKER_SUBDOMAIN_PREFIX)) continue;
|
|
const workerName = workerFullName.slice(
|
|
WORKER_SUBDOMAIN_PREFIX.length,
|
|
);
|
|
// Null until resolved, and for a worker bound to no app at all.
|
|
let appUid: string | null = null;
|
|
|
|
try {
|
|
const ownerUser = await this.stores.user.getById(entry.userId);
|
|
if (!ownerUser) continue;
|
|
const ownerActor = makeActor({ user: ownerUser });
|
|
|
|
// Read the updated file content. `ownerActor` is the file's
|
|
// owner from the originating write event, so the read-ACL
|
|
// check inside loadFileInput will pass.
|
|
const loaded = await loadFileInput(
|
|
{
|
|
fsEntry: this.stores.fsEntry,
|
|
s3Object: this.stores.s3Object,
|
|
},
|
|
this.services.fs,
|
|
ownerActor,
|
|
entry.path ?? entry.uuid, // prefer path, fall back to uuid
|
|
{ maxBytes: MAX_SOURCE_SIZE },
|
|
);
|
|
const sourceCode = loaded.buffer.toString('utf-8');
|
|
|
|
// Mint a worker token for the redeploy. Idempotent on
|
|
// (user, app_uid, worker_name) so a hot-reload reuses
|
|
// the same row across reloads and the long-lived token
|
|
// stays stable for the worker's whole lifetime.
|
|
//
|
|
// A null `app_owner` means the worker was never bound to
|
|
// an app: a deleted app takes its rows with it, so this
|
|
// row cannot be a binding that was lost.
|
|
const appOwnerId = row.app_owner as number | null;
|
|
let authorization: string;
|
|
if (appOwnerId) {
|
|
const app = await this.stores.app.getById(appOwnerId);
|
|
if (!app) continue; // app gone
|
|
appUid = String(app.uid);
|
|
authorization =
|
|
await this.services.auth.createWorkerAppToken(
|
|
ownerActor,
|
|
app.uid,
|
|
workerName,
|
|
);
|
|
} else {
|
|
const session =
|
|
await this.services.auth.createWorkerSessionToken(
|
|
ownerActor,
|
|
ownerUser,
|
|
workerName,
|
|
);
|
|
|
|
authorization = session.token;
|
|
}
|
|
|
|
// Deploy
|
|
const cfResult = (await this.#cfDeploy(
|
|
workerName,
|
|
authorization,
|
|
getWorkerPreamble() + sourceCode,
|
|
)) as { success?: boolean; errors?: unknown[]; url?: string };
|
|
|
|
if (cfResult.success && row.uuid) {
|
|
await this.stores.subdomain.update(
|
|
String(row.uuid),
|
|
{ preamble_version: preambleVersion },
|
|
{ userId: entry.userId },
|
|
);
|
|
}
|
|
|
|
// Notify the user
|
|
await this.#notifyUser(
|
|
entry.userId,
|
|
workerName,
|
|
cfResult,
|
|
appUid,
|
|
);
|
|
} catch (err) {
|
|
console.warn(
|
|
`[workers] hot-reload deploy failed for ${workerName}`,
|
|
err,
|
|
);
|
|
await this.#notifyUser(
|
|
entry.userId,
|
|
workerName,
|
|
{ success: false, errors: [String(err)] },
|
|
appUid,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async #handleSourceRemove(
|
|
data: unknown,
|
|
meta: EventMetadata,
|
|
): Promise<void> {
|
|
const metaObj =
|
|
meta && typeof meta === 'object'
|
|
? (meta as Record<string, unknown>)
|
|
: {};
|
|
if (metaObj.from_outside) return;
|
|
|
|
const entry = this.#extractFsEntryFromEvent(data);
|
|
if (!entry || entry.isDir) return;
|
|
|
|
const matched = await this.#listWorkerRowsForEntry(entry);
|
|
for (const row of matched) {
|
|
await this.#deleteWorkerForSourceRow(row, entry.userId);
|
|
}
|
|
}
|
|
|
|
async #handleSourceMove(data: unknown, meta: EventMetadata): Promise<void> {
|
|
const metaObj =
|
|
meta && typeof meta === 'object'
|
|
? (meta as Record<string, unknown>)
|
|
: {};
|
|
if (metaObj.from_outside) return;
|
|
|
|
const entry = this.#extractFsEntryFromEvent(data);
|
|
if (!entry || !this.#isTrashPath(entry.path)) return;
|
|
|
|
const matched = entry.isDir
|
|
? await this.#listWorkerRowsUnderPath(entry.userId, entry.path)
|
|
: await this.#listWorkerRowsForEntry(entry);
|
|
for (const row of matched) {
|
|
await this.#deleteWorkerForSourceRow(row, entry.userId);
|
|
}
|
|
}
|
|
|
|
#extractFsEntryFromEvent(data: unknown): FSEntry | undefined {
|
|
if (!data || typeof data !== 'object') return undefined;
|
|
const event = data as Record<string, unknown>;
|
|
for (const key of ['node', 'entry', 'target']) {
|
|
const value = event[key];
|
|
if (this.#isFsEntry(value)) {
|
|
return value;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
#isFsEntry(value: unknown): value is FSEntry {
|
|
if (!value || typeof value !== 'object') return false;
|
|
const entry = value as Partial<FSEntry>;
|
|
return (
|
|
typeof entry.id === 'number' &&
|
|
typeof entry.uuid === 'string' &&
|
|
typeof entry.userId === 'number' &&
|
|
typeof entry.path === 'string' &&
|
|
typeof entry.isDir === 'boolean'
|
|
);
|
|
}
|
|
|
|
async #listWorkerRowsForEntry(entry: FSEntry): Promise<SubdomainRow[]> {
|
|
const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix(
|
|
entry.userId,
|
|
WORKER_SUBDOMAIN_PREFIX,
|
|
);
|
|
return workerSubs.filter((r) => {
|
|
return (
|
|
String(r.root_dir_id) === String(entry.id) ||
|
|
String(r.root_dir_id) === String(entry.uuid) ||
|
|
String(r.root_dir_id) === String(entry.uid)
|
|
);
|
|
});
|
|
}
|
|
|
|
async #listWorkerRowsUnderPath(
|
|
userId: number,
|
|
parentPath: string,
|
|
): Promise<SubdomainRow[]> {
|
|
const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix(
|
|
userId,
|
|
WORKER_SUBDOMAIN_PREFIX,
|
|
);
|
|
const rootDirIds = workerSubs
|
|
.map((r) => r.root_dir_id)
|
|
.filter((id): id is number => typeof id === 'number');
|
|
const entriesById =
|
|
await this.stores.fsEntry.getEntriesByIds(rootDirIds);
|
|
return workerSubs.filter((row) => {
|
|
const rootDirId = row.root_dir_id;
|
|
if (typeof rootDirId !== 'number') return false;
|
|
const entry = entriesById.get(rootDirId);
|
|
return (
|
|
entry?.path === parentPath ||
|
|
entry?.path.startsWith(`${parentPath}/`)
|
|
);
|
|
});
|
|
}
|
|
|
|
#isTrashPath(entryPath: string): boolean {
|
|
const parts = entryPath.split('/').filter(Boolean);
|
|
return parts[1] === 'Trash';
|
|
}
|
|
|
|
async #deleteWorkerForSourceRow(
|
|
row: SubdomainRow,
|
|
userId: number,
|
|
): Promise<void> {
|
|
const workerFullName = String(row.subdomain ?? '');
|
|
if (!workerFullName.startsWith(WORKER_SUBDOMAIN_PREFIX)) return;
|
|
const workerName = workerFullName.slice(WORKER_SUBDOMAIN_PREFIX.length);
|
|
|
|
try {
|
|
await this.#cfDelete(workerName);
|
|
if (row.uuid) {
|
|
await this.stores.subdomain.deleteByUuid(String(row.uuid), {
|
|
userId,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.warn(
|
|
`[workers] source cleanup failed for ${workerName}`,
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
async #notifyUser(
|
|
userId: number,
|
|
workerName: string,
|
|
result: { success?: boolean; errors?: unknown[]; url?: string },
|
|
appUid: string | null,
|
|
): Promise<void> {
|
|
try {
|
|
const title = result.success
|
|
? `Successfully deployed https://${workerName}.puter.work`
|
|
: `Failed to deploy ${workerName}! ${(result.errors ?? []).join(', ')}`;
|
|
|
|
await this.services.notification.notify(
|
|
[userId],
|
|
{ title },
|
|
{
|
|
type: result.success
|
|
? 'app.worker.deployed'
|
|
: 'app.worker.deployFailed',
|
|
appUid,
|
|
},
|
|
);
|
|
} catch (err) {
|
|
console.warn('[workers] notification create failed', err);
|
|
}
|
|
}
|
|
}
|