rootless-config 1.4.6 → 1.5.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/package.json +1 -1
- package/src/cli/commands/serve.js +176 -0
- package/src/cli/index.js +4 -0
package/package.json
CHANGED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/*-------- rootless serve — virtual static server --------*/
|
|
2
|
+
// Serves files from projectRoot first, then falls back to .root/assets/
|
|
3
|
+
// Files never need to be copied to root for local development.
|
|
4
|
+
|
|
5
|
+
import http from 'node:http'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
8
|
+
import { createLogger } from '../../utils/logger.js'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PORT = 3000
|
|
11
|
+
|
|
12
|
+
const MIME = {
|
|
13
|
+
'.html': 'text/html; charset=utf-8',
|
|
14
|
+
'.htm': 'text/html; charset=utf-8',
|
|
15
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
16
|
+
'.css': 'text/css; charset=utf-8',
|
|
17
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
18
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
19
|
+
'.cjs': 'application/javascript; charset=utf-8',
|
|
20
|
+
'.ts': 'application/typescript; charset=utf-8',
|
|
21
|
+
'.json': 'application/json; charset=utf-8',
|
|
22
|
+
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
23
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
24
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.jpg': 'image/jpeg',
|
|
27
|
+
'.jpeg': 'image/jpeg',
|
|
28
|
+
'.gif': 'image/gif',
|
|
29
|
+
'.svg': 'image/svg+xml',
|
|
30
|
+
'.ico': 'image/x-icon',
|
|
31
|
+
'.webp': 'image/webp',
|
|
32
|
+
'.avif': 'image/avif',
|
|
33
|
+
'.woff': 'font/woff',
|
|
34
|
+
'.woff2': 'font/woff2',
|
|
35
|
+
'.ttf': 'font/ttf',
|
|
36
|
+
'.otf': 'font/otf',
|
|
37
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
38
|
+
'.pdf': 'application/pdf',
|
|
39
|
+
'.zip': 'application/zip',
|
|
40
|
+
'.map': 'application/json',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getMime(filePath) {
|
|
44
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
45
|
+
return MIME[ext] ?? 'application/octet-stream'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function isFile(p) {
|
|
49
|
+
try {
|
|
50
|
+
return (await stat(p)).isFile()
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Try to resolve a URL path to a real file across multiple search directories.
|
|
58
|
+
* For directory requests also tries index.html / index.htm inside that dir.
|
|
59
|
+
*/
|
|
60
|
+
async function resolveFile(requestPath, searchDirs) {
|
|
61
|
+
// Normalize: remove leading slash, decode
|
|
62
|
+
const rel = requestPath.replace(/^\//, '') || ''
|
|
63
|
+
|
|
64
|
+
for (const dir of searchDirs) {
|
|
65
|
+
// Skip .root itself to avoid serving internal rootless files
|
|
66
|
+
const candidates = rel === ''
|
|
67
|
+
? [
|
|
68
|
+
path.join(dir, 'index.html'),
|
|
69
|
+
path.join(dir, 'index.htm'),
|
|
70
|
+
]
|
|
71
|
+
: [
|
|
72
|
+
path.join(dir, rel),
|
|
73
|
+
path.join(dir, rel, 'index.html'),
|
|
74
|
+
path.join(dir, rel, 'index.htm'),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
if (await isFile(candidate)) return candidate
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default {
|
|
86
|
+
name: 'serve',
|
|
87
|
+
description: 'Virtual static server — serves from root + .root/assets/ without copying files',
|
|
88
|
+
|
|
89
|
+
async handler(args) {
|
|
90
|
+
const logger = createLogger({ verbose: args.verbose ?? false })
|
|
91
|
+
const port = parseInt(args.port ?? DEFAULT_PORT, 10)
|
|
92
|
+
const projectRoot = args.cwd ? path.resolve(args.cwd) : process.cwd()
|
|
93
|
+
const containerPath = path.join(projectRoot, '.root')
|
|
94
|
+
const assetsDir = path.join(containerPath, 'assets')
|
|
95
|
+
const envDir = path.join(containerPath, 'env')
|
|
96
|
+
|
|
97
|
+
// Lookup order: real root first → .root/assets/ → .root/env/
|
|
98
|
+
// This means files physically in root always win.
|
|
99
|
+
const searchDirs = [projectRoot, assetsDir, envDir]
|
|
100
|
+
|
|
101
|
+
const server = http.createServer(async (req, res) => {
|
|
102
|
+
try {
|
|
103
|
+
const url = new URL(req.url, `http://localhost:${port}`)
|
|
104
|
+
const requestPath = decodeURIComponent(url.pathname)
|
|
105
|
+
|
|
106
|
+
// Block access to .root internals
|
|
107
|
+
if (requestPath.startsWith('/.root')) {
|
|
108
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' })
|
|
109
|
+
res.end('Forbidden')
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const filePath = await resolveFile(requestPath, searchDirs)
|
|
114
|
+
|
|
115
|
+
if (filePath) {
|
|
116
|
+
const content = await readFile(filePath)
|
|
117
|
+
res.writeHead(200, {
|
|
118
|
+
'Content-Type': getMime(filePath),
|
|
119
|
+
'Cache-Control': 'no-cache',
|
|
120
|
+
})
|
|
121
|
+
res.end(content)
|
|
122
|
+
|
|
123
|
+
const source = filePath.startsWith(assetsDir)
|
|
124
|
+
? '.root/assets'
|
|
125
|
+
: filePath.startsWith(envDir)
|
|
126
|
+
? '.root/env'
|
|
127
|
+
: 'root'
|
|
128
|
+
logger.info(`200 GET ${requestPath} [${source}]`)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 404 — try to serve custom error page from .root or root
|
|
133
|
+
const notFoundPage = await resolveFile('/404.html', searchDirs)
|
|
134
|
+
?? await resolveFile('/404.htm', searchDirs)
|
|
135
|
+
?? await resolveFile('/404.md', searchDirs)
|
|
136
|
+
|
|
137
|
+
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
138
|
+
if (notFoundPage) {
|
|
139
|
+
res.end(await readFile(notFoundPage))
|
|
140
|
+
} else {
|
|
141
|
+
res.end('<h1>404 Not Found</h1>')
|
|
142
|
+
}
|
|
143
|
+
logger.info(`404 ${requestPath}`)
|
|
144
|
+
|
|
145
|
+
} catch (err) {
|
|
146
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
147
|
+
res.end('Internal Server Error')
|
|
148
|
+
logger.fail(`500 ${req.url}: ${err.message}`)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
server.listen(port, () => {
|
|
153
|
+
logger.success(`Virtual server running at http://localhost:${port}`)
|
|
154
|
+
logger.info('')
|
|
155
|
+
logger.info('Serving from (in order):')
|
|
156
|
+
logger.info(` [1] ${projectRoot}`)
|
|
157
|
+
logger.info(` [2] ${assetsDir}`)
|
|
158
|
+
logger.info(` [3] ${envDir}`)
|
|
159
|
+
logger.info('')
|
|
160
|
+
logger.info('Files in .root/ are served AS IF they are in root.')
|
|
161
|
+
logger.info('No need to run rootless prepare for local development.')
|
|
162
|
+
logger.info('')
|
|
163
|
+
logger.info('Press Ctrl+C to stop.')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// Keep process alive until Ctrl+C
|
|
167
|
+
await new Promise((_resolve, reject) => {
|
|
168
|
+
server.on('error', reject)
|
|
169
|
+
process.on('SIGINT', () => {
|
|
170
|
+
logger.info('\nServer stopped.')
|
|
171
|
+
server.close()
|
|
172
|
+
process.exit(0)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
},
|
|
176
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -25,6 +25,10 @@ async function run(argv) {
|
|
|
25
25
|
sub.option('--no-yes', 'Auto-decline all file override prompts')
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
if (cmd.name === 'serve') {
|
|
29
|
+
sub.option('--port <number>', 'Port to listen on (default: 3000)')
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
sub.option('--verbose', 'Enable verbose output')
|
|
29
33
|
sub.option('--silent', 'Suppress all output')
|
|
30
34
|
|