scriptorium 0.0.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.
Files changed (82) hide show
  1. package/README.md +110 -0
  2. package/bin/scriptorium.js +18 -0
  3. package/build/client/apple-touch-icon.png +0 -0
  4. package/build/client/assets/_layout-D-bxuQBW.js +1 -0
  5. package/build/client/assets/_layout-D2eOWFrG.js +1 -0
  6. package/build/client/assets/_layout-D4m0RbUC.js +1 -0
  7. package/build/client/assets/_layout-DggtTDZp.js +1 -0
  8. package/build/client/assets/ack-yx2DGjj6.js +1 -0
  9. package/build/client/assets/auth-shell-FEL5QDuP.js +1 -0
  10. package/build/client/assets/begin-DP2rzg_V.js +1 -0
  11. package/build/client/assets/begin-l0sNRNKZ.js +1 -0
  12. package/build/client/assets/chunk-EPOLDU6W-B-j6nV8T.js +26 -0
  13. package/build/client/assets/cn-Kn7NbJqM.js +1 -0
  14. package/build/client/assets/confirm-passkey-ntLsS7_k.js +1 -0
  15. package/build/client/assets/entry.client-BkscxSA6.js +5 -0
  16. package/build/client/assets/events-l0sNRNKZ.js +1 -0
  17. package/build/client/assets/file-list-DBDZbrBp.js +1 -0
  18. package/build/client/assets/files-Bzkr7-i7.js +1 -0
  19. package/build/client/assets/files-browser-KNurSsen.js +1 -0
  20. package/build/client/assets/files-cnv1kfgp.js +1 -0
  21. package/build/client/assets/files.browse-9p4xl9MV.js +1 -0
  22. package/build/client/assets/finish-DP2rzg_V.js +1 -0
  23. package/build/client/assets/finish-l0sNRNKZ.js +1 -0
  24. package/build/client/assets/git-CjSY0eSC.js +1 -0
  25. package/build/client/assets/git-T0lLjMNd.js +1 -0
  26. package/build/client/assets/git-browser-DwSOY6QN.js +1 -0
  27. package/build/client/assets/iconify-BDcX0yw8.js +1 -0
  28. package/build/client/assets/index-8zQ7Fizv.js +9 -0
  29. package/build/client/assets/index-BUN6tnpl.js +1 -0
  30. package/build/client/assets/index-BlNoCKAt.js +1 -0
  31. package/build/client/assets/index-g2QB9BbT.js +1 -0
  32. package/build/client/assets/instance-events-provider-DTYnvumj.js +39 -0
  33. package/build/client/assets/instance-route-C25LRYYo.js +1 -0
  34. package/build/client/assets/instances-CzjXhR3c.js +1 -0
  35. package/build/client/assets/instances._instanceId.proxy._-l0sNRNKZ.js +1 -0
  36. package/build/client/assets/instances._instanceId.sessions._sessionId.test-COZIMxji.js +208 -0
  37. package/build/client/assets/instances.new-BZi5xwEV.js +1 -0
  38. package/build/client/assets/login-Bwt79t7_.js +1 -0
  39. package/build/client/assets/logout-l0sNRNKZ.js +1 -0
  40. package/build/client/assets/magic-string.es-DjU5CGuV.js +10 -0
  41. package/build/client/assets/manifest-3c01c628.js +1 -0
  42. package/build/client/assets/message-card-BclR_JqA.css +1 -0
  43. package/build/client/assets/message-card-DJ5C3WNp.js +34 -0
  44. package/build/client/assets/passkeys-DpNQs4bn.js +1 -0
  45. package/build/client/assets/register-DXPj5RLZ.js +1 -0
  46. package/build/client/assets/root-DxD-Skic.css +1 -0
  47. package/build/client/assets/root-DxyfhNRI.js +1 -0
  48. package/build/client/assets/route-handle-DcEil3NY.js +1 -0
  49. package/build/client/assets/safe-area-DTPfLjT-.css +1 -0
  50. package/build/client/assets/safe-area.module-CwCzHS6V.js +1 -0
  51. package/build/client/assets/scroll-indicator-CFaTM_rb.js +12 -0
  52. package/build/client/assets/scroll-indicator-D4qxLVUu.css +1 -0
  53. package/build/client/assets/scrollable-layout-CS-vPTNM.js +1 -0
  54. package/build/client/assets/session-route-oxi4s_Qg.js +1 -0
  55. package/build/client/assets/sessions-provider-D1t07kir.js +1 -0
  56. package/build/client/assets/settings-CameSNIM.js +1 -0
  57. package/build/client/assets/sidebar-DRhB33rT.js +1 -0
  58. package/build/client/assets/use-double-check-CbbarqDt.js +1 -0
  59. package/build/client/assets/webauthn.client-C1ZSQfKs.js +2 -0
  60. package/build/client/dev-sw.js +7 -0
  61. package/build/client/favicon.ico +0 -0
  62. package/build/client/icon-192.png +0 -0
  63. package/build/client/icon-512.png +0 -0
  64. package/build/client/icon-maskable-512.png +0 -0
  65. package/build/client/icon-maskable.svg +8 -0
  66. package/build/client/icon.svg +8 -0
  67. package/build/client/manifest.webmanifest +28 -0
  68. package/build/client/sw.js +73 -0
  69. package/build/runtime/app/lib/runtime-config.server.js +422 -0
  70. package/build/runtime/cli/scriptorium.js +21 -0
  71. package/build/runtime/cli/serve.js +101 -0
  72. package/build/server/index.js +8574 -0
  73. package/docs/config.md +49 -0
  74. package/drizzle/20260312181200_fair_caretaker/migration.sql +54 -0
  75. package/drizzle/20260312181200_fair_caretaker/snapshot.json +484 -0
  76. package/drizzle/20260313000548_windy_paibok/migration.sql +37 -0
  77. package/drizzle/20260313000548_windy_paibok/snapshot.json +617 -0
  78. package/drizzle/20260318120000_session_read_statuses/migration.sql +11 -0
  79. package/drizzle/20260318120000_session_read_statuses/snapshot.json +710 -0
  80. package/drizzle/20260318213224_dear_captain_midlands/migration.sql +24 -0
  81. package/drizzle/20260318213224_dear_captain_midlands/snapshot.json +695 -0
  82. package/package.json +88 -0
