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 = [];