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/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);
@@ -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
+ }