glashjs 0.12.1 → 0.13.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 +161 -164
- package/bin/glash.mjs +78 -3
- 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 +28 -6
- package/src/server-functions.mjs +47 -0
- package/src/sql-runner.mjs +30 -0
- package/src/typed-routes.mjs +65 -0
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);
|
|
@@ -118,7 +120,19 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
118
120
|
});
|
|
119
121
|
|
|
120
122
|
const listen = (port = cfg.port || 3000, host = '0.0.0.0') =>
|
|
121
|
-
new Promise((resolve
|
|
123
|
+
new Promise((resolve, reject) => {
|
|
124
|
+
const onError = (error) => {
|
|
125
|
+
server.off('listening', onListening);
|
|
126
|
+
reject(error);
|
|
127
|
+
};
|
|
128
|
+
const onListening = () => {
|
|
129
|
+
server.off('error', onError);
|
|
130
|
+
resolve({ port, host });
|
|
131
|
+
};
|
|
132
|
+
server.once('error', onError);
|
|
133
|
+
server.once('listening', onListening);
|
|
134
|
+
server.listen(port, host);
|
|
135
|
+
});
|
|
122
136
|
|
|
123
137
|
return { server, listen, cfg, routes, routesDir, outDir };
|
|
124
138
|
}
|
|
@@ -156,7 +170,7 @@ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
|
|
|
156
170
|
const meta = await resolveMeta(page.metadata || mod.metadata, ctx);
|
|
157
171
|
const docHtml = renderDocument({
|
|
158
172
|
title: meta.title || page.title || mod.title || cfg.name,
|
|
159
|
-
head: renderMeta(meta) + (page.head ? (page.head.__raw || page.head) : ''),
|
|
173
|
+
head: renderStylesheets(cfg) + renderMeta(meta) + (page.head ? (page.head.__raw || page.head) : ''),
|
|
160
174
|
body: page.body ?? '',
|
|
161
175
|
offline: cfg.offline,
|
|
162
176
|
animatedFavicon: !!cfg.animatedFavicon,
|
|
@@ -183,7 +197,7 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
183
197
|
const nonce = randomBytes(16).toString('base64');
|
|
184
198
|
// Props in a non-executed JSON block (CSP-safe); hydration bundle is an
|
|
185
199
|
// external 'self' module — both pass the strict CSP without 'unsafe-inline'.
|
|
186
|
-
const head = renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
200
|
+
const head = renderStylesheets(cfg) + renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
187
201
|
const { open, tail } = documentParts({
|
|
188
202
|
title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
|
|
189
203
|
});
|
|
@@ -195,7 +209,7 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
195
209
|
// then stream each boundary's real content as its data resolves. preact emits
|
|
196
210
|
// inline <script> swap tags, so we inject this request's nonce into the stream
|
|
197
211
|
// to keep the strict CSP intact.
|
|
198
|
-
const pipeable = await getPipeableRenderer();
|
|
212
|
+
const pipeable = mod.stream === true ? await getPipeableRenderer() : null;
|
|
199
213
|
if (pipeable) {
|
|
200
214
|
try {
|
|
201
215
|
const vnode = await composeVNode(mod, props);
|
|
@@ -294,7 +308,7 @@ async function renderStandalone(route, ctx, cfg, root, routesDir, dev) {
|
|
|
294
308
|
const props = (typeof mod.getServerData === 'function' ? await mod.getServerData(ctx) : {}) || {};
|
|
295
309
|
const meta = await resolveMeta(mod.metadata, ctx);
|
|
296
310
|
const rendered = await renderComponent(mod, props);
|
|
297
|
-
const head = renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
311
|
+
const head = renderStylesheets(cfg) + renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
298
312
|
const body = `<div id="glash-root">${rendered}</div><script type="module" src="/_glash/${routeId(route.file)}.js"></script>`;
|
|
299
313
|
return { html: renderDocument({ title: meta.title || mod.title || cfg.name, head, body, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev }), nonce };
|
|
300
314
|
}
|
|
@@ -302,7 +316,15 @@ async function renderStandalone(route, ctx, cfg, root, routesDir, dev) {
|
|
|
302
316
|
const out = typeof mod.default === 'function' ? await mod.default(ctx) : '';
|
|
303
317
|
const page = typeof out === 'string' || (out && out.__raw) ? { body: out } : (out || {});
|
|
304
318
|
const meta = await resolveMeta(page.metadata || mod.metadata, ctx);
|
|
305
|
-
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 };
|
|
319
|
+
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 };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function renderStylesheets(cfg) {
|
|
323
|
+
const list = Array.isArray(cfg.stylesheets) ? cfg.stylesheets : [];
|
|
324
|
+
return list
|
|
325
|
+
.filter((href) => typeof href === 'string' && href.trim())
|
|
326
|
+
.map((href) => `<link rel="stylesheet" href="${escapeHtml(href.trim())}">`)
|
|
327
|
+
.join('');
|
|
306
328
|
}
|
|
307
329
|
|
|
308
330
|
/** 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
|
+
}
|