glashjs 0.14.1 → 0.15.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 +2 -1
- package/bin/glash.mjs +8 -0
- package/package.json +1 -1
- package/src/config.mjs +8 -0
- package/src/create.mjs +11 -25
- package/src/security/headers.mjs +24 -0
- package/src/server/html.mjs +12 -2
- package/src/server/server.mjs +22 -4
package/README.md
CHANGED
|
@@ -179,7 +179,8 @@ export default defineConfig({
|
|
|
179
179
|
outDir: '.glash/out',
|
|
180
180
|
stylesheets: ['/app.css'],
|
|
181
181
|
offline: true,
|
|
182
|
-
|
|
182
|
+
favicon: 'node_modules/glashjs/templates/glash_favicon.svg',
|
|
183
|
+
animatedFavicon: false,
|
|
183
184
|
dataPrefixes: ['/api/', '/auth/', '/rest/', '/live', '/stream'],
|
|
184
185
|
});
|
|
185
186
|
```
|
package/bin/glash.mjs
CHANGED
|
@@ -56,6 +56,11 @@ function glashMark() {
|
|
|
56
56
|
];
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
function faviconLabel(cfg) {
|
|
60
|
+
const source = String(cfg.favicon || 'glash_favicon.svg');
|
|
61
|
+
return source.split(/[\\/]/).pop() || 'glash_favicon.svg';
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
// LAN IPv4 addresses, so the dev server prints a Network URL you can open from
|
|
60
65
|
// your phone or another device on the same network.
|
|
61
66
|
function lanAddresses() {
|
|
@@ -79,6 +84,7 @@ async function serve(dev) {
|
|
|
79
84
|
console.log('');
|
|
80
85
|
for (const line of glashMark()) console.log(line);
|
|
81
86
|
console.log(` glashjs ${dev ? 'dev' : 'serve'} — "${cfg.name}"`);
|
|
87
|
+
console.log(` favicon ${faviconLabel(cfg)} -> /favicon.svg`);
|
|
82
88
|
if (port !== preferredPort) console.log(` Port ${preferredPort} is in use; using ${port} instead.`);
|
|
83
89
|
console.log(` ${pages} page route(s), ${apis} api route(s)`);
|
|
84
90
|
routes.forEach((r) => console.log(` ${r.isApi ? 'api ' : 'page'} ${r.pattern}`));
|
|
@@ -118,6 +124,8 @@ async function main() {
|
|
|
118
124
|
auth: optionBool('--no-auth', undefined),
|
|
119
125
|
sqlRunner: optionBool('--no-sql-runner', undefined),
|
|
120
126
|
aiPrompts: optionBool('--no-ai-prompts', undefined),
|
|
127
|
+
translate: optionBool('--no-translate', undefined),
|
|
128
|
+
animation: flag('--animation') ? true : undefined,
|
|
121
129
|
});
|
|
122
130
|
break;
|
|
123
131
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glashjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "glashjs — The Postgres-native full-stack framework for builders who want to ship without DevOps. Framework, hosting, database, auth, and deploy in one GlashDB-native runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/config.mjs
CHANGED
|
@@ -25,6 +25,14 @@ export const DEFAULT_CONFIG = {
|
|
|
25
25
|
outDir: '.glash/out',
|
|
26
26
|
themeColor: '#0b0d12',
|
|
27
27
|
offline: true,
|
|
28
|
+
// Built-in IP auto-translation. true = detect the visitor's country (from the
|
|
29
|
+
// edge geo header, falling back to a public IP service) and, if their native
|
|
30
|
+
// language differs from the page, offer a one-tap in-place translation. The
|
|
31
|
+
// framework serves the runtime (/_glash/i18n.js) and a geo endpoint
|
|
32
|
+
// (/api/_glash/geo) automatically — no route or layout wiring per app.
|
|
33
|
+
// true | false | { auto?: boolean, geoEndpoint?: string }
|
|
34
|
+
// auto: translate immediately instead of prompting.
|
|
35
|
+
i18n: true,
|
|
28
36
|
// Requests under these prefixes are treated as live/updated data: network-first
|
|
29
37
|
// in the Service Worker, so offline mode degrades exactly there (no stale live data).
|
|
30
38
|
dataPrefixes: ['/api/', '/rest/', '/auth/', '/realtime', '/live', '/stream'],
|
package/src/create.mjs
CHANGED
|
@@ -111,7 +111,7 @@ function projectFiles(answers) {
|
|
|
111
111
|
'.env.local': envLocal(answers),
|
|
112
112
|
'glash.config.mjs': configFile(answers, hasCss),
|
|
113
113
|
'README.md': projectReadme(answers),
|
|
114
|
-
'routes/_layout.jsx': layoutRoute(
|
|
114
|
+
'routes/_layout.jsx': layoutRoute(),
|
|
115
115
|
'routes/index.jsx': indexRoute(answers),
|
|
116
116
|
'routes/api/health.mjs': healthRoute(),
|
|
117
117
|
'db/schema.sql': schemaSql(answers),
|
|
@@ -119,7 +119,6 @@ function projectFiles(answers) {
|
|
|
119
119
|
...(answers.css === 'tailwind' ? { 'styles/input.css': tailwindInput() } : {}),
|
|
120
120
|
...(answers.auth ? authRoutes() : {}),
|
|
121
121
|
...(answers.sqlRunner ? sqlRunnerRoutes() : {}),
|
|
122
|
-
...(answers.translate ? { 'routes/api/_glash/geo.mjs': geoRoute() } : {}),
|
|
123
122
|
...(answers.animation ? { 'routes/demo/motion.jsx': motionDemoRoute() } : {}),
|
|
124
123
|
...(answers.aiPrompts ? { '.glash/prompts/deploy.md': aiDeployPrompt(answers) } : {}),
|
|
125
124
|
'public/favicon.svg': faviconSvg(),
|
|
@@ -136,7 +135,13 @@ export default defineConfig({
|
|
|
136
135
|
publicDir: 'public',
|
|
137
136
|
outDir: '.glash/out',
|
|
138
137
|
offline: true,
|
|
139
|
-
|
|
138
|
+
// Built-in IP auto-translation: detect the visitor's country and offer a
|
|
139
|
+
// one-tap in-place translation into their native language. The framework
|
|
140
|
+
// serves the runtime + geo endpoint and relaxes the CSP for the translate
|
|
141
|
+
// widget automatically — no route or layout wiring needed.
|
|
142
|
+
i18n: ${answers.translate ? 'true' : 'false'},
|
|
143
|
+
favicon: 'node_modules/glashjs/templates/glash_favicon.svg',
|
|
144
|
+
animatedFavicon: false,
|
|
140
145
|
stylesheets: ${hasCss ? "['/app.css']" : '[]'},
|
|
141
146
|
dataPrefixes: ['/api/', '/auth/', '/rest/', '/live', '/stream'],
|
|
142
147
|
security: {
|
|
@@ -148,19 +153,10 @@ export default defineConfig({
|
|
|
148
153
|
`;
|
|
149
154
|
}
|
|
150
155
|
|
|
151
|
-
function layoutRoute(
|
|
152
|
-
|
|
153
|
-
const imports = [`import { Link } from 'glashjs/link';`];
|
|
154
|
-
if (t) {
|
|
155
|
-
imports.push(`import { useEffect } from 'preact/hooks';`);
|
|
156
|
-
imports.push(`import { glashAutoTranslate } from 'glashjs/i18n';`);
|
|
157
|
-
}
|
|
158
|
-
return `${imports.join('\n')}
|
|
156
|
+
function layoutRoute() {
|
|
157
|
+
return `import { Link } from 'glashjs/link';
|
|
159
158
|
|
|
160
|
-
export default function RootLayout({ children }) {
|
|
161
|
-
// Built-in IP auto-translation: detect the visitor's country and offer to
|
|
162
|
-
// translate the page into their native language. Reads /api/_glash/geo.
|
|
163
|
-
useEffect(() => { glashAutoTranslate(); }, []);` : ''}
|
|
159
|
+
export default function RootLayout({ children }) {
|
|
164
160
|
return (
|
|
165
161
|
<div className="shell">
|
|
166
162
|
<header className="nav">
|
|
@@ -177,16 +173,6 @@ export default function RootLayout({ children }) {${t ? `
|
|
|
177
173
|
`;
|
|
178
174
|
}
|
|
179
175
|
|
|
180
|
-
function geoRoute() {
|
|
181
|
-
return `// IP -> country -> native language, read from the edge geo header
|
|
182
|
-
// (CF-IPCountry / X-Glash-Country on glashDB hosting). The client i18n runtime
|
|
183
|
-
// calls this to decide whether to offer auto-translation.
|
|
184
|
-
import { geoRouteHandler } from 'glashjs/i18n';
|
|
185
|
-
|
|
186
|
-
export const GET = geoRouteHandler;
|
|
187
|
-
`;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
176
|
function motionDemoRoute() {
|
|
191
177
|
return `import { useEffect, useRef } from 'preact/hooks';
|
|
192
178
|
import { animate } from 'motion';
|
package/src/security/headers.mjs
CHANGED
|
@@ -11,14 +11,37 @@ import { createHash } from 'node:crypto';
|
|
|
11
11
|
* builds hash/nonce-based, so XSS via injected <script> is blocked by default.
|
|
12
12
|
* Pass `connectSrc` to allow your API/realtime origins.
|
|
13
13
|
*/
|
|
14
|
+
// Google Translate widget origins used by the built-in i18n auto-translation.
|
|
15
|
+
// Scripts stay nonce-based (no 'unsafe-inline'): GT's element.js loads as an
|
|
16
|
+
// EXTERNAL script from these origins and fetches translations over XHR, so the
|
|
17
|
+
// framework's strict, nonce-based script CSP is preserved. Only style-src gains
|
|
18
|
+
// 'unsafe-inline' (low-risk) for the inline styles GT applies to translated text.
|
|
19
|
+
const I18N_CSP = {
|
|
20
|
+
script: ['https://translate.google.com', 'https://translate.googleapis.com', 'https://www.gstatic.com'],
|
|
21
|
+
style: ["'unsafe-inline'", 'https://www.gstatic.com'],
|
|
22
|
+
img: ['https://translate.google.com', 'https://translate.googleapis.com', 'https://www.gstatic.com'],
|
|
23
|
+
connect: ['https://translate.googleapis.com', 'https://ipapi.co'],
|
|
24
|
+
frame: ['https://translate.google.com'],
|
|
25
|
+
};
|
|
26
|
+
const merge = (base, extra) => [...new Set([...base, ...extra])];
|
|
27
|
+
|
|
14
28
|
export function buildCsp({
|
|
15
29
|
connectSrc = ["'self'"],
|
|
16
30
|
imgSrc = ["'self'", 'data:', 'blob:'],
|
|
17
31
|
mediaSrc = ["'self'", 'blob:'],
|
|
18
32
|
styleSrc = ["'self'"],
|
|
19
33
|
scriptSrc = ["'self'"],
|
|
34
|
+
frameSrc = ["'self'"],
|
|
20
35
|
nonce,
|
|
36
|
+
i18n = false,
|
|
21
37
|
} = {}) {
|
|
38
|
+
if (i18n) {
|
|
39
|
+
scriptSrc = merge(scriptSrc, I18N_CSP.script);
|
|
40
|
+
styleSrc = merge(styleSrc, I18N_CSP.style);
|
|
41
|
+
imgSrc = merge(imgSrc, I18N_CSP.img);
|
|
42
|
+
connectSrc = merge(connectSrc, I18N_CSP.connect);
|
|
43
|
+
frameSrc = merge(frameSrc, I18N_CSP.frame);
|
|
44
|
+
}
|
|
22
45
|
const script = nonce ? [...scriptSrc, `'nonce-${nonce}'`] : scriptSrc;
|
|
23
46
|
const directives = {
|
|
24
47
|
'default-src': ["'self'"],
|
|
@@ -31,6 +54,7 @@ export function buildCsp({
|
|
|
31
54
|
'img-src': imgSrc,
|
|
32
55
|
'media-src': mediaSrc,
|
|
33
56
|
'connect-src': connectSrc,
|
|
57
|
+
'frame-src': frameSrc,
|
|
34
58
|
'worker-src': ["'self'"],
|
|
35
59
|
'manifest-src': ["'self'"],
|
|
36
60
|
'upgrade-insecure-requests': [],
|
package/src/server/html.mjs
CHANGED
|
@@ -49,7 +49,7 @@ ${headHtml}
|
|
|
49
49
|
`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function shellTail({ offline = true, animatedFavicon = false, dev = false, nonce = '' }) {
|
|
52
|
+
function shellTail({ offline = true, animatedFavicon = false, dev = false, nonce = '', i18n = false }) {
|
|
53
53
|
const n = nonce ? ` nonce="${escapeHtml(nonce)}"` : '';
|
|
54
54
|
const fav = animatedFavicon
|
|
55
55
|
? `<script type="module"${n}>try{const m=await import("/glash-favicon.mjs");m.startGlashFavicon&&m.startGlashFavicon();}catch{}</script>`
|
|
@@ -57,6 +57,16 @@ function shellTail({ offline = true, animatedFavicon = false, dev = false, nonce
|
|
|
57
57
|
const off = offline
|
|
58
58
|
? `<script type="module"${n}>try{const m=await import("/glash-offline.mjs");m.registerGlashOffline&&m.registerGlashOffline();}catch{}</script>`
|
|
59
59
|
: '';
|
|
60
|
+
// Built-in IP auto-translation: the framework serves /_glash/i18n.js (the
|
|
61
|
+
// dependency-free runtime). It detects the visitor's country, maps it to their
|
|
62
|
+
// native language, and offers a one-tap in-place translation. CSP-safe: a
|
|
63
|
+
// nonce'd module import of a same-origin URL, no inline logic.
|
|
64
|
+
const i18nOpts = i18n && typeof i18n === 'object'
|
|
65
|
+
? { auto: !!i18n.auto, ...(i18n.geoEndpoint ? { geoEndpoint: i18n.geoEndpoint } : {}) }
|
|
66
|
+
: {};
|
|
67
|
+
const intl = i18n
|
|
68
|
+
? `<script type="module"${n}>try{const m=await import("/_glash/i18n.js");m.glashAutoTranslate&&m.glashAutoTranslate(${JSON.stringify(i18nOpts)});}catch{}</script>`
|
|
69
|
+
: '';
|
|
60
70
|
// Dev HMR: a nonce'd inline script (CSP-safe). On a change event it does an
|
|
61
71
|
// in-place soft refresh (no full reload, keeps scroll/focus/inputs), falling
|
|
62
72
|
// back to a full reload if the nav runtime hasn't loaded yet.
|
|
@@ -65,7 +75,7 @@ function shellTail({ offline = true, animatedFavicon = false, dev = false, nonce
|
|
|
65
75
|
: '';
|
|
66
76
|
// Client-side navigation runtime (external 'self' module, CSP-safe).
|
|
67
77
|
const nav = '<script type="module" src="/_glash/nav.js"></script>';
|
|
68
|
-
return `\n${fav}${off}${hmr}${nav}\n</body>\n</html>`;
|
|
78
|
+
return `\n${fav}${off}${intl}${hmr}${nav}\n</body>\n</html>`;
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
/**
|
package/src/server/server.mjs
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
// service worker, favicons) with Brotli negotiation.
|
|
7
7
|
// Every response carries the secure-by-default headers.
|
|
8
8
|
import http from 'node:http';
|
|
9
|
-
import { promises as fs, existsSync, statSync, watch, createReadStream } from 'node:fs';
|
|
9
|
+
import { promises as fs, existsSync, statSync, watch, createReadStream, readFileSync } from 'node:fs';
|
|
10
10
|
import { Transform } from 'node:stream';
|
|
11
11
|
import { randomBytes } from 'node:crypto';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
-
import { pathToFileURL } from 'node:url';
|
|
13
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
14
|
+
import { geoRouteHandler } from '../i18n.mjs';
|
|
14
15
|
import { discoverRoutes, matchRoute, findMiddleware } from './router.mjs';
|
|
15
16
|
import { renderDocument, documentParts, renderMeta, escapeHtml } from './html.mjs';
|
|
16
17
|
import { NAV_CLIENT } from './nav-client.mjs';
|
|
@@ -28,6 +29,11 @@ const MIME = {
|
|
|
28
29
|
};
|
|
29
30
|
const mime = (file) => MIME[path.extname(file).toLowerCase()] || 'application/octet-stream';
|
|
30
31
|
|
|
32
|
+
// The built-in i18n runtime served at /_glash/i18n.js. i18n.mjs is dependency-free
|
|
33
|
+
// and valid browser ESM (its server-only exports are harmless in the browser), so
|
|
34
|
+
// we serve it raw — single source of truth, works identically in dev and prod.
|
|
35
|
+
const I18N_CLIENT = readFileSync(fileURLToPath(new URL('../i18n.mjs', import.meta.url)), 'utf8');
|
|
36
|
+
|
|
31
37
|
/** Build the glashjs server. Returns { server, listen, cfg }. */
|
|
32
38
|
export async function createGlashServer({ root = process.cwd(), dev = false } = {}) {
|
|
33
39
|
loadEnvFiles(root);
|
|
@@ -70,6 +76,17 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
70
76
|
if (pathname === '/_glash/nav.js') {
|
|
71
77
|
return send(res, 200, 'text/javascript; charset=utf-8', NAV_CLIENT, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
|
|
72
78
|
}
|
|
79
|
+
// Built-in IP auto-translation runtime + geo endpoint (config: i18n).
|
|
80
|
+
if (cfg.i18n) {
|
|
81
|
+
if (pathname === '/_glash/i18n.js') {
|
|
82
|
+
return send(res, 200, 'text/javascript; charset=utf-8', I18N_CLIENT, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
|
|
83
|
+
}
|
|
84
|
+
// Geo endpoint: an app-defined routes/api/_glash/geo.mjs wins; otherwise
|
|
85
|
+
// the framework answers from the edge country header.
|
|
86
|
+
if (pathname === '/api/_glash/geo' && !matchRoute(routes, pathname)) {
|
|
87
|
+
return send(res, 200, 'application/json', JSON.stringify(geoRouteHandler({ headers: req.headers })), { ...secHeaders, 'cache-control': 'no-store' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
73
90
|
// Static first: in production this serves prebuilt /_glash/<id>.js bundles
|
|
74
91
|
// (written by `glash build`) — no runtime esbuild needed.
|
|
75
92
|
if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
|
|
@@ -176,6 +193,7 @@ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
|
|
|
176
193
|
body: page.body ?? '',
|
|
177
194
|
offline: cfg.offline,
|
|
178
195
|
animatedFavicon: !!cfg.animatedFavicon,
|
|
196
|
+
i18n: cfg.i18n,
|
|
179
197
|
nonce, dev,
|
|
180
198
|
});
|
|
181
199
|
send(res, page.status || 200, 'text/html; charset=utf-8', docHtml, { ...pageHeaders(cfg, secHeaders, nonce), ...(page.headers || {}) });
|
|
@@ -201,7 +219,7 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
201
219
|
// external 'self' module — both pass the strict CSP without 'unsafe-inline'.
|
|
202
220
|
const head = renderStylesheets(cfg) + renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
203
221
|
const { open, tail } = documentParts({
|
|
204
|
-
title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
|
|
222
|
+
title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, i18n: cfg.i18n, nonce, dev,
|
|
205
223
|
});
|
|
206
224
|
const bundleTag = `</div><script type="module" src="/_glash/${id}.js"></script>`;
|
|
207
225
|
res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), 'content-type': 'text/html; charset=utf-8' });
|
|
@@ -250,7 +268,7 @@ async function resolveMeta(metadata, ctx) {
|
|
|
250
268
|
// Per-request page headers: a fresh CSP carrying this request's script nonce, so
|
|
251
269
|
// the framework's own inline <script>s run while injected scripts stay blocked.
|
|
252
270
|
function pageHeaders(cfg, secHeaders, nonce) {
|
|
253
|
-
const csp = securityHeaders({ ...(cfg.security || {}), csp: { ...((cfg.security || {}).csp || {}), nonce } })['Content-Security-Policy'];
|
|
271
|
+
const csp = securityHeaders({ ...(cfg.security || {}), csp: { ...((cfg.security || {}).csp || {}), nonce, i18n: !!cfg.i18n } })['Content-Security-Policy'];
|
|
254
272
|
return { ...secHeaders, 'Content-Security-Policy': csp };
|
|
255
273
|
}
|
|
256
274
|
|