Make it work, add lifecycle expiry since workers are process heavy in current implementation

This commit is contained in:
Neal Shah
2026-07-14 19:44:26 -04:00
parent 04b820afb6
commit 18dc9e22b1
5 changed files with 304 additions and 30 deletions
@@ -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);
}
};
};
+24 -3
View File
@@ -33,6 +33,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 +67,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.
@@ -101,7 +112,7 @@ export class WorkerDriver extends PuterDriver {
);
}
} else if (cfg.localServer) {
//
USE_LOCAL_WORKERD = true;
}
this.#subscribeHotReload();
}
@@ -367,6 +378,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',
@@ -432,6 +450,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',
@@ -457,7 +478,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',
});
@@ -529,7 +550,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',
+13
View File
@@ -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]
+10 -8
View File
@@ -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;
@@ -1,13 +1,20 @@
import { Miniflare, RequestInit } from 'miniflare';
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 { 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
interface SubdomainRow {
id: number;
uuid: string;
@@ -23,6 +30,9 @@ interface SubdomainRow {
}
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>;
@@ -32,18 +42,23 @@ export class LocalWorkerService extends PuterService {
authorization: string,
code: string,
) {
const mf = new Miniflare({
modules: false,
name: workerName,
bindings: {
puter_auth: authorization,
//todo: maybe dont hardcode this
puter_endpoint: 'http://api.puter.localhost:4100/',
}, // Binds variable/secret to environment
script: code,
} as WorkerOptions);
activeWorkers.set(workerName, mf);
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);
@@ -64,15 +79,72 @@ export class LocalWorkerService extends PuterService {
await this.cfDeployLocal(workerName, authorization, code);
mf = activeWorkers.get(workerName)!;
}
return mf.dispatchFetch(request.url, request as unknown as RequestInit);
// 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) {
mf.dispose();
activeWorkers.delete(workerName);
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);
}
return true;
}
async reconstructDeployArgs(workerName: string, row: SubdomainRow) {
const appOwnerId = row.app_owner as number | null;
@@ -101,6 +173,20 @@ export class LocalWorkerService extends PuterService {
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,
@@ -108,10 +194,12 @@ export class LocalWorkerService extends PuterService {
},
this.services.fs,
ownerActor,
row.root_dir_id,
sourceEntry.path ?? sourceEntry.uuid,
{ maxBytes: MAX_SOURCE_SIZE },
);
const code = loaded.buffer.toString('utf-8');
const sourceCode = loaded.buffer.toString('utf-8');
const code = getWorkerPreamble() + sourceCode;
return [workerName, authorization, code];
}