@myteril/hammock-cli 0.0.0-stage → 1.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/LICENSE +21 -0
- package/README.md +163 -2
- package/README.tur.md +164 -0
- package/bin/hammock.js +6 -0
- package/package.json +30 -3
- package/src/cli.js +210 -0
- package/src/port.js +77 -0
- package/src/server.js +386 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 myteril
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,164 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Hammock
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Turn a folder of JSON files into a working mock API with one command — no backend code, no config files, no dependencies.
|
|
4
|
+
|
|
5
|
+
  
|
|
6
|
+
|
|
7
|
+
**English Documentation** · [Turkish Documentation](README.tur.md)
|
|
8
|
+
|
|
9
|
+
## Local-only warning
|
|
10
|
+
|
|
11
|
+
> [!WARNING]
|
|
12
|
+
> **Hammock is for local development only, and it is not secured.** It has no authentication, no authorization, no CORS control. Never use it in production or on a public server, and never expose it through port forwarding, a reverse proxy, or a tunnel.
|
|
13
|
+
>
|
|
14
|
+
> **Serve a dedicated folder that holds only synthetic mock data.** Every routable `.json` file in that folder, including dot-prefixed hidden files, can be read whenever its route is known or guessed, and the absence of a directory listing is not confidentiality. Do not place secrets, tokens, credentials, private keys, production data, or customer data in the served folder.
|
|
15
|
+
>
|
|
16
|
+
> **Do not start Hammock from your home directory, a real project or repository root, or an untrusted project**, because files such as `package.json` would become routable. Stop the server with Ctrl+C when you are done.
|
|
17
|
+
|
|
18
|
+
## Why Hammock?
|
|
19
|
+
|
|
20
|
+
- **One command, zero setup.** Run `hammock` inside a folder and every JSON file in it becomes an endpoint.
|
|
21
|
+
- **Routes mirror your folders.** Any nesting depth works, and the URL pattern never changes.
|
|
22
|
+
- **No extra packages.** Hammock runs on Node.js built-in modules only.
|
|
23
|
+
- **Ports pick themselves.** Hammock takes the first free port from `3000`; `--port` pins a specific one.
|
|
24
|
+
- **Always current.** Files are re-read on every request, so edits show up without a restart.
|
|
25
|
+
|
|
26
|
+
Hammock fits frontend work: prototype a screen, demo a feature, or hand a client realistic responses before the real API exists.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
Hammock needs Node.js 22 or newer:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
node --version
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Install it globally:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g @myteril/hammock-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Create a folder with a JSON file or two:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
my-project/
|
|
46
|
+
├── users.json
|
|
47
|
+
└── orders/
|
|
48
|
+
└── latest.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"id": 1,
|
|
54
|
+
"name": "Ada Lovelace",
|
|
55
|
+
"role": "developer"
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Start Hammock from that folder:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
cd my-project
|
|
63
|
+
hammock
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
Serving /path/to/my-project
|
|
68
|
+
Listening on http://127.0.0.1:3000
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Both endpoints are now live:
|
|
72
|
+
|
|
73
|
+
- http://127.0.0.1:3000/users
|
|
74
|
+
- http://127.0.0.1:3000/orders/latest
|
|
75
|
+
|
|
76
|
+
Hammock serves the folder you start it from and listens on `127.0.0.1` only, so those endpoints work on your machine alone.
|
|
77
|
+
|
|
78
|
+
## How routes work
|
|
79
|
+
|
|
80
|
+
Hammock appends `.json` to the last segment of the URL, so routes never carry the extension:
|
|
81
|
+
|
|
82
|
+
| File | URL |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `users.json` | `/users` |
|
|
85
|
+
| `orders/latest.json` | `/orders/latest` |
|
|
86
|
+
| `api/v1/products.json` | `/api/v1/products` |
|
|
87
|
+
|
|
88
|
+
- Query parameters are ignored: `/users?role=admin` serves `users.json`.
|
|
89
|
+
- A URL whose last segment already ends in `.json` returns `404`, so request `/users`, not `/users.json`.
|
|
90
|
+
- Nested folders work to any depth, and directory listings are never served.
|
|
91
|
+
- Symbolic links that resolve outside the served folder are not served and return `404`.
|
|
92
|
+
- Every request re-reads the file from disk and loads it fully into memory, so keep mock files reasonably small.
|
|
93
|
+
|
|
94
|
+
## Requests and browser access
|
|
95
|
+
|
|
96
|
+
Hammock is read-only: it never creates, modifies, or deletes files. Read-only behavior prevents writes, not reads, and is not an access-control boundary.
|
|
97
|
+
|
|
98
|
+
| Method | Result |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `GET`, `POST`, `PUT` | The same JSON file is returned |
|
|
101
|
+
| `OPTIONS` | `204` preflight response |
|
|
102
|
+
| Anything else | `405` with an `Allow` header |
|
|
103
|
+
|
|
104
|
+
POST and PUT bodies are read and discarded. A body larger than `1 MiB` is rejected with `413`.
|
|
105
|
+
|
|
106
|
+
> **CORS is permissive, not disabled.** Hammock reflects any single syntactically valid `http` or `https` `Origin` in `Access-Control-Allow-Origin`, always as the exact origin and never as `*`, and it sends `Access-Control-Allow-Credentials: true` for those requests. Credentialed `fetch` requests such as `credentials: 'include'` and `XMLHttpRequest` requests with `withCredentials = true` are therefore supported. Preflight requests approve `GET`, `POST`, `PUT`, and `OPTIONS`; a syntactically valid `Access-Control-Request-Headers` value such as `Content-Type, Authorization` is echoed in `Access-Control-Allow-Headers`, while a missing or invalid value omits that header instead of falling back to `*`. Error responses such as `400`, `404`, `405`, `413`, and `500` carry the same CORS headers, so browser code can read them. A missing, invalid, multiple, `null`, or non-HTTP(S) `Origin` gets no CORS response headers, but the request is still processed normally. Origin reflection is not an origin allowlist: any `http` or `https` page your browser allows to reach this server — including sites you did not create — can read the served JSON, with credentials if it asks for them, and non-browser clients can ignore CORS entirely. Hammock still has no authentication and no authorization and never validates or uses credentials; the browser merely carries them, and the server does not protect them. Cookies are host-scoped and generally ignore ports, so a request to `127.0.0.1:3000` may carry cookies another local service set for `127.0.0.1`, subject to browser cookie policy. `Origin: null`, `file://` pages, sandboxed frames, and non-HTTP(S) origins are unsupported; serve the frontend from a normal local `http` origin instead.
|
|
107
|
+
|
|
108
|
+
## Options
|
|
109
|
+
|
|
110
|
+
| Option | Description |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `-p, --port <number>` | Use a specific port: an integer from `1` to `65535` |
|
|
113
|
+
| `--https` | Serve over HTTPS; requires `--cert` and `--key` |
|
|
114
|
+
| `--cert <path>` | PEM certificate or certificate chain |
|
|
115
|
+
| `--key <path>` | PEM private key |
|
|
116
|
+
| `-h, --help` | Show the help text |
|
|
117
|
+
| `-v, --version` | Show the version number |
|
|
118
|
+
|
|
119
|
+
Without `--port`, Hammock starts at `3000` and moves upward until it finds a free port. With `--port`, a busy port stops startup with an error; drop the option to let Hammock choose, or pick another port. `--cert` and `--key` are valid only together with `--https`, and `--https` fails if either of them is missing.
|
|
120
|
+
|
|
121
|
+
## HTTPS
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
hammock --https --cert ./cert.pem --key ./key.pem
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The certificate must be trusted by your browser and valid for `127.0.0.1`, ideally with `localhost` in its Subject Alternative Names; an untrusted certificate earns a browser warning. Hammock does not create certificates, download them, or contact a certificate authority. If the certificate or key cannot be read or loaded, the server does not start — it never falls back to HTTP.
|
|
128
|
+
|
|
129
|
+
HTTPS protects transport only. It does not make public exposure, proxying, or tunneling safe.
|
|
130
|
+
|
|
131
|
+
> [!IMPORTANT]
|
|
132
|
+
> **Keep certificate and key files outside the served folder, and restrict access to the private key.** Never reuse or commit a production private key; use a certificate dedicated to local development.
|
|
133
|
+
|
|
134
|
+
On macOS or Linux you can tighten the key file permissions:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
chmod 600 key.pem
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
For a locally trusted certificate you can use `mkcert`:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
mkcert -install
|
|
144
|
+
mkcert -cert-file cert.pem -key-file key.pem 127.0.0.1 localhost
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`mkcert -install` installs a local root CA that browsers and applications trust. Run it only on a machine you control and understand.
|
|
148
|
+
|
|
149
|
+
## Troubleshooting
|
|
150
|
+
|
|
151
|
+
| Problem | Fix |
|
|
152
|
+
|---|---|
|
|
153
|
+
| `404` although the file exists | The URL probably ends in `.json` or the path is wrong; request `/orders/latest`, not `/orders/latest.json`. |
|
|
154
|
+
| `404` for a symlinked file | The link resolves outside the served folder, so Hammock refuses to serve it. |
|
|
155
|
+
| Port already in use | The `--port` value is taken; omit `--port` or choose a different port. |
|
|
156
|
+
| Browser certificate warning | The certificate is not trusted or does not cover `127.0.0.1`; check trust and Subject Alternative Names. |
|
|
157
|
+
| `413` | A POST or PUT body is larger than `1 MiB`. |
|
|
158
|
+
| `405` | The method is not `GET`, `POST`, `PUT`, or `OPTIONS`. |
|
|
159
|
+
| No CORS headers in `curl` | No valid `Origin` header was sent; this is expected and the request was still served. |
|
|
160
|
+
| Credentialed request fails with a CORS error | Serve the page from a normal `http` or `https` origin, not `file://` or a sandboxed `null` origin, and check that the response carries the exact reflected `Origin` plus `Access-Control-Allow-Credentials: true`. |
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
Hammock is released under the MIT License. See [LICENSE](LICENSE) for the full text.
|
package/README.tur.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Hammock
|
|
2
|
+
|
|
3
|
+
> Bir JSON klasörünü tek komutla çalışan bir mock API'ye dönüştürün — arka uç kodu yok, yapılandırma dosyası yok, bağımlılık yok.
|
|
4
|
+
|
|
5
|
+
  
