spooder 5.0.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +190 -103
- package/package.json +1 -1
- package/src/api.ts +106 -37
package/README.md
CHANGED
|
@@ -96,6 +96,8 @@ The `CLI` component of `spooder` is a global command-line tool for running serve
|
|
|
96
96
|
- [API > Workers](#api-workers)
|
|
97
97
|
- [API > Caching](#api-caching)
|
|
98
98
|
- [API > Templating](#api-templating)
|
|
99
|
+
- [API > Cache Busting](#api-cache-busting)
|
|
100
|
+
- [API > Git](#api-git)
|
|
99
101
|
- [API > Database](#api-database)
|
|
100
102
|
- [API > Database > Schema](#api-database-schema)
|
|
101
103
|
- [API > Database > Interface](#api-database-interface)
|
|
@@ -525,9 +527,18 @@ pipe.once(event: string, callback: (data: object) => void | Promise<void>): void
|
|
|
525
527
|
pipe.off(event: string): void;
|
|
526
528
|
|
|
527
529
|
// templates
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
530
|
+
Replacements = Record<string, string | Array<string> | object | object[]> | ReplacerFn | AsyncReplaceFn;
|
|
531
|
+
parse_template(template: string, replacements: Replacements, drop_missing?: boolean): Promise<string>;
|
|
532
|
+
|
|
533
|
+
// cache busting
|
|
534
|
+
cache_bust(string: path, format: string): string
|
|
535
|
+
cache_bust_set_hash_length(length: number): void;
|
|
536
|
+
cache_bust_set_format(format: string): void;
|
|
537
|
+
cache_bust_get_hash_table(): Record<string, string>;
|
|
538
|
+
|
|
539
|
+
// git
|
|
540
|
+
git_get_hashes(length: number): Promise<Record<string, string>>;
|
|
541
|
+
git_get_hashes_sync(length: number): Record<string, string>
|
|
531
542
|
|
|
532
543
|
// database interface
|
|
533
544
|
db_sqlite(filename: string, options: number|object): db_sqlite;
|
|
@@ -1197,7 +1208,7 @@ server.websocket('/path/to/websocket', {
|
|
|
1197
1208
|
For simpler projects, the scaffolding can often look the same, potentially something similar to below.
|
|
1198
1209
|
|
|
1199
1210
|
```ts
|
|
1200
|
-
import { http_serve, cache_http,
|
|
1211
|
+
import { http_serve, cache_http, parse_template, http_apply_range, git_get_hashes } from 'spooder';
|
|
1201
1212
|
import path from 'node:path';
|
|
1202
1213
|
|
|
1203
1214
|
const server = http_serve(80);
|
|
@@ -1209,7 +1220,7 @@ const cache = cache_http({
|
|
|
1209
1220
|
});
|
|
1210
1221
|
|
|
1211
1222
|
const base_file = await Bun.file('./html/base_template.html').text();
|
|
1212
|
-
const
|
|
1223
|
+
const git_hash_table = await git_get_hashes();
|
|
1213
1224
|
|
|
1214
1225
|
async function default_handler(status_code: number): Promise<Response> {
|
|
1215
1226
|
const error_text = HTTP_STATUS_CODE[status_code] as string;
|
|
@@ -1239,10 +1250,12 @@ server.dir('/static', './static', async (file_path, file, stat, request) => {
|
|
|
1239
1250
|
if (stat.isDirectory())
|
|
1240
1251
|
return HTTP_STATUS_CODE.Unauthorized_401;
|
|
1241
1252
|
|
|
1242
|
-
//
|
|
1253
|
+
// serve css/js files directly
|
|
1243
1254
|
const ext = path.extname(file_path);
|
|
1244
1255
|
if (ext === '.css' || ext === '.js') {
|
|
1245
|
-
const content = await parse_template(await file.text(),
|
|
1256
|
+
const content = await parse_template(await file.text(), {
|
|
1257
|
+
cache_bust: (file) => `${file}?v=${git_hash_table[file]}`
|
|
1258
|
+
}, true);
|
|
1246
1259
|
|
|
1247
1260
|
return new Response(content, {
|
|
1248
1261
|
headers: {
|
|
@@ -1261,8 +1274,8 @@ function add_route(route: string, file: string, title: string) {
|
|
|
1261
1274
|
const template = await parse_template(base_file, {
|
|
1262
1275
|
title: title,
|
|
1263
1276
|
content: file_content,
|
|
1264
|
-
|
|
1265
|
-
},
|
|
1277
|
+
asset: (file) => git_hash_table[file]
|
|
1278
|
+
}, true);
|
|
1266
1279
|
|
|
1267
1280
|
return template;
|
|
1268
1281
|
});
|
|
@@ -1300,7 +1313,11 @@ server.bootstrap({
|
|
|
1300
1313
|
error_page: Bun.file('./html/error.html')
|
|
1301
1314
|
},
|
|
1302
1315
|
|
|
1303
|
-
|
|
1316
|
+
cache_bust: { // true or options
|
|
1317
|
+
format: '$file#$hash', // default: $file?v=$hash
|
|
1318
|
+
hash_length: 20, // default: 7
|
|
1319
|
+
prefix: 'bust' // default: cache_bust
|
|
1320
|
+
},
|
|
1304
1321
|
|
|
1305
1322
|
static: {
|
|
1306
1323
|
directory: './static',
|
|
@@ -1399,37 +1416,30 @@ cache: {
|
|
|
1399
1416
|
}
|
|
1400
1417
|
```
|
|
1401
1418
|
|
|
1402
|
-
##### `
|
|
1403
|
-
|
|
1419
|
+
##### `cache_bust?: CacheBustOptions | boolean`
|
|
1420
|
+
Enables the use of the [`cache_bust()`](#api-cache-busting) API inside templates using the ``{{cache_bust=file}}`` directive.
|
|
1404
1421
|
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1422
|
+
```html
|
|
1423
|
+
<link href="{{cache_bust=static/css/style.css}}">
|
|
1424
|
+
<script src="{{cache_bust=static/js/app.js}}"></script>
|
|
1425
|
+
<img src="{{cache_bust=static/images/logo.png}}">
|
|
1408
1426
|
```
|
|
1409
1427
|
|
|
1410
|
-
|
|
1411
|
-
```ts
|
|
1412
|
-
hash_subs: {
|
|
1413
|
-
length: 7, // Hash length (default: 7)
|
|
1414
|
-
prefix: 'asset=', // Substitution prefix (default: 'hash=')
|
|
1415
|
-
format: '$file?v=$hash', // Custom format (default: just hash)
|
|
1416
|
-
hashes: { ... } // Pre-generated hash map from get_git_hashes (optional)
|
|
1417
|
-
}
|
|
1418
|
-
```
|
|
1428
|
+
Since this uses the [`cache_bust()`](#api-cache-busting) API internally, it is effected by the `cache_bust_set_hash_length` and `cache_bust_set_format` global functions.
|
|
1419
1429
|
|
|
1420
|
-
|
|
1421
|
-
```ts
|
|
1422
|
-
// Default hash substitutions
|
|
1423
|
-
hash_subs: true
|
|
1424
|
-
// Creates: {{hash=static/css/style.css}} -> "a1b2c3d"
|
|
1425
|
-
// Usage: <link href="/css/style.css?v={{hash=static/css/style.css}}">
|
|
1430
|
+
Setting `cache_bust` to `true` assumes the normal defaults, however this can be customized by providing an options object.
|
|
1426
1431
|
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1432
|
+
```ts
|
|
1433
|
+
cache_bust: { // true or options
|
|
1434
|
+
format: '$file#$hash', // default: $file?v=$hash
|
|
1435
|
+
hash_length: 20, // default: 7
|
|
1436
|
+
prefix: 'bust' // default: cache_bust
|
|
1437
|
+
},
|
|
1431
1438
|
```
|
|
1432
1439
|
|
|
1440
|
+
> ![IMPORTANT]
|
|
1441
|
+
> `format` and `hash_length` internally call `cache_bust_set_format` and `cache_bust_set_hash_length` respectively, so these values will effect `cache_bust()` globally.
|
|
1442
|
+
|
|
1433
1443
|
##### `error?: object`
|
|
1434
1444
|
Optional error page configuration:
|
|
1435
1445
|
- `error_page`: Template for error pages (string or BunFile)
|
|
@@ -1458,7 +1468,25 @@ static: {
|
|
|
1458
1468
|
}
|
|
1459
1469
|
```
|
|
1460
1470
|
|
|
1461
|
-
Files with extensions in `sub_ext` will have template substitutions applied before serving.
|
|
1471
|
+
Files with extensions in `sub_ext` will have template substitutions applied before serving. This includes support for functions to generate dynamic content:
|
|
1472
|
+
|
|
1473
|
+
```ts
|
|
1474
|
+
// Dynamic CSS with function-based substitutions
|
|
1475
|
+
static: {
|
|
1476
|
+
route: '/assets',
|
|
1477
|
+
directory: './public',
|
|
1478
|
+
sub_ext: ['.css']
|
|
1479
|
+
},
|
|
1480
|
+
|
|
1481
|
+
global_subs: {
|
|
1482
|
+
theme_color: () => {
|
|
1483
|
+
const hour = new Date().getHours();
|
|
1484
|
+
return hour < 6 || hour > 18 ? '#2d3748' : '#4a5568';
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
```
|
|
1488
|
+
|
|
1489
|
+
This allows CSS files to use dynamic substitutions: `color: {{theme_color}};`
|
|
1462
1490
|
|
|
1463
1491
|
##### `global_subs?: Record<string, BootstrapSub>`
|
|
1464
1492
|
Optional global template substitutions available to all routes, error pages, and static files with `sub_ext`.
|
|
@@ -1467,10 +1495,28 @@ Optional global template substitutions available to all routes, error pages, and
|
|
|
1467
1495
|
global_subs: {
|
|
1468
1496
|
site_name: 'My Website',
|
|
1469
1497
|
version: '1.0.0',
|
|
1470
|
-
api_url: 'https://api.example.com'
|
|
1498
|
+
api_url: 'https://api.example.com',
|
|
1499
|
+
|
|
1500
|
+
// Function-based substitutions for dynamic content
|
|
1501
|
+
current_year: () => new Date().getFullYear().toString(),
|
|
1502
|
+
|
|
1503
|
+
build_time: async () => {
|
|
1504
|
+
// Example: fetch build timestamp from git
|
|
1505
|
+
const process = Bun.spawn(['git', 'log', '-1', '--format=%ct']);
|
|
1506
|
+
const output = await Bun.readableStreamToText(process.stdout);
|
|
1507
|
+
return new Date(parseInt(output.trim()) * 1000).toISOString();
|
|
1508
|
+
},
|
|
1509
|
+
|
|
1510
|
+
user_count: async () => {
|
|
1511
|
+
// Example: dynamic user count from database
|
|
1512
|
+
const count = await db.count('SELECT COUNT(*) as count FROM users');
|
|
1513
|
+
return count.toLocaleString();
|
|
1514
|
+
}
|
|
1471
1515
|
}
|
|
1472
1516
|
```
|
|
1473
1517
|
|
|
1518
|
+
Functions in `global_subs` and route-specific `subs` are called during template processing, allowing for dynamic content generation. Both synchronous and asynchronous functions are supported.
|
|
1519
|
+
|
|
1474
1520
|
#### Template Processing Order
|
|
1475
1521
|
|
|
1476
1522
|
1. Route content is loaded
|
|
@@ -1829,29 +1875,60 @@ await parse_template(template, replacements, true);
|
|
|
1829
1875
|
</html>
|
|
1830
1876
|
```
|
|
1831
1877
|
|
|
1832
|
-
|
|
1878
|
+
#### Custom Replacer Function
|
|
1879
|
+
|
|
1880
|
+
`parse_template` supports passing a function instead of a replacement object. This function will be called for each placeholder and the return value will be used as the replacement. Both synchronous and asynchronous functions are supported.
|
|
1833
1881
|
|
|
1834
1882
|
```ts
|
|
1835
|
-
const replacer = (
|
|
1836
|
-
|
|
1883
|
+
const replacer = (key: string) => {
|
|
1884
|
+
switch (key) {
|
|
1885
|
+
case 'timestamp': return Date.now().toString();
|
|
1886
|
+
case 'random': return Math.random().toString(36).substring(7);
|
|
1887
|
+
case 'greeting': return 'Hello, World!';
|
|
1888
|
+
default: return undefined;
|
|
1889
|
+
}
|
|
1837
1890
|
};
|
|
1838
1891
|
|
|
1839
|
-
await parse_template('
|
|
1892
|
+
await parse_template('Generated at {{timestamp}}: {{greeting}} (ID: {{random}})', replacer);
|
|
1893
|
+
// Result: "Generated at 1635789123456: Hello, World! (ID: x7k2p9m)"
|
|
1840
1894
|
```
|
|
1841
1895
|
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1896
|
+
Custom replacer functions are supported on a per-key basis, mixing with static string replacement.
|
|
1897
|
+
|
|
1898
|
+
```ts
|
|
1899
|
+
await parse_template('Hello {{foo}}, it is {{now}}', {
|
|
1900
|
+
foo: 'world',
|
|
1901
|
+
now: () => Date.now()
|
|
1902
|
+
});
|
|
1903
|
+
```
|
|
1904
|
+
|
|
1905
|
+
#### Key/Value Based Substitutions
|
|
1906
|
+
|
|
1907
|
+
`parse_template` supports key/value based substitutions using the `{{key=value}}` syntax. When a function replacer is provided for the key, the value is passed as a parameter to the function.
|
|
1908
|
+
|
|
1909
|
+
```ts
|
|
1910
|
+
await parse_template('Color: {{hex=blue}}', {
|
|
1911
|
+
hex: (color) => {
|
|
1912
|
+
const colors = { blue: '#0000ff', red: '#ff0000', green: '#00ff00' };
|
|
1913
|
+
return colors[color] || color;
|
|
1914
|
+
}
|
|
1915
|
+
});
|
|
1916
|
+
// Result: "Color: #0000ff"
|
|
1853
1917
|
```
|
|
1854
1918
|
|
|
1919
|
+
Global replacer functions also support the value parameter:
|
|
1920
|
+
|
|
1921
|
+
```ts
|
|
1922
|
+
await parse_template('Transform: {{upper=hello}} and {{lower=WORLD}}', (key, value) => {
|
|
1923
|
+
if (key === 'upper' && value) return value.toUpperCase();
|
|
1924
|
+
if (key === 'lower' && value) return value.toLowerCase();
|
|
1925
|
+
return 'unknown';
|
|
1926
|
+
});
|
|
1927
|
+
// Result: "Transform: HELLO and world"
|
|
1928
|
+
```
|
|
1929
|
+
|
|
1930
|
+
#### Conditional Rendering
|
|
1931
|
+
|
|
1855
1932
|
`parse_template` supports conditional rendering with the following syntax.
|
|
1856
1933
|
|
|
1857
1934
|
```html
|
|
@@ -1900,6 +1977,34 @@ const html = await parse_template(template, replacements);
|
|
|
1900
1977
|
</ul>
|
|
1901
1978
|
```
|
|
1902
1979
|
|
|
1980
|
+
#### Simple Array Iteration
|
|
1981
|
+
|
|
1982
|
+
For simple arrays containing strings, you can iterate directly over the array items:
|
|
1983
|
+
|
|
1984
|
+
```ts
|
|
1985
|
+
const template = `
|
|
1986
|
+
<ul>
|
|
1987
|
+
<t-for items="fruits" as="fruit">
|
|
1988
|
+
<li>{{fruit}}</li>
|
|
1989
|
+
</t-for>
|
|
1990
|
+
</ul>
|
|
1991
|
+
`;
|
|
1992
|
+
|
|
1993
|
+
const replacements = {
|
|
1994
|
+
fruits: ['apple', 'banana', 'orange']
|
|
1995
|
+
};
|
|
1996
|
+
|
|
1997
|
+
const html = await parse_template(template, replacements);
|
|
1998
|
+
```
|
|
1999
|
+
|
|
2000
|
+
```html
|
|
2001
|
+
<ul>
|
|
2002
|
+
<li>apple</li>
|
|
2003
|
+
<li>banana</li>
|
|
2004
|
+
<li>orange</li>
|
|
2005
|
+
</ul>
|
|
2006
|
+
```
|
|
2007
|
+
|
|
1903
2008
|
#### Dot Notation Property Access
|
|
1904
2009
|
|
|
1905
2010
|
You can access nested object properties using dot notation:
|
|
@@ -1940,91 +2045,73 @@ await parse_template(..., {
|
|
|
1940
2045
|
</t-for>
|
|
1941
2046
|
```
|
|
1942
2047
|
|
|
1943
|
-
|
|
2048
|
+
<a id="api-cache-busting"></a>
|
|
2049
|
+
## API > Cache Busting
|
|
1944
2050
|
|
|
1945
|
-
|
|
2051
|
+
### 🔧 ``cache_bust(string: path, format: string): string``
|
|
1946
2052
|
|
|
1947
|
-
|
|
1948
|
-
> Internally `generate_hash_subs()` uses `git ls-tree -r HEAD`, so the working directory must be a git repository.
|
|
2053
|
+
Appends a hash-suffix to the provided string, formatted by default as a query parameter, for cache-busting purposes.
|
|
1949
2054
|
|
|
1950
2055
|
```ts
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
generate_hash_subs().then(subs => hash_sub_table = subs).catch(caution);
|
|
1954
|
-
|
|
1955
|
-
server.route('/test', (req, url) => {
|
|
1956
|
-
return parse_template('Hello world {{hash=docs/project-logo.png}}', hash_sub_table);
|
|
1957
|
-
});
|
|
2056
|
+
cache_bust('static/my_image.png'); // > static/my_image.png?v=123fea
|
|
1958
2057
|
```
|
|
1959
2058
|
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
```
|
|
2059
|
+
> ![NOTE]
|
|
2060
|
+
> Internally `cache_bust()` uses `git_get_hashes()` to hash paths, requiring the input `path` to be a valid git path. If the path cannot be resolved in git, an empty hash is substituted.
|
|
1963
2061
|
|
|
1964
|
-
|
|
1965
|
-
> Specify paths as they appear in git, relative to the repository root and with forward slashes (no leading slash).
|
|
2062
|
+
### 🔧 ``cache_bust_set_format(format: string): void``
|
|
1966
2063
|
|
|
1967
|
-
|
|
2064
|
+
The default format for used for `cache_bust()` is `$file?v=$hash`, this can be customized per-call with the `format` parameter, or globally using `cache_bust_set_format()`
|
|
1968
2065
|
|
|
1969
2066
|
```ts
|
|
1970
|
-
|
|
1971
|
-
//
|
|
2067
|
+
cache_bust('dogs.txt'); // > dogs.txt?v=fff
|
|
2068
|
+
cache_bust('dogs.txt', '$file?hash=$hash'); // > dogs.txt?hash=fff
|
|
2069
|
+
|
|
2070
|
+
cache_bust_set_format('$file#$hash');
|
|
2071
|
+
cache_bust('dogs.txt'); // > dogs#fff
|
|
1972
2072
|
```
|
|
1973
2073
|
|
|
1974
|
-
|
|
1975
|
-
> SHA-1 hashes are `40` characters. Git is transitioning to SHA-256, which are `64` characters. Short hashes of `7` are generally sufficient for cache-busting.
|
|
2074
|
+
### 🔧 ``cache_bust_set_hash_length(length: number): void``
|
|
1976
2075
|
|
|
1977
|
-
|
|
2076
|
+
The default hash-length used by `cache_bust()` is 7. This can be changed with `cache_bust_set_hash_length()`.
|
|
1978
2077
|
|
|
1979
|
-
|
|
1980
|
-
|
|
2078
|
+
> ![NOTE]
|
|
2079
|
+
> Hashes are cached once at the specified length, therefore `cache_bust_set_hash_length()` must be called before calling `cache_bust()` and has no effect calling it after.
|
|
1981
2080
|
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2081
|
+
```ts
|
|
2082
|
+
cache_bust_set_hash_length(10);
|
|
2083
|
+
cache_bust('dogs.txt'); // > dogs.txt?v=ffffffffff
|
|
1985
2084
|
```
|
|
1986
2085
|
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
The optional `format` parameter allows you to customize how the substitution values are formatted. Use `$file` and `$hash` placeholders within the format string:
|
|
2086
|
+
### 🔧 ``cache_bust_get_hash_table(): Record<string, string>``
|
|
1990
2087
|
|
|
1991
|
-
|
|
1992
|
-
// Asset-style substitutions - reduces verbosity
|
|
1993
|
-
const asset_subs = await generate_hash_subs(7, 'asset=', undefined, '$file?v=$hash');
|
|
2088
|
+
This function returns the internal hash table used by `cache_bust()`. This is exposed to userland in the event that you which to use the hashes for other purposes, avoiding the need to call and store `git_get_hashes()` twice.
|
|
1994
2089
|
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
// Resolves to: static/css/shared.css?v=a1b2c3d
|
|
1998
|
-
|
|
1999
|
-
// Custom format example:
|
|
2000
|
-
await generate_hash_subs(7, 'url=', undefined, 'https://assets.example.com/$file?hash=$hash');
|
|
2001
|
-
// Result: { 'url=app.js': 'https://assets.example.com/app.js?hash=a1b2c3d' }
|
|
2002
|
-
```
|
|
2090
|
+
<a id="api-templating"></a>
|
|
2091
|
+
## API > Git
|
|
2003
2092
|
|
|
2004
|
-
|
|
2093
|
+
### 🔧 ``git_get_hashes(length: number): Promise<Record<string, string>>``
|
|
2005
2094
|
|
|
2006
|
-
### 🔧 ``
|
|
2095
|
+
### 🔧 ``git_get_hashes_sync(length: number): Record<string, string>``
|
|
2007
2096
|
|
|
2008
|
-
|
|
2097
|
+
Retrieve git hashes for all files in the repository. This is useful for implementing cache-busting functionality or creating file integrity checks.
|
|
2009
2098
|
|
|
2010
2099
|
> [!IMPORTANT]
|
|
2011
|
-
> Internally `
|
|
2100
|
+
> Internally `git_get_hashes()` uses `git ls-tree -r HEAD`, so the working directory must be a git repository.
|
|
2012
2101
|
|
|
2013
2102
|
```ts
|
|
2014
|
-
const hashes = await
|
|
2103
|
+
const hashes = await git_get_hashes(7);
|
|
2015
2104
|
// { 'docs/project-logo.png': '754d9ea' }
|
|
2016
2105
|
```
|
|
2017
2106
|
|
|
2018
|
-
|
|
2107
|
+
You can specify the hash length (default is 7 characters for short hashes):
|
|
2019
2108
|
|
|
2020
2109
|
```ts
|
|
2021
|
-
const
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
// hashes[0] -> { 'docs/project-logo.png': '754d9ea' }
|
|
2025
|
-
// subs[0] -> { 'hash=docs/project-logo.png': '754d9ea' }
|
|
2110
|
+
const full_hashes = await git_get_hashes(40);
|
|
2111
|
+
// { 'docs/project-logo.png': 'd65c52a41a75db43e184d2268c6ea9f9741de63e' }
|
|
2026
2112
|
```
|
|
2027
2113
|
|
|
2114
|
+
|
|
2028
2115
|
<a id="api-database"></a>
|
|
2029
2116
|
<a id="api-database-interface"></a>
|
|
2030
2117
|
## API > Database
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -397,9 +397,14 @@ export async function safe(target_fn: Callable) {
|
|
|
397
397
|
// endregion
|
|
398
398
|
|
|
399
399
|
// region templates
|
|
400
|
-
type ReplacerFn = (key: string) => string | Array<string> | undefined;
|
|
401
|
-
type AsyncReplaceFn = (key: string) => Promise<string | Array<string> | undefined>;
|
|
402
|
-
type
|
|
400
|
+
type ReplacerFn = (key: string, value?: string) => string | Array<string> | undefined;
|
|
401
|
+
type AsyncReplaceFn = (key: string, value?: string) => Promise<string | Array<string> | undefined>;
|
|
402
|
+
type ReplacementValueFn = () => string | Array<string> | undefined;
|
|
403
|
+
type AsyncReplacementValueFn = () => Promise<string | Array<string> | undefined>;
|
|
404
|
+
type ReplacementValueWithKeyFn = (value?: string) => string | Array<string> | undefined;
|
|
405
|
+
type AsyncReplacementValueWithKeyFn = (value?: string) => Promise<string | Array<string> | undefined>;
|
|
406
|
+
type ReplacementValue = string | Array<string> | object | object[] | ReplacementValueFn | AsyncReplacementValueFn | ReplacementValueWithKeyFn | AsyncReplacementValueWithKeyFn;
|
|
407
|
+
type Replacements = Record<string, ReplacementValue> | ReplacerFn | AsyncReplaceFn;
|
|
403
408
|
|
|
404
409
|
function get_nested_property(obj: any, path: string): any {
|
|
405
410
|
const keys = path.split('.');
|
|
@@ -435,11 +440,14 @@ export async function parse_template(template: string, replacements: Replacement
|
|
|
435
440
|
|
|
436
441
|
if (typeof replacements === 'function') {
|
|
437
442
|
scoped_replacements = async (key: string) => {
|
|
438
|
-
if (key === alias_name)
|
|
443
|
+
if (key === alias_name)
|
|
444
|
+
return loop_entry;
|
|
445
|
+
|
|
439
446
|
if (key.startsWith(alias_name + '.')) {
|
|
440
447
|
const prop_path = key.substring(alias_name.length + 1);
|
|
441
448
|
return get_nested_property(loop_entry, prop_path);
|
|
442
449
|
}
|
|
450
|
+
|
|
443
451
|
return await replacements(key);
|
|
444
452
|
};
|
|
445
453
|
} else {
|
|
@@ -467,6 +475,7 @@ export async function parse_template(template: string, replacements: Replacement
|
|
|
467
475
|
|
|
468
476
|
if (!drop_missing && !condition_value)
|
|
469
477
|
return match;
|
|
478
|
+
|
|
470
479
|
if (condition_value)
|
|
471
480
|
return await parse_template(if_content, replacements, drop_missing);
|
|
472
481
|
|
|
@@ -478,15 +487,31 @@ export async function parse_template(template: string, replacements: Replacement
|
|
|
478
487
|
result = await replace_async(result, var_regex, async (match, var_name) => {
|
|
479
488
|
// Trim whitespace from variable name
|
|
480
489
|
var_name = var_name.trim();
|
|
490
|
+
|
|
491
|
+
// Check for key=value syntax
|
|
492
|
+
let key = var_name;
|
|
493
|
+
let value: string | undefined = undefined;
|
|
494
|
+
const equals_index = var_name.indexOf('=');
|
|
495
|
+
|
|
496
|
+
if (equals_index !== -1) {
|
|
497
|
+
key = var_name.substring(0, equals_index);
|
|
498
|
+
value = var_name.substring(equals_index + 1);
|
|
499
|
+
}
|
|
500
|
+
|
|
481
501
|
let replacement;
|
|
482
502
|
|
|
483
503
|
if (is_replacer_fn) {
|
|
484
|
-
replacement = await replacements(
|
|
504
|
+
replacement = await replacements(key, value);
|
|
485
505
|
} else {
|
|
486
506
|
// First try direct key lookup (handles hash keys with dots like "hash=.gitignore")
|
|
487
507
|
replacement = replacements[var_name];
|
|
488
508
|
|
|
489
|
-
// If direct lookup fails and
|
|
509
|
+
// If direct lookup fails and we have key=value syntax, try key lookup
|
|
510
|
+
if (replacement === undefined && value !== undefined) {
|
|
511
|
+
replacement = replacements[key];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// If still undefined and variable contains dots, try nested property access
|
|
490
515
|
if (replacement === undefined && var_name.includes('.')) {
|
|
491
516
|
const dot_index = var_name.indexOf('.');
|
|
492
517
|
const base_key = var_name.substring(0, dot_index);
|
|
@@ -499,6 +524,13 @@ export async function parse_template(template: string, replacements: Replacement
|
|
|
499
524
|
}
|
|
500
525
|
}
|
|
501
526
|
|
|
527
|
+
if (replacement !== undefined && typeof replacement === 'function') {
|
|
528
|
+
if (value !== undefined && replacement.length > 0)
|
|
529
|
+
replacement = await replacement(value);
|
|
530
|
+
else
|
|
531
|
+
replacement = await replacement();
|
|
532
|
+
}
|
|
533
|
+
|
|
502
534
|
if (replacement !== undefined)
|
|
503
535
|
return replacement;
|
|
504
536
|
|
|
@@ -524,8 +556,36 @@ async function replace_async(str: string, regex: RegExp, replacer_fn: (match: st
|
|
|
524
556
|
|
|
525
557
|
return result;
|
|
526
558
|
}
|
|
559
|
+
// endregion
|
|
560
|
+
|
|
561
|
+
// region cache busting
|
|
562
|
+
let cache_bust_map: Record<string, string> | null = null;
|
|
563
|
+
let cache_bust_global_length = 7;
|
|
564
|
+
let cache_bust_global_format = '$file?v=$hash';
|
|
565
|
+
|
|
566
|
+
export function cache_bust_get_hash_table(): Record<string, string> {
|
|
567
|
+
if (cache_bust_map === null)
|
|
568
|
+
cache_bust_map = git_get_hashes_sync(cache_bust_global_length);
|
|
569
|
+
|
|
570
|
+
return cache_bust_map;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function cache_bust(path: string, format = cache_bust_global_format): string {
|
|
574
|
+
const hash = cache_bust_get_hash_table()[path] || '';
|
|
575
|
+
return format.replace('$file', path).replace('$hash', hash);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function cache_bust_set_hash_length(length: number): void {
|
|
579
|
+
cache_bust_global_length = length;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function cache_bust_set_format(format: string): void {
|
|
583
|
+
cache_bust_global_format = format;
|
|
584
|
+
}
|
|
585
|
+
// endregion
|
|
527
586
|
|
|
528
|
-
|
|
587
|
+
// region git
|
|
588
|
+
export async function git_get_hashes(length = 7): Promise<Record<string, string>> {
|
|
529
589
|
const cmd = ['git', 'ls-tree', '-r', 'HEAD'];
|
|
530
590
|
const process = Bun.spawn(cmd, {
|
|
531
591
|
stdout: 'pipe',
|
|
@@ -535,7 +595,7 @@ export async function get_git_hashes(length = 7): Promise<Record<string, string>
|
|
|
535
595
|
await process.exited;
|
|
536
596
|
|
|
537
597
|
if (process.exitCode as number > 0)
|
|
538
|
-
throw new Error('
|
|
598
|
+
throw new Error('git_get_hashes() failed, `' + cmd.join(' ') + '` exited with non-zero exit code.');
|
|
539
599
|
|
|
540
600
|
const stdout = await Bun.readableStreamToText(process.stdout as ReadableStream);
|
|
541
601
|
const hash_map: Record<string, string> = {};
|
|
@@ -549,22 +609,24 @@ export async function get_git_hashes(length = 7): Promise<Record<string, string>
|
|
|
549
609
|
return hash_map;
|
|
550
610
|
}
|
|
551
611
|
|
|
552
|
-
export
|
|
612
|
+
export function git_get_hashes_sync(length = 7): Record<string, string> {
|
|
613
|
+
const cmd = ['git', 'ls-tree', '-r', 'HEAD'];
|
|
614
|
+
const process = Bun.spawnSync(cmd, {
|
|
615
|
+
stdout: 'pipe',
|
|
616
|
+
stderr: 'pipe'
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
if (process.exitCode > 0)
|
|
620
|
+
throw new Error('git_get_hashes_sync() failed, `' + cmd.join(' ') + '` exited with non-zero exit code.');
|
|
621
|
+
|
|
622
|
+
const stdout = process.stdout.toString();
|
|
553
623
|
const hash_map: Record<string, string> = {};
|
|
554
624
|
|
|
555
|
-
|
|
556
|
-
|
|
625
|
+
const regex = /([^\s]+)\s([^\s]+)\s([^\s]+)\t(.+)/g;
|
|
626
|
+
let match: RegExpExecArray | null;
|
|
557
627
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const formatted_value = format
|
|
561
|
-
.replace(/\$file/g, file)
|
|
562
|
-
.replace(/\$hash/g, hash);
|
|
563
|
-
hash_map[prefix + file] = formatted_value;
|
|
564
|
-
} else {
|
|
565
|
-
hash_map[prefix + file] = hash;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
628
|
+
while (match = regex.exec(stdout))
|
|
629
|
+
hash_map[match[4]] = match[3].substring(0, length);
|
|
568
630
|
|
|
569
631
|
return hash_map;
|
|
570
632
|
}
|
|
@@ -984,23 +1046,26 @@ type WebsocketHandlers = {
|
|
|
984
1046
|
drain?: (ws: WebSocket) => void
|
|
985
1047
|
};
|
|
986
1048
|
|
|
987
|
-
type BootstrapSub =
|
|
1049
|
+
type BootstrapSub = ReplacementValue;
|
|
988
1050
|
|
|
989
1051
|
type BootstrapRoute = {
|
|
990
1052
|
content: string | BunFile;
|
|
991
1053
|
subs?: Record<string, BootstrapSub>;
|
|
992
1054
|
};
|
|
993
1055
|
|
|
1056
|
+
type BootstrapCacheBust = {
|
|
1057
|
+
prefix?: string;
|
|
1058
|
+
hash_length?: number;
|
|
1059
|
+
format?: string;
|
|
1060
|
+
};
|
|
1061
|
+
|
|
994
1062
|
type BootstrapOptions = {
|
|
995
1063
|
base?: string | BunFile;
|
|
996
1064
|
routes: Record<string, BootstrapRoute>;
|
|
997
1065
|
cache?: ReturnType<typeof cache_http> | CacheOptions;
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
format?: string;
|
|
1002
|
-
hashes?: Record<string, string>;
|
|
1003
|
-
};
|
|
1066
|
+
|
|
1067
|
+
cache_bust?: boolean | BootstrapCacheBust;
|
|
1068
|
+
|
|
1004
1069
|
error?: {
|
|
1005
1070
|
use_canary_reporting?: boolean;
|
|
1006
1071
|
error_page: string | BunFile;
|
|
@@ -1420,18 +1485,22 @@ export function http_serve(port: number, hostname?: string) {
|
|
|
1420
1485
|
|
|
1421
1486
|
/* Bootstrap a static web server */
|
|
1422
1487
|
bootstrap: async function(options: BootstrapOptions) {
|
|
1423
|
-
let
|
|
1488
|
+
let cache_bust_subs: Record<string, ReplacementValue> = {};
|
|
1424
1489
|
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1490
|
+
const cache_bust_opts = options.cache_bust;
|
|
1491
|
+
if (typeof cache_bust_opts === 'object' && cache_bust_opts !== null) {
|
|
1492
|
+
if (typeof cache_bust_opts.hash_length === 'number')
|
|
1493
|
+
cache_bust_set_hash_length(cache_bust_opts.hash_length);
|
|
1494
|
+
|
|
1495
|
+
if (typeof cache_bust_opts.format === 'string')
|
|
1496
|
+
cache_bust_set_format(cache_bust_opts.format);
|
|
1497
|
+
|
|
1498
|
+
cache_bust_subs[cache_bust_opts.prefix ?? 'cache_bust'] = cache_bust;
|
|
1499
|
+
} else if (cache_bust_opts === true) {
|
|
1500
|
+
cache_bust_subs = { cache_bust };
|
|
1432
1501
|
}
|
|
1433
1502
|
|
|
1434
|
-
const global_sub_table = sub_table_merge(
|
|
1503
|
+
const global_sub_table = sub_table_merge(cache_bust_subs, options.global_subs);
|
|
1435
1504
|
|
|
1436
1505
|
let cache = options.cache;
|
|
1437
1506
|
if (cache !== undefined && !is_cache_http(cache))
|