glashjs 0.12.1 → 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/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
@@ -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);
@@ -156,7 +158,7 @@ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
156
158
  const meta = await resolveMeta(page.metadata || mod.metadata, ctx);
157
159
  const docHtml = renderDocument({
158
160
  title: meta.title || page.title || mod.title || cfg.name,
159
- head: renderMeta(meta) + (page.head ? (page.head.__raw || page.head) : ''),
161
+ head: renderStylesheets(cfg) + renderMeta(meta) + (page.head ? (page.head.__raw || page.head) : ''),
160
162
  body: page.body ?? '',
161
163
  offline: cfg.offline,
162
164
  animatedFavicon: !!cfg.animatedFavicon,
@@ -183,7 +185,7 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
183
185
  const nonce = randomBytes(16).toString('base64');
184
186
  // Props in a non-executed JSON block (CSP-safe); hydration bundle is an
185
187
  // 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>`;
188
+ const head = renderStylesheets(cfg) + renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
187
189
  const { open, tail } = documentParts({
188
190
  title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
189
191
  });
@@ -195,7 +197,7 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
195
197
  // then stream each boundary's real content as its data resolves. preact emits
196
198
  // inline <script> swap tags, so we inject this request's nonce into the stream
197
199
  // to keep the strict CSP intact.
198
- const pipeable = await getPipeableRenderer();
200
+ const pipeable = mod.stream === true ? await getPipeableRenderer() : null;
199
201
  if (pipeable) {
200
202
  try {
201
203
  const vnode = await composeVNode(mod, props);
@@ -294,7 +296,7 @@ async function renderStandalone(route, ctx, cfg, root, routesDir, dev) {
294
296
  const props = (typeof mod.getServerData === 'function' ? await mod.getServerData(ctx) : {}) || {};
295
297
  const meta = await resolveMeta(mod.metadata, ctx);
296
298
  const rendered = await renderComponent(mod, props);
297
- 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>`;
298
300
  const body = `<div id="glash-root">${rendered}</div><script type="module" src="/_glash/${routeId(route.file)}.js"></script>`;
299
301
  return { html: renderDocument({ title: meta.title || mod.title || cfg.name, head, body, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev }), nonce };
300
302
  }
@@ -302,7 +304,15 @@ async function renderStandalone(route, ctx, cfg, root, routesDir, dev) {
302
304
  const out = typeof mod.default === 'function' ? await mod.default(ctx) : '';
303
305
  const page = typeof out === 'string' || (out && out.__raw) ? { body: out } : (out || {});
304
306
  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 };
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('');
306
316
  }
307
317
 
308
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
+ }