-3
.gitmodules
-3
.gitmodules
+31
-1
README.md
+31
-1
README.md
···
50
50
cargo build
51
51
```
52
52
53
+
## Features
54
+
55
+
### URL Redirects and Rewrites
56
+
57
+
The hosting service supports Netlify-style `_redirects` files for managing URLs. Place a `_redirects` file in your site root to enable:
58
+
59
+
- **301/302 Redirects**: Permanent and temporary URL redirects
60
+
- **200 Rewrites**: Serve different content without changing the URL
61
+
- **404 Custom Pages**: Custom error pages for specific paths
62
+
- **Splats & Placeholders**: Dynamic path matching (`/blog/:year/:month/:day`, `/news/*`)
63
+
- **Query Parameter Matching**: Redirect based on URL parameters
64
+
- **Conditional Redirects**: Route by country, language, or cookie presence
65
+
- **Force Redirects**: Override existing files with redirects
66
+
67
+
Example `_redirects`:
68
+
```
69
+
# Single-page app routing (React, Vue, etc.)
70
+
/* /index.html 200
71
+
72
+
# Simple redirects
73
+
/home /
74
+
/old-blog/* /blog/:splat
75
+
76
+
# API proxy
77
+
/api/* https://api.example.com/:splat 200
78
+
79
+
# Country-based routing
80
+
/ /us/ 302 Country=us
81
+
/ /uk/ 302 Country=gb
82
+
```
83
+
53
84
## Limits
54
85
55
86
- Max file size: 100MB (PDS limit)
56
-
- Max site size: 300MB
57
87
- Max files: 2000
58
88
59
89
## Tech Stack
+625
-72
cli/Cargo.lock
+625
-72
cli/Cargo.lock
···
139
139
140
140
[[package]]
141
141
name = "async-compression"
142
-
version = "0.4.32"
142
+
version = "0.4.33"
143
143
source = "registry+https://github.com/rust-lang/crates.io-index"
144
-
checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0"
144
+
checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2"
145
145
dependencies = [
146
146
"compression-codecs",
147
147
"compression-core",
···
158
158
dependencies = [
159
159
"proc-macro2",
160
160
"quote",
161
-
"syn 2.0.108",
161
+
"syn 2.0.110",
162
162
]
163
163
164
164
[[package]]
···
174
174
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
175
175
176
176
[[package]]
177
+
name = "axum"
178
+
version = "0.7.9"
179
+
source = "registry+https://github.com/rust-lang/crates.io-index"
180
+
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
181
+
dependencies = [
182
+
"async-trait",
183
+
"axum-core",
184
+
"bytes",
185
+
"futures-util",
186
+
"http",
187
+
"http-body",
188
+
"http-body-util",
189
+
"hyper",
190
+
"hyper-util",
191
+
"itoa",
192
+
"matchit",
193
+
"memchr",
194
+
"mime",
195
+
"percent-encoding",
196
+
"pin-project-lite",
197
+
"rustversion",
198
+
"serde",
199
+
"serde_json",
200
+
"serde_path_to_error",
201
+
"serde_urlencoded",
202
+
"sync_wrapper",
203
+
"tokio",
204
+
"tower 0.5.2",
205
+
"tower-layer",
206
+
"tower-service",
207
+
"tracing",
208
+
]
209
+
210
+
[[package]]
211
+
name = "axum-core"
212
+
version = "0.4.5"
213
+
source = "registry+https://github.com/rust-lang/crates.io-index"
214
+
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
215
+
dependencies = [
216
+
"async-trait",
217
+
"bytes",
218
+
"futures-util",
219
+
"http",
220
+
"http-body",
221
+
"http-body-util",
222
+
"mime",
223
+
"pin-project-lite",
224
+
"rustversion",
225
+
"sync_wrapper",
226
+
"tower-layer",
227
+
"tower-service",
228
+
"tracing",
229
+
]
230
+
231
+
[[package]]
177
232
name = "backtrace"
178
233
version = "0.3.76"
179
234
source = "registry+https://github.com/rust-lang/crates.io-index"
···
274
329
"proc-macro2",
275
330
"quote",
276
331
"rustversion",
277
-
"syn 2.0.108",
332
+
"syn 2.0.110",
278
333
]
279
334
280
335
[[package]]
···
348
403
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
349
404
350
405
[[package]]
406
+
name = "byteorder"
407
+
version = "1.5.0"
408
+
source = "registry+https://github.com/rust-lang/crates.io-index"
409
+
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
410
+
411
+
[[package]]
351
412
name = "bytes"
352
413
version = "1.10.1"
353
414
source = "registry+https://github.com/rust-lang/crates.io-index"
···
367
428
368
429
[[package]]
369
430
name = "cc"
370
-
version = "1.2.44"
431
+
version = "1.2.45"
371
432
source = "registry+https://github.com/rust-lang/crates.io-index"
372
-
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3"
433
+
checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe"
373
434
dependencies = [
374
435
"find-msvc-tools",
375
436
"shlex",
···
494
555
"heck 0.5.0",
495
556
"proc-macro2",
496
557
"quote",
497
-
"syn 2.0.108",
558
+
"syn 2.0.110",
498
559
]
499
560
500
561
[[package]]
···
521
582
522
583
[[package]]
523
584
name = "compression-codecs"
524
-
version = "0.4.31"
585
+
version = "0.4.32"
525
586
source = "registry+https://github.com/rust-lang/crates.io-index"
526
-
checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23"
587
+
checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b"
527
588
dependencies = [
528
589
"compression-core",
529
590
"flate2",
···
532
593
533
594
[[package]]
534
595
name = "compression-core"
535
-
version = "0.4.29"
596
+
version = "0.4.30"
536
597
source = "registry+https://github.com/rust-lang/crates.io-index"
537
-
checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb"
598
+
checksum = "3a9b614a5787ef0c8802a55766480563cb3a93b435898c422ed2a359cf811582"
538
599
539
600
[[package]]
540
601
name = "const-oid"
···
549
610
checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
550
611
551
612
[[package]]
613
+
name = "cordyceps"
614
+
version = "0.3.4"
615
+
source = "registry+https://github.com/rust-lang/crates.io-index"
616
+
checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a"
617
+
dependencies = [
618
+
"loom",
619
+
"tracing",
620
+
]
621
+
622
+
[[package]]
552
623
name = "core-foundation"
553
624
version = "0.9.4"
554
625
source = "registry+https://github.com/rust-lang/crates.io-index"
···
665
736
"proc-macro2",
666
737
"quote",
667
738
"strsim",
668
-
"syn 2.0.108",
739
+
"syn 2.0.110",
669
740
]
670
741
671
742
[[package]]
···
676
747
dependencies = [
677
748
"darling_core",
678
749
"quote",
679
-
"syn 2.0.108",
750
+
"syn 2.0.110",
680
751
]
681
752
682
753
[[package]]
···
716
787
checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976"
717
788
dependencies = [
718
789
"data-encoding",
719
-
"syn 2.0.108",
790
+
"syn 2.0.110",
720
791
]
721
792
722
793
[[package]]
···
751
822
]
752
823
753
824
[[package]]
825
+
name = "derive_more"
826
+
version = "1.0.0"
827
+
source = "registry+https://github.com/rust-lang/crates.io-index"
828
+
checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
829
+
dependencies = [
830
+
"derive_more-impl",
831
+
]
832
+
833
+
[[package]]
834
+
name = "derive_more-impl"
835
+
version = "1.0.0"
836
+
source = "registry+https://github.com/rust-lang/crates.io-index"
837
+
checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
838
+
dependencies = [
839
+
"proc-macro2",
840
+
"quote",
841
+
"syn 2.0.110",
842
+
"unicode-xid",
843
+
]
844
+
845
+
[[package]]
846
+
name = "diatomic-waker"
847
+
version = "0.2.3"
848
+
source = "registry+https://github.com/rust-lang/crates.io-index"
849
+
checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c"
850
+
851
+
[[package]]
754
852
name = "digest"
755
853
version = "0.10.7"
756
854
source = "registry+https://github.com/rust-lang/crates.io-index"
···
791
889
dependencies = [
792
890
"proc-macro2",
793
891
"quote",
794
-
"syn 2.0.108",
892
+
"syn 2.0.110",
795
893
]
796
894
797
895
[[package]]
···
852
950
"heck 0.5.0",
853
951
"proc-macro2",
854
952
"quote",
855
-
"syn 2.0.108",
953
+
"syn 2.0.110",
856
954
]
857
955
858
956
[[package]]
···
956
1054
]
957
1055
958
1056
[[package]]
1057
+
name = "futures-buffered"
1058
+
version = "0.2.12"
1059
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1060
+
checksum = "a8e0e1f38ec07ba4abbde21eed377082f17ccb988be9d988a5adbf4bafc118fd"
1061
+
dependencies = [
1062
+
"cordyceps",
1063
+
"diatomic-waker",
1064
+
"futures-core",
1065
+
"pin-project-lite",
1066
+
"spin 0.10.0",
1067
+
]
1068
+
1069
+
[[package]]
959
1070
name = "futures-channel"
960
1071
version = "0.3.31"
961
1072
source = "registry+https://github.com/rust-lang/crates.io-index"
···
989
1100
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
990
1101
991
1102
[[package]]
1103
+
name = "futures-lite"
1104
+
version = "2.6.1"
1105
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1106
+
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
1107
+
dependencies = [
1108
+
"fastrand",
1109
+
"futures-core",
1110
+
"futures-io",
1111
+
"parking",
1112
+
"pin-project-lite",
1113
+
]
1114
+
1115
+
[[package]]
992
1116
name = "futures-macro"
993
1117
version = "0.3.31"
994
1118
source = "registry+https://github.com/rust-lang/crates.io-index"
···
996
1120
dependencies = [
997
1121
"proc-macro2",
998
1122
"quote",
999
-
"syn 2.0.108",
1123
+
"syn 2.0.110",
1000
1124
]
1001
1125
1002
1126
[[package]]
···
1027
1151
"pin-project-lite",
1028
1152
"pin-utils",
1029
1153
"slab",
1154
+
]
1155
+
1156
+
[[package]]
1157
+
name = "generator"
1158
+
version = "0.8.7"
1159
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1160
+
checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2"
1161
+
dependencies = [
1162
+
"cc",
1163
+
"cfg-if",
1164
+
"libc",
1165
+
"log",
1166
+
"rustversion",
1167
+
"windows",
1030
1168
]
1031
1169
1032
1170
[[package]]
···
1236
1374
"markup5ever",
1237
1375
"proc-macro2",
1238
1376
"quote",
1239
-
"syn 2.0.108",
1377
+
"syn 2.0.110",
1240
1378
]
1241
1379
1242
1380
[[package]]
···
1274
1412
]
1275
1413
1276
1414
[[package]]
1415
+
name = "http-range-header"
1416
+
version = "0.4.2"
1417
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1418
+
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
1419
+
1420
+
[[package]]
1277
1421
name = "httparse"
1278
1422
version = "1.10.1"
1279
1423
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1287
1431
1288
1432
[[package]]
1289
1433
name = "hyper"
1290
-
version = "1.7.0"
1434
+
version = "1.8.0"
1291
1435
source = "registry+https://github.com/rust-lang/crates.io-index"
1292
-
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
1436
+
checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f"
1293
1437
dependencies = [
1294
1438
"atomic-waker",
1295
1439
"bytes",
···
1299
1443
"http",
1300
1444
"http-body",
1301
1445
"httparse",
1446
+
"httpdate",
1302
1447
"itoa",
1303
1448
"pin-project-lite",
1304
1449
"pin-utils",
···
1362
1507
"js-sys",
1363
1508
"log",
1364
1509
"wasm-bindgen",
1365
-
"windows-core",
1510
+
"windows-core 0.62.2",
1366
1511
]
1367
1512
1368
1513
[[package]]
···
1554
1699
1555
1700
[[package]]
1556
1701
name = "iri-string"
1557
-
version = "0.7.8"
1702
+
version = "0.7.9"
1558
1703
source = "registry+https://github.com/rust-lang/crates.io-index"
1559
-
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
1704
+
checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397"
1560
1705
dependencies = [
1561
1706
"memchr",
1562
1707
"serde",
···
1583
1728
[[package]]
1584
1729
name = "jacquard"
1585
1730
version = "0.9.0"
1586
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1731
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1587
1732
dependencies = [
1588
1733
"bytes",
1589
1734
"getrandom 0.2.16",
···
1611
1756
[[package]]
1612
1757
name = "jacquard-api"
1613
1758
version = "0.9.0"
1614
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1759
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1615
1760
dependencies = [
1616
1761
"bon",
1617
1762
"bytes",
···
1629
1774
[[package]]
1630
1775
name = "jacquard-common"
1631
1776
version = "0.9.0"
1632
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1777
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1633
1778
dependencies = [
1634
1779
"base64 0.22.1",
1635
1780
"bon",
1636
1781
"bytes",
1637
1782
"chrono",
1783
+
"ciborium",
1638
1784
"cid",
1785
+
"futures",
1639
1786
"getrandom 0.2.16",
1640
1787
"getrandom 0.3.4",
1641
1788
"http",
···
1645
1792
"miette",
1646
1793
"multibase",
1647
1794
"multihash",
1795
+
"n0-future",
1648
1796
"ouroboros",
1649
1797
"p256",
1650
1798
"rand 0.9.2",
···
1658
1806
"smol_str",
1659
1807
"thiserror 2.0.17",
1660
1808
"tokio",
1809
+
"tokio-tungstenite-wasm",
1661
1810
"tokio-util",
1662
1811
"trait-variant",
1663
1812
"url",
···
1666
1815
[[package]]
1667
1816
name = "jacquard-derive"
1668
1817
version = "0.9.0"
1669
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1818
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1670
1819
dependencies = [
1671
1820
"heck 0.5.0",
1672
1821
"jacquard-lexicon",
1673
1822
"proc-macro2",
1674
1823
"quote",
1675
-
"syn 2.0.108",
1824
+
"syn 2.0.110",
1676
1825
]
1677
1826
1678
1827
[[package]]
1679
1828
name = "jacquard-identity"
1680
1829
version = "0.9.1"
1681
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1830
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1682
1831
dependencies = [
1683
1832
"bon",
1684
1833
"bytes",
···
1704
1853
[[package]]
1705
1854
name = "jacquard-lexicon"
1706
1855
version = "0.9.1"
1707
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1856
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1708
1857
dependencies = [
1709
1858
"cid",
1710
1859
"dashmap",
···
1722
1871
"serde_repr",
1723
1872
"serde_with",
1724
1873
"sha2",
1725
-
"syn 2.0.108",
1874
+
"syn 2.0.110",
1726
1875
"thiserror 2.0.17",
1727
1876
"unicode-segmentation",
1728
1877
]
···
1730
1879
[[package]]
1731
1880
name = "jacquard-oauth"
1732
1881
version = "0.9.0"
1733
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1882
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1734
1883
dependencies = [
1735
1884
"base64 0.22.1",
1736
1885
"bytes",
···
1856
2005
source = "registry+https://github.com/rust-lang/crates.io-index"
1857
2006
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1858
2007
dependencies = [
1859
-
"spin",
2008
+
"spin 0.9.8",
1860
2009
]
1861
2010
1862
2011
[[package]]
···
1916
2065
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
1917
2066
1918
2067
[[package]]
2068
+
name = "loom"
2069
+
version = "0.7.2"
2070
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2071
+
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
2072
+
dependencies = [
2073
+
"cfg-if",
2074
+
"generator",
2075
+
"scoped-tls",
2076
+
"tracing",
2077
+
"tracing-subscriber",
2078
+
]
2079
+
2080
+
[[package]]
1919
2081
name = "lru-cache"
1920
2082
version = "0.1.2"
1921
2083
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1974
2136
]
1975
2137
1976
2138
[[package]]
2139
+
name = "matchers"
2140
+
version = "0.2.0"
2141
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2142
+
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
2143
+
dependencies = [
2144
+
"regex-automata",
2145
+
]
2146
+
2147
+
[[package]]
2148
+
name = "matchit"
2149
+
version = "0.7.3"
2150
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2151
+
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
2152
+
2153
+
[[package]]
1977
2154
name = "memchr"
1978
2155
version = "2.7.6"
1979
2156
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2006
2183
dependencies = [
2007
2184
"proc-macro2",
2008
2185
"quote",
2009
-
"syn 2.0.108",
2186
+
"syn 2.0.110",
2010
2187
]
2011
2188
2012
2189
[[package]]
···
2108
2285
]
2109
2286
2110
2287
[[package]]
2288
+
name = "n0-future"
2289
+
version = "0.1.3"
2290
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2291
+
checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794"
2292
+
dependencies = [
2293
+
"cfg_aliases",
2294
+
"derive_more",
2295
+
"futures-buffered",
2296
+
"futures-lite",
2297
+
"futures-util",
2298
+
"js-sys",
2299
+
"pin-project",
2300
+
"send_wrapper",
2301
+
"tokio",
2302
+
"tokio-util",
2303
+
"wasm-bindgen",
2304
+
"wasm-bindgen-futures",
2305
+
"web-time",
2306
+
]
2307
+
2308
+
[[package]]
2111
2309
name = "ndk-context"
2112
2310
version = "0.1.1"
2113
2311
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2130
2328
]
2131
2329
2132
2330
[[package]]
2331
+
name = "nu-ansi-term"
2332
+
version = "0.50.3"
2333
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2334
+
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
2335
+
dependencies = [
2336
+
"windows-sys 0.61.2",
2337
+
]
2338
+
2339
+
[[package]]
2133
2340
name = "num-bigint-dig"
2134
-
version = "0.8.5"
2341
+
version = "0.8.6"
2135
2342
source = "registry+https://github.com/rust-lang/crates.io-index"
2136
-
checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b"
2343
+
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
2137
2344
dependencies = [
2138
2345
"lazy_static",
2139
2346
"libm",
···
2247
2454
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
2248
2455
2249
2456
[[package]]
2457
+
name = "openssl-probe"
2458
+
version = "0.1.6"
2459
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2460
+
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
2461
+
2462
+
[[package]]
2250
2463
name = "option-ext"
2251
2464
version = "0.2.0"
2252
2465
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2273
2486
"proc-macro2",
2274
2487
"proc-macro2-diagnostics",
2275
2488
"quote",
2276
-
"syn 2.0.108",
2489
+
"syn 2.0.110",
2277
2490
]
2278
2491
2279
2492
[[package]]
···
2303
2516
"elliptic-curve",
2304
2517
"primeorder",
2305
2518
]
2519
+
2520
+
[[package]]
2521
+
name = "parking"
2522
+
version = "2.2.1"
2523
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2524
+
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2306
2525
2307
2526
[[package]]
2308
2527
name = "parking_lot"
···
2381
2600
]
2382
2601
2383
2602
[[package]]
2603
+
name = "pin-project"
2604
+
version = "1.1.10"
2605
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2606
+
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
2607
+
dependencies = [
2608
+
"pin-project-internal",
2609
+
]
2610
+
2611
+
[[package]]
2612
+
name = "pin-project-internal"
2613
+
version = "1.1.10"
2614
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2615
+
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
2616
+
dependencies = [
2617
+
"proc-macro2",
2618
+
"quote",
2619
+
"syn 2.0.110",
2620
+
]
2621
+
2622
+
[[package]]
2384
2623
name = "pin-project-lite"
2385
2624
version = "0.2.16"
2386
2625
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2450
2689
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
2451
2690
dependencies = [
2452
2691
"proc-macro2",
2453
-
"syn 2.0.108",
2692
+
"syn 2.0.110",
2454
2693
]
2455
2694
2456
2695
[[package]]
···
2503
2742
dependencies = [
2504
2743
"proc-macro2",
2505
2744
"quote",
2506
-
"syn 2.0.108",
2745
+
"syn 2.0.110",
2507
2746
"version_check",
2508
2747
"yansi",
2509
2748
]
···
2571
2810
2572
2811
[[package]]
2573
2812
name = "quote"
2574
-
version = "1.0.41"
2813
+
version = "1.0.42"
2575
2814
source = "registry+https://github.com/rust-lang/crates.io-index"
2576
-
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
2815
+
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
2577
2816
dependencies = [
2578
2817
"proc-macro2",
2579
2818
]
···
2686
2925
dependencies = [
2687
2926
"proc-macro2",
2688
2927
"quote",
2689
-
"syn 2.0.108",
2928
+
"syn 2.0.110",
2690
2929
]
2691
2930
2692
2931
[[package]]
···
2752
2991
"tokio",
2753
2992
"tokio-rustls",
2754
2993
"tokio-util",
2755
-
"tower",
2756
-
"tower-http",
2994
+
"tower 0.5.2",
2995
+
"tower-http 0.6.6",
2757
2996
"tower-service",
2758
2997
"url",
2759
2998
"wasm-bindgen",
···
2864
3103
2865
3104
[[package]]
2866
3105
name = "rustls"
2867
-
version = "0.23.34"
3106
+
version = "0.23.35"
2868
3107
source = "registry+https://github.com/rust-lang/crates.io-index"
2869
-
checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7"
3108
+
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
2870
3109
dependencies = [
2871
3110
"once_cell",
2872
3111
"ring",
···
2874
3113
"rustls-webpki",
2875
3114
"subtle",
2876
3115
"zeroize",
3116
+
]
3117
+
3118
+
[[package]]
3119
+
name = "rustls-native-certs"
3120
+
version = "0.8.2"
3121
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3122
+
checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923"
3123
+
dependencies = [
3124
+
"openssl-probe",
3125
+
"rustls-pki-types",
3126
+
"schannel",
3127
+
"security-framework",
2877
3128
]
2878
3129
2879
3130
[[package]]
···
2925
3176
]
2926
3177
2927
3178
[[package]]
3179
+
name = "schannel"
3180
+
version = "0.1.28"
3181
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3182
+
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
3183
+
dependencies = [
3184
+
"windows-sys 0.61.2",
3185
+
]
3186
+
3187
+
[[package]]
2928
3188
name = "schemars"
2929
3189
version = "0.9.0"
2930
3190
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2938
3198
2939
3199
[[package]]
2940
3200
name = "schemars"
2941
-
version = "1.0.4"
3201
+
version = "1.1.0"
2942
3202
source = "registry+https://github.com/rust-lang/crates.io-index"
2943
-
checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
3203
+
checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289"
2944
3204
dependencies = [
2945
3205
"dyn-clone",
2946
3206
"ref-cast",
···
2949
3209
]
2950
3210
2951
3211
[[package]]
3212
+
name = "scoped-tls"
3213
+
version = "1.0.1"
3214
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3215
+
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
3216
+
3217
+
[[package]]
2952
3218
name = "scopeguard"
2953
3219
version = "1.2.0"
2954
3220
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2969
3235
]
2970
3236
2971
3237
[[package]]
3238
+
name = "security-framework"
3239
+
version = "3.5.1"
3240
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3241
+
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
3242
+
dependencies = [
3243
+
"bitflags",
3244
+
"core-foundation 0.10.1",
3245
+
"core-foundation-sys",
3246
+
"libc",
3247
+
"security-framework-sys",
3248
+
]
3249
+
3250
+
[[package]]
3251
+
name = "security-framework-sys"
3252
+
version = "2.15.0"
3253
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3254
+
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
3255
+
dependencies = [
3256
+
"core-foundation-sys",
3257
+
"libc",
3258
+
]
3259
+
3260
+
[[package]]
3261
+
name = "send_wrapper"
3262
+
version = "0.6.0"
3263
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3264
+
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
3265
+
3266
+
[[package]]
2972
3267
name = "serde"
2973
3268
version = "1.0.228"
2974
3269
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3005
3300
dependencies = [
3006
3301
"proc-macro2",
3007
3302
"quote",
3008
-
"syn 2.0.108",
3303
+
"syn 2.0.110",
3009
3304
]
3010
3305
3011
3306
[[package]]
···
3047
3342
]
3048
3343
3049
3344
[[package]]
3345
+
name = "serde_path_to_error"
3346
+
version = "0.1.20"
3347
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3348
+
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
3349
+
dependencies = [
3350
+
"itoa",
3351
+
"serde",
3352
+
"serde_core",
3353
+
]
3354
+
3355
+
[[package]]
3050
3356
name = "serde_repr"
3051
3357
version = "0.1.20"
3052
3358
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3054
3360
dependencies = [
3055
3361
"proc-macro2",
3056
3362
"quote",
3057
-
"syn 2.0.108",
3363
+
"syn 2.0.110",
3058
3364
]
3059
3365
3060
3366
[[package]]
···
3081
3387
"indexmap 1.9.3",
3082
3388
"indexmap 2.12.0",
3083
3389
"schemars 0.9.0",
3084
-
"schemars 1.0.4",
3390
+
"schemars 1.1.0",
3085
3391
"serde_core",
3086
3392
"serde_json",
3087
3393
"serde_with_macros",
···
3097
3403
"darling",
3098
3404
"proc-macro2",
3099
3405
"quote",
3100
-
"syn 2.0.108",
3406
+
"syn 2.0.110",
3407
+
]
3408
+
3409
+
[[package]]
3410
+
name = "sha1"
3411
+
version = "0.10.6"
3412
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3413
+
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
3414
+
dependencies = [
3415
+
"cfg-if",
3416
+
"cpufeatures",
3417
+
"digest",
3101
3418
]
3102
3419
3103
3420
[[package]]
···
3115
3432
"cfg-if",
3116
3433
"cpufeatures",
3117
3434
"digest",
3435
+
]
3436
+
3437
+
[[package]]
3438
+
name = "sharded-slab"
3439
+
version = "0.1.7"
3440
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3441
+
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
3442
+
dependencies = [
3443
+
"lazy_static",
3118
3444
]
3119
3445
3120
3446
[[package]]
···
3210
3536
version = "0.9.8"
3211
3537
source = "registry+https://github.com/rust-lang/crates.io-index"
3212
3538
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
3539
+
3540
+
[[package]]
3541
+
name = "spin"
3542
+
version = "0.10.0"
3543
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3544
+
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
3213
3545
3214
3546
[[package]]
3215
3547
name = "spki"
···
3243
3575
"quote",
3244
3576
"serde",
3245
3577
"sha2",
3246
-
"syn 2.0.108",
3578
+
"syn 2.0.110",
3247
3579
"thiserror 1.0.69",
3248
3580
]
3249
3581
···
3324
3656
3325
3657
[[package]]
3326
3658
name = "syn"
3327
-
version = "2.0.108"
3659
+
version = "2.0.110"
3328
3660
source = "registry+https://github.com/rust-lang/crates.io-index"
3329
-
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
3661
+
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
3330
3662
dependencies = [
3331
3663
"proc-macro2",
3332
3664
"quote",
···
3350
3682
dependencies = [
3351
3683
"proc-macro2",
3352
3684
"quote",
3353
-
"syn 2.0.108",
3685
+
"syn 2.0.110",
3354
3686
]
3355
3687
3356
3688
[[package]]
···
3450
3782
dependencies = [
3451
3783
"proc-macro2",
3452
3784
"quote",
3453
-
"syn 2.0.108",
3785
+
"syn 2.0.110",
3454
3786
]
3455
3787
3456
3788
[[package]]
···
3461
3793
dependencies = [
3462
3794
"proc-macro2",
3463
3795
"quote",
3464
-
"syn 2.0.108",
3796
+
"syn 2.0.110",
3797
+
]
3798
+
3799
+
[[package]]
3800
+
name = "thread_local"
3801
+
version = "1.1.9"
3802
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3803
+
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
3804
+
dependencies = [
3805
+
"cfg-if",
3465
3806
]
3466
3807
3467
3808
[[package]]
···
3568
3909
dependencies = [
3569
3910
"proc-macro2",
3570
3911
"quote",
3571
-
"syn 2.0.108",
3912
+
"syn 2.0.110",
3572
3913
]
3573
3914
3574
3915
[[package]]
···
3582
3923
]
3583
3924
3584
3925
[[package]]
3926
+
name = "tokio-tungstenite"
3927
+
version = "0.24.0"
3928
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3929
+
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
3930
+
dependencies = [
3931
+
"futures-util",
3932
+
"log",
3933
+
"rustls",
3934
+
"rustls-native-certs",
3935
+
"rustls-pki-types",
3936
+
"tokio",
3937
+
"tokio-rustls",
3938
+
"tungstenite",
3939
+
]
3940
+
3941
+
[[package]]
3942
+
name = "tokio-tungstenite-wasm"
3943
+
version = "0.4.0"
3944
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3945
+
checksum = "e21a5c399399c3db9f08d8297ac12b500e86bca82e930253fdc62eaf9c0de6ae"
3946
+
dependencies = [
3947
+
"futures-channel",
3948
+
"futures-util",
3949
+
"http",
3950
+
"httparse",
3951
+
"js-sys",
3952
+
"rustls",
3953
+
"thiserror 1.0.69",
3954
+
"tokio",
3955
+
"tokio-tungstenite",
3956
+
"wasm-bindgen",
3957
+
"web-sys",
3958
+
]
3959
+
3960
+
[[package]]
3585
3961
name = "tokio-util"
3586
-
version = "0.7.16"
3962
+
version = "0.7.17"
3587
3963
source = "registry+https://github.com/rust-lang/crates.io-index"
3588
-
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
3964
+
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
3589
3965
dependencies = [
3590
3966
"bytes",
3591
3967
"futures-core",
3592
3968
"futures-sink",
3969
+
"futures-util",
3593
3970
"pin-project-lite",
3594
3971
"tokio",
3595
3972
]
3596
3973
3597
3974
[[package]]
3598
3975
name = "tower"
3976
+
version = "0.4.13"
3977
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3978
+
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
3979
+
dependencies = [
3980
+
"tower-layer",
3981
+
"tower-service",
3982
+
"tracing",
3983
+
]
3984
+
3985
+
[[package]]
3986
+
name = "tower"
3599
3987
version = "0.5.2"
3600
3988
source = "registry+https://github.com/rust-lang/crates.io-index"
3601
3989
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
···
3607
3995
"tokio",
3608
3996
"tower-layer",
3609
3997
"tower-service",
3998
+
"tracing",
3999
+
]
4000
+
4001
+
[[package]]
4002
+
name = "tower-http"
4003
+
version = "0.5.2"
4004
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4005
+
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
4006
+
dependencies = [
4007
+
"async-compression",
4008
+
"bitflags",
4009
+
"bytes",
4010
+
"futures-core",
4011
+
"futures-util",
4012
+
"http",
4013
+
"http-body",
4014
+
"http-body-util",
4015
+
"http-range-header",
4016
+
"httpdate",
4017
+
"mime",
4018
+
"mime_guess",
4019
+
"percent-encoding",
4020
+
"pin-project-lite",
4021
+
"tokio",
4022
+
"tokio-util",
4023
+
"tower-layer",
4024
+
"tower-service",
4025
+
"tracing",
3610
4026
]
3611
4027
3612
4028
[[package]]
···
3622
4038
"http-body",
3623
4039
"iri-string",
3624
4040
"pin-project-lite",
3625
-
"tower",
4041
+
"tower 0.5.2",
3626
4042
"tower-layer",
3627
4043
"tower-service",
3628
4044
]
···
3645
4061
source = "registry+https://github.com/rust-lang/crates.io-index"
3646
4062
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
3647
4063
dependencies = [
4064
+
"log",
3648
4065
"pin-project-lite",
3649
4066
"tracing-attributes",
3650
4067
"tracing-core",
···
3658
4075
dependencies = [
3659
4076
"proc-macro2",
3660
4077
"quote",
3661
-
"syn 2.0.108",
4078
+
"syn 2.0.110",
3662
4079
]
3663
4080
3664
4081
[[package]]
···
3668
4085
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
3669
4086
dependencies = [
3670
4087
"once_cell",
4088
+
"valuable",
4089
+
]
4090
+
4091
+
[[package]]
4092
+
name = "tracing-log"
4093
+
version = "0.2.0"
4094
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4095
+
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
4096
+
dependencies = [
4097
+
"log",
4098
+
"once_cell",
4099
+
"tracing-core",
4100
+
]
4101
+
4102
+
[[package]]
4103
+
name = "tracing-subscriber"
4104
+
version = "0.3.20"
4105
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4106
+
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
4107
+
dependencies = [
4108
+
"matchers",
4109
+
"nu-ansi-term",
4110
+
"once_cell",
4111
+
"regex-automata",
4112
+
"sharded-slab",
4113
+
"smallvec",
4114
+
"thread_local",
4115
+
"tracing",
4116
+
"tracing-core",
4117
+
"tracing-log",
3671
4118
]
3672
4119
3673
4120
[[package]]
···
3678
4125
dependencies = [
3679
4126
"proc-macro2",
3680
4127
"quote",
3681
-
"syn 2.0.108",
4128
+
"syn 2.0.110",
3682
4129
]
3683
4130
3684
4131
[[package]]
···
3694
4141
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
3695
4142
3696
4143
[[package]]
4144
+
name = "tungstenite"
4145
+
version = "0.24.0"
4146
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4147
+
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
4148
+
dependencies = [
4149
+
"byteorder",
4150
+
"bytes",
4151
+
"data-encoding",
4152
+
"http",
4153
+
"httparse",
4154
+
"log",
4155
+
"rand 0.8.5",
4156
+
"rustls",
4157
+
"rustls-pki-types",
4158
+
"sha1",
4159
+
"thiserror 1.0.69",
4160
+
"utf-8",
4161
+
]
4162
+
4163
+
[[package]]
3697
4164
name = "twoway"
3698
4165
version = "0.1.8"
3699
4166
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3745
4212
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
3746
4213
3747
4214
[[package]]
4215
+
name = "unicode-xid"
4216
+
version = "0.2.6"
4217
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4218
+
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
4219
+
4220
+
[[package]]
3748
4221
name = "unsigned-varint"
3749
4222
version = "0.8.0"
3750
4223
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3793
4266
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
3794
4267
3795
4268
[[package]]
4269
+
name = "valuable"
4270
+
version = "0.1.1"
4271
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4272
+
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
4273
+
4274
+
[[package]]
3796
4275
name = "version_check"
3797
4276
version = "0.9.5"
3798
4277
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3877
4356
"bumpalo",
3878
4357
"proc-macro2",
3879
4358
"quote",
3880
-
"syn 2.0.108",
4359
+
"syn 2.0.110",
3881
4360
"wasm-bindgen-shared",
3882
4361
]
3883
4362
···
3976
4455
]
3977
4456
3978
4457
[[package]]
4458
+
name = "windows"
4459
+
version = "0.61.3"
4460
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4461
+
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
4462
+
dependencies = [
4463
+
"windows-collections",
4464
+
"windows-core 0.61.2",
4465
+
"windows-future",
4466
+
"windows-link 0.1.3",
4467
+
"windows-numerics",
4468
+
]
4469
+
4470
+
[[package]]
4471
+
name = "windows-collections"
4472
+
version = "0.2.0"
4473
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4474
+
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
4475
+
dependencies = [
4476
+
"windows-core 0.61.2",
4477
+
]
4478
+
4479
+
[[package]]
4480
+
name = "windows-core"
4481
+
version = "0.61.2"
4482
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4483
+
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
4484
+
dependencies = [
4485
+
"windows-implement",
4486
+
"windows-interface",
4487
+
"windows-link 0.1.3",
4488
+
"windows-result 0.3.4",
4489
+
"windows-strings 0.4.2",
4490
+
]
4491
+
4492
+
[[package]]
3979
4493
name = "windows-core"
3980
4494
version = "0.62.2"
3981
4495
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3989
4503
]
3990
4504
3991
4505
[[package]]
4506
+
name = "windows-future"
4507
+
version = "0.2.1"
4508
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4509
+
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
4510
+
dependencies = [
4511
+
"windows-core 0.61.2",
4512
+
"windows-link 0.1.3",
4513
+
"windows-threading",
4514
+
]
4515
+
4516
+
[[package]]
3992
4517
name = "windows-implement"
3993
4518
version = "0.60.2"
3994
4519
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3996
4521
dependencies = [
3997
4522
"proc-macro2",
3998
4523
"quote",
3999
-
"syn 2.0.108",
4524
+
"syn 2.0.110",
4000
4525
]
4001
4526
4002
4527
[[package]]
···
4007
4532
dependencies = [
4008
4533
"proc-macro2",
4009
4534
"quote",
4010
-
"syn 2.0.108",
4535
+
"syn 2.0.110",
4011
4536
]
4012
4537
4013
4538
[[package]]
···
4021
4546
version = "0.2.1"
4022
4547
source = "registry+https://github.com/rust-lang/crates.io-index"
4023
4548
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
4549
+
4550
+
[[package]]
4551
+
name = "windows-numerics"
4552
+
version = "0.2.0"
4553
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4554
+
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
4555
+
dependencies = [
4556
+
"windows-core 0.61.2",
4557
+
"windows-link 0.1.3",
4558
+
]
4024
4559
4025
4560
[[package]]
4026
4561
name = "windows-registry"
···
4178
4713
]
4179
4714
4180
4715
[[package]]
4716
+
name = "windows-threading"
4717
+
version = "0.1.0"
4718
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4719
+
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
4720
+
dependencies = [
4721
+
"windows-link 0.1.3",
4722
+
]
4723
+
4724
+
[[package]]
4181
4725
name = "windows_aarch64_gnullvm"
4182
4726
version = "0.42.2"
4183
4727
source = "registry+https://github.com/rust-lang/crates.io-index"
···
4369
4913
4370
4914
[[package]]
4371
4915
name = "wisp-cli"
4372
-
version = "0.1.0"
4916
+
version = "0.2.0"
4373
4917
dependencies = [
4918
+
"axum",
4374
4919
"base64 0.22.1",
4375
4920
"bytes",
4921
+
"chrono",
4376
4922
"clap",
4377
4923
"flate2",
4378
4924
"futures",
···
4385
4931
"jacquard-oauth",
4386
4932
"miette",
4387
4933
"mime_guess",
4934
+
"multibase",
4935
+
"multihash",
4936
+
"n0-future",
4388
4937
"reqwest",
4389
4938
"rustversion",
4390
4939
"serde",
4391
4940
"serde_json",
4941
+
"sha2",
4392
4942
"shellexpand",
4393
4943
"tokio",
4944
+
"tower 0.4.13",
4945
+
"tower-http 0.5.2",
4946
+
"url",
4394
4947
"walkdir",
4395
4948
]
4396
4949
···
4442
4995
dependencies = [
4443
4996
"proc-macro2",
4444
4997
"quote",
4445
-
"syn 2.0.108",
4998
+
"syn 2.0.110",
4446
4999
"synstructure",
4447
5000
]
4448
5001
···
4463
5016
dependencies = [
4464
5017
"proc-macro2",
4465
5018
"quote",
4466
-
"syn 2.0.108",
5019
+
"syn 2.0.110",
4467
5020
]
4468
5021
4469
5022
[[package]]
···
4483
5036
dependencies = [
4484
5037
"proc-macro2",
4485
5038
"quote",
4486
-
"syn 2.0.108",
5039
+
"syn 2.0.110",
4487
5040
"synstructure",
4488
5041
]
4489
5042
···
4526
5079
dependencies = [
4527
5080
"proc-macro2",
4528
5081
"quote",
4529
-
"syn 2.0.108",
5082
+
"syn 2.0.110",
4530
5083
]
+11
-2
cli/Cargo.toml
+11
-2
cli/Cargo.toml
···
1
1
[package]
2
2
name = "wisp-cli"
3
-
version = "0.1.0"
3
+
version = "0.2.0"
4
4
edition = "2024"
5
5
6
6
[features]
···
11
11
jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["loopback"] }
12
12
jacquard-oauth = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
13
13
jacquard-api = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
14
-
jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
14
+
jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["websocket"] }
15
15
jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["dns"] }
16
16
jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
17
17
jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
···
30
30
mime_guess = "2.0"
31
31
bytes = "1.10"
32
32
futures = "0.3.31"
33
+
multihash = "0.19.3"
34
+
multibase = "0.9"
35
+
sha2 = "0.10"
36
+
axum = "0.7"
37
+
tower-http = { version = "0.5", features = ["fs", "compression-gzip"] }
38
+
tower = "0.4"
39
+
n0-future = "0.1"
40
+
chrono = "0.4"
41
+
url = "2.5"
+85
cli/src/blob_map.rs
+85
cli/src/blob_map.rs
···
1
+
use jacquard_common::types::blob::BlobRef;
2
+
use jacquard_common::IntoStatic;
3
+
use std::collections::HashMap;
4
+
5
+
use crate::place_wisp::fs::{Directory, EntryNode};
6
+
7
+
/// Extract blob information from a directory tree
8
+
/// Returns a map of file paths to their blob refs and CIDs
9
+
///
10
+
/// This mirrors the TypeScript implementation in src/lib/wisp-utils.ts lines 275-302
11
+
pub fn extract_blob_map(
12
+
directory: &Directory,
13
+
) -> HashMap<String, (BlobRef<'static>, String)> {
14
+
extract_blob_map_recursive(directory, String::new())
15
+
}
16
+
17
+
fn extract_blob_map_recursive(
18
+
directory: &Directory,
19
+
current_path: String,
20
+
) -> HashMap<String, (BlobRef<'static>, String)> {
21
+
let mut blob_map = HashMap::new();
22
+
23
+
for entry in &directory.entries {
24
+
let full_path = if current_path.is_empty() {
25
+
entry.name.to_string()
26
+
} else {
27
+
format!("{}/{}", current_path, entry.name)
28
+
};
29
+
30
+
match &entry.node {
31
+
EntryNode::File(file_node) => {
32
+
// Extract CID from blob ref
33
+
// BlobRef is an enum with Blob variant, which has a ref field (CidLink)
34
+
let blob_ref = &file_node.blob;
35
+
let cid_string = blob_ref.blob().r#ref.to_string();
36
+
37
+
// Store with full path (mirrors TypeScript implementation)
38
+
blob_map.insert(
39
+
full_path,
40
+
(blob_ref.clone().into_static(), cid_string)
41
+
);
42
+
}
43
+
EntryNode::Directory(subdir) => {
44
+
let sub_map = extract_blob_map_recursive(subdir, full_path);
45
+
blob_map.extend(sub_map);
46
+
}
47
+
EntryNode::Unknown(_) => {
48
+
// Skip unknown node types
49
+
}
50
+
}
51
+
}
52
+
53
+
blob_map
54
+
}
55
+
56
+
/// Normalize file path by removing base folder prefix
57
+
/// Example: "cobblemon/index.html" -> "index.html"
58
+
///
59
+
/// Note: This function is kept for reference but is no longer used in production code.
60
+
/// The TypeScript server has a similar normalization (src/routes/wisp.ts line 291) to handle
61
+
/// uploads that include a base folder prefix, but our CLI doesn't need this since we
62
+
/// track full paths consistently.
63
+
#[allow(dead_code)]
64
+
pub fn normalize_path(path: &str) -> String {
65
+
// Remove base folder prefix (everything before first /)
66
+
if let Some(idx) = path.find('/') {
67
+
path[idx + 1..].to_string()
68
+
} else {
69
+
path.to_string()
70
+
}
71
+
}
72
+
73
+
#[cfg(test)]
74
+
mod tests {
75
+
use super::*;
76
+
77
+
#[test]
78
+
fn test_normalize_path() {
79
+
assert_eq!(normalize_path("index.html"), "index.html");
80
+
assert_eq!(normalize_path("cobblemon/index.html"), "index.html");
81
+
assert_eq!(normalize_path("folder/subfolder/file.txt"), "subfolder/file.txt");
82
+
assert_eq!(normalize_path("a/b/c/d.txt"), "b/c/d.txt");
83
+
}
84
+
}
85
+
+66
cli/src/cid.rs
+66
cli/src/cid.rs
···
1
+
use jacquard_common::types::cid::IpldCid;
2
+
use sha2::{Digest, Sha256};
3
+
4
+
/// Compute CID (Content Identifier) for blob content
5
+
/// Uses the same algorithm as AT Protocol: CIDv1 with raw codec (0x55) and SHA-256
6
+
///
7
+
/// CRITICAL: This must be called on BASE64-ENCODED GZIPPED content, not just gzipped content
8
+
///
9
+
/// Based on @atproto/common/src/ipld.ts sha256RawToCid implementation
10
+
pub fn compute_cid(content: &[u8]) -> String {
11
+
// Use node crypto to compute sha256 hash (same as AT Protocol)
12
+
let hash = Sha256::digest(content);
13
+
14
+
// Create multihash (code 0x12 = sha2-256)
15
+
let multihash = multihash::Multihash::wrap(0x12, &hash)
16
+
.expect("SHA-256 hash should always fit in multihash");
17
+
18
+
// Create CIDv1 with raw codec (0x55)
19
+
let cid = IpldCid::new_v1(0x55, multihash);
20
+
21
+
// Convert to base32 string representation
22
+
cid.to_string_of_base(multibase::Base::Base32Lower)
23
+
.unwrap_or_else(|_| cid.to_string())
24
+
}
25
+
26
+
#[cfg(test)]
27
+
mod tests {
28
+
use super::*;
29
+
use base64::Engine;
30
+
31
+
#[test]
32
+
fn test_compute_cid() {
33
+
// Test with a simple string: "hello"
34
+
let content = b"hello";
35
+
let cid = compute_cid(content);
36
+
37
+
// CID should start with 'baf' for raw codec base32
38
+
assert!(cid.starts_with("baf"));
39
+
}
40
+
41
+
#[test]
42
+
fn test_compute_cid_base64_encoded() {
43
+
// Simulate the actual use case: gzipped then base64 encoded
44
+
use flate2::write::GzEncoder;
45
+
use flate2::Compression;
46
+
use std::io::Write;
47
+
48
+
let original = b"hello world";
49
+
50
+
// Gzip compress
51
+
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
52
+
encoder.write_all(original).unwrap();
53
+
let gzipped = encoder.finish().unwrap();
54
+
55
+
// Base64 encode the gzipped data
56
+
let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
57
+
58
+
// Compute CID on the base64 bytes
59
+
let cid = compute_cid(&base64_bytes);
60
+
61
+
// Should be a valid CID
62
+
assert!(cid.starts_with("baf"));
63
+
assert!(cid.len() > 10);
64
+
}
65
+
}
66
+
+71
cli/src/download.rs
+71
cli/src/download.rs
···
1
+
use base64::Engine;
2
+
use bytes::Bytes;
3
+
use flate2::read::GzDecoder;
4
+
use jacquard_common::types::blob::BlobRef;
5
+
use miette::IntoDiagnostic;
6
+
use std::io::Read;
7
+
use url::Url;
8
+
9
+
/// Download a blob from the PDS
10
+
pub async fn download_blob(pds_url: &Url, blob_ref: &BlobRef<'_>, did: &str) -> miette::Result<Bytes> {
11
+
// Extract CID from blob ref
12
+
let cid = blob_ref.blob().r#ref.to_string();
13
+
14
+
// Construct blob download URL
15
+
// The correct endpoint is: /xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}
16
+
let blob_url = pds_url
17
+
.join(&format!("/xrpc/com.atproto.sync.getBlob?did={}&cid={}", did, cid))
18
+
.into_diagnostic()?;
19
+
20
+
let client = reqwest::Client::new();
21
+
let response = client
22
+
.get(blob_url)
23
+
.send()
24
+
.await
25
+
.into_diagnostic()?;
26
+
27
+
if !response.status().is_success() {
28
+
return Err(miette::miette!(
29
+
"Failed to download blob: {}",
30
+
response.status()
31
+
));
32
+
}
33
+
34
+
let bytes = response.bytes().await.into_diagnostic()?;
35
+
Ok(bytes)
36
+
}
37
+
38
+
/// Decompress and decode a blob (base64 + gzip)
39
+
pub fn decompress_blob(data: &[u8], is_base64: bool, is_gzipped: bool) -> miette::Result<Vec<u8>> {
40
+
let mut current_data = data.to_vec();
41
+
42
+
// First, decode base64 if needed
43
+
if is_base64 {
44
+
current_data = base64::prelude::BASE64_STANDARD
45
+
.decode(¤t_data)
46
+
.into_diagnostic()?;
47
+
}
48
+
49
+
// Then, decompress gzip if needed
50
+
if is_gzipped {
51
+
let mut decoder = GzDecoder::new(¤t_data[..]);
52
+
let mut decompressed = Vec::new();
53
+
decoder.read_to_end(&mut decompressed).into_diagnostic()?;
54
+
current_data = decompressed;
55
+
}
56
+
57
+
Ok(current_data)
58
+
}
59
+
60
+
/// Download and decompress a blob
61
+
pub async fn download_and_decompress_blob(
62
+
pds_url: &Url,
63
+
blob_ref: &BlobRef<'_>,
64
+
did: &str,
65
+
is_base64: bool,
66
+
is_gzipped: bool,
67
+
) -> miette::Result<Vec<u8>> {
68
+
let data = download_blob(pds_url, blob_ref, did).await?;
69
+
decompress_blob(&data, is_base64, is_gzipped)
70
+
}
71
+
+243
-56
cli/src/main.rs
+243
-56
cli/src/main.rs
···
1
1
mod builder_types;
2
2
mod place_wisp;
3
+
mod cid;
4
+
mod blob_map;
5
+
mod metadata;
6
+
mod download;
7
+
mod pull;
8
+
mod serve;
3
9
4
-
use clap::Parser;
10
+
use clap::{Parser, Subcommand};
5
11
use jacquard::CowStr;
6
-
use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession};
12
+
use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession, AgentSession};
7
13
use jacquard::oauth::client::OAuthClient;
8
14
use jacquard::oauth::loopback::LoopbackConfig;
9
15
use jacquard::prelude::IdentityResolver;
···
11
17
use jacquard_common::types::blob::MimeType;
12
18
use miette::IntoDiagnostic;
13
19
use std::path::{Path, PathBuf};
20
+
use std::collections::HashMap;
14
21
use flate2::Compression;
15
22
use flate2::write::GzEncoder;
16
23
use std::io::Write;
···
20
27
use place_wisp::fs::*;
21
28
22
29
#[derive(Parser, Debug)]
23
-
#[command(author, version, about = "Deploy a static site to wisp.place")]
30
+
#[command(author, version, about = "wisp.place CLI tool")]
24
31
struct Args {
32
+
#[command(subcommand)]
33
+
command: Option<Commands>,
34
+
35
+
// Deploy arguments (when no subcommand is specified)
25
36
/// Handle (e.g., alice.bsky.social), DID, or PDS URL
26
-
input: CowStr<'static>,
37
+
#[arg(global = true, conflicts_with = "command")]
38
+
input: Option<CowStr<'static>>,
27
39
28
40
/// Path to the directory containing your static site
29
-
#[arg(short, long, default_value = ".")]
30
-
path: PathBuf,
41
+
#[arg(short, long, global = true, conflicts_with = "command")]
42
+
path: Option<PathBuf>,
31
43
32
44
/// Site name (defaults to directory name)
33
-
#[arg(short, long)]
45
+
#[arg(short, long, global = true, conflicts_with = "command")]
34
46
site: Option<String>,
35
47
36
-
/// Path to auth store file (will be created if missing, only used with OAuth)
37
-
#[arg(long, default_value = "/tmp/wisp-oauth-session.json")]
38
-
store: String,
48
+
/// Path to auth store file
49
+
#[arg(long, global = true, conflicts_with = "command")]
50
+
store: Option<String>,
39
51
40
-
/// App Password for authentication (alternative to OAuth)
41
-
#[arg(long)]
52
+
/// App Password for authentication
53
+
#[arg(long, global = true, conflicts_with = "command")]
42
54
password: Option<CowStr<'static>>,
43
55
}
44
56
57
+
#[derive(Subcommand, Debug)]
58
+
enum Commands {
59
+
/// Deploy a static site to wisp.place (default command)
60
+
Deploy {
61
+
/// Handle (e.g., alice.bsky.social), DID, or PDS URL
62
+
input: CowStr<'static>,
63
+
64
+
/// Path to the directory containing your static site
65
+
#[arg(short, long, default_value = ".")]
66
+
path: PathBuf,
67
+
68
+
/// Site name (defaults to directory name)
69
+
#[arg(short, long)]
70
+
site: Option<String>,
71
+
72
+
/// Path to auth store file (will be created if missing, only used with OAuth)
73
+
#[arg(long, default_value = "/tmp/wisp-oauth-session.json")]
74
+
store: String,
75
+
76
+
/// App Password for authentication (alternative to OAuth)
77
+
#[arg(long)]
78
+
password: Option<CowStr<'static>>,
79
+
},
80
+
/// Pull a site from the PDS to a local directory
81
+
Pull {
82
+
/// Handle (e.g., alice.bsky.social) or DID
83
+
input: CowStr<'static>,
84
+
85
+
/// Site name (record key)
86
+
#[arg(short, long)]
87
+
site: String,
88
+
89
+
/// Output directory for the downloaded site
90
+
#[arg(short, long, default_value = ".")]
91
+
output: PathBuf,
92
+
},
93
+
/// Serve a site locally with real-time firehose updates
94
+
Serve {
95
+
/// Handle (e.g., alice.bsky.social) or DID
96
+
input: CowStr<'static>,
97
+
98
+
/// Site name (record key)
99
+
#[arg(short, long)]
100
+
site: String,
101
+
102
+
/// Output directory for the site files
103
+
#[arg(short, long, default_value = ".")]
104
+
output: PathBuf,
105
+
106
+
/// Port to serve on
107
+
#[arg(short, long, default_value = "8080")]
108
+
port: u16,
109
+
},
110
+
}
111
+
45
112
#[tokio::main]
46
113
async fn main() -> miette::Result<()> {
47
114
let args = Args::parse();
48
115
49
-
// Dispatch to appropriate authentication method
50
-
if let Some(password) = args.password {
51
-
run_with_app_password(args.input, password, args.path, args.site).await
52
-
} else {
53
-
run_with_oauth(args.input, args.store, args.path, args.site).await
116
+
match args.command {
117
+
Some(Commands::Deploy { input, path, site, store, password }) => {
118
+
// Dispatch to appropriate authentication method
119
+
if let Some(password) = password {
120
+
run_with_app_password(input, password, path, site).await
121
+
} else {
122
+
run_with_oauth(input, store, path, site).await
123
+
}
124
+
}
125
+
Some(Commands::Pull { input, site, output }) => {
126
+
pull::pull_site(input, CowStr::from(site), output).await
127
+
}
128
+
Some(Commands::Serve { input, site, output, port }) => {
129
+
serve::serve_site(input, CowStr::from(site), output, port).await
130
+
}
131
+
None => {
132
+
// Legacy mode: if input is provided, assume deploy command
133
+
if let Some(input) = args.input {
134
+
let path = args.path.unwrap_or_else(|| PathBuf::from("."));
135
+
let store = args.store.unwrap_or_else(|| "/tmp/wisp-oauth-session.json".to_string());
136
+
137
+
// Dispatch to appropriate authentication method
138
+
if let Some(password) = args.password {
139
+
run_with_app_password(input, password, path, args.site).await
140
+
} else {
141
+
run_with_oauth(input, store, path, args.site).await
142
+
}
143
+
} else {
144
+
// No command and no input, show help
145
+
use clap::CommandFactory;
146
+
Args::command().print_help().into_diagnostic()?;
147
+
Ok(())
148
+
}
149
+
}
54
150
}
55
151
}
56
152
···
107
203
108
204
println!("Deploying site '{}'...", site_name);
109
205
110
-
// Build directory tree
111
-
let root_dir = build_directory(agent, &path).await?;
206
+
// Try to fetch existing manifest for incremental updates
207
+
let existing_blob_map: HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)> = {
208
+
use jacquard_common::types::string::AtUri;
209
+
210
+
// Get the DID for this session
211
+
let session_info = agent.session_info().await;
212
+
if let Some((did, _)) = session_info {
213
+
// Construct the AT URI for the record
214
+
let uri_string = format!("at://{}/place.wisp.fs/{}", did, site_name);
215
+
if let Ok(uri) = AtUri::new(&uri_string) {
216
+
match agent.get_record::<Fs>(&uri).await {
217
+
Ok(response) => {
218
+
match response.into_output() {
219
+
Ok(record_output) => {
220
+
let existing_manifest = record_output.value;
221
+
let blob_map = blob_map::extract_blob_map(&existing_manifest.root);
222
+
println!("Found existing manifest with {} files, checking for changes...", blob_map.len());
223
+
blob_map
224
+
}
225
+
Err(_) => {
226
+
println!("No existing manifest found, uploading all files...");
227
+
HashMap::new()
228
+
}
229
+
}
230
+
}
231
+
Err(_) => {
232
+
// Record doesn't exist yet - this is a new site
233
+
println!("No existing manifest found, uploading all files...");
234
+
HashMap::new()
235
+
}
236
+
}
237
+
} else {
238
+
println!("No existing manifest found (invalid URI), uploading all files...");
239
+
HashMap::new()
240
+
}
241
+
} else {
242
+
println!("No existing manifest found (could not get DID), uploading all files...");
243
+
HashMap::new()
244
+
}
245
+
};
112
246
113
-
// Count total files
114
-
let file_count = count_files(&root_dir);
247
+
// Build directory tree
248
+
let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?;
249
+
let uploaded_count = total_files - reused_count;
115
250
116
251
// Create the Fs record
117
252
let fs_record = Fs::new()
118
253
.site(CowStr::from(site_name.clone()))
119
254
.root(root_dir)
120
-
.file_count(file_count as i64)
255
+
.file_count(total_files as i64)
121
256
.created_at(Datetime::now())
122
257
.build();
123
258
···
132
267
.and_then(|s| s.split('/').next())
133
268
.ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?;
134
269
135
-
println!("Deployed site '{}': {}", site_name, output.uri);
136
-
println!("Available at: https://sites.wisp.place/{}/{}", did, site_name);
270
+
println!("\nโ Deployed site '{}': {}", site_name, output.uri);
271
+
println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count);
272
+
println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name);
137
273
138
274
Ok(())
139
275
}
140
276
141
277
/// Recursively build a Directory from a filesystem path
278
+
/// current_path is the path from the root of the site (e.g., "" for root, "config" for config dir)
142
279
fn build_directory<'a>(
143
280
agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>,
144
281
dir_path: &'a Path,
145
-
) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>>
282
+
existing_blobs: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
283
+
current_path: String,
284
+
) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<(Directory<'static>, usize, usize)>> + 'a>>
146
285
{
147
286
Box::pin(async move {
148
287
// Collect all directory entries first
···
170
309
let metadata = entry.metadata().into_diagnostic()?;
171
310
172
311
if metadata.is_file() {
173
-
file_tasks.push((name_str, path));
312
+
// Construct full path for this file (for blob map lookup)
313
+
let full_path = if current_path.is_empty() {
314
+
name_str.clone()
315
+
} else {
316
+
format!("{}/{}", current_path, name_str)
317
+
};
318
+
file_tasks.push((name_str, path, full_path));
174
319
} else if metadata.is_dir() {
175
320
dir_tasks.push((name_str, path));
176
321
}
177
322
}
178
323
179
324
// Process files concurrently with a limit of 5
180
-
let file_entries: Vec<Entry> = stream::iter(file_tasks)
181
-
.map(|(name, path)| async move {
182
-
let file_node = process_file(agent, &path).await?;
183
-
Ok::<_, miette::Report>(Entry::new()
325
+
let file_results: Vec<(Entry<'static>, bool)> = stream::iter(file_tasks)
326
+
.map(|(name, path, full_path)| async move {
327
+
let (file_node, reused) = process_file(agent, &path, &full_path, existing_blobs).await?;
328
+
let entry = Entry::new()
184
329
.name(CowStr::from(name))
185
330
.node(EntryNode::File(Box::new(file_node)))
186
-
.build())
331
+
.build();
332
+
Ok::<_, miette::Report>((entry, reused))
187
333
})
188
334
.buffer_unordered(5)
189
335
.collect::<Vec<_>>()
190
336
.await
191
337
.into_iter()
192
338
.collect::<miette::Result<Vec<_>>>()?;
339
+
340
+
let mut file_entries = Vec::new();
341
+
let mut reused_count = 0;
342
+
let mut total_files = 0;
343
+
344
+
for (entry, reused) in file_results {
345
+
file_entries.push(entry);
346
+
total_files += 1;
347
+
if reused {
348
+
reused_count += 1;
349
+
}
350
+
}
193
351
194
352
// Process directories recursively (sequentially to avoid too much nesting)
195
353
let mut dir_entries = Vec::new();
196
354
for (name, path) in dir_tasks {
197
-
let subdir = build_directory(agent, &path).await?;
355
+
// Construct full path for subdirectory
356
+
let subdir_path = if current_path.is_empty() {
357
+
name.clone()
358
+
} else {
359
+
format!("{}/{}", current_path, name)
360
+
};
361
+
let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs, subdir_path).await?;
198
362
dir_entries.push(Entry::new()
199
363
.name(CowStr::from(name))
200
364
.node(EntryNode::Directory(Box::new(subdir)))
201
365
.build());
366
+
total_files += sub_total;
367
+
reused_count += sub_reused;
202
368
}
203
369
204
370
// Combine file and directory entries
205
371
let mut entries = file_entries;
206
372
entries.extend(dir_entries);
207
373
208
-
Ok(Directory::new()
374
+
let directory = Directory::new()
209
375
.r#type(CowStr::from("directory"))
210
376
.entries(entries)
211
-
.build())
377
+
.build();
378
+
379
+
Ok((directory, total_files, reused_count))
212
380
})
213
381
}
214
382
215
-
/// Process a single file: gzip -> base64 -> upload blob
383
+
/// Process a single file: gzip -> base64 -> upload blob (or reuse existing)
384
+
/// Returns (File, reused: bool)
385
+
/// file_path_key is the full path from the site root (e.g., "config/file.json") for blob map lookup
216
386
async fn process_file(
217
387
agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
218
388
file_path: &Path,
219
-
) -> miette::Result<File<'static>>
389
+
file_path_key: &str,
390
+
existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
391
+
) -> miette::Result<(File<'static>, bool)>
220
392
{
221
393
// Read file
222
394
let file_data = std::fs::read(file_path).into_diagnostic()?;
···
234
406
// Base64 encode the gzipped data
235
407
let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
236
408
237
-
// Upload blob as octet-stream
409
+
// Compute CID for this file (CRITICAL: on base64-encoded gzipped content)
410
+
let file_cid = cid::compute_cid(&base64_bytes);
411
+
412
+
// Check if we have an existing blob with the same CID
413
+
let existing_blob = existing_blobs.get(file_path_key);
414
+
415
+
if let Some((existing_blob_ref, existing_cid)) = existing_blob {
416
+
if existing_cid == &file_cid {
417
+
// CIDs match - reuse existing blob
418
+
println!(" โ Reusing blob for {} (CID: {})", file_path_key, file_cid);
419
+
return Ok((
420
+
File::new()
421
+
.r#type(CowStr::from("file"))
422
+
.blob(existing_blob_ref.clone())
423
+
.encoding(CowStr::from("gzip"))
424
+
.mime_type(CowStr::from(original_mime))
425
+
.base64(true)
426
+
.build(),
427
+
true
428
+
));
429
+
}
430
+
}
431
+
432
+
// File is new or changed - upload it
433
+
println!(" โ Uploading {} ({} bytes, CID: {})", file_path_key, base64_bytes.len(), file_cid);
238
434
let blob = agent.upload_blob(
239
435
base64_bytes,
240
436
MimeType::new_static("application/octet-stream"),
241
437
).await?;
242
438
243
-
Ok(File::new()
244
-
.r#type(CowStr::from("file"))
245
-
.blob(blob)
246
-
.encoding(CowStr::from("gzip"))
247
-
.mime_type(CowStr::from(original_mime))
248
-
.base64(true)
249
-
.build())
439
+
Ok((
440
+
File::new()
441
+
.r#type(CowStr::from("file"))
442
+
.blob(blob)
443
+
.encoding(CowStr::from("gzip"))
444
+
.mime_type(CowStr::from(original_mime))
445
+
.base64(true)
446
+
.build(),
447
+
false
448
+
))
250
449
}
251
450
252
-
/// Count total files in a directory tree
253
-
fn count_files(dir: &Directory) -> usize {
254
-
let mut count = 0;
255
-
for entry in &dir.entries {
256
-
match &entry.node {
257
-
EntryNode::File(_) => count += 1,
258
-
EntryNode::Directory(subdir) => count += count_files(subdir),
259
-
_ => {} // Unknown variants
260
-
}
261
-
}
262
-
count
263
-
}
+46
cli/src/metadata.rs
+46
cli/src/metadata.rs
···
1
+
use serde::{Deserialize, Serialize};
2
+
use std::collections::HashMap;
3
+
use std::path::Path;
4
+
use miette::IntoDiagnostic;
5
+
6
+
/// Metadata tracking file CIDs for incremental updates
7
+
#[derive(Debug, Clone, Serialize, Deserialize)]
8
+
pub struct SiteMetadata {
9
+
/// Record CID from the PDS
10
+
pub record_cid: String,
11
+
/// Map of file paths to their blob CIDs
12
+
pub file_cids: HashMap<String, String>,
13
+
/// Timestamp when the site was last synced
14
+
pub last_sync: i64,
15
+
}
16
+
17
+
impl SiteMetadata {
18
+
pub fn new(record_cid: String, file_cids: HashMap<String, String>) -> Self {
19
+
Self {
20
+
record_cid,
21
+
file_cids,
22
+
last_sync: chrono::Utc::now().timestamp(),
23
+
}
24
+
}
25
+
26
+
/// Load metadata from a directory
27
+
pub fn load(dir: &Path) -> miette::Result<Option<Self>> {
28
+
let metadata_path = dir.join(".wisp-metadata.json");
29
+
if !metadata_path.exists() {
30
+
return Ok(None);
31
+
}
32
+
33
+
let contents = std::fs::read_to_string(&metadata_path).into_diagnostic()?;
34
+
let metadata: SiteMetadata = serde_json::from_str(&contents).into_diagnostic()?;
35
+
Ok(Some(metadata))
36
+
}
37
+
38
+
/// Save metadata to a directory
39
+
pub fn save(&self, dir: &Path) -> miette::Result<()> {
40
+
let metadata_path = dir.join(".wisp-metadata.json");
41
+
let contents = serde_json::to_string_pretty(self).into_diagnostic()?;
42
+
std::fs::write(&metadata_path, contents).into_diagnostic()?;
43
+
Ok(())
44
+
}
45
+
}
46
+
+305
cli/src/pull.rs
+305
cli/src/pull.rs
···
1
+
use crate::blob_map;
2
+
use crate::download;
3
+
use crate::metadata::SiteMetadata;
4
+
use crate::place_wisp::fs::*;
5
+
use jacquard::CowStr;
6
+
use jacquard::prelude::IdentityResolver;
7
+
use jacquard_common::types::string::Did;
8
+
use jacquard_common::xrpc::XrpcExt;
9
+
use jacquard_identity::PublicResolver;
10
+
use miette::IntoDiagnostic;
11
+
use std::collections::HashMap;
12
+
use std::path::{Path, PathBuf};
13
+
use url::Url;
14
+
15
+
/// Pull a site from the PDS to a local directory
16
+
pub async fn pull_site(
17
+
input: CowStr<'static>,
18
+
rkey: CowStr<'static>,
19
+
output_dir: PathBuf,
20
+
) -> miette::Result<()> {
21
+
println!("Pulling site {} from {}...", rkey, input);
22
+
23
+
// Resolve handle to DID if needed
24
+
let resolver = PublicResolver::default();
25
+
let did = if input.starts_with("did:") {
26
+
Did::new(&input).into_diagnostic()?
27
+
} else {
28
+
// It's a handle, resolve it
29
+
let handle = jacquard_common::types::string::Handle::new(&input).into_diagnostic()?;
30
+
resolver.resolve_handle(&handle).await.into_diagnostic()?
31
+
};
32
+
33
+
// Resolve PDS endpoint for the DID
34
+
let pds_url = resolver.pds_for_did(&did).await.into_diagnostic()?;
35
+
println!("Resolved PDS: {}", pds_url);
36
+
37
+
// Fetch the place.wisp.fs record
38
+
39
+
println!("Fetching record from PDS...");
40
+
let client = reqwest::Client::new();
41
+
42
+
// Use com.atproto.repo.getRecord
43
+
use jacquard::api::com_atproto::repo::get_record::GetRecord;
44
+
use jacquard_common::types::string::Rkey as RkeyType;
45
+
let rkey_parsed = RkeyType::new(&rkey).into_diagnostic()?;
46
+
47
+
use jacquard_common::types::ident::AtIdentifier;
48
+
use jacquard_common::types::string::RecordKey;
49
+
let request = GetRecord::new()
50
+
.repo(AtIdentifier::Did(did.clone()))
51
+
.collection(CowStr::from("place.wisp.fs"))
52
+
.rkey(RecordKey::from(rkey_parsed))
53
+
.build();
54
+
55
+
let response = client
56
+
.xrpc(pds_url.clone())
57
+
.send(&request)
58
+
.await
59
+
.into_diagnostic()?;
60
+
61
+
let record_output = response.into_output().into_diagnostic()?;
62
+
let record_cid = record_output.cid.as_ref().map(|c| c.to_string()).unwrap_or_default();
63
+
64
+
// Parse the record value as Fs
65
+
use jacquard_common::types::value::from_data;
66
+
let fs_record: Fs = from_data(&record_output.value).into_diagnostic()?;
67
+
68
+
let file_count = fs_record.file_count.map(|c| c.to_string()).unwrap_or_else(|| "?".to_string());
69
+
println!("Found site '{}' with {} files", fs_record.site, file_count);
70
+
71
+
// Load existing metadata for incremental updates
72
+
let existing_metadata = SiteMetadata::load(&output_dir)?;
73
+
let existing_file_cids = existing_metadata
74
+
.as_ref()
75
+
.map(|m| m.file_cids.clone())
76
+
.unwrap_or_default();
77
+
78
+
// Extract blob map from the new manifest
79
+
let new_blob_map = blob_map::extract_blob_map(&fs_record.root);
80
+
let new_file_cids: HashMap<String, String> = new_blob_map
81
+
.iter()
82
+
.map(|(path, (_blob_ref, cid))| (path.clone(), cid.clone()))
83
+
.collect();
84
+
85
+
// Clean up any leftover temp directories from previous failed attempts
86
+
let parent = output_dir.parent().unwrap_or_else(|| std::path::Path::new("."));
87
+
let output_name = output_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("site")).to_string_lossy();
88
+
let temp_prefix = format!(".tmp-{}-", output_name);
89
+
90
+
if let Ok(entries) = parent.read_dir() {
91
+
for entry in entries.flatten() {
92
+
let name = entry.file_name();
93
+
if name.to_string_lossy().starts_with(&temp_prefix) {
94
+
let _ = std::fs::remove_dir_all(entry.path());
95
+
}
96
+
}
97
+
}
98
+
99
+
// Check if we need to update (but only if output directory actually exists with files)
100
+
if let Some(metadata) = &existing_metadata {
101
+
if metadata.record_cid == record_cid {
102
+
// Verify that the output directory actually exists and has content
103
+
let has_content = output_dir.exists() &&
104
+
output_dir.read_dir()
105
+
.map(|mut entries| entries.any(|e| {
106
+
if let Ok(entry) = e {
107
+
!entry.file_name().to_string_lossy().starts_with(".wisp-metadata")
108
+
} else {
109
+
false
110
+
}
111
+
}))
112
+
.unwrap_or(false);
113
+
114
+
if has_content {
115
+
println!("Site is already up to date!");
116
+
return Ok(());
117
+
}
118
+
}
119
+
}
120
+
121
+
// Create temporary directory for atomic update
122
+
// Place temp dir in parent directory to avoid issues with non-existent output_dir
123
+
let parent = output_dir.parent().unwrap_or_else(|| std::path::Path::new("."));
124
+
let temp_dir_name = format!(
125
+
".tmp-{}-{}",
126
+
output_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("site")).to_string_lossy(),
127
+
chrono::Utc::now().timestamp()
128
+
);
129
+
let temp_dir = parent.join(temp_dir_name);
130
+
std::fs::create_dir_all(&temp_dir).into_diagnostic()?;
131
+
132
+
println!("Downloading files...");
133
+
let mut downloaded = 0;
134
+
let mut reused = 0;
135
+
136
+
// Download files recursively
137
+
let download_result = download_directory(
138
+
&fs_record.root,
139
+
&temp_dir,
140
+
&pds_url,
141
+
did.as_str(),
142
+
&new_blob_map,
143
+
&existing_file_cids,
144
+
&output_dir,
145
+
String::new(),
146
+
&mut downloaded,
147
+
&mut reused,
148
+
)
149
+
.await;
150
+
151
+
// If download failed, clean up temp directory
152
+
if let Err(e) = download_result {
153
+
let _ = std::fs::remove_dir_all(&temp_dir);
154
+
return Err(e);
155
+
}
156
+
157
+
println!(
158
+
"Downloaded {} files, reused {} files",
159
+
downloaded, reused
160
+
);
161
+
162
+
// Save metadata
163
+
let metadata = SiteMetadata::new(record_cid, new_file_cids);
164
+
metadata.save(&temp_dir)?;
165
+
166
+
// Move files from temp to output directory
167
+
let output_abs = std::fs::canonicalize(&output_dir).unwrap_or_else(|_| output_dir.clone());
168
+
let current_dir = std::env::current_dir().into_diagnostic()?;
169
+
170
+
// Special handling for pulling to current directory
171
+
if output_abs == current_dir {
172
+
// Move files from temp to current directory
173
+
for entry in std::fs::read_dir(&temp_dir).into_diagnostic()? {
174
+
let entry = entry.into_diagnostic()?;
175
+
let dest = current_dir.join(entry.file_name());
176
+
177
+
// Remove existing file/dir if it exists
178
+
if dest.exists() {
179
+
if dest.is_dir() {
180
+
std::fs::remove_dir_all(&dest).into_diagnostic()?;
181
+
} else {
182
+
std::fs::remove_file(&dest).into_diagnostic()?;
183
+
}
184
+
}
185
+
186
+
// Move from temp to current dir
187
+
std::fs::rename(entry.path(), dest).into_diagnostic()?;
188
+
}
189
+
190
+
// Clean up temp directory
191
+
std::fs::remove_dir_all(&temp_dir).into_diagnostic()?;
192
+
} else {
193
+
// If output directory exists and has content, remove it first
194
+
if output_dir.exists() {
195
+
std::fs::remove_dir_all(&output_dir).into_diagnostic()?;
196
+
}
197
+
198
+
// Ensure parent directory exists
199
+
if let Some(parent) = output_dir.parent() {
200
+
if !parent.as_os_str().is_empty() && !parent.exists() {
201
+
std::fs::create_dir_all(parent).into_diagnostic()?;
202
+
}
203
+
}
204
+
205
+
// Rename temp to final location
206
+
match std::fs::rename(&temp_dir, &output_dir) {
207
+
Ok(_) => {},
208
+
Err(e) => {
209
+
// Clean up temp directory on failure
210
+
let _ = std::fs::remove_dir_all(&temp_dir);
211
+
return Err(miette::miette!("Failed to move temp directory: {}", e));
212
+
}
213
+
}
214
+
}
215
+
216
+
println!("โ Site pulled successfully to {}", output_dir.display());
217
+
218
+
Ok(())
219
+
}
220
+
221
+
/// Recursively download a directory
222
+
fn download_directory<'a>(
223
+
dir: &'a Directory<'_>,
224
+
output_dir: &'a Path,
225
+
pds_url: &'a Url,
226
+
did: &'a str,
227
+
new_blob_map: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
228
+
existing_file_cids: &'a HashMap<String, String>,
229
+
existing_output_dir: &'a Path,
230
+
path_prefix: String,
231
+
downloaded: &'a mut usize,
232
+
reused: &'a mut usize,
233
+
) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send + 'a>> {
234
+
Box::pin(async move {
235
+
for entry in &dir.entries {
236
+
let entry_name = entry.name.as_str();
237
+
let current_path = if path_prefix.is_empty() {
238
+
entry_name.to_string()
239
+
} else {
240
+
format!("{}/{}", path_prefix, entry_name)
241
+
};
242
+
243
+
match &entry.node {
244
+
EntryNode::File(file) => {
245
+
let output_path = output_dir.join(entry_name);
246
+
247
+
// Check if file CID matches existing
248
+
if let Some((_blob_ref, new_cid)) = new_blob_map.get(¤t_path) {
249
+
if let Some(existing_cid) = existing_file_cids.get(¤t_path) {
250
+
if existing_cid == new_cid {
251
+
// File unchanged, copy from existing directory
252
+
let existing_path = existing_output_dir.join(¤t_path);
253
+
if existing_path.exists() {
254
+
std::fs::copy(&existing_path, &output_path).into_diagnostic()?;
255
+
*reused += 1;
256
+
println!(" โ Reused {}", current_path);
257
+
continue;
258
+
}
259
+
}
260
+
}
261
+
}
262
+
263
+
// File is new or changed, download it
264
+
println!(" โ Downloading {}", current_path);
265
+
let data = download::download_and_decompress_blob(
266
+
pds_url,
267
+
&file.blob,
268
+
did,
269
+
file.base64.unwrap_or(false),
270
+
file.encoding.as_ref().map(|e| e.as_str() == "gzip").unwrap_or(false),
271
+
)
272
+
.await?;
273
+
274
+
std::fs::write(&output_path, data).into_diagnostic()?;
275
+
*downloaded += 1;
276
+
}
277
+
EntryNode::Directory(subdir) => {
278
+
let subdir_path = output_dir.join(entry_name);
279
+
std::fs::create_dir_all(&subdir_path).into_diagnostic()?;
280
+
281
+
download_directory(
282
+
subdir,
283
+
&subdir_path,
284
+
pds_url,
285
+
did,
286
+
new_blob_map,
287
+
existing_file_cids,
288
+
existing_output_dir,
289
+
current_path,
290
+
downloaded,
291
+
reused,
292
+
)
293
+
.await?;
294
+
}
295
+
EntryNode::Unknown(_) => {
296
+
// Skip unknown node types
297
+
println!(" โ Skipping unknown node type for {}", current_path);
298
+
}
299
+
}
300
+
}
301
+
302
+
Ok(())
303
+
})
304
+
}
305
+
+202
cli/src/serve.rs
+202
cli/src/serve.rs
···
1
+
use crate::pull::pull_site;
2
+
use axum::Router;
3
+
use jacquard::CowStr;
4
+
use jacquard_common::jetstream::{CommitOperation, JetstreamMessage, JetstreamParams};
5
+
use jacquard_common::types::string::Did;
6
+
use jacquard_common::xrpc::{SubscriptionClient, TungsteniteSubscriptionClient};
7
+
use miette::IntoDiagnostic;
8
+
use n0_future::StreamExt;
9
+
use std::path::PathBuf;
10
+
use std::sync::Arc;
11
+
use tokio::sync::RwLock;
12
+
use tower_http::compression::CompressionLayer;
13
+
use tower_http::services::ServeDir;
14
+
use url::Url;
15
+
16
+
/// Shared state for the server
17
+
#[derive(Clone)]
18
+
struct ServerState {
19
+
did: CowStr<'static>,
20
+
rkey: CowStr<'static>,
21
+
output_dir: PathBuf,
22
+
last_cid: Arc<RwLock<Option<String>>>,
23
+
}
24
+
25
+
/// Serve a site locally with real-time firehose updates
26
+
pub async fn serve_site(
27
+
input: CowStr<'static>,
28
+
rkey: CowStr<'static>,
29
+
output_dir: PathBuf,
30
+
port: u16,
31
+
) -> miette::Result<()> {
32
+
println!("Serving site {} from {} on port {}...", rkey, input, port);
33
+
34
+
// Resolve handle to DID if needed
35
+
use jacquard_identity::PublicResolver;
36
+
use jacquard::prelude::IdentityResolver;
37
+
38
+
let resolver = PublicResolver::default();
39
+
let did = if input.starts_with("did:") {
40
+
Did::new(&input).into_diagnostic()?
41
+
} else {
42
+
// It's a handle, resolve it
43
+
let handle = jacquard_common::types::string::Handle::new(&input).into_diagnostic()?;
44
+
resolver.resolve_handle(&handle).await.into_diagnostic()?
45
+
};
46
+
47
+
println!("Resolved to DID: {}", did.as_str());
48
+
49
+
// Create output directory if it doesn't exist
50
+
std::fs::create_dir_all(&output_dir).into_diagnostic()?;
51
+
52
+
// Initial pull of the site
53
+
println!("Performing initial pull...");
54
+
let did_str = CowStr::from(did.as_str().to_string());
55
+
pull_site(did_str.clone(), rkey.clone(), output_dir.clone()).await?;
56
+
57
+
// Create shared state
58
+
let state = ServerState {
59
+
did: did_str.clone(),
60
+
rkey: rkey.clone(),
61
+
output_dir: output_dir.clone(),
62
+
last_cid: Arc::new(RwLock::new(None)),
63
+
};
64
+
65
+
// Start firehose listener in background
66
+
let firehose_state = state.clone();
67
+
tokio::spawn(async move {
68
+
if let Err(e) = watch_firehose(firehose_state).await {
69
+
eprintln!("Firehose error: {}", e);
70
+
}
71
+
});
72
+
73
+
// Create HTTP server with gzip compression
74
+
let app = Router::new()
75
+
.fallback_service(
76
+
ServeDir::new(&output_dir)
77
+
.precompressed_gzip()
78
+
)
79
+
.layer(CompressionLayer::new())
80
+
.with_state(state);
81
+
82
+
let addr = format!("0.0.0.0:{}", port);
83
+
let listener = tokio::net::TcpListener::bind(&addr)
84
+
.await
85
+
.into_diagnostic()?;
86
+
87
+
println!("\nโ Server running at http://localhost:{}", port);
88
+
println!(" Watching for updates on the firehose...\n");
89
+
90
+
axum::serve(listener, app).await.into_diagnostic()?;
91
+
92
+
Ok(())
93
+
}
94
+
95
+
/// Watch the firehose for updates to the specific site
96
+
fn watch_firehose(state: ServerState) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send>> {
97
+
Box::pin(async move {
98
+
let jetstream_url = Url::parse("wss://jetstream1.us-east.fire.hose.cam")
99
+
.into_diagnostic()?;
100
+
101
+
println!("[Firehose] Connecting to Jetstream...");
102
+
103
+
// Create subscription client
104
+
let client = TungsteniteSubscriptionClient::from_base_uri(jetstream_url);
105
+
106
+
// Subscribe with no filters (we'll filter manually)
107
+
// Jetstream doesn't support filtering by collection in the params builder
108
+
let params = JetstreamParams::new().build();
109
+
110
+
let stream = client.subscribe(¶ms).await.into_diagnostic()?;
111
+
println!("[Firehose] Connected! Watching for updates...");
112
+
113
+
// Convert to typed message stream
114
+
let (_sink, mut messages) = stream.into_stream();
115
+
116
+
loop {
117
+
match messages.next().await {
118
+
Some(Ok(msg)) => {
119
+
if let Err(e) = handle_firehose_message(&state, msg).await {
120
+
eprintln!("[Firehose] Error handling message: {}", e);
121
+
}
122
+
}
123
+
Some(Err(e)) => {
124
+
eprintln!("[Firehose] Stream error: {}", e);
125
+
// Try to reconnect after a delay
126
+
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
127
+
return Box::pin(watch_firehose(state)).await;
128
+
}
129
+
None => {
130
+
println!("[Firehose] Stream ended, reconnecting...");
131
+
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
132
+
return Box::pin(watch_firehose(state)).await;
133
+
}
134
+
}
135
+
}
136
+
})
137
+
}
138
+
139
+
/// Handle a firehose message
140
+
async fn handle_firehose_message(
141
+
state: &ServerState,
142
+
msg: JetstreamMessage<'_>,
143
+
) -> miette::Result<()> {
144
+
match msg {
145
+
JetstreamMessage::Commit {
146
+
did,
147
+
commit,
148
+
..
149
+
} => {
150
+
// Check if this is our site
151
+
if did.as_str() == state.did.as_str()
152
+
&& commit.collection.as_str() == "place.wisp.fs"
153
+
&& commit.rkey.as_str() == state.rkey.as_str()
154
+
{
155
+
match commit.operation {
156
+
CommitOperation::Create | CommitOperation::Update => {
157
+
let new_cid = commit.cid.as_ref().map(|c| c.to_string());
158
+
159
+
// Check if CID changed
160
+
let should_update = {
161
+
let last_cid = state.last_cid.read().await;
162
+
new_cid != *last_cid
163
+
};
164
+
165
+
if should_update {
166
+
println!("\n[Update] Detected change to site {} (CID: {:?})", state.rkey, new_cid);
167
+
println!("[Update] Pulling latest version...");
168
+
169
+
// Pull the updated site
170
+
match pull_site(
171
+
state.did.clone(),
172
+
state.rkey.clone(),
173
+
state.output_dir.clone(),
174
+
)
175
+
.await
176
+
{
177
+
Ok(_) => {
178
+
// Update last CID
179
+
let mut last_cid = state.last_cid.write().await;
180
+
*last_cid = new_cid;
181
+
println!("[Update] โ Site updated successfully!\n");
182
+
}
183
+
Err(e) => {
184
+
eprintln!("[Update] Failed to pull site: {}", e);
185
+
}
186
+
}
187
+
}
188
+
}
189
+
CommitOperation::Delete => {
190
+
println!("\n[Update] Site {} was deleted", state.rkey);
191
+
}
192
+
}
193
+
}
194
+
}
195
+
_ => {
196
+
// Ignore identity and account messages
197
+
}
198
+
}
199
+
200
+
Ok(())
201
+
}
202
+
+28
-1
crates.nix
+28
-1
crates.nix
···
19
19
targets.x86_64-pc-windows-gnu.latest.rust-std
20
20
targets.x86_64-unknown-linux-gnu.latest.rust-std
21
21
targets.aarch64-apple-darwin.latest.rust-std
22
+
targets.aarch64-unknown-linux-gnu.latest.rust-std
22
23
];
23
24
# configure crates
24
25
nci.crates."wisp-cli" = {
···
26
27
dev.runTests = false;
27
28
release.runTests = false;
28
29
};
29
-
targets."x86_64-unknown-linux-gnu" = {
30
+
targets."x86_64-unknown-linux-gnu" = let
31
+
targetPkgs = pkgs.pkgsCross.gnu64;
32
+
targetCC = targetPkgs.stdenv.cc;
33
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
34
+
in rec {
30
35
default = true;
36
+
depsDrvConfig.mkDerivation = {
37
+
nativeBuildInputs = [targetCC];
38
+
};
39
+
depsDrvConfig.env = rec {
40
+
TARGET_CC = "${targetCC.targetPrefix}cc";
41
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
42
+
};
43
+
drvConfig = depsDrvConfig;
31
44
};
32
45
targets."x86_64-pc-windows-gnu" = let
33
46
targetPkgs = pkgs.pkgsCross.mingwW64;
···
46
59
};
47
60
targets."aarch64-apple-darwin" = let
48
61
targetPkgs = pkgs.pkgsCross.aarch64-darwin;
62
+
targetCC = targetPkgs.stdenv.cc;
63
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
64
+
in rec {
65
+
depsDrvConfig.mkDerivation = {
66
+
nativeBuildInputs = [targetCC];
67
+
};
68
+
depsDrvConfig.env = rec {
69
+
TARGET_CC = "${targetCC.targetPrefix}cc";
70
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
71
+
};
72
+
drvConfig = depsDrvConfig;
73
+
};
74
+
targets."aarch64-unknown-linux-gnu" = let
75
+
targetPkgs = pkgs.pkgsCross.aarch64-multiplatform;
49
76
targetCC = targetPkgs.stdenv.cc;
50
77
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
51
78
in rec {
+25
-2
flake.nix
+25
-2
flake.nix
···
26
26
...
27
27
}: let
28
28
crateOutputs = config.nci.outputs."wisp-cli";
29
+
mkRenamedPackage = name: pkg: isWindows: pkgs.runCommand name {} ''
30
+
mkdir -p $out/bin
31
+
if [ -f ${pkg}/bin/wisp-cli.exe ]; then
32
+
cp ${pkg}/bin/wisp-cli.exe $out/bin/${name}
33
+
elif [ -f ${pkg}/bin/wisp-cli ]; then
34
+
cp ${pkg}/bin/wisp-cli $out/bin/${name}
35
+
else
36
+
echo "Error: Could not find wisp-cli binary in ${pkg}/bin/"
37
+
ls -la ${pkg}/bin/ || true
38
+
exit 1
39
+
fi
40
+
'';
29
41
in {
30
42
devShells.default = crateOutputs.devShell;
31
43
packages.default = crateOutputs.packages.release;
32
-
packages.wisp-cli-windows = crateOutputs.allTargets."x86_64-pc-windows-gnu".packages.release;
33
-
packages.wisp-cli-darwin = crateOutputs.allTargets."aarch64-apple-darwin".packages.release;
44
+
packages.wisp-cli-x86_64-linux = mkRenamedPackage "wisp-cli-x86_64-linux" crateOutputs.packages.release false;
45
+
packages.wisp-cli-aarch64-linux = mkRenamedPackage "wisp-cli-aarch64-linux" crateOutputs.allTargets."aarch64-unknown-linux-gnu".packages.release false;
46
+
packages.wisp-cli-x86_64-windows = mkRenamedPackage "wisp-cli-x86_64-windows.exe" crateOutputs.allTargets."x86_64-pc-windows-gnu".packages.release true;
47
+
packages.wisp-cli-aarch64-darwin = mkRenamedPackage "wisp-cli-aarch64-darwin" crateOutputs.allTargets."aarch64-apple-darwin".packages.release false;
48
+
packages.all = pkgs.symlinkJoin {
49
+
name = "wisp-cli-all";
50
+
paths = [
51
+
config.packages.wisp-cli-x86_64-linux
52
+
config.packages.wisp-cli-aarch64-linux
53
+
config.packages.wisp-cli-x86_64-windows
54
+
config.packages.wisp-cli-aarch64-darwin
55
+
];
56
+
};
34
57
};
35
58
};
36
59
}
-123
hosting-service/EXAMPLE.md
-123
hosting-service/EXAMPLE.md
···
1
-
# HTML Path Rewriting Example
2
-
3
-
This document demonstrates how HTML path rewriting works when serving sites via the `/s/:identifier/:site/*` route.
4
-
5
-
## Problem
6
-
7
-
When you create a static site with absolute paths like `/style.css` or `/images/logo.png`, these paths work fine when served from the root domain. However, when served from a subdirectory like `/s/alice.bsky.social/mysite/`, these absolute paths break because they resolve to the server root instead of the site root.
8
-
9
-
## Solution
10
-
11
-
The hosting service automatically rewrites absolute paths in HTML files to work correctly in the subdirectory context.
12
-
13
-
## Example
14
-
15
-
**Original HTML file (index.html):**
16
-
```html
17
-
<!DOCTYPE html>
18
-
<html>
19
-
<head>
20
-
<meta charset="UTF-8">
21
-
<title>My Site</title>
22
-
<link rel="stylesheet" href="/style.css">
23
-
<link rel="icon" href="/favicon.ico">
24
-
<script src="/app.js"></script>
25
-
</head>
26
-
<body>
27
-
<header>
28
-
<img src="/images/logo.png" alt="Logo">
29
-
<nav>
30
-
<a href="/">Home</a>
31
-
<a href="/about">About</a>
32
-
<a href="/contact">Contact</a>
33
-
</nav>
34
-
</header>
35
-
36
-
<main>
37
-
<h1>Welcome</h1>
38
-
<img src="/images/hero.jpg"
39
-
srcset="/images/hero.jpg 1x, /images/hero@2x.jpg 2x"
40
-
alt="Hero">
41
-
42
-
<form action="/submit" method="post">
43
-
<input type="text" name="email">
44
-
<button>Submit</button>
45
-
</form>
46
-
</main>
47
-
48
-
<footer>
49
-
<a href="https://example.com">External Link</a>
50
-
<a href="#top">Back to Top</a>
51
-
</footer>
52
-
</body>
53
-
</html>
54
-
```
55
-
56
-
**When accessed via `/s/alice.bsky.social/mysite/`, the HTML is rewritten to:**
57
-
```html
58
-
<!DOCTYPE html>
59
-
<html>
60
-
<head>
61
-
<meta charset="UTF-8">
62
-
<title>My Site</title>
63
-
<link rel="stylesheet" href="/s/alice.bsky.social/mysite/style.css">
64
-
<link rel="icon" href="/s/alice.bsky.social/mysite/favicon.ico">
65
-
<script src="/s/alice.bsky.social/mysite/app.js"></script>
66
-
</head>
67
-
<body>
68
-
<header>
69
-
<img src="/s/alice.bsky.social/mysite/images/logo.png" alt="Logo">
70
-
<nav>
71
-
<a href="/s/alice.bsky.social/mysite/">Home</a>
72
-
<a href="/s/alice.bsky.social/mysite/about">About</a>
73
-
<a href="/s/alice.bsky.social/mysite/contact">Contact</a>
74
-
</nav>
75
-
</header>
76
-
77
-
<main>
78
-
<h1>Welcome</h1>
79
-
<img src="/s/alice.bsky.social/mysite/images/hero.jpg"
80
-
srcset="/s/alice.bsky.social/mysite/images/hero.jpg 1x, /s/alice.bsky.social/mysite/images/hero@2x.jpg 2x"
81
-
alt="Hero">
82
-
83
-
<form action="/s/alice.bsky.social/mysite/submit" method="post">
84
-
<input type="text" name="email">
85
-
<button>Submit</button>
86
-
</form>
87
-
</main>
88
-
89
-
<footer>
90
-
<a href="https://example.com">External Link</a>
91
-
<a href="#top">Back to Top</a>
92
-
</footer>
93
-
</body>
94
-
</html>
95
-
```
96
-
97
-
## What's Preserved
98
-
99
-
Notice that:
100
-
- โ
Absolute paths are rewritten: `/style.css` โ `/s/alice.bsky.social/mysite/style.css`
101
-
- โ
External URLs are preserved: `https://example.com` stays the same
102
-
- โ
Anchors are preserved: `#top` stays the same
103
-
- โ
The rewriting is safe and won't break your site
104
-
105
-
## Supported Attributes
106
-
107
-
The rewriter handles these HTML attributes:
108
-
- `src` - images, scripts, iframes, videos, audio
109
-
- `href` - links, stylesheets
110
-
- `action` - forms
111
-
- `data` - objects
112
-
- `poster` - video posters
113
-
- `srcset` - responsive images
114
-
115
-
## Testing Your Site
116
-
117
-
To test if your site works with path rewriting:
118
-
119
-
1. Upload your site to your PDS as a `place.wisp.fs` record
120
-
2. Access it via: `https://hosting.wisp.place/s/YOUR_HANDLE/SITE_NAME/`
121
-
3. Check that all resources load correctly
122
-
123
-
If you're using relative paths already (like `./style.css` or `../images/logo.png`), they'll work without any rewriting.
+134
hosting-service/example-_redirects
+134
hosting-service/example-_redirects
···
1
+
# Example _redirects file for Wisp hosting
2
+
# Place this file in the root directory of your site as "_redirects"
3
+
# Lines starting with # are comments
4
+
5
+
# ===================================
6
+
# SIMPLE REDIRECTS
7
+
# ===================================
8
+
9
+
# Redirect home page
10
+
# /home /
11
+
12
+
# Redirect old URLs to new ones
13
+
# /old-blog /blog
14
+
# /about-us /about
15
+
16
+
# ===================================
17
+
# SPLAT REDIRECTS (WILDCARDS)
18
+
# ===================================
19
+
20
+
# Redirect entire directories
21
+
# /news/* /blog/:splat
22
+
# /old-site/* /new-site/:splat
23
+
24
+
# ===================================
25
+
# PLACEHOLDER REDIRECTS
26
+
# ===================================
27
+
28
+
# Restructure blog URLs
29
+
# /blog/:year/:month/:day/:slug /posts/:year-:month-:day/:slug
30
+
31
+
# Capture multiple parameters
32
+
# /products/:category/:id /shop/:category/item/:id
33
+
34
+
# ===================================
35
+
# STATUS CODES
36
+
# ===================================
37
+
38
+
# Permanent redirect (301) - default if not specified
39
+
# /permanent-move /new-location 301
40
+
41
+
# Temporary redirect (302)
42
+
# /temp-redirect /temp-location 302
43
+
44
+
# Rewrite (200) - serves different content, URL stays the same
45
+
# /api/* /functions/:splat 200
46
+
47
+
# Custom 404 page
48
+
# /shop/* /shop-closed.html 404
49
+
50
+
# ===================================
51
+
# FORCE REDIRECTS
52
+
# ===================================
53
+
54
+
# Force redirect even if file exists (note the ! after status code)
55
+
# /override-file /other-file.html 200!
56
+
57
+
# ===================================
58
+
# CONDITIONAL REDIRECTS
59
+
# ===================================
60
+
61
+
# Country-based redirects (ISO 3166-1 alpha-2 codes)
62
+
# / /us/ 302 Country=us
63
+
# / /uk/ 302 Country=gb
64
+
# / /anz/ 302 Country=au,nz
65
+
66
+
# Language-based redirects
67
+
# /products /en/products 301 Language=en
68
+
# /products /de/products 301 Language=de
69
+
# /products /fr/products 301 Language=fr
70
+
71
+
# Cookie-based redirects (checks if cookie exists)
72
+
# /* /legacy/:splat 200 Cookie=is_legacy
73
+
74
+
# ===================================
75
+
# QUERY PARAMETERS
76
+
# ===================================
77
+
78
+
# Match specific query parameters
79
+
# /store id=:id /blog/:id 301
80
+
81
+
# Multiple parameters
82
+
# /search q=:query category=:cat /find/:cat/:query 301
83
+
84
+
# ===================================
85
+
# DOMAIN-LEVEL REDIRECTS
86
+
# ===================================
87
+
88
+
# Redirect to different domain (must include protocol)
89
+
# /external https://example.com/path
90
+
91
+
# Redirect entire subdomain
92
+
# http://blog.example.com/* https://example.com/blog/:splat 301!
93
+
# https://blog.example.com/* https://example.com/blog/:splat 301!
94
+
95
+
# ===================================
96
+
# COMMON PATTERNS
97
+
# ===================================
98
+
99
+
# Remove .html extensions
100
+
# /page.html /page
101
+
102
+
# Add trailing slash
103
+
# /about /about/
104
+
105
+
# Single-page app fallback (serve index.html for all paths)
106
+
# /* /index.html 200
107
+
108
+
# API proxy
109
+
# /api/* https://api.example.com/:splat 200
110
+
111
+
# ===================================
112
+
# CUSTOM ERROR PAGES
113
+
# ===================================
114
+
115
+
# Language-specific 404 pages
116
+
# /en/* /en/404.html 404
117
+
# /de/* /de/404.html 404
118
+
119
+
# Section-specific 404 pages
120
+
# /shop/* /shop/not-found.html 404
121
+
# /blog/* /blog/404.html 404
122
+
123
+
# ===================================
124
+
# NOTES
125
+
# ===================================
126
+
#
127
+
# - Rules are processed in order (first match wins)
128
+
# - More specific rules should come before general ones
129
+
# - Splats (*) can only be used at the end of a path
130
+
# - Query parameters are automatically preserved for 200, 301, 302
131
+
# - Trailing slashes are normalized (/ and no / are treated the same)
132
+
# - Default status code is 301 if not specified
133
+
#
134
+
+215
hosting-service/src/lib/redirects.test.ts
+215
hosting-service/src/lib/redirects.test.ts
···
1
+
import { describe, it, expect } from 'bun:test'
2
+
import { parseRedirectsFile, matchRedirectRule } from './redirects';
3
+
4
+
describe('parseRedirectsFile', () => {
5
+
it('should parse simple redirects', () => {
6
+
const content = `
7
+
# Comment line
8
+
/old-path /new-path
9
+
/home / 301
10
+
`;
11
+
const rules = parseRedirectsFile(content);
12
+
expect(rules).toHaveLength(2);
13
+
expect(rules[0]).toMatchObject({
14
+
from: '/old-path',
15
+
to: '/new-path',
16
+
status: 301,
17
+
force: false,
18
+
});
19
+
expect(rules[1]).toMatchObject({
20
+
from: '/home',
21
+
to: '/',
22
+
status: 301,
23
+
force: false,
24
+
});
25
+
});
26
+
27
+
it('should parse redirects with different status codes', () => {
28
+
const content = `
29
+
/temp-redirect /target 302
30
+
/rewrite /content 200
31
+
/not-found /404 404
32
+
`;
33
+
const rules = parseRedirectsFile(content);
34
+
expect(rules).toHaveLength(3);
35
+
expect(rules[0]?.status).toBe(302);
36
+
expect(rules[1]?.status).toBe(200);
37
+
expect(rules[2]?.status).toBe(404);
38
+
});
39
+
40
+
it('should parse force redirects', () => {
41
+
const content = `/force-path /target 301!`;
42
+
const rules = parseRedirectsFile(content);
43
+
expect(rules[0]?.force).toBe(true);
44
+
expect(rules[0]?.status).toBe(301);
45
+
});
46
+
47
+
it('should parse splat redirects', () => {
48
+
const content = `/news/* /blog/:splat`;
49
+
const rules = parseRedirectsFile(content);
50
+
expect(rules[0]?.from).toBe('/news/*');
51
+
expect(rules[0]?.to).toBe('/blog/:splat');
52
+
});
53
+
54
+
it('should parse placeholder redirects', () => {
55
+
const content = `/blog/:year/:month/:day /posts/:year-:month-:day`;
56
+
const rules = parseRedirectsFile(content);
57
+
expect(rules[0]?.from).toBe('/blog/:year/:month/:day');
58
+
expect(rules[0]?.to).toBe('/posts/:year-:month-:day');
59
+
});
60
+
61
+
it('should parse country-based redirects', () => {
62
+
const content = `/ /anz 302 Country=au,nz`;
63
+
const rules = parseRedirectsFile(content);
64
+
expect(rules[0]?.conditions?.country).toEqual(['au', 'nz']);
65
+
});
66
+
67
+
it('should parse language-based redirects', () => {
68
+
const content = `/products /en/products 301 Language=en`;
69
+
const rules = parseRedirectsFile(content);
70
+
expect(rules[0]?.conditions?.language).toEqual(['en']);
71
+
});
72
+
73
+
it('should parse cookie-based redirects', () => {
74
+
const content = `/* /legacy/:splat 200 Cookie=is_legacy,my_cookie`;
75
+
const rules = parseRedirectsFile(content);
76
+
expect(rules[0]?.conditions?.cookie).toEqual(['is_legacy', 'my_cookie']);
77
+
});
78
+
});
79
+
80
+
describe('matchRedirectRule', () => {
81
+
it('should match exact paths', () => {
82
+
const rules = parseRedirectsFile('/old-path /new-path');
83
+
const match = matchRedirectRule('/old-path', rules);
84
+
expect(match).toBeTruthy();
85
+
expect(match?.targetPath).toBe('/new-path');
86
+
expect(match?.status).toBe(301);
87
+
});
88
+
89
+
it('should match paths with trailing slash', () => {
90
+
const rules = parseRedirectsFile('/old-path /new-path');
91
+
const match = matchRedirectRule('/old-path/', rules);
92
+
expect(match).toBeTruthy();
93
+
expect(match?.targetPath).toBe('/new-path');
94
+
});
95
+
96
+
it('should match splat patterns', () => {
97
+
const rules = parseRedirectsFile('/news/* /blog/:splat');
98
+
const match = matchRedirectRule('/news/2024/01/15/my-post', rules);
99
+
expect(match).toBeTruthy();
100
+
expect(match?.targetPath).toBe('/blog/2024/01/15/my-post');
101
+
});
102
+
103
+
it('should match placeholder patterns', () => {
104
+
const rules = parseRedirectsFile('/blog/:year/:month/:day /posts/:year-:month-:day');
105
+
const match = matchRedirectRule('/blog/2024/01/15', rules);
106
+
expect(match).toBeTruthy();
107
+
expect(match?.targetPath).toBe('/posts/2024-01-15');
108
+
});
109
+
110
+
it('should preserve query strings for 301/302 redirects', () => {
111
+
const rules = parseRedirectsFile('/old /new 301');
112
+
const match = matchRedirectRule('/old', rules, {
113
+
queryParams: { foo: 'bar', baz: 'qux' },
114
+
});
115
+
expect(match?.targetPath).toContain('?');
116
+
expect(match?.targetPath).toContain('foo=bar');
117
+
expect(match?.targetPath).toContain('baz=qux');
118
+
});
119
+
120
+
it('should match based on query parameters', () => {
121
+
const rules = parseRedirectsFile('/store id=:id /blog/:id 301');
122
+
const match = matchRedirectRule('/store', rules, {
123
+
queryParams: { id: 'my-post' },
124
+
});
125
+
expect(match).toBeTruthy();
126
+
expect(match?.targetPath).toContain('/blog/my-post');
127
+
});
128
+
129
+
it('should not match when query params are missing', () => {
130
+
const rules = parseRedirectsFile('/store id=:id /blog/:id 301');
131
+
const match = matchRedirectRule('/store', rules, {
132
+
queryParams: {},
133
+
});
134
+
expect(match).toBeNull();
135
+
});
136
+
137
+
it('should match based on country header', () => {
138
+
const rules = parseRedirectsFile('/ /aus 302 Country=au');
139
+
const match = matchRedirectRule('/', rules, {
140
+
headers: { 'cf-ipcountry': 'AU' },
141
+
});
142
+
expect(match).toBeTruthy();
143
+
expect(match?.targetPath).toBe('/aus');
144
+
});
145
+
146
+
it('should not match wrong country', () => {
147
+
const rules = parseRedirectsFile('/ /aus 302 Country=au');
148
+
const match = matchRedirectRule('/', rules, {
149
+
headers: { 'cf-ipcountry': 'US' },
150
+
});
151
+
expect(match).toBeNull();
152
+
});
153
+
154
+
it('should match based on language header', () => {
155
+
const rules = parseRedirectsFile('/products /en/products 301 Language=en');
156
+
const match = matchRedirectRule('/products', rules, {
157
+
headers: { 'accept-language': 'en-US,en;q=0.9' },
158
+
});
159
+
expect(match).toBeTruthy();
160
+
expect(match?.targetPath).toBe('/en/products');
161
+
});
162
+
163
+
it('should match based on cookie presence', () => {
164
+
const rules = parseRedirectsFile('/* /legacy/:splat 200 Cookie=is_legacy');
165
+
const match = matchRedirectRule('/some-path', rules, {
166
+
cookies: { is_legacy: 'true' },
167
+
});
168
+
expect(match).toBeTruthy();
169
+
expect(match?.targetPath).toBe('/legacy/some-path');
170
+
});
171
+
172
+
it('should return first matching rule', () => {
173
+
const content = `
174
+
/path /first
175
+
/path /second
176
+
`;
177
+
const rules = parseRedirectsFile(content);
178
+
const match = matchRedirectRule('/path', rules);
179
+
expect(match?.targetPath).toBe('/first');
180
+
});
181
+
182
+
it('should match more specific rules before general ones', () => {
183
+
const content = `
184
+
/jobs/customer-ninja /careers/support
185
+
/jobs/* /careers/:splat
186
+
`;
187
+
const rules = parseRedirectsFile(content);
188
+
189
+
const match1 = matchRedirectRule('/jobs/customer-ninja', rules);
190
+
expect(match1?.targetPath).toBe('/careers/support');
191
+
192
+
const match2 = matchRedirectRule('/jobs/developer', rules);
193
+
expect(match2?.targetPath).toBe('/careers/developer');
194
+
});
195
+
196
+
it('should handle SPA routing pattern', () => {
197
+
const rules = parseRedirectsFile('/* /index.html 200');
198
+
199
+
// Should match any path
200
+
const match1 = matchRedirectRule('/about', rules);
201
+
expect(match1).toBeTruthy();
202
+
expect(match1?.targetPath).toBe('/index.html');
203
+
expect(match1?.status).toBe(200);
204
+
205
+
const match2 = matchRedirectRule('/users/123/profile', rules);
206
+
expect(match2).toBeTruthy();
207
+
expect(match2?.targetPath).toBe('/index.html');
208
+
expect(match2?.status).toBe(200);
209
+
210
+
const match3 = matchRedirectRule('/', rules);
211
+
expect(match3).toBeTruthy();
212
+
expect(match3?.targetPath).toBe('/index.html');
213
+
});
214
+
});
215
+
+413
hosting-service/src/lib/redirects.ts
+413
hosting-service/src/lib/redirects.ts
···
1
+
import { readFile } from 'fs/promises';
2
+
import { existsSync } from 'fs';
3
+
4
+
export interface RedirectRule {
5
+
from: string;
6
+
to: string;
7
+
status: number;
8
+
force: boolean;
9
+
conditions?: {
10
+
country?: string[];
11
+
language?: string[];
12
+
role?: string[];
13
+
cookie?: string[];
14
+
};
15
+
// For pattern matching
16
+
fromPattern?: RegExp;
17
+
fromParams?: string[]; // Named parameters from the pattern
18
+
queryParams?: Record<string, string>; // Expected query parameters
19
+
}
20
+
21
+
export interface RedirectMatch {
22
+
rule: RedirectRule;
23
+
targetPath: string;
24
+
status: number;
25
+
}
26
+
27
+
/**
28
+
* Parse a _redirects file into an array of redirect rules
29
+
*/
30
+
export function parseRedirectsFile(content: string): RedirectRule[] {
31
+
const lines = content.split('\n');
32
+
const rules: RedirectRule[] = [];
33
+
34
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
35
+
const lineRaw = lines[lineNum];
36
+
if (!lineRaw) continue;
37
+
38
+
const line = lineRaw.trim();
39
+
40
+
// Skip empty lines and comments
41
+
if (!line || line.startsWith('#')) {
42
+
continue;
43
+
}
44
+
45
+
try {
46
+
const rule = parseRedirectLine(line);
47
+
if (rule && rule.fromPattern) {
48
+
rules.push(rule);
49
+
}
50
+
} catch (err) {
51
+
console.warn(`Failed to parse redirect rule on line ${lineNum + 1}: ${line}`, err);
52
+
}
53
+
}
54
+
55
+
return rules;
56
+
}
57
+
58
+
/**
59
+
* Parse a single redirect rule line
60
+
* Format: /from [query_params] /to [status] [conditions]
61
+
*/
62
+
function parseRedirectLine(line: string): RedirectRule | null {
63
+
// Split by whitespace, but respect quoted strings (though not commonly used)
64
+
const parts = line.split(/\s+/);
65
+
66
+
if (parts.length < 2) {
67
+
return null;
68
+
}
69
+
70
+
let idx = 0;
71
+
const from = parts[idx++];
72
+
73
+
if (!from) {
74
+
return null;
75
+
}
76
+
77
+
let status = 301; // Default status
78
+
let force = false;
79
+
const conditions: NonNullable<RedirectRule['conditions']> = {};
80
+
const queryParams: Record<string, string> = {};
81
+
82
+
// Parse query parameters that come before the destination path
83
+
// They look like: key=:value (and don't start with /)
84
+
while (idx < parts.length) {
85
+
const part = parts[idx];
86
+
if (!part) {
87
+
idx++;
88
+
continue;
89
+
}
90
+
91
+
// If it starts with / or http, it's the destination path
92
+
if (part.startsWith('/') || part.startsWith('http://') || part.startsWith('https://')) {
93
+
break;
94
+
}
95
+
96
+
// If it contains = and comes before the destination, it's a query param
97
+
if (part.includes('=')) {
98
+
const splitIndex = part.indexOf('=');
99
+
const key = part.slice(0, splitIndex);
100
+
const value = part.slice(splitIndex + 1);
101
+
102
+
if (key && value) {
103
+
queryParams[key] = value;
104
+
}
105
+
idx++;
106
+
} else {
107
+
// Not a query param, must be destination or something else
108
+
break;
109
+
}
110
+
}
111
+
112
+
// Next part should be the destination
113
+
if (idx >= parts.length) {
114
+
return null;
115
+
}
116
+
117
+
const to = parts[idx++];
118
+
if (!to) {
119
+
return null;
120
+
}
121
+
122
+
// Parse remaining parts for status code and conditions
123
+
for (let i = idx; i < parts.length; i++) {
124
+
const part = parts[i];
125
+
126
+
if (!part) continue;
127
+
128
+
// Check for status code (with optional ! for force)
129
+
if (/^\d+!?$/.test(part)) {
130
+
if (part.endsWith('!')) {
131
+
force = true;
132
+
status = parseInt(part.slice(0, -1));
133
+
} else {
134
+
status = parseInt(part);
135
+
}
136
+
continue;
137
+
}
138
+
139
+
// Check for condition parameters (Country=, Language=, Role=, Cookie=)
140
+
if (part.includes('=')) {
141
+
const splitIndex = part.indexOf('=');
142
+
const key = part.slice(0, splitIndex);
143
+
const value = part.slice(splitIndex + 1);
144
+
145
+
if (!key || !value) continue;
146
+
147
+
const keyLower = key.toLowerCase();
148
+
149
+
if (keyLower === 'country') {
150
+
conditions.country = value.split(',').map(v => v.trim().toLowerCase());
151
+
} else if (keyLower === 'language') {
152
+
conditions.language = value.split(',').map(v => v.trim().toLowerCase());
153
+
} else if (keyLower === 'role') {
154
+
conditions.role = value.split(',').map(v => v.trim());
155
+
} else if (keyLower === 'cookie') {
156
+
conditions.cookie = value.split(',').map(v => v.trim().toLowerCase());
157
+
}
158
+
}
159
+
}
160
+
161
+
// Parse the 'from' pattern
162
+
const { pattern, params } = convertPathToRegex(from);
163
+
164
+
return {
165
+
from,
166
+
to,
167
+
status,
168
+
force,
169
+
conditions: Object.keys(conditions).length > 0 ? conditions : undefined,
170
+
queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined,
171
+
fromPattern: pattern,
172
+
fromParams: params,
173
+
};
174
+
}
175
+
176
+
/**
177
+
* Convert a path pattern with placeholders and splats to a regex
178
+
* Examples:
179
+
* /blog/:year/:month/:day -> captures year, month, day
180
+
* /news/* -> captures splat
181
+
*/
182
+
function convertPathToRegex(pattern: string): { pattern: RegExp; params: string[] } {
183
+
const params: string[] = [];
184
+
let regexStr = '^';
185
+
186
+
// Split by query string if present
187
+
const pathPart = pattern.split('?')[0] || pattern;
188
+
189
+
// Escape special regex characters except * and :
190
+
let escaped = pathPart.replace(/[.+^${}()|[\]\\]/g, '\\$&');
191
+
192
+
// Replace :param with named capture groups
193
+
escaped = escaped.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, paramName) => {
194
+
params.push(paramName);
195
+
// Match path segment (everything except / and ?)
196
+
return '([^/?]+)';
197
+
});
198
+
199
+
// Replace * with splat capture (matches everything including /)
200
+
if (escaped.includes('*')) {
201
+
escaped = escaped.replace(/\*/g, '(.*)');
202
+
params.push('splat');
203
+
}
204
+
205
+
regexStr += escaped;
206
+
207
+
// Make trailing slash optional
208
+
if (!regexStr.endsWith('.*')) {
209
+
regexStr += '/?';
210
+
}
211
+
212
+
regexStr += '$';
213
+
214
+
return {
215
+
pattern: new RegExp(regexStr),
216
+
params,
217
+
};
218
+
}
219
+
220
+
/**
221
+
* Match a request path against redirect rules
222
+
*/
223
+
export function matchRedirectRule(
224
+
requestPath: string,
225
+
rules: RedirectRule[],
226
+
context?: {
227
+
queryParams?: Record<string, string>;
228
+
headers?: Record<string, string>;
229
+
cookies?: Record<string, string>;
230
+
}
231
+
): RedirectMatch | null {
232
+
// Normalize path: ensure leading slash, remove trailing slash (except for root)
233
+
let normalizedPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
234
+
235
+
for (const rule of rules) {
236
+
// Check query parameter conditions first (if any)
237
+
if (rule.queryParams) {
238
+
// If rule requires query params but none provided, skip this rule
239
+
if (!context?.queryParams) {
240
+
continue;
241
+
}
242
+
243
+
const queryMatches = Object.entries(rule.queryParams).every(([key, value]) => {
244
+
const actualValue = context.queryParams?.[key];
245
+
return actualValue !== undefined;
246
+
});
247
+
248
+
if (!queryMatches) {
249
+
continue;
250
+
}
251
+
}
252
+
253
+
// Check conditional redirects (country, language, role, cookie)
254
+
if (rule.conditions) {
255
+
if (rule.conditions.country && context?.headers) {
256
+
const cfCountry = context.headers['cf-ipcountry'];
257
+
const xCountry = context.headers['x-country'];
258
+
const country = (cfCountry?.toLowerCase() || xCountry?.toLowerCase());
259
+
if (!country || !rule.conditions.country.includes(country)) {
260
+
continue;
261
+
}
262
+
}
263
+
264
+
if (rule.conditions.language && context?.headers) {
265
+
const acceptLang = context.headers['accept-language'];
266
+
if (!acceptLang) {
267
+
continue;
268
+
}
269
+
// Parse accept-language header (simplified)
270
+
const langs = acceptLang.split(',').map(l => {
271
+
const langPart = l.split(';')[0];
272
+
return langPart ? langPart.trim().toLowerCase() : '';
273
+
}).filter(l => l !== '');
274
+
const hasMatch = rule.conditions.language.some(lang =>
275
+
langs.some(l => l === lang || l.startsWith(lang + '-'))
276
+
);
277
+
if (!hasMatch) {
278
+
continue;
279
+
}
280
+
}
281
+
282
+
if (rule.conditions.cookie && context?.cookies) {
283
+
const hasCookie = rule.conditions.cookie.some(cookieName =>
284
+
context.cookies && cookieName in context.cookies
285
+
);
286
+
if (!hasCookie) {
287
+
continue;
288
+
}
289
+
}
290
+
291
+
// Role-based redirects would need JWT verification - skip for now
292
+
if (rule.conditions.role) {
293
+
continue;
294
+
}
295
+
}
296
+
297
+
// Match the path pattern
298
+
const match = rule.fromPattern?.exec(normalizedPath);
299
+
if (!match) {
300
+
continue;
301
+
}
302
+
303
+
// Build the target path by replacing placeholders
304
+
let targetPath = rule.to;
305
+
306
+
// Replace captured parameters
307
+
if (rule.fromParams && match.length > 1) {
308
+
for (let i = 0; i < rule.fromParams.length; i++) {
309
+
const paramName = rule.fromParams[i];
310
+
const paramValue = match[i + 1];
311
+
312
+
if (!paramName || !paramValue) continue;
313
+
314
+
if (paramName === 'splat') {
315
+
targetPath = targetPath.replace(':splat', paramValue);
316
+
} else {
317
+
targetPath = targetPath.replace(`:${paramName}`, paramValue);
318
+
}
319
+
}
320
+
}
321
+
322
+
// Handle query parameter replacements
323
+
if (rule.queryParams && context?.queryParams) {
324
+
for (const [key, placeholder] of Object.entries(rule.queryParams)) {
325
+
const actualValue = context.queryParams[key];
326
+
if (actualValue && placeholder && placeholder.startsWith(':')) {
327
+
const paramName = placeholder.slice(1);
328
+
if (paramName) {
329
+
targetPath = targetPath.replace(`:${paramName}`, actualValue);
330
+
}
331
+
}
332
+
}
333
+
}
334
+
335
+
// Preserve query string for 200, 301, 302 redirects (unless target already has one)
336
+
if ([200, 301, 302].includes(rule.status) && context?.queryParams && !targetPath.includes('?')) {
337
+
const queryString = Object.entries(context.queryParams)
338
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
339
+
.join('&');
340
+
if (queryString) {
341
+
targetPath += `?${queryString}`;
342
+
}
343
+
}
344
+
345
+
return {
346
+
rule,
347
+
targetPath,
348
+
status: rule.status,
349
+
};
350
+
}
351
+
352
+
return null;
353
+
}
354
+
355
+
/**
356
+
* Load redirect rules from a cached site
357
+
*/
358
+
export async function loadRedirectRules(did: string, rkey: string): Promise<RedirectRule[]> {
359
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
360
+
const redirectsPath = `${CACHE_DIR}/${did}/${rkey}/_redirects`;
361
+
362
+
if (!existsSync(redirectsPath)) {
363
+
return [];
364
+
}
365
+
366
+
try {
367
+
const content = await readFile(redirectsPath, 'utf-8');
368
+
return parseRedirectsFile(content);
369
+
} catch (err) {
370
+
console.error('Failed to load _redirects file', err);
371
+
return [];
372
+
}
373
+
}
374
+
375
+
/**
376
+
* Parse cookies from Cookie header
377
+
*/
378
+
export function parseCookies(cookieHeader?: string): Record<string, string> {
379
+
if (!cookieHeader) return {};
380
+
381
+
const cookies: Record<string, string> = {};
382
+
const parts = cookieHeader.split(';');
383
+
384
+
for (const part of parts) {
385
+
const [key, ...valueParts] = part.split('=');
386
+
if (key && valueParts.length > 0) {
387
+
cookies[key.trim()] = valueParts.join('=').trim();
388
+
}
389
+
}
390
+
391
+
return cookies;
392
+
}
393
+
394
+
/**
395
+
* Parse query string into object
396
+
*/
397
+
export function parseQueryString(url: string): Record<string, string> {
398
+
const queryStart = url.indexOf('?');
399
+
if (queryStart === -1) return {};
400
+
401
+
const queryString = url.slice(queryStart + 1);
402
+
const params: Record<string, string> = {};
403
+
404
+
for (const pair of queryString.split('&')) {
405
+
const [key, value] = pair.split('=');
406
+
if (key) {
407
+
params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
408
+
}
409
+
}
410
+
411
+
return params;
412
+
}
413
+
+168
-6
hosting-service/src/server.ts
+168
-6
hosting-service/src/server.ts
···
7
7
import { lookup } from 'mime-types';
8
8
import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability';
9
9
import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata } from './lib/cache';
10
+
import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects';
10
11
11
12
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
12
13
···
35
36
}
36
37
}
37
38
39
+
// Cache for redirect rules (per site)
40
+
const redirectRulesCache = new Map<string, RedirectRule[]>();
41
+
42
+
/**
43
+
* Clear redirect rules cache for a specific site
44
+
* Should be called when a site is updated/recached
45
+
*/
46
+
export function clearRedirectRulesCache(did: string, rkey: string) {
47
+
const cacheKey = `${did}:${rkey}`;
48
+
redirectRulesCache.delete(cacheKey);
49
+
}
50
+
38
51
// Helper to serve files from cache
39
-
async function serveFromCache(did: string, rkey: string, filePath: string) {
52
+
async function serveFromCache(
53
+
did: string,
54
+
rkey: string,
55
+
filePath: string,
56
+
fullUrl?: string,
57
+
headers?: Record<string, string>
58
+
) {
59
+
// Check for redirect rules first
60
+
const redirectCacheKey = `${did}:${rkey}`;
61
+
let redirectRules = redirectRulesCache.get(redirectCacheKey);
62
+
63
+
if (redirectRules === undefined) {
64
+
// Load rules for the first time
65
+
redirectRules = await loadRedirectRules(did, rkey);
66
+
redirectRulesCache.set(redirectCacheKey, redirectRules);
67
+
}
68
+
69
+
// Apply redirect rules if any exist
70
+
if (redirectRules.length > 0) {
71
+
const requestPath = '/' + (filePath || '');
72
+
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
73
+
const cookies = parseCookies(headers?.['cookie']);
74
+
75
+
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
76
+
queryParams,
77
+
headers,
78
+
cookies,
79
+
});
80
+
81
+
if (redirectMatch) {
82
+
const { targetPath, status } = redirectMatch;
83
+
84
+
// Handle different status codes
85
+
if (status === 200) {
86
+
// Rewrite: serve different content but keep URL the same
87
+
// Remove leading slash for internal path resolution
88
+
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
89
+
return serveFileInternal(did, rkey, rewritePath);
90
+
} else if (status === 301 || status === 302) {
91
+
// External redirect: change the URL
92
+
return new Response(null, {
93
+
status,
94
+
headers: {
95
+
'Location': targetPath,
96
+
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
97
+
},
98
+
});
99
+
} else if (status === 404) {
100
+
// Custom 404 page
101
+
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
102
+
const response = await serveFileInternal(did, rkey, custom404Path);
103
+
// Override status to 404
104
+
return new Response(response.body, {
105
+
status: 404,
106
+
headers: response.headers,
107
+
});
108
+
}
109
+
}
110
+
}
111
+
112
+
// No redirect matched, serve normally
113
+
return serveFileInternal(did, rkey, filePath);
114
+
}
115
+
116
+
// Internal function to serve a file (used by both normal serving and rewrites)
117
+
async function serveFileInternal(did: string, rkey: string, filePath: string) {
40
118
// Default to index.html if path is empty or ends with /
41
119
let requestPath = filePath || 'index.html';
42
120
if (requestPath.endsWith('/')) {
···
138
216
did: string,
139
217
rkey: string,
140
218
filePath: string,
141
-
basePath: string
219
+
basePath: string,
220
+
fullUrl?: string,
221
+
headers?: Record<string, string>
142
222
) {
223
+
// Check for redirect rules first
224
+
const redirectCacheKey = `${did}:${rkey}`;
225
+
let redirectRules = redirectRulesCache.get(redirectCacheKey);
226
+
227
+
if (redirectRules === undefined) {
228
+
// Load rules for the first time
229
+
redirectRules = await loadRedirectRules(did, rkey);
230
+
redirectRulesCache.set(redirectCacheKey, redirectRules);
231
+
}
232
+
233
+
// Apply redirect rules if any exist
234
+
if (redirectRules.length > 0) {
235
+
const requestPath = '/' + (filePath || '');
236
+
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
237
+
const cookies = parseCookies(headers?.['cookie']);
238
+
239
+
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
240
+
queryParams,
241
+
headers,
242
+
cookies,
243
+
});
244
+
245
+
if (redirectMatch) {
246
+
const { targetPath, status } = redirectMatch;
247
+
248
+
// Handle different status codes
249
+
if (status === 200) {
250
+
// Rewrite: serve different content but keep URL the same
251
+
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
252
+
return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath);
253
+
} else if (status === 301 || status === 302) {
254
+
// External redirect: change the URL
255
+
// For sites.wisp.place, we need to adjust the target path to include the base path
256
+
// unless it's an absolute URL
257
+
let redirectTarget = targetPath;
258
+
if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) {
259
+
redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath);
260
+
}
261
+
return new Response(null, {
262
+
status,
263
+
headers: {
264
+
'Location': redirectTarget,
265
+
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
266
+
},
267
+
});
268
+
} else if (status === 404) {
269
+
// Custom 404 page
270
+
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
271
+
const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath);
272
+
// Override status to 404
273
+
return new Response(response.body, {
274
+
status: 404,
275
+
headers: response.headers,
276
+
});
277
+
}
278
+
}
279
+
}
280
+
281
+
// No redirect matched, serve normally
282
+
return serveFileInternalWithRewrite(did, rkey, filePath, basePath);
283
+
}
284
+
285
+
// Internal function to serve a file with rewriting
286
+
async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) {
143
287
// Default to index.html if path is empty or ends with /
144
288
let requestPath = filePath || 'index.html';
145
289
if (requestPath.endsWith('/')) {
···
317
461
318
462
try {
319
463
await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
464
+
// Clear redirect rules cache since the site was updated
465
+
clearRedirectRulesCache(did, rkey);
320
466
logger.info('Site cached successfully', { did, rkey });
321
467
return true;
322
468
} catch (err) {
···
384
530
385
531
// Serve with HTML path rewriting to handle absolute paths
386
532
const basePath = `/${identifier}/${site}/`;
387
-
return serveFromCacheWithRewrite(did, site, filePath, basePath);
533
+
const headers: Record<string, string> = {};
534
+
c.req.raw.headers.forEach((value, key) => {
535
+
headers[key.toLowerCase()] = value;
536
+
});
537
+
return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers);
388
538
}
389
539
390
540
// Check if this is a DNS hash subdomain
···
420
570
return c.text('Site not found', 404);
421
571
}
422
572
423
-
return serveFromCache(customDomain.did, rkey, path);
573
+
const headers: Record<string, string> = {};
574
+
c.req.raw.headers.forEach((value, key) => {
575
+
headers[key.toLowerCase()] = value;
576
+
});
577
+
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
424
578
}
425
579
426
580
// Route 2: Registered subdomains - /*.wisp.place/*
···
444
598
return c.text('Site not found', 404);
445
599
}
446
600
447
-
return serveFromCache(domainInfo.did, rkey, path);
601
+
const headers: Record<string, string> = {};
602
+
c.req.raw.headers.forEach((value, key) => {
603
+
headers[key.toLowerCase()] = value;
604
+
});
605
+
return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers);
448
606
}
449
607
450
608
// Route 1: Custom domains - /*
···
467
625
return c.text('Site not found', 404);
468
626
}
469
627
470
-
return serveFromCache(customDomain.did, rkey, path);
628
+
const headers: Record<string, string> = {};
629
+
c.req.raw.headers.forEach((value, key) => {
630
+
headers[key.toLowerCase()] = value;
631
+
});
632
+
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
471
633
});
472
634
473
635
// Internal observability endpoints (for admin panel)
+78
-14
public/editor/tabs/CLITab.tsx
+78
-14
public/editor/tabs/CLITab.tsx
···
16
16
<CardHeader>
17
17
<div className="flex items-center gap-2 mb-2">
18
18
<CardTitle>Wisp CLI Tool</CardTitle>
19
-
<Badge variant="secondary" className="text-xs">v0.1.0</Badge>
19
+
<Badge variant="secondary" className="text-xs">v0.2.0</Badge>
20
20
<Badge variant="outline" className="text-xs">Alpha</Badge>
21
21
</div>
22
22
<CardDescription>
···
32
32
</div>
33
33
34
34
<div className="space-y-3">
35
-
<h3 className="text-sm font-semibold">Download CLI</h3>
35
+
<h3 className="text-sm font-semibold">Features</h3>
36
+
<ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside">
37
+
<li><strong>Deploy:</strong> Push static sites directly from your terminal</li>
38
+
<li><strong>Pull:</strong> Download sites from the PDS for development or backup</li>
39
+
<li><strong>Serve:</strong> Run a local server with real-time firehose updates</li>
40
+
</ul>
41
+
</div>
42
+
43
+
<div className="space-y-3">
44
+
<h3 className="text-sm font-semibold">Download v0.2.0</h3>
36
45
<div className="grid gap-2">
37
46
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
38
47
<a
39
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
48
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin"
40
49
target="_blank"
41
50
rel="noopener noreferrer"
42
51
className="flex items-center justify-between mb-2"
···
45
54
<ExternalLink className="w-4 h-4 text-muted-foreground" />
46
55
</a>
47
56
<div className="text-xs text-muted-foreground">
48
-
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
57
+
<span className="font-mono">SHA-1: a8c27ea41c5e2672bfecb3476ece1c801741d759</span>
49
58
</div>
50
59
</div>
51
60
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
···
59
68
<ExternalLink className="w-4 h-4 text-muted-foreground" />
60
69
</a>
61
70
<div className="text-xs text-muted-foreground">
62
-
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
71
+
<span className="font-mono">SHA-1: fd7ee689c7600fc953179ea755b0357c8481a622</span>
63
72
</div>
64
73
</div>
65
74
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
···
73
82
<ExternalLink className="w-4 h-4 text-muted-foreground" />
74
83
</a>
75
84
<div className="text-xs text-muted-foreground">
76
-
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
85
+
<span className="font-mono">SHA-1: 8bca6992559e19e1d29ab3d2fcc6d09b28e5a485</span>
86
+
</div>
87
+
</div>
88
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
89
+
<a
90
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-windows.exe"
91
+
target="_blank"
92
+
rel="noopener noreferrer"
93
+
className="flex items-center justify-between mb-2"
94
+
>
95
+
<span className="font-mono text-sm">Windows (x86_64)</span>
96
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
97
+
</a>
98
+
<div className="text-xs text-muted-foreground">
99
+
<span className="font-mono">SHA-1: 90ea3987a06597fa6c42e1df9009e9758e92dd54</span>
77
100
</div>
78
101
</div>
79
102
</div>
80
103
</div>
81
104
82
105
<div className="space-y-3">
83
-
<h3 className="text-sm font-semibold">Basic Usage</h3>
106
+
<h3 className="text-sm font-semibold">Deploy a Site</h3>
84
107
<CodeBlock
85
108
code={`# Download and make executable
86
-
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64
87
-
chmod +x wisp-cli-macos-arm64
109
+
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin
110
+
chmod +x wisp-cli-aarch64-darwin
88
111
89
-
# Deploy your site (will use OAuth)
90
-
./wisp-cli-macos-arm64 your-handle.bsky.social \\
112
+
# Deploy your site
113
+
./wisp-cli-aarch64-darwin deploy your-handle.bsky.social \\
91
114
--path ./dist \\
92
-
--site my-site
115
+
--site my-site \\
116
+
--password your-app-password
93
117
94
118
# Your site will be available at:
95
119
# https://sites.wisp.place/your-handle/my-site`}
···
98
122
</div>
99
123
100
124
<div className="space-y-3">
125
+
<h3 className="text-sm font-semibold">Pull a Site from PDS</h3>
126
+
<p className="text-xs text-muted-foreground">
127
+
Download a site from the PDS to your local machine (uses OAuth authentication):
128
+
</p>
129
+
<CodeBlock
130
+
code={`# Pull a site to a specific directory
131
+
wisp-cli pull your-handle.bsky.social \\
132
+
--site my-site \\
133
+
--output ./my-site
134
+
135
+
# Pull to current directory
136
+
wisp-cli pull your-handle.bsky.social \\
137
+
--site my-site
138
+
139
+
# Opens browser for OAuth authentication on first run`}
140
+
language="bash"
141
+
/>
142
+
</div>
143
+
144
+
<div className="space-y-3">
145
+
<h3 className="text-sm font-semibold">Serve a Site Locally with Real-Time Updates</h3>
146
+
<p className="text-xs text-muted-foreground">
147
+
Run a local server that monitors the firehose for real-time updates (uses OAuth authentication):
148
+
</p>
149
+
<CodeBlock
150
+
code={`# Serve on http://localhost:8080 (default)
151
+
wisp-cli serve your-handle.bsky.social \\
152
+
--site my-site
153
+
154
+
# Serve on a custom port
155
+
wisp-cli serve your-handle.bsky.social \\
156
+
--site my-site \\
157
+
--port 3000
158
+
159
+
# Downloads site, serves it, and watches firehose for live updates!`}
160
+
language="bash"
161
+
/>
162
+
</div>
163
+
164
+
<div className="space-y-3">
101
165
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
102
166
<p className="text-xs text-muted-foreground">
103
167
Deploy automatically on every push using{' '}
···
147
211
chmod +x wisp-cli
148
212
149
213
# Deploy to Wisp
150
-
./wisp-cli \\
214
+
./wisp-cli deploy \\
151
215
"$WISP_HANDLE" \\
152
216
--path "$SITE_PATH" \\
153
217
--site "$SITE_NAME" \\
···
210
274
chmod +x wisp-cli
211
275
212
276
# Deploy to Wisp
213
-
./wisp-cli \\
277
+
./wisp-cli deploy \\
214
278
"$WISP_HANDLE" \\
215
279
--path "$SITE_PATH" \\
216
280
--site "$SITE_NAME" \\
+5
-5
src/index.ts
+5
-5
src/index.ts
···
70
70
},
71
71
cookie: {
72
72
secrets: cookieSecret,
73
-
sign: true
73
+
sign: ['did']
74
74
}
75
75
})
76
76
// Observability middleware
···
105
105
.onError(observabilityMiddleware('main-app').onError)
106
106
.use(csrfProtection())
107
107
.use(authRoutes(client, cookieSecret))
108
-
.use(wispRoutes(client))
109
-
.use(domainRoutes(client))
110
-
.use(userRoutes(client))
111
-
.use(siteRoutes(client))
108
+
.use(wispRoutes(client, cookieSecret))
109
+
.use(domainRoutes(client, cookieSecret))
110
+
.use(userRoutes(client, cookieSecret))
111
+
.use(siteRoutes(client, cookieSecret))
112
112
.use(adminRoutes(cookieSecret))
113
113
.use(
114
114
await staticPlugin({
+6
-22
src/routes/auth.ts
+6
-22
src/routes/auth.ts
···
5
5
import { authenticateRequest } from '../lib/wisp-auth'
6
6
import { logger } from '../lib/observability'
7
7
8
-
export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia()
8
+
export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({
9
+
cookie: {
10
+
secrets: cookieSecret,
11
+
sign: ['did']
12
+
}
13
+
})
9
14
.post('/api/auth/signin', async (c) => {
10
15
let handle = 'unknown'
11
16
try {
···
74
79
c.cookie.did.remove()
75
80
return c.redirect('/?error=auth_failed')
76
81
}
77
-
}, {
78
-
cookie: t.Cookie({
79
-
did: t.Optional(t.String())
80
-
}, {
81
-
secrets: cookieSecret,
82
-
sign: ['did']
83
-
})
84
82
})
85
83
.post('/api/auth/logout', async (c) => {
86
84
try {
···
106
104
logger.error('[Auth] Logout error', err)
107
105
return { error: 'Logout failed' }
108
106
}
109
-
}, {
110
-
cookie: t.Cookie({
111
-
did: t.Optional(t.String())
112
-
}, {
113
-
secrets: cookieSecret,
114
-
sign: ['did']
115
-
})
116
107
})
117
108
.get('/api/auth/status', async (c) => {
118
109
try {
···
132
123
c.cookie.did.remove()
133
124
return { authenticated: false }
134
125
}
135
-
}, {
136
-
cookie: t.Cookie({
137
-
did: t.Optional(t.String())
138
-
}, {
139
-
secrets: cookieSecret,
140
-
sign: ['did']
141
-
})
142
126
})
+8
-2
src/routes/domain.ts
+8
-2
src/routes/domain.ts
···
24
24
import { verifyCustomDomain } from '../lib/dns-verify'
25
25
import { logger } from '../lib/logger'
26
26
27
-
export const domainRoutes = (client: NodeOAuthClient) =>
28
-
new Elysia({ prefix: '/api/domain' })
27
+
export const domainRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
28
+
new Elysia({
29
+
prefix: '/api/domain',
30
+
cookie: {
31
+
secrets: cookieSecret,
32
+
sign: ['did']
33
+
}
34
+
})
29
35
// Public endpoints (no auth required)
30
36
.get('/check', async ({ query }) => {
31
37
try {
+8
-2
src/routes/site.ts
+8
-2
src/routes/site.ts
···
5
5
import { deleteSite } from '../lib/db'
6
6
import { logger } from '../lib/logger'
7
7
8
-
export const siteRoutes = (client: NodeOAuthClient) =>
9
-
new Elysia({ prefix: '/api/site' })
8
+
export const siteRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
9
+
new Elysia({
10
+
prefix: '/api/site',
11
+
cookie: {
12
+
secrets: cookieSecret,
13
+
sign: ['did']
14
+
}
15
+
})
10
16
.derive(async ({ cookie }) => {
11
17
const auth = await requireAuth(client, cookie)
12
18
return { auth }
+9
-3
src/routes/user.ts
+9
-3
src/routes/user.ts
···
1
-
import { Elysia } from 'elysia'
1
+
import { Elysia, t } from 'elysia'
2
2
import { requireAuth } from '../lib/wisp-auth'
3
3
import { NodeOAuthClient } from '@atproto/oauth-client-node'
4
4
import { Agent } from '@atproto/api'
···
6
6
import { syncSitesFromPDS } from '../lib/sync-sites'
7
7
import { logger } from '../lib/logger'
8
8
9
-
export const userRoutes = (client: NodeOAuthClient) =>
10
-
new Elysia({ prefix: '/api/user' })
9
+
export const userRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
10
+
new Elysia({
11
+
prefix: '/api/user',
12
+
cookie: {
13
+
secrets: cookieSecret,
14
+
sign: ['did']
15
+
}
16
+
})
11
17
.derive(async ({ cookie }) => {
12
18
const auth = await requireAuth(client, cookie)
13
19
return { auth }
+8
-2
src/routes/wisp.ts
+8
-2
src/routes/wisp.ts
···
37
37
return true;
38
38
}
39
39
40
-
export const wispRoutes = (client: NodeOAuthClient) =>
41
-
new Elysia({ prefix: '/wisp' })
40
+
export const wispRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
41
+
new Elysia({
42
+
prefix: '/wisp',
43
+
cookie: {
44
+
secrets: cookieSecret,
45
+
sign: ['did']
46
+
}
47
+
})
42
48
.derive(async ({ cookie }) => {
43
49
const auth = await requireAuth(client, cookie)
44
50
return { auth }