glashjs 0.12.0 → 0.13.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 +161 -164
- package/bin/glash.mjs +58 -0
- package/package.json +13 -2
- package/src/auth.mjs +96 -0
- package/src/build.mjs +5 -0
- package/src/config.mjs +2 -0
- package/src/create.mjs +593 -0
- package/src/env.mjs +88 -0
- package/src/index.mjs +2 -0
- package/src/postgres.mjs +77 -0
- package/src/routes.mjs +7 -0
- package/src/server/jsx.mjs +3 -3
- package/src/server/server.mjs +24 -5
- package/src/server-functions.mjs +47 -0
- package/src/sql-runner.mjs +30 -0
- package/src/typed-routes.mjs +65 -0
package/src/index.mjs
CHANGED
|
@@ -4,11 +4,13 @@ export { build } from './build.mjs';
|
|
|
4
4
|
export { deploy } from './deploy.mjs';
|
|
5
5
|
export { update } from './update.mjs';
|
|
6
6
|
export { migrate } from './migrate.mjs';
|
|
7
|
+
export { createProject } from './create.mjs';
|
|
7
8
|
export { optimizeAssets } from './assets/optimize.mjs';
|
|
8
9
|
export { generateAnimatedFavicon } from './assets/animated-favicon.mjs';
|
|
9
10
|
export { generateServiceWorker } from './offline/generate-sw.mjs';
|
|
10
11
|
export { securityHeaders, buildCsp, sri, glashSecurity } from './security/headers.mjs';
|
|
11
12
|
export { createGlashServer, json, redirect } from './server/server.mjs';
|
|
13
|
+
export { serverFunction, callServerFunction } from './server-functions.mjs';
|
|
12
14
|
export { discoverRoutes, matchRoute, findMiddleware } from './server/router.mjs';
|
|
13
15
|
export { html, raw, escapeHtml, renderDocument, renderMeta } from './server/html.mjs';
|
|
14
16
|
// NOTE: <Image>/<Video>/<Link> are Preact components, so they live on subpaths
|
package/src/postgres.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// glashjs Postgres helpers
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// GlashDB projects get Postgres by default. This module gives server routes,
|
|
4
|
+
// API routes, and server functions a tiny query layer over `pg`.
|
|
5
|
+
|
|
6
|
+
import pg from 'pg';
|
|
7
|
+
|
|
8
|
+
const { Pool } = pg;
|
|
9
|
+
let defaultPool;
|
|
10
|
+
let defaultPoolKey = '';
|
|
11
|
+
|
|
12
|
+
export function createPostgresPool(options = {}) {
|
|
13
|
+
const connectionString = options.connectionString ?? process.env.DATABASE_URL;
|
|
14
|
+
if (!connectionString) throw new Error('DATABASE_URL is required for glashjs/postgres');
|
|
15
|
+
return new Pool({
|
|
16
|
+
connectionString,
|
|
17
|
+
ssl: options.ssl ?? sslFor(connectionString),
|
|
18
|
+
max: options.max ?? 10,
|
|
19
|
+
idleTimeoutMillis: options.idleTimeoutMillis ?? 30_000,
|
|
20
|
+
connectionTimeoutMillis: options.connectionTimeoutMillis ?? 10_000,
|
|
21
|
+
...options.pool,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function postgres(options = {}) {
|
|
26
|
+
const connectionString = options.connectionString ?? process.env.DATABASE_URL;
|
|
27
|
+
if (!connectionString) throw new Error('DATABASE_URL is required for glashjs/postgres');
|
|
28
|
+
const key = `${connectionString}|${JSON.stringify(options.pool || {})}`;
|
|
29
|
+
if (!defaultPool || defaultPoolKey !== key) {
|
|
30
|
+
defaultPool = createPostgresPool(options);
|
|
31
|
+
defaultPoolKey = key;
|
|
32
|
+
}
|
|
33
|
+
return defaultPool;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function query(text, params = [], options = {}) {
|
|
37
|
+
return postgres(options).query(text, params);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function sql(strings, ...values) {
|
|
41
|
+
if (typeof strings === 'string') return query(strings, values[0] ?? []);
|
|
42
|
+
const text = templateToSql(strings, values);
|
|
43
|
+
return query(text, values);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
sql.query = query;
|
|
47
|
+
sql.pool = postgres;
|
|
48
|
+
|
|
49
|
+
export async function transaction(fn, options = {}) {
|
|
50
|
+
const client = await postgres(options).connect();
|
|
51
|
+
try {
|
|
52
|
+
await client.query('BEGIN');
|
|
53
|
+
const result = await fn(client);
|
|
54
|
+
await client.query('COMMIT');
|
|
55
|
+
return result;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
58
|
+
throw error;
|
|
59
|
+
} finally {
|
|
60
|
+
client.release();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function templateToSql(strings, values) {
|
|
65
|
+
let text = '';
|
|
66
|
+
for (let i = 0; i < strings.length; i += 1) {
|
|
67
|
+
text += strings[i];
|
|
68
|
+
if (i < values.length) text += `$${i + 1}`;
|
|
69
|
+
}
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sslFor(connectionString) {
|
|
74
|
+
if (/sslmode=disable/i.test(connectionString)) return false;
|
|
75
|
+
if (/localhost|127\.0\.0\.1|::1/.test(connectionString)) return false;
|
|
76
|
+
return { rejectUnauthorized: true };
|
|
77
|
+
}
|
package/src/routes.mjs
ADDED
package/src/server/jsx.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// glashjs JSX + hydration engine
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
|
-
// This is the layer that
|
|
4
|
-
//
|
|
3
|
+
// This is the layer that lets glashjs author pages as JSX components, render
|
|
4
|
+
// them on the server, and HYDRATE them in the
|
|
5
5
|
// browser so hooks (useState/useEffect) work — true interactivity.
|
|
6
6
|
//
|
|
7
7
|
// Built on proven primitives, added as OPTIONAL peers so glashjs core stays
|
|
@@ -113,7 +113,7 @@ const compId = (pageFile, layouts) => routeId(pageFile + '|' + layouts.join('|')
|
|
|
113
113
|
function serverEntry(pageFile, layouts) {
|
|
114
114
|
const lines = [`import * as __p from ${JSON.stringify(pageFile)};`];
|
|
115
115
|
layouts.forEach((f, i) => lines.push(`import __L${i} from ${JSON.stringify(f)};`));
|
|
116
|
-
lines.push('export const Page = __p.default;', 'export const getServerData = __p.getServerData;', 'export const title = __p.title;', 'export const metadata = __p.metadata;');
|
|
116
|
+
lines.push('export const Page = __p.default;', 'export const getServerData = __p.getServerData;', 'export const title = __p.title;', 'export const metadata = __p.metadata;', 'export const stream = __p.stream;');
|
|
117
117
|
lines.push(`export const layouts = [${layouts.map((_, i) => `__L${i}`).join(',')}];`);
|
|
118
118
|
return lines.join('\n');
|
|
119
119
|
}
|
package/src/server/server.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { NAV_CLIENT } from './nav-client.mjs';
|
|
|
17
17
|
import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, composeVNode, getPipeableRenderer, routeId, findLayouts, clearJsxCaches, compileModule } from './jsx.mjs';
|
|
18
18
|
import { securityHeaders } from '../security/headers.mjs';
|
|
19
19
|
import { loadConfig } from '../config.mjs';
|
|
20
|
+
import { loadEnvFiles } from '../env.mjs';
|
|
20
21
|
|
|
21
22
|
const MIME = {
|
|
22
23
|
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
|
@@ -29,6 +30,7 @@ const mime = (file) => MIME[path.extname(file).toLowerCase()] || 'application/oc
|
|
|
29
30
|
|
|
30
31
|
/** Build the glashjs server. Returns { server, listen, cfg }. */
|
|
31
32
|
export async function createGlashServer({ root = process.cwd(), dev = false } = {}) {
|
|
33
|
+
loadEnvFiles(root);
|
|
32
34
|
const cfg = await loadConfig(root);
|
|
33
35
|
const routesDir = path.resolve(root, cfg.routesDir || 'routes');
|
|
34
36
|
const outDir = path.resolve(root, cfg.outDir);
|
|
@@ -131,6 +133,15 @@ async function handleApi(res, mod, req, ctx, secHeaders) {
|
|
|
131
133
|
}
|
|
132
134
|
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) ctx.body = await readJson(req);
|
|
133
135
|
const result = await handler(ctx);
|
|
136
|
+
// Next-style route handlers return a Web `Response` (e.g. Response.json(...)).
|
|
137
|
+
// Pass it through so migrated API routes work unchanged.
|
|
138
|
+
if (typeof Response !== 'undefined' && result instanceof Response) {
|
|
139
|
+
const headers = { ...secHeaders };
|
|
140
|
+
result.headers.forEach((v, k) => { headers[k] = v; });
|
|
141
|
+
const buf = Buffer.from(await result.arrayBuffer());
|
|
142
|
+
res.writeHead(result.status || 200, headers);
|
|
143
|
+
return res.end(buf);
|
|
144
|
+
}
|
|
134
145
|
if (result && result.__response) {
|
|
135
146
|
return send(res, result.status || 200, result.contentType || 'application/json',
|
|
136
147
|
typeof result.body === 'string' ? result.body : JSON.stringify(result.body), { ...secHeaders, ...(result.headers || {}) });
|
|
@@ -147,7 +158,7 @@ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
|
|
|
147
158
|
const meta = await resolveMeta(page.metadata || mod.metadata, ctx);
|
|
148
159
|
const docHtml = renderDocument({
|
|
149
160
|
title: meta.title || page.title || mod.title || cfg.name,
|
|
150
|
-
head: renderMeta(meta) + (page.head ? (page.head.__raw || page.head) : ''),
|
|
161
|
+
head: renderStylesheets(cfg) + renderMeta(meta) + (page.head ? (page.head.__raw || page.head) : ''),
|
|
151
162
|
body: page.body ?? '',
|
|
152
163
|
offline: cfg.offline,
|
|
153
164
|
animatedFavicon: !!cfg.animatedFavicon,
|
|
@@ -174,7 +185,7 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
174
185
|
const nonce = randomBytes(16).toString('base64');
|
|
175
186
|
// Props in a non-executed JSON block (CSP-safe); hydration bundle is an
|
|
176
187
|
// external 'self' module — both pass the strict CSP without 'unsafe-inline'.
|
|
177
|
-
const head = renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
188
|
+
const head = renderStylesheets(cfg) + renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
178
189
|
const { open, tail } = documentParts({
|
|
179
190
|
title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
|
|
180
191
|
});
|
|
@@ -186,7 +197,7 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
186
197
|
// then stream each boundary's real content as its data resolves. preact emits
|
|
187
198
|
// inline <script> swap tags, so we inject this request's nonce into the stream
|
|
188
199
|
// to keep the strict CSP intact.
|
|
189
|
-
const pipeable = await getPipeableRenderer();
|
|
200
|
+
const pipeable = mod.stream === true ? await getPipeableRenderer() : null;
|
|
190
201
|
if (pipeable) {
|
|
191
202
|
try {
|
|
192
203
|
const vnode = await composeVNode(mod, props);
|
|
@@ -285,7 +296,7 @@ async function renderStandalone(route, ctx, cfg, root, routesDir, dev) {
|
|
|
285
296
|
const props = (typeof mod.getServerData === 'function' ? await mod.getServerData(ctx) : {}) || {};
|
|
286
297
|
const meta = await resolveMeta(mod.metadata, ctx);
|
|
287
298
|
const rendered = await renderComponent(mod, props);
|
|
288
|
-
const head = renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
299
|
+
const head = renderStylesheets(cfg) + renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
289
300
|
const body = `<div id="glash-root">${rendered}</div><script type="module" src="/_glash/${routeId(route.file)}.js"></script>`;
|
|
290
301
|
return { html: renderDocument({ title: meta.title || mod.title || cfg.name, head, body, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev }), nonce };
|
|
291
302
|
}
|
|
@@ -293,7 +304,15 @@ async function renderStandalone(route, ctx, cfg, root, routesDir, dev) {
|
|
|
293
304
|
const out = typeof mod.default === 'function' ? await mod.default(ctx) : '';
|
|
294
305
|
const page = typeof out === 'string' || (out && out.__raw) ? { body: out } : (out || {});
|
|
295
306
|
const meta = await resolveMeta(page.metadata || mod.metadata, ctx);
|
|
296
|
-
return { html: renderDocument({ title: meta.title || page.title || mod.title || cfg.name, head: renderMeta(meta) + (page.head ? (page.head.__raw || page.head) : ''), body: page.body ?? '', offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev }), nonce };
|
|
307
|
+
return { html: renderDocument({ title: meta.title || page.title || mod.title || cfg.name, head: renderStylesheets(cfg) + renderMeta(meta) + (page.head ? (page.head.__raw || page.head) : ''), body: page.body ?? '', offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev }), nonce };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function renderStylesheets(cfg) {
|
|
311
|
+
const list = Array.isArray(cfg.stylesheets) ? cfg.stylesheets : [];
|
|
312
|
+
return list
|
|
313
|
+
.filter((href) => typeof href === 'string' && href.trim())
|
|
314
|
+
.map((href) => `<link rel="stylesheet" href="${escapeHtml(href.trim())}">`)
|
|
315
|
+
.join('');
|
|
297
316
|
}
|
|
298
317
|
|
|
299
318
|
/** 404: render a custom `404` route if present, else a clean default page. */
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// glashjs server functions
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// A small convention over API routes: wrap a trusted server-only function and
|
|
4
|
+
// expose it as a JSON endpoint under routes/api/functions/*.
|
|
5
|
+
|
|
6
|
+
export function serverFunction(handler, options = {}) {
|
|
7
|
+
const allowedMethods = new Set(options.methods ?? ['POST']);
|
|
8
|
+
return async function handleServerFunction(ctx) {
|
|
9
|
+
if (!allowedMethods.has(ctx.method)) {
|
|
10
|
+
return json({ ok: false, error: 'method not allowed' }, { status: 405 });
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const input = inputFor(ctx, options.input ?? 'auto');
|
|
14
|
+
const data = await handler(input, ctx);
|
|
15
|
+
return json({ ok: true, data });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const expose = options.exposeErrors ?? process.env.NODE_ENV !== 'production';
|
|
18
|
+
return json({
|
|
19
|
+
ok: false,
|
|
20
|
+
error: expose ? String(error?.message ?? error) : 'server function failed',
|
|
21
|
+
}, { status: error?.statusCode || error?.status || 500 });
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function json(body, { status = 200, headers } = {}) {
|
|
27
|
+
return { __response: true, status, contentType: 'application/json', body, headers };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function callServerFunction(path, input, options = {}) {
|
|
31
|
+
const res = await fetch(path, {
|
|
32
|
+
method: options.method ?? 'POST',
|
|
33
|
+
headers: { 'content-type': 'application/json', ...(options.headers || {}) },
|
|
34
|
+
body: JSON.stringify(input ?? {}),
|
|
35
|
+
});
|
|
36
|
+
const body = await res.json().catch(() => null);
|
|
37
|
+
if (!res.ok || body?.ok === false) throw new Error(body?.error || `server function failed with HTTP ${res.status}`);
|
|
38
|
+
return body?.data;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function inputFor(ctx, mode) {
|
|
42
|
+
if (mode === 'body') return ctx.body ?? {};
|
|
43
|
+
if (mode === 'query') return ctx.query ?? {};
|
|
44
|
+
if (mode === 'params') return ctx.params ?? {};
|
|
45
|
+
if (ctx.method === 'GET') return ctx.query ?? {};
|
|
46
|
+
return ctx.body ?? {};
|
|
47
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// glashjs SQL runner
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Runs checked-in SQL files against DATABASE_URL. Designed for local setup,
|
|
4
|
+
// migrations, seed data, and glashDB-hosted projects.
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { query } from './postgres.mjs';
|
|
9
|
+
import { loadEnvFiles } from './env.mjs';
|
|
10
|
+
|
|
11
|
+
export async function runSqlFile(file, options = {}) {
|
|
12
|
+
if (!file) throw new Error('SQL file path is required');
|
|
13
|
+
const root = options.root ?? process.cwd();
|
|
14
|
+
loadEnvFiles(root);
|
|
15
|
+
const full = path.resolve(root, file);
|
|
16
|
+
const text = await fs.readFile(full, 'utf8');
|
|
17
|
+
const started = Date.now();
|
|
18
|
+
const result = await query(text, [], options);
|
|
19
|
+
return {
|
|
20
|
+
file: full,
|
|
21
|
+
rowCount: result.rowCount ?? 0,
|
|
22
|
+
ms: Date.now() - started,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runSqlFiles(files, options = {}) {
|
|
27
|
+
const results = [];
|
|
28
|
+
for (const file of files) results.push(await runSqlFile(file, options));
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// glashjs typed route generation
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Scans routes/ and writes a small TypeScript declaration file with literal
|
|
4
|
+
// route unions. JS apps can ignore it; TS editors pick it up automatically.
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { discoverRoutes } from './server/router.mjs';
|
|
9
|
+
import { loadConfig } from './config.mjs';
|
|
10
|
+
|
|
11
|
+
export async function generateTypedRoutes({ root = process.cwd(), outFile = 'glash-routes.d.ts', log = console.log } = {}) {
|
|
12
|
+
const cfg = await loadConfig(root);
|
|
13
|
+
const routesDir = path.resolve(root, cfg.routesDir || 'routes');
|
|
14
|
+
const routes = await discoverRoutes(routesDir);
|
|
15
|
+
const pages = routes.filter((route) => !route.isApi).map((route) => toTsRoute(route.pattern));
|
|
16
|
+
const apis = routes.filter((route) => route.isApi).map((route) => toTsRoute(route.pattern));
|
|
17
|
+
const body = renderTypes({
|
|
18
|
+
pages: unique(pages),
|
|
19
|
+
apis: unique(apis),
|
|
20
|
+
generatedAt: new Date().toISOString(),
|
|
21
|
+
});
|
|
22
|
+
const target = path.resolve(root, outFile);
|
|
23
|
+
await fs.writeFile(target, body);
|
|
24
|
+
log(`glashjs typegen -> ${path.relative(root, target)} (${pages.length} page, ${apis.length} api)`);
|
|
25
|
+
return { outFile: target, pages: pages.length, apis: apis.length };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function renderTypes({ pages, apis, generatedAt }) {
|
|
29
|
+
const pageUnion = union(pages);
|
|
30
|
+
const apiUnion = union(apis);
|
|
31
|
+
return `// AUTO-GENERATED by glashjs typegen at ${generatedAt}
|
|
32
|
+
// Do not edit by hand. Re-run: glashjs typegen
|
|
33
|
+
|
|
34
|
+
declare module "glashjs/routes" {
|
|
35
|
+
export type GlashPageRoute = ${pageUnion};
|
|
36
|
+
export type GlashApiRoute = ${apiUnion};
|
|
37
|
+
export type GlashRoute = GlashPageRoute | GlashApiRoute;
|
|
38
|
+
export function route<T extends GlashRoute>(path: T): T;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export {};
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toTsRoute(pattern) {
|
|
46
|
+
if (pattern === '/') return '"/"';
|
|
47
|
+
const parts = pattern.split('/').filter(Boolean).map((part) => {
|
|
48
|
+
if (part.startsWith(':') || part.startsWith('*')) return '${string}';
|
|
49
|
+
return escapeTemplate(part);
|
|
50
|
+
});
|
|
51
|
+
if (parts.some((part) => part === '${string}')) return '`/' + parts.join('/') + '`';
|
|
52
|
+
return `"/${parts.join('/')}"`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function union(values) {
|
|
56
|
+
return values.length ? values.join(' | ') : 'never';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unique(values) {
|
|
60
|
+
return [...new Set(values)].sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function escapeTemplate(value) {
|
|
64
|
+
return value.replace(/[`$\\]/g, '\\$&');
|
|
65
|
+
}
|