mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-22 05:57:57 +00:00
Implement workers without cloudflare for local dev testing (#3389)
* typeify subdomains wip * initial (untested) logic for LocalWorkerService * Make it work, add lifecycle expiry since workers are process heavy in current implementation * fix type errors --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
This commit is contained in:
co-authored by
Daniel Salazar
parent
3240a4670a
commit
23e8705b03
@@ -78,6 +78,7 @@
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.6",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import type { RequestHandler } from 'express';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { puterClients } from '../../../clients';
|
||||
import type { puterServices } from '../../../services';
|
||||
import type { puterStores } from '../../../stores';
|
||||
import type { IConfig, LayerInstances } from '../../../types';
|
||||
|
||||
interface Layers {
|
||||
clients: LayerInstances<typeof puterClients>;
|
||||
stores: LayerInstances<typeof puterStores>;
|
||||
services: LayerInstances<typeof puterServices>;
|
||||
}
|
||||
|
||||
// Local analogue of the production `<name>.puter.work` worker domain. Requests
|
||||
// to `<name>.workers.puter.localhost` are dispatched into a Miniflare instance
|
||||
// by `LocalWorkerService`, which mirrors the real Cloudflare dispatch path.
|
||||
const WORKER_HOST_SUFFIX = 'workers.puter.localhost';
|
||||
|
||||
// Minimal WHATWG-Response shape we consume from Miniflare's `dispatchFetch`.
|
||||
// It isn't the Node global `Response`, so we type it structurally rather than
|
||||
// importing Miniflare's classes into the HTTP layer.
|
||||
interface FetchResponse {
|
||||
status: number;
|
||||
headers: { forEach(cb: (value: string, key: string) => void): void };
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
}
|
||||
|
||||
function normalizeHost(value: string | undefined | null): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim().toLowerCase().replace(/^\./, '');
|
||||
if (!trimmed) return null;
|
||||
return trimmed.split(':')[0] || null;
|
||||
}
|
||||
|
||||
// `<name>.workers.puter.localhost` → `name`. Returns null for the bare zone or
|
||||
// any host outside it. Flip this if your dev DNS puts the name elsewhere.
|
||||
function workerNameFromHost(host: string): string | null {
|
||||
if (host === WORKER_HOST_SUFFIX) return null;
|
||||
if (!host.endsWith(`.${WORKER_HOST_SUFFIX}`)) return null;
|
||||
const prefix = host.slice(0, host.length - WORKER_HOST_SUFFIX.length - 1);
|
||||
return prefix.split('.')[0] || null;
|
||||
}
|
||||
|
||||
// Express (Node) request → WHATWG Request the Worker's `fetch(request)` sees.
|
||||
// Must run BEFORE any body-parsing middleware so `req` is still an unconsumed
|
||||
// stream; otherwise the Worker gets an empty body on POST/PUT.
|
||||
function toFetchRequest(req: Parameters<RequestHandler>[0]): Request {
|
||||
const url = `http://${req.headers.host ?? WORKER_HOST_SUFFIX}${req.originalUrl}`;
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) headers.append(key, v);
|
||||
} else if (value != null) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers,
|
||||
// `duplex: 'half'` is required by undici whenever a stream body is set.
|
||||
body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined,
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
} as RequestInit);
|
||||
}
|
||||
|
||||
// WHATWG Response from the Worker → Express response.
|
||||
function sendFetchResponse(
|
||||
res: Parameters<RequestHandler>[1],
|
||||
response: FetchResponse,
|
||||
): void {
|
||||
res.status(response.status);
|
||||
response.headers.forEach((value, key) => {
|
||||
// Node manages framing headers itself; forwarding them corrupts the
|
||||
// response (double content-length, stale transfer-encoding).
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'content-length' || lower === 'transfer-encoding') return;
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
|
||||
if (!response.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeStream = Readable.fromWeb(response.body as never);
|
||||
nodeStream.on('error', () => res.destroy());
|
||||
nodeStream.pipe(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves local Workers on `*.workers.puter.localhost` by dispatching into
|
||||
* Miniflare via `LocalWorkerService`. No-op unless `config.workers.localServer`
|
||||
* is set — production keeps hitting real Cloudflare through `WorkerDriver`.
|
||||
*
|
||||
* Mount this BEFORE the body-parsing middleware in `server.ts` so the Worker
|
||||
* receives the raw request stream.
|
||||
*/
|
||||
export const createLocalWorkerProxyMiddleware = (
|
||||
config: IConfig,
|
||||
layers: Layers,
|
||||
): RequestHandler => {
|
||||
if (!config.workers?.localServer) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
|
||||
const localWorkerService = layers.services.localworkerservice;
|
||||
|
||||
return async (req, res, next) => {
|
||||
const host = normalizeHost(req.hostname);
|
||||
if (!host) return next();
|
||||
|
||||
const workerName = workerNameFromHost(host);
|
||||
if (!workerName) return next();
|
||||
|
||||
try {
|
||||
const fetchRequest = toFetchRequest(req);
|
||||
const response = (await localWorkerService.cfCallLocal(
|
||||
workerName,
|
||||
fetchRequest,
|
||||
)) as unknown as FetchResponse;
|
||||
sendFetchResponse(res, response);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -244,9 +244,11 @@ export class SubdomainDriver extends PuterDriver {
|
||||
if (object.domain !== undefined)
|
||||
patch.domain = object.domain != null ? String(object.domain) : null;
|
||||
|
||||
const updated = await this.stores.subdomain.update(row.uuid, patch, {
|
||||
userId: row.user_id,
|
||||
});
|
||||
const updated = await this.stores.subdomain.update(
|
||||
String(row.uuid),
|
||||
patch,
|
||||
{ userId: row.user_id as number },
|
||||
);
|
||||
const [shaped] = await this.#hydrateRows(
|
||||
updated ? [updated as Record<string, unknown>] : [],
|
||||
);
|
||||
@@ -351,8 +353,8 @@ export class SubdomainDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
await this.#checkWriteAccess(row, actor);
|
||||
await this.stores.subdomain.deleteByUuid(row.uuid, {
|
||||
userId: row.user_id,
|
||||
await this.stores.subdomain.deleteByUuid(String(row.uuid), {
|
||||
userId: row.user_id as number,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,7 @@ 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 type { SubdomainRow } from '../../stores/subdomain/SubdomainStore.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { loadFileInput } from '../util/fileInput.js';
|
||||
|
||||
@@ -33,6 +34,7 @@ const WORKER_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
const MAX_WORKERS_PER_USER = 100;
|
||||
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
const WORKER_SUBDOMAIN_PREFIX = 'workers.puter.';
|
||||
let USE_LOCAL_WORKERD = false;
|
||||
|
||||
// -- Preamble --------------------------------------------------------
|
||||
//
|
||||
@@ -66,6 +68,16 @@ try {
|
||||
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(): string {
|
||||
return preamble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver exposing the `workers` interface — Cloudflare Workers
|
||||
* deployment, lifecycle, and file-path queries.
|
||||
@@ -100,6 +112,8 @@ export class WorkerDriver extends PuterDriver {
|
||||
'[workers] preamble not build but workers configured to be enabled. Halting start',
|
||||
);
|
||||
}
|
||||
} else if (cfg.localServer) {
|
||||
USE_LOCAL_WORKERD = true;
|
||||
}
|
||||
this.#subscribeHotReload();
|
||||
}
|
||||
@@ -300,7 +314,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
const actor = this.#requireActor();
|
||||
const workerName = args.workerName as string | undefined;
|
||||
|
||||
let rows: Array<Record<string, unknown>>;
|
||||
let rows: SubdomainRow[];
|
||||
if (typeof workerName === 'string' && workerName.length > 0) {
|
||||
const sub = await this.stores.subdomain.getBySubdomain(
|
||||
`${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
|
||||
@@ -365,6 +379,13 @@ export class WorkerDriver extends PuterDriver {
|
||||
authorization: string,
|
||||
code: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (USE_LOCAL_WORKERD) {
|
||||
return this.services.localworkerservice.cfDeployLocal(
|
||||
workerName,
|
||||
authorization,
|
||||
code,
|
||||
);
|
||||
}
|
||||
const cfg = this.#workerConfig();
|
||||
const metadata = JSON.stringify({
|
||||
body_part: 'swCode',
|
||||
@@ -430,6 +451,9 @@ export class WorkerDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -455,7 +479,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
|
||||
#requireCfConfig(): void {
|
||||
const cfg = this.#workerConfig();
|
||||
if (!cfg.XAUTHKEY || !cfg.ACCOUNTID) {
|
||||
if ((!cfg.XAUTHKEY || !cfg.ACCOUNTID) && !cfg.localServer) {
|
||||
throw new HttpError(503, 'Cloudflare Workers not configured', {
|
||||
legacyCode: 'response_timeout',
|
||||
});
|
||||
@@ -472,7 +496,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
#checkWorkerWriteAccess(
|
||||
row: Record<string, unknown>,
|
||||
row: SubdomainRow,
|
||||
actor: Actor & { user: { id: number } },
|
||||
errorStatus: number,
|
||||
errorMessage: string,
|
||||
@@ -527,7 +551,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
// while worker subdomains are keyed to the numeric fsentries.id.
|
||||
|
||||
#subscribeHotReload(): void {
|
||||
if (!this.#cfBaseUrl) return; // CF not configured — skip
|
||||
if (!this.#cfBaseUrl && !USE_LOCAL_WORKERD) return;
|
||||
|
||||
this.clients.event.on(
|
||||
'fs.write.file',
|
||||
@@ -715,14 +739,12 @@ export class WorkerDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
async #listWorkerRowsForEntry(
|
||||
entry: FSEntry,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
async #listWorkerRowsForEntry(entry: FSEntry): Promise<SubdomainRow[]> {
|
||||
const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix(
|
||||
entry.userId,
|
||||
WORKER_SUBDOMAIN_PREFIX,
|
||||
);
|
||||
return workerSubs.filter((r: Record<string, unknown>) => {
|
||||
return workerSubs.filter((r) => {
|
||||
return (
|
||||
String(r.root_dir_id) === String(entry.id) ||
|
||||
String(r.root_dir_id) === String(entry.uuid) ||
|
||||
@@ -734,17 +756,17 @@ export class WorkerDriver extends PuterDriver {
|
||||
async #listWorkerRowsUnderPath(
|
||||
userId: number,
|
||||
parentPath: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
): Promise<SubdomainRow[]> {
|
||||
const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix(
|
||||
userId,
|
||||
WORKER_SUBDOMAIN_PREFIX,
|
||||
);
|
||||
const rootDirIds = workerSubs
|
||||
.map((r: Record<string, unknown>) => r.root_dir_id)
|
||||
.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: Record<string, unknown>) => {
|
||||
return workerSubs.filter((row) => {
|
||||
const rootDirId = row.root_dir_id;
|
||||
if (typeof rootDirId !== 'number') return false;
|
||||
const entry = entriesById.get(rootDirId);
|
||||
@@ -761,7 +783,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async #deleteWorkerForSourceRow(
|
||||
row: Record<string, unknown>,
|
||||
row: SubdomainRow,
|
||||
userId: number,
|
||||
): Promise<void> {
|
||||
const workerFullName = String(row.subdomain ?? '');
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
createUserSubdomainRedirect,
|
||||
createNativeAppStatic,
|
||||
} from './core/http/middleware/hostRedirects';
|
||||
import { createLocalWorkerProxyMiddleware } from './core/http/middleware/localWorkerProxy';
|
||||
import { createPuterSiteMiddleware } from './core/http/middleware/puterSite';
|
||||
import { PuterRouter } from './core/http/PuterRouter';
|
||||
import { createRouteLifecycleMiddleware } from './core/http/routeLifecycle';
|
||||
@@ -454,6 +455,18 @@ export class PuterServer {
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
// -- Local Worker proxy (*.workers.puter.localhost) ----------
|
||||
// Dev-only Miniflare dispatch, gated on `config.workers.localServer`.
|
||||
// Mounted BEFORE body parsing so the Worker receives the raw request
|
||||
// stream; no-op in production (real Cloudflare via WorkerDriver).
|
||||
this.#app.use(
|
||||
createLocalWorkerProxyMiddleware(this.#config, {
|
||||
clients: this.clients,
|
||||
stores: this.stores,
|
||||
services: this.services,
|
||||
}),
|
||||
);
|
||||
|
||||
// -- Body parsing (JSON + text-as-json shim) -----------------
|
||||
const captureRawBody: NonNullable<
|
||||
Parameters<typeof express.json>[0]
|
||||
|
||||
@@ -17,23 +17,24 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ACLService } from './acl/ACLService';
|
||||
import { AppOriginBlocklistService } from './abuse/AppOriginBlocklistService';
|
||||
import { ACLService } from './acl/ACLService';
|
||||
import { AppIconService } from './appIcon/AppIconService';
|
||||
import { AppPermissionService } from './apps/AppPermissionService';
|
||||
import { RecommendedAppsService } from './apps/RecommendedAppsService';
|
||||
import { SuggestedAppsService } from './apps/SuggestedAppsService';
|
||||
import { AuthService } from './auth/AuthService';
|
||||
import { BroadcastService } from './broadcast/BroadcastService';
|
||||
import { NotificationService } from './notification/NotificationService';
|
||||
import { AppIconService } from './appIcon/AppIconService';
|
||||
import { DefaultUserService } from './selfhosted/DefaultUserService';
|
||||
import { PuterHomepageService } from './homepage/PuterHomepageService';
|
||||
import { OIDCService } from './auth/OIDCService';
|
||||
import { TokenService } from './auth/TokenService';
|
||||
import { BroadcastService } from './broadcast/BroadcastService';
|
||||
import { FSService } from './fs/FSService';
|
||||
import { MeteringService } from './metering/MeteringService';
|
||||
import { PermissionService } from './permission/PermissionService';
|
||||
import { ServerHealthService } from './health/ServerHealthService';
|
||||
import { PuterHomepageService } from './homepage/PuterHomepageService';
|
||||
import { LocalWorkerService } from './localworker/LocalWorkerService';
|
||||
import { MeteringService } from './metering/MeteringService';
|
||||
import { NotificationService } from './notification/NotificationService';
|
||||
import { PermissionService } from './permission/PermissionService';
|
||||
import { DefaultUserService } from './selfhosted/DefaultUserService';
|
||||
import { SocketService } from './socket/SocketService';
|
||||
import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService';
|
||||
import type { IPuterServiceRegistry } from './types';
|
||||
@@ -106,4 +107,5 @@ export const puterServices = {
|
||||
// Health comes after socket so its default `socket-initialized`
|
||||
// check can reference the peer.
|
||||
health: ServerHealthService,
|
||||
localworkerservice: LocalWorkerService,
|
||||
} satisfies IPuterServiceRegistry;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Miniflare, RequestInit as MiniflareRequestInit } from 'miniflare';
|
||||
import { puterServices } from '..';
|
||||
import { Actor } from '../../core';
|
||||
import { loadFileInput } from '../../drivers/util/fileInput';
|
||||
import { getWorkerPreamble } from '../../drivers/workers/WorkerDriver';
|
||||
import { puterStores } from '../../stores';
|
||||
import type { SubdomainRow } from '../../stores/subdomain/SubdomainStore';
|
||||
import { LayerInstances } from '../../types';
|
||||
import { PuterService } from '../types';
|
||||
|
||||
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
// Each Miniflare instance holds a dedicated loopback port, so we can't keep
|
||||
// every deployed worker resident indefinitely. Dispose a worker after this
|
||||
// much inactivity; the next request lazily re-deploys it via cfCallLocal.
|
||||
const WORKER_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const IDLE_SWEEP_INTERVAL_MS = 60 * 1000; // sweep cadence
|
||||
|
||||
const activeWorkers = new Map<string, Miniflare>();
|
||||
// workerName -> last dispatch/deploy time (ms). Drives the idle sweep.
|
||||
const lastAccess = new Map<string, number>();
|
||||
let idleSweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export class LocalWorkerService extends PuterService {
|
||||
declare protected stores: LayerInstances<typeof puterStores>;
|
||||
declare protected services: LayerInstances<typeof puterServices>;
|
||||
async cfDeployLocal(
|
||||
workerName: string,
|
||||
authorization: string,
|
||||
code: string,
|
||||
) {
|
||||
await this.#disposeWorker(workerName);
|
||||
try {
|
||||
const mf = new Miniflare({
|
||||
modules: false,
|
||||
name: workerName,
|
||||
bindings: {
|
||||
puter_auth: authorization,
|
||||
puter_endpoint: this.config.api_base_url,
|
||||
}, // Binds variable/secret to environment
|
||||
script: code,
|
||||
} as WorkerOptions);
|
||||
activeWorkers.set(workerName, mf);
|
||||
this.#touch(workerName);
|
||||
return { success: true, errors: [], url: null };
|
||||
} catch (_e) {
|
||||
return { success: false, errors: [], url: null };
|
||||
}
|
||||
}
|
||||
async cfCallLocal(workerName: string, request: Request) {
|
||||
let mf = activeWorkers.get(workerName);
|
||||
if (!mf) {
|
||||
// cfDeployLocal here
|
||||
const existingSub: SubdomainRow | null =
|
||||
await this.stores.subdomain.getBySubdomain(
|
||||
'workers.puter.' + workerName,
|
||||
);
|
||||
|
||||
if (!existingSub) {
|
||||
return new Response('subdomain not found', { status: 404 });
|
||||
}
|
||||
const [_, authorization, code] = await this.reconstructDeployArgs(
|
||||
workerName,
|
||||
existingSub,
|
||||
);
|
||||
await this.cfDeployLocal(workerName, authorization, code);
|
||||
mf = activeWorkers.get(workerName)!;
|
||||
}
|
||||
// Mark activity so the idle sweep keeps this worker resident.
|
||||
this.#touch(workerName);
|
||||
// `request` is a WHATWG Request built by the local-worker proxy
|
||||
// middleware. Miniflare's `dispatchFetch(input, init)` needs us to coerce this
|
||||
const hasBody = request.body != null;
|
||||
return mf.dispatchFetch(request.url, {
|
||||
method: request.method,
|
||||
headers: [...request.headers] as [string, string][],
|
||||
body: hasBody ? (request.body as unknown as BodyInit) : undefined,
|
||||
// `duplex: 'half'` is required by undici when body is a stream.
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
} as unknown as MiniflareRequestInit);
|
||||
}
|
||||
async cfDeleteLocal(workerName: string) {
|
||||
await this.#disposeWorker(workerName);
|
||||
return {};
|
||||
}
|
||||
|
||||
// -- Idle lifecycle stuff
|
||||
|
||||
#touch(workerName: string): void {
|
||||
lastAccess.set(workerName, Date.now());
|
||||
this.#ensureIdleSweep();
|
||||
}
|
||||
|
||||
async #disposeWorker(workerName: string): Promise<void> {
|
||||
const mf = activeWorkers.get(workerName);
|
||||
activeWorkers.delete(workerName);
|
||||
lastAccess.delete(workerName);
|
||||
if (mf) {
|
||||
try {
|
||||
await mf.dispose(); // releases the instance's port
|
||||
} catch {
|
||||
/* best-effort teardown */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazily started on first deploy; disposes workers idle past the timeout
|
||||
// and stops itself once nothing is resident.
|
||||
#ensureIdleSweep(): void {
|
||||
if (idleSweepTimer) return;
|
||||
idleSweepTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [name, ts] of [...lastAccess]) {
|
||||
if (now - ts > WORKER_IDLE_TIMEOUT_MS) {
|
||||
void this.#disposeWorker(name);
|
||||
}
|
||||
}
|
||||
if (activeWorkers.size === 0 && idleSweepTimer) {
|
||||
clearInterval(idleSweepTimer);
|
||||
idleSweepTimer = null;
|
||||
}
|
||||
}, IDLE_SWEEP_INTERVAL_MS);
|
||||
// Don't keep the process (or test runner) alive just for the sweep.
|
||||
idleSweepTimer.unref?.();
|
||||
}
|
||||
|
||||
override onServerShutdown(): void {
|
||||
if (idleSweepTimer) {
|
||||
clearInterval(idleSweepTimer);
|
||||
idleSweepTimer = null;
|
||||
}
|
||||
for (const name of [...activeWorkers.keys()]) {
|
||||
void this.#disposeWorker(name);
|
||||
}
|
||||
}
|
||||
async reconstructDeployArgs(workerName: string, row: SubdomainRow) {
|
||||
const appOwnerId = row.app_owner as number | null;
|
||||
let authorization: string;
|
||||
const ownerUser = await this.stores.user.getById(row.user_id);
|
||||
if (!ownerUser) throw new Error('Owner seems to not exist');
|
||||
const ownerActor = { user: ownerUser } as Actor;
|
||||
|
||||
if (appOwnerId) {
|
||||
const app = await this.stores.app.getById(appOwnerId);
|
||||
if (!app)
|
||||
throw new Error(
|
||||
'Local: Worker belongs to existant application',
|
||||
); // app gone
|
||||
authorization = await this.services.auth.createWorkerAppToken(
|
||||
ownerActor,
|
||||
app.uid,
|
||||
workerName,
|
||||
);
|
||||
} else {
|
||||
const session = await this.services.auth.createWorkerSessionToken(
|
||||
ownerUser,
|
||||
workerName,
|
||||
);
|
||||
|
||||
authorization = session.token;
|
||||
}
|
||||
|
||||
if (row.root_dir_id == null) {
|
||||
throw new Error(
|
||||
`Local: worker ${workerName} has no root_dir_id (source file)`,
|
||||
);
|
||||
}
|
||||
const sourceEntry = await this.stores.fsEntry.getEntryById(
|
||||
row.root_dir_id,
|
||||
);
|
||||
if (!sourceEntry) {
|
||||
throw new Error(
|
||||
`Local: worker ${workerName} source file not found (id=${row.root_dir_id})`,
|
||||
);
|
||||
}
|
||||
|
||||
const loaded = await loadFileInput(
|
||||
{
|
||||
fsEntry: this.stores.fsEntry,
|
||||
s3Object: this.stores.s3Object,
|
||||
},
|
||||
this.services.fs,
|
||||
ownerActor,
|
||||
sourceEntry.path ?? sourceEntry.uuid,
|
||||
{ maxBytes: MAX_SOURCE_SIZE },
|
||||
);
|
||||
const sourceCode = loaded.buffer.toString('utf-8');
|
||||
|
||||
const code = getWorkerPreamble() + sourceCode;
|
||||
|
||||
return [workerName, authorization, code];
|
||||
}
|
||||
}
|
||||
+78
-20
@@ -20,6 +20,29 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PuterStore } from '../types';
|
||||
|
||||
/**
|
||||
* A row from the `subdomains` table (the shape `getBySubdomain` / `getByUuid`
|
||||
* resolve to). Kept alongside the store so callers share one definition instead
|
||||
* of redeclaring it locally.
|
||||
*/
|
||||
export interface SubdomainRow {
|
||||
id: number;
|
||||
uuid: string;
|
||||
ts: number | string; // system timestamp
|
||||
subdomain: string; // immutable name
|
||||
user_id: number; // owner
|
||||
app_owner: number | null; // owning app, if any
|
||||
protected: 0 | 1; // access gate
|
||||
database_id: string | null; // Cloudflare D1 binding
|
||||
root_dir_id: number | null; // editable
|
||||
associated_app_id: string | null; // editable
|
||||
domain: string | null; // custom domain, editable
|
||||
// `SELECT *` may surface columns not modelled above (and callers still
|
||||
// treat rows as `Record<string, unknown>` in places). The index signature
|
||||
// keeps the named fields strongly typed while staying Record-compatible.
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Columns that may not be set through an `update` patch map. Defence-in-depth
|
||||
// against future callers (admin routes, extensions, new REST handlers) that
|
||||
// might forward `req.body` straight into the store: the driver's update
|
||||
@@ -62,7 +85,16 @@ const NEGATIVE_CACHE_TTL_SECONDS = 10;
|
||||
export class SubdomainStore extends PuterStore {
|
||||
// -- Reads --------------------------------------------------------
|
||||
|
||||
async getByUuid(uuid, { userId, primary = false } = {}) {
|
||||
async getByUuid(
|
||||
uuid: string,
|
||||
{
|
||||
userId,
|
||||
primary = false,
|
||||
}: {
|
||||
userId?: number | undefined;
|
||||
primary?: boolean;
|
||||
} = {},
|
||||
): Promise<SubdomainRow | null> {
|
||||
const where =
|
||||
userId !== undefined
|
||||
? 'WHERE `uuid` = ? AND `user_id` = ?'
|
||||
@@ -72,10 +104,10 @@ export class SubdomainStore extends PuterStore {
|
||||
const rows = primary
|
||||
? await this.clients.db.pread(sql, params)
|
||||
: await this.clients.db.read(sql, params);
|
||||
return rows[0] ?? null;
|
||||
return (rows[0] as unknown as SubdomainRow) ?? null;
|
||||
}
|
||||
|
||||
async getBySubdomain(subdomain) {
|
||||
async getBySubdomain(subdomain: string): Promise<SubdomainRow | null> {
|
||||
if (!subdomain) return null;
|
||||
|
||||
const cacheKey = this.#cacheKey(subdomain);
|
||||
@@ -83,7 +115,7 @@ export class SubdomainStore extends PuterStore {
|
||||
const raw = await this.clients.redis.get(cacheKey);
|
||||
if (raw === NEGATIVE_CACHE_MARKER) return null;
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = JSON.parse(raw) as SubdomainRow | null;
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
} catch {
|
||||
@@ -94,7 +126,7 @@ export class SubdomainStore extends PuterStore {
|
||||
'SELECT * FROM `subdomains` WHERE `subdomain` = ? LIMIT 1',
|
||||
[subdomain],
|
||||
);
|
||||
const row = rows[0] ?? null;
|
||||
const row = (rows[0] as unknown as SubdomainRow | undefined) ?? null;
|
||||
|
||||
if (row) {
|
||||
this.clients.redis
|
||||
@@ -113,7 +145,7 @@ export class SubdomainStore extends PuterStore {
|
||||
return row;
|
||||
}
|
||||
|
||||
async listByUserId(userId, { limit = 500 } = {}) {
|
||||
async listByUserId(userId: number, { limit = 500 } = {}) {
|
||||
const rows = await this.clients.db.read(
|
||||
`SELECT * FROM \`subdomains\` WHERE \`user_id\` = ? LIMIT ?`,
|
||||
[userId, limit],
|
||||
@@ -129,7 +161,7 @@ export class SubdomainStore extends PuterStore {
|
||||
return rows;
|
||||
}
|
||||
|
||||
async existsBySubdomain(subdomain) {
|
||||
async existsBySubdomain(subdomain: string) {
|
||||
// Reuse the positive/negative cache populated by getBySubdomain —
|
||||
// creation uniqueness checks and the Workers quota path would
|
||||
// otherwise punch through to the DB on every call.
|
||||
@@ -137,7 +169,7 @@ export class SubdomainStore extends PuterStore {
|
||||
return row != null;
|
||||
}
|
||||
|
||||
async countByUserId(userId) {
|
||||
async countByUserId(userId: number) {
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT COUNT(*) AS n FROM `subdomains` WHERE `user_id` = ?',
|
||||
[userId],
|
||||
@@ -145,7 +177,7 @@ export class SubdomainStore extends PuterStore {
|
||||
return rows[0]?.n ?? 0;
|
||||
}
|
||||
|
||||
async getByDomain(domain) {
|
||||
async getByDomain(domain: string) {
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT * FROM `subdomains` WHERE `domain` = ? LIMIT 1',
|
||||
[domain],
|
||||
@@ -153,14 +185,18 @@ export class SubdomainStore extends PuterStore {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByDomain(domain) {
|
||||
async listByDomain(domain: string) {
|
||||
return this.clients.db.read(
|
||||
'SELECT * FROM `subdomains` WHERE `domain` = ?',
|
||||
[domain],
|
||||
);
|
||||
}
|
||||
|
||||
async listByUserIdAndPrefix(userId, prefix, extra = {}) {
|
||||
async listByUserIdAndPrefix(
|
||||
userId: number,
|
||||
prefix: string,
|
||||
extra: { appId?: number } = {},
|
||||
): Promise<SubdomainRow[]> {
|
||||
if (!userId || prefix == null) return [];
|
||||
|
||||
const like = `${prefix}%`;
|
||||
@@ -177,7 +213,7 @@ export class SubdomainStore extends PuterStore {
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
return rows as unknown as SubdomainRow[];
|
||||
}
|
||||
|
||||
// -- Writes -------------------------------------------------------
|
||||
@@ -190,6 +226,13 @@ export class SubdomainStore extends PuterStore {
|
||||
associatedAppId = null,
|
||||
appOwner = null,
|
||||
preambleVersion = null,
|
||||
}: {
|
||||
userId: number;
|
||||
subdomain: string;
|
||||
rootDirId?: number | null;
|
||||
associatedAppId?: number | null;
|
||||
appOwner?: number | null;
|
||||
preambleVersion?: string | null;
|
||||
}) {
|
||||
if (!userId || !subdomain) {
|
||||
throw new Error('create: userId and subdomain are required');
|
||||
@@ -226,8 +269,16 @@ export class SubdomainStore extends PuterStore {
|
||||
return row;
|
||||
}
|
||||
|
||||
async update(uuid, patch, { userId } = {}) {
|
||||
const allowed = {};
|
||||
async update(
|
||||
uuid: string,
|
||||
patch: Record<string, unknown>,
|
||||
{
|
||||
userId,
|
||||
}: {
|
||||
userId?: number | undefined;
|
||||
} = {},
|
||||
) {
|
||||
const allowed: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (READ_ONLY_COLUMNS.has(k)) continue;
|
||||
allowed[k] = v;
|
||||
@@ -284,7 +335,14 @@ export class SubdomainStore extends PuterStore {
|
||||
return after;
|
||||
}
|
||||
|
||||
async deleteByUuid(uuid, { userId } = {}) {
|
||||
async deleteByUuid(
|
||||
uuid: string,
|
||||
{
|
||||
userId,
|
||||
}: {
|
||||
userId?: number | undefined;
|
||||
} = {},
|
||||
) {
|
||||
const row = await this.getByUuid(uuid, { userId });
|
||||
|
||||
const where =
|
||||
@@ -313,15 +371,15 @@ export class SubdomainStore extends PuterStore {
|
||||
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
#cacheKey(subdomain) {
|
||||
#cacheKey(subdomain: string) {
|
||||
return `${CACHE_KEY_PREFIX}:name:${subdomain}`;
|
||||
}
|
||||
|
||||
#prefixListTrackerKey(userId) {
|
||||
#prefixListTrackerKey(userId: number) {
|
||||
return `${CACHE_KEY_PREFIX}:listByUserPrefixKeys:${userId}`;
|
||||
}
|
||||
|
||||
async #refreshCache(row) {
|
||||
async #refreshCache(row: { subdomain?: string }) {
|
||||
if (!row?.subdomain) return;
|
||||
await this.publishCacheKeys({
|
||||
keys: [this.#cacheKey(row.subdomain)],
|
||||
@@ -336,7 +394,7 @@ export class SubdomainStore extends PuterStore {
|
||||
// renamed subdomain keeps showing up under its old folder in the GUI
|
||||
// (website badge, "associated websites" popover) until the entry's
|
||||
// independent TTL expires.
|
||||
async #invalidateRootDirEntry(rootDirId) {
|
||||
async #invalidateRootDirEntry(rootDirId: number | null | undefined) {
|
||||
if (rootDirId == null) return;
|
||||
const id =
|
||||
typeof rootDirId === 'number' ? rootDirId : Number(rootDirId);
|
||||
@@ -350,7 +408,7 @@ export class SubdomainStore extends PuterStore {
|
||||
}
|
||||
}
|
||||
|
||||
async #invalidatePrefixListsForUser(userId) {
|
||||
async #invalidatePrefixListsForUser(userId: number) {
|
||||
if (userId == null) return;
|
||||
const trackerKey = this.#prefixListTrackerKey(userId);
|
||||
let cacheKeys = [];
|
||||
Reference in New Issue
Block a user