@@ -0,0 +1,422 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir, platform } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
7
+ import { z } from "zod";
8
+ const CONFIG_FILE_NAME = "config.yml";
9
+ const SECRETS_FILE_NAME = "secrets.yml";
10
+ const PRIVATE_FILE_MODE = 0o600;
11
+ const DOCUMENTATION_HOME = "$HOME";
12
+ const DOCUMENTATION_DATA_DIR = "<scriptorium_data_dir>";
13
+ let cachedRuntimeConfiguration = null;
14
+ function meta(schema, metadata) {
15
+ return schema.meta(metadata);
16
+ }
17
+ function section(shape) {
18
+ return z.object(shape).prefault(() => Object.fromEntries(Object.keys(shape).map((key) => [key, undefined])));
19
+ }
20
+ function getOptionMetadata(schema) {
21
+ const metadata = schema.meta();
22
+ if (!metadata?.description) {
23
+ throw new Error("Missing required option metadata.");
24
+ }
25
+ return metadata;
26
+ }
27
+ function override(schema, sources) {
28
+ const metadata = getOptionMetadata(schema);
29
+ return z.transform((configValue) => {
30
+ if (metadata.cli) {
31
+ const cliValue = sources.cli?.[metadata.cli];
32
+ if (cliValue !== undefined) {
33
+ return cliValue;
34
+ }
35
+ }
36
+ if (metadata.env) {
37
+ const envValue = sources.env?.[metadata.env];
38
+ if (envValue !== undefined) {
39
+ return envValue;
40
+ }
41
+ }
42
+ return configValue;
43
+ }).pipe(schema);
44
+ }
45
+ function defaultConfigDirectory(env = process.env) {
46
+ const home = env.HOME?.trim() || homedir();
47
+ switch (platform()) {
48
+ case "darwin":
49
+ return resolve(home, "Library/Application Support/scriptorium");
50
+ case "win32":
51
+ return resolve(env.APPDATA?.trim() || join(home, "AppData/Roaming"), "scriptorium");
52
+ default:
53
+ return resolve(env.XDG_DATA_HOME?.trim() || join(home, ".local/share"), "scriptorium");
54
+ }
55
+ }
56
+ export function getRuntimeConfigPaths(sources = {}) {
57
+ const directory = resolve(sources.configDir ||
58
+ sources.env?.SCRIPTORIUM_CONFIG_DIR?.trim() ||
59
+ defaultConfigDirectory(sources.env));
60
+ return {
61
+ directory,
62
+ configFile: join(directory, CONFIG_FILE_NAME),
63
+ secretsFile: join(directory, SECRETS_FILE_NAME),
64
+ };
65
+ }
66
+ function getDefaultDatabasePath(sources) {
67
+ return join(getRuntimeConfigPaths(sources).directory, "app.db");
68
+ }
69
+ function createConfigSchema(sources = {}) {
70
+ const defaultHomeDirectory = sources.env?.HOME?.trim() || homedir();
71
+ return z.object({
72
+ server: section({
73
+ host: override(meta(z.string().min(1).default("0.0.0.0"), {
74
+ cli: "host",
75
+ env: "HOST",
76
+ description: "Host interface for the web server.",
77
+ }), sources),
78
+ port: override(meta(z.coerce.number().int().min(1).max(65535).default(5174), {
79
+ cli: "port",
80
+ env: "PORT",
81
+ description: "Port for the web server.",
82
+ }), sources),
83
+ }),
84
+ workspace: section({
85
+ browserRoot: override(meta(z.string().min(1).default(defaultHomeDirectory), {
86
+ cli: "browser-root",
87
+ env: "SCRIPTORIUM_BROWSER_ROOT",
88
+ description: "Root directory exposed in the workspace browser.",
89
+ }), sources),
90
+ }),
91
+ opencode: section({
92
+ bin: override(meta(z.string().min(1).default("opencode"), {
93
+ cli: "opencode-bin",
94
+ env: "OPENCODE_BIN",
95
+ description: "OpenCode executable name or path.",
96
+ }), sources),
97
+ }),
98
+ network: section({
99
+ tailscale: override(meta(z.coerce.boolean().default(false), {
100
+ cli: "tailscale",
101
+ description: "Expose the app with tailscale serve.",
102
+ }), sources),
103
+ }),
104
+ database: section({
105
+ path: override(meta(z.string().min(1).default(getDefaultDatabasePath(sources)), {
106
+ cli: "db-path",
107
+ env: "SCRIPTORIUM_DB_PATH",
108
+ description: "Path to the SQLite database file.",
109
+ }), sources),
110
+ }),
111
+ });
112
+ }
113
+ function createSecretsSchema(sources = {}) {
114
+ return z.object({
115
+ auth: section({
116
+ sessionSecret: override(meta(z.string().min(32).optional(), {
117
+ env: "SESSION_SECRET",
118
+ description: "Session signing secret.",
119
+ }), sources),
120
+ }),
121
+ });
122
+ }
123
+ function readYamlFile(filePath) {
124
+ if (!existsSync(filePath)) {
125
+ return {};
126
+ }
127
+ const content = readFileSync(filePath, "utf8");
128
+ const parsed = parseYaml(content);
129
+ if (parsed === null || parsed === undefined) {
130
+ return {};
131
+ }
132
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
133
+ throw new Error(`Expected ${filePath} to contain a YAML mapping.`);
134
+ }
135
+ return parsed;
136
+ }
137
+ function writeYamlFile(filePath, value) {
138
+ mkdirSync(dirname(filePath), { recursive: true });
139
+ const content = stringifyYaml(value);
140
+ writeFileSync(filePath, content, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
141
+ try {
142
+ chmodSync(filePath, PRIVATE_FILE_MODE);
143
+ }
144
+ catch {
145
+ // Best-effort only. Some platforms ignore chmod semantics.
146
+ }
147
+ }
148
+ function ensureSessionSecret(parsedSecrets, paths, sources) {
149
+ const configuredSecret = parsedSecrets.auth.sessionSecret;
150
+ if (configuredSecret) {
151
+ return {
152
+ auth: {
153
+ sessionSecret: configuredSecret,
154
+ },
155
+ };
156
+ }
157
+ const generatedSecret = randomBytes(32).toString("hex");
158
+ const persistedSecrets = {
159
+ auth: {
160
+ sessionSecret: generatedSecret,
161
+ },
162
+ };
163
+ if (sources.env?.SESSION_SECRET === undefined) {
164
+ writeYamlFile(paths.secretsFile, persistedSecrets);
165
+ }
166
+ return persistedSecrets;
167
+ }
168
+ export function resolveRuntimeConfiguration(sources = {}) {
169
+ const paths = getRuntimeConfigPaths(sources);
170
+ const rawConfig = readYamlFile(paths.configFile);
171
+ const rawSecrets = readYamlFile(paths.secretsFile);
172
+ const config = createConfigSchema(sources).parse(rawConfig);
173
+ const secrets = ensureSessionSecret(createSecretsSchema(sources).parse(rawSecrets), paths, sources);
174
+ return { config, secrets, paths };
175
+ }
176
+ export function getRuntimeConfiguration() {
177
+ if (!cachedRuntimeConfiguration) {
178
+ cachedRuntimeConfiguration = resolveRuntimeConfiguration({ env: process.env });
179
+ }
180
+ return cachedRuntimeConfiguration;
181
+ }
182
+ export function initializeRuntimeConfiguration(sources = {}) {
183
+ cachedRuntimeConfiguration = resolveRuntimeConfiguration(sources);
184
+ return cachedRuntimeConfiguration;
185
+ }
186
+ export function resetRuntimeConfigurationCache() {
187
+ cachedRuntimeConfiguration = null;
188
+ }
189
+ function unwrapSchema(schema) {
190
+ if (schema instanceof z.ZodDefault) {
191
+ return unwrapSchema(schema._def.innerType);
192
+ }
193
+ if (schema instanceof z.ZodPrefault) {
194
+ return unwrapSchema(schema._def.innerType);
195
+ }
196
+ if (schema instanceof z.ZodPipe) {
197
+ return unwrapSchema(schema._def.out);
198
+ }
199
+ if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
200
+ return unwrapSchema(schema._def.innerType);
201
+ }
202
+ return schema;
203
+ }
204
+ function getDefaultValue(schema) {
205
+ if (schema instanceof z.ZodDefault) {
206
+ return schema._def.defaultValue;
207
+ }
208
+ if (schema instanceof z.ZodPipe) {
209
+ return getDefaultValue(schema._def.out);
210
+ }
211
+ if (schema instanceof z.ZodPrefault) {
212
+ return getDefaultValue(schema._def.innerType);
213
+ }
214
+ return undefined;
215
+ }
216
+ function getTypeName(schema) {
217
+ const unwrapped = unwrapSchema(schema);
218
+ if (unwrapped instanceof z.ZodString) {
219
+ return "string";
220
+ }
221
+ if (unwrapped instanceof z.ZodNumber) {
222
+ return "number";
223
+ }
224
+ if (unwrapped instanceof z.ZodBoolean) {
225
+ return "boolean";
226
+ }
227
+ if (unwrapped instanceof z.ZodEnum) {
228
+ return "enum";
229
+ }
230
+ return "unknown";
231
+ }
232
+ export function collectSchemaDocumentation(schema, path = []) {
233
+ const unwrapped = unwrapSchema(schema);
234
+ if (unwrapped instanceof z.ZodObject) {
235
+ return Object.entries(unwrapped.shape).flatMap(([key, child]) => collectSchemaDocumentation(child, [...path, key]));
236
+ }
237
+ const metadata = schema instanceof z.ZodPipe
238
+ ? getOptionMetadata(schema._def.out)
239
+ : getOptionMetadata(schema);
240
+ return [{
241
+ path,
242
+ type: getTypeName(schema),
243
+ description: metadata.description,
244
+ cli: metadata.cli ? `--${metadata.cli}` : undefined,
245
+ env: metadata.env,
246
+ defaultValue: getDefaultValue(schema),
247
+ }];
248
+ }
249
+ export function getRuntimeConfigurationDocumentation() {
250
+ const sources = {
251
+ env: {
252
+ APPDATA: "C:/Users/you/AppData/Roaming",
253
+ HOME: "/path/to/home",
254
+ XDG_DATA_HOME: "/path/to/home/.local/share",
255
+ },
256
+ cli: {},
257
+ };
258
+ return {
259
+ config: collectSchemaDocumentation(createConfigSchema(sources)),
260
+ secrets: collectSchemaDocumentation(createSecretsSchema(sources)),
261
+ };
262
+ }
263
+ function getCliRows() {
264
+ return getRuntimeConfigurationDocumentation().config.filter((row) => row.cli);
265
+ }
266
+ function getCliOptionsConfig() {
267
+ const options = {
268
+ help: {
269
+ type: "boolean",
270
+ },
271
+ "config-dir": {
272
+ type: "string",
273
+ },
274
+ };
275
+ for (const row of getCliRows()) {
276
+ const flag = row.cli.slice(2);
277
+ options[flag] = {
278
+ type: row.type === "boolean" ? "boolean" : "string",
279
+ };
280
+ if (row.type === "boolean") {
281
+ options[`no-${flag}`] = {
282
+ type: "boolean",
283
+ };
284
+ }
285
+ }
286
+ return options;
287
+ }
288
+ export function parseRuntimeCliArgs(args) {
289
+ const parsed = parseArgs({
290
+ args,
291
+ options: getCliOptionsConfig(),
292
+ strict: true,
293
+ allowPositionals: false,
294
+ });
295
+ const values = parsed.values;
296
+ const cli = {};
297
+ for (const row of getCliRows()) {
298
+ const flag = row.cli.slice(2);
299
+ const value = values[flag];
300
+ if (value !== undefined) {
301
+ cli[flag] = value;
302
+ continue;
303
+ }
304
+ if (row.type === "boolean" && values[`no-${flag}`] === true) {
305
+ cli[flag] = false;
306
+ }
307
+ }
308
+ return {
309
+ cli,
310
+ configDir: typeof values["config-dir"] === "string"
311
+ ? values["config-dir"]
312
+ : undefined,
313
+ help: values.help === true,
314
+ };
315
+ }
316
+ function stringifyDefaultValue(value) {
317
+ if (value === undefined) {
318
+ return "";
319
+ }
320
+ return `\`${String(normalizeDocumentationValue(value))}\``;
321
+ }
322
+ function normalizeDocumentationValue(value) {
323
+ if (typeof value !== "string") {
324
+ return value;
325
+ }
326
+ return value
327
+ .replaceAll("/path/to/home/Library/Application Support/scriptorium", DOCUMENTATION_DATA_DIR)
328
+ .replaceAll("/path/to/home/.local/share/scriptorium", DOCUMENTATION_DATA_DIR)
329
+ .replaceAll("C:/Users/you/AppData/Roaming/scriptorium", DOCUMENTATION_DATA_DIR)
330
+ .replaceAll("/path/to/home", DOCUMENTATION_HOME);
331
+ }
332
+ function escapeTableCell(value) {
333
+ return (value || "").replaceAll("|", "\\|");
334
+ }
335
+ function setNestedValue(target, path, value) {
336
+ let current = target;
337
+ for (const segment of path.slice(0, -1)) {
338
+ const existing = current[segment];
339
+ if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
340
+ current[segment] = {};
341
+ }
342
+ current = current[segment];
343
+ }
344
+ current[path.at(-1)] = value;
345
+ }
346
+ function getExampleValue(row, section) {
347
+ if (row.defaultValue !== undefined) {
348
+ return normalizeDocumentationValue(row.defaultValue);
349
+ }
350
+ if (section === "secrets") {
351
+ return "<generated on first run or set via env>";
352
+ }
353
+ return `<set ${row.path.join(".")}>`;
354
+ }
355
+ export function buildDocumentationExample(rows, section) {
356
+ const result = {};
357
+ for (const row of rows) {
358
+ setNestedValue(result, row.path, getExampleValue(row, section));
359
+ }
360
+ return stringifyYaml(result).trim();
361
+ }
362
+ function renderDocumentationTable(rows) {
363
+ const lines = [
364
+ "| Key | Type | Default | CLI | Env | Description |",
365
+ "| --- | --- | --- | --- | --- | --- |",
366
+ ];
367
+ for (const row of rows) {
368
+ lines.push(`| \`${row.path.join(".")}\` | ${row.type} | ${escapeTableCell(stringifyDefaultValue(row.defaultValue))} | ${escapeTableCell(row.cli ? `\`${row.cli}\`` : "")} | ${escapeTableCell(row.env ? `\`${row.env}\`` : "")} | ${escapeTableCell(row.description)} |`);
369
+ }
370
+ return lines.join("\n");
371
+ }
372
+ export function renderRuntimeConfigurationMarkdown() {
373
+ const documentation = getRuntimeConfigurationDocumentation();
374
+ return [
375
+ "# Configuration Reference",
376
+ "",
377
+ "Scriptorium reads non-sensitive settings from `config.yml` and secrets from `secrets.yml` in its per-user config directory.",
378
+ "CLI flags override environment variables, which override YAML values, which override schema defaults.",
379
+ "",
380
+ `| Platform | ${DOCUMENTATION_DATA_DIR} |`,
381
+ "| --- | --- |",
382
+ "| macOS | `~/Library/Application Support/scriptorium` |",
383
+ "| Linux | `$XDG_DATA_HOME/scriptorium` or `~/.local/share/scriptorium` |",
384
+ "| Windows | `%APPDATA%\\scriptorium` |",
385
+ "",
386
+ `Examples use \`${DOCUMENTATION_DATA_DIR}\` as shorthand for Scriptorium's per-user data directory and \`${DOCUMENTATION_HOME}\` for the user's home directory.`,
387
+ "",
388
+ "## config.yml",
389
+ "",
390
+ "```yaml",
391
+ buildDocumentationExample(documentation.config, "config"),
392
+ "```",
393
+ "",
394
+ renderDocumentationTable(documentation.config),
395
+ "",
396
+ "## secrets.yml",
397
+ "",
398
+ "```yaml",
399
+ buildDocumentationExample(documentation.secrets, "secrets"),
400
+ "```",
401
+ "",
402
+ renderDocumentationTable(documentation.secrets),
403
+ "",
404
+ ].join("\n");
405
+ }
406
+ export function renderRuntimeConfigurationHelp() {
407
+ const rows = getCliRows();
408
+ return [
409
+ "Usage: scriptorium [options]",
410
+ "",
411
+ "Options:",
412
+ " --help Show this help message",
413
+ " --config-dir <path> Use an alternate config directory",
414
+ ...rows.map((row) => {
415
+ const flag = row.cli;
416
+ const typeSuffix = row.type === "boolean" ? "" : ` <${row.type}>`;
417
+ const negated = row.type === "boolean" ? `, --no-${flag.slice(2)}` : "";
418
+ const env = row.env ? ` [env: ${row.env}]` : "";
419
+ return ` ${flag}${typeSuffix}${negated} ${row.description}${env}`;
420
+ }),
421
+ ].join("\n");
422
+ }
@@ -0,0 +1,21 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { initializeRuntimeConfiguration, parseRuntimeCliArgs, renderRuntimeConfigurationHelp, } from "../app/lib/runtime-config.server.js";
4
+ import { serveProductionApp } from "./serve.js";
5
+ function getPackageRoot() {
6
+ return path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
7
+ }
8
+ export async function main(args = process.argv.slice(2)) {
9
+ const parsedCli = parseRuntimeCliArgs(args);
10
+ if (parsedCli.help) {
11
+ process.stdout.write(`${renderRuntimeConfigurationHelp()}\n`);
12
+ return;
13
+ }
14
+ const runtime = initializeRuntimeConfiguration({
15
+ cli: parsedCli.cli,
16
+ configDir: parsedCli.configDir,
17
+ env: process.env,
18
+ });
19
+ await serveProductionApp(runtime, getPackageRoot());
20
+ }
21
+ await main();
@@ -0,0 +1,101 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import compression from "compression";
7
+ import express from "express";
8
+ import morgan from "morgan";
9
+ import { createRequestHandler } from "@react-router/express";
10
+ function getBuildPaths(packageRoot) {
11
+ return {
12
+ packageRoot,
13
+ clientDirectory: path.join(packageRoot, "build/client"),
14
+ clientAssetsDirectory: path.join(packageRoot, "build/client/assets"),
15
+ serverBuildPath: path.join(packageRoot, "build/server/index.js"),
16
+ };
17
+ }
18
+ function assertBuildExists(paths) {
19
+ if (!existsSync(paths.serverBuildPath) || !existsSync(paths.clientDirectory)) {
20
+ throw new Error("Scriptorium has not been built yet. Run `npm run build` before `npm start`, or use the published package with `npx scriptorium`.");
21
+ }
22
+ }
23
+ async function loadServerBuild(serverBuildPath) {
24
+ return import(pathToFileURL(serverBuildPath).href);
25
+ }
26
+ function startTailscale(port) {
27
+ try {
28
+ execSync(`tailscale serve --bg http://localhost:${port}`, { stdio: "inherit" });
29
+ }
30
+ catch {
31
+ // tailscale not available - ignore
32
+ }
33
+ }
34
+ function stopTailscale() {
35
+ try {
36
+ execSync("tailscale serve --https=443 off", { stdio: "ignore" });
37
+ }
38
+ catch {
39
+ // tailscale not available - ignore
40
+ }
41
+ }
42
+ export async function serveProductionApp(runtime, packageRoot) {
43
+ const buildPaths = getBuildPaths(packageRoot);
44
+ assertBuildExists(buildPaths);
45
+ process.env.NODE_ENV = process.env.NODE_ENV ?? "production";
46
+ const build = await loadServerBuild(buildPaths.serverBuildPath);
47
+ const app = express();
48
+ const port = runtime.config.server.port;
49
+ const host = runtime.config.server.host;
50
+ app.disable("x-powered-by");
51
+ app.use(compression());
52
+ app.use("/assets", express.static(buildPaths.clientAssetsDirectory, {
53
+ immutable: true,
54
+ maxAge: "1y",
55
+ }));
56
+ app.use(express.static(buildPaths.clientDirectory));
57
+ app.use(morgan("tiny"));
58
+ app.all("*", createRequestHandler({
59
+ build,
60
+ mode: process.env.NODE_ENV,
61
+ }));
62
+ if (runtime.config.network.tailscale) {
63
+ startTailscale(port);
64
+ }
65
+ const server = await new Promise((resolve, reject) => {
66
+ const httpServer = app.listen(port, host, () => resolve(httpServer));
67
+ httpServer.once("error", reject);
68
+ });
69
+ const networkAddress = Object.values(os.networkInterfaces())
70
+ .flat()
71
+ .find((entry) => String(entry?.family).includes("4") && !entry?.internal)?.address;
72
+ if (networkAddress) {
73
+ process.stdout.write(`[scriptorium] http://${host}:${port} (http://${networkAddress}:${port})\n`);
74
+ }
75
+ else {
76
+ process.stdout.write(`[scriptorium] http://${host}:${port}\n`);
77
+ }
78
+ let shuttingDown = false;
79
+ const shutdown = () => {
80
+ if (shuttingDown) {
81
+ return;
82
+ }
83
+ shuttingDown = true;
84
+ if (runtime.config.network.tailscale) {
85
+ stopTailscale();
86
+ }
87
+ server.close((error) => {
88
+ if (error) {
89
+ process.stderr.write(`${error.message}\n`);
90
+ process.exitCode = 1;
91
+ }
92
+ });
93
+ };
94
+ server.once("close", () => {
95
+ if (runtime.config.network.tailscale) {
96
+ stopTailscale();
97
+ }
98
+ });
99
+ process.once("SIGINT", shutdown);
100
+ process.once("SIGTERM", shutdown);
101
+ }