278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http';
|
|
import { Readable } from 'node:stream';
|
|
|
|
import { PgAssetStore, ensureAssetSchema } from '@openmaic/storage/asset/pg';
|
|
import { PgDocumentStore, ensureDocumentSchema } from '@openmaic/storage/document/pg';
|
|
import { PgRuntimeStore, ensureSchema } from '@openmaic/storage/runtime/pg';
|
|
import { createStorageHttpHandler } from '@openmaic/storage/server';
|
|
import {
|
|
nodePostgresTransaction,
|
|
type ConnectableQueryable,
|
|
} from '@openmaic/storage/server/reference';
|
|
import { Pool } from 'pg';
|
|
|
|
import { validateAppScene, validateAppStage } from '@/lib/document-store/validators';
|
|
import { lazyAssetByteStore } from '@/lib/persistence/asset-byte-store';
|
|
import { authenticatePersistenceRequest } from '@/lib/persistence/server-auth';
|
|
import { APP_RUNTIME_PAYLOAD_VALIDATORS } from '@/lib/runtime/payload-validators';
|
|
|
|
const ROUTE_PREFIX = '/api/persistence';
|
|
|
|
type PoolFactory = (connectionString: string) => Pool;
|
|
|
|
interface PersistenceHandlerState {
|
|
connectionString?: string;
|
|
handlerPromise?: Promise<RequestListener>;
|
|
}
|
|
|
|
const HANDLER_STATE_KEY = Symbol.for('openmaic.persistence-route.handler');
|
|
const globalState = globalThis as typeof globalThis & {
|
|
[key: symbol]: PersistenceHandlerState | undefined;
|
|
};
|
|
const handlerState = (globalState[HANDLER_STATE_KEY] ??= {});
|
|
|
|
function jsonError(status: number, code: string, message: string): Response {
|
|
return Response.json({ error: { code, message } }, { status });
|
|
}
|
|
|
|
async function createPersistenceHandler(
|
|
connectionString: string,
|
|
poolFactory: PoolFactory,
|
|
): Promise<RequestListener> {
|
|
const pool = poolFactory(connectionString);
|
|
const queryable = pool as unknown as ConnectableQueryable;
|
|
try {
|
|
await ensureSchema(queryable);
|
|
await ensureDocumentSchema(queryable);
|
|
await ensureAssetSchema(queryable);
|
|
const withTransaction = nodePostgresTransaction(queryable);
|
|
// Deferred to first asset use: the asset backend is optional, so its
|
|
// misconfiguration (an invalid ASSET_S3_BUCKET, an unresolvable AWS SDK)
|
|
// must fail asset requests only, never this handler's initialization.
|
|
const byteStore = lazyAssetByteStore(process.env.ASSET_S3_BUCKET, queryable);
|
|
const runtimeStore = new PgRuntimeStore(queryable, {
|
|
withTransaction,
|
|
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
|
|
});
|
|
const documentStore = new PgDocumentStore(queryable, {
|
|
withTransaction,
|
|
validateScene: validateAppScene,
|
|
validateStage: validateAppStage,
|
|
});
|
|
// The asset contract requires a server-derived principal; this development
|
|
// authenticator instead takes the partition key from a client-supplied header.
|
|
// Cross-principal isolation is therefore not in force: asset bytes are as
|
|
// reachable as documents and runtime records under this authenticator. Before
|
|
// asset routes carry anything that matters, production must replace
|
|
// authenticatePersistenceRequest with real session verification. See
|
|
// lib/persistence/server-auth.ts for the token's limits.
|
|
const assetStore = new PgAssetStore(queryable, { withTransaction, byteStore });
|
|
// Reclamation is not scheduled from here, and must not be: a route module
|
|
// has no once-per-process guarantee and no shutdown hook. AssetCollector
|
|
// runs from instrumentation.ts instead, over the byte store this same
|
|
// lib/persistence/asset-byte-store selection produces, so the collector
|
|
// always deletes through the layer the request path wrote through.
|
|
return createStorageHttpHandler(runtimeStore, documentStore, {
|
|
authenticate: authenticatePersistenceRequest,
|
|
authorizeMerge: async () => false,
|
|
authorizeAdmin: async () => false,
|
|
authorizeDocuments: async () => true,
|
|
validateScene: validateAppScene,
|
|
validateStage: validateAppStage,
|
|
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
|
|
assetStore,
|
|
});
|
|
} catch (error) {
|
|
await pool.end().catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function getPersistenceHandler(
|
|
connectionString: string,
|
|
poolFactory: PoolFactory,
|
|
): Promise<RequestListener> {
|
|
if (handlerState.handlerPromise && handlerState.connectionString === connectionString) {
|
|
return handlerState.handlerPromise;
|
|
}
|
|
|
|
handlerState.connectionString = connectionString;
|
|
const initialization = createPersistenceHandler(connectionString, poolFactory).catch((error) => {
|
|
// Do not poison the singleton with a rejected promise. createPersistenceHandler
|
|
// has already closed its failed pool, and the next request gets a clean retry.
|
|
if (handlerState.handlerPromise === initialization) {
|
|
handlerState.handlerPromise = undefined;
|
|
handlerState.connectionString = undefined;
|
|
}
|
|
throw error;
|
|
});
|
|
handlerState.handlerPromise = initialization;
|
|
return initialization;
|
|
}
|
|
|
|
function nodeRequest(request: Request): IncomingMessage {
|
|
const url = new URL(request.url);
|
|
const pathname = url.pathname.startsWith(ROUTE_PREFIX)
|
|
? url.pathname.slice(ROUTE_PREFIX.length) || '/'
|
|
: url.pathname;
|
|
const body = request.body
|
|
? Readable.fromWeb(
|
|
request.body as unknown as import('node:stream/web').ReadableStream<Uint8Array>,
|
|
)
|
|
: Readable.from([]);
|
|
return Object.assign(body, {
|
|
method: request.method,
|
|
url: `${pathname}${url.search}`,
|
|
headers: Object.fromEntries(request.headers.entries()),
|
|
}) as IncomingMessage;
|
|
}
|
|
|
|
function setHeaders(target: Headers, source: Record<string, string | number | string[]>): void {
|
|
for (const [name, value] of Object.entries(source)) {
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) target.append(name, item);
|
|
} else {
|
|
target.set(name, String(value));
|
|
}
|
|
}
|
|
}
|
|
|
|
type ResponseCallback = () => void;
|
|
|
|
function responseEncoding(encodingOrCallback?: BufferEncoding | ResponseCallback): BufferEncoding {
|
|
const encoding = typeof encodingOrCallback === 'string' ? encodingOrCallback : 'utf8';
|
|
if (!Buffer.isEncoding(encoding)) {
|
|
// Let Buffer produce Node's ERR_UNKNOWN_ENCODING TypeError.
|
|
Buffer.from('', encoding);
|
|
}
|
|
return encoding;
|
|
}
|
|
|
|
function responseCallback(
|
|
encodingOrCallback?: BufferEncoding | ResponseCallback,
|
|
callback?: ResponseCallback,
|
|
): ResponseCallback | undefined {
|
|
return typeof encodingOrCallback === 'function' ? encodingOrCallback : callback;
|
|
}
|
|
|
|
function suppressesResponseBody(request: Request, status: number): boolean {
|
|
return request.method === 'HEAD' || status === 204 || status === 205 || status === 304;
|
|
}
|
|
|
|
function runNodeHandler(handler: RequestListener, request: Request): Promise<Response> {
|
|
return new Promise<Response>((resolve, reject) => {
|
|
let status = 200;
|
|
const headers = new Headers();
|
|
let headersSent = false;
|
|
// Buffered as bytes rather than as a string. A handler may end with a
|
|
// `Uint8Array`, which `ServerResponse.end` accepts and which is not
|
|
// necessarily valid UTF-8; decoding it would replace every unpaired byte
|
|
// with U+FFFD and silently corrupt the response.
|
|
const body: Buffer[] = [];
|
|
|
|
const appendChunk = (chunk: string | Uint8Array, encoding: BufferEncoding) => {
|
|
body.push(typeof chunk === 'string' ? Buffer.from(chunk, encoding) : Buffer.from(chunk));
|
|
};
|
|
|
|
const response = {
|
|
get headersSent() {
|
|
return headersSent;
|
|
},
|
|
writeHead(
|
|
statusCode: number,
|
|
statusMessageOrHeaders?: string | Record<string, string | number | string[]>,
|
|
outgoingHeaders?: Record<string, string | number | string[]>,
|
|
) {
|
|
status = statusCode;
|
|
headersSent = true;
|
|
const values =
|
|
typeof statusMessageOrHeaders === 'string' ? outgoingHeaders : statusMessageOrHeaders;
|
|
if (values) setHeaders(headers, values);
|
|
return this;
|
|
},
|
|
write(
|
|
chunk: string | Uint8Array,
|
|
encodingOrCallback?: BufferEncoding | ResponseCallback,
|
|
callback?: ResponseCallback,
|
|
) {
|
|
// `write` is part of the `ServerResponse` surface this object claims to
|
|
// implement. Omitting it made any chunked handler a runtime TypeError
|
|
// that the `as unknown as ServerResponse` cast hid from the compiler.
|
|
headersSent = true;
|
|
appendChunk(chunk, responseEncoding(encodingOrCallback));
|
|
const done = responseCallback(encodingOrCallback, callback);
|
|
if (done) process.nextTick(done);
|
|
return true;
|
|
},
|
|
end(
|
|
chunkOrCallback?: string | Uint8Array | ResponseCallback,
|
|
encodingOrCallback?: BufferEncoding | ResponseCallback,
|
|
callback?: ResponseCallback,
|
|
) {
|
|
headersSent = true;
|
|
const chunk = typeof chunkOrCallback === 'function' ? undefined : chunkOrCallback;
|
|
const done =
|
|
typeof chunkOrCallback === 'function'
|
|
? chunkOrCallback
|
|
: responseCallback(encodingOrCallback, callback);
|
|
if (chunk !== undefined) appendChunk(chunk, responseEncoding(encodingOrCallback));
|
|
resolve(
|
|
new Response(
|
|
suppressesResponseBody(request, status) || body.length === 0
|
|
? undefined
|
|
: Buffer.concat(body),
|
|
{
|
|
status,
|
|
headers,
|
|
},
|
|
),
|
|
);
|
|
if (done) process.nextTick(done);
|
|
return this;
|
|
},
|
|
destroy(error?: Error) {
|
|
reject(error ?? new Error('Persistence HTTP handler destroyed the response'));
|
|
return this;
|
|
},
|
|
} as unknown as ServerResponse;
|
|
|
|
try {
|
|
handler(nodeRequest(request), response);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
interface PersistenceRequestDeps {
|
|
poolFactory?: PoolFactory;
|
|
}
|
|
|
|
export async function handlePersistenceRequest(
|
|
request: Request,
|
|
deps: PersistenceRequestDeps = {},
|
|
): Promise<Response> {
|
|
const connectionString = process.env.DATABASE_URL;
|
|
if (!connectionString) {
|
|
return jsonError(404, 'PERSISTENCE_NOT_CONFIGURED', 'server persistence not configured');
|
|
}
|
|
if (!process.env.PERSISTENCE_DEV_TOKEN) {
|
|
return jsonError(
|
|
503,
|
|
'PERSISTENCE_DEV_TOKEN_MISSING',
|
|
'server persistence requires PERSISTENCE_DEV_TOKEN (development auth only)',
|
|
);
|
|
}
|
|
|
|
try {
|
|
const poolFactory = deps.poolFactory ?? ((value) => new Pool({ connectionString: value }));
|
|
return await runNodeHandler(
|
|
await getPersistenceHandler(connectionString, poolFactory),
|
|
request,
|
|
);
|
|
} catch (error) {
|
|
console.error('Embedded persistence route initialization failed', error);
|
|
return jsonError(500, 'PERSISTENCE_INIT_FAILED', 'server persistence initialization failed');
|
|
}
|
|
}
|