@madhanmaaz/udane 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.
package/app.js ADDED
@@ -0,0 +1,95 @@
1
+ const path = require("node:path");
2
+ const http = require("node:http");
3
+
4
+ const fastify = require("fastify");
5
+ const tarkine = require("tarkine");
6
+
7
+ const {
8
+ listDirectory,
9
+ resolveDirectory,
10
+ } = require("./helpers/directory.helper");
11
+ const templateEngine = require("./helpers/templateEngine.helper");
12
+ const { PUBLIC_DIR_PREFIX } = require("./constants/index.constant");
13
+
14
+ const noStore = {
15
+ cacheControl: false,
16
+ setHeaders(reply) {
17
+ reply.header("cache-control", "no-store");
18
+ },
19
+ };
20
+
21
+ module.exports = function buildApp(appConfig = {}, fastifyOptions = {}) {
22
+ const app = fastify(fastifyOptions);
23
+
24
+ if (appConfig.cors) {
25
+ app.addHook("onRequest", async (request, reply) => {
26
+ reply.header("access-control-allow-origin", "*");
27
+ });
28
+ }
29
+
30
+ // plugins
31
+ app.decorateReply("render", tarkine.adapters.fastify(templateEngine));
32
+
33
+ // inject script
34
+ app.addHook("onSend", require("./middleware/inject.middleware"));
35
+
36
+ // udane's own assets. This is the first static instance, so it owns the reply decorator.
37
+ app.register(require("@fastify/static"), {
38
+ root: path.join(__dirname, "public"),
39
+ prefix: `/${PUBLIC_DIR_PREFIX}/`,
40
+ });
41
+
42
+ // extra folders from appConfig.mount
43
+ for (const [prefix, root] of Object.entries(appConfig.mount)) {
44
+ app.register(require("@fastify/static"), {
45
+ root,
46
+ prefix: `${prefix}/`,
47
+ decorateReply: false,
48
+ ...noStore,
49
+ });
50
+ }
51
+
52
+ // the project being served
53
+ app.register(require("@fastify/static"), {
54
+ root: appConfig.root,
55
+ index: appConfig.index,
56
+ decorateReply: false,
57
+ ...noStore,
58
+ });
59
+
60
+ app.setErrorHandler((error, request, reply) => {
61
+ const statusCode = error.statusCode || 500;
62
+ const title = http.STATUS_CODES[statusCode];
63
+
64
+ reply.render("/error", {
65
+ PUBLIC_DIR_PREFIX,
66
+ statusCode,
67
+ title,
68
+ });
69
+ });
70
+
71
+ app.setNotFoundHandler(async (request, reply) => {
72
+ const { pathname } = new URL(request.url, "http://localhost");
73
+
74
+ if (request.method === "GET") {
75
+ const folder = resolveDirectory(pathname, appConfig);
76
+
77
+ if (folder) {
78
+ const listing = await listDirectory(folder, pathname);
79
+
80
+ return reply.render("listing", {
81
+ PUBLIC_DIR_PREFIX,
82
+ listing,
83
+ });
84
+ }
85
+ }
86
+
87
+ return reply.render("/error", {
88
+ statusCode: 404,
89
+ title: "Not Found",
90
+ PUBLIC_DIR_PREFIX,
91
+ });
92
+ });
93
+
94
+ return app;
95
+ };
@@ -0,0 +1,47 @@
1
+ // config/udane.config.js
2
+ // Default configuration for udane.
3
+ // Priority (lowest to highest): these defaults < udane.config.js in the user's cwd < CLI flags.
4
+ // Every option is listed here so this file doubles as the reference.
5
+
6
+ module.exports = {
7
+ // "0.0.0.0" exposes the server to the local network (phone testing, other devices).
8
+ // Use "127.0.0.1" to keep it private to this machine.
9
+ host: "0.0.0.0",
10
+
11
+ // Port to listen on. If it is busy, udane tries the next free port.
12
+ port: 5300,
13
+
14
+ // Folder to serve, relative to the current working directory.
15
+ root: ".",
16
+
17
+ // Extra folders served under a URL prefix. Empty by default, because with
18
+ // host "0.0.0.0" everything mounted here is visible to the whole network.
19
+ // Keys are URL prefixes, values are folders ("~" is expanded to the home directory).
20
+ // Example: { "/downloads": "~/Downloads" }
21
+ mount: {},
22
+
23
+ // Entry files tried for a directory, in order.
24
+ index: ["index.html"],
25
+
26
+ // Send "Access-Control-Allow-Origin: *" on every response.
27
+ cors: false,
28
+
29
+ // Swap changed CSS in place without a full page reload.
30
+ cssHotSwap: true,
31
+
32
+ // Milliseconds to wait after the last file change before notifying the browser.
33
+ // Prevents several reloads when an editor writes many files at once.
34
+ debounce: 100,
35
+
36
+ // Paths to watch, relative to root. Empty array means watch the whole root.
37
+ watch: [],
38
+
39
+ // Paths and patterns the watcher ignores.
40
+ ignore: ["node_modules", ".git", ".DS_Store", "*.log"],
41
+
42
+ // Print one line per request.
43
+ logRequests: true,
44
+
45
+ // Print a line when a watched file changes.
46
+ logChanges: true,
47
+ };
@@ -0,0 +1,4 @@
1
+ module.exports = {
2
+ PUBLIC_DIR_PREFIX: "__udane",
3
+ WS_PATH: "/__udane-ws",
4
+ };
@@ -0,0 +1,74 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ // Maps a URL path to a real folder inside a mount or root. Returns null if it is not one.
5
+ function resolveDirectory(pathname, config) {
6
+ let decoded;
7
+ try {
8
+ decoded = decodeURIComponent(pathname);
9
+ } catch {
10
+ return null;
11
+ }
12
+
13
+ // longest mount prefix first, root last
14
+ const targets = [
15
+ ...Object.entries(config.mount)
16
+ .map(([prefix, dir]) => ({ prefix, dir }))
17
+ .sort((a, b) => b.prefix.length - a.prefix.length),
18
+ { prefix: "", dir: config.root },
19
+ ];
20
+
21
+ for (const { prefix, dir } of targets) {
22
+ if (prefix && decoded !== prefix && !decoded.startsWith(`${prefix}/`))
23
+ continue;
24
+
25
+ const full = path.resolve(dir, `.${decoded.slice(prefix.length)}`);
26
+ const inside = path.relative(dir, full);
27
+
28
+ // blocks "../" traversal outside the served folder
29
+ if (inside.startsWith("..") || path.isAbsolute(inside)) continue;
30
+
31
+ try {
32
+ if (fs.statSync(full).isDirectory()) return full;
33
+ } catch {
34
+ // not found in this target, try the next one
35
+ }
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ async function listDirectory(full, pathname) {
42
+ const base = pathname.endsWith("/") ? pathname : `${pathname}/`;
43
+ const items = await fs.promises.readdir(full, { withFileTypes: true });
44
+
45
+ const entries = items
46
+ .filter((item) => !item.name.startsWith("."))
47
+ .map((item) => {
48
+ const isDirectory = item.isDirectory();
49
+ return {
50
+ name: isDirectory ? `${item.name}/` : item.name,
51
+ href:
52
+ base +
53
+ encodeURIComponent(item.name) +
54
+ (isDirectory ? "/" : ""),
55
+ isDirectory,
56
+ };
57
+ })
58
+ .sort(
59
+ (a, b) =>
60
+ Number(b.isDirectory) - Number(a.isDirectory) ||
61
+ a.name.localeCompare(b.name),
62
+ );
63
+
64
+ return {
65
+ parent: base === "/" ? null : base.replace(/[^/]+\/$/, ""),
66
+ base,
67
+ entries,
68
+ };
69
+ }
70
+
71
+ module.exports = {
72
+ resolveDirectory,
73
+ listDirectory,
74
+ };
@@ -0,0 +1,187 @@
1
+ const process = require("node:process");
2
+ const path = require("node:path");
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+
6
+ const { z } = require("zod");
7
+
8
+ const defaults = require("../config/udane.config");
9
+ const { PUBLIC_DIR_PREFIX } = require("../constants/index.constant");
10
+
11
+ const NESTED = ["mount"];
12
+ const CONFIG_NAME = "udane.config.js";
13
+ const RESERVED_PREFIX = `/${PUBLIC_DIR_PREFIX}`;
14
+
15
+ const configSchema = z
16
+ .object({
17
+ host: z.string(),
18
+ port: z.number().int().min(1).max(65535),
19
+ root: z.string(),
20
+ mount: z.record(z.string().startsWith("/"), z.string()),
21
+ index: z.array(z.string()),
22
+ cors: z.boolean(),
23
+ cssHotSwap: z.boolean(),
24
+ debounce: z.number().int().min(0),
25
+ watch: z.array(z.string()),
26
+ ignore: z.array(z.string()),
27
+ logRequests: z.boolean(),
28
+ logChanges: z.boolean(),
29
+ })
30
+ .strict();
31
+
32
+ function isPlainObject(value) {
33
+ return value !== null && typeof value === "object" && !Array.isArray(value);
34
+ }
35
+
36
+ // Drops undefined values so an unset CLI flag never overwrites a real value.
37
+ function clean(obj) {
38
+ return Object.fromEntries(
39
+ Object.entries(obj).filter(([, value]) => value !== undefined),
40
+ );
41
+ }
42
+
43
+ // Looks for udane.config.js inside a folder. Returns { path, config } or null.
44
+ function loadConfigFile(dir) {
45
+ const file = path.join(dir, CONFIG_NAME);
46
+ if (!fs.existsSync(file)) return null;
47
+
48
+ let config;
49
+ try {
50
+ config = require(file);
51
+ } catch (err) {
52
+ throw new Error(`Failed to load ${file}: ${err.message}`);
53
+ }
54
+
55
+ if (!isPlainObject(config)) {
56
+ throw new Error(
57
+ `${file} must export an object (module.exports = { ... })`,
58
+ );
59
+ }
60
+
61
+ return { path: file, config };
62
+ }
63
+
64
+ function mergeConfigs(...sources) {
65
+ const result = {};
66
+
67
+ for (const source of sources) {
68
+ for (const [key, value] of Object.entries(source)) {
69
+ result[key] =
70
+ NESTED.includes(key) && isPlainObject(value)
71
+ ? { ...result[key], ...value }
72
+ : value;
73
+ }
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ function formatIssues(error, files) {
80
+ const where = files.length ? ` (config: ${files.join(", ")})` : "";
81
+ const lines = error.issues.map((issue) => {
82
+ const key =
83
+ issue.code === "unrecognized_keys"
84
+ ? issue.keys.join(", ")
85
+ : issue.path.join(".") || "(root)";
86
+ return ` ${key}: ${issue.message}`;
87
+ });
88
+
89
+ return `Invalid config${where}\n${lines.join("\n")}`;
90
+ }
91
+
92
+ // Expands "~" (Node does not) and resolves to an absolute path.
93
+ function expandPath(target, cwd) {
94
+ let value = target;
95
+
96
+ if (value === "~") {
97
+ value = os.homedir();
98
+ } else if (value.startsWith("~/") || value.startsWith("~\\")) {
99
+ value = path.join(os.homedir(), value.slice(2));
100
+ }
101
+
102
+ return path.resolve(cwd, value);
103
+ }
104
+
105
+ function assertFolder(folder, label) {
106
+ let stat;
107
+
108
+ try {
109
+ stat = fs.statSync(folder);
110
+ } catch {
111
+ throw new Error(`${label} not found: ${folder}`);
112
+ }
113
+
114
+ if (!stat.isDirectory()) {
115
+ throw new Error(`${label} is not a folder: ${folder}`);
116
+ }
117
+ }
118
+
119
+ function normalizeMount(mount, cwd) {
120
+ const result = {};
121
+
122
+ for (const [prefix, target] of Object.entries(mount)) {
123
+ const key = prefix.replace(/\/+$/, "");
124
+
125
+ if (!key) {
126
+ throw new Error(`mount: "/" is not allowed as a prefix`);
127
+ }
128
+
129
+ if (key === RESERVED_PREFIX || key.startsWith(`${RESERVED_PREFIX}/`)) {
130
+ throw new Error(`mount: "${prefix}" is reserved by udane`);
131
+ }
132
+
133
+ const folder = expandPath(target, cwd);
134
+ assertFolder(folder, `mount "${prefix}"`);
135
+ result[key] = folder;
136
+ }
137
+
138
+ return result;
139
+ }
140
+
141
+ function normalize(config, cwd) {
142
+ const root = expandPath(config.root, cwd);
143
+ assertFolder(root, "root");
144
+
145
+ return {
146
+ ...config,
147
+ root,
148
+ mount: normalizeMount(config.mount, cwd),
149
+ // Empty list means watch the whole root.
150
+ watch: config.watch.length
151
+ ? config.watch.map((item) => path.resolve(root, item))
152
+ : [root],
153
+ };
154
+ }
155
+
156
+ // Priority (lowest to highest):
157
+ // defaults < ~/.config/udane.config.js < udane.config.js in cwd < options (CLI flags)
158
+ module.exports = function getConfig(options = {}) {
159
+ const cwd = process.cwd();
160
+
161
+ const files = [
162
+ loadConfigFile(path.join(os.homedir(), ".config")),
163
+ loadConfigFile(cwd),
164
+ ].filter(Boolean);
165
+
166
+ const merged = mergeConfigs(
167
+ defaults,
168
+ ...files.map((file) => file.config),
169
+ clean(options),
170
+ );
171
+
172
+ const result = configSchema.safeParse(merged);
173
+
174
+ if (!result.success) {
175
+ throw new Error(
176
+ formatIssues(
177
+ result.error,
178
+ files.map((file) => file.path),
179
+ ),
180
+ );
181
+ }
182
+
183
+ return Object.freeze({
184
+ ...normalize(result.data, cwd),
185
+ configFiles: files.map((file) => file.path),
186
+ });
187
+ };
@@ -0,0 +1,47 @@
1
+ // Builds the `ignored` function for chokidar from config.ignore.
2
+ // - a pattern without "/" matches any file or folder name ("node_modules", "*.log", ".git")
3
+ // - a pattern with "/" matches a path fragment ("dist/cache")
4
+ // - "*" matches any characters, "?" matches one character
5
+ // Matching ignores the part of the path above the watched root, so serving a
6
+ // project that lives inside a folder like "node_modules" still works.
7
+
8
+ const escape = (text) => text.replace(/[.+^${}()|[\]\\]/g, "\\$&");
9
+
10
+ const toRegex = (pattern) =>
11
+ new RegExp(`^${escape(pattern).replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
12
+
13
+ module.exports = function createIgnore(patterns, roots) {
14
+ const matchers = patterns.map((pattern) => {
15
+ const clean = pattern
16
+ .replace(/\\/g, "/")
17
+ .replace(/^\.?\//, "")
18
+ .replace(/\/+$/, "");
19
+
20
+ return { clean, hasSlash: clean.includes("/"), regex: toRegex(clean) };
21
+ });
22
+
23
+ const bases = roots.map((root) =>
24
+ root.replace(/\\/g, "/").replace(/\/+$/, ""),
25
+ );
26
+
27
+ return (filepath) => {
28
+ let normalized = filepath.replace(/\\/g, "/");
29
+
30
+ const base = bases.find(
31
+ (item) => normalized === item || normalized.startsWith(`${item}/`),
32
+ );
33
+
34
+ if (base) {
35
+ normalized = normalized.slice(base.length);
36
+ }
37
+
38
+ const segments = normalized.split("/").filter(Boolean);
39
+
40
+ return matchers.some(({ clean, hasSlash, regex }) =>
41
+ hasSlash
42
+ ? normalized.endsWith(`/${clean}`) ||
43
+ normalized.includes(`/${clean}/`)
44
+ : segments.some((segment) => regex.test(segment)),
45
+ );
46
+ };
47
+ };
@@ -0,0 +1,10 @@
1
+ const path = require("node:path");
2
+
3
+ const tarkine = require("tarkine");
4
+
5
+ const engine = tarkine.createEngine({
6
+ templateRoot: path.resolve(__dirname, "../views"),
7
+ cache: false
8
+ });
9
+
10
+ module.exports = engine;
package/lib/bin.js ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+
3
+ const process = require("node:process");
4
+ const { parseArgs } = require("node:util");
5
+ const path = require("node:path");
6
+ const fs = require("node:fs");
7
+
8
+ const udane = require("./udane");
9
+ const pkg = require("../package.json");
10
+
11
+ const HELP = `
12
+ udane v${pkg.version}
13
+ Live-reload development server.
14
+
15
+ Usage
16
+ udane [root] [options]
17
+ udane init [--force]
18
+
19
+ Commands
20
+ init Copy the default config file into the current folder
21
+
22
+ Options
23
+ -p, --port <number> Port to listen on
24
+ -H, --host <address> Interface to bind (0.0.0.0 = network, 127.0.0.1 = local only)
25
+ --cors Send Access-Control-Allow-Origin: *
26
+ --debounce <ms> Wait time after the last file change
27
+ --watch <path> Path to watch (repeatable)
28
+ --ignore <pattern> Pattern to ignore (repeatable)
29
+ -f, --force Overwrite an existing config file (init only)
30
+ -h, --help Show this help
31
+ -v, --version Show the version
32
+ `;
33
+
34
+ function readArgs(argv) {
35
+ const { values, positionals } = parseArgs({
36
+ args: argv,
37
+ allowPositionals: true,
38
+ options: {
39
+ port: { type: "string", short: "p" },
40
+ host: { type: "string", short: "H" },
41
+ cors: { type: "boolean" },
42
+ debounce: { type: "string" },
43
+ watch: { type: "string", multiple: true },
44
+ ignore: { type: "string", multiple: true },
45
+ force: { type: "boolean", short: "f" },
46
+ help: { type: "boolean", short: "h" },
47
+ version: { type: "boolean", short: "v" },
48
+ },
49
+ });
50
+
51
+ const number = (value) => (value === undefined ? undefined : Number(value));
52
+ const command = positionals[0] === "init" ? "init" : undefined;
53
+
54
+ return {
55
+ command,
56
+ force: values.force,
57
+ help: values.help,
58
+ version: values.version,
59
+
60
+ // undefined values are dropped by getConfig, so unset flags never override config
61
+ options: {
62
+ root: command ? undefined : positionals[0],
63
+ port: number(values.port),
64
+ host: values.host,
65
+ cors: values.cors,
66
+ debounce: number(values.debounce),
67
+ watch: values.watch,
68
+ ignore: values.ignore,
69
+ },
70
+ };
71
+ }
72
+
73
+ function init(force) {
74
+ const source = path.join(__dirname, "..", "config", "udane.config.js");
75
+ const target = path.join(process.cwd(), "udane.config.js");
76
+
77
+ if (fs.existsSync(target) && !force) {
78
+ throw new Error(
79
+ "udane.config.js already exists (use --force to overwrite)",
80
+ );
81
+ }
82
+
83
+ fs.copyFileSync(source, target);
84
+ console.log(`[+] Created ${target}`);
85
+ }
86
+
87
+ async function main() {
88
+ const { command, force, help, version, options } = readArgs(
89
+ process.argv.slice(2),
90
+ );
91
+
92
+ if (help) return console.log(HELP);
93
+ if (version) return console.log(pkg.version);
94
+ if (command === "init") return init(force);
95
+
96
+ const server = await udane(options);
97
+
98
+ if (server.port !== server.requestedPort) {
99
+ console.log(
100
+ `[+] Port ${server.requestedPort} is busy, using ${server.port}`,
101
+ );
102
+ }
103
+
104
+ console.log(
105
+ `\n[+] udane v${pkg.version} by https://madhanmaaz.netlify.app`,
106
+ );
107
+ console.log(`[+] Root: ${server.config.root}\n`);
108
+
109
+ let closing = false;
110
+ const shutdown = async () => {
111
+ if (closing) return;
112
+ closing = true;
113
+ await server.close();
114
+ process.exit(0);
115
+ };
116
+
117
+ process.on("SIGINT", shutdown);
118
+ process.on("SIGTERM", shutdown);
119
+ }
120
+
121
+ main().catch((error) => {
122
+ // console.log(error);
123
+ console.error(`[-] ${error.message}`);
124
+ process.exit(1);
125
+ });