@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.
Files changed (77) hide show
  1. package/README.md +19 -0
  2. package/dist/adapters/r2.d.ts +24 -0
  3. package/dist/adapters/r2.d.ts.map +1 -0
  4. package/dist/adapters/r2.js +136 -0
  5. package/dist/adapters/r2.js.map +1 -0
  6. package/dist/adapters/sqlite.d.ts +25 -0
  7. package/dist/adapters/sqlite.d.ts.map +1 -0
  8. package/dist/adapters/sqlite.js +131 -0
  9. package/dist/adapters/sqlite.js.map +1 -0
  10. package/dist/config.d.ts +29 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +139 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/contract.d.ts +40 -0
  15. package/dist/contract.d.ts.map +1 -0
  16. package/dist/contract.js +5 -0
  17. package/dist/contract.js.map +1 -0
  18. package/dist/errors.d.ts +29 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +40 -0
  21. package/dist/errors.js.map +1 -0
  22. package/dist/head.d.ts +32 -0
  23. package/dist/head.d.ts.map +1 -0
  24. package/dist/head.js +93 -0
  25. package/dist/head.js.map +1 -0
  26. package/dist/health.d.ts +14 -0
  27. package/dist/health.d.ts.map +1 -0
  28. package/dist/health.js +85 -0
  29. package/dist/health.js.map +1 -0
  30. package/dist/hooks.d.ts +28 -0
  31. package/dist/hooks.d.ts.map +1 -0
  32. package/dist/hooks.js +64 -0
  33. package/dist/hooks.js.map +1 -0
  34. package/dist/hydration.d.ts +6 -0
  35. package/dist/hydration.d.ts.map +1 -0
  36. package/dist/hydration.js +68 -0
  37. package/dist/hydration.js.map +1 -0
  38. package/dist/index.d.ts +26 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +16 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/manifest.d.ts +68 -0
  43. package/dist/manifest.d.ts.map +1 -0
  44. package/dist/manifest.js +9 -0
  45. package/dist/manifest.js.map +1 -0
  46. package/dist/routes.d.ts +67 -0
  47. package/dist/routes.d.ts.map +1 -0
  48. package/dist/routes.js +130 -0
  49. package/dist/routes.js.map +1 -0
  50. package/dist/source.d.ts +36 -0
  51. package/dist/source.d.ts.map +1 -0
  52. package/dist/source.js +52 -0
  53. package/dist/source.js.map +1 -0
  54. package/dist/track-ctx.d.ts +13 -0
  55. package/dist/track-ctx.d.ts.map +1 -0
  56. package/dist/track-ctx.js +15 -0
  57. package/dist/track-ctx.js.map +1 -0
  58. package/dist/validate.d.ts +20 -0
  59. package/dist/validate.d.ts.map +1 -0
  60. package/dist/validate.js +333 -0
  61. package/dist/validate.js.map +1 -0
  62. package/package.json +40 -0
  63. package/src/adapters/r2.ts +163 -0
  64. package/src/adapters/sqlite.ts +182 -0
  65. package/src/config.ts +213 -0
  66. package/src/contract.ts +82 -0
  67. package/src/errors.ts +52 -0
  68. package/src/head.ts +130 -0
  69. package/src/health.ts +99 -0
  70. package/src/hooks.ts +72 -0
  71. package/src/hydration.ts +72 -0
  72. package/src/index.ts +113 -0
  73. package/src/manifest.ts +104 -0
  74. package/src/routes.ts +320 -0
  75. package/src/source.ts +91 -0
  76. package/src/track-ctx.ts +29 -0
  77. 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
+ }
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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, "&quot;");
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
+ }