glashjs 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -0
- package/bin/glash.mjs +44 -0
- package/package.json +37 -0
- package/src/assets/animated-favicon.mjs +85 -0
- package/src/assets/optimize.mjs +181 -0
- package/src/build.mjs +97 -0
- package/src/config.mjs +45 -0
- package/src/index.mjs +7 -0
- package/src/offline/generate-sw.mjs +130 -0
- package/src/security/headers.mjs +72 -0
- package/templates/favicon-animated.svg +12 -0
- package/templates/favicon.svg +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# glashjs
|
|
2
|
+
|
|
3
|
+
A build pipeline + runtime conventions for **fast, offline-capable, hard-to-hack** sites — the glashdb-native web framework. Built on proven primitives (not a from-scratch bundler, not a Next.js fork), so it ships real features instead of promises.
|
|
4
|
+
|
|
5
|
+
> **Status:** `0.0.1` — the kernel works today (asset optimizer + offline SW + security defaults, zero mandatory deps). The full framework (routing, SSR, dev server) is on the roadmap below.
|
|
6
|
+
|
|
7
|
+
## What it does today
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
glash build # optimize assets, emit offline SW + PWA + security manifests
|
|
11
|
+
glash optimize public # just run the asset optimizer over a directory
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Run against the real glashdb SVG logos (zero optional deps installed):
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
assets 6
|
|
18
|
+
original 33.2 KB
|
|
19
|
+
optimized 10.1 KB
|
|
20
|
+
saved 69.7% ← real Brotli, browser-transparent
|
|
21
|
+
offline 10 files precached (works offline after first visit)
|
|
22
|
+
security strict CSP + 11 headers
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## The three pillars
|
|
26
|
+
|
|
27
|
+
### 1. Asset optimizer — the honest version of "10–20× compression"
|
|
28
|
+
JPG and MP4 are *already* compressed; you can't losslessly shrink them 10–20× and restore them. So glashjs does what actually wins:
|
|
29
|
+
- **Text / SVG / JS / CSS / HTML** → **Brotli + Gzip** (`zlib`, built in). Real 4–8× on text/SVG. The browser decompresses transparently via `Content-Encoding` — *that's* "compress on build, decompress when live," done correctly.
|
|
30
|
+
- **jpg / png / webp** → **AVIF + WebP** variants (needs optional `sharp`). Typically 3–10× vs unoptimized originals.
|
|
31
|
+
- **mp4 / mov / webm** → **AV1** + poster frame (needs optional `ffmpeg`).
|
|
32
|
+
- Emits `glash-assets.manifest.json` so the glashdb edge (or any server) serves the best variant per client. Originals are never mutated.
|
|
33
|
+
|
|
34
|
+
### 2. Offline layer — usable with no internet after first visit
|
|
35
|
+
Generates a **Service Worker** (`glash-sw.js`) + PWA manifest that precache the app shell and hashed assets into small cached files. Strategies:
|
|
36
|
+
- **assets** → cache-first (immutable, hash-busted on deploy)
|
|
37
|
+
- **HTML** → stale-while-revalidate (instant, self-healing)
|
|
38
|
+
- **`/api` `/rest` `/auth` `/live` `/stream`** → **network-first**, so offline mode degrades *exactly* at live/updated data and streaming — the site keeps working, just without fresh data. (Configurable via `dataPrefixes`.)
|
|
39
|
+
|
|
40
|
+
### 3. Security — "hard to hack," honestly (not "unhackable")
|
|
41
|
+
Nothing is unhackable. glashjs ships strong, opinionated defaults so you're secure unless you loosen them:
|
|
42
|
+
- **Strict CSP** with no `'unsafe-inline'` scripts (XSS-via-injection blocked by default)
|
|
43
|
+
- HSTS, `X-Content-Type-Options`, `X-Frame-Options: DENY`, COOP/COEP/CORP isolation, tight `Permissions-Policy` & `Referrer-Policy`
|
|
44
|
+
- **Subresource Integrity** helper (`sri()`) for build assets
|
|
45
|
+
- Emitted to `glash-headers.json` for the edge, plus a `glashSecurity()` Express middleware
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
// glash.config.mjs
|
|
51
|
+
import { defineConfig } from 'glashjs/config';
|
|
52
|
+
|
|
53
|
+
export default defineConfig({
|
|
54
|
+
name: 'My Site',
|
|
55
|
+
publicDir: 'public',
|
|
56
|
+
outDir: '.glash/out',
|
|
57
|
+
offline: true,
|
|
58
|
+
dataPrefixes: ['/api/', '/rest/', '/live'], // network-first (no stale live data offline)
|
|
59
|
+
// favicon defaults to the official glashdb logo bundled with glashjs
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { build, optimizeAssets, securityHeaders, sri } from 'glashjs';
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The preview favicon defaults to the **official glashdb logo** (`templates/favicon.svg`).
|
|
68
|
+
|
|
69
|
+
### Animated favicon (on by default)
|
|
70
|
+
Every build also emits an **animated favicon** — the bundled glash mark, your own
|
|
71
|
+
animated SVG/GIF, or a set of frames that cycle in the tab. The build writes a tiny
|
|
72
|
+
runtime; call it once and it animates the tab icon, pausing while the tab is hidden:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import { startGlashFavicon } from '/glash-favicon.mjs';
|
|
76
|
+
startGlashFavicon(); // config is baked in at build time
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
// glash.config.mjs
|
|
81
|
+
animatedFavicon: true, // bundled animated glash mark (default)
|
|
82
|
+
// animatedFavicon: '/brand/logo-animated.svg' // your own animated SVG/GIF
|
|
83
|
+
// animatedFavicon: { frames: ['/f0.svg','/f1.svg'], fps: 10 }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Roadmap (toward "bigger than Next")
|
|
87
|
+
- [x] Asset optimizer (Brotli/Gzip real; AVIF/WebP/AV1 via optional sharp/ffmpeg)
|
|
88
|
+
- [x] Offline Service Worker + PWA manifest
|
|
89
|
+
- [x] Secure-by-default headers + CSP + SRI
|
|
90
|
+
- [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
|
|
91
|
+
- [ ] Dev server + HMR (on Vite/esbuild) and file-based routing
|
|
92
|
+
- [ ] SSR / streaming + edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
|
|
93
|
+
- [ ] Signed, immutable asset URLs + automatic SRI injection into HTML
|
|
94
|
+
- [ ] `glash deploy` → glashdb hosting in one command
|
|
95
|
+
|
|
96
|
+
## Design stance
|
|
97
|
+
glashjs composes the best existing primitives rather than reinventing them. "Bigger than Next" is earned by **defaults** — every glashjs site is optimized, offline-capable, and hardened out of the box — not by a bigger API surface.
|
package/bin/glash.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// glashjs CLI
|
|
3
|
+
import { build } from '../src/build.mjs';
|
|
4
|
+
import { optimizeAssets } from '../src/assets/optimize.mjs';
|
|
5
|
+
|
|
6
|
+
const [, , cmd, ...rest] = process.argv;
|
|
7
|
+
|
|
8
|
+
function arg(name, fallback) {
|
|
9
|
+
const i = rest.indexOf(name);
|
|
10
|
+
return i >= 0 && rest[i + 1] ? rest[i + 1] : fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
switch (cmd) {
|
|
15
|
+
case 'build': {
|
|
16
|
+
await build({ root: arg('--root', process.cwd()) });
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
case 'optimize': {
|
|
20
|
+
const dir = rest[0] && !rest[0].startsWith('--') ? rest[0] : 'public';
|
|
21
|
+
console.log(`glashjs optimize — ${dir}\n`);
|
|
22
|
+
const manifest = await optimizeAssets(dir, { log: console.log });
|
|
23
|
+
const t = manifest.totals;
|
|
24
|
+
console.log(`\n${t.assets} assets · ${t.savedPercent}% smaller (${(t.originalBytes / 1024).toFixed(1)} KB -> ${(t.optimizedBytes / 1024).toFixed(1)} KB)`);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case 'version':
|
|
28
|
+
case '--version':
|
|
29
|
+
case '-v':
|
|
30
|
+
console.log('glashjs 0.0.1');
|
|
31
|
+
break;
|
|
32
|
+
default:
|
|
33
|
+
console.log(`glashjs — fast, offline-capable, hard-to-hack sites
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
glash build [--root <dir>] Optimize assets, generate offline SW + PWA + security manifests
|
|
37
|
+
glash optimize [<dir>] Just run the asset optimizer over a directory
|
|
38
|
+
glash version Print version
|
|
39
|
+
|
|
40
|
+
Docs: ./README.md`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
main().catch((err) => { console.error('glashjs error:', err?.message ?? err); process.exit(1); });
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "glashjs",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "glashjs — a build pipeline + runtime conventions for fast, offline-capable, hard-to-hack sites. Built on proven primitives, with a best-in-class asset optimizer, PWA offline layer, and secure-by-default headers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"glash": "bin/glash.mjs"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.mjs",
|
|
11
|
+
"./config": "./src/config.mjs",
|
|
12
|
+
"./security": "./src/security/headers.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"templates"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"optionalDependencies": {
|
|
23
|
+
"sharp": "^0.33.0"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"glash",
|
|
27
|
+
"glashdb",
|
|
28
|
+
"framework",
|
|
29
|
+
"offline",
|
|
30
|
+
"pwa",
|
|
31
|
+
"asset-optimization",
|
|
32
|
+
"avif",
|
|
33
|
+
"brotli",
|
|
34
|
+
"security"
|
|
35
|
+
],
|
|
36
|
+
"license": "UNLICENSED"
|
|
37
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// glashjs animated favicon
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Ships an animated favicon with every build. Two reliable modes:
|
|
4
|
+
// - animated SVG (SMIL) -> set once; browsers that support it animate the
|
|
5
|
+
// tab icon natively (the default glash mark).
|
|
6
|
+
// - frame cycling -> a tiny runtime swaps the <link rel=icon> href
|
|
7
|
+
// across frames on an interval. Works everywhere,
|
|
8
|
+
// and pauses while the tab is hidden (no wasted CPU).
|
|
9
|
+
//
|
|
10
|
+
// Build emits `glash-favicon.mjs` with the resolved config baked in, so the app
|
|
11
|
+
// just calls `startGlashFavicon()` once — no arguments needed.
|
|
12
|
+
import { promises as fs } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const RUNTIME = `// AUTO-GENERATED by glashjs. Animated favicon runtime.
|
|
17
|
+
const CONFIG = __CONFIG__;
|
|
18
|
+
|
|
19
|
+
export function startGlashFavicon(opts = {}) {
|
|
20
|
+
if (typeof document === 'undefined') return () => {};
|
|
21
|
+
const cfg = { ...CONFIG, ...opts };
|
|
22
|
+
let link = document.querySelector('link[rel~="icon"]');
|
|
23
|
+
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
|
|
24
|
+
|
|
25
|
+
// Single animated SVG: SMIL animates natively where supported.
|
|
26
|
+
if (cfg.animated && !(cfg.frames && cfg.frames.length)) {
|
|
27
|
+
link.type = 'image/svg+xml';
|
|
28
|
+
link.href = cfg.animated;
|
|
29
|
+
return () => {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const frames = cfg.frames || [];
|
|
33
|
+
if (!frames.length) return () => {};
|
|
34
|
+
const interval = 1000 / (cfg.fps || 8);
|
|
35
|
+
let i = 0, timer = null;
|
|
36
|
+
const tick = () => { i = (i + 1) % frames.length; link.href = frames[i]; };
|
|
37
|
+
const start = () => { if (!timer) timer = setInterval(tick, interval); };
|
|
38
|
+
const stop = () => { if (timer) { clearInterval(timer); timer = null; } };
|
|
39
|
+
document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
|
|
40
|
+
start();
|
|
41
|
+
return stop;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (CONFIG.auto) startGlashFavicon();
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve + emit the animated favicon for a build.
|
|
49
|
+
* cfg.animatedFavicon may be:
|
|
50
|
+
* true -> the bundled default glash animated mark
|
|
51
|
+
* "<path>" -> a project animated SVG/GIF
|
|
52
|
+
* { frames:[...], fps, auto } -> frame-cycling animation
|
|
53
|
+
*/
|
|
54
|
+
export async function generateAnimatedFavicon(outDir, cfg, root, log = () => {}) {
|
|
55
|
+
const setting = cfg.animatedFavicon;
|
|
56
|
+
if (!setting) return { enabled: false };
|
|
57
|
+
|
|
58
|
+
let baked = { auto: true, fps: 8 };
|
|
59
|
+
|
|
60
|
+
if (setting === true || typeof setting === 'string') {
|
|
61
|
+
const src = setting === true
|
|
62
|
+
? fileURLToPath(new URL('../../templates/favicon-animated.svg', import.meta.url))
|
|
63
|
+
: path.resolve(root, setting);
|
|
64
|
+
const out = 'favicon-animated' + path.extname(src);
|
|
65
|
+
try {
|
|
66
|
+
await fs.copyFile(src, path.join(outDir, out));
|
|
67
|
+
} catch {
|
|
68
|
+
log(` ! animated favicon source not found (${setting}) — using bundled default`);
|
|
69
|
+
await fs.copyFile(fileURLToPath(new URL('../../templates/favicon-animated.svg', import.meta.url)), path.join(outDir, 'favicon-animated.svg'));
|
|
70
|
+
baked.animated = '/favicon-animated.svg';
|
|
71
|
+
}
|
|
72
|
+
baked.animated = baked.animated || '/' + out;
|
|
73
|
+
} else if (setting && Array.isArray(setting.frames)) {
|
|
74
|
+
baked.frames = setting.frames;
|
|
75
|
+
baked.fps = setting.fps ?? 8;
|
|
76
|
+
if (setting.auto === false) baked.auto = false;
|
|
77
|
+
} else {
|
|
78
|
+
return { enabled: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const runtime = RUNTIME.replace('__CONFIG__', JSON.stringify(baked));
|
|
82
|
+
await fs.writeFile(path.join(outDir, 'glash-favicon.mjs'), runtime);
|
|
83
|
+
log(` animated favicon -> ${baked.animated ? baked.animated : `${baked.frames.length} frames @ ${baked.fps}fps`}`);
|
|
84
|
+
return { enabled: true, ...baked, runtime: 'glash-favicon.mjs' };
|
|
85
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// glashjs asset optimizer
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Walks an input directory and produces optimized variants of every asset:
|
|
4
|
+
// - text/SVG/JS/CSS/HTML/JSON -> Brotli + Gzip (real, Node built-in zlib).
|
|
5
|
+
// The browser decompresses transparently via
|
|
6
|
+
// `Content-Encoding`, so this is the honest
|
|
7
|
+
// version of "compress on build, decompress
|
|
8
|
+
// when live."
|
|
9
|
+
// - jpg/png/webp images -> AVIF + WebP variants IF `sharp` is present.
|
|
10
|
+
// - mp4/mov/webm video -> AV1/HEVC + poster IF `ffmpeg` is on PATH.
|
|
11
|
+
//
|
|
12
|
+
// Everything degrades gracefully: with zero optional tools installed you still
|
|
13
|
+
// get real Brotli/Gzip savings and a complete manifest. Nothing is destructive
|
|
14
|
+
// — originals are never modified; variants are written alongside them.
|
|
15
|
+
import { promises as fs } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import zlib from 'node:zlib';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
import { execFile } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
const brotli = promisify(zlib.brotliCompress);
|
|
23
|
+
const gzip = promisify(zlib.gzip);
|
|
24
|
+
const exec = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
const TEXT_EXT = new Set(['.svg', '.css', '.js', '.mjs', '.html', '.json', '.txt', '.xml', '.map', '.webmanifest']);
|
|
27
|
+
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
|
28
|
+
const VIDEO_EXT = new Set(['.mp4', '.mov', '.webm', '.m4v']);
|
|
29
|
+
|
|
30
|
+
const BROTLI_OPTS = {
|
|
31
|
+
params: {
|
|
32
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
|
|
33
|
+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: 0,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async function sha(buf) {
|
|
38
|
+
return createHash('sha256').update(buf).digest('hex').slice(0, 16);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function* walk(dir) {
|
|
42
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
43
|
+
const full = path.join(dir, entry.name);
|
|
44
|
+
if (entry.isDirectory()) yield* walk(full);
|
|
45
|
+
else if (entry.isFile()) yield full;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let _sharp; // lazy, optional
|
|
50
|
+
async function getSharp() {
|
|
51
|
+
if (_sharp !== undefined) return _sharp;
|
|
52
|
+
try { _sharp = (await import('sharp')).default; } catch { _sharp = null; }
|
|
53
|
+
return _sharp;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let _ffmpeg; // lazy, optional
|
|
57
|
+
async function hasFfmpeg() {
|
|
58
|
+
if (_ffmpeg !== undefined) return _ffmpeg;
|
|
59
|
+
try { await exec('ffmpeg', ['-version']); _ffmpeg = true; } catch { _ffmpeg = false; }
|
|
60
|
+
return _ffmpeg;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pct(from, to) {
|
|
64
|
+
if (!from) return 0;
|
|
65
|
+
return Math.round((1 - to / from) * 1000) / 10;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function optimizeText(file, buf, rel) {
|
|
69
|
+
const [br, gz] = await Promise.all([brotli(buf, BROTLI_OPTS), gzip(buf, { level: 9 })]);
|
|
70
|
+
await Promise.all([
|
|
71
|
+
fs.writeFile(file + '.br', br),
|
|
72
|
+
fs.writeFile(file + '.gz', gz),
|
|
73
|
+
]);
|
|
74
|
+
return {
|
|
75
|
+
kind: 'text',
|
|
76
|
+
original: buf.length,
|
|
77
|
+
variants: {
|
|
78
|
+
br: { path: rel + '.br', bytes: br.length, encoding: 'br', saved: pct(buf.length, br.length) },
|
|
79
|
+
gz: { path: rel + '.gz', bytes: gz.length, encoding: 'gzip', saved: pct(buf.length, gz.length) },
|
|
80
|
+
},
|
|
81
|
+
best: Math.min(br.length, gz.length),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function optimizeImage(file, buf, rel) {
|
|
86
|
+
const sharp = await getSharp();
|
|
87
|
+
if (!sharp) {
|
|
88
|
+
return { kind: 'image', original: buf.length, best: buf.length, skipped: 'sharp not installed (npm i sharp to enable AVIF/WebP)', variants: {} };
|
|
89
|
+
}
|
|
90
|
+
const base = file.replace(/\.(jpe?g|png|webp)$/i, '');
|
|
91
|
+
const relBase = rel.replace(/\.(jpe?g|png|webp)$/i, '');
|
|
92
|
+
const [avif, webp] = await Promise.all([
|
|
93
|
+
sharp(buf).avif({ quality: 50 }).toBuffer(),
|
|
94
|
+
sharp(buf).webp({ quality: 72 }).toBuffer(),
|
|
95
|
+
]);
|
|
96
|
+
await Promise.all([
|
|
97
|
+
fs.writeFile(base + '.avif', avif),
|
|
98
|
+
fs.writeFile(base + '.webp', webp),
|
|
99
|
+
]);
|
|
100
|
+
return {
|
|
101
|
+
kind: 'image',
|
|
102
|
+
original: buf.length,
|
|
103
|
+
variants: {
|
|
104
|
+
avif: { path: relBase + '.avif', bytes: avif.length, type: 'image/avif', saved: pct(buf.length, avif.length) },
|
|
105
|
+
webp: { path: relBase + '.webp', bytes: webp.length, type: 'image/webp', saved: pct(buf.length, webp.length) },
|
|
106
|
+
},
|
|
107
|
+
best: Math.min(avif.length, webp.length),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function optimizeVideo(file, buf, rel) {
|
|
112
|
+
if (!(await hasFfmpeg())) {
|
|
113
|
+
return { kind: 'video', original: buf.length, best: buf.length, skipped: 'ffmpeg not on PATH (install ffmpeg to enable AV1 + poster)', variants: {} };
|
|
114
|
+
}
|
|
115
|
+
const base = file.replace(/\.(mp4|mov|webm|m4v)$/i, '');
|
|
116
|
+
const relBase = rel.replace(/\.(mp4|mov|webm|m4v)$/i, '');
|
|
117
|
+
const out = base + '.glash.webm';
|
|
118
|
+
const poster = base + '.poster.jpg';
|
|
119
|
+
// AV1 (libaom) — much smaller than H.264 source; capped CRF for sane build times.
|
|
120
|
+
await exec('ffmpeg', ['-y', '-i', file, '-c:v', 'libaom-av1', '-crf', '34', '-b:v', '0', '-c:a', 'libopus', out]);
|
|
121
|
+
await exec('ffmpeg', ['-y', '-i', file, '-vf', 'select=eq(n\\,0)', '-vframes', '1', poster]).catch(() => {});
|
|
122
|
+
const outBuf = await fs.readFile(out);
|
|
123
|
+
return {
|
|
124
|
+
kind: 'video',
|
|
125
|
+
original: buf.length,
|
|
126
|
+
variants: {
|
|
127
|
+
av1: { path: relBase + '.glash.webm', bytes: outBuf.length, type: 'video/webm', saved: pct(buf.length, outBuf.length) },
|
|
128
|
+
poster: { path: relBase + '.poster.jpg', type: 'image/jpeg' },
|
|
129
|
+
},
|
|
130
|
+
best: outBuf.length,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Optimize every asset under `dir`. Returns a manifest object:
|
|
136
|
+
* { generatedAt, totals, assets: { "<rel>": <entry> } }
|
|
137
|
+
* Writes variant files alongside originals; never mutates originals.
|
|
138
|
+
*/
|
|
139
|
+
export async function optimizeAssets(dir, { log = () => {} } = {}) {
|
|
140
|
+
const root = path.resolve(dir);
|
|
141
|
+
const assets = {};
|
|
142
|
+
let totalOriginal = 0;
|
|
143
|
+
let totalBest = 0;
|
|
144
|
+
|
|
145
|
+
for await (const file of walk(root)) {
|
|
146
|
+
const ext = path.extname(file).toLowerCase();
|
|
147
|
+
// Don't re-process our own outputs.
|
|
148
|
+
if (file.endsWith('.br') || file.endsWith('.gz') || file.endsWith('.glash.webm') || file.endsWith('.poster.jpg')) continue;
|
|
149
|
+
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
150
|
+
const buf = await fs.readFile(file);
|
|
151
|
+
const hash = await sha(buf);
|
|
152
|
+
|
|
153
|
+
let entry;
|
|
154
|
+
if (TEXT_EXT.has(ext)) entry = await optimizeText(file, buf, rel);
|
|
155
|
+
else if (IMAGE_EXT.has(ext)) entry = await optimizeImage(file, buf, rel);
|
|
156
|
+
else if (VIDEO_EXT.has(ext)) entry = await optimizeVideo(file, buf, rel);
|
|
157
|
+
else entry = { kind: 'raw', original: buf.length, best: buf.length, variants: {} };
|
|
158
|
+
|
|
159
|
+
entry.hash = hash;
|
|
160
|
+
assets[rel] = entry;
|
|
161
|
+
totalOriginal += entry.original;
|
|
162
|
+
totalBest += entry.best ?? entry.original;
|
|
163
|
+
|
|
164
|
+
const saved = pct(entry.original, entry.best ?? entry.original);
|
|
165
|
+
const tag = entry.skipped ? `skip (${entry.skipped})` : `${saved}% smaller`;
|
|
166
|
+
log(` ${entry.kind.padEnd(5)} ${rel} — ${tag}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
generatedAt: null, // stamped by caller (build is deterministic; time injected outside)
|
|
171
|
+
totals: {
|
|
172
|
+
assets: Object.keys(assets).length,
|
|
173
|
+
originalBytes: totalOriginal,
|
|
174
|
+
optimizedBytes: totalBest,
|
|
175
|
+
savedPercent: pct(totalOriginal, totalBest),
|
|
176
|
+
},
|
|
177
|
+
assets,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export { TEXT_EXT, IMAGE_EXT, VIDEO_EXT };
|
package/src/build.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// glashjs build orchestrator
|
|
2
|
+
// Ties the pieces together: optimize assets -> derive a content version ->
|
|
3
|
+
// generate the offline Service Worker + PWA manifest -> emit security headers
|
|
4
|
+
// + a deploy manifest the glashdb edge (or any server) can consume.
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { optimizeAssets } from './assets/optimize.mjs';
|
|
10
|
+
import { generateAnimatedFavicon } from './assets/animated-favicon.mjs';
|
|
11
|
+
import { generateServiceWorker } from './offline/generate-sw.mjs';
|
|
12
|
+
import { securityHeaders } from './security/headers.mjs';
|
|
13
|
+
import { loadConfig } from './config.mjs';
|
|
14
|
+
|
|
15
|
+
function deriveVersion(manifest) {
|
|
16
|
+
const h = createHash('sha256');
|
|
17
|
+
for (const [rel, entry] of Object.entries(manifest.assets)) h.update(rel + ':' + entry.hash);
|
|
18
|
+
return h.digest('hex').slice(0, 12);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pwaManifest(cfg, version) {
|
|
22
|
+
return {
|
|
23
|
+
name: cfg.name,
|
|
24
|
+
short_name: cfg.shortName ?? cfg.name,
|
|
25
|
+
start_url: '/',
|
|
26
|
+
display: 'standalone',
|
|
27
|
+
background_color: cfg.themeColor ?? '#0b0d12',
|
|
28
|
+
theme_color: cfg.themeColor ?? '#0b0d12',
|
|
29
|
+
icons: [{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' }],
|
|
30
|
+
version,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function build({ root = process.cwd(), log = console.log } = {}) {
|
|
35
|
+
const cfg = await loadConfig(root);
|
|
36
|
+
const publicDir = path.resolve(root, cfg.publicDir);
|
|
37
|
+
const outDir = path.resolve(root, cfg.outDir);
|
|
38
|
+
|
|
39
|
+
log(`\nglashjs build — "${cfg.name}"`);
|
|
40
|
+
log(` public: ${path.relative(root, publicDir)} -> out: ${path.relative(root, outDir)}\n`);
|
|
41
|
+
|
|
42
|
+
log('Optimizing assets:');
|
|
43
|
+
const manifest = await optimizeAssets(publicDir, { log });
|
|
44
|
+
const version = deriveVersion(manifest);
|
|
45
|
+
manifest.version = version;
|
|
46
|
+
manifest.generatedAt = new Date().toISOString();
|
|
47
|
+
|
|
48
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
49
|
+
|
|
50
|
+
// Offline Service Worker + registration helper.
|
|
51
|
+
const offline = cfg.offline
|
|
52
|
+
? await generateServiceWorker(outDir, manifest, { dataPrefixes: cfg.dataPrefixes })
|
|
53
|
+
: { precached: 0, serviceWorker: null };
|
|
54
|
+
|
|
55
|
+
// PWA manifest + favicon (static + animated).
|
|
56
|
+
await fs.writeFile(path.join(outDir, 'manifest.webmanifest'), JSON.stringify(pwaManifest(cfg, version), null, 2));
|
|
57
|
+
await copyFavicon(cfg, root, outDir, log);
|
|
58
|
+
const animated = await generateAnimatedFavicon(outDir, cfg, root, log);
|
|
59
|
+
|
|
60
|
+
// Asset + deploy manifests for the edge/server to serve best variants.
|
|
61
|
+
await fs.writeFile(path.join(outDir, 'glash-assets.manifest.json'), JSON.stringify(manifest, null, 2));
|
|
62
|
+
await fs.writeFile(path.join(outDir, 'glash-headers.json'), JSON.stringify(securityHeaders(cfg.security), null, 2));
|
|
63
|
+
|
|
64
|
+
const t = manifest.totals;
|
|
65
|
+
log('\nSummary');
|
|
66
|
+
log(` assets ${t.assets}`);
|
|
67
|
+
log(` original ${kb(t.originalBytes)}`);
|
|
68
|
+
log(` optimized ${kb(t.optimizedBytes)}`);
|
|
69
|
+
log(` saved ${t.savedPercent}% (${kb(t.originalBytes - t.optimizedBytes)})`);
|
|
70
|
+
log(` offline ${cfg.offline ? `${offline.precached} files precached (works offline after first visit)` : 'disabled'}`);
|
|
71
|
+
log(` favicon static glashdb logo${animated.enabled ? ' + animated (glash-favicon.mjs)' : ''}`);
|
|
72
|
+
log(` version ${version}`);
|
|
73
|
+
log(` security strict CSP + ${Object.keys(securityHeaders(cfg.security)).length} headers\n`);
|
|
74
|
+
|
|
75
|
+
return { manifest, version, offline };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The preview favicon defaults to the official glashdb logo bundled with the
|
|
79
|
+
// framework. Try the configured path first, then fall back to that bundled logo
|
|
80
|
+
// so the glashdb favicon shows out-of-the-box even before any override.
|
|
81
|
+
async function copyFavicon(cfg, root, outDir, log) {
|
|
82
|
+
const bundled = path.resolve(fileURLToPath(new URL('../templates/favicon.svg', import.meta.url)));
|
|
83
|
+
const candidates = [path.resolve(root, cfg.favicon), bundled];
|
|
84
|
+
for (const src of candidates) {
|
|
85
|
+
try {
|
|
86
|
+
await fs.copyFile(src, path.join(outDir, 'favicon.svg'));
|
|
87
|
+
return;
|
|
88
|
+
} catch { /* try next */ }
|
|
89
|
+
}
|
|
90
|
+
log(` ! favicon not found (looked in ${cfg.favicon}, bundled glashdb logo) — preview favicon will be missing`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function kb(bytes) {
|
|
94
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
95
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
96
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
97
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// glashjs configuration
|
|
2
|
+
// Define a `glash.config.mjs` at your project root:
|
|
3
|
+
//
|
|
4
|
+
// import { defineConfig } from 'glashjs/config';
|
|
5
|
+
// export default defineConfig({ name: 'My Site', publicDir: 'public' });
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CONFIG = {
|
|
10
|
+
name: 'glash app',
|
|
11
|
+
shortName: undefined,
|
|
12
|
+
// The preview favicon defaults to the official glashdb logo shipped with the
|
|
13
|
+
// framework; override with your own path relative to the project root.
|
|
14
|
+
favicon: 'node_modules/glashjs/templates/favicon.svg',
|
|
15
|
+
// Animated favicon: true = bundled animated glash mark, a path to your own
|
|
16
|
+
// animated SVG/GIF, or { frames: ['/f0.svg', ...], fps: 8 } to cycle frames.
|
|
17
|
+
// false disables it. Emits `glash-favicon.mjs` — call startGlashFavicon() once.
|
|
18
|
+
animatedFavicon: true,
|
|
19
|
+
publicDir: 'public',
|
|
20
|
+
outDir: '.glash/out',
|
|
21
|
+
themeColor: '#0b0d12',
|
|
22
|
+
offline: true,
|
|
23
|
+
// Requests under these prefixes are treated as live/updated data: network-first
|
|
24
|
+
// in the Service Worker, so offline mode degrades exactly there (no stale live data).
|
|
25
|
+
dataPrefixes: ['/api/', '/rest/', '/auth/', '/realtime', '/live', '/stream'],
|
|
26
|
+
security: {},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function defineConfig(config) {
|
|
30
|
+
return { ...DEFAULT_CONFIG, ...config };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function loadConfig(root = process.cwd()) {
|
|
34
|
+
for (const name of ['glash.config.mjs', 'glash.config.js']) {
|
|
35
|
+
const file = path.resolve(root, name);
|
|
36
|
+
try {
|
|
37
|
+
const mod = await import(pathToFileURL(file).href);
|
|
38
|
+
const cfg = mod.default ?? mod.config ?? {};
|
|
39
|
+
return { ...DEFAULT_CONFIG, ...cfg };
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err?.code !== 'ERR_MODULE_NOT_FOUND' && !String(err?.message).includes('Cannot find module')) throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { ...DEFAULT_CONFIG };
|
|
45
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// glashjs public API
|
|
2
|
+
export { defineConfig, loadConfig, DEFAULT_CONFIG } from './config.mjs';
|
|
3
|
+
export { build } from './build.mjs';
|
|
4
|
+
export { optimizeAssets } from './assets/optimize.mjs';
|
|
5
|
+
export { generateAnimatedFavicon } from './assets/animated-favicon.mjs';
|
|
6
|
+
export { generateServiceWorker } from './offline/generate-sw.mjs';
|
|
7
|
+
export { securityHeaders, buildCsp, sri, glashSecurity } from './security/headers.mjs';
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// glashjs offline layer
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Generates a Service Worker that makes a built glashjs site usable offline
|
|
4
|
+
// after the first visit — exactly the behavior requested: the whole live
|
|
5
|
+
// project is cached into small hashed files; once cached, the site works with
|
|
6
|
+
// no internet, EXCEPT freshly-updated data and live/streaming features, which
|
|
7
|
+
// are gated behind a network-first strategy so they degrade gracefully.
|
|
8
|
+
//
|
|
9
|
+
// Strategies:
|
|
10
|
+
// - App shell + hashed assets : cache-first (immutable, hash-busted on deploy)
|
|
11
|
+
// - HTML navigations : stale-while-revalidate (instant, self-heals)
|
|
12
|
+
// - /api, /rest, /auth, live : network-first, fall back to a cached "offline"
|
|
13
|
+
// marker so the app can show a degraded state
|
|
14
|
+
import { promises as fs } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
const DATA_PREFIXES = ['/api/', '/rest/', '/auth/', '/realtime', '/live', '/stream'];
|
|
18
|
+
|
|
19
|
+
function swSource({ cacheName, precache, dataPrefixes }) {
|
|
20
|
+
return `// AUTO-GENERATED by glashjs. Do not edit by hand.
|
|
21
|
+
const CACHE = ${JSON.stringify(cacheName)};
|
|
22
|
+
const PRECACHE = ${JSON.stringify(precache, null, 0)};
|
|
23
|
+
const DATA_PREFIXES = ${JSON.stringify(dataPrefixes)};
|
|
24
|
+
|
|
25
|
+
self.addEventListener('install', (event) => {
|
|
26
|
+
event.waitUntil((async () => {
|
|
27
|
+
const cache = await caches.open(CACHE);
|
|
28
|
+
// Precache in chunks so one failure doesn't abort the whole install.
|
|
29
|
+
await Promise.allSettled(PRECACHE.map((url) => cache.add(url)));
|
|
30
|
+
await self.skipWaiting();
|
|
31
|
+
})());
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
self.addEventListener('activate', (event) => {
|
|
35
|
+
event.waitUntil((async () => {
|
|
36
|
+
const keys = await caches.keys();
|
|
37
|
+
await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)));
|
|
38
|
+
await self.clients.claim();
|
|
39
|
+
})());
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function isData(url) {
|
|
43
|
+
return DATA_PREFIXES.some((p) => url.pathname.startsWith(p));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
self.addEventListener('fetch', (event) => {
|
|
47
|
+
const { request } = event;
|
|
48
|
+
if (request.method !== 'GET') return;
|
|
49
|
+
const url = new URL(request.url);
|
|
50
|
+
if (url.origin !== self.location.origin) return;
|
|
51
|
+
|
|
52
|
+
// Live/updated data: network-first, fall back to cache, then a degraded marker.
|
|
53
|
+
if (isData(url)) {
|
|
54
|
+
event.respondWith((async () => {
|
|
55
|
+
try {
|
|
56
|
+
const fresh = await fetch(request);
|
|
57
|
+
return fresh;
|
|
58
|
+
} catch {
|
|
59
|
+
const cached = await caches.match(request);
|
|
60
|
+
if (cached) return cached;
|
|
61
|
+
return new Response(JSON.stringify({ offline: true, error: 'offline: live data unavailable' }), {
|
|
62
|
+
status: 503, headers: { 'content-type': 'application/json', 'x-glash-offline': '1' },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
})());
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// HTML navigations: stale-while-revalidate.
|
|
70
|
+
if (request.mode === 'navigate') {
|
|
71
|
+
event.respondWith((async () => {
|
|
72
|
+
const cache = await caches.open(CACHE);
|
|
73
|
+
const cached = await cache.match(request);
|
|
74
|
+
const network = fetch(request).then((res) => { cache.put(request, res.clone()); return res; }).catch(() => null);
|
|
75
|
+
return cached || (await network) || cache.match('/offline.html') || new Response('offline', { status: 503 });
|
|
76
|
+
})());
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Hashed static assets: cache-first.
|
|
81
|
+
event.respondWith((async () => {
|
|
82
|
+
const cached = await caches.match(request);
|
|
83
|
+
if (cached) return cached;
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(request);
|
|
86
|
+
const cache = await caches.open(CACHE);
|
|
87
|
+
cache.put(request, res.clone());
|
|
88
|
+
return res;
|
|
89
|
+
} catch {
|
|
90
|
+
return cached || new Response('offline', { status: 503 });
|
|
91
|
+
}
|
|
92
|
+
})());
|
|
93
|
+
});
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Write a Service Worker + a small registration snippet into `outDir`.
|
|
99
|
+
* `manifest` is the object returned by optimizeAssets(); we precache the best
|
|
100
|
+
* (smallest) variant of each asset plus any provided `appShell` URLs.
|
|
101
|
+
*/
|
|
102
|
+
export async function generateServiceWorker(outDir, manifest, {
|
|
103
|
+
cacheName,
|
|
104
|
+
appShell = ['/', '/index.html', '/offline.html', '/favicon.svg'],
|
|
105
|
+
dataPrefixes = DATA_PREFIXES,
|
|
106
|
+
} = {}) {
|
|
107
|
+
const version = manifest?.version || 'dev';
|
|
108
|
+
const cache = cacheName || `glash-${version}`;
|
|
109
|
+
|
|
110
|
+
const assetUrls = new Set(appShell);
|
|
111
|
+
for (const [rel, entry] of Object.entries(manifest?.assets ?? {})) {
|
|
112
|
+
// Prefer the smallest modern variant; fall back to the original path.
|
|
113
|
+
const v = entry.variants || {};
|
|
114
|
+
const best = v.avif?.path || v.webp?.path || v.br?.path || rel;
|
|
115
|
+
assetUrls.add('/' + best.replace(/^\//, ''));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sw = swSource({ cacheName: cache, precache: [...assetUrls], dataPrefixes });
|
|
119
|
+
const reg = `// glashjs SW registration — import this once from your entry.
|
|
120
|
+
export function registerGlashOffline() {
|
|
121
|
+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
|
122
|
+
window.addEventListener('load', () => navigator.serviceWorker.register('/glash-sw.js').catch(() => {}));
|
|
123
|
+
}
|
|
124
|
+
`;
|
|
125
|
+
|
|
126
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
127
|
+
await fs.writeFile(path.join(outDir, 'glash-sw.js'), sw);
|
|
128
|
+
await fs.writeFile(path.join(outDir, 'glash-offline.mjs'), reg);
|
|
129
|
+
return { serviceWorker: 'glash-sw.js', precached: assetUrls.size };
|
|
130
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// glashjs security defaults
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// "Unhackable" isn't real — but "hard to hack by default" is. glashjs ships a
|
|
4
|
+
// strict, opinionated baseline so a site is secure unless you deliberately
|
|
5
|
+
// loosen it: strict CSP (no inline scripts), tight framing/MIME/referrer,
|
|
6
|
+
// HSTS, isolation headers, and helpers for Subresource Integrity on assets.
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Strict Content-Security-Policy. No 'unsafe-inline' for scripts — glashjs
|
|
11
|
+
* builds hash/nonce-based, so XSS via injected <script> is blocked by default.
|
|
12
|
+
* Pass `connectSrc` to allow your API/realtime origins.
|
|
13
|
+
*/
|
|
14
|
+
export function buildCsp({
|
|
15
|
+
connectSrc = ["'self'"],
|
|
16
|
+
imgSrc = ["'self'", 'data:', 'blob:'],
|
|
17
|
+
mediaSrc = ["'self'", 'blob:'],
|
|
18
|
+
styleSrc = ["'self'"],
|
|
19
|
+
scriptSrc = ["'self'"],
|
|
20
|
+
nonce,
|
|
21
|
+
} = {}) {
|
|
22
|
+
const script = nonce ? [...scriptSrc, `'nonce-${nonce}'`] : scriptSrc;
|
|
23
|
+
const directives = {
|
|
24
|
+
'default-src': ["'self'"],
|
|
25
|
+
'base-uri': ["'self'"],
|
|
26
|
+
'object-src': ["'none'"],
|
|
27
|
+
'frame-ancestors': ["'none'"],
|
|
28
|
+
'form-action': ["'self'"],
|
|
29
|
+
'script-src': script,
|
|
30
|
+
'style-src': styleSrc,
|
|
31
|
+
'img-src': imgSrc,
|
|
32
|
+
'media-src': mediaSrc,
|
|
33
|
+
'connect-src': connectSrc,
|
|
34
|
+
'worker-src': ["'self'"],
|
|
35
|
+
'manifest-src': ["'self'"],
|
|
36
|
+
'upgrade-insecure-requests': [],
|
|
37
|
+
};
|
|
38
|
+
return Object.entries(directives)
|
|
39
|
+
.map(([k, v]) => (v.length ? `${k} ${v.join(' ')}` : k))
|
|
40
|
+
.join('; ');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The full secure-by-default response header set for a glashjs site. */
|
|
44
|
+
export function securityHeaders(opts = {}) {
|
|
45
|
+
return {
|
|
46
|
+
'Content-Security-Policy': buildCsp(opts.csp ?? {}),
|
|
47
|
+
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
|
|
48
|
+
'X-Content-Type-Options': 'nosniff',
|
|
49
|
+
'X-Frame-Options': 'DENY',
|
|
50
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
51
|
+
'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), browsing-topics=()',
|
|
52
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
53
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
54
|
+
'Cross-Origin-Embedder-Policy': opts.coep ?? 'credentialless',
|
|
55
|
+
'Origin-Agent-Cluster': '?1',
|
|
56
|
+
'X-DNS-Prefetch-Control': 'off',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Subresource Integrity hash for a built asset buffer (sri="sha384-..."). */
|
|
61
|
+
export function sri(buf, algo = 'sha384') {
|
|
62
|
+
return `${algo}-${createHash(algo).update(buf).digest('base64')}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Express/Connect-style middleware applying the security headers. */
|
|
66
|
+
export function glashSecurity(opts = {}) {
|
|
67
|
+
const headers = securityHeaders(opts);
|
|
68
|
+
return (_req, res, next) => {
|
|
69
|
+
for (const [k, v] of Object.entries(headers)) res.setHeader(k, v);
|
|
70
|
+
if (typeof next === 'function') next();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<!-- glashjs default animated favicon: a glash-green orbiting ring + pulsing core. -->
|
|
3
|
+
<circle cx="32" cy="32" r="22" fill="none" stroke="#22c55e" stroke-width="6"
|
|
4
|
+
stroke-linecap="round" stroke-dasharray="70 200" opacity="0.95">
|
|
5
|
+
<animateTransform attributeName="transform" type="rotate"
|
|
6
|
+
from="0 32 32" to="360 32 32" dur="1.4s" repeatCount="indefinite"/>
|
|
7
|
+
</circle>
|
|
8
|
+
<circle cx="32" cy="32" r="9" fill="#22c55e">
|
|
9
|
+
<animate attributeName="opacity" values="1;0.35;1" dur="1.4s" repeatCount="indefinite"/>
|
|
10
|
+
<animate attributeName="r" values="9;7;9" dur="1.4s" repeatCount="indefinite"/>
|
|
11
|
+
</circle>
|
|
12
|
+
</svg>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<svg width="508" height="508" viewBox="0 0 508 508" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<g filter="url(#filter0_ii_12189_2)">
|
|
3
|
+
<rect x="43.9863" y="34.4604" width="420.999" height="420.999" rx="102.477" fill="url(#paint0_linear_12189_2)"/>
|
|
4
|
+
</g>
|
|
5
|
+
<rect x="45.1251" y="35.5992" width="418.722" height="418.722" rx="101.338" stroke="#D4D4D4" stroke-width="2.27759"/>
|
|
6
|
+
<g opacity="0.6" filter="url(#filter1_f_12189_2)" style="mix-blend-mode:overlay">
|
|
7
|
+
<circle cx="253.556" cy="253.556" r="119.602" fill="url(#paint1_linear_12189_2)"/>
|
|
8
|
+
<circle cx="253.556" cy="253.556" r="118.463" stroke="#D4D4D4" stroke-width="2.27759"/>
|
|
9
|
+
</g>
|
|
10
|
+
<mask id="mask0_12189_2" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="43" y="34" width="422" height="422">
|
|
11
|
+
<rect x="45.1241" y="35.5997" width="418.722" height="418.722" rx="101.338" fill="url(#paint2_linear_12189_2)" stroke="#D4D4D4" stroke-width="2.27759"/>
|
|
12
|
+
</mask>
|
|
13
|
+
<g mask="url(#mask0_12189_2)">
|
|
14
|
+
<g filter="url(#filter2_f_12189_2)">
|
|
15
|
+
<ellipse cx="259.269" cy="23.0167" rx="215.284" ry="122.507" fill="url(#paint3_linear_12189_2)"/>
|
|
16
|
+
<path d="M259.269 -98.3521C318.557 -98.3521 372.177 -84.6747 410.935 -62.6196C449.725 -40.5457 473.414 -10.2249 473.414 23.0171C473.414 56.2589 449.725 86.579 410.935 108.653C372.177 130.708 318.557 144.385 259.269 144.385C199.98 144.385 146.361 130.708 107.604 108.653C68.813 86.579 45.1242 56.2589 45.124 23.0171C45.124 -10.2249 68.8128 -40.5457 107.604 -62.6196C146.361 -84.6747 199.98 -98.352 259.269 -98.3521Z" stroke="#D4D4D4" stroke-width="2.27759"/>
|
|
17
|
+
</g>
|
|
18
|
+
<g filter="url(#filter3_f_12189_2)" style="mix-blend-mode:plus-lighter">
|
|
19
|
+
<ellipse cx="254.486" cy="29.677" rx="47.8408" ry="81.3294" fill="url(#paint4_linear_12189_2)"/>
|
|
20
|
+
<path d="M254.486 -50.5137C267.129 -50.5137 278.78 -41.7943 287.333 -27.2539C295.871 -12.7399 301.188 7.3854 301.188 29.6768C301.188 51.9682 295.871 72.0943 287.333 86.6084C278.78 101.149 267.129 109.867 254.486 109.867C241.844 109.867 230.193 101.149 221.64 86.6084C213.102 72.0943 207.784 51.9682 207.784 29.6768C207.784 7.3854 213.102 -12.7399 221.64 -27.2539C230.193 -41.7943 241.844 -50.5137 254.486 -50.5137Z" stroke="#D4D4D4" stroke-width="2.27759"/>
|
|
21
|
+
</g>
|
|
22
|
+
</g>
|
|
23
|
+
<path d="M336.671 312.331L239.265 374.956L164.548 342.247L261.906 279.621L336.671 312.331Z" fill="#FEFFFF"/>
|
|
24
|
+
<path d="M264.17 166.655C286.185 166.655 304.057 184.575 304.057 206.591C304.057 213.479 302.371 219.983 299.047 225.956C292.014 238.626 278.67 246.478 264.17 246.478C258.148 246.478 252.367 245.178 246.972 242.624C233.146 236.025 224.234 221.91 224.234 206.591C224.234 184.575 242.155 166.655 264.17 166.655ZM264.17 128.116C220.814 128.116 185.695 163.235 185.695 206.591C185.695 237.807 203.953 264.784 230.352 277.406C240.565 282.319 252.03 285.017 264.122 285.017C293.604 285.017 319.328 268.734 332.672 244.648C338.983 233.375 342.548 220.416 342.548 206.591C342.548 163.283 307.43 128.116 264.122 128.116H264.17Z" fill="#FEFFFF"/>
|
|
25
|
+
<defs>
|
|
26
|
+
<filter id="filter0_ii_12189_2" x="43.9863" y="34.4604" width="420.999" height="449.704" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
27
|
+
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
|
28
|
+
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
|
29
|
+
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
|
30
|
+
<feOffset/>
|
|
31
|
+
<feGaussianBlur stdDeviation="19.1363"/>
|
|
32
|
+
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
|
33
|
+
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.24 0"/>
|
|
34
|
+
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_12189_2"/>
|
|
35
|
+
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
|
36
|
+
<feOffset dy="28.7045"/>
|
|
37
|
+
<feGaussianBlur stdDeviation="19.1363"/>
|
|
38
|
+
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
|
39
|
+
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.3 0"/>
|
|
40
|
+
<feBlend mode="normal" in2="effect1_innerShadow_12189_2" result="effect2_innerShadow_12189_2"/>
|
|
41
|
+
</filter>
|
|
42
|
+
<filter id="filter1_f_12189_2" x="-0.000152588" y="-0.000152588" width="507.113" height="507.113" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
43
|
+
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
|
44
|
+
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
|
45
|
+
<feGaussianBlur stdDeviation="66.9771" result="effect1_foregroundBlur_12189_2"/>
|
|
46
|
+
</filter>
|
|
47
|
+
<filter id="filter2_f_12189_2" x="-109.105" y="-252.581" width="736.749" height="551.196" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
48
|
+
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
|
49
|
+
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
|
50
|
+
<feGaussianBlur stdDeviation="76.5453" result="effect1_foregroundBlur_12189_2"/>
|
|
51
|
+
</filter>
|
|
52
|
+
<filter id="filter3_f_12189_2" x="149.237" y="-109.061" width="210.5" height="277.477" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
53
|
+
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
|
54
|
+
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
|
55
|
+
<feGaussianBlur stdDeviation="28.7045" result="effect1_foregroundBlur_12189_2"/>
|
|
56
|
+
</filter>
|
|
57
|
+
<linearGradient id="paint0_linear_12189_2" x1="254.486" y1="34.4604" x2="254.486" y2="455.46" gradientUnits="userSpaceOnUse">
|
|
58
|
+
<stop stop-opacity="0.3"/>
|
|
59
|
+
<stop offset="1" stop-opacity="0.6"/>
|
|
60
|
+
</linearGradient>
|
|
61
|
+
<linearGradient id="paint1_linear_12189_2" x1="253.556" y1="133.954" x2="253.556" y2="373.158" gradientUnits="userSpaceOnUse">
|
|
62
|
+
<stop stop-opacity="0"/>
|
|
63
|
+
<stop offset="1" stop-opacity="0.6"/>
|
|
64
|
+
</linearGradient>
|
|
65
|
+
<linearGradient id="paint2_linear_12189_2" x1="254.485" y1="34.4609" x2="254.485" y2="455.46" gradientUnits="userSpaceOnUse">
|
|
66
|
+
<stop stop-color="#4B4B4B"/>
|
|
67
|
+
<stop offset="1" stop-color="#AFAFAF"/>
|
|
68
|
+
</linearGradient>
|
|
69
|
+
<linearGradient id="paint3_linear_12189_2" x1="272.501" y1="142.813" x2="267.703" y2="-48.6569" gradientUnits="userSpaceOnUse">
|
|
70
|
+
<stop stop-color="#D8D8D8" stop-opacity="0.05"/>
|
|
71
|
+
<stop offset="0.61" stop-color="#B2B2B2" stop-opacity="0.05"/>
|
|
72
|
+
<stop offset="0.98" stop-color="#404040"/>
|
|
73
|
+
</linearGradient>
|
|
74
|
+
<linearGradient id="paint4_linear_12189_2" x1="254.486" y1="-51.6523" x2="254.486" y2="111.006" gradientUnits="userSpaceOnUse">
|
|
75
|
+
<stop stop-opacity="0"/>
|
|
76
|
+
<stop offset="1" stop-opacity="0.6"/>
|
|
77
|
+
</linearGradient>
|
|
78
|
+
</defs>
|
|
79
|
+
</svg>
|