From 23e8705b038ceb308eddb65d526f3838353fb49a Mon Sep 17 00:00:00 2001 From: Neal Shah <30693865+ProgrammerIn-wonderland@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:01:42 -0400 Subject: [PATCH] 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 --- package.json | 1 + .../core/http/middleware/localWorkerProxy.ts | 150 ++++++++++++++ .../drivers/subdomain/SubdomainDriver.ts | 12 +- src/backend/drivers/workers/WorkerDriver.ts | 46 +++-- src/backend/server.ts | 13 ++ src/backend/services/index.ts | 18 +- .../localworker/LocalWorkerService.ts | 193 ++++++++++++++++++ .../{SubdomainStore.js => SubdomainStore.ts} | 98 +++++++-- 8 files changed, 486 insertions(+), 45 deletions(-) create mode 100644 src/backend/core/http/middleware/localWorkerProxy.ts create mode 100644 src/backend/services/localworker/LocalWorkerService.ts rename src/backend/stores/subdomain/{SubdomainStore.js => SubdomainStore.ts} (81%) diff --git a/package.json b/package.json index 6bc09029b..d4494109a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/backend/core/http/middleware/localWorkerProxy.ts b/src/backend/core/http/middleware/localWorkerProxy.ts new file mode 100644 index 000000000..c2cf5c242 --- /dev/null +++ b/src/backend/core/http/middleware/localWorkerProxy.ts @@ -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 . + */ + +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; + stores: LayerInstances; + services: LayerInstances; +} + +// Local analogue of the production `.puter.work` worker domain. Requests +// to `.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 | 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; +} + +// `.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[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[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); + } + }; +}; diff --git a/src/backend/drivers/subdomain/SubdomainDriver.ts b/src/backend/drivers/subdomain/SubdomainDriver.ts index f60d0fea8..7fd69a3d4 100644 --- a/src/backend/drivers/subdomain/SubdomainDriver.ts +++ b/src/backend/drivers/subdomain/SubdomainDriver.ts @@ -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] : [], ); @@ -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 { diff --git a/src/backend/drivers/workers/WorkerDriver.ts b/src/backend/drivers/workers/WorkerDriver.ts index b98ba7b7f..8c1f2514f 100644 --- a/src/backend/drivers/workers/WorkerDriver.ts +++ b/src/backend/drivers/workers/WorkerDriver.ts @@ -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>; + 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> { + 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> { + 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, + 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>> { + async #listWorkerRowsForEntry(entry: FSEntry): Promise { const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix( entry.userId, WORKER_SUBDOMAIN_PREFIX, ); - return workerSubs.filter((r: Record) => { + 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>> { + ): Promise { const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix( userId, WORKER_SUBDOMAIN_PREFIX, ); const rootDirIds = workerSubs - .map((r: Record) => 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) => { + 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, + row: SubdomainRow, userId: number, ): Promise { const workerFullName = String(row.subdomain ?? ''); diff --git a/src/backend/server.ts b/src/backend/server.ts index d32a96895..acad0bf58 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -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[0] diff --git a/src/backend/services/index.ts b/src/backend/services/index.ts index a33ec37a2..694af143e 100644 --- a/src/backend/services/index.ts +++ b/src/backend/services/index.ts @@ -17,23 +17,24 @@ * along with this program. If not, see . */ -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; diff --git a/src/backend/services/localworker/LocalWorkerService.ts b/src/backend/services/localworker/LocalWorkerService.ts new file mode 100644 index 000000000..d82c6d2e4 --- /dev/null +++ b/src/backend/services/localworker/LocalWorkerService.ts @@ -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(); +// workerName -> last dispatch/deploy time (ms). Drives the idle sweep. +const lastAccess = new Map(); +let idleSweepTimer: ReturnType | null = null; + +export class LocalWorkerService extends PuterService { + declare protected stores: LayerInstances; + declare protected services: LayerInstances; + 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 { + 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]; + } +} diff --git a/src/backend/stores/subdomain/SubdomainStore.js b/src/backend/stores/subdomain/SubdomainStore.ts similarity index 81% rename from src/backend/stores/subdomain/SubdomainStore.js rename to src/backend/stores/subdomain/SubdomainStore.ts index 03f3488eb..8b9362a30 100644 --- a/src/backend/stores/subdomain/SubdomainStore.js +++ b/src/backend/stores/subdomain/SubdomainStore.ts @@ -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` 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 { 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 { 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 { 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, + { + userId, + }: { + userId?: number | undefined; + } = {}, + ) { + const allowed: Record = {}; 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 = [];