asljs-server 0.2.0 → 0.2.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.
package/README.md CHANGED
@@ -26,10 +26,11 @@ Or without installing:
26
26
  - `asljs-server --port 8080`
27
27
  - `asljs-server --host 0.0.0.0 --port 8080`
28
28
 
29
- ## JSON API
29
+ ## File API
30
30
 
31
- - `GET /api/json?file=name[.json]` returns the JSON file
32
- - `PUT|POST /api/json?file=name[.json]` writes JSON (pretty-printed)
31
+ - `GET /api/file?path=path` returns the file contents
32
+ - `PUT|POST /api/file?path=path` writes the file
33
+ - `GET /api/files?path=path` lists all files in the directory
33
34
 
34
35
  ## Live reload
35
36
 
package/cli.js CHANGED
@@ -1,3 +1,3 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  import './dist/cli.js';
package/dist/server.js CHANGED
@@ -20,6 +20,36 @@ const safeJsonName = (name) => typeof name === 'string'
20
20
  && /^[a-zA-Z0-9._/-]+$/.test(name)
21
21
  ? name
22
22
  : null;
23
+ const safeRelativeApiPath = (value, { allowEmpty = false } = {}) => {
24
+ if (value === null
25
+ || value === undefined) {
26
+ return allowEmpty
27
+ ? ''
28
+ : null;
29
+ }
30
+ if (typeof value !== 'string')
31
+ return null;
32
+ if (value === '')
33
+ return allowEmpty
34
+ ? ''
35
+ : null;
36
+ if (!/^[a-zA-Z0-9._/-]+$/.test(value))
37
+ return null;
38
+ // Force URL-style separators.
39
+ if (value.includes('\\'))
40
+ return null;
41
+ if (value.startsWith('/'))
42
+ return null;
43
+ const parts = value.split('/');
44
+ for (const part of parts) {
45
+ if (!part
46
+ || part === '.'
47
+ || part === '..') {
48
+ return null;
49
+ }
50
+ }
51
+ return value;
52
+ };
23
53
  function readBody(request, limit) {
24
54
  return new Promise((resolve, reject) => {
25
55
  let size = 0;
@@ -76,50 +106,47 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
76
106
  }, 50);
77
107
  };
78
108
  watchStaticTree(FILES_DIR, () => broadcastReload());
79
- // ---------- JSON API helpers
80
- const jsonFilePath = (name) => {
81
- const base = safeJsonName(name);
82
- if (!base)
83
- return null;
84
- const file = base.endsWith('.json')
85
- ? base
86
- : (base + '.json');
87
- const full = path.join(FILES_DIR, file);
109
+ // ---------- File API helpers
110
+ const fileApiPath = (relativePath) => {
111
+ const full = path.join(FILES_DIR, relativePath);
88
112
  return isPathInside(full, FILES_DIR)
89
113
  ? full
90
114
  : null;
91
115
  };
92
- async function handleJsonApi(request, response, url) {
116
+ async function handleFileApi(request, response, url) {
93
117
  await fsp.mkdir(FILES_DIR, { recursive: true });
94
- const name = url.searchParams.get('file');
95
- const file = jsonFilePath(name);
96
- if (!file) {
97
- return send.badRequest(response, 'Invalid "file" name. Allowed: [a-zA-Z0-9._-] and optional .json');
118
+ const relativePath = safeRelativeApiPath(url.searchParams.get('path'));
119
+ if (!relativePath) {
120
+ return send.badRequest(response, 'Invalid "path". Use a relative path with URL-style separators.');
121
+ }
122
+ const filePath = fileApiPath(relativePath);
123
+ if (!filePath) {
124
+ return send.forbidden(response);
98
125
  }
99
126
  if (request.method === 'GET') {
100
- try {
101
- return send.json(response, 200, await fsp.readFile(file, 'utf8'));
102
- }
103
- catch (e) {
104
- if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT')
105
- return send.notFound(response, 'JSON file not found');
106
- throw e;
107
- }
127
+ return send.file(response, filePath);
108
128
  }
109
129
  if (request.method === 'PUT'
110
130
  || request.method === 'POST') {
111
131
  try {
112
132
  // 1MB limit
113
133
  const body = await readBody(request, 1000000);
114
- let parsed;
115
- try {
116
- parsed = JSON.parse(body);
134
+ await fsp.mkdir(path.dirname(filePath), { recursive: true });
135
+ // Preserve the old behavior for JSON files.
136
+ if (path.extname(filePath).toLowerCase() === '.json') {
137
+ let parsed;
138
+ try {
139
+ parsed = JSON.parse(body);
140
+ }
141
+ catch {
142
+ return send.badRequest(response, 'Invalid JSON');
143
+ }
144
+ await fsp.writeFile(filePath, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
117
145
  }
118
- catch {
119
- return send.badRequest(response, 'Invalid JSON');
146
+ else {
147
+ await fsp.writeFile(filePath, body, 'utf8');
120
148
  }
121
- await fsp.writeFile(file, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
122
- return send.api(response, { file: path.basename(file) });
149
+ return send.api(response, { path: relativePath });
123
150
  }
124
151
  catch (error) {
125
152
  return send.apiError(response, error);
@@ -127,15 +154,60 @@ export function startServer({ root = '.', port = 3000, host = 'localhost', logge
127
154
  }
128
155
  return send.methodNotAllowed(response, 'GET, PUT, POST');
129
156
  }
157
+ async function handleFilesApi(request, response, url) {
158
+ if (request.method !== 'GET') {
159
+ return send.methodNotAllowed(response, 'GET');
160
+ }
161
+ await fsp.mkdir(FILES_DIR, { recursive: true });
162
+ const relativePath = safeRelativeApiPath(url.searchParams.get('path'), { allowEmpty: true });
163
+ if (relativePath === null) {
164
+ return send.badRequest(response, 'Invalid "path". Use a relative directory path.');
165
+ }
166
+ const directoryPath = fileApiPath(relativePath);
167
+ if (!directoryPath) {
168
+ return send.forbidden(response);
169
+ }
170
+ try {
171
+ const stat = await fsp.stat(directoryPath);
172
+ if (!stat.isDirectory()) {
173
+ return send.badRequest(response, '"path" must point to a directory');
174
+ }
175
+ }
176
+ catch (error) {
177
+ if (error
178
+ && typeof error === 'object'
179
+ && 'code' in error
180
+ && error.code === 'ENOENT') {
181
+ return send.notFound(response, 'Directory not found');
182
+ }
183
+ return send.error(response, error);
184
+ }
185
+ try {
186
+ const entries = await fsp.readdir(directoryPath, { withFileTypes: true });
187
+ const files = entries
188
+ .filter(e => e.isFile())
189
+ .map(e => e.name)
190
+ .sort((a, b) => a.localeCompare(b));
191
+ return send.api(response, { path: relativePath,
192
+ files });
193
+ }
194
+ catch (error) {
195
+ return send.apiError(response, error);
196
+ }
197
+ }
130
198
  async function serveStatic(request, response, url) {
131
199
  const pathname = decodeURIComponent(url.pathname);
132
200
  // SSE endpoint
133
201
  if (pathname === '/__events') {
134
202
  return serveSSE(request, response);
135
203
  }
136
- // JSON API: /api/json?file=name[.json]
137
- if (pathname === '/api/json') {
138
- return handleJsonApi(request, response, url);
204
+ // File API: /api/file?path=relative/path.ext
205
+ if (pathname === '/api/file') {
206
+ return handleFileApi(request, response, url);
207
+ }
208
+ // Directory listing API: /api/files?path=relative/dir
209
+ if (pathname === '/api/files') {
210
+ return handleFilesApi(request, response, url);
139
211
  }
140
212
  // Resolve file path
141
213
  let filePath = path.normalize(path.join(FILES_DIR, pathname));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asljs-server",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A lightweight development-time static files server. Supports live-reload. Provides filesystem read-write API.",
5
5
  "type": "module",
6
6
  "main": "server.js",