@axis-network/butter-col 0.0.0-stage → 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -2
  3. package/bin/butter-col.js +4 -0
  4. package/dist/bootstrap.html +67 -0
  5. package/dist/client.js +2 -0
  6. package/dist/client.js.map +7 -0
  7. package/dist/controller.mjs +2 -0
  8. package/dist/controller.mjs.map +7 -0
  9. package/dist/formatter.mjs +2 -0
  10. package/dist/formatter.mjs.map +7 -0
  11. package/dist/sw.js +30 -0
  12. package/dist/sw.js.map +7 -0
  13. package/dist/transports/butter.js +2 -0
  14. package/dist/transports/butter.js.map +7 -0
  15. package/dist/types/controller/index.d.ts +65 -0
  16. package/dist/types/formatter/codecs.d.ts +17 -0
  17. package/dist/types/formatter/css.d.ts +10 -0
  18. package/dist/types/formatter/html.d.ts +61 -0
  19. package/dist/types/formatter/index.d.ts +77 -0
  20. package/dist/types/formatter/js.d.ts +27 -0
  21. package/dist/types/formatter/url.d.ts +40 -0
  22. package/lib/cli.js +257 -0
  23. package/lib/index.d.ts +68 -0
  24. package/lib/index.js +166 -0
  25. package/package.json +77 -4
  26. package/server/conf/butter-proxy.conf.template +29 -0
  27. package/server/conf/mime.types +19 -0
  28. package/server/conf/nginx.conf.template +152 -0
  29. package/server/config.lua +34 -0
  30. package/server/lua/butter/config.lua +160 -0
  31. package/server/lua/butter/cookiejar.lua +197 -0
  32. package/server/lua/butter/dns.lua +67 -0
  33. package/server/lua/butter/guard.lua +78 -0
  34. package/server/lua/butter/headers.lua +83 -0
  35. package/server/lua/butter/init.lua +18 -0
  36. package/server/lua/butter/ip.lua +165 -0
  37. package/server/lua/butter/limit.lua +18 -0
  38. package/server/lua/butter/session.lua +42 -0
  39. package/server/lua/butter/store.lua +77 -0
  40. package/server/lua/butter/transport.lua +248 -0
  41. package/server/lua/butter/url.lua +78 -0
  42. package/site/app.css +373 -0
  43. package/site/app.js +335 -0
  44. package/site/butter-col.png +0 -0
  45. package/site/index.html +139 -0
