@openlup/platform 0.0.0-stage → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +204 -0
- package/README.md +125 -2
- package/dist/bff/dispatch.d.ts +27 -0
- package/dist/bff/dispatch.js +47 -0
- package/dist/bff/envelope.d.ts +46 -0
- package/dist/bff/envelope.js +80 -0
- package/dist/bff/health.d.ts +24 -0
- package/dist/bff/health.js +19 -0
- package/dist/bff/index.d.ts +6 -0
- package/dist/bff/index.js +6 -0
- package/dist/bff/lazyRoute.d.ts +4 -0
- package/dist/bff/lazyRoute.js +13 -0
- package/dist/bff/requestContext.d.ts +17 -0
- package/dist/bff/requestContext.js +26 -0
- package/dist/bff/routerRewrite.d.ts +2 -0
- package/dist/bff/routerRewrite.js +31 -0
- package/dist/blob/binding.d.ts +16 -0
- package/dist/blob/binding.js +37 -0
- package/dist/blob/filesystem.d.ts +8 -0
- package/dist/blob/filesystem.js +71 -0
- package/dist/blob/index.d.ts +6 -0
- package/dist/blob/index.js +6 -0
- package/dist/blob/s3.d.ts +32 -0
- package/dist/blob/s3.js +56 -0
- package/dist/blob/s3Client.d.ts +25 -0
- package/dist/blob/s3Client.js +76 -0
- package/dist/blob/supabase.d.ts +40 -0
- package/dist/blob/supabase.js +59 -0
- package/dist/blob/vercel.d.ts +63 -0
- package/dist/blob/vercel.js +69 -0
- package/dist/composition/composeBundle.d.ts +47 -0
- package/dist/composition/composeBundle.js +45 -0
- package/dist/composition/httpBinding.d.ts +8 -0
- package/dist/composition/httpBinding.js +18 -0
- package/dist/composition/index.d.ts +3 -0
- package/dist/composition/index.js +3 -0
- package/dist/composition/nodeHost.d.ts +103 -0
- package/dist/composition/nodeHost.js +145 -0
- package/dist/data/binding.d.ts +34 -0
- package/dist/data/binding.js +92 -0
- package/dist/data/index.d.ts +5 -0
- package/dist/data/index.js +5 -0
- package/dist/data/postgresGateway.d.ts +42 -0
- package/dist/data/postgresGateway.js +135 -0
- package/dist/data/queryBuilder.d.ts +54 -0
- package/dist/data/queryBuilder.js +240 -0
- package/dist/data/queryBuilderSql.d.ts +76 -0
- package/dist/data/queryBuilderSql.js +145 -0
- package/dist/data/supabaseClientFactory.d.ts +34 -0
- package/dist/data/supabaseClientFactory.js +56 -0
- package/dist/data/supabaseGateway.d.ts +22 -0
- package/dist/data/supabaseGateway.js +49 -0
- package/dist/http/deadline.d.ts +20 -0
- package/dist/http/deadline.js +93 -0
- package/dist/http/index.d.ts +6 -0
- package/dist/http/index.js +5 -0
- package/dist/http/requestAdapter.d.ts +38 -0
- package/dist/http/requestAdapter.js +203 -0
- package/dist/http/runtime.d.ts +83 -0
- package/dist/http/runtime.js +199 -0
- package/dist/http/security.d.ts +44 -0
- package/dist/http/security.js +114 -0
- package/dist/http/staticAssets.d.ts +3 -0
- package/dist/http/staticAssets.js +42 -0
- package/dist/http/types.d.ts +23 -0
- package/dist/http/types.js +6 -0
- package/dist/job-ledger/index.d.ts +5 -0
- package/dist/job-ledger/index.js +3 -0
- package/dist/job-ledger/ports.d.ts +10 -0
- package/dist/job-ledger/ports.js +7 -0
- package/dist/job-ledger/postgres.d.ts +10 -0
- package/dist/job-ledger/postgres.js +101 -0
- package/dist/job-ledger/sql.d.ts +7 -0
- package/dist/job-ledger/sql.js +10 -0
- package/dist/job-ledger/supabase.d.ts +77 -0
- package/dist/job-ledger/supabase.js +202 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +1 -0
- package/dist/kernel/platformKernel.d.ts +34 -0
- package/dist/kernel/platformKernel.js +115 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.js +1 -0
- package/dist/migrations/postgresRunner.d.ts +50 -0
- package/dist/migrations/postgresRunner.js +176 -0
- package/dist/scheduler/advisoryLock.d.ts +44 -0
- package/dist/scheduler/advisoryLock.js +114 -0
- package/dist/scheduler/cronGuard.d.ts +10 -0
- package/dist/scheduler/cronGuard.js +19 -0
- package/dist/scheduler/cronSecret.d.ts +5 -0
- package/dist/scheduler/cronSecret.js +23 -0
- package/dist/scheduler/handlers.d.ts +9 -0
- package/dist/scheduler/handlers.js +10 -0
- package/dist/scheduler/httpEntry.d.ts +16 -0
- package/dist/scheduler/httpEntry.js +31 -0
- package/dist/scheduler/index.d.ts +9 -0
- package/dist/scheduler/index.js +9 -0
- package/dist/scheduler/jobRegistry.d.ts +21 -0
- package/dist/scheduler/jobRegistry.js +39 -0
- package/dist/scheduler/jobs.d.ts +48 -0
- package/dist/scheduler/jobs.js +92 -0
- package/dist/scheduler/nodeScheduler.d.ts +42 -0
- package/dist/scheduler/nodeScheduler.js +73 -0
- package/dist/scheduler/vercelScheduler.d.ts +29 -0
- package/dist/scheduler/vercelScheduler.js +51 -0
- package/package.json +108 -4
- package/release-gates.json +121 -0
- package/sql/20260809150000_job_run_ledger.sql +301 -0
- package/sql/20260809150001_job_run_ledger_v3.sql +70 -0
- package/src/bff/dispatch.ts +81 -0
- package/src/bff/envelope.ts +142 -0
- package/src/bff/health.ts +37 -0
- package/src/bff/index.ts +30 -0
- package/src/bff/lazyRoute.ts +28 -0
- package/src/bff/requestContext.ts +48 -0
- package/src/bff/routerRewrite.ts +38 -0
- package/src/blob/binding.ts +55 -0
- package/src/blob/filesystem.ts +86 -0
- package/src/blob/index.ts +17 -0
- package/src/blob/s3.ts +87 -0
- package/src/blob/s3Client.ts +91 -0
- package/src/blob/supabase.ts +88 -0
- package/src/blob/vercel.ts +127 -0
- package/src/composition/composeBundle.ts +109 -0
- package/src/composition/httpBinding.ts +30 -0
- package/src/composition/index.ts +17 -0
- package/src/composition/nodeHost.ts +289 -0
- package/src/data/binding.ts +157 -0
- package/src/data/index.ts +33 -0
- package/src/data/postgresGateway.ts +186 -0
- package/src/data/queryBuilder.ts +300 -0
- package/src/data/queryBuilderSql.ts +183 -0
- package/src/data/supabaseClientFactory.ts +92 -0
- package/src/data/supabaseGateway.ts +76 -0
- package/src/http/deadline.ts +117 -0
- package/src/http/index.ts +36 -0
- package/src/http/requestAdapter.ts +215 -0
- package/src/http/runtime.ts +319 -0
- package/src/http/security.ts +155 -0
- package/src/http/staticAssets.ts +42 -0
- package/src/http/types.ts +31 -0
- package/src/job-ledger/index.ts +25 -0
- package/src/job-ledger/ports.ts +27 -0
- package/src/job-ledger/postgres.ts +121 -0
- package/src/job-ledger/sql.ts +12 -0
- package/src/job-ledger/supabase.ts +294 -0
- package/src/kernel/index.ts +17 -0
- package/src/kernel/platformKernel.ts +153 -0
- package/src/migrations/index.ts +10 -0
- package/src/migrations/postgresRunner.ts +255 -0
- package/src/scheduler/advisoryLock.ts +145 -0
- package/src/scheduler/cronGuard.ts +30 -0
- package/src/scheduler/cronSecret.ts +26 -0
- package/src/scheduler/handlers.ts +24 -0
- package/src/scheduler/httpEntry.ts +49 -0
- package/src/scheduler/index.ts +47 -0
- package/src/scheduler/jobRegistry.ts +57 -0
- package/src/scheduler/jobs.ts +119 -0
- package/src/scheduler/nodeScheduler.ts +123 -0
- package/src/scheduler/vercelScheduler.ts +71 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Native Node adapter for manifest-driven public routing and API topology.
|
|
2
|
+
//
|
|
3
|
+
// The request order is fixed: trailing-slash redirect, the CSP report entrypoint, deployment
|
|
4
|
+
// exact-path entrypoints, the BFF router entrypoint, deployment path entrypoints, the BFF
|
|
5
|
+
// dispatcher, robots.txt, declared scheduled routes, the API 404, legacy redirects, deployment
|
|
6
|
+
// fallback path entrypoints, then static documents. Every deployment value (routes, entrypoints,
|
|
7
|
+
// header policy, robots policy, deadlines) is supplied by the composition root.
|
|
8
|
+
import { createServer } from "node:http";
|
|
9
|
+
import { adaptRequest, decorateRequest, decorateResponse, NodeRequestAbortedError, NodeRequestBodyTooLargeError, } from "./requestAdapter.js";
|
|
10
|
+
import { closeInputAfterResponse, runWithHttpDeadline } from "./deadline.js";
|
|
11
|
+
import { applySecurityHeaders, serveSpaStatic, } from "./security.js";
|
|
12
|
+
const SCHEDULED_PREFIX = "/api/cron/";
|
|
13
|
+
const ROBOTS_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
14
|
+
const EMPTY_ROUTING = { legacyRedirects: [], ssgPaths: [], csrFallback: { exactPaths: [], routeFamilies: [] } };
|
|
15
|
+
function requestUrl(req) {
|
|
16
|
+
return new URL(req.url ?? "/", "http://node.local");
|
|
17
|
+
}
|
|
18
|
+
function capturesFor(source, pathname) {
|
|
19
|
+
const sourceParts = source.split("/").slice(1);
|
|
20
|
+
const pathParts = pathname.split("/").slice(1);
|
|
21
|
+
const captures = {};
|
|
22
|
+
let cursor = 0;
|
|
23
|
+
for (const sourcePart of sourceParts) {
|
|
24
|
+
if (!sourcePart.startsWith(":")) {
|
|
25
|
+
if (pathParts[cursor++] !== sourcePart)
|
|
26
|
+
return null;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const name = sourcePart.slice(1).replace(/\*$/, "");
|
|
30
|
+
if (sourcePart.endsWith("*")) {
|
|
31
|
+
captures[name] = pathParts.slice(cursor).join("/");
|
|
32
|
+
cursor = pathParts.length;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
const value = pathParts[cursor++];
|
|
36
|
+
if (!value)
|
|
37
|
+
return null;
|
|
38
|
+
captures[name] = value;
|
|
39
|
+
}
|
|
40
|
+
return cursor === pathParts.length ? captures : null;
|
|
41
|
+
}
|
|
42
|
+
function legacyRedirect(pathname, redirects) {
|
|
43
|
+
for (const redirect of redirects) {
|
|
44
|
+
const captures = capturesFor(redirect.source, pathname);
|
|
45
|
+
if (!captures)
|
|
46
|
+
continue;
|
|
47
|
+
return redirect.destination.replace(/:([A-Za-z][A-Za-z0-9_]*)\*?/g, (_match, name) => captures[name] ?? "");
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
function isCsrFallback(pathname, csrFallback) {
|
|
52
|
+
return csrFallback.exactPaths.includes(pathname)
|
|
53
|
+
|| csrFallback.routeFamilies.some((path) => pathname === path || pathname.startsWith(`${path}/`));
|
|
54
|
+
}
|
|
55
|
+
function finalize(res) {
|
|
56
|
+
if (!res.writableEnded && !res.destroyed)
|
|
57
|
+
res.end();
|
|
58
|
+
}
|
|
59
|
+
function matchPathEntrypoint(entrypoints, pathname) {
|
|
60
|
+
for (const entrypoint of entrypoints ?? []) {
|
|
61
|
+
const query = entrypoint.match(pathname);
|
|
62
|
+
if (query)
|
|
63
|
+
return { entrypoint, query };
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/** Build the runtime's request handler (exported for unit tests; wraps every dispatch path). */
|
|
68
|
+
export function createRequestHandler(options) {
|
|
69
|
+
const onError = options.onError ?? ((ctx, err) => console.error(`[node-http] ${ctx}`, err));
|
|
70
|
+
const staticReader = options.staticReader;
|
|
71
|
+
const routing = options.routing ?? EMPTY_ROUTING;
|
|
72
|
+
const ssgPaths = new Set(routing.ssgPaths);
|
|
73
|
+
// Built once; a deployment can deliberately replace an inherited path.
|
|
74
|
+
const extra = new Map((options.extraEntrypoints ?? []).map((entry) => [entry.path, entry]));
|
|
75
|
+
return async function handle(req, res) {
|
|
76
|
+
const url = requestUrl(req);
|
|
77
|
+
const pathname = url.pathname;
|
|
78
|
+
const host = req.headers.host;
|
|
79
|
+
const bounded = (durationMs, run) => runWithHttpDeadline({ req, res, durationMs, run, onLateError: (error) => onError(`${pathname}:late`, error) });
|
|
80
|
+
const invoke = (loader, durationMs, rawStream, query) => bounded(durationMs, async () => {
|
|
81
|
+
const [mod, vreq] = await Promise.all([loader(), adaptRequest(req, pathname, rawStream)]);
|
|
82
|
+
if (query)
|
|
83
|
+
vreq.query = { ...vreq.query, ...query };
|
|
84
|
+
await mod.default(vreq, res);
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
applySecurityHeaders(res, pathname, host, options.security);
|
|
88
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
89
|
+
res.statusCode = 308;
|
|
90
|
+
res.setHeader("location", `${pathname.replace(/\/+$/, "")}${url.search}`);
|
|
91
|
+
return finalize(res);
|
|
92
|
+
}
|
|
93
|
+
// 1. Standalone entrypoints (filesystem-parity with api/*.ts).
|
|
94
|
+
const cspReport = options.entrypoints?.cspReport;
|
|
95
|
+
if (pathname === "/api/csp-report" && cspReport) {
|
|
96
|
+
await invoke(cspReport, options.durations?.cspReport ?? 30_000);
|
|
97
|
+
return finalize(res);
|
|
98
|
+
}
|
|
99
|
+
// 1b. Deployment entrypoints: exact path, lazy load, owner-declared body policy.
|
|
100
|
+
const registered = extra.get(pathname);
|
|
101
|
+
if (registered) {
|
|
102
|
+
await invoke(registered.load, registered.maxDurationMs ?? 30_000, registered.rawStream);
|
|
103
|
+
return finalize(res);
|
|
104
|
+
}
|
|
105
|
+
const bffRouter = options.entrypoints?.bffRouter;
|
|
106
|
+
if (pathname === "/api/bff-router" && bffRouter) {
|
|
107
|
+
await invoke(bffRouter, options.durations?.bffRouter ?? 30_000);
|
|
108
|
+
return finalize(res);
|
|
109
|
+
}
|
|
110
|
+
// 1c. Deployment path-rule entrypoints, before the BFF dispatcher.
|
|
111
|
+
const routed = matchPathEntrypoint(options.pathEntrypoints, pathname);
|
|
112
|
+
if (routed) {
|
|
113
|
+
await invoke(routed.entrypoint.load, routed.entrypoint.maxDurationMs ?? 10_000, false, routed.query);
|
|
114
|
+
return finalize(res);
|
|
115
|
+
}
|
|
116
|
+
// 2. The BFF dispatcher (same routes + dispatch as the hosted entrypoint).
|
|
117
|
+
if (pathname === "/api/bff" || pathname.startsWith("/api/bff/")) {
|
|
118
|
+
await bounded(options.durations?.bff ?? 30_000, async () => options.dispatch(options.routes, await adaptRequest(req, pathname), res));
|
|
119
|
+
return finalize(res);
|
|
120
|
+
}
|
|
121
|
+
if (pathname === "/robots.txt" && options.robotsTxt) {
|
|
122
|
+
res.statusCode = 200;
|
|
123
|
+
res.setHeader("content-type", ROBOTS_CONTENT_TYPE);
|
|
124
|
+
res.setHeader("cache-control", "public, max-age=0, s-maxage=300, stale-while-revalidate=3600");
|
|
125
|
+
res.end(options.robotsTxt(host));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// 3. Scheduled routes share the scheduler's single-source allowlist.
|
|
129
|
+
if (pathname.startsWith(SCHEDULED_PREFIX)) {
|
|
130
|
+
const jobId = pathname.slice(SCHEDULED_PREFIX.length);
|
|
131
|
+
const loader = options.scheduledEntrypoint?.(jobId);
|
|
132
|
+
if (loader) {
|
|
133
|
+
await invoke(loader, options.durations?.scheduled?.(jobId) ?? 60_000);
|
|
134
|
+
return finalize(res);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// 4. Unknown /api/* => 404 JSON (never fall through to the SPA).
|
|
138
|
+
if (pathname.startsWith("/api/")) {
|
|
139
|
+
res.statusCode = 404;
|
|
140
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
141
|
+
res.end(JSON.stringify({ ok: false, error: { code: "NOT_FOUND", message: "Not found" } }));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const legacyLocation = legacyRedirect(pathname, routing.legacyRedirects);
|
|
145
|
+
if (legacyLocation) {
|
|
146
|
+
res.statusCode = 308;
|
|
147
|
+
res.setHeader("location", `${legacyLocation}${url.search}`);
|
|
148
|
+
return finalize(res);
|
|
149
|
+
}
|
|
150
|
+
const fallback = matchPathEntrypoint(options.fallbackPathEntrypoints, pathname);
|
|
151
|
+
if (fallback) {
|
|
152
|
+
await invoke(fallback.entrypoint.load, fallback.entrypoint.maxDurationMs ?? 10_000, false, fallback.query);
|
|
153
|
+
return finalize(res);
|
|
154
|
+
}
|
|
155
|
+
// 5. Static documents and declared CSR fallback. Unknown extensionless routes are 404.
|
|
156
|
+
if (staticReader) {
|
|
157
|
+
await serveSpaStatic(req, res, pathname, staticReader, ssgPaths.has(pathname), isCsrFallback(pathname, routing.csrFallback));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// No static reader configured (pure-API smoke): respond 404 rather than hang.
|
|
161
|
+
res.statusCode = 404;
|
|
162
|
+
res.end("Not Found");
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error instanceof NodeRequestAbortedError)
|
|
166
|
+
return;
|
|
167
|
+
onError(pathname, error);
|
|
168
|
+
const tooLarge = error instanceof NodeRequestBodyTooLargeError;
|
|
169
|
+
if (tooLarge)
|
|
170
|
+
closeInputAfterResponse(req, res);
|
|
171
|
+
if (!res.headersSent && !res.destroyed) {
|
|
172
|
+
res.statusCode = tooLarge ? 413 : 500;
|
|
173
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
174
|
+
}
|
|
175
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
176
|
+
res.end(JSON.stringify({ ok: false, error: {
|
|
177
|
+
code: tooLarge ? "REQUEST_BODY_TOO_LARGE" : "NODE_HTTP_ERROR",
|
|
178
|
+
message: tooLarge ? "Request body too large" : "Internal server error",
|
|
179
|
+
} }));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** Create the Node HttpRuntimePort: `handle` for one request, `listen` to bind the server. */
|
|
185
|
+
export function createNodeHttpRuntime(options) {
|
|
186
|
+
const handle = createRequestHandler(options);
|
|
187
|
+
let server = null;
|
|
188
|
+
return {
|
|
189
|
+
server,
|
|
190
|
+
handle,
|
|
191
|
+
async listen({ port, host }) {
|
|
192
|
+
server = createServer((req, res) => {
|
|
193
|
+
void handle(decorateRequest(req), decorateResponse(res));
|
|
194
|
+
});
|
|
195
|
+
this.server = server;
|
|
196
|
+
await new Promise((resolve) => server.listen(port, host ?? "0.0.0.0", resolve));
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { HttpResponse } from "./types.js";
|
|
3
|
+
/** Deployment-owned header policy applied to every response of the Node runtime. */
|
|
4
|
+
export interface NodeSecurityHeaderPolicy {
|
|
5
|
+
/** Headers set on every response unless the handler already set them, in this order. */
|
|
6
|
+
readonly globalHeaders: ReadonlyArray<readonly [string, string]>;
|
|
7
|
+
/** Static-asset path prefixes that receive the long-lived asset Cache-Control. */
|
|
8
|
+
readonly assetCachePrefixes: readonly string[];
|
|
9
|
+
/** Path families (the path or anything below it) that receive `X-Robots-Tag: noindex, nofollow`. */
|
|
10
|
+
readonly noindexFamilies: readonly string[];
|
|
11
|
+
/** Exact paths that receive `X-Robots-Tag: noindex, nofollow`. */
|
|
12
|
+
readonly noindexExacts: readonly string[];
|
|
13
|
+
/** True for a production host; every other host receives the strict non-production X-Robots-Tag. */
|
|
14
|
+
readonly isProductionHost: (host: string | undefined) => boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Apply the deployment's global and path-specific headers for this route. */
|
|
17
|
+
export declare function applySecurityHeaders(res: ServerResponse, pathname: string, host: string | undefined, policy: NodeSecurityHeaderPolicy): void;
|
|
18
|
+
/** Pluggable static reader so tests can inject an in-memory dist/ without touching the filesystem. */
|
|
19
|
+
export interface StaticAssetReader {
|
|
20
|
+
/** Read a built asset (returns null if it does not exist). */
|
|
21
|
+
read(relativePath: string): Promise<{
|
|
22
|
+
body: Buffer;
|
|
23
|
+
contentType: string;
|
|
24
|
+
} | null>;
|
|
25
|
+
/** The SPA fallback document (dist/index.html). */
|
|
26
|
+
readIndex(): Promise<{
|
|
27
|
+
body: Buffer;
|
|
28
|
+
contentType: string;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
export declare function contentTypeFor(pathname: string): string;
|
|
32
|
+
/** A path "looks like" a built asset if it has a file extension (so we 404 missing assets, not SPA). */
|
|
33
|
+
export declare function looksLikeAsset(pathname: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Serve a built SPA asset if present, otherwise the document the build produced for this route,
|
|
36
|
+
* otherwise the declared CSR fallback for client routes. Missing real assets (a hashed bundle that
|
|
37
|
+
* does not exist) and unknown extensionless paths return 404 rather than the SPA shell.
|
|
38
|
+
*
|
|
39
|
+
* The middle step is not decoration. The build writes each pre-rendered route as
|
|
40
|
+
* `<route>/index.html` and writes the HOME document to `index.html`; skipping that lookup hands
|
|
41
|
+
* every pre-rendered URL the home document instead of its own. The hosted platform does the same
|
|
42
|
+
* thing declaratively, one rewrite per route, which is why this host had no equivalent code.
|
|
43
|
+
*/
|
|
44
|
+
export declare function serveSpaStatic(_req: IncomingMessage, res: HttpResponse, pathname: string, reader: StaticAssetReader, allowRouteDocument?: boolean, allowFallback?: boolean): Promise<void>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Security headers + SPA static middleware for the Node HttpRuntimePort.
|
|
2
|
+
//
|
|
3
|
+
// Replicates, as Node middleware, a hosted response surface whose values the deployment owns:
|
|
4
|
+
// the global headers (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
|
|
5
|
+
// Permissions-Policy, Reporting-Endpoints, the content security policy), the path-specific
|
|
6
|
+
// Cache-Control and X-Robots-Tag rules, and which hosts are production. The caller supplies all of
|
|
7
|
+
// them through `NodeSecurityHeaderPolicy`; this module only applies them in a fixed order.
|
|
8
|
+
//
|
|
9
|
+
// SPA fallback boundary: the runtime handles its API and entrypoint branches before static
|
|
10
|
+
// serving, so those paths never fall through to a document.
|
|
11
|
+
import { extname } from "node:path";
|
|
12
|
+
const ASSET_CACHE_CONTROL = "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800";
|
|
13
|
+
function matchesNoindex(policy, pathname) {
|
|
14
|
+
if (policy.noindexExacts.includes(pathname))
|
|
15
|
+
return true;
|
|
16
|
+
return policy.noindexFamilies.some((path) => pathname === path || pathname.startsWith(`${path}/`));
|
|
17
|
+
}
|
|
18
|
+
/** Apply the deployment's global and path-specific headers for this route. */
|
|
19
|
+
export function applySecurityHeaders(res, pathname, host, policy) {
|
|
20
|
+
for (const [key, value] of policy.globalHeaders) {
|
|
21
|
+
if (!res.hasHeader(key))
|
|
22
|
+
res.setHeader(key, value);
|
|
23
|
+
}
|
|
24
|
+
if (policy.assetCachePrefixes.some((p) => pathname.startsWith(p))) {
|
|
25
|
+
res.setHeader("Cache-Control", ASSET_CACHE_CONTROL);
|
|
26
|
+
}
|
|
27
|
+
if (pathname === "/.well-known/security.txt") {
|
|
28
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
29
|
+
}
|
|
30
|
+
if (pathname === "/ssg-manifest.json" || pathname === "/spa-fallback.html") {
|
|
31
|
+
res.setHeader("Cache-Control", "public, max-age=0, s-maxage=0, must-revalidate");
|
|
32
|
+
}
|
|
33
|
+
if (matchesNoindex(policy, pathname)) {
|
|
34
|
+
res.setHeader("X-Robots-Tag", "noindex, nofollow");
|
|
35
|
+
}
|
|
36
|
+
if (!policy.isProductionHost(host)) {
|
|
37
|
+
res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive, nosnippet, noimageindex");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const CONTENT_TYPES = {
|
|
41
|
+
".html": "text/html; charset=utf-8",
|
|
42
|
+
".js": "text/javascript; charset=utf-8",
|
|
43
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
44
|
+
".css": "text/css; charset=utf-8",
|
|
45
|
+
".json": "application/json; charset=utf-8",
|
|
46
|
+
".svg": "image/svg+xml",
|
|
47
|
+
".png": "image/png",
|
|
48
|
+
".jpg": "image/jpeg",
|
|
49
|
+
".jpeg": "image/jpeg",
|
|
50
|
+
".webp": "image/webp",
|
|
51
|
+
".ico": "image/x-icon",
|
|
52
|
+
".woff": "font/woff",
|
|
53
|
+
".woff2": "font/woff2",
|
|
54
|
+
".txt": "text/plain; charset=utf-8",
|
|
55
|
+
".webmanifest": "application/manifest+json",
|
|
56
|
+
};
|
|
57
|
+
export function contentTypeFor(pathname) {
|
|
58
|
+
return CONTENT_TYPES[extname(pathname).toLowerCase()] ?? "application/octet-stream";
|
|
59
|
+
}
|
|
60
|
+
/** A path "looks like" a built asset if it has a file extension (so we 404 missing assets, not SPA). */
|
|
61
|
+
export function looksLikeAsset(pathname) {
|
|
62
|
+
return extname(pathname) !== "";
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Serve a built SPA asset if present, otherwise the document the build produced for this route,
|
|
66
|
+
* otherwise the declared CSR fallback for client routes. Missing real assets (a hashed bundle that
|
|
67
|
+
* does not exist) and unknown extensionless paths return 404 rather than the SPA shell.
|
|
68
|
+
*
|
|
69
|
+
* The middle step is not decoration. The build writes each pre-rendered route as
|
|
70
|
+
* `<route>/index.html` and writes the HOME document to `index.html`; skipping that lookup hands
|
|
71
|
+
* every pre-rendered URL the home document instead of its own. The hosted platform does the same
|
|
72
|
+
* thing declaratively, one rewrite per route, which is why this host had no equivalent code.
|
|
73
|
+
*/
|
|
74
|
+
export async function serveSpaStatic(_req, res, pathname, reader, allowRouteDocument = false, allowFallback = false) {
|
|
75
|
+
const relative = pathname === "/" ? "/index.html" : pathname;
|
|
76
|
+
const asset = await reader.read(relative);
|
|
77
|
+
if (asset) {
|
|
78
|
+
if (!res.hasHeader("content-type"))
|
|
79
|
+
res.setHeader("content-type", asset.contentType);
|
|
80
|
+
res.statusCode = 200;
|
|
81
|
+
res.end(asset.body);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (looksLikeAsset(pathname)) {
|
|
85
|
+
// A concrete asset request that does not exist -> genuine 404 (not the SPA shell).
|
|
86
|
+
res.statusCode = 404;
|
|
87
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
88
|
+
res.end("Not Found");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// A manifest-declared SSG route -> its own pre-rendered document.
|
|
92
|
+
if (allowRouteDocument) {
|
|
93
|
+
const document = await reader.read(`${pathname.replace(/\/+$/, "")}/index.html`);
|
|
94
|
+
if (document) {
|
|
95
|
+
if (!res.hasHeader("content-type"))
|
|
96
|
+
res.setHeader("content-type", document.contentType);
|
|
97
|
+
res.statusCode = 200;
|
|
98
|
+
res.end(document.body);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!allowFallback) {
|
|
103
|
+
res.statusCode = 404;
|
|
104
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
105
|
+
res.end("Not Found");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Declared client route -> SPA fallback.
|
|
109
|
+
const index = await reader.readIndex();
|
|
110
|
+
res.statusCode = 200;
|
|
111
|
+
if (!res.hasHeader("content-type"))
|
|
112
|
+
res.setHeader("content-type", index.contentType);
|
|
113
|
+
res.end(index.body);
|
|
114
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Filesystem reader for a built static site, used by the Node runtime's static branch.
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { join, normalize, resolve } from "node:path";
|
|
4
|
+
import { contentTypeFor } from "./security.js";
|
|
5
|
+
const SPA_FALLBACK_DOCUMENT = "spa-fallback.html";
|
|
6
|
+
/** Read files below `distDir` (path-traversal safe; null for a missing file or a directory). */
|
|
7
|
+
export function createDistStaticReader(distDir) {
|
|
8
|
+
const root = resolve(distDir);
|
|
9
|
+
const safe = (relative) => {
|
|
10
|
+
const full = normalize(join(root, relative));
|
|
11
|
+
return full.startsWith(root) ? full : null;
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
async read(relative) {
|
|
15
|
+
const full = safe(relative);
|
|
16
|
+
if (!full)
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const info = await stat(full);
|
|
20
|
+
if (!info.isFile())
|
|
21
|
+
return null;
|
|
22
|
+
const body = await readFile(full);
|
|
23
|
+
return { body, contentType: contentTypeFor(relative) };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
async readIndex() {
|
|
30
|
+
// Prefer the client fallback document; a build without a pre-render step has only
|
|
31
|
+
// index.html, which then serves as the fallback.
|
|
32
|
+
try {
|
|
33
|
+
const body = await readFile(join(root, SPA_FALLBACK_DOCUMENT));
|
|
34
|
+
return { body, contentType: "text/html; charset=utf-8" };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
const body = await readFile(join(root, "index.html"));
|
|
38
|
+
return { body, contentType: "text/html; charset=utf-8" };
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
/** A Node request decorated with the parsed query, cookies and body. */
|
|
3
|
+
export interface HttpRequest extends IncomingMessage {
|
|
4
|
+
query: Record<string, string | string[] | undefined>;
|
|
5
|
+
cookies?: Record<string, string>;
|
|
6
|
+
body?: unknown;
|
|
7
|
+
/** Cooperative cancellation owned by the portable host adapter. */
|
|
8
|
+
abortSignal?: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
/** A Node response decorated with the helper methods hosted functions provide. */
|
|
11
|
+
export interface HttpResponse extends ServerResponse {
|
|
12
|
+
status(code: number): this;
|
|
13
|
+
json(body: unknown): this;
|
|
14
|
+
send(body: string | Buffer | object): this;
|
|
15
|
+
redirect(url: string): this;
|
|
16
|
+
redirect(status: number, url: string): this;
|
|
17
|
+
}
|
|
18
|
+
/** A default-export handler shared by every physical API entrypoint. */
|
|
19
|
+
export type HttpHandler = (req: HttpRequest, res: HttpResponse) => Promise<unknown> | unknown;
|
|
20
|
+
/** A lazily loaded module exposing one default-export handler. */
|
|
21
|
+
export type EntrypointLoader = () => Promise<{
|
|
22
|
+
default: HttpHandler;
|
|
23
|
+
}>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Host-neutral request and response shapes for every HTTP entrypoint.
|
|
2
|
+
//
|
|
3
|
+
// They are defined over node:http so that a handler runs unchanged on a hosted
|
|
4
|
+
// serverless function, the Node runtime of this package, or a test double.
|
|
5
|
+
// No host SDK is required to name them.
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { PlatformJobBackstopState, PlatformJobClaim, PlatformJobFinishStatus, PlatformJobFinishSummary, PlatformJobInvocation, PlatformJobRunLedgerPort, PlatformJobTriggerKind, } from "./ports.js";
|
|
2
|
+
export type { PgQueryExecutor } from "../data/index.js";
|
|
3
|
+
export { createPostgresPlatformJobRunLedger, type PostgresPlatformJobRunLedgerOptions, } from "./postgres.js";
|
|
4
|
+
export { JOB_RUN_LEDGER_MIGRATIONS, JOB_RUN_LEDGER_SQL_URL, JOB_RUN_LEDGER_V3_SQL_URL } from "./sql.js";
|
|
5
|
+
export { createManagedJobRunCalls, createSupabasePlatformJobRunLedger, platformJobInvocationForDriver, type ManagedJobRunOptions, type PlatformJobClient, type PlatformJobDriver, type PlatformJobSupabaseClient, type SupabasePlatformJobRunLedgerOptions, } from "./supabase.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createPostgresPlatformJobRunLedger, } from "./postgres.js";
|
|
2
|
+
export { JOB_RUN_LEDGER_MIGRATIONS, JOB_RUN_LEDGER_SQL_URL, JOB_RUN_LEDGER_V3_SQL_URL } from "./sql.js";
|
|
3
|
+
export { createManagedJobRunCalls, createSupabasePlatformJobRunLedger, platformJobInvocationForDriver, } from "./supabase.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { JobLeasePort } from "@openlup/core/platform-runtime";
|
|
2
|
+
export type { PlatformJobClaim, PlatformJobFinishStatus, PlatformJobFinishSummary, PlatformJobInvocation, PlatformJobTriggerKind, } from "@openlup/core/platform-runtime";
|
|
3
|
+
export type PlatformJobBackstopState = {
|
|
4
|
+
lastStatus: string | null;
|
|
5
|
+
lastSuccessAt: string | null;
|
|
6
|
+
};
|
|
7
|
+
export interface PlatformJobRunLedgerPort extends JobLeasePort {
|
|
8
|
+
readJobBackstopState(jobName: string): Promise<PlatformJobBackstopState | null>;
|
|
9
|
+
readLatestTerminalRunMetadata(jobName: string): Promise<Record<string, unknown> | null>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Persistence-neutral scheduled-job lease and attempt ledger port.
|
|
2
|
+
//
|
|
3
|
+
// The claim and finish contract is core's `JobLeasePort`. A job-run ledger adds the two reads a
|
|
4
|
+
// scheduled job uses for its backstop and checkpoint. Invocation is deployment evidence supplied by
|
|
5
|
+
// the composition root; a job never sees a database client, SDK query, RPC name, vendor driver or
|
|
6
|
+
// environment configuration.
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PgQueryExecutor } from "../data/index.js";
|
|
2
|
+
import type { PlatformJobRunLedgerPort } from "./ports.js";
|
|
3
|
+
export interface PostgresPlatformJobRunLedgerOptions {
|
|
4
|
+
/** Release and deployment identity merged into every claim and finish row's metadata. */
|
|
5
|
+
releaseCorrelation: () => Record<string, string | null>;
|
|
6
|
+
/** Failure reason recorded when a failed run's summary carries none. */
|
|
7
|
+
defaultFailureReason: string;
|
|
8
|
+
}
|
|
9
|
+
/** Direct PostgreSQL adapter for the public neutral-vocabulary job-control rail. */
|
|
10
|
+
export declare function createPostgresPlatformJobRunLedger(executor: PgQueryExecutor, options: PostgresPlatformJobRunLedgerOptions): PlatformJobRunLedgerPort;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Direct PostgreSQL adapter for the public job-run ledger (claim, finish, backstop, checkpoint).
|
|
2
|
+
//
|
|
3
|
+
// The RPCs and tables come from the package's job-run ledger SQL. Every claim and finish row
|
|
4
|
+
// carries the invocation and the deployment's release correlation in its metadata.
|
|
5
|
+
const CLAIM_SQL = `
|
|
6
|
+
SELECT acquired, run_id, reason
|
|
7
|
+
FROM public.platform_claim_job_run_v3($1, $2, $3, $4, $5::jsonb)`;
|
|
8
|
+
const FINISH_SQL = `
|
|
9
|
+
SELECT public.platform_finish_job_run_v3(
|
|
10
|
+
$1, $2::uuid, $3, $4, $5, $6, $7, $8::jsonb
|
|
11
|
+
) AS released`;
|
|
12
|
+
const BACKSTOP_SQL = `
|
|
13
|
+
SELECT last_status, last_success_at
|
|
14
|
+
FROM public.platform_job_controls
|
|
15
|
+
WHERE job_name = $1`;
|
|
16
|
+
const LATEST_TERMINAL_METADATA_SQL = `
|
|
17
|
+
SELECT metadata
|
|
18
|
+
FROM public.platform_job_runs
|
|
19
|
+
WHERE job_name = $1
|
|
20
|
+
AND status IN ('success', 'failed')
|
|
21
|
+
AND finished_at IS NOT NULL
|
|
22
|
+
ORDER BY started_at DESC
|
|
23
|
+
LIMIT 1`;
|
|
24
|
+
/** Direct PostgreSQL adapter for the public neutral-vocabulary job-control rail. */
|
|
25
|
+
export function createPostgresPlatformJobRunLedger(executor, options) {
|
|
26
|
+
return {
|
|
27
|
+
async claimJobRun(jobName, invocation, leaseSeconds = 900) {
|
|
28
|
+
const invocationSource = requiredInvocationSource(invocation.invocationSource);
|
|
29
|
+
const result = await executor.query(CLAIM_SQL, [
|
|
30
|
+
jobName,
|
|
31
|
+
invocation.triggerKind,
|
|
32
|
+
invocationSource,
|
|
33
|
+
leaseSeconds,
|
|
34
|
+
{
|
|
35
|
+
triggerKind: invocation.triggerKind,
|
|
36
|
+
invocationSource,
|
|
37
|
+
...options.releaseCorrelation(),
|
|
38
|
+
},
|
|
39
|
+
]);
|
|
40
|
+
const row = plainRecord(result.rows[0]);
|
|
41
|
+
return {
|
|
42
|
+
acquired: row?.acquired === true,
|
|
43
|
+
runId: typeof row?.run_id === "string" ? row.run_id : null,
|
|
44
|
+
reason: typeof row?.reason === "string" ? row.reason : "unknown",
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
async finishJobRun(jobName, runId, invocation, status, summary, extraMetadata) {
|
|
48
|
+
const invocationSource = requiredInvocationSource(invocation.invocationSource);
|
|
49
|
+
const result = await executor.query(FINISH_SQL, [
|
|
50
|
+
jobName,
|
|
51
|
+
runId,
|
|
52
|
+
status,
|
|
53
|
+
summary.checked,
|
|
54
|
+
summary.updated,
|
|
55
|
+
status === "failed" ? summary.reason ?? options.defaultFailureReason : null,
|
|
56
|
+
null,
|
|
57
|
+
{
|
|
58
|
+
failures: summary.failures,
|
|
59
|
+
skipped: summary.skipped,
|
|
60
|
+
reason: summary.reason ?? null,
|
|
61
|
+
...options.releaseCorrelation(),
|
|
62
|
+
...(extraMetadata ?? {}),
|
|
63
|
+
triggerKind: invocation.triggerKind,
|
|
64
|
+
invocationSource,
|
|
65
|
+
driver: invocationSource,
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
return plainRecord(result.rows[0])?.released === true;
|
|
69
|
+
},
|
|
70
|
+
async readJobBackstopState(jobName) {
|
|
71
|
+
const result = await executor.query(BACKSTOP_SQL, [jobName]);
|
|
72
|
+
const row = plainRecord(result.rows[0]);
|
|
73
|
+
return row ? {
|
|
74
|
+
lastStatus: typeof row.last_status === "string" ? row.last_status : null,
|
|
75
|
+
lastSuccessAt: timestampString(row.last_success_at),
|
|
76
|
+
} : null;
|
|
77
|
+
},
|
|
78
|
+
async readLatestTerminalRunMetadata(jobName) {
|
|
79
|
+
const result = await executor.query(LATEST_TERMINAL_METADATA_SQL, [jobName]);
|
|
80
|
+
return plainRecord(plainRecord(result.rows[0])?.metadata);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function plainRecord(value) {
|
|
85
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
86
|
+
return null;
|
|
87
|
+
return { ...value };
|
|
88
|
+
}
|
|
89
|
+
function timestampString(value) {
|
|
90
|
+
if (typeof value === "string")
|
|
91
|
+
return value;
|
|
92
|
+
if (value instanceof Date)
|
|
93
|
+
return value.toISOString();
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
function requiredInvocationSource(value) {
|
|
97
|
+
const source = value.trim();
|
|
98
|
+
if (!source)
|
|
99
|
+
throw new Error("platform_job_invocation_source_required");
|
|
100
|
+
return source;
|
|
101
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const JOB_RUN_LEDGER_SQL_URL: URL;
|
|
2
|
+
export declare const JOB_RUN_LEDGER_V3_SQL_URL: URL;
|
|
3
|
+
/** Every job-run ledger migration, ordered by migration identity. */
|
|
4
|
+
export declare const JOB_RUN_LEDGER_MIGRATIONS: ReadonlyArray<{
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly url: URL;
|
|
7
|
+
}>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The job-run ledger SQL a fresh installation applies through its own migration chain, in order.
|
|
2
|
+
// The first file's bytes and migration identity equal the reference application's platform
|
|
3
|
+
// migration of the same name; the second adds the v3 claim and finish entry points.
|
|
4
|
+
export const JOB_RUN_LEDGER_SQL_URL = new URL("../../sql/20260809150000_job_run_ledger.sql", import.meta.url);
|
|
5
|
+
export const JOB_RUN_LEDGER_V3_SQL_URL = new URL("../../sql/20260809150001_job_run_ledger_v3.sql", import.meta.url);
|
|
6
|
+
/** Every job-run ledger migration, ordered by migration identity. */
|
|
7
|
+
export const JOB_RUN_LEDGER_MIGRATIONS = [
|
|
8
|
+
{ id: "20260809150000_job_run_ledger", url: JOB_RUN_LEDGER_SQL_URL },
|
|
9
|
+
{ id: "20260809150001_job_run_ledger_v3", url: JOB_RUN_LEDGER_V3_SQL_URL },
|
|
10
|
+
];
|