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.
@@ -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
@@ -0,0 +1,7 @@
1
+ // Runtime companion for generated glashjs route types.
2
+ // TypeScript users get strict route literals from `glashjs typegen`; JavaScript
3
+ // users can call route("/path") as a tiny self-documenting helper.
4
+
5
+ export function route(path) {
6
+ return path;
7
+ }
@@ -1,7 +1,7 @@
1
1
  // glashjs JSX + hydration engine
2
2
  // ---------------------------------------------------------------------------
3
- // This is the layer that makes glashjs a real Next.js alternative: author
4
- // pages as JSX components, render them on the server, and HYDRATE them in the
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
  }
@@ -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) => server.listen(port, host, () => resolve({ port, host })));
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
+ }