@@ -0,0 +1,77 @@
1
+ /**
2
+ * butter-col formatter: everything that turns a target site's content into
3
+ * content that keeps working when served from the proxy origin.
4
+ *
5
+ * It is shared by the service worker (src/sw) and the in-page runtime
6
+ * (src/client) and has no dependencies on either environment beyond the
7
+ * standard URL / TextEncoder APIs.
8
+ */
9
+ import { MODULE_WORKER_MARK } from "./url.js";
10
+ import { rewriteCss } from "./css.js";
11
+ import { rewriteJs, LOCATION, EVAL_WRAP, UNREWRITE } from "./js.js";
12
+ import { rewriteHtml, URL_ATTRS, decodeEntities, escapeAttribute } from "./html.js";
13
+ import { codecs, getCodec } from "./codecs.js";
14
+ export type FormatterConfig = {
15
+ /**
16
+ * Proxy origin
17
+ */
18
+ origin: string;
19
+ /**
20
+ * Prefix for proxied URLs, e.g. "/butter/go/"
21
+ */
22
+ prefix: string;
23
+ /**
24
+ * Codec name
25
+ */
26
+ codec: string;
27
+ };
28
+ /**
29
+ * @typedef {object} FormatterConfig
30
+ * @property {string} origin Proxy origin
31
+ * @property {string} prefix Prefix for proxied URLs, e.g. "/butter/go/"
32
+ * @property {string} codec Codec name
33
+ */
34
+ /**
35
+ * @param {FormatterConfig} config
36
+ */
37
+ export declare function createFormatter(config: FormatterConfig): {
38
+ config: FormatterConfig;
39
+ url: {
40
+ origin: string;
41
+ prefix: string;
42
+ codec: import("./codecs.js").Codec;
43
+ decodeTail: (tail: string) => string | null;
44
+ isProxied: (url: string) => boolean;
45
+ rewrite: (raw: string, base: string | URL, opts?: {
46
+ moduleWorker?: boolean;
47
+ }) => string;
48
+ unrewrite: (url: string) => string;
49
+ rewriteSrcset: (value: string, base: string | URL, map?: (url: string, base: string | URL) => string) => string;
50
+ unrewriteSrcset: (value: any) => string;
51
+ };
52
+ /** @param {string} raw @param {string | URL} base @param {{ moduleWorker?: boolean }} [opts] */
53
+ rewriteUrl: (raw: string, base: string | URL, opts?: {
54
+ moduleWorker?: boolean;
55
+ }) => string;
56
+ /** @param {string} proxied */
57
+ unrewriteUrl: (proxied: string) => string;
58
+ /** @param {string} src @param {string | URL} base */
59
+ css: (src: string, base: string | URL) => string;
60
+ /** @param {string} src */
61
+ js: (src: string) => string;
62
+ /** @param {string} src @param {{ base: string, inject?: string }} options */
63
+ html: (src: string, options: {
64
+ base: string;
65
+ inject?: string;
66
+ }) => string;
67
+ /** @param {string} tag @param {string} name @param {string} value @param {{ base: string, inject?: string }} ctx */
68
+ attr: (tag: string, name: string, value: string, ctx: {
69
+ base: string;
70
+ inject?: string;
71
+ }) => string;
72
+ /** @param {string} value @param {string} base */
73
+ refresh: (value: string, base: string) => string;
74
+ };
75
+ export type Formatter = ReturnType<typeof createFormatter>;
76
+ /** @typedef {ReturnType<typeof createFormatter>} Formatter */
77
+ export { codecs, getCodec, rewriteCss, rewriteJs, rewriteHtml, decodeEntities, escapeAttribute, URL_ATTRS, MODULE_WORKER_MARK, LOCATION, EVAL_WRAP, UNREWRITE, };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * JavaScript rewriting.
3
+ *
4
+ * butter-col does not parse JavaScript into an AST. Instead a small,
5
+ * error-tolerant tokenizer walks the source and applies a handful of targeted
6
+ * edits. Everything else (fetches, imports, workers, ...) is handled at
7
+ * runtime by the service worker and the client hooks, so the rewriter only
8
+ * has to deal with what cannot be hooked:
9
+ *
10
+ * location / x.location / x?.location / x["location"]
11
+ * -> __butter$location (resolved by an accessor on Object.prototype)
12
+ * eval(code)
13
+ * -> eval(__butter$js(code)) (stays a *direct* eval)
14
+ * import.meta.url
15
+ * -> __butter$unrewrite(import.meta.url)
16
+ *
17
+ * The tokenizer never edits inside strings, template text, comments or regex
18
+ * literals, and every edit is local, so a mis-scan can at worst miss an edit.
19
+ */
20
+ export declare const LOCATION = "__butter$location";
21
+ export declare const EVAL_WRAP = "__butter$js";
22
+ export declare const UNREWRITE = "__butter$unrewrite";
23
+ /**
24
+ * @param {string} src
25
+ * @returns {string}
26
+ */
27
+ export declare function rewriteJs(src: string): string;
@@ -0,0 +1,40 @@
1
+ /** Marker placed after the prefix for module workers: /butter/go/~m/<encoded> */
2
+ export declare const MODULE_WORKER_MARK = "~m/";
3
+ export type UrlRewriterOptions = {
4
+ /**
5
+ * Proxy origin, e.g. "https://proxy.example"
6
+ */
7
+ origin: string;
8
+ /**
9
+ * Path prefix for proxied URLs, e.g. "/butter/go/"
10
+ */
11
+ prefix: string;
12
+ /**
13
+ * Codec name, see codecs.js
14
+ */
15
+ codec: string;
16
+ };
17
+ /**
18
+ * @typedef {object} UrlRewriterOptions
19
+ * @property {string} origin Proxy origin, e.g. "https://proxy.example"
20
+ * @property {string} prefix Path prefix for proxied URLs, e.g. "/butter/go/"
21
+ * @property {string} codec Codec name, see codecs.js
22
+ */
23
+ /**
24
+ * @param {UrlRewriterOptions} options
25
+ */
26
+ export declare function createUrlRewriter({ origin, prefix, codec: codecName }: UrlRewriterOptions): {
27
+ origin: string;
28
+ prefix: string;
29
+ codec: import("./codecs.js").Codec;
30
+ decodeTail: (tail: string) => string | null;
31
+ isProxied: (url: string) => boolean;
32
+ rewrite: (raw: string, base: string | URL, opts?: {
33
+ moduleWorker?: boolean;
34
+ }) => string;
35
+ unrewrite: (url: string) => string;
36
+ rewriteSrcset: (value: string, base: string | URL, map?: (url: string, base: string | URL) => string) => string;
37
+ unrewriteSrcset: (value: any) => string;
38
+ };
39
+ export type UrlRewriter = ReturnType<typeof createUrlRewriter>;
40
+ /** @typedef {ReturnType<typeof createUrlRewriter>} UrlRewriter */
package/lib/cli.js ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * The `butter-col` command line.
3
+ *
4
+ * butter-col server start [options] run OpenResty (foreground)
5
+ * butter-col server stop|reload|test control a running server
6
+ * butter-col fetch-ca download Mozilla's CA bundle
7
+ * butter-col transport add <pkg|file> make a transport servable
8
+ * butter-col transport list
9
+ * butter-col static <outdir> [options] build a serverless deployment
10
+ * butter-col paths print package paths as JSON
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import path from "node:path";
15
+ import {
16
+ createConfigScript,
17
+ createPublicConfig,
18
+ defaultConfigPath,
19
+ distPath,
20
+ findOpenResty,
21
+ luaPath,
22
+ packageRoot,
23
+ renderServerConfig,
24
+ resolveTransport,
25
+ serverPath,
26
+ sitePath,
27
+ transportsPath,
28
+ version,
29
+ } from "./index.js";
30
+
31
+ const HELP = `butter-col ${version}
32
+
33
+ Usage:
34
+ butter-col server start [--port 8080] [--site <dir>] [--config <file>] [--runtime .butter]
35
+ [--transports butter-transports] [--cacert <file>] [--openresty <bin>]
36
+ butter-col server stop|reload|test (same options)
37
+ butter-col fetch-ca [--runtime .butter]
38
+ butter-col transport add <package-or-file> [--name <name>] [--transports butter-transports]
39
+ butter-col transport list [--transports butter-transports]
40
+ butter-col static <outdir> [--config butter.static.json] [--site <dir>] [--transports butter-transports]
41
+ butter-col paths
42
+
43
+ Defaults:
44
+ --config ./butter.config.lua if it exists, else the package default
45
+ --site the package's minimal launcher
46
+ --runtime ./.butter (rendered nginx config, logs, cacert.pem, served transports)
47
+ --transports ./butter-transports (scripts added with "transport add")
48
+ `;
49
+
50
+ function parseArgs(argv) {
51
+ const positional = [];
52
+ const flags = {};
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const arg = argv[i];
55
+ if (arg.startsWith("--")) {
56
+ const [key, inline] = arg.slice(2).split("=", 2);
57
+ if (inline !== undefined) flags[key] = inline;
58
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) flags[key] = argv[++i];
59
+ else flags[key] = true;
60
+ } else {
61
+ positional.push(arg);
62
+ }
63
+ }
64
+ return { positional, flags };
65
+ }
66
+
67
+ const log = (...args) => console.log("[butter-col]", ...args);
68
+
69
+ function fail(message) {
70
+ console.error(`butter-col: ${message}`);
71
+ process.exit(1);
72
+ }
73
+
74
+ function options(flags) {
75
+ const cwd = process.cwd();
76
+ const runtimeDir = path.resolve(cwd, flags.runtime ?? ".butter");
77
+ const localConfig = path.resolve(cwd, "butter.config.lua");
78
+ return {
79
+ cwd,
80
+ runtimeDir,
81
+ listen: flags.port ?? flags.listen ?? 8080,
82
+ siteRoot: flags.site ? path.resolve(cwd, flags.site) : sitePath,
83
+ configFile: flags.config ? path.resolve(cwd, flags.config) : existsSync(localConfig) ? localConfig : defaultConfigPath,
84
+ transportsDir: path.resolve(cwd, flags.transports ?? "butter-transports"),
85
+ cacert: flags.cacert ? path.resolve(cwd, flags.cacert) : path.join(runtimeDir, "cacert.pem"),
86
+ openresty: flags.openresty ?? findOpenResty(cwd),
87
+ };
88
+ }
89
+
90
+ async function fetchCa(target) {
91
+ const source = "https://curl.se/ca/cacert.pem";
92
+ const res = await fetch(source);
93
+ if (!res.ok) throw new Error(`GET ${source} failed: HTTP ${res.status}`);
94
+ const pem = await res.text();
95
+ const count = (pem.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
96
+ if (count < 50) throw new Error(`unexpected CA bundle contents (${count} certificates)`);
97
+ mkdirSync(path.dirname(target), { recursive: true });
98
+ writeFileSync(target, pem);
99
+ log(`wrote ${count} certificates to ${path.relative(process.cwd(), target) || target}`);
100
+ }
101
+
102
+ /** Copy built-in and added transports to where nginx serves /butter/transports/. */
103
+ function stageTransports(opts) {
104
+ const served = path.join(opts.runtimeDir, "www", "butter", "transports");
105
+ mkdirSync(served, { recursive: true });
106
+ const copied = [];
107
+ for (const dir of [transportsPath, opts.transportsDir]) {
108
+ if (!existsSync(dir)) continue;
109
+ for (const file of readdirSync(dir)) {
110
+ if (!/\.js(\.map)?$/.test(file)) continue;
111
+ copyFileSync(path.join(dir, file), path.join(served, file));
112
+ if (file.endsWith(".js")) copied.push(file.replace(/\.js$/, ""));
113
+ }
114
+ }
115
+ return copied;
116
+ }
117
+
118
+ function prepareRuntime(opts) {
119
+ if (!existsSync(path.join(distPath, "sw.js"))) fail(`${distPath} is missing; build the package first (npm run build)`);
120
+ const confDir = path.join(opts.runtimeDir, "conf");
121
+ mkdirSync(confDir, { recursive: true });
122
+ mkdirSync(path.join(opts.runtimeDir, "logs"), { recursive: true });
123
+ const files = renderServerConfig(opts);
124
+ for (const [name, contents] of Object.entries(files)) writeFileSync(path.join(confDir, name), contents);
125
+ return stageTransports(opts);
126
+ }
127
+
128
+ function runOpenResty(opts, extra) {
129
+ const binary = opts.openresty;
130
+ // Windows builds resolve their bundled lualib/ relative to the working directory.
131
+ const cwd = path.isAbsolute(binary) ? path.dirname(binary) : opts.cwd;
132
+ const args = ["-p", opts.runtimeDir + path.sep, "-c", "conf/nginx.conf", ...extra];
133
+ const child = spawn(binary, args, { cwd, stdio: "inherit" });
134
+ child.on("error", (err) => fail(`could not run ${binary}: ${err.message}. Install OpenResty or pass --openresty <path>.`));
135
+ return child;
136
+ }
137
+
138
+ async function server(command, flags) {
139
+ const opts = options(flags);
140
+ if (command === "stop") {
141
+ runOpenResty(opts, ["-s", "stop"]).on("exit", (code) => process.exit(code ?? 0));
142
+ return;
143
+ }
144
+ if (!["start", "reload", "test"].includes(command)) fail(`unknown server command "${command}"`);
145
+ if (!existsSync(opts.cacert)) {
146
+ log("no CA bundle yet; downloading one");
147
+ await fetchCa(opts.cacert);
148
+ }
149
+ const transports = prepareRuntime(opts);
150
+ if (command === "test") {
151
+ runOpenResty(opts, ["-t"]).on("exit", (code) => process.exit(code ?? 0));
152
+ return;
153
+ }
154
+ if (command === "reload") {
155
+ runOpenResty(opts, ["-s", "reload"]).on("exit", (code) => process.exit(code ?? 0));
156
+ return;
157
+ }
158
+
159
+ log(`config ${opts.configFile}`);
160
+ log(`site ${opts.siteRoot}`);
161
+ log(`transports ${transports.join(", ")}`);
162
+ log(`listening http://localhost:${opts.listen}/`);
163
+ const extra = process.platform === "win32" ? [] : ["-g", "daemon off;"];
164
+ const child = runOpenResty(opts, extra);
165
+ const stop = () => runOpenResty(opts, ["-s", "stop"]);
166
+ process.on("SIGINT", stop);
167
+ process.on("SIGTERM", stop);
168
+ child.on("exit", (code) => process.exit(code ?? 0));
169
+ }
170
+
171
+ function transportAdd(specifier, flags) {
172
+ if (!specifier) fail("usage: butter-col transport add <package-or-file>");
173
+ const info = resolveTransport(specifier);
174
+ const name = flags.name ?? info.name;
175
+ const dir = path.resolve(flags.transports ?? "butter-transports");
176
+ mkdirSync(dir, { recursive: true });
177
+ copyFileSync(info.file, path.join(dir, `${name}.js`));
178
+ if (existsSync(info.file + ".map")) copyFileSync(info.file + ".map", path.join(dir, `${name}.js.map`));
179
+ log(`added transport "${name}"${info.packageName ? ` from ${info.packageName}@${info.version}` : ""} → ${path.relative(process.cwd(), dir)}/${name}.js`);
180
+ console.log(`
181
+ Offer it in butter.config.lua:
182
+
183
+ transports = {
184
+ { name = "butter" },
185
+ { name = "${name}", options = { --[[ see the transport's README ]] } },
186
+ },
187
+ `);
188
+ }
189
+
190
+ function transportList(flags) {
191
+ const dir = path.resolve(flags.transports ?? "butter-transports");
192
+ const list = (d) => (existsSync(d) ? readdirSync(d).filter((f) => f.endsWith(".js")).map((f) => f.slice(0, -3)) : []);
193
+ console.log(`built in: ${list(transportsPath).join(", ") || "(none)"}`);
194
+ console.log(`added (${path.relative(process.cwd(), dir) || dir}): ${list(dir).join(", ") || "(none)"}`);
195
+ }
196
+
197
+ function buildStatic(outdir, flags) {
198
+ if (!outdir) fail("usage: butter-col static <outdir> [--config butter.static.json]");
199
+ const out = path.resolve(outdir);
200
+ const configPath = path.resolve(flags.config ?? "butter.static.json");
201
+ if (!existsSync(configPath)) fail(`${configPath} not found (see the docs: "Serverless deployments")`);
202
+ const publicConfig = createPublicConfig(JSON.parse(readFileSync(configPath, "utf8")));
203
+ if (publicConfig.transports.some((t) => t.name === "butter")) {
204
+ log('warning: the "butter" transport needs the OpenResty server and will not work in a static deployment');
205
+ }
206
+
207
+ const butterDir = path.join(out, "butter");
208
+ mkdirSync(butterDir, { recursive: true });
209
+ cpSync(distPath, butterDir, { recursive: true, filter: (src) => !src.includes(`${path.sep}types`) });
210
+ const transportsDir = path.join(butterDir, "transports");
211
+ const extra = path.resolve(flags.transports ?? "butter-transports");
212
+ if (existsSync(extra)) cpSync(extra, transportsDir, { recursive: true });
213
+ writeFileSync(path.join(butterDir, "config.js"), createConfigScript(publicConfig));
214
+
215
+ const site = flags.site ? path.resolve(flags.site) : sitePath;
216
+ cpSync(site, out, { recursive: true, force: false, errorOnExist: false });
217
+ // Static hosts serve 404.html for unknown paths: let it bootstrap /butter/go/ URLs.
218
+ if (!existsSync(path.join(out, "404.html"))) copyFileSync(path.join(distPath, "bootstrap.html"), path.join(out, "404.html"));
219
+
220
+ for (const t of publicConfig.transports) {
221
+ const local = t.url.startsWith(publicConfig.base) ? path.join(out, t.url) : null;
222
+ if (local && !existsSync(local)) log(`warning: transport "${t.name}" points at ${t.url}, which is not in the output`);
223
+ }
224
+ log(`static deployment written to ${out}`);
225
+ log("serve it over HTTPS, with Service-Worker-Allowed: / on /butter/sw.js (or register with scope /butter/)");
226
+ }
227
+
228
+ export async function main(argv) {
229
+ const { positional, flags } = parseArgs(argv);
230
+ const [command, sub, arg] = positional;
231
+ try {
232
+ switch (command) {
233
+ case "server":
234
+ return await server(sub ?? "start", flags);
235
+ case "fetch-ca":
236
+ return await fetchCa(options(flags).cacert);
237
+ case "transport":
238
+ if (sub === "add") return transportAdd(arg, flags);
239
+ if (sub === "list") return transportList(flags);
240
+ return fail('usage: butter-col transport add|list');
241
+ case "static":
242
+ return buildStatic(sub, flags);
243
+ case "paths":
244
+ return console.log(JSON.stringify({ packageRoot, distPath, transportsPath, serverPath, luaPath, sitePath, defaultConfigPath }, null, 2));
245
+ case undefined:
246
+ case "help":
247
+ case "--help":
248
+ return console.log(HELP);
249
+ default:
250
+ if (flags.help || flags.h) return console.log(HELP);
251
+ if (flags.version || flags.v) return console.log(version);
252
+ return fail(`unknown command "${command}"\n\n${HELP}`);
253
+ }
254
+ } catch (err) {
255
+ fail(err instanceof Error ? err.message : String(err));
256
+ }
257
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { ButterPublicConfig } from "@axis-network/butter-transport";
2
+
3
+ /** Root of the installed package. */
4
+ export declare const packageRoot: string;
5
+ /** Built browser files: sw.js, client.js, controller.mjs, formatter.mjs, bootstrap.html, transports/. */
6
+ export declare const distPath: string;
7
+ /** Built-in transport scripts (dist/transports). */
8
+ export declare const transportsPath: string;
9
+ /** OpenResty files: conf templates, lua/, default config.lua. */
10
+ export declare const serverPath: string;
11
+ /** Lua modules (server/lua). */
12
+ export declare const luaPath: string;
13
+ /** Minimal launcher site, used when no --site is given. */
14
+ export declare const sitePath: string;
15
+ /** Default server configuration (server/config.lua). */
16
+ export declare const defaultConfigPath: string;
17
+ export declare const version: string;
18
+
19
+ export interface StaticTransportEntry {
20
+ name: string;
21
+ /** Defaults to /butter/transports/<name>.js */
22
+ url?: string;
23
+ options?: unknown;
24
+ }
25
+
26
+ export interface StaticConfig {
27
+ /** Default "/butter/go/". */
28
+ prefix?: string;
29
+ /** "none" | "plain" | "xor" | "base64" (default "plain"). */
30
+ codec?: string;
31
+ transports: StaticTransportEntry[];
32
+ /** Default: the first transport. */
33
+ defaultTransport?: string;
34
+ }
35
+
36
+ /** Build the public configuration (/butter/config.js contents) without the Lua server. */
37
+ export declare function createPublicConfig(input: StaticConfig): ButterPublicConfig;
38
+
39
+ /** The JavaScript for /butter/config.js. */
40
+ export declare function createConfigScript(publicConfig: ButterPublicConfig): string;
41
+
42
+ export interface RenderOptions {
43
+ listen?: string | number;
44
+ siteRoot?: string;
45
+ configFile?: string;
46
+ runtimeDir: string;
47
+ cacert: string;
48
+ }
49
+
50
+ /** Render the OpenResty configuration files for a runtime directory. */
51
+ export declare function renderServerConfig(options: RenderOptions): {
52
+ "nginx.conf": string;
53
+ "butter-proxy.conf": string;
54
+ "mime.types": string;
55
+ };
56
+
57
+ export interface ResolvedTransport {
58
+ name: string;
59
+ file: string;
60
+ packageName?: string;
61
+ version?: string;
62
+ }
63
+
64
+ /** Locate a transport package's browser script (package.json "butter.transport"). */
65
+ export declare function resolveTransport(specifier: string, from?: string): ResolvedTransport;
66
+
67
+ /** Find an OpenResty binary ($OPENRESTY, .tools/openresty-*, or "openresty"). */
68
+ export declare function findOpenResty(cwd?: string): string;
package/lib/index.js ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @axis-network/butter-col: Node API.
3
+ *
4
+ * import { distPath } from "@axis-network/butter-col";
5
+ * app.use("/butter/", express.static(distPath));
6
+ *
7
+ * Paths to the built files, helpers to produce /butter/config.js for static
8
+ * deployments, nginx config rendering for the OpenResty server, and transport
9
+ * package resolution. The `butter-col` CLI is built on these.
10
+ */
11
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ /** Root of the installed package. */
17
+ export const packageRoot = fileURLToPath(new URL("..", import.meta.url));
18
+ /** Built browser files: sw.js, client.js, controller.mjs, formatter.mjs, bootstrap.html, transports/. */
19
+ export const distPath = path.join(packageRoot, "dist");
20
+ /** Built-in transport scripts (dist/transports). */
21
+ export const transportsPath = path.join(distPath, "transports");
22
+ /** OpenResty files: conf templates, lua/, default config.lua. */
23
+ export const serverPath = path.join(packageRoot, "server");
24
+ /** Lua modules (server/lua). */
25
+ export const luaPath = path.join(serverPath, "lua");
26
+ /** The built-in frontend: served at /app/, and at / when no --site is given. */
27
+ export const sitePath = path.join(packageRoot, "site");
28
+ /** Default server configuration. */
29
+ export const defaultConfigPath = path.join(serverPath, "config.lua");
30
+
31
+ export const version = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")).version;
32
+
33
+ const BASE = "/butter/";
34
+ const CODECS = ["none", "plain", "xor", "base64"];
35
+ const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
36
+
37
+ /**
38
+ * Build the public configuration (what /butter/config.js contains) from a
39
+ * small static description. Used for deployments without the Lua server.
40
+ *
41
+ * @param {{ prefix?: string, codec?: string, transports: Array<{ name: string, url?: string, options?: unknown }>, defaultTransport?: string }} input
42
+ */
43
+ export function createPublicConfig(input) {
44
+ const prefix = input.prefix ?? `${BASE}go/`;
45
+ const codec = input.codec ?? "plain";
46
+ if (!prefix.startsWith(BASE) || !prefix.endsWith("/") || prefix === BASE) {
47
+ throw new Error(`prefix must start with ${BASE}, end with "/" and not equal it`);
48
+ }
49
+ if (!CODECS.includes(codec)) throw new Error(`codec must be one of ${CODECS.join(", ")}`);
50
+ if (!Array.isArray(input.transports) || input.transports.length === 0) throw new Error("transports must list at least one transport");
51
+
52
+ const seen = new Set();
53
+ const transports = input.transports.map((entry, i) => {
54
+ if (!entry || typeof entry.name !== "string" || !NAME.test(entry.name)) throw new Error(`transports[${i}].name is invalid`);
55
+ if (seen.has(entry.name)) throw new Error(`transport "${entry.name}" is listed twice`);
56
+ seen.add(entry.name);
57
+ const out = { name: entry.name, url: entry.url ?? `${BASE}transports/${entry.name}.js` };
58
+ if (entry.options !== undefined) out.options = entry.options;
59
+ return out;
60
+ });
61
+ const defaultTransport = input.defaultTransport ?? transports[0].name;
62
+ if (!seen.has(defaultTransport)) throw new Error(`defaultTransport "${defaultTransport}" is not in transports`);
63
+
64
+ return {
65
+ version,
66
+ prefix,
67
+ base: BASE,
68
+ codec,
69
+ transport: { fetch: `${BASE}fetch`, ws: `${BASE}ws`, cookie: `${BASE}cookie` },
70
+ client: `${BASE}client.js`,
71
+ config: `${BASE}config.js`,
72
+ transports,
73
+ defaultTransport,
74
+ };
75
+ }
76
+
77
+ /** The contents of /butter/config.js for a public configuration. */
78
+ export function createConfigScript(publicConfig) {
79
+ return `self.__butter$config = ${JSON.stringify(publicConfig)};\n`;
80
+ }
81
+
82
+ const slash = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
83
+
84
+ /**
85
+ * Render the OpenResty configuration files.
86
+ *
87
+ * @param {{ listen?: string | number, siteRoot?: string, configFile?: string, runtimeDir: string, cacert: string }} options
88
+ * @returns {{ "nginx.conf": string, "butter-proxy.conf": string, "mime.types": string }}
89
+ */
90
+ export function renderServerConfig(options) {
91
+ const values = {
92
+ LISTEN: String(options.listen ?? 8080),
93
+ LUA_DIR: slash(luaPath),
94
+ CONFIG_FILE: slash(options.configFile ?? defaultConfigPath),
95
+ SITE_ROOT: slash(options.siteRoot ?? sitePath),
96
+ APP_DIR: slash(sitePath),
97
+ DIST_DIR: slash(distPath),
98
+ RUNTIME_DIR: slash(options.runtimeDir),
99
+ CACERT: slash(options.cacert),
100
+ };
101
+ const fill = (template) =>
102
+ template.replace(/\{\{([A-Z_]+)\}\}/g, (match, key) => {
103
+ if (!(key in values)) throw new Error(`unknown placeholder ${match}`);
104
+ return values[key];
105
+ });
106
+ const read = (name) => readFileSync(path.join(serverPath, "conf", name), "utf8");
107
+ return {
108
+ "nginx.conf": fill(read("nginx.conf.template")),
109
+ "butter-proxy.conf": fill(read("butter-proxy.conf.template")),
110
+ "mime.types": read("mime.types"),
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Locate a transport's browser script.
116
+ *
117
+ * Transport packages declare it in package.json:
118
+ * "butter": { "transport": { "name": "bare", "browser": "dist/bare-transport.js" } }
119
+ *
120
+ * @param {string} specifier package name, or a path to a .js file
121
+ * @param {string} [from] directory to resolve packages from (default: cwd)
122
+ * @returns {{ name: string, file: string, packageName?: string, version?: string }}
123
+ */
124
+ export function resolveTransport(specifier, from = process.cwd()) {
125
+ if (/\.m?js$/.test(specifier) && existsSync(path.resolve(from, specifier))) {
126
+ const file = path.resolve(from, specifier);
127
+ return { name: path.basename(file).replace(/\.m?js$/, "").replace(/-transport$/, ""), file };
128
+ }
129
+ const require = createRequire(path.join(from, "noop.js"));
130
+ let manifestPath;
131
+ try {
132
+ manifestPath = require.resolve(`${specifier}/package.json`);
133
+ } catch {
134
+ throw new Error(`cannot find package "${specifier}" (is it installed?)`);
135
+ }
136
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
137
+ const info = manifest.butter && manifest.butter.transport;
138
+ if (!info || typeof info.name !== "string" || typeof info.browser !== "string") {
139
+ throw new Error(`${specifier} is not a butter-col transport (package.json has no "butter.transport" field)`);
140
+ }
141
+ const file = path.join(path.dirname(manifestPath), info.browser);
142
+ if (!existsSync(file)) throw new Error(`${specifier}: ${info.browser} does not exist (was the package built?)`);
143
+ return { name: info.name, file, packageName: manifest.name, version: manifest.version };
144
+ }
145
+
146
+ /**
147
+ * Find an OpenResty binary: $OPENRESTY, then .tools/openresty-* (Windows zips
148
+ * unpacked there) in `cwd` and its parents, then `openresty` on PATH.
149
+ */
150
+ export function findOpenResty(cwd = process.cwd()) {
151
+ if (process.env.OPENRESTY) return process.env.OPENRESTY;
152
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
153
+ const tools = path.join(dir, ".tools");
154
+ if (existsSync(tools)) {
155
+ const versions = readdirSync(tools).filter((d) => d.startsWith("openresty-")).sort().reverse();
156
+ for (const version of versions) {
157
+ for (const exe of ["nginx.exe", "openresty.exe", "bin/openresty"]) {
158
+ const candidate = path.join(tools, version, exe);
159
+ if (existsSync(candidate)) return candidate;
160
+ }
161
+ }
162
+ }
163
+ if (path.dirname(dir) === dir) break;
164
+ }
165
+ return "openresty";
166
+ }