@mesofact/runtime 0.8.29
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/README.md +19 -0
- package/dist/adapters/r2.d.ts +24 -0
- package/dist/adapters/r2.d.ts.map +1 -0
- package/dist/adapters/r2.js +136 -0
- package/dist/adapters/r2.js.map +1 -0
- package/dist/adapters/sqlite.d.ts +25 -0
- package/dist/adapters/sqlite.d.ts.map +1 -0
- package/dist/adapters/sqlite.js +131 -0
- package/dist/adapters/sqlite.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +139 -0
- package/dist/config.js.map +1 -0
- package/dist/contract.d.ts +40 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +5 -0
- package/dist/contract.js.map +1 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +40 -0
- package/dist/errors.js.map +1 -0
- package/dist/head.d.ts +32 -0
- package/dist/head.d.ts.map +1 -0
- package/dist/head.js +93 -0
- package/dist/head.js.map +1 -0
- package/dist/health.d.ts +14 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +85 -0
- package/dist/health.js.map +1 -0
- package/dist/hooks.d.ts +28 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +64 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hydration.d.ts +6 -0
- package/dist/hydration.d.ts.map +1 -0
- package/dist/hydration.js +68 -0
- package/dist/hydration.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +68 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +9 -0
- package/dist/manifest.js.map +1 -0
- package/dist/routes.d.ts +67 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +130 -0
- package/dist/routes.js.map +1 -0
- package/dist/source.d.ts +36 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +52 -0
- package/dist/source.js.map +1 -0
- package/dist/track-ctx.d.ts +13 -0
- package/dist/track-ctx.d.ts.map +1 -0
- package/dist/track-ctx.js +15 -0
- package/dist/track-ctx.js.map +1 -0
- package/dist/validate.d.ts +20 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +333 -0
- package/dist/validate.js.map +1 -0
- package/package.json +40 -0
- package/src/adapters/r2.ts +163 -0
- package/src/adapters/sqlite.ts +182 -0
- package/src/config.ts +213 -0
- package/src/contract.ts +82 -0
- package/src/errors.ts +52 -0
- package/src/head.ts +130 -0
- package/src/health.ts +99 -0
- package/src/hooks.ts +72 -0
- package/src/hydration.ts +72 -0
- package/src/index.ts +113 -0
- package/src/manifest.ts +104 -0
- package/src/routes.ts +320 -0
- package/src/source.ts +91 -0
- package/src/track-ctx.ts +29 -0
- package/src/validate.ts +388 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// `sqlite` adapter — read-only KeyValueSource over a local SQLite file (or a
|
|
2
|
+
// Litestream / LiteFS replica). Emits `sqlite:<source>:<table>:<id>` (row) and
|
|
3
|
+
// `sqlite:<source>:<table>` (table-wide) tags into the ambient trackCtx, honors
|
|
4
|
+
// per-call `.noTrack()` / `.timeout(ms)`, 100 ms default timeout.
|
|
5
|
+
//
|
|
6
|
+
// See `.yah/docs/architecture/mesofact.md` §"Adapter API surface", §"Adapter
|
|
7
|
+
// read-set provenance" (tag taxonomy), and §"Cache-key composition" (the
|
|
8
|
+
// proxy folds the file mtime into the cache key — generation is computed
|
|
9
|
+
// proxy-side from the source path, not here).
|
|
10
|
+
|
|
11
|
+
import { BaseSource, type KeyValueSource } from "../source.js";
|
|
12
|
+
import { SourceQueryError, SourceTimeoutError, SourceUnavailableError } from "../errors.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 100;
|
|
15
|
+
|
|
16
|
+
// Minimal slice of `bun:sqlite` we depend on. Declared locally so the published
|
|
17
|
+
// contract package keeps `types: ["node"]` (no Bun globals leak into the types
|
|
18
|
+
// an outside TS consumer sees). The real `Database` is reached via a runtime
|
|
19
|
+
// dynamic import below; this is only its read surface.
|
|
20
|
+
export interface SqliteRunner {
|
|
21
|
+
// Run `sql` with positional `params` and return every row as a plain object.
|
|
22
|
+
all(sql: string, params: unknown[]): unknown[];
|
|
23
|
+
close?(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SqliteConfig = {
|
|
27
|
+
// Logical source name from `mesofact.config.toml` (e.g. "project_db"). Used as
|
|
28
|
+
// the registry key, the `sqlite:<name>:...` tag prefix, and surfaced in errors.
|
|
29
|
+
name: string;
|
|
30
|
+
// Filesystem path to the SQLite database. Opened read-only and lazily on the
|
|
31
|
+
// first read so registration never touches disk.
|
|
32
|
+
path: string;
|
|
33
|
+
// Test seam — inject a runner to observe queries without a real DB file.
|
|
34
|
+
// Defaults to opening `path` via `bun:sqlite`.
|
|
35
|
+
runner?: SqliteRunner;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export class SqliteAdapter extends BaseSource implements KeyValueSource {
|
|
39
|
+
readonly path: string;
|
|
40
|
+
private runner: SqliteRunner | null;
|
|
41
|
+
private opening: Promise<SqliteRunner> | null = null;
|
|
42
|
+
|
|
43
|
+
constructor(config: SqliteConfig) {
|
|
44
|
+
super(config.name);
|
|
45
|
+
this.path = config.path;
|
|
46
|
+
this.runner = config.runner ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async get<T>(table: string, id: string): Promise<T | null> {
|
|
50
|
+
const { track, timeout_ms } = this.consumeOverrides(DEFAULT_TIMEOUT_MS);
|
|
51
|
+
this.emitTag(`sqlite:${this.name}:${table}:${id}`, track);
|
|
52
|
+
const rows = await this.run(
|
|
53
|
+
`SELECT * FROM ${quoteIdent(table)} WHERE id = ? LIMIT 1`,
|
|
54
|
+
[id],
|
|
55
|
+
timeout_ms,
|
|
56
|
+
);
|
|
57
|
+
return (rows[0] as T | undefined) ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async query<T>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
61
|
+
const { track, timeout_ms } = this.consumeOverrides(DEFAULT_TIMEOUT_MS);
|
|
62
|
+
// Tag every table the query reads. When no table can be extracted (CTEs,
|
|
63
|
+
// exotic SQL), fall back to a source-wide tag so invalidation stays
|
|
64
|
+
// conservative — over-purging is recoverable; a missed tag is stale-forever.
|
|
65
|
+
const tables = extractTables(sql);
|
|
66
|
+
if (tables.length === 0) {
|
|
67
|
+
this.emitTag(`sqlite:${this.name}`, track);
|
|
68
|
+
} else {
|
|
69
|
+
for (const table of tables) this.emitTag(`sqlite:${this.name}:${table}`, track);
|
|
70
|
+
}
|
|
71
|
+
return (await this.run(sql, params, timeout_ms)) as T[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async run(sql: string, params: unknown[], timeout_ms: number): Promise<unknown[]> {
|
|
75
|
+
const exec = (async () => {
|
|
76
|
+
const runner = await this.ensureRunner();
|
|
77
|
+
try {
|
|
78
|
+
return runner.all(sql, params);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw new SourceQueryError(this.name, sqliteMessage(sql, err), { cause: err });
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
return this.race(exec, timeout_ms);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async ensureRunner(): Promise<SqliteRunner> {
|
|
87
|
+
if (this.runner) return this.runner;
|
|
88
|
+
if (!this.opening) {
|
|
89
|
+
this.opening = openBunSqlite(this.path)
|
|
90
|
+
.then((r) => (this.runner = r))
|
|
91
|
+
.catch((err) => {
|
|
92
|
+
this.opening = null;
|
|
93
|
+
throw new SourceUnavailableError(this.name, { cause: err });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return this.opening;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async race<T>(call: Promise<T>, timeout_ms: number): Promise<T> {
|
|
100
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
101
|
+
try {
|
|
102
|
+
return await Promise.race<T>([
|
|
103
|
+
call,
|
|
104
|
+
new Promise<T>((_, reject) => {
|
|
105
|
+
timer = setTimeout(
|
|
106
|
+
() => reject(new SourceTimeoutError(this.name, timeout_ms)),
|
|
107
|
+
timeout_ms,
|
|
108
|
+
);
|
|
109
|
+
}),
|
|
110
|
+
]);
|
|
111
|
+
} finally {
|
|
112
|
+
if (timer) clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sqliteMessage(sql: string, err: unknown): string {
|
|
118
|
+
const head = sql.length > 80 ? `${sql.slice(0, 77)}...` : sql;
|
|
119
|
+
return `sqlite query failed (${head}): ${err instanceof Error ? err.message : String(err)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Quote a table identifier so a table named like a keyword (or containing a
|
|
123
|
+
// dot) can't break the generated SQL. Table names come from server-side render
|
|
124
|
+
// code, not request input, so this is defensive rather than an injection guard;
|
|
125
|
+
// `id` and `query` params are always bound, never interpolated.
|
|
126
|
+
function quoteIdent(name: string): string {
|
|
127
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Pull table names out of a SELECT for table-wide tag emission. A regex over
|
|
131
|
+
// `FROM`/`JOIN` clauses — same pragmatic posture as the build's regex source
|
|
132
|
+
// inference and the r2 list-XML parser. Misses are absorbed by the source-wide
|
|
133
|
+
// fallback in `query`.
|
|
134
|
+
function extractTables(sql: string): string[] {
|
|
135
|
+
const out = new Set<string>();
|
|
136
|
+
for (const m of sql.matchAll(/\b(?:from|join)\s+["'`]?([A-Za-z_][\w$]*)/gi)) {
|
|
137
|
+
out.add(m[1]!);
|
|
138
|
+
}
|
|
139
|
+
return [...out];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Reach `bun:sqlite` at runtime without making it a static import — the
|
|
143
|
+
// published runtime types stay Bun-free, and a non-Bun consumer that never
|
|
144
|
+
// calls a sqlite source never resolves the module. The `as string` defeats
|
|
145
|
+
// tsc's static module resolution (dynamic specifier → Promise<any>); the cast
|
|
146
|
+
// to the constructor shape restores type-safety on the result.
|
|
147
|
+
type BunStatement = { all(...params: unknown[]): unknown[] };
|
|
148
|
+
type BunDatabase = { query(sql: string): BunStatement; close(): void };
|
|
149
|
+
|
|
150
|
+
async function openBunSqlite(path: string): Promise<SqliteRunner> {
|
|
151
|
+
const mod = (await import("bun:sqlite" as string)) as {
|
|
152
|
+
Database: new (filename: string, options?: { readonly?: boolean }) => BunDatabase;
|
|
153
|
+
};
|
|
154
|
+
const db = new mod.Database(path, { readonly: true });
|
|
155
|
+
return {
|
|
156
|
+
all: (sql, params) => db.query(sql).all(...params),
|
|
157
|
+
close: () => db.close(),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Per-process registry, mirroring the r2 adapter. `registerSourcesFromConfig`
|
|
162
|
+
// populates it from `[sources.*]` of `kind = "sqlite"`; render code looks up by
|
|
163
|
+
// name via `sqlite(name)`.
|
|
164
|
+
const registry = new Map<string, SqliteAdapter>();
|
|
165
|
+
|
|
166
|
+
export function registerSqlite(adapter: SqliteAdapter): void {
|
|
167
|
+
registry.set(adapter.name, adapter);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function clearSqliteRegistry(): void {
|
|
171
|
+
registry.clear();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function sqlite(name: string): KeyValueSource {
|
|
175
|
+
const adapter = registry.get(name);
|
|
176
|
+
if (!adapter) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`sqlite source not registered: ${name} (declare it in mesofact.config.toml under [sources.${name}])`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return adapter;
|
|
182
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// `mesofact.config.toml` parser. Yubaba writes this file atomically and
|
|
2
|
+
// SIGHUPs the proxy + Bun pool on change; mesofact reads it on boot to
|
|
3
|
+
// instantiate adapters. Credentials come in via env vars (yubaba injects);
|
|
4
|
+
// the config carries only the env var *names*, never the secrets themselves.
|
|
5
|
+
//
|
|
6
|
+
// See `.yah/docs/architecture/mesofact.md` §"Yubaba owns config and credentials".
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { parse as parseToml } from "smol-toml";
|
|
10
|
+
import { R2Adapter, registerR2 } from "./adapters/r2.js";
|
|
11
|
+
import { SqliteAdapter, registerSqlite } from "./adapters/sqlite.js";
|
|
12
|
+
import type { SourceScope } from "./validate.js";
|
|
13
|
+
|
|
14
|
+
export type R2SourceConfig = {
|
|
15
|
+
kind: "r2";
|
|
16
|
+
scope: SourceScope;
|
|
17
|
+
bucket: string;
|
|
18
|
+
// Env var name resolved at register-time. R2's endpoint is account-scoped
|
|
19
|
+
// (`https://<account_id>.r2.cloudflarestorage.com`), so yubaba injects it
|
|
20
|
+
// rather than us guessing.
|
|
21
|
+
endpoint_env: string;
|
|
22
|
+
// Env var names for credentials. Defaults are the AWS-standard names so a
|
|
23
|
+
// simple deployment can omit them.
|
|
24
|
+
access_key_id_env?: string;
|
|
25
|
+
secret_access_key_env?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SqliteSourceConfig = {
|
|
29
|
+
kind: "sqlite";
|
|
30
|
+
scope: SourceScope;
|
|
31
|
+
// Filesystem path to the database file. For `scope = "global"` it's a literal
|
|
32
|
+
// path; the design templates `{project_id}` for scoped sources, deferred until
|
|
33
|
+
// the first scoped SSR dogfood (no credentials — sqlite is a local file).
|
|
34
|
+
path: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Future kinds (pg, rpc) extend this union as their adapters land.
|
|
38
|
+
export type SourceConfig = R2SourceConfig | SqliteSourceConfig;
|
|
39
|
+
|
|
40
|
+
// Build-time knobs (R490-F4). `public_dir` names the workload-relative dir
|
|
41
|
+
// whose files are copied verbatim into `dist/html/` and listed in the
|
|
42
|
+
// manifest's `static_assets`. Default "public"; a missing dir is fine (no
|
|
43
|
+
// assets emitted).
|
|
44
|
+
export type BuildConfig = {
|
|
45
|
+
public_dir?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type MesofactConfig = {
|
|
49
|
+
sources: Record<string, SourceConfig>;
|
|
50
|
+
build?: BuildConfig;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function loadConfig(path: string): MesofactConfig {
|
|
54
|
+
return parseConfig(readFileSync(path, "utf8"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseConfig(toml: string): MesofactConfig {
|
|
58
|
+
const raw = parseToml(toml) as unknown;
|
|
59
|
+
if (!isPlainObject(raw)) {
|
|
60
|
+
throw new ConfigError("config must be a TOML object at top level");
|
|
61
|
+
}
|
|
62
|
+
const build = parseBuild(raw.build);
|
|
63
|
+
const sourcesRaw = raw.sources;
|
|
64
|
+
if (sourcesRaw === undefined) return { sources: {}, ...(build ? { build } : {}) };
|
|
65
|
+
if (!isPlainObject(sourcesRaw)) {
|
|
66
|
+
throw new ConfigError("[sources] must be a table");
|
|
67
|
+
}
|
|
68
|
+
const sources: Record<string, SourceConfig> = {};
|
|
69
|
+
for (const [name, body] of Object.entries(sourcesRaw)) {
|
|
70
|
+
sources[name] = parseSource(name, body);
|
|
71
|
+
}
|
|
72
|
+
return { sources, ...(build ? { build } : {}) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseBuild(raw: unknown): BuildConfig | undefined {
|
|
76
|
+
if (raw === undefined) return undefined;
|
|
77
|
+
if (!isPlainObject(raw)) {
|
|
78
|
+
throw new ConfigError("[build] must be a table");
|
|
79
|
+
}
|
|
80
|
+
const out: BuildConfig = {};
|
|
81
|
+
if (raw.public_dir !== undefined) {
|
|
82
|
+
if (typeof raw.public_dir !== "string" || raw.public_dir.length === 0) {
|
|
83
|
+
throw new ConfigError("[build] public_dir must be a non-empty string");
|
|
84
|
+
}
|
|
85
|
+
out.public_dir = raw.public_dir;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseSource(name: string, body: unknown): SourceConfig {
|
|
91
|
+
if (!isPlainObject(body)) {
|
|
92
|
+
throw new ConfigError(`[sources.${name}] must be a table`);
|
|
93
|
+
}
|
|
94
|
+
const kind = body.kind;
|
|
95
|
+
const scope = parseScope(name, body.scope);
|
|
96
|
+
if (kind === "r2") return parseR2(name, body, scope);
|
|
97
|
+
if (kind === "sqlite") return parseSqlite(name, body, scope);
|
|
98
|
+
// P4 shipped r2; P9 adds sqlite. pg/rpc widen this union later.
|
|
99
|
+
throw new ConfigError(
|
|
100
|
+
`[sources.${name}] unsupported kind: ${JSON.stringify(kind)} (supported: "r2", "sqlite")`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseScope(name: string, raw: unknown): SourceScope {
|
|
105
|
+
const scope = raw ?? "global";
|
|
106
|
+
if (scope !== "global" && scope !== "project" && scope !== "user") {
|
|
107
|
+
throw new ConfigError(
|
|
108
|
+
`[sources.${name}] invalid scope: ${JSON.stringify(scope)} (expected "global" | "project" | "user")`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return scope;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseR2(name: string, body: Record<string, unknown>, scope: SourceScope): R2SourceConfig {
|
|
115
|
+
const bucket = body.bucket;
|
|
116
|
+
if (typeof bucket !== "string" || bucket === "") {
|
|
117
|
+
throw new ConfigError(`[sources.${name}] missing or empty \`bucket\` (string)`);
|
|
118
|
+
}
|
|
119
|
+
const endpoint_env = body.endpoint_env;
|
|
120
|
+
if (typeof endpoint_env !== "string" || endpoint_env === "") {
|
|
121
|
+
throw new ConfigError(`[sources.${name}] missing or empty \`endpoint_env\` (string)`);
|
|
122
|
+
}
|
|
123
|
+
const access_key_id_env = optionalString(body.access_key_id_env, name, "access_key_id_env");
|
|
124
|
+
const secret_access_key_env = optionalString(body.secret_access_key_env, name, "secret_access_key_env");
|
|
125
|
+
return {
|
|
126
|
+
kind: "r2",
|
|
127
|
+
scope,
|
|
128
|
+
bucket,
|
|
129
|
+
endpoint_env,
|
|
130
|
+
...(access_key_id_env !== undefined ? { access_key_id_env } : {}),
|
|
131
|
+
...(secret_access_key_env !== undefined ? { secret_access_key_env } : {}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseSqlite(
|
|
136
|
+
name: string,
|
|
137
|
+
body: Record<string, unknown>,
|
|
138
|
+
scope: SourceScope,
|
|
139
|
+
): SqliteSourceConfig {
|
|
140
|
+
const path = body.path;
|
|
141
|
+
if (typeof path !== "string" || path === "") {
|
|
142
|
+
throw new ConfigError(`[sources.${name}] missing or empty \`path\` (string)`);
|
|
143
|
+
}
|
|
144
|
+
return { kind: "sqlite", scope, path };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function optionalString(value: unknown, source: string, field: string): string | undefined {
|
|
148
|
+
if (value === undefined) return undefined;
|
|
149
|
+
if (typeof value !== "string" || value === "") {
|
|
150
|
+
throw new ConfigError(`[sources.${source}] \`${field}\` must be a non-empty string`);
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Resolve credentials from env and register an R2Adapter for each r2 source.
|
|
156
|
+
// Returns the resolved adapter names so callers can verify a known set landed.
|
|
157
|
+
export function registerSourcesFromConfig(
|
|
158
|
+
config: MesofactConfig,
|
|
159
|
+
env: Record<string, string | undefined> = process.env,
|
|
160
|
+
): string[] {
|
|
161
|
+
const registered: string[] = [];
|
|
162
|
+
for (const [name, src] of Object.entries(config.sources)) {
|
|
163
|
+
if (src.kind === "r2") {
|
|
164
|
+
const endpoint = requireEnv(env, src.endpoint_env, name, "endpoint_env");
|
|
165
|
+
const accessKeyId = requireEnv(
|
|
166
|
+
env,
|
|
167
|
+
src.access_key_id_env ?? "AWS_ACCESS_KEY_ID",
|
|
168
|
+
name,
|
|
169
|
+
"access_key_id_env",
|
|
170
|
+
);
|
|
171
|
+
const secretAccessKey = requireEnv(
|
|
172
|
+
env,
|
|
173
|
+
src.secret_access_key_env ?? "AWS_SECRET_ACCESS_KEY",
|
|
174
|
+
name,
|
|
175
|
+
"secret_access_key_env",
|
|
176
|
+
);
|
|
177
|
+
registerR2(new R2Adapter({ name, bucket: src.bucket, endpoint, accessKeyId, secretAccessKey }));
|
|
178
|
+
registered.push(name);
|
|
179
|
+
} else if (src.kind === "sqlite") {
|
|
180
|
+
// No credentials — sqlite is a local file. The DB opens lazily on first
|
|
181
|
+
// read, so registration never touches disk.
|
|
182
|
+
registerSqlite(new SqliteAdapter({ name, path: src.path }));
|
|
183
|
+
registered.push(name);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return registered;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function requireEnv(
|
|
190
|
+
env: Record<string, string | undefined>,
|
|
191
|
+
varName: string,
|
|
192
|
+
source: string,
|
|
193
|
+
field: string,
|
|
194
|
+
): string {
|
|
195
|
+
const v = env[varName];
|
|
196
|
+
if (v === undefined || v === "") {
|
|
197
|
+
throw new ConfigError(
|
|
198
|
+
`[sources.${source}] env var \`${varName}\` (from ${field}) is unset or empty`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return v;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export class ConfigError extends Error {
|
|
205
|
+
constructor(message: string) {
|
|
206
|
+
super(message);
|
|
207
|
+
this.name = "ConfigError";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
212
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
213
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Render contract — the single seam between mesofact and any frontend.
|
|
2
|
+
// See `.yah/docs/architecture/mesofact.md` §"The shared seam: one render
|
|
3
|
+
// contract" and §"Request context — what Rust pre-resolves".
|
|
4
|
+
|
|
5
|
+
import type { Head } from "./head.js";
|
|
6
|
+
|
|
7
|
+
export type Region = string;
|
|
8
|
+
|
|
9
|
+
export type User = {
|
|
10
|
+
id: string;
|
|
11
|
+
attrs: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type Project = {
|
|
15
|
+
id: string;
|
|
16
|
+
home_region: Region;
|
|
17
|
+
generation: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type RenderRequest = {
|
|
21
|
+
url: string;
|
|
22
|
+
params: Record<string, string>;
|
|
23
|
+
query: Record<string, string>;
|
|
24
|
+
headers: Record<string, string>;
|
|
25
|
+
cookies: Record<string, string>;
|
|
26
|
+
|
|
27
|
+
// Proxy-resolved before render is invoked. Routes declare which of these
|
|
28
|
+
// they require in the manifest; the proxy returns 401/404/redirect when a
|
|
29
|
+
// required field can't resolve (render is never called).
|
|
30
|
+
user?: User;
|
|
31
|
+
project?: Project;
|
|
32
|
+
region?: Region;
|
|
33
|
+
|
|
34
|
+
// Per-deployment escape hatch for route-specific Rust middleware
|
|
35
|
+
// (feature flags, A/B bucket). Not type-checked across the proxy↔render
|
|
36
|
+
// boundary.
|
|
37
|
+
ctx?: Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
// Build-time data artifacts declared in the route's `data_inputs`.
|
|
40
|
+
// Keys are the artifact paths (relative to project root); values are parsed
|
|
41
|
+
// JSON. Populated only for mode="static" during prerender; absent at runtime.
|
|
42
|
+
data?: Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type CachePolicy = {
|
|
46
|
+
ttl: number;
|
|
47
|
+
tags?: readonly string[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Mode 3 only. The render only ships `initial_state` — the build owns the
|
|
51
|
+
// resolved (content-hashed) entry `script` + `code_split` chunks and writes
|
|
52
|
+
// them into the manifest's `hydration`. A render MAY set `script` as a logical
|
|
53
|
+
// hint, but the manifest's build-derived value is what the shell references.
|
|
54
|
+
//
|
|
55
|
+
// The build serializes `initial_state` into a
|
|
56
|
+
// `<script id="__MESOFACT_STATE__" type="application/json">` tag in the shell
|
|
57
|
+
// HTML. The six-line client snippet that consumes it:
|
|
58
|
+
//
|
|
59
|
+
// import { hydrateRoot } from "react-dom/client"; // or any framework
|
|
60
|
+
// const el = document.getElementById("__MESOFACT_STATE__");
|
|
61
|
+
// const initialState = el ? JSON.parse(el.textContent ?? "null") : null;
|
|
62
|
+
// hydrateRoot(document.getElementById("root")!, <App initial={initialState} />);
|
|
63
|
+
//
|
|
64
|
+
// mesofact ships no runtime helper — the snippet lives in the client entry the
|
|
65
|
+
// route declares via `client_entrypoint` and the build bundles to `hydrate/`.
|
|
66
|
+
export type Hydration = {
|
|
67
|
+
script?: string;
|
|
68
|
+
initial_state?: unknown;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type RenderResult = {
|
|
72
|
+
html: string;
|
|
73
|
+
headers?: Record<string, string>;
|
|
74
|
+
cache: CachePolicy;
|
|
75
|
+
hydration?: Hydration;
|
|
76
|
+
// Typed <head> contract (W270 §4). Woven into the document head by the
|
|
77
|
+
// prerenderer / SSG dispatch; the framework owns all escaping. Optional —
|
|
78
|
+
// a render that manages its own <head> can omit it.
|
|
79
|
+
head?: Head;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type RenderFn = (req: RenderRequest) => Promise<RenderResult>;
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Typed errors adapters throw. Render functions can catch and return
|
|
2
|
+
// fallback HTML, or rethrow to let mesofact decide (stale-on-error / 503).
|
|
3
|
+
// See `.yah/docs/architecture/mesofact.md` §"Adapter API surface".
|
|
4
|
+
|
|
5
|
+
export class SourceError extends Error {
|
|
6
|
+
readonly source: string;
|
|
7
|
+
readonly retryable: boolean;
|
|
8
|
+
|
|
9
|
+
constructor(
|
|
10
|
+
message: string,
|
|
11
|
+
source: string,
|
|
12
|
+
retryable: boolean,
|
|
13
|
+
options?: { cause?: unknown },
|
|
14
|
+
) {
|
|
15
|
+
super(message, options as ErrorOptions | undefined);
|
|
16
|
+
this.name = new.target.name;
|
|
17
|
+
this.source = source;
|
|
18
|
+
this.retryable = retryable;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class SourceUnavailableError extends SourceError {
|
|
23
|
+
constructor(source: string, options?: { cause?: unknown }) {
|
|
24
|
+
super(`source unavailable: ${source}`, source, true, options);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class SourceTimeoutError extends SourceError {
|
|
29
|
+
readonly timeout_ms: number;
|
|
30
|
+
|
|
31
|
+
constructor(source: string, timeout_ms: number, options?: { cause?: unknown }) {
|
|
32
|
+
super(`source timeout after ${timeout_ms}ms: ${source}`, source, true, options);
|
|
33
|
+
this.timeout_ms = timeout_ms;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class SourceQueryError extends SourceError {
|
|
38
|
+
constructor(source: string, message: string, options?: { cause?: unknown }) {
|
|
39
|
+
super(`source query error (${source}): ${message}`, source, false, options);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class RowNotFoundError extends SourceError {
|
|
44
|
+
readonly table: string;
|
|
45
|
+
readonly id: string;
|
|
46
|
+
|
|
47
|
+
constructor(source: string, table: string, id: string) {
|
|
48
|
+
super(`row not found: ${source}.${table}[${id}]`, source, false);
|
|
49
|
+
this.table = table;
|
|
50
|
+
this.id = id;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/head.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Typed <head> contract (W270 §4). A render MAY return a `head` value on its
|
|
2
|
+
// RenderResult; the prerenderer / SSG dispatch weaves it into the document
|
|
3
|
+
// head at the same seam as the hydration tags. Consumers return structured
|
|
4
|
+
// data — they never hand-assemble head markup or escape it themselves.
|
|
5
|
+
//
|
|
6
|
+
// Escaping lives HERE, one audited implementation, same posture as
|
|
7
|
+
// `escapeJsonForScriptTag` (hydration.ts): all interpolated values are HTML-
|
|
8
|
+
// escaped, attribute values additionally escape the quote that closes the
|
|
9
|
+
// attribute. Meta keys (`og:title`, `twitter:card`, …) are framework-owned
|
|
10
|
+
// literals and are never interpolated from consumer data.
|
|
11
|
+
//
|
|
12
|
+
// Also ported byte-for-byte into the deno_core SSG runtime shim at
|
|
13
|
+
// `crates/mesofact-ssr/js/runtime_shim.js` — keep the two in lockstep so both
|
|
14
|
+
// pipelines emit the same head bytes (same rule as the hydration helpers).
|
|
15
|
+
|
|
16
|
+
export type OpenGraph = {
|
|
17
|
+
title?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
// og:type — e.g. "website" | "article". Framework passes it through as-is
|
|
20
|
+
// (value is escaped like any other content).
|
|
21
|
+
type?: string;
|
|
22
|
+
url?: string;
|
|
23
|
+
image?: string;
|
|
24
|
+
siteName?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type TwitterCard = {
|
|
28
|
+
// twitter:card — e.g. "summary" | "summary_large_image".
|
|
29
|
+
card?: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
image?: string;
|
|
33
|
+
site?: string;
|
|
34
|
+
creator?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// A generic <link>. `rel` + `href` are the only universally-required
|
|
38
|
+
// attributes; both are attribute-escaped. Richer link attrs (sizes, type)
|
|
39
|
+
// can be folded in later without breaking this shape.
|
|
40
|
+
export type HeadLink = {
|
|
41
|
+
rel: string;
|
|
42
|
+
href: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type Head = {
|
|
46
|
+
title?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
// Emitted as <link rel="canonical" href="…">.
|
|
49
|
+
canonical?: string;
|
|
50
|
+
og?: OpenGraph;
|
|
51
|
+
twitter?: TwitterCard;
|
|
52
|
+
// When true, emits <meta name="robots" content="noindex">. Instance-
|
|
53
|
+
// addressed (deferred) pages set this; enumerable static routes that set it
|
|
54
|
+
// are also dropped from the manifest-derived sitemap.
|
|
55
|
+
noindex?: boolean;
|
|
56
|
+
links?: readonly HeadLink[];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Escape a value destined for HTML text content (`<title>…`): the three
|
|
60
|
+
// characters that can open a tag / entity. `&` first so we never double-encode
|
|
61
|
+
// the escapes we introduce.
|
|
62
|
+
function escapeHtmlText(value: string): string {
|
|
63
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Escape a value destined for a double-quoted attribute: text escaping plus
|
|
67
|
+
// the closing quote.
|
|
68
|
+
function escapeHtmlAttr(value: string): string {
|
|
69
|
+
return escapeHtmlText(value).replace(/"/g, """);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// key is a framework-owned literal (og:title, twitter:card, …); only content
|
|
73
|
+
// is consumer-supplied, so only content is escaped.
|
|
74
|
+
function metaTag(attr: "name" | "property", key: string, content: string): string {
|
|
75
|
+
return `<meta ${attr}="${key}" content="${escapeHtmlAttr(content)}">`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Render a Head into the concatenated head-tag markup (no wrapping <head>).
|
|
79
|
+
// Order is stable and deterministic so prerendered bytes are reproducible.
|
|
80
|
+
export function renderHead(head: Head): string {
|
|
81
|
+
const tags: string[] = [];
|
|
82
|
+
|
|
83
|
+
if (head.title !== undefined) tags.push(`<title>${escapeHtmlText(head.title)}</title>`);
|
|
84
|
+
if (head.description !== undefined) tags.push(metaTag("name", "description", head.description));
|
|
85
|
+
if (head.canonical !== undefined) {
|
|
86
|
+
tags.push(`<link rel="canonical" href="${escapeHtmlAttr(head.canonical)}">`);
|
|
87
|
+
}
|
|
88
|
+
if (head.noindex) tags.push(`<meta name="robots" content="noindex">`);
|
|
89
|
+
|
|
90
|
+
const og = head.og;
|
|
91
|
+
if (og) {
|
|
92
|
+
if (og.title !== undefined) tags.push(metaTag("property", "og:title", og.title));
|
|
93
|
+
if (og.description !== undefined) {
|
|
94
|
+
tags.push(metaTag("property", "og:description", og.description));
|
|
95
|
+
}
|
|
96
|
+
if (og.type !== undefined) tags.push(metaTag("property", "og:type", og.type));
|
|
97
|
+
if (og.url !== undefined) tags.push(metaTag("property", "og:url", og.url));
|
|
98
|
+
if (og.image !== undefined) tags.push(metaTag("property", "og:image", og.image));
|
|
99
|
+
if (og.siteName !== undefined) tags.push(metaTag("property", "og:site_name", og.siteName));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const tw = head.twitter;
|
|
103
|
+
if (tw) {
|
|
104
|
+
if (tw.card !== undefined) tags.push(metaTag("name", "twitter:card", tw.card));
|
|
105
|
+
if (tw.title !== undefined) tags.push(metaTag("name", "twitter:title", tw.title));
|
|
106
|
+
if (tw.description !== undefined) {
|
|
107
|
+
tags.push(metaTag("name", "twitter:description", tw.description));
|
|
108
|
+
}
|
|
109
|
+
if (tw.image !== undefined) tags.push(metaTag("name", "twitter:image", tw.image));
|
|
110
|
+
if (tw.site !== undefined) tags.push(metaTag("name", "twitter:site", tw.site));
|
|
111
|
+
if (tw.creator !== undefined) tags.push(metaTag("name", "twitter:creator", tw.creator));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const link of head.links ?? []) {
|
|
115
|
+
tags.push(`<link rel="${escapeHtmlAttr(link.rel)}" href="${escapeHtmlAttr(link.href)}">`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return tags.join("");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Weave a Head into a rendered document: inject the head markup immediately
|
|
122
|
+
// before the last </head> (case-insensitive). A document without one gets the
|
|
123
|
+
// markup prepended. A head that renders to nothing leaves the html untouched.
|
|
124
|
+
export function weaveHead(html: string, head: Head): string {
|
|
125
|
+
const markup = renderHead(head);
|
|
126
|
+
if (markup === "") return html;
|
|
127
|
+
const idx = html.toLowerCase().lastIndexOf("</head>");
|
|
128
|
+
if (idx === -1) return markup + html;
|
|
129
|
+
return html.slice(0, idx) + markup + html.slice(idx);
|
|
130
|
+
}
|