@materializecms/server 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ import { Readable } from 'node:stream';
2
+ /**
3
+ * The whole site in one file (Impostazioni → Sicurezza → Scarica tutto): a .tar.gz with the
4
+ * database, the original media and the downloaded fonts. Written as it is sent, so a library of
5
+ * several GB needs no space on the server; read back by `materialize restore`.
6
+ */
7
+ export interface ArchiveEntry {
8
+ /** Path inside the archive: "content.db", "media/monolite-16ee2580.jpg"… */
9
+ name: string;
10
+ /** The file on disk. */
11
+ path: string;
12
+ }
13
+ /** The archive as a stream of gzip bytes. */
14
+ export declare function tarGz(entries: ArchiveEntry[]): Readable;
15
+ /** Every file of a folder (not the dot-folders such as .cache), as archive entries under `prefix`. */
16
+ export declare function folderEntries(dir: string, prefix: string): Promise<ArchiveEntry[]>;
17
+ /**
18
+ * Reads a .tar.gz, handing each file to `place`, which says where to write it (or null to
19
+ * skip it). Files are written beside their destination first, then moved into place.
20
+ */
21
+ export declare function extractTarGz(file: string, place: (name: string) => string | null): Promise<string[]>;
22
+ /** Copies a stream to a file (the archive being downloaded, in tests). */
23
+ export declare function saveStream(stream: Readable, path: string): Promise<void>;
package/src/archive.js ADDED
@@ -0,0 +1,158 @@
1
+ import { createReadStream, createWriteStream } from 'node:fs';
2
+ import { mkdir, readdir, rename, stat } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { createGunzip, createGzip } from 'node:zlib';
7
+ const BLOCK = 512;
8
+ function octal(value, length) {
9
+ return `${value.toString(8).padStart(length - 1, '0')}\0`;
10
+ }
11
+ /** A ustar header; names longer than 100 characters go in the prefix field. */
12
+ function header(name, size, mtime) {
13
+ let prefix = '';
14
+ let short = name;
15
+ if (Buffer.byteLength(name) > 100) {
16
+ const cut = name.lastIndexOf('/', 155);
17
+ prefix = name.slice(0, cut);
18
+ short = name.slice(cut + 1);
19
+ if (cut < 0 || Buffer.byteLength(short) > 100 || Buffer.byteLength(prefix) > 155)
20
+ throw new Error(`Name too long for the archive: ${name}`);
21
+ }
22
+ const h = Buffer.alloc(BLOCK);
23
+ h.write(short, 0, 100, 'utf8');
24
+ h.write(octal(0o644, 8), 100, 8, 'ascii');
25
+ h.write(octal(0, 8), 108, 8, 'ascii');
26
+ h.write(octal(0, 8), 116, 8, 'ascii');
27
+ h.write(octal(size, 12), 124, 12, 'ascii');
28
+ h.write(octal(Math.floor(mtime.getTime() / 1000), 12), 136, 12, 'ascii');
29
+ h.write(' ', 148, 8, 'ascii');
30
+ h.write('0', 156, 1, 'ascii');
31
+ h.write('ustar\0', 257, 6, 'ascii');
32
+ h.write('00', 263, 2, 'ascii');
33
+ h.write(prefix, 345, 155, 'utf8');
34
+ let sum = 0;
35
+ for (const byte of h)
36
+ sum += byte;
37
+ h.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii');
38
+ return h;
39
+ }
40
+ async function* tarBlocks(entries) {
41
+ for (const entry of entries) {
42
+ const info = await stat(entry.path).catch(() => null);
43
+ if (!info?.isFile())
44
+ continue;
45
+ yield header(entry.name, info.size, info.mtime);
46
+ let written = 0;
47
+ for await (const chunk of createReadStream(entry.path)) {
48
+ written += chunk.length;
49
+ yield chunk;
50
+ }
51
+ // A file that grew while being read would break the archive: it is cut to the size declared.
52
+ if (written !== info.size)
53
+ throw new Error(`${entry.name} changed while being archived`);
54
+ const pad = (BLOCK - (info.size % BLOCK)) % BLOCK;
55
+ if (pad)
56
+ yield Buffer.alloc(pad);
57
+ }
58
+ yield Buffer.alloc(BLOCK * 2);
59
+ }
60
+ /** The archive as a stream of gzip bytes. */
61
+ export function tarGz(entries) {
62
+ return Readable.from(tarBlocks(entries)).pipe(createGzip());
63
+ }
64
+ /** Every file of a folder (not the dot-folders such as .cache), as archive entries under `prefix`. */
65
+ export async function folderEntries(dir, prefix) {
66
+ const out = [];
67
+ const walk = async (sub) => {
68
+ for (const item of await readdir(join(dir, sub), { withFileTypes: true }).catch(() => [])) {
69
+ if (item.name.startsWith('.'))
70
+ continue;
71
+ const rel = sub ? `${sub}/${item.name}` : item.name;
72
+ if (item.isDirectory())
73
+ await walk(rel);
74
+ else if (item.isFile())
75
+ out.push({ name: `${prefix}/${rel}`, path: join(dir, rel) });
76
+ }
77
+ };
78
+ await walk('');
79
+ return out;
80
+ }
81
+ /**
82
+ * Reads a .tar.gz, handing each file to `place`, which says where to write it (or null to
83
+ * skip it). Files are written beside their destination first, then moved into place.
84
+ */
85
+ export async function extractTarGz(file, place) {
86
+ const written = [];
87
+ let buffer = Buffer.alloc(0);
88
+ let current = null;
89
+ let ended = false;
90
+ const finish = async () => {
91
+ if (!current)
92
+ return;
93
+ const done = current;
94
+ current = null;
95
+ if (done.out) {
96
+ await new Promise((resolve, reject) => done.out?.end((e) => (e ? reject(e) : resolve())));
97
+ await rename(done.tmp, done.dest);
98
+ written.push(done.name);
99
+ }
100
+ };
101
+ const gunzip = createReadStream(file).pipe(createGunzip());
102
+ for await (const chunk of gunzip) {
103
+ buffer = Buffer.concat([buffer, chunk]);
104
+ while (!ended) {
105
+ if (current) {
106
+ if (current.remaining > 0) {
107
+ if (!buffer.length)
108
+ break;
109
+ const take = Math.min(current.remaining, buffer.length);
110
+ const part = buffer.subarray(0, take);
111
+ if (current.out && !current.out.write(part))
112
+ await new Promise((resolve) => current?.out?.once('drain', () => resolve()));
113
+ current.remaining -= take;
114
+ buffer = buffer.subarray(take);
115
+ continue;
116
+ }
117
+ if (buffer.length < current.pad)
118
+ break;
119
+ buffer = buffer.subarray(current.pad);
120
+ await finish();
121
+ continue;
122
+ }
123
+ if (buffer.length < BLOCK)
124
+ break;
125
+ const h = buffer.subarray(0, BLOCK);
126
+ buffer = buffer.subarray(BLOCK);
127
+ if (h.every((b) => b === 0)) {
128
+ ended = true;
129
+ break;
130
+ }
131
+ const field = (start, length) => h
132
+ .subarray(start, start + length)
133
+ .toString('utf8')
134
+ .replace(/\0.*$/s, '');
135
+ const size = Number.parseInt(field(124, 12).trim() || '0', 8);
136
+ const prefix = field(345, 155);
137
+ const name = prefix ? `${prefix}/${field(0, 100)}` : field(0, 100);
138
+ const type = field(156, 1);
139
+ const dest = type === '0' || type === '' ? place(name) : null;
140
+ let out = null;
141
+ const tmp = dest ? `${dest}.restoring` : '';
142
+ if (dest) {
143
+ await mkdir(dirname(dest), { recursive: true });
144
+ out = createWriteStream(tmp);
145
+ }
146
+ current = { name, remaining: size, pad: (BLOCK - (size % BLOCK)) % BLOCK, out, tmp, dest: dest ?? '' };
147
+ }
148
+ if (ended)
149
+ break;
150
+ }
151
+ if (current)
152
+ throw new Error('The archive is incomplete');
153
+ return written;
154
+ }
155
+ /** Copies a stream to a file (the archive being downloaded, in tests). */
156
+ export async function saveStream(stream, path) {
157
+ await pipeline(stream, createWriteStream(path));
158
+ }
@@ -0,0 +1,42 @@
1
+ import type { Database } from '@materializecms/db';
2
+ export interface Backup {
3
+ name: string;
4
+ size: number;
5
+ createdAt: Date;
6
+ }
7
+ export interface BackupSettings {
8
+ /** A copy every day, made by the server. */
9
+ enabled: boolean;
10
+ /** How many copies to keep; older ones are deleted. */
11
+ keep: number;
12
+ }
13
+ export declare const DEFAULT_BACKUPS: BackupSettings;
14
+ export declare function createBackups(database: Database, mediaDir: () => string): {
15
+ /** False for a hosted database, or one in memory (tests). */
16
+ available: () => boolean;
17
+ folder: () => string | null;
18
+ list: () => Promise<Backup[]>;
19
+ /** A new copy; `tag` marks why ("auto", "restore"). */
20
+ create(tag?: string): Promise<Backup>;
21
+ /** Keeps the newest `keep` copies. */
22
+ prune(keep: number): Promise<number>;
23
+ /** The file of a copy, or null when the name is not one of ours. */
24
+ path(name: string): Promise<string | null>;
25
+ /** The media copy: new uploads in, files deleted long enough ago out. */
26
+ mirrorMedia(): Promise<{
27
+ copied: number;
28
+ purged: number;
29
+ }>;
30
+ /** How many files the media copy holds, and how much space. */
31
+ mediaCopy(): Promise<{
32
+ files: number;
33
+ size: number;
34
+ }>;
35
+ remove(name: string): Promise<boolean>;
36
+ };
37
+ export type Backups = ReturnType<typeof createBackups>;
38
+ /**
39
+ * After a database is put back: the files it uses that are missing from the library, taken from
40
+ * the media copy next to the backups. Returns how many came back.
41
+ */
42
+ export declare function restoreMedia(database: string, mediaDir: string, keys: string[]): Promise<number>;
package/src/backups.js ADDED
@@ -0,0 +1,167 @@
1
+ import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ /**
4
+ * Copies of the content database, made by SQLite itself (VACUUM INTO: consistent even while
5
+ * people are editing), in a backups/ folder next to it, and a copy of the uploaded files in
6
+ * backups/media. Only for a local database: a hosted one (Turso, libSQL) is backed up by its
7
+ * provider.
8
+ *
9
+ * Uploaded originals never change once saved (each has a name of its own), so the media copy
10
+ * only adds what is new. A file deleted from the library stays in the copy at least 30 days, and
11
+ * until no kept database copy is older than its deletion (those copies may still show it).
12
+ */
13
+ /** How long a deleted file stays in the media copy, at least. */
14
+ const KEEP_DELETED_MS = 30 * 24 * 60 * 60_000;
15
+ export const DEFAULT_BACKUPS = { enabled: true, keep: 14 };
16
+ const NAME = /^content-\d{8}-\d{6}(-[a-z0-9]+)*\.db$/;
17
+ const stamp = (d = new Date()) => {
18
+ const p = (n) => String(n).padStart(2, '0');
19
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
20
+ };
21
+ export function createBackups(database, mediaDir) {
22
+ const file = () => {
23
+ const u = database.url;
24
+ return u.startsWith('file:') ? resolve(u.slice('file:'.length)) : null;
25
+ };
26
+ const folder = () => {
27
+ const f = file();
28
+ return f ? join(dirname(f), 'backups') : null;
29
+ };
30
+ const list = async () => {
31
+ const dir = folder();
32
+ if (!dir)
33
+ return [];
34
+ const names = (await readdir(dir).catch(() => [])).filter((n) => NAME.test(n));
35
+ const out = await Promise.all(names.map(async (name) => {
36
+ const s = await stat(join(dir, name));
37
+ return { name, size: s.size, createdAt: s.mtime };
38
+ }));
39
+ return out.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
40
+ };
41
+ return {
42
+ /** False for a hosted database, or one in memory (tests). */
43
+ available: () => !!file(),
44
+ folder,
45
+ list,
46
+ /** A new copy; `tag` marks why ("auto", "restore"). */
47
+ async create(tag) {
48
+ const dir = folder();
49
+ if (!dir)
50
+ throw new Error('Backups need a local database');
51
+ await mkdir(dir, { recursive: true });
52
+ const base = `content-${stamp()}${tag ? `-${tag}` : ''}`;
53
+ let name = `${base}.db`;
54
+ // Two copies in the same second keep both.
55
+ for (let i = 2; await stat(join(dir, name)).catch(() => null); i++)
56
+ name = `${base}-${i}.db`;
57
+ const path = join(dir, name);
58
+ await database.client.execute({ sql: 'VACUUM INTO ?', args: [path] });
59
+ const s = await stat(path);
60
+ return { name, size: s.size, createdAt: s.mtime };
61
+ },
62
+ /** Keeps the newest `keep` copies. */
63
+ async prune(keep) {
64
+ const dir = folder();
65
+ if (!dir)
66
+ return 0;
67
+ const old = (await list()).slice(Math.max(1, keep));
68
+ for (const b of old)
69
+ await rm(join(dir, b.name), { force: true });
70
+ return old.length;
71
+ },
72
+ /** The file of a copy, or null when the name is not one of ours. */
73
+ async path(name) {
74
+ const dir = folder();
75
+ if (!dir || !NAME.test(name))
76
+ return null;
77
+ const path = join(dir, name);
78
+ return (await stat(path).catch(() => null))?.isFile() ? path : null;
79
+ },
80
+ /** The media copy: new uploads in, files deleted long enough ago out. */
81
+ async mirrorMedia() {
82
+ const dir = folder();
83
+ if (!dir)
84
+ return { copied: 0, purged: 0 };
85
+ const source = resolve(mediaDir());
86
+ const target = join(dir, 'media');
87
+ await mkdir(target, { recursive: true });
88
+ const originals = await mediaFiles(source);
89
+ const copies = new Set(await mediaFiles(target));
90
+ let copied = 0;
91
+ for (const name of originals)
92
+ if (!copies.has(name)) {
93
+ await copyFile(join(source, name), join(target, name));
94
+ copied++;
95
+ }
96
+ // When each file went missing from the library, to know when it can go.
97
+ const ledgerPath = join(target, '.removed.json');
98
+ const ledger = JSON.parse(await readFile(ledgerPath, 'utf8').catch(() => '{}'));
99
+ const present = new Set(originals);
100
+ const now = Date.now();
101
+ const oldest = (await list()).at(-1)?.createdAt.getTime() ?? now;
102
+ let purged = 0;
103
+ for (const name of copies) {
104
+ if (present.has(name)) {
105
+ delete ledger[name];
106
+ continue;
107
+ }
108
+ ledger[name] ??= now;
109
+ const gone = ledger[name];
110
+ if (now - gone > KEEP_DELETED_MS && gone < oldest) {
111
+ await rm(join(target, name), { force: true });
112
+ delete ledger[name];
113
+ purged++;
114
+ }
115
+ }
116
+ await writeFile(ledgerPath, JSON.stringify(ledger));
117
+ return { copied, purged };
118
+ },
119
+ /** How many files the media copy holds, and how much space. */
120
+ async mediaCopy() {
121
+ const dir = folder();
122
+ if (!dir)
123
+ return { files: 0, size: 0 };
124
+ const target = join(dir, 'media');
125
+ let size = 0;
126
+ const names = await mediaFiles(target);
127
+ for (const name of names)
128
+ size += (await stat(join(target, name)).catch(() => null))?.size ?? 0;
129
+ return { files: names.length, size };
130
+ },
131
+ async remove(name) {
132
+ const dir = folder();
133
+ if (!dir || !NAME.test(name))
134
+ return false;
135
+ const path = join(dir, name);
136
+ if (!(await stat(path).catch(() => null)))
137
+ return false;
138
+ await rm(path);
139
+ return true;
140
+ },
141
+ };
142
+ }
143
+ /** The files of a media folder: originals only (resized copies live in .cache and are remade). */
144
+ async function mediaFiles(dir) {
145
+ const items = await readdir(dir, { withFileTypes: true }).catch(() => []);
146
+ return items.filter((i) => i.isFile() && !i.name.startsWith('.')).map((i) => i.name);
147
+ }
148
+ /**
149
+ * After a database is put back: the files it uses that are missing from the library, taken from
150
+ * the media copy next to the backups. Returns how many came back.
151
+ */
152
+ export async function restoreMedia(database, mediaDir, keys) {
153
+ const copy = join(dirname(database), 'backups', 'media');
154
+ await mkdir(mediaDir, { recursive: true });
155
+ let restored = 0;
156
+ for (const key of keys) {
157
+ if (!key || key.includes('/') || key.includes('\\') || key.startsWith('.'))
158
+ continue;
159
+ if (await stat(join(mediaDir, key)).catch(() => null))
160
+ continue;
161
+ if (!(await stat(join(copy, key)).catch(() => null)))
162
+ continue;
163
+ await copyFile(join(copy, key), join(mediaDir, key));
164
+ restored++;
165
+ }
166
+ return restored;
167
+ }
package/src/fonts.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ export interface FontInfo {
2
+ id: string;
3
+ family: string;
4
+ category: string;
5
+ weights: number[];
6
+ styles: string[];
7
+ variable: boolean;
8
+ }
9
+ export declare const FONT_ID: RegExp;
10
+ export declare const FONT_FILE: RegExp;
11
+ export declare function createFonts(dir: string): {
12
+ /** Every Google Font, by family name. */
13
+ list(): Promise<FontInfo[]>;
14
+ /** @font-face rules pointing at /fonts/<id>/…, for the Latin subsets and every weight. */
15
+ css(id: string): Promise<string>;
16
+ /** A font file: from the cache, or fetched once from the CDN. */
17
+ file(id: string, name: string): Promise<Buffer>;
18
+ };
19
+ export type Fonts = ReturnType<typeof createFonts>;
package/src/fonts.js ADDED
@@ -0,0 +1,150 @@
1
+ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * Google Fonts served by the site itself. The catalog and the files come from Fontsource once,
5
+ * then from a local cache: visitors never contact a third party for fonts.
6
+ */
7
+ const API = 'https://api.fontsource.org/v1/fonts';
8
+ const CDN = 'https://cdn.jsdelivr.net/fontsource/fonts';
9
+ const WEEK = 7 * 24 * 60 * 60 * 1000;
10
+ /** Enough for Italian and the other Latin-script languages. */
11
+ const SUBSETS = ['latin', 'latin-ext'];
12
+ export const FONT_ID = /^[a-z0-9][a-z0-9-]{0,80}$/;
13
+ export const FONT_FILE = /^[a-z0-9-]+-(\d{3})-(normal|italic)\.woff2$/;
14
+ /** Fonts bundled with the panel and the starter site, listed even when the catalog cannot be reached. */
15
+ const BUNDLED = [
16
+ {
17
+ id: 'geist',
18
+ family: 'Geist',
19
+ category: 'sans-serif',
20
+ weights: [400, 500, 600, 700],
21
+ styles: ['normal'],
22
+ variable: true,
23
+ },
24
+ {
25
+ id: 'inter',
26
+ family: 'Inter',
27
+ category: 'sans-serif',
28
+ weights: [400, 500, 600, 700],
29
+ styles: ['normal', 'italic'],
30
+ variable: true,
31
+ },
32
+ {
33
+ id: 'instrument-serif',
34
+ family: 'Instrument Serif',
35
+ category: 'serif',
36
+ weights: [400],
37
+ styles: ['normal', 'italic'],
38
+ variable: false,
39
+ },
40
+ {
41
+ id: 'fraunces',
42
+ family: 'Fraunces',
43
+ category: 'serif',
44
+ weights: [400, 500, 600, 700],
45
+ styles: ['normal', 'italic'],
46
+ variable: true,
47
+ },
48
+ ];
49
+ export function createFonts(dir) {
50
+ let catalog = null;
51
+ async function cached(name, maxAge, load) {
52
+ const path = join(dir, name);
53
+ const info = await stat(path).catch(() => null);
54
+ if (info && Date.now() - info.mtimeMs < maxAge)
55
+ return JSON.parse(await readFile(path, 'utf8'));
56
+ try {
57
+ const value = await load();
58
+ await mkdir(dir, { recursive: true });
59
+ await writeFile(`${path}.tmp`, JSON.stringify(value));
60
+ await rename(`${path}.tmp`, path);
61
+ return value;
62
+ }
63
+ catch (error) {
64
+ // Offline: an old copy is better than nothing.
65
+ if (info)
66
+ return JSON.parse(await readFile(path, 'utf8'));
67
+ throw error;
68
+ }
69
+ }
70
+ async function fetchJson(url) {
71
+ const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
72
+ if (!res.ok)
73
+ throw new Error(`${url}: ${res.status}`);
74
+ return res.json();
75
+ }
76
+ async function detail(id) {
77
+ return cached(`${id}.json`, 30 * 24 * 60 * 60 * 1000, async () => {
78
+ const d = (await fetchJson(`${API}/${id}`));
79
+ return {
80
+ id: d.id,
81
+ family: d.family,
82
+ category: d.category,
83
+ weights: d.weights,
84
+ styles: d.styles,
85
+ variable: d.variable,
86
+ subsets: d.subsets,
87
+ unicodeRange: d.unicodeRange,
88
+ };
89
+ });
90
+ }
91
+ return {
92
+ /** Every Google Font, by family name. */
93
+ async list() {
94
+ if (catalog)
95
+ return catalog;
96
+ try {
97
+ const all = await cached('catalog.json', WEEK, async () => {
98
+ const list = (await fetchJson(`${API}?type=google`));
99
+ return list
100
+ .filter((f) => f.subsets.includes('latin'))
101
+ .map((f) => ({
102
+ id: f.id,
103
+ family: f.family,
104
+ category: f.category,
105
+ weights: f.weights,
106
+ styles: f.styles,
107
+ variable: f.variable,
108
+ }))
109
+ .sort((a, b) => a.family.localeCompare(b.family));
110
+ });
111
+ catalog = all;
112
+ return all;
113
+ }
114
+ catch {
115
+ return BUNDLED;
116
+ }
117
+ },
118
+ /** @font-face rules pointing at /fonts/<id>/…, for the Latin subsets and every weight. */
119
+ async css(id) {
120
+ const d = await detail(id);
121
+ const rules = [];
122
+ for (const subset of SUBSETS.filter((s) => d.subsets.includes(s))) {
123
+ for (const style of d.styles.filter((s) => s === 'normal' || s === 'italic')) {
124
+ for (const weight of d.weights) {
125
+ if (style === 'italic' && weight !== 400)
126
+ continue;
127
+ rules.push(`@font-face{font-family:'${d.family.replace(/'/g, '')}';font-style:${style};font-display:swap;font-weight:${weight};` +
128
+ `src:url(/fonts/${id}/${subset}-${weight}-${style}.woff2) format('woff2');unicode-range:${d.unicodeRange[subset] ?? 'U+0000-00FF'}}`);
129
+ }
130
+ }
131
+ }
132
+ return rules.join('\n');
133
+ },
134
+ /** A font file: from the cache, or fetched once from the CDN. */
135
+ async file(id, name) {
136
+ const path = join(dir, id, name);
137
+ const local = await readFile(path).catch(() => null);
138
+ if (local)
139
+ return local;
140
+ const res = await fetch(`${CDN}/${id}@latest/${name}`, { signal: AbortSignal.timeout(15_000) });
141
+ if (!res.ok)
142
+ throw new Error(`font ${id}/${name}: ${res.status}`);
143
+ const data = Buffer.from(await res.arrayBuffer());
144
+ await mkdir(join(dir, id), { recursive: true });
145
+ await writeFile(`${path}.tmp`, data);
146
+ await rename(`${path}.tmp`, path);
147
+ return data;
148
+ },
149
+ };
150
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import type { MaterializeConfig } from '@materializecms/core';
3
+ import type { Updates } from '@materializecms/core/updates';
4
+ import { type Database } from '@materializecms/db';
5
+ export { isPatch, newer, type ReleaseNotes, type UpdateSettings, type UpdateStatus, type Updates, } from '@materializecms/core/updates';
6
+ export { createApp, type ServerContext, VERSION } from './app.ts';
7
+ export { extractTarGz } from './archive.ts';
8
+ export { restoreMedia } from './backups.ts';
9
+ export { createFonts, type FontInfo, type Fonts } from './fonts.ts';
10
+ export { createMcpServer, registerTools } from './mcp.ts';
11
+ export { createMediaFiles, type MediaFiles } from './media.ts';
12
+ export { hashPassword, verifyPassword } from './password.ts';
13
+ export { apiCaller, siteTools } from './tools.ts';
14
+ /** Serves the public site: every request outside /api, /admin and /media goes here. */
15
+ export type SiteHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
16
+ export interface RunningServer {
17
+ url: string;
18
+ database: Database;
19
+ /** Swap the schema without restarting: used by `materialize dev` when the config changes. */
20
+ reload: (config: MaterializeConfig) => void;
21
+ close: () => Promise<void>;
22
+ }
23
+ export interface ServerOptions {
24
+ port?: number;
25
+ /** The address to listen on: every interface when missing, 127.0.0.1 behind a proxy on the same machine. */
26
+ host?: string;
27
+ /** The site renderer. Without one, "/" shows a status page. */
28
+ site?: SiteHandler;
29
+ onPublish?: () => void;
30
+ updates?: Updates;
31
+ /** Required by the installation in the browser while there is no account yet. */
32
+ setupToken?: string;
33
+ }
34
+ export declare function startServer(initial: MaterializeConfig, options?: ServerOptions): Promise<RunningServer>;
package/src/index.js ADDED
@@ -0,0 +1,60 @@
1
+ import { createServer } from 'node:http';
2
+ import { getRequestListener } from '@hono/node-server';
3
+ import { ADMIN_DIST } from '@materializecms/admin';
4
+ import { openDatabase } from '@materializecms/db';
5
+ import { createApp } from './app.js';
6
+ export { isPatch, newer, } from '@materializecms/core/updates';
7
+ export { createApp, VERSION } from './app.js';
8
+ export { extractTarGz } from './archive.js';
9
+ export { restoreMedia } from './backups.js';
10
+ export { createFonts } from './fonts.js';
11
+ export { createMcpServer, registerTools } from './mcp.js';
12
+ export { createMediaFiles } from './media.js';
13
+ export { hashPassword, verifyPassword } from './password.js';
14
+ export { apiCaller, siteTools } from './tools.js';
15
+ const OWN = /^\/(api|admin|media|fonts|mcp)(\/|\?|$)/;
16
+ export async function startServer(initial, options = {}) {
17
+ let config = initial;
18
+ const database = await openDatabase(config.database);
19
+ const app = createApp({
20
+ config: () => config,
21
+ database,
22
+ adminDir: ADMIN_DIST,
23
+ preview: !!options.site,
24
+ onPublish: options.onPublish,
25
+ updates: options.updates,
26
+ setupToken: options.setupToken,
27
+ });
28
+ const api = getRequestListener(app.fetch);
29
+ const site = options.site;
30
+ const server = createServer((req, res) => {
31
+ if (site && !OWN.test(req.url ?? '/')) {
32
+ Promise.resolve(site(req, res)).catch((error) => {
33
+ console.error(error);
34
+ if (!res.headersSent)
35
+ res.writeHead(500).end('Errore del sito');
36
+ });
37
+ return;
38
+ }
39
+ void api(req, res);
40
+ });
41
+ const port = options.port ?? config.server.port;
42
+ await new Promise((resolve, reject) => {
43
+ server.once('error', reject);
44
+ server.listen(port, options.host, resolve);
45
+ });
46
+ return {
47
+ url: `http://localhost:${port}`,
48
+ database,
49
+ reload: (next) => {
50
+ config = next;
51
+ },
52
+ close: () => new Promise((resolve) => {
53
+ server.close(() => {
54
+ database.close();
55
+ resolve();
56
+ });
57
+ server.closeAllConnections();
58
+ }),
59
+ };
60
+ }