|
|
6
|
+
|
|
7
|
+
[English Documentation](README.md) · **Türkçe Belgeler**
|
|
8
|
+
|
|
9
|
+
## Yerel kullanım uyarısı
|
|
10
|
+
|
|
11
|
+
> [!WARNING]
|
|
12
|
+
> **Hammock yalnızca yerel geliştirme içindir.** Kimlik doğrulama yoktur, yetkilendirme, CORS denetimi yoktur. Üretim ortamında veya genel erişime açık bir sunucuda asla kullanmayın; port yönlendirme, ters proxy ya da tünel üzerinden asla genel erişime açmayın.
|
|
13
|
+
>
|
|
14
|
+
> **Yalnızca sentetik mock verisi barındıran ayrı bir klasör sunun.** O klasördeki adreslenebilir her `.json` dosyası — nokta ile başlayan gizli dosyalar dahil — adresi bilindiğinde veya tahmin edildiğinde okunabilir; dizin listelemesinin olmaması gizlilik anlamına gelmez. Sunulan klasöre gizli bilgiler, token'lar, kimlik bilgileri, özel anahtarlar, üretim verisi ya da müşteri verisi koymayın.
|
|
15
|
+
>
|
|
16
|
+
> **Hammock'u ev dizininizden, gerçek bir proje ya da depo kökünden veya güvenmediğiniz bir projeden başlatmayın**, çünkü `package.json` gibi dosyalar da adreslenebilir hale gelir. İşiniz bittiğinde sunucuyu Ctrl+C ile durdurun.
|
|
17
|
+
|
|
18
|
+
## Neden Hammock?
|
|
19
|
+
|
|
20
|
+
- **Tek komut, sıfır kurulum.** Bir klasörün içinde `hammock` komutunu çalıştırın; içindeki her JSON dosyası bir uç noktaya dönüşür.
|
|
21
|
+
- **Adresler klasörlerinizi yansıtır.** Adres derinliği iç içe klasörleri ifade eder ve URL düzeni hiç değişmez.
|
|
22
|
+
- **Ek paket gerekmez.** Hammock yalnızca Node.js'in yerleşik modülleriyle çalışır.
|
|
23
|
+
- **Portu kendisi seçer.** Hammock `3000` portundan başlayarak ilk boş portu alır. İsterseniz `--port`komutuyla belirli bir portu sabitler.
|
|
24
|
+
- **Her zaman güncel.** Dosyalar her istekte yeniden okunur, bu yüzden değişiklikler yeniden başlatma gerektirmeden görünür.
|
|
25
|
+
|
|
26
|
+
Hammock ön yüz çalışmalarına uygundur: bir ekranı prototipleyin, bir özelliğin ön gösterimini (demo) oluşturun ya da gerçek API ortaya çıkmadan müşteriye gerçekçi yanıtlar verin.
|
|
27
|
+
|
|
28
|
+
## Hızlı başlangıç
|
|
29
|
+
|
|
30
|
+
Hammock için Node.js 22 sürümü veya daha yenisi gerekir:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
node --version
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Global olarak kurun:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g @myteril/hammock-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Bir iki JSON dosyası olan bir klasör oluşturun:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
projem/
|
|
46
|
+
├── kullanicilar.json
|
|
47
|
+
└── siparisler/
|
|
48
|
+
└── son.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"id": 1,
|
|
54
|
+
"isim": "Ada Lovelace",
|
|
55
|
+
"rol": "gelistirici"
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Hammock'u o klasörden başlatın:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
cd projem
|
|
63
|
+
hammock
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
Serving /path/to/my-project
|
|
68
|
+
Listening on http://127.0.0.1:3000
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
İki uç nokta artık çalışıyor:
|
|
72
|
+
|
|
73
|
+
- http://127.0.0.1:3000/kullanicilar
|
|
74
|
+
- http://127.0.0.1:3000/siparisler/son
|
|
75
|
+
|
|
76
|
+
Hammock başlatıldığı klasörü sunar ve yalnızca `127.0.0.1` üzerinde dinler, bu yüzden bu uç noktalar yalnızca sizin makinenizde çalışır.
|
|
77
|
+
|
|
78
|
+
## Adresler nasıl çalışır?
|
|
79
|
+
|
|
80
|
+
Hammock, URL'nin son bölümüne `.json` ekler, bu yüzden adreslerde uzantı yer almaz:
|
|
81
|
+
|
|
82
|
+
| Dosya | URL |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `kullanicilar.json` | `/kullanicilar` |
|
|
85
|
+
| `siparisler/son.json` | `/siparisler/son` |
|
|
86
|
+
| `api/v1/urunler.json` | `/api/v1/urunler` |
|
|
87
|
+
|
|
88
|
+
- Sorgu parametreleri yok sayılır: `/kullanicilar?rol=admin` isteği `kullanicilar.json` dosyasını sunar.
|
|
89
|
+
- Son bölümü `.json` ile biten bir URL `404` döndürür; yani `/kullanicilar.json` değil `/kullanicilar` isteyin.
|
|
90
|
+
- İç içe klasörler her derinlikte çalışır ve dizin listelemesi hiçbir zaman sunulmaz.
|
|
91
|
+
- Sunulan klasörün dışına çıkan sembolik bağlantılar sunulmaz ve `404` döndürür.
|
|
92
|
+
- Her istek dosyayı diskten yeniden okur ve tamamen belleğe alır, bu yüzden mock dosyaları makul boyutta tutun.
|
|
93
|
+
|
|
94
|
+
## İstekler ve tarayıcı erişimi
|
|
95
|
+
|
|
96
|
+
Hammock sadece okur: hiçbir dosyayı oluşturmaz, değiştirmez veya silmez.
|
|
97
|
+
|
|
98
|
+
| Yöntem | Sonuç |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `GET`, `POST`, `PUT` | Aynı JSON dosyası döndürülür |
|
|
101
|
+
| `OPTIONS` | `204` ön kontrol yanıtı |
|
|
102
|
+
| Diğerleri | `Allow` başlığıyla `405` |
|
|
103
|
+
|
|
104
|
+
POST ve PUT gövdeleri okunur ve görmezden gelinir. `1 MiB` değerinden büyük bir gövde `413` ile reddedilir.
|
|
105
|
+
|
|
106
|
+
> **CORS olabildiğince serbest bırakılmıştır.** Hammock, sözdizimsel olarak geçerli tek bir `http` veya `https` `Origin` değerini `Access-Control-Allow-Origin` başlığında her zaman tam kaynak olarak yansıtır ve asla `*` kullanmaz; bu istekler için `Access-Control-Allow-Credentials: true` başlığını da gönderir. Böylece `credentials: 'include'` gibi kimlik bilgisi içeren `fetch` istekleri ve `withCredentials = true` kullanan `XMLHttpRequest` istekleri desteklenir. Ön kontrol istekleri `GET`, `POST`, `PUT` ve `OPTIONS` yöntemlerini onaylar; `Content-Type, Authorization` gibi sözdizimsel olarak geçerli bir `Access-Control-Request-Headers` değeri `Access-Control-Allow-Headers` başlığında yansıtılır, eksik veya geçersiz olduğunda ise bu başlık `*` yerine hiç gönderilmez. `400`, `404`, `405`, `413` ve `500` gibi hata yanıtları da aynı CORS başlıklarını taşır, böylece tarayıcı kodu bu yanıtları okuyabilir. Eksik, geçersiz, birden çok, `null` veya HTTP(S) dışı bir `Origin` için CORS yanıt başlığı gönderilmez, ancak istek yine normal şekilde işlenir. Kaynak yansıtma bir kaynak izin listesi değildir: tarayıcınızın bu sunucuya erişmesine izin verdiği her `http` veya `https` sayfası, sizin oluşturmadığınız siteler dâhil, sunulan JSON'u okuyabilir; isterse kimlik bilgileriyle de okuyabilir ve tarayıcı olmayan istemciler CORS'u tamamen yok sayabilir. Hammock'ta kimlik doğrulama ve yetkilendirme yoktur; kimlik bilgilerini hiçbir zaman doğrulamaz veya kullanmaz. Kimlik bilgilerini yalnızca tarayıcı taşır ve sunucu bunları korumaz. Çerezler ana makineye (host) göre gönderilir ve genellikle portları yok sayar; bu yüzden `127.0.0.1:3000` adresine yapılan bir istek, başka bir yerel servisin `127.0.0.1` için ayarladığı çerezleri taşıyabilir; tarayıcı çerez ilkesi yine de geçerlidir. `Origin: null`, `file://` sayfaları, korumalı alan (sandbox) çerçeveleri ve HTTP(S) dışı kaynaklar desteklenmez; ön yüzü bunun yerine normal bir yerel `http` kaynağından sunun.
|
|
107
|
+
|
|
108
|
+
## Seçenekler
|
|
109
|
+
|
|
110
|
+
| Seçenek | Açıklama |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `-p, --port <number>` | Belirli bir port kullanır: `1` ile `65535` arasında bir tam sayı |
|
|
113
|
+
| `--https` | HTTPS üzerinden sunar; `--cert` ve `--key` gerektirir |
|
|
114
|
+
| `--cert <path>` | PEM sertifikası veya sertifika zinciri |
|
|
115
|
+
| `--key <path>` | PEM özel anahtarı |
|
|
116
|
+
| `-h, --help` | Yardım metnini gösterir |
|
|
117
|
+
| `-v, --version` | Sürüm numarasını gösterir |
|
|
118
|
+
|
|
119
|
+
`--port` verilmezse Hammock `3000` portundan başlar ve boş bir port bulana kadar yukarı doğru ilerler. `--port` verildiğinde dolu bir port başlatmayı hatayla durdurur; byle bir durumda seçeneği kaldırıp Hammock'un portu seçmesine izin verin veya başka bir port seçin. `--cert` ve `--key` yalnızca `--https` ile birlikte geçerlidir ve bunlardan biri eksikse `--https` başarısız olur.
|
|
120
|
+
|
|
121
|
+
## HTTPS
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
hammock --https --cert ./cert.pem --key ./key.pem
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Sertifika tarayıcınız tarafından güvenilir olmalı ve `127.0.0.1` için geçerli olmalıdır; `Subject Alternative Names` alanında `localhost` bulunması ideal olur. Hammock sertifika oluşturmaz, indirmez ve bir sertifika otoritesiyle iletişim kurmaz. Sertifika veya anahtar okunamaz ya da yüklenemezse sunucu başlamaz; hiçbir zaman HTTP'ye geri dönmez.
|
|
128
|
+
|
|
129
|
+
HTTPS yalnızca taşıma katmanını korur. Genel erişime açmayı, proxy kullanmayı veya tünellemeyi güvenli hale getirmez.
|
|
130
|
+
|
|
131
|
+
> [!IMPORTANT]
|
|
132
|
+
> **Sertifika ve anahtar dosyalarını sunulan klasörün dışında tutun ve özel anahtara erişimi kısıtlayın.** Üretim özel anahtarını asla yeniden kullanmayın veya sürüm kontrolüne eklemeyin; yerel geliştirmeye özel bir sertifika kullanın.
|
|
133
|
+
|
|
134
|
+
macOS veya Linux'ta anahtar dosyasının izinlerini sıkılaştırabilirsiniz:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
chmod 600 key.pem
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Yerel olarak güvenilen bir sertifika için `mkcert` kullanabilirsiniz:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
mkcert -install
|
|
144
|
+
mkcert -cert-file cert.pem -key-file key.pem 127.0.0.1 localhost
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`mkcert -install` tarayıcıların ve uygulamaların güvendiği yerel bir kök sertifika otoritesi kurar. Bu komutu yalnızca sahibi olduğunuz ve anladığınız bir makinede çalıştırın.
|
|
148
|
+
|
|
149
|
+
## Sorun giderme
|
|
150
|
+
|
|
151
|
+
| Sorun | Çözüm |
|
|
152
|
+
|---|---|
|
|
153
|
+
| Dosya var olduğu halde `404` | URL muhtemelen `.json` ile bitiyor ya da yol yanlış; `/siparisler/son.json` değil `/siparisler/son`'a gitmeyi deneyin. |
|
|
154
|
+
| Sembolik bağlantılı dosyada `404` | Bağlantı sunulan klasörün dışına çıkıyor, bu yüzden Hammock onu sunmayı reddeder. |
|
|
155
|
+
| Port zaten kullanımda | `--port`ile sabitlenen port kullanılıyor; `--port` seçeneğini kaldırın veya farklı bir port seçin. |
|
|
156
|
+
| Tarayıcı sertifika uyarısı | Sertifika güvenilir değil ya da `127.0.0.1` adresini kapsamıyor; güveni ve `Subject Alternative Names` alanını kontrol edin. |
|
|
157
|
+
| `413` | Bir POST veya PUT gövdesi `1 MiB` değerinden büyük. |
|
|
158
|
+
| `405` | Yöntem `GET`, `POST`, `PUT` veya `OPTIONS` değil. |
|
|
159
|
+
| `curl` çıktısında CORS başlığı yok | Geçerli bir `Origin` başlığı gönderilmedi; bu beklenen bir durumdur ve istek yine de sunulur. |
|
|
160
|
+
| Kimlik bilgisi içeren istek CORS hatasıyla başarısız oluyor | Sayfayı `file://` ya da korumalı alan (sandbox) kaynaklı `null` bir kaynaktan değil, normal bir `http` veya `https` kaynağından sunun ve yanıtta tam yansıtılmış `Origin` ile `Access-Control-Allow-Credentials: true` başlığının bulunduğunu doğrulayın. |
|
|
161
|
+
|
|
162
|
+
## Lisans
|
|
163
|
+
|
|
164
|
+
Hammock MIT Lisansı ile yayımlanır. Tam metin için [LICENSE](LICENSE) dosyasına bakın.
|
package/bin/hammock.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myteril/hammock-cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "A zero-dependency CLI that serves JSON files from the current folder as a local mock API.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"hammock": "bin/hammock.js"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"src/",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"keywords": [
|
|
26
|
+
"api",
|
|
27
|
+
"cli",
|
|
28
|
+
"development",
|
|
29
|
+
"json",
|
|
30
|
+
"mock",
|
|
31
|
+
"server"
|
|
32
|
+
]
|
|
6
33
|
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const tls = require('node:tls');
|
|
6
|
+
const { parseArgs } = require('node:util');
|
|
7
|
+
|
|
8
|
+
const pkg = require('../package.json');
|
|
9
|
+
|
|
10
|
+
const HELP = `Usage: hammock [options]
|
|
11
|
+
|
|
12
|
+
Start a read-only mock JSON API for the current folder.
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
-p, --port <number> Port to use (1-65535); default: first free port from 3000
|
|
16
|
+
--https Serve over HTTPS (requires --cert and --key)
|
|
17
|
+
--cert <path> PEM certificate or certificate chain
|
|
18
|
+
--key <path> PEM private key
|
|
19
|
+
-h, --help Show this help
|
|
20
|
+
-v, --version Show the version
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
const SERVER_LOAD_ERROR = 'Unable to load the server implementation.';
|
|
24
|
+
const HTTPS_CREATE_ERROR =
|
|
25
|
+
'Unable to create the HTTPS server. Check the certificate and key files.';
|
|
26
|
+
|
|
27
|
+
class CliError extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'CliError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sanitize(value, limit = 500) {
|
|
35
|
+
const text = String(value ?? '')
|
|
36
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
|
37
|
+
.trim();
|
|
38
|
+
return text.length > limit ? `${text.slice(0, limit - 3)}...` : text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatArgumentError(error) {
|
|
42
|
+
if (error && error.code === 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL') {
|
|
43
|
+
return 'Positional arguments are not supported.';
|
|
44
|
+
}
|
|
45
|
+
return sanitize(error && error.message) || 'Invalid arguments.';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parsePort(value) {
|
|
49
|
+
if (value === undefined) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
if (!/^[1-9][0-9]{0,4}$/.test(value) || Number(value) > 65535) {
|
|
53
|
+
throw new CliError('Invalid value for --port. Use an integer from 1 to 65535.');
|
|
54
|
+
}
|
|
55
|
+
return Number(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseOptions(args) {
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = parseArgs({
|
|
62
|
+
args,
|
|
63
|
+
allowPositionals: false,
|
|
64
|
+
strict: true,
|
|
65
|
+
options: {
|
|
66
|
+
port: { type: 'string', short: 'p' },
|
|
67
|
+
https: { type: 'boolean' },
|
|
68
|
+
cert: { type: 'string' },
|
|
69
|
+
key: { type: 'string' },
|
|
70
|
+
help: { type: 'boolean', short: 'h' },
|
|
71
|
+
version: { type: 'boolean', short: 'v' }
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new CliError(formatArgumentError(error));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const values = parsed.values;
|
|
79
|
+
if (values.help) {
|
|
80
|
+
return { action: 'help' };
|
|
81
|
+
}
|
|
82
|
+
if (values.version) {
|
|
83
|
+
return { action: 'version' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const hasCert = values.cert !== undefined;
|
|
87
|
+
const hasKey = values.key !== undefined;
|
|
88
|
+
if (values.https) {
|
|
89
|
+
if (!values.cert || !values.key) {
|
|
90
|
+
throw new CliError('--https requires both --cert and --key.');
|
|
91
|
+
}
|
|
92
|
+
} else if (hasCert || hasKey) {
|
|
93
|
+
throw new CliError('--cert and --key require --https.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
action: 'serve',
|
|
98
|
+
port: parsePort(values.port),
|
|
99
|
+
https: Boolean(values.https),
|
|
100
|
+
cert: values.cert,
|
|
101
|
+
key: values.key
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readPem(label, filePath) {
|
|
106
|
+
try {
|
|
107
|
+
return fs.readFileSync(path.resolve(filePath));
|
|
108
|
+
} catch {
|
|
109
|
+
throw new CliError(`Unable to read the ${label} file.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateTlsMaterial(cert, key) {
|
|
114
|
+
try {
|
|
115
|
+
tls.createSecureContext({ cert, key });
|
|
116
|
+
} catch {
|
|
117
|
+
throw new CliError(HTTPS_CREATE_ERROR);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function loadExport(modulePath, exportName) {
|
|
122
|
+
let loaded;
|
|
123
|
+
try {
|
|
124
|
+
loaded = require(modulePath);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new CliError(SERVER_LOAD_ERROR);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const fn = typeof loaded === 'function' ? loaded : loaded && loaded[exportName];
|
|
130
|
+
if (typeof fn !== 'function') {
|
|
131
|
+
throw new CliError(SERVER_LOAD_ERROR);
|
|
132
|
+
}
|
|
133
|
+
return fn;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function start(options, stdout) {
|
|
137
|
+
const rootDir = process.cwd();
|
|
138
|
+
const cert = options.https ? readPem('certificate', options.cert) : undefined;
|
|
139
|
+
const key = options.https ? readPem('key', options.key) : undefined;
|
|
140
|
+
if (options.https) {
|
|
141
|
+
validateTlsMaterial(cert, key);
|
|
142
|
+
}
|
|
143
|
+
const createServer = loadExport('./server', 'createServer');
|
|
144
|
+
const listen = loadExport('./port', 'listen');
|
|
145
|
+
|
|
146
|
+
let server;
|
|
147
|
+
try {
|
|
148
|
+
server = await createServer({
|
|
149
|
+
rootDir,
|
|
150
|
+
https: options.https,
|
|
151
|
+
cert,
|
|
152
|
+
key
|
|
153
|
+
});
|
|
154
|
+
} catch {
|
|
155
|
+
throw new CliError(
|
|
156
|
+
options.https
|
|
157
|
+
? HTTPS_CREATE_ERROR
|
|
158
|
+
: 'Unable to create the server.'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await listen(server, options.port);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (server.listening) {
|
|
166
|
+
server.close();
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const address = server.address();
|
|
172
|
+
if (!address || address.address !== '127.0.0.1') {
|
|
173
|
+
server.close();
|
|
174
|
+
throw new CliError('The server did not bind to 127.0.0.1.');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const port = address.port;
|
|
178
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
179
|
+
server.close();
|
|
180
|
+
throw new CliError('The server did not report a valid port.');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
stdout.write(`Serving ${sanitize(rootDir)}\n`);
|
|
184
|
+
stdout.write(`Listening on ${options.https ? 'https' : 'http'}://127.0.0.1:${port}\n`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function main(argv = process.argv.slice(2), streams = process) {
|
|
188
|
+
try {
|
|
189
|
+
const options = parseOptions(argv);
|
|
190
|
+
if (options.action === 'help') {
|
|
191
|
+
streams.stdout.write(HELP);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (options.action === 'version') {
|
|
195
|
+
streams.stdout.write(`${pkg.version}\n`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
await start(options, streams.stdout);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const message = sanitize(error && error.message) || 'Unexpected error.';
|
|
201
|
+
streams.stderr.write(`hammock: ${message}\n`);
|
|
202
|
+
process.exitCode = 1;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (require.main === module) {
|
|
207
|
+
main();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = { main };
|
package/src/port.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const HOST = '127.0.0.1';
|
|
4
|
+
const FIRST_PORT = 3000;
|
|
5
|
+
const LAST_PORT = 65535;
|
|
6
|
+
|
|
7
|
+
function bind(server, port) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
function onError(error) {
|
|
10
|
+
server.off('listening', onListening);
|
|
11
|
+
reject(error);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function onListening() {
|
|
15
|
+
server.off('error', onError);
|
|
16
|
+
resolve();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
server.once('error', onError);
|
|
20
|
+
server.once('listening', onListening);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
server.listen(port, HOST);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
server.off('error', onError);
|
|
26
|
+
server.off('listening', onListening);
|
|
27
|
+
reject(error);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isValidPort(port) {
|
|
33
|
+
return Number.isInteger(port) && port >= 1 && port <= LAST_PORT;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function bindError(error, port) {
|
|
37
|
+
if (error && error.code === 'EACCES') {
|
|
38
|
+
return new Error(`Permission denied while binding to ${HOST}:${port}.`);
|
|
39
|
+
}
|
|
40
|
+
return new Error(`Unable to bind to ${HOST}:${port}.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function listen(server, requestedPort) {
|
|
44
|
+
if (requestedPort !== undefined) {
|
|
45
|
+
if (!isValidPort(requestedPort)) {
|
|
46
|
+
throw new Error('The port must be an integer from 1 to 65535.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await bind(server, requestedPort);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error && error.code === 'EADDRINUSE') {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Port ${requestedPort} is already in use. Omit --port to let Hammock choose a free port, or choose a different port.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
throw bindError(error, requestedPort);
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (let port = FIRST_PORT; port <= LAST_PORT; port += 1) {
|
|
63
|
+
try {
|
|
64
|
+
await bind(server, port);
|
|
65
|
+
return;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error && error.code === 'EADDRINUSE') {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
throw bindError(error, port);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
throw new Error(`No free port is available between ${FIRST_PORT} and ${LAST_PORT}.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { listen };
|
package/src/server.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const http = require('node:http');
|
|
5
|
+
const https = require('node:https');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const BODY_LIMIT = 1024 * 1024;
|
|
9
|
+
const ALLOWED_METHODS = 'GET, POST, PUT, OPTIONS';
|
|
10
|
+
const SERVING_METHODS = new Set(['GET', 'POST', 'PUT']);
|
|
11
|
+
const ERROR_MESSAGES = {
|
|
12
|
+
400: 'Bad request.',
|
|
13
|
+
404: 'Not found.',
|
|
14
|
+
405: 'Method not allowed.',
|
|
15
|
+
413: 'Payload too large.',
|
|
16
|
+
500: 'Internal server error.'
|
|
17
|
+
};
|
|
18
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/;
|
|
19
|
+
const RAW_UNSAFE_SEQUENCE = /%(?:2f|5c|00)/i;
|
|
20
|
+
const HEADER_LIST = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+(?:[ \t]*,[ \t]*[!#$%&'*+\-.^_`|~0-9A-Za-z]+)*$/;
|
|
21
|
+
|
|
22
|
+
function allowHeaders(req) {
|
|
23
|
+
const requested = req.headers['access-control-request-headers'];
|
|
24
|
+
if (
|
|
25
|
+
typeof requested === 'string' &&
|
|
26
|
+
requested.length <= 1024 &&
|
|
27
|
+
HEADER_LIST.test(requested)
|
|
28
|
+
) {
|
|
29
|
+
return requested;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function requestOrigin(req) {
|
|
35
|
+
const rawHeaders = req && req.rawHeaders;
|
|
36
|
+
if (!Array.isArray(rawHeaders)) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let count = 0;
|
|
41
|
+
let origin;
|
|
42
|
+
for (let index = 0; index < rawHeaders.length; index += 2) {
|
|
43
|
+
const name = rawHeaders[index];
|
|
44
|
+
if (typeof name === 'string' && name.toLowerCase() === 'origin') {
|
|
45
|
+
count += 1;
|
|
46
|
+
origin = rawHeaders[index + 1];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (count !== 1 || typeof origin !== 'string') {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
if (origin.length === 0 || origin.length > 1024) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
if (/\s/.test(origin) || origin.includes(',') || CONTROL_CHARACTERS.test(origin)) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = new URL(origin);
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
if (parsed.origin !== origin) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return origin;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function responseHeaders(req) {
|
|
78
|
+
const headers = {
|
|
79
|
+
'Cache-Control': 'no-store',
|
|
80
|
+
'Vary': 'Origin, Access-Control-Request-Headers',
|
|
81
|
+
'X-Content-Type-Options': 'nosniff'
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const origin = requestOrigin(req);
|
|
85
|
+
if (origin !== undefined && req.headers) {
|
|
86
|
+
const allowedHeaders = allowHeaders(req);
|
|
87
|
+
if (allowedHeaders !== undefined) {
|
|
88
|
+
headers['Access-Control-Allow-Headers'] = allowedHeaders;
|
|
89
|
+
}
|
|
90
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
91
|
+
headers['Access-Control-Allow-Methods'] = ALLOWED_METHODS;
|
|
92
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return headers;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sendJson(res, status, message, headers, extraHeaders = {}) {
|
|
99
|
+
if (res.writableEnded || res.destroyed) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (res.headersSent) {
|
|
103
|
+
res.end();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const body = Buffer.from(JSON.stringify({ error: message }));
|
|
108
|
+
res.writeHead(status, {
|
|
109
|
+
...headers,
|
|
110
|
+
...extraHeaders,
|
|
111
|
+
'Content-Length': body.length,
|
|
112
|
+
'Content-Type': 'application/json; charset=utf-8'
|
|
113
|
+
});
|
|
114
|
+
res.end(body);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sendError(res, status, headers, extraHeaders) {
|
|
118
|
+
sendJson(res, status, ERROR_MESSAGES[status] || 'Request failed.', headers, extraHeaders);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sendNoContent(res, headers, extraHeaders = {}) {
|
|
122
|
+
if (res.writableEnded || res.destroyed) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (res.headersSent) {
|
|
126
|
+
res.end();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
res.writeHead(204, { ...headers, ...extraHeaders });
|
|
131
|
+
res.end();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function closeWhenDone(req, res) {
|
|
135
|
+
res.once('finish', () => {
|
|
136
|
+
if (!req.destroyed) {
|
|
137
|
+
req.destroy();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function declaredTooLarge(req) {
|
|
143
|
+
const declared = req.headers['content-length'];
|
|
144
|
+
if (declared === undefined) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
const length = Number(declared);
|
|
148
|
+
return Number.isFinite(length) && length > BODY_LIMIT;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function drainBody(req) {
|
|
152
|
+
if (req.readableEnded) {
|
|
153
|
+
return Promise.resolve('complete');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
let received = 0;
|
|
158
|
+
let settled = false;
|
|
159
|
+
|
|
160
|
+
function cleanup() {
|
|
161
|
+
req.off('data', onData);
|
|
162
|
+
req.off('end', onEnd);
|
|
163
|
+
req.off('error', onError);
|
|
164
|
+
req.off('aborted', onAborted);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function finish(result) {
|
|
168
|
+
if (settled) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
settled = true;
|
|
172
|
+
cleanup();
|
|
173
|
+
resolve(result);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onData(chunk) {
|
|
177
|
+
received += chunk.length;
|
|
178
|
+
if (received > BODY_LIMIT) {
|
|
179
|
+
req.pause();
|
|
180
|
+
finish('overflow');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function onEnd() {
|
|
185
|
+
finish('complete');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function onError() {
|
|
189
|
+
finish('aborted');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function onAborted() {
|
|
193
|
+
finish('aborted');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
req.on('data', onData);
|
|
197
|
+
req.once('end', onEnd);
|
|
198
|
+
req.once('error', onError);
|
|
199
|
+
req.once('aborted', onAborted);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isInside(root, target) {
|
|
204
|
+
const relative = path.relative(root, target);
|
|
205
|
+
return (
|
|
206
|
+
relative !== '' &&
|
|
207
|
+
relative !== '..' &&
|
|
208
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
209
|
+
!path.isAbsolute(relative)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseRequestPath(rawUrl) {
|
|
214
|
+
if (typeof rawUrl !== 'string') {
|
|
215
|
+
return { status: 404 };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const queryIndex = rawUrl.indexOf('?');
|
|
219
|
+
const rawPath = queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
|
|
220
|
+
if (!rawPath.startsWith('/') || rawPath === '/' || rawPath.endsWith('/')) {
|
|
221
|
+
return { status: 404 };
|
|
222
|
+
}
|
|
223
|
+
if (RAW_UNSAFE_SEQUENCE.test(rawPath) || CONTROL_CHARACTERS.test(rawPath)) {
|
|
224
|
+
return { status: 404 };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const rawSegments = rawPath.slice(1).split('/');
|
|
228
|
+
if (rawSegments.some((segment) => segment === '')) {
|
|
229
|
+
return { status: 404 };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let segments;
|
|
233
|
+
try {
|
|
234
|
+
segments = rawSegments.map((segment) => decodeURIComponent(segment));
|
|
235
|
+
} catch {
|
|
236
|
+
return { status: 400 };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const segment of segments) {
|
|
240
|
+
if (
|
|
241
|
+
segment === '' ||
|
|
242
|
+
segment === '.' ||
|
|
243
|
+
segment === '..' ||
|
|
244
|
+
segment.includes('/') ||
|
|
245
|
+
segment.includes('\\') ||
|
|
246
|
+
segment.includes(':') ||
|
|
247
|
+
CONTROL_CHARACTERS.test(segment) ||
|
|
248
|
+
/[. ]$/.test(segment)
|
|
249
|
+
) {
|
|
250
|
+
return { status: 404 };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const lastIndex = segments.length - 1;
|
|
255
|
+
if (segments[lastIndex].endsWith('.json')) {
|
|
256
|
+
return { status: 404 };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
segments[lastIndex] += '.json';
|
|
260
|
+
return { status: 200, segments };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function statusForError(error) {
|
|
264
|
+
if (
|
|
265
|
+
error &&
|
|
266
|
+
(error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'ELOOP')
|
|
267
|
+
) {
|
|
268
|
+
return 404;
|
|
269
|
+
}
|
|
270
|
+
return 500;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function locateTarget(realRoot, rawUrl) {
|
|
274
|
+
const parsed = parseRequestPath(rawUrl);
|
|
275
|
+
if (parsed.status !== 200) {
|
|
276
|
+
return parsed;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const candidate = path.resolve(realRoot, ...parsed.segments);
|
|
280
|
+
if (!isInside(realRoot, candidate)) {
|
|
281
|
+
return { status: 404 };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let target;
|
|
285
|
+
try {
|
|
286
|
+
target = await fs.promises.realpath(candidate);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return { status: statusForError(error) };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!isInside(realRoot, target)) {
|
|
292
|
+
return { status: 404 };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const info = await fs.promises.stat(target);
|
|
297
|
+
if (!info.isFile()) {
|
|
298
|
+
return { status: 404 };
|
|
299
|
+
}
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return { status: statusForError(error) };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { status: 200, target };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function createServer({ rootDir, https: useHttps, cert, key } = {}) {
|
|
308
|
+
const realRoot = fs.realpathSync(path.resolve(rootDir));
|
|
309
|
+
if (!fs.statSync(realRoot).isDirectory()) {
|
|
310
|
+
throw new Error('The root must be a directory.');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const handler = async (req, res) => {
|
|
314
|
+
const headers = responseHeaders(req);
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
if (req.method === 'OPTIONS') {
|
|
318
|
+
sendNoContent(res, headers, { Connection: 'close' });
|
|
319
|
+
closeWhenDone(req, res);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!SERVING_METHODS.has(req.method)) {
|
|
324
|
+
sendError(res, 405, headers, {
|
|
325
|
+
Allow: ALLOWED_METHODS,
|
|
326
|
+
Connection: 'close'
|
|
327
|
+
});
|
|
328
|
+
closeWhenDone(req, res);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (declaredTooLarge(req)) {
|
|
333
|
+
sendError(res, 413, headers, { Connection: 'close' });
|
|
334
|
+
closeWhenDone(req, res);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const bodyState = await drainBody(req);
|
|
339
|
+
if (bodyState === 'aborted') {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (bodyState === 'overflow') {
|
|
343
|
+
sendError(res, 413, headers, { Connection: 'close' });
|
|
344
|
+
closeWhenDone(req, res);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const resolved = await locateTarget(realRoot, req.url);
|
|
349
|
+
if (resolved.status !== 200) {
|
|
350
|
+
sendError(res, resolved.status, headers);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let body;
|
|
355
|
+
try {
|
|
356
|
+
body = await fs.promises.readFile(resolved.target);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
sendError(res, statusForError(error), headers);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (res.writableEnded || res.destroyed) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
res.writeHead(200, {
|
|
366
|
+
...headers,
|
|
367
|
+
'Content-Length': body.length,
|
|
368
|
+
'Content-Type': 'application/json; charset=utf-8'
|
|
369
|
+
});
|
|
370
|
+
res.end(body);
|
|
371
|
+
} catch {
|
|
372
|
+
sendError(res, 500, headers);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
if (useHttps) {
|
|
377
|
+
if (!cert || !key) {
|
|
378
|
+
throw new Error('HTTPS requires a certificate and key.');
|
|
379
|
+
}
|
|
380
|
+
return https.createServer({ cert, key }, handler);
|
|
381
|
+
}
|
|
382
|
+
return http.createServer(handler);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = createServer;
|
|
386
|
+
module.exports.createServer = createServer;
|