@minamocms/framework 0.0.0-stage → 0.10.1
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 +234 -0
- package/LICENSE +105 -0
- package/README.md +133 -2
- package/dist/boot.d.ts +19 -0
- package/dist/boot.js +22 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +44 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +12 -0
- package/dist/create-site/generate-site.js +168 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/AGENTS.md +473 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +23 -0
- package/dist/create-site/template/content/pages/about/careers.json +22 -0
- package/dist/create-site/template/content/pages/about/team.json +22 -0
- package/dist/create-site/template/content/pages/about.json +22 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
- package/dist/create-site/template/content/pages/docs.json +22 -0
- package/dist/create-site/template/content/pages/index.json +182 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1223 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/config/site_settings.json +11 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +48 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +81 -0
- package/dist/media/filename.d.ts +2 -0
- package/dist/media/filename.js +54 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/media/seed-media-cli.d.ts +2 -0
- package/dist/media/seed-media-cli.js +22 -0
- package/dist/media/seed-media.d.ts +11 -0
- package/dist/media/seed-media.js +76 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +83 -0
- package/dist/pull-site/cli.d.ts +2 -0
- package/dist/pull-site/cli.js +78 -0
- package/dist/pull-site/prompt-token.d.ts +16 -0
- package/dist/pull-site/prompt-token.js +69 -0
- package/dist/pull-site/prompts.d.ts +22 -0
- package/dist/pull-site/prompts.js +127 -0
- package/dist/pull-site/pull-site.d.ts +23 -0
- package/dist/pull-site/pull-site.js +211 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-cache.d.ts +11 -0
- package/dist/renderer/render-cache.js +11 -0
- package/dist/renderer/render-page.d.ts +34 -0
- package/dist/renderer/render-page.js +209 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/admin-redirect.d.ts +5 -0
- package/dist/routes/admin-redirect.js +26 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +6 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +169 -0
- package/dist/routes/deleted-pages.d.ts +8 -0
- package/dist/routes/deleted-pages.js +9 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +12 -0
- package/dist/routes/index.js +86 -0
- package/dist/routes/links.d.ts +8 -0
- package/dist/routes/links.js +18 -0
- package/dist/routes/media-public.d.ts +11 -0
- package/dist/routes/media-public.js +125 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +80 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +148 -0
- package/dist/routes/preview-revision.d.ts +10 -0
- package/dist/routes/preview-revision.js +96 -0
- package/dist/routes/preview.d.ts +10 -0
- package/dist/routes/preview.js +56 -0
- package/dist/routes/public.d.ts +10 -0
- package/dist/routes/public.js +123 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +139 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search-public.d.ts +6 -0
- package/dist/routes/search-public.js +104 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +15 -0
- package/dist/routes/settings.d.ts +10 -0
- package/dist/routes/settings.js +68 -0
- package/dist/routes/sitemap.d.ts +7 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +116 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +24 -0
- package/dist/schemas/page.schema.json +26 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-content.d.ts +32 -0
- package/dist/search/query-content.js +207 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +3 -0
- package/dist/search/rebuild-index.js +320 -0
- package/dist/server-config.d.ts +23 -0
- package/dist/server-config.js +237 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +235 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +145 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +151 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +166 -0
- package/dist/services/deleted-pages.d.ts +11 -0
- package/dist/services/deleted-pages.js +83 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +16 -0
- package/dist/services/drafts.js +164 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/links.d.ts +22 -0
- package/dist/services/links.js +169 -0
- package/dist/services/manage-menus.d.ts +15 -0
- package/dist/services/manage-menus.js +138 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +156 -0
- package/dist/services/menu-references.d.ts +3 -0
- package/dist/services/menu-references.js +38 -0
- package/dist/services/menus.d.ts +9 -0
- package/dist/services/menus.js +43 -0
- package/dist/services/migration-runner.d.ts +16 -0
- package/dist/services/migration-runner.js +169 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +43 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +236 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/pid-file.d.ts +13 -0
- package/dist/services/pid-file.js +54 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +15 -0
- package/dist/services/publish.js +313 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +22 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/reindex-on-write.d.ts +3 -0
- package/dist/services/reindex-on-write.js +30 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/site-settings.d.ts +28 -0
- package/dist/services/site-settings.js +116 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +6 -0
- package/dist/services/theme-component-file.js +39 -0
- package/dist/services/theme-files.d.ts +33 -0
- package/dist/services/theme-files.js +148 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +137 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +26 -0
- package/dist/services/validation.js +195 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +48 -0
- package/dist/site-check/run-check.d.ts +12 -0
- package/dist/site-check/run-check.js +188 -0
- package/dist/site-sync/cli-helpers.d.ts +23 -0
- package/dist/site-sync/cli-helpers.js +75 -0
- package/dist/site-sync/deploy.d.ts +36 -0
- package/dist/site-sync/deploy.js +124 -0
- package/dist/site-sync/live-content.d.ts +30 -0
- package/dist/site-sync/live-content.js +68 -0
- package/dist/site-sync/prompts.d.ts +30 -0
- package/dist/site-sync/prompts.js +237 -0
- package/dist/site-sync/pull-cli.d.ts +2 -0
- package/dist/site-sync/pull-cli.js +75 -0
- package/dist/site-sync/pull-site.d.ts +28 -0
- package/dist/site-sync/pull-site.js +171 -0
- package/dist/site-sync/push-cli.d.ts +2 -0
- package/dist/site-sync/push-cli.js +268 -0
- package/dist/site-sync/push-site.d.ts +96 -0
- package/dist/site-sync/push-site.js +376 -0
- package/dist/site-sync/remote-site.d.ts +38 -0
- package/dist/site-sync/remote-site.js +122 -0
- package/dist/site-sync/sync-record.d.ts +26 -0
- package/dist/site-sync/sync-record.js +76 -0
- package/dist/site-sync/theme-sync.d.ts +34 -0
- package/dist/site-sync/theme-sync.js +128 -0
- package/dist/stop-site/cli.d.ts +2 -0
- package/dist/stop-site/cli.js +28 -0
- package/dist/stop-site/stop-site.d.ts +26 -0
- package/dist/stop-site/stop-site.js +56 -0
- package/dist/theme-state.d.ts +20 -0
- package/dist/theme-state.js +33 -0
- package/dist/upgrade/changelog.d.ts +8 -0
- package/dist/upgrade/changelog.js +46 -0
- package/dist/upgrade/finish.d.ts +9 -0
- package/dist/upgrade/finish.js +30 -0
- package/dist/upgrade/owned-files.d.ts +11 -0
- package/dist/upgrade/owned-files.js +42 -0
- package/dist/upgrade/package-names.d.ts +4 -0
- package/dist/upgrade/package-names.js +26 -0
- package/dist/upgrade/upgrade-cli.d.ts +2 -0
- package/dist/upgrade/upgrade-cli.js +170 -0
- package/package.json +64 -4
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
5
|
+
import { listFilesRecursively } from "../services/fs-walk.js";
|
|
6
|
+
import { loadThemeSchemas } from "../services/theme-schemas.js";
|
|
7
|
+
import { pagePathToUrl } from "../services/urls.js";
|
|
8
|
+
import { enqueue } from "../services/write-queue.js";
|
|
9
|
+
import { openNodeSqliteDriver } from "./drivers/node-sqlite-driver.js";
|
|
10
|
+
// How many content files the rebuild loop processes between yields to
|
|
11
|
+
// the event loop (see the yield's own comment below for why this
|
|
12
|
+
// exists at all). Large enough that setImmediate's own overhead is
|
|
13
|
+
// negligible next to the real per-file work (a parse plus several
|
|
14
|
+
// SQLite inserts); small enough that no single slice runs long enough
|
|
15
|
+
// to meaningfully stall another request. Not a config knob - no
|
|
16
|
+
// evidence yet this needs to be tunable per site.
|
|
17
|
+
const YIELD_EVERY_N_FILES = 25;
|
|
18
|
+
function collectStrings(value, out) {
|
|
19
|
+
if (typeof value === 'string') {
|
|
20
|
+
out.push(value);
|
|
21
|
+
}
|
|
22
|
+
else if (Array.isArray(value)) {
|
|
23
|
+
for (const item of value) {
|
|
24
|
+
collectStrings(item, out);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else if (value !== null && typeof value === 'object') {
|
|
28
|
+
for (const nested of Object.values(value)) {
|
|
29
|
+
collectStrings(nested, out);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Recursively collects every string value out of each section's
|
|
34
|
+
// settings and any nested blocks' settings (instance.schema.json's
|
|
35
|
+
// blocks is self-referential, so this covers arbitrarily deep
|
|
36
|
+
// nesting). Only settings values, not id/type, so the indexed body is
|
|
37
|
+
// actual authored content, not structural metadata.
|
|
38
|
+
function extractBody(instances) {
|
|
39
|
+
const strings = [];
|
|
40
|
+
const walk = (list) => {
|
|
41
|
+
if (!list) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
for (const instance of list) {
|
|
45
|
+
collectStrings(instance.settings, strings);
|
|
46
|
+
walk(instance.blocks);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
walk(instances);
|
|
50
|
+
return strings.join(' ');
|
|
51
|
+
}
|
|
52
|
+
// Epoch milliseconds for a date-like string - undefined if it doesn't
|
|
53
|
+
// parse, so a malformed date is silently skipped rather than indexed
|
|
54
|
+
// as a nonsensical NaN row (the same "malformed input skipped, not a
|
|
55
|
+
// rebuild failure" tolerance every other part of this pipeline
|
|
56
|
+
// already has for a bad file or a missing theme type).
|
|
57
|
+
function parseDateValue(value) {
|
|
58
|
+
if (typeof value !== 'string') {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const parsed = Date.parse(value);
|
|
62
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
63
|
+
}
|
|
64
|
+
// Turns one raw value into ApiFieldRow entries for a given field key -
|
|
65
|
+
// shared by both theme-flagged fields (extractInstanceApiFields below)
|
|
66
|
+
// and the built-in post envelope fields (extractEnvelopeApiFields),
|
|
67
|
+
// since both need the identical "what does this value actually mean
|
|
68
|
+
// for indexing" logic. Three shapes:
|
|
69
|
+
// - an array: one row per scalar element, all under the same
|
|
70
|
+
// fieldKey (e.g. a post's own "tags") - a plain "eq" filter then
|
|
71
|
+
// matches via the exact same mechanism a single-valued field already
|
|
72
|
+
// uses, no separate array-aware query logic needed anywhere else.
|
|
73
|
+
// - a date-like string (isDateField true - a theme field schema'd
|
|
74
|
+
// "type": "string", "format": "date", reusing the existing format
|
|
75
|
+
// convention, or the post envelope's own publishDate): stored as an
|
|
76
|
+
// epoch-ms number in valueNumber, not text, so range operators work
|
|
77
|
+
// on it through the same numeric path a flagged price field uses.
|
|
78
|
+
// - a plain scalar (string/number/boolean): stored in its own typed
|
|
79
|
+
// column. Anything else (object, null, undefined) has nothing
|
|
80
|
+
// sensible to store or compare and is silently skipped, the same way
|
|
81
|
+
// a malformed schema block already is elsewhere in this pipeline.
|
|
82
|
+
function pushFieldValue(blockType, instanceId, fieldKey, value, isDateField, out) {
|
|
83
|
+
if (Array.isArray(value)) {
|
|
84
|
+
for (const item of value) {
|
|
85
|
+
pushFieldValue(blockType, instanceId, fieldKey, item, isDateField, out);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (typeof value === 'string' && isDateField) {
|
|
90
|
+
const epoch = parseDateValue(value);
|
|
91
|
+
if (epoch !== undefined) {
|
|
92
|
+
out.push({ blockType, instanceId, fieldKey, valueText: null, valueNumber: epoch, valueBool: null });
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (typeof value === 'string') {
|
|
97
|
+
out.push({ blockType, instanceId, fieldKey, valueText: value, valueNumber: null, valueBool: null });
|
|
98
|
+
}
|
|
99
|
+
else if (typeof value === 'number') {
|
|
100
|
+
out.push({ blockType, instanceId, fieldKey, valueText: null, valueNumber: value, valueBool: null });
|
|
101
|
+
}
|
|
102
|
+
else if (typeof value === 'boolean') {
|
|
103
|
+
out.push({ blockType, instanceId, fieldKey, valueText: null, valueNumber: null, valueBool: value ? 1 : 0 });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Reads schema.properties for the given instance's own type, keeping
|
|
107
|
+
// only properties explicitly flagged "api": true (an unvalidated,
|
|
108
|
+
// theme-authored JSON Schema keyword - same status as "format"/
|
|
109
|
+
// "allowedBlocks", see docs/guide-theme-authoring.md and
|
|
110
|
+
// services/validation.ts's own allowedBlockTypesOf) - and pairs each
|
|
111
|
+
// with its actual value out of instance.settings via pushFieldValue.
|
|
112
|
+
function extractInstanceApiFields(instance, schemaMap, out) {
|
|
113
|
+
const type = typeof instance.type === 'string' ? instance.type : undefined;
|
|
114
|
+
const id = typeof instance.id === 'string' ? instance.id : undefined;
|
|
115
|
+
if (!type || !id) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const properties = schemaMap[type]?.properties;
|
|
119
|
+
if (!properties || typeof properties !== 'object') {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const settings = (instance.settings && typeof instance.settings === 'object' ? instance.settings : {});
|
|
123
|
+
for (const [key, propSchema] of Object.entries(properties)) {
|
|
124
|
+
const schema = propSchema;
|
|
125
|
+
if (schema?.api !== true) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const isDateField = schema.type === 'string' && schema.format === 'date';
|
|
129
|
+
pushFieldValue(type, id, key, settings[key], isDateField, out);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Built-in envelope fields, auto-indexed with no "api": true needed -
|
|
133
|
+
// author/publishDate/tags are optional on every page (page.schema.json),
|
|
134
|
+
// so indexing is presence-based rather than gated on a specific "type"
|
|
135
|
+
// value: any page carrying one of these fields gets it indexed,
|
|
136
|
+
// regardless of what its own type string is. block_type '__page__' is a
|
|
137
|
+
// sentinel (never a real theme type, which always matches a *.liquid
|
|
138
|
+
// filename) marking these rows as envelope-level rather than a real
|
|
139
|
+
// section/block instance; instanceId is the page's own url - stable
|
|
140
|
+
// and unique enough, since there's exactly one envelope per page.
|
|
141
|
+
function extractEnvelopeApiFields(page, url) {
|
|
142
|
+
const rows = [];
|
|
143
|
+
pushFieldValue('__page__', url, 'author', page.author, false, rows);
|
|
144
|
+
pushFieldValue('__page__', url, 'publishDate', page.publishDate, true, rows);
|
|
145
|
+
pushFieldValue('__page__', url, 'tags', page.tags, false, rows);
|
|
146
|
+
return rows;
|
|
147
|
+
}
|
|
148
|
+
// Top-level page.sections entries are sections; every level of nested
|
|
149
|
+
// .blocks (arbitrarily deep - instance.schema.json's blocks is self-
|
|
150
|
+
// referential, same reasoning extractBody's own walk above already
|
|
151
|
+
// documents) is a block, so which theme-schema map applies flips
|
|
152
|
+
// exactly once, at the top, and stays fixed for everything nested
|
|
153
|
+
// underneath.
|
|
154
|
+
function extractApiFields(sections, sectionSchemas, blockSchemas) {
|
|
155
|
+
const rows = [];
|
|
156
|
+
const walk = (list, schemaMap) => {
|
|
157
|
+
if (!list) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
for (const instance of list) {
|
|
161
|
+
extractInstanceApiFields(instance, schemaMap, rows);
|
|
162
|
+
walk(instance.blocks, blockSchemas);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
walk(sections, sectionSchemas);
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
168
|
+
function unlinkIfExists(path) {
|
|
169
|
+
try {
|
|
170
|
+
unlinkSync(path);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error.code !== 'ENOENT') {
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// A fresh sqlite file is never actually left in WAL mode by this
|
|
179
|
+
// module (nothing here turns that on), but cleaning up any stray
|
|
180
|
+
// -wal/-shm sidecar files defensively costs nothing and avoids ever
|
|
181
|
+
// leaving one behind next to an abandoned temp build.
|
|
182
|
+
function cleanupSqliteArtifacts(path) {
|
|
183
|
+
unlinkIfExists(path);
|
|
184
|
+
unlinkIfExists(`${path}-wal`);
|
|
185
|
+
unlinkIfExists(`${path}-shm`);
|
|
186
|
+
}
|
|
187
|
+
async function rebuildIndexJob(config) {
|
|
188
|
+
mkdirSync(config.dataRoot, { recursive: true });
|
|
189
|
+
// Built into a fresh temp file, then renamed atomically over the
|
|
190
|
+
// real path (below) - not the old delete-then-recreate-in-place
|
|
191
|
+
// approach, which left a real window where a concurrent read saw
|
|
192
|
+
// either a missing file or one that exists but has no tables in it
|
|
193
|
+
// yet. POSIX rename() is atomic: a reader always sees either the
|
|
194
|
+
// complete old index or the complete new one, never an in-between
|
|
195
|
+
// state, and never has to retry an open that landed in the gap.
|
|
196
|
+
const tmpPath = `${config.searchIndexPath}.tmp-${randomUUID()}`;
|
|
197
|
+
cleanupSqliteArtifacts(tmpPath);
|
|
198
|
+
const themeSchemas = loadThemeSchemas(config.themeRoot);
|
|
199
|
+
try {
|
|
200
|
+
const driver = openNodeSqliteDriver(tmpPath);
|
|
201
|
+
try {
|
|
202
|
+
driver.exec('CREATE VIRTUAL TABLE pages_fts USING fts5(url UNINDEXED, title, body, page_type UNINDEXED)');
|
|
203
|
+
// A plain table, not FTS5 - page_fields holds typed, exact/range-
|
|
204
|
+
// comparable values (a price, a rating), the opposite of pages_fts's
|
|
205
|
+
// own free-text matching. One row per exposed field per instance
|
|
206
|
+
// (not one column per field name): a page can carry several
|
|
207
|
+
// instances of the same block type (several "product" blocks on one
|
|
208
|
+
// listing page), each with its own value, and different pages may
|
|
209
|
+
// expose entirely different field sets - a fixed column-per-field
|
|
210
|
+
// schema can't accommodate either. No foreign key back to
|
|
211
|
+
// pages_fts.url - this whole index is disposable and rebuilt wholly
|
|
212
|
+
// from scratch every time, same as pages_fts itself.
|
|
213
|
+
driver.exec('CREATE TABLE page_fields (url TEXT NOT NULL, block_type TEXT NOT NULL, instance_id TEXT NOT NULL, field_key TEXT NOT NULL, value_text TEXT, value_number REAL, value_bool INTEGER)');
|
|
214
|
+
// Composite, not a bare field_key index - field_key alone only
|
|
215
|
+
// narrows to one field's rows; leading with it here still serves
|
|
216
|
+
// that same narrowing (the leftmost-column rule), but the second
|
|
217
|
+
// column also covers the typed value comparison itself (an
|
|
218
|
+
// equality or range check) without a further per-row scan. url
|
|
219
|
+
// supports the self-join a multi-field query ANDs together
|
|
220
|
+
// (queryContent, query-content.ts) - with no index there, ANDing a
|
|
221
|
+
// second filter means scanning page_fields in full for every row
|
|
222
|
+
// the first filter matched.
|
|
223
|
+
driver.exec('CREATE INDEX page_fields_key_number ON page_fields (field_key, value_number)');
|
|
224
|
+
driver.exec('CREATE INDEX page_fields_key_text ON page_fields (field_key, value_text)');
|
|
225
|
+
driver.exec('CREATE INDEX page_fields_url ON page_fields (url)');
|
|
226
|
+
const insert = driver.prepare('INSERT INTO pages_fts (url, title, body, page_type) VALUES (?, ?, ?, ?)');
|
|
227
|
+
const insertField = driver.prepare('INSERT INTO page_fields (url, block_type, instance_id, field_key, value_text, value_number, value_bool) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
228
|
+
// Menus are deliberately never walked here at all: they have no
|
|
229
|
+
// public URL to point a search result at.
|
|
230
|
+
const collections = [{ root: config.pagesRoot, toUrl: pagePathToUrl }];
|
|
231
|
+
driver.exec('BEGIN');
|
|
232
|
+
let filesExamined = 0;
|
|
233
|
+
for (const { root, toUrl } of collections) {
|
|
234
|
+
for (const relativePath of listFilesRecursively(root, root, '.json')) {
|
|
235
|
+
// A genuine macrotask yield (setImmediate, not a microtask like
|
|
236
|
+
// Promise.resolve()/queueMicrotask - Node drains every queued
|
|
237
|
+
// microtask before the event loop ever reaches its I/O phases,
|
|
238
|
+
// so a chain of only-microtask yields still fully blocks an
|
|
239
|
+
// incoming HTTP request from being processed). Without this,
|
|
240
|
+
// this loop's entire body - potentially thousands of files -
|
|
241
|
+
// runs as one uninterruptible synchronous block: since Node is
|
|
242
|
+
// single-threaded, that means every other request the server
|
|
243
|
+
// is handling (auth, content reads, publishes) stalls for the
|
|
244
|
+
// rebuild's whole duration, not just other search queries.
|
|
245
|
+
// Counted once per file examined regardless of whether it
|
|
246
|
+
// ends up skipped below, so the cadence tracks total work
|
|
247
|
+
// done, not just files actually indexed.
|
|
248
|
+
filesExamined += 1;
|
|
249
|
+
if (filesExamined % YIELD_EVERY_N_FILES === 0) {
|
|
250
|
+
await yieldToEventLoop();
|
|
251
|
+
}
|
|
252
|
+
let page;
|
|
253
|
+
try {
|
|
254
|
+
page = JSON.parse(readFileSync(join(root, relativePath), 'utf-8'));
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// A malformed individual file is skipped, not an all-or-nothing
|
|
258
|
+
// abort: the index is explicitly disposable/best-effort, and
|
|
259
|
+
// aborting the whole rebuild over one bad file would leave no
|
|
260
|
+
// working index at all - strictly worse than skipping one page.
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
// Never walks draftsRoot at all, and skips unpublished content
|
|
264
|
+
// here - both halves of "drafts and unpublished content are
|
|
265
|
+
// absent from the index" (G3) are true by construction, not by
|
|
266
|
+
// a filter that could be gotten wrong.
|
|
267
|
+
if (page.published === false) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const url = toUrl(relativePath);
|
|
271
|
+
const title = typeof page.title === 'string' ? page.title : '';
|
|
272
|
+
const pageType = typeof page.type === 'string' ? page.type : '';
|
|
273
|
+
const body = extractBody(page.sections);
|
|
274
|
+
insert.run(url, title, body, pageType);
|
|
275
|
+
const apiFields = [
|
|
276
|
+
...extractApiFields(page.sections, themeSchemas.sections, themeSchemas.blocks),
|
|
277
|
+
...extractEnvelopeApiFields(page, url),
|
|
278
|
+
];
|
|
279
|
+
for (const row of apiFields) {
|
|
280
|
+
insertField.run(url, row.blockType, row.instanceId, row.fieldKey, row.valueText, row.valueNumber, row.valueBool);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
driver.exec('COMMIT');
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
driver.close();
|
|
288
|
+
}
|
|
289
|
+
renameSync(tmpPath, config.searchIndexPath);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
cleanupSqliteArtifacts(tmpPath);
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Queued via enqueue() - two concurrent rebuilds could otherwise both
|
|
297
|
+
// build their own temp file and both attempt the final rename; the
|
|
298
|
+
// second rename would still win cleanly (rename() just replaces
|
|
299
|
+
// whatever is there), but the first rebuild's now-orphaned temp file
|
|
300
|
+
// would never get cleaned up. enqueue() is a generic, domain-agnostic
|
|
301
|
+
// primitive, so reusing it for self-exclusion costs nothing.
|
|
302
|
+
export function rebuildIndex(config) {
|
|
303
|
+
return enqueue(() => rebuildIndexJob(config));
|
|
304
|
+
}
|
|
305
|
+
// For startServer's own boot-time call: a search index is never
|
|
306
|
+
// git-tracked (constraint 3 - a derived, disposable index), so a fresh
|
|
307
|
+
// clone or a first-ever boot has no index file at all and GET
|
|
308
|
+
// /search.json would stay permanently empty until some content write
|
|
309
|
+
// happened to trigger reindex-on-write.ts, or a caller manually hit
|
|
310
|
+
// POST /v1/search/rebuild. Guarded on existsSync rather than
|
|
311
|
+
// unconditionally rebuilding on every restart - a currently-running
|
|
312
|
+
// site's index is already kept fresh by every publish/unpublish/
|
|
313
|
+
// delete/move/batch (reindex-on-write.ts), so an unconditional rebuild
|
|
314
|
+
// here would just be redundant work on every ordinary restart.
|
|
315
|
+
export function rebuildIndexIfMissing(config) {
|
|
316
|
+
if (existsSync(config.searchIndexPath)) {
|
|
317
|
+
return Promise.resolve();
|
|
318
|
+
}
|
|
319
|
+
return rebuildIndex(config);
|
|
320
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type Scope = 'content' | 'theme' | 'media';
|
|
2
|
+
export interface TokenEntry {
|
|
3
|
+
hash: string;
|
|
4
|
+
scopes: Scope[];
|
|
5
|
+
}
|
|
6
|
+
export interface RateLimitConfig {
|
|
7
|
+
max: number;
|
|
8
|
+
windowMs: number;
|
|
9
|
+
}
|
|
10
|
+
export interface MediaConfig {
|
|
11
|
+
maxUploadBytes: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ServerConfig {
|
|
14
|
+
port: number;
|
|
15
|
+
tokens: TokenEntry[];
|
|
16
|
+
rateLimit: RateLimitConfig;
|
|
17
|
+
trustProxy: boolean;
|
|
18
|
+
ipAllowlist: string[];
|
|
19
|
+
checkpointIntervalMs: number;
|
|
20
|
+
media: MediaConfig;
|
|
21
|
+
adminBaseUrl: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
export declare function loadServerConfig(siteRoot: string): ServerConfig;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { VHOST_DIR_NAME } from "./config.js";
|
|
4
|
+
import { StartupCheckError } from "./services/startup-checks.js";
|
|
5
|
+
const DEFAULT_PORT = 3000;
|
|
6
|
+
const DEFAULT_RATE_LIMIT = { max: 60, windowMs: 60000 };
|
|
7
|
+
// 10MB (docs/cms-build-plan.md's own suggested default) - comfortably
|
|
8
|
+
// above a properly web-optimised image, well below an unoptimised raw
|
|
9
|
+
// camera export.
|
|
10
|
+
const DEFAULT_MEDIA_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
11
|
+
// The build plan's own literal example ("every 30 minutes"), adopted
|
|
12
|
+
// as-is rather than re-litigated - its own [REVIEW] flag was about
|
|
13
|
+
// whether checkpoints belong on main vs a dedicated branch (resolved:
|
|
14
|
+
// main), not this number specifically.
|
|
15
|
+
const DEFAULT_CHECKPOINT_INTERVAL_MS = 1_800_000;
|
|
16
|
+
const VALID_SCOPES = new Set(['content', 'theme', 'media']);
|
|
17
|
+
// sha256 digest, hex-encoded: exactly 64 lowercase hex characters.
|
|
18
|
+
const HEX64_PATTERN = /^[0-9a-f]{64}$/;
|
|
19
|
+
// A loose IPv4/IPv6-shaped typo-catcher, not a real IP parser -
|
|
20
|
+
// exact-IP-only is a deliberate scope reduction (see ip-allowlist.ts),
|
|
21
|
+
// so this doesn't need to be exhaustive either.
|
|
22
|
+
const IP_SHAPE_PATTERN = /^[0-9a-fA-F:.]+$/;
|
|
23
|
+
function parseTokens(value) {
|
|
24
|
+
if (value === undefined) {
|
|
25
|
+
// Absent entirely -> no configured tokens -> every scope check
|
|
26
|
+
// fails closed (401 on everything). The safe default, not an
|
|
27
|
+
// incidental side effect - see server-config.test.ts's explicit
|
|
28
|
+
// "no tokens configured" case.
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
if (!Array.isArray(value)) {
|
|
32
|
+
throw new StartupCheckError('invalid-token-config', 'site.config.json\'s "tokens" must be an array');
|
|
33
|
+
}
|
|
34
|
+
const seenHashes = new Set();
|
|
35
|
+
const tokens = [];
|
|
36
|
+
value.forEach((entry, index) => {
|
|
37
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
38
|
+
throw new StartupCheckError('invalid-token-config', `tokens[${index}] must be an object`);
|
|
39
|
+
}
|
|
40
|
+
const record = entry;
|
|
41
|
+
const hash = record.hash;
|
|
42
|
+
if (typeof hash !== 'string' || !HEX64_PATTERN.test(hash)) {
|
|
43
|
+
throw new StartupCheckError('invalid-token-config', `tokens[${index}].hash must be a 64-character hex-encoded sha256 digest`);
|
|
44
|
+
}
|
|
45
|
+
if (seenHashes.has(hash)) {
|
|
46
|
+
throw new StartupCheckError('invalid-token-config', `tokens[${index}].hash duplicates an earlier entry`);
|
|
47
|
+
}
|
|
48
|
+
seenHashes.add(hash);
|
|
49
|
+
const scopesValue = record.scopes;
|
|
50
|
+
if (!Array.isArray(scopesValue) || scopesValue.length === 0) {
|
|
51
|
+
throw new StartupCheckError('invalid-token-config', `tokens[${index}].scopes must be a non-empty array`);
|
|
52
|
+
}
|
|
53
|
+
const scopes = scopesValue.map((scope) => {
|
|
54
|
+
if (typeof scope !== 'string' || !VALID_SCOPES.has(scope)) {
|
|
55
|
+
throw new StartupCheckError('invalid-token-config', `tokens[${index}].scopes contains an unknown scope: ${JSON.stringify(scope)}`);
|
|
56
|
+
}
|
|
57
|
+
return scope;
|
|
58
|
+
});
|
|
59
|
+
tokens.push({ hash, scopes });
|
|
60
|
+
});
|
|
61
|
+
return tokens;
|
|
62
|
+
}
|
|
63
|
+
function parseRateLimit(value) {
|
|
64
|
+
if (value === undefined) {
|
|
65
|
+
// Absent entirely -> the default limit applies - matches tokens'/
|
|
66
|
+
// port's own "absence is not an error" philosophy.
|
|
67
|
+
return DEFAULT_RATE_LIMIT;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
70
|
+
throw new StartupCheckError('invalid-site-config', 'site.config.json\'s "rateLimit" must be an object');
|
|
71
|
+
}
|
|
72
|
+
const record = value;
|
|
73
|
+
const max = record.max;
|
|
74
|
+
if (typeof max !== 'number' || !Number.isInteger(max) || max < 1) {
|
|
75
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "rateLimit.max" must be a positive integer, got ${JSON.stringify(max)}`);
|
|
76
|
+
}
|
|
77
|
+
const windowMs = record.windowMs;
|
|
78
|
+
if (typeof windowMs !== 'number' || !Number.isInteger(windowMs) || windowMs < 1) {
|
|
79
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "rateLimit.windowMs" must be a positive integer, got ${JSON.stringify(windowMs)}`);
|
|
80
|
+
}
|
|
81
|
+
return { max, windowMs };
|
|
82
|
+
}
|
|
83
|
+
function parseMedia(value) {
|
|
84
|
+
if (value === undefined) {
|
|
85
|
+
// Absent entirely -> the default limit applies, matching every
|
|
86
|
+
// other optional-object field's own "absence is not an error"
|
|
87
|
+
// philosophy.
|
|
88
|
+
return { maxUploadBytes: DEFAULT_MEDIA_MAX_UPLOAD_BYTES };
|
|
89
|
+
}
|
|
90
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
91
|
+
throw new StartupCheckError('invalid-site-config', 'site.config.json\'s "media" must be an object');
|
|
92
|
+
}
|
|
93
|
+
const record = value;
|
|
94
|
+
const maxUploadBytes = record.maxUploadBytes;
|
|
95
|
+
if (maxUploadBytes === undefined) {
|
|
96
|
+
return { maxUploadBytes: DEFAULT_MEDIA_MAX_UPLOAD_BYTES };
|
|
97
|
+
}
|
|
98
|
+
if (typeof maxUploadBytes !== 'number' || !Number.isInteger(maxUploadBytes) || maxUploadBytes < 1) {
|
|
99
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "media.maxUploadBytes" must be a positive integer, got ${JSON.stringify(maxUploadBytes)}`);
|
|
100
|
+
}
|
|
101
|
+
return { maxUploadBytes };
|
|
102
|
+
}
|
|
103
|
+
function parseTrustProxy(value) {
|
|
104
|
+
if (value === undefined) {
|
|
105
|
+
// Absent -> false, matching a bare single-process, no-reverse-
|
|
106
|
+
// proxy deployment assumption unless an operator explicitly opts
|
|
107
|
+
// in.
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (typeof value !== 'boolean') {
|
|
111
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "trustProxy" must be a boolean, got ${JSON.stringify(value)}`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
function parseIpAllowlist(value) {
|
|
116
|
+
if (value === undefined) {
|
|
117
|
+
// Absent -> [] -> a no-op (checklist H3), not "nothing is
|
|
118
|
+
// allowed" - see ip-allowlist.ts's isIpAllowed.
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
if (!Array.isArray(value)) {
|
|
122
|
+
throw new StartupCheckError('invalid-site-config', 'site.config.json\'s "ipAllowlist" must be an array');
|
|
123
|
+
}
|
|
124
|
+
value.forEach((entry, index) => {
|
|
125
|
+
if (typeof entry !== 'string' || entry.length === 0 || !IP_SHAPE_PATTERN.test(entry)) {
|
|
126
|
+
throw new StartupCheckError('invalid-site-config', `ipAllowlist[${index}] must be a non-empty IP-address-shaped string, got ${JSON.stringify(entry)}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
// Undefined -> undefined: absence means the /admin redirect feature
|
|
132
|
+
// is off entirely, not a default target to redirect to (there's no
|
|
133
|
+
// sensible default admin URL to assume). Validated as a real absolute
|
|
134
|
+
// http(s) URL, not just any non-empty string, since it becomes a
|
|
135
|
+
// redirect target - rejects `javascript:`/`ftp:`/anything else before
|
|
136
|
+
// it can ever reach a Location header.
|
|
137
|
+
function parseAdminBaseUrl(value) {
|
|
138
|
+
if (value === undefined) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
142
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "adminBaseUrl" must be a non-empty string, got ${JSON.stringify(value)}`);
|
|
143
|
+
}
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = new URL(value);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "adminBaseUrl" must be a valid URL, got ${JSON.stringify(value)}`);
|
|
150
|
+
}
|
|
151
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
152
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "adminBaseUrl" must be an http or https URL, got ${JSON.stringify(value)}`);
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
function parseCheckpointIntervalMs(value) {
|
|
157
|
+
if (value === undefined) {
|
|
158
|
+
return DEFAULT_CHECKPOINT_INTERVAL_MS;
|
|
159
|
+
}
|
|
160
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
|
|
161
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "checkpointIntervalMs" must be a positive integer, got ${JSON.stringify(value)}`);
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
// Kept separate from SiteConfig (src/config.ts, pure filesystem paths)
|
|
166
|
+
// and from BootedSite (src/boot.ts): this is site.config.json's
|
|
167
|
+
// non-path settings. Later groups add fields here without needing to
|
|
168
|
+
// restructure this module or its callers - the "media" field below is
|
|
169
|
+
// exactly that: the max-upload-size setting anticipated here since
|
|
170
|
+
// Phase 2 planning, now landed as Phase 4's local-storage foundation.
|
|
171
|
+
//
|
|
172
|
+
// Two different failure classes: the file being entirely absent is
|
|
173
|
+
// not an error (defaults silently) - the whole Phase 1 test estate
|
|
174
|
+
// has no site.config.json and shouldn't need one invented just to
|
|
175
|
+
// boot. The file existing but being malformed IS an error (the
|
|
176
|
+
// operator wrote it and got it wrong), consistent with
|
|
177
|
+
// startup-checks.ts's fail-fast discipline. Token rotation requires a
|
|
178
|
+
// restart, since this loads once at boot - matches the existing
|
|
179
|
+
// "agent upgrade = restart" story already established for schema
|
|
180
|
+
// migrations, a deliberate decision, not an accident.
|
|
181
|
+
// PORT overrides site.config.json's own "port" (or its default) without
|
|
182
|
+
// editing a file - the universal Node/12-factor convention, and how
|
|
183
|
+
// several hosting platforms (Railway, Render, etc.) already inject the
|
|
184
|
+
// port a process must bind. An invalid value is ignored, not a hard
|
|
185
|
+
// failure - a stray non-numeric PORT shouldn't stop the whole site from
|
|
186
|
+
// booting when the configured/default port would have worked fine.
|
|
187
|
+
function resolvePort(configuredPort) {
|
|
188
|
+
const envPort = process.env.PORT;
|
|
189
|
+
if (envPort === undefined) {
|
|
190
|
+
return configuredPort;
|
|
191
|
+
}
|
|
192
|
+
const parsed = Number(envPort);
|
|
193
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : configuredPort;
|
|
194
|
+
}
|
|
195
|
+
export function loadServerConfig(siteRoot) {
|
|
196
|
+
const configPath = join(siteRoot, VHOST_DIR_NAME, 'site.config.json');
|
|
197
|
+
let raw;
|
|
198
|
+
try {
|
|
199
|
+
raw = readFileSync(configPath, 'utf-8');
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return {
|
|
203
|
+
port: resolvePort(DEFAULT_PORT),
|
|
204
|
+
tokens: [],
|
|
205
|
+
rateLimit: DEFAULT_RATE_LIMIT,
|
|
206
|
+
trustProxy: false,
|
|
207
|
+
ipAllowlist: [],
|
|
208
|
+
checkpointIntervalMs: DEFAULT_CHECKPOINT_INTERVAL_MS,
|
|
209
|
+
media: { maxUploadBytes: DEFAULT_MEDIA_MAX_UPLOAD_BYTES },
|
|
210
|
+
adminBaseUrl: undefined,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
let parsed;
|
|
214
|
+
try {
|
|
215
|
+
parsed = JSON.parse(raw);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
219
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json is not valid JSON: ${detail}`);
|
|
220
|
+
}
|
|
221
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
222
|
+
throw new StartupCheckError('invalid-site-config', 'site.config.json must be a JSON object');
|
|
223
|
+
}
|
|
224
|
+
const record = parsed;
|
|
225
|
+
const port = record.port ?? DEFAULT_PORT;
|
|
226
|
+
if (typeof port !== 'number' || !Number.isInteger(port) || port < 0) {
|
|
227
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "port" must be a non-negative integer, got ${JSON.stringify(record.port)}`);
|
|
228
|
+
}
|
|
229
|
+
const tokens = parseTokens(record.tokens);
|
|
230
|
+
const rateLimit = parseRateLimit(record.rateLimit);
|
|
231
|
+
const trustProxy = parseTrustProxy(record.trustProxy);
|
|
232
|
+
const ipAllowlist = parseIpAllowlist(record.ipAllowlist);
|
|
233
|
+
const checkpointIntervalMs = parseCheckpointIntervalMs(record.checkpointIntervalMs);
|
|
234
|
+
const media = parseMedia(record.media);
|
|
235
|
+
const adminBaseUrl = parseAdminBaseUrl(record.adminBaseUrl);
|
|
236
|
+
return { port: resolvePort(port), tokens, rateLimit, trustProxy, ipAllowlist, checkpointIntervalMs, media, adminBaseUrl };
|
|
237
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type FastifyInstance } from 'fastify';
|
|
2
|
+
import type { BootedSite } from './boot.ts';
|
|
3
|
+
import type { ServerConfig } from './server-config.ts';
|
|
4
|
+
import type { DevTunnel } from './services/dev-tunnel.ts';
|
|
5
|
+
export interface BuildServerOptions {
|
|
6
|
+
logger?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface StartServerOptions extends BuildServerOptions {
|
|
9
|
+
tunnel?: boolean;
|
|
10
|
+
startTunnel?: (port: number) => Promise<DevTunnel>;
|
|
11
|
+
}
|
|
12
|
+
export declare function buildServer(booted: BootedSite, serverConfig: ServerConfig, options?: BuildServerOptions): FastifyInstance;
|
|
13
|
+
export declare function startServer(siteRoot: string, options?: StartServerOptions): Promise<FastifyInstance>;
|