@ternkit/tern 1.0.0 → 1.2.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.
@@ -0,0 +1,180 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ /**
5
+ * Responsive images: `import hero from './hero.jpg?image'` resizes the image
6
+ * to several widths in AVIF, WebP and its own format, and gives a Picture
7
+ * for the <Image> component. `?w=400,800&image` picks the widths.
8
+ *
9
+ * Images are encoded with sharp, an optional dependency. Output names are
10
+ * derived from the source's contents and the encoding settings, so the client
11
+ * and server builds agree on URLs without sharing state, and encoded files
12
+ * are cached in node_modules/.cache/tern-images between builds.
13
+ */
14
+ /** Bumped when the encoding changes, so cached files are not reused. */
15
+ const VERSION = 1;
16
+ const DEFAULT_WIDTHS = [640, 1280, 1920];
17
+ const QUALITY = { avif: 50, webp: 75, jpeg: 80, png: 100 };
18
+ const MIME = { avif: 'image/avif', webp: 'image/webp', jpeg: 'image/jpeg', png: 'image/png' };
19
+ const DEV_PREFIX = '@tern-image/';
20
+ const OUT_DIR = '_app/immutable/img/';
21
+ export function imagePlugin() {
22
+ let config;
23
+ let cacheDir;
24
+ let sharpPromise;
25
+ /** Dev: generated file name → how to make it. */
26
+ const pending = new Map();
27
+ const sharp = () => (sharpPromise ??= import('sharp').then((m) => m.default, () => {
28
+ throw new Error('tern: ?image imports need sharp. Install it with: npm install -D sharp');
29
+ }));
30
+ async function encode(source, v) {
31
+ const cached = path.join(cacheDir, `${v.key}.${v.format}`);
32
+ try {
33
+ return await fs.promises.readFile(cached);
34
+ }
35
+ catch {
36
+ // not cached yet
37
+ }
38
+ const s = await sharp();
39
+ const img = s(source).rotate().resize({ width: v.width, withoutEnlargement: true });
40
+ const out = await img.toFormat(v.format, { quality: QUALITY[v.format] }).toBuffer();
41
+ await fs.promises.mkdir(cacheDir, { recursive: true });
42
+ await fs.promises.writeFile(cached, out);
43
+ return out;
44
+ }
45
+ return {
46
+ name: 'tern:image',
47
+ enforce: 'pre',
48
+ configResolved(c) {
49
+ config = c;
50
+ cacheDir = path.join(c.root, 'node_modules/.cache/tern-images');
51
+ },
52
+ async load(id) {
53
+ const q = id.indexOf('?');
54
+ if (q < 0)
55
+ return undefined;
56
+ const params = new URLSearchParams(id.slice(q + 1));
57
+ if (!params.has('image'))
58
+ return undefined;
59
+ const source = id.slice(0, q);
60
+ this.addWatchFile(source);
61
+ const bytes = await fs.promises.readFile(source);
62
+ const meta = await metadata(bytes, cacheDir, sharp);
63
+ const fallback = fallbackFormat(meta.format, meta.hasAlpha);
64
+ const widths = pickWidths(meta.width, params.get('w'));
65
+ const formats = fallback === 'webp' ? ['avif', 'webp'] : ['avif', 'webp', fallback];
66
+ const contentHash = hash(bytes);
67
+ const name = path.basename(source, path.extname(source)).replace(/[^\w-]/g, '_');
68
+ const variants = new Map();
69
+ for (const format of formats) {
70
+ variants.set(format, widths.map((width) => {
71
+ const key = hash(`${VERSION}:${contentHash}:${width}:${format}:${QUALITY[format]}`).slice(0, 10);
72
+ return { file: `${name}-${width}-${key}.${format}`, width, format, key };
73
+ }));
74
+ }
75
+ const isBuild = config.command === 'build';
76
+ const isClient = !(this.environment?.config.consumer === 'server' || config.build.ssr);
77
+ const url = (v) => (isBuild ? config.base + OUT_DIR + v.file : config.base + DEV_PREFIX + v.file);
78
+ const all = [...variants.values()].flat();
79
+ if (isBuild && isClient) {
80
+ await Promise.all(all.map(async (v) => {
81
+ this.emitFile({ type: 'asset', fileName: OUT_DIR + v.file, source: await encode(source, v) });
82
+ }));
83
+ }
84
+ else if (!isBuild) {
85
+ for (const v of all)
86
+ pending.set(v.file, { ...v, source });
87
+ }
88
+ const srcset = (f) => variants
89
+ .get(f)
90
+ .map((v) => `${url(v)} ${v.width}w`)
91
+ .join(', ');
92
+ const largest = variants.get(fallback).at(-1);
93
+ const height = Math.round((meta.height * largest.width) / meta.width);
94
+ const picture = {
95
+ src: url(largest),
96
+ width: largest.width,
97
+ height,
98
+ srcset: srcset(fallback),
99
+ sources: formats.filter((f) => f !== fallback).map((f) => ({ type: MIME[f], srcset: srcset(f) }))
100
+ };
101
+ return `export default ${JSON.stringify(picture)};`;
102
+ },
103
+ configureServer(server) {
104
+ server.middlewares.use((req, res, next) => {
105
+ const at = req.url?.indexOf('/' + DEV_PREFIX) ?? -1;
106
+ if (at < 0)
107
+ return next();
108
+ const file = decodeURIComponent(req.url.slice(at + DEV_PREFIX.length + 1).split('?')[0]);
109
+ const v = pending.get(file);
110
+ if (!v)
111
+ return next();
112
+ encode(v.source, v).then((buf) => {
113
+ res.setHeader('Content-Type', MIME[v.format]);
114
+ res.setHeader('Cache-Control', 'no-cache');
115
+ res.end(buf);
116
+ }, (err) => next(err));
117
+ });
118
+ }
119
+ };
120
+ }
121
+ function hash(data) {
122
+ return createHash('sha256').update(data).digest('hex');
123
+ }
124
+ /** The widths to generate: the requested ones, or common widths below the image's, plus its own. */
125
+ export function pickWidths(original, requested) {
126
+ let widths;
127
+ if (requested) {
128
+ widths = requested
129
+ .split(/[,;]/)
130
+ .map((w) => Number.parseInt(w, 10))
131
+ .filter((w) => w > 0)
132
+ .map((w) => Math.min(w, original));
133
+ if (widths.length === 0)
134
+ throw new Error(`tern: invalid image widths "${requested}"`);
135
+ }
136
+ else {
137
+ widths = [...DEFAULT_WIDTHS.filter((w) => w < original), original];
138
+ }
139
+ return [...new Set(widths)].sort((a, b) => a - b);
140
+ }
141
+ function fallbackFormat(format, hasAlpha) {
142
+ switch (format) {
143
+ case 'jpeg':
144
+ case 'jpg':
145
+ return 'jpeg';
146
+ case 'webp':
147
+ return 'webp';
148
+ case 'png':
149
+ case 'gif':
150
+ return 'png';
151
+ default:
152
+ return hasAlpha ? 'png' : 'jpeg';
153
+ }
154
+ }
155
+ async function metadata(bytes, cacheDir, sharp) {
156
+ const cached = path.join(cacheDir, `${hash(bytes).slice(0, 16)}.json`);
157
+ try {
158
+ return JSON.parse(await fs.promises.readFile(cached, 'utf8'));
159
+ }
160
+ catch {
161
+ // not cached yet
162
+ }
163
+ const s = await sharp();
164
+ const m = await s(bytes).metadata();
165
+ if (m.format === 'svg')
166
+ throw new Error('tern: ?image is for raster images; import SVGs without ?image');
167
+ if (!m.width || !m.height)
168
+ throw new Error('tern: could not read the image size');
169
+ // EXIF orientations 5–8 rotate by 90°, swapping width and height.
170
+ const rotated = (m.orientation ?? 1) >= 5;
171
+ const meta = {
172
+ width: rotated ? m.height : m.width,
173
+ height: rotated ? m.width : m.height,
174
+ format: m.format,
175
+ hasAlpha: m.hasAlpha ?? false
176
+ };
177
+ await fs.promises.mkdir(cacheDir, { recursive: true });
178
+ await fs.promises.writeFile(cached, JSON.stringify(meta));
179
+ return meta;
180
+ }
@@ -3,6 +3,12 @@ import type { Plugin } from 'vite';
3
3
  export interface TernOptions {
4
4
  /** Options passed to @sveltejs/vite-plugin-svelte. */
5
5
  svelte?: Partial<SvelteOptions>;
6
+ /**
7
+ * Packages to bundle in their "browser" build for server rendering, for
8
+ * libraries whose default build needs Node APIs such as Buffer while the
9
+ * browser build is portable. nanoid is included by default.
10
+ */
11
+ ssrBrowserBuilds?: string[];
6
12
  }
7
13
  /**
8
14
  * The Vite plugin for Tern apps. It includes the Svelte plugin, so it is
@@ -1,13 +1,21 @@
1
1
  import { svelte } from '@sveltejs/vite-plugin-svelte';
2
2
  import fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
3
4
  import path from 'node:path';
5
+ import { goScriptPlugin, goScriptPreprocessor } from './goscript.js';
6
+ import { imagePlugin } from './image.js';
7
+ /** Packages whose browser build works in the server renderer and whose default build needs Node. */
8
+ const BROWSER_BUILDS = ['nanoid'];
4
9
  /** Paths shared with the Go code generator; keep in sync with internal/codegen. */
5
10
  const CLIENT_ENTRY = '.tern/generated/client.js';
6
11
  const SERVER_ENTRY = '.tern/generated/server.js';
7
12
  const CLIENT_OUT = '.tern/output/client';
13
+ const SW_MODULE = '$service-worker';
8
14
  const SERVER_OUT = '.tern/output/server';
9
15
  /** The dev endpoint the Go app calls to server-render a page. */
10
16
  const RENDER_PATH = '/__tern/render';
17
+ /** The dev endpoint the Go app calls to render an email. */
18
+ const EMAIL_PATH = '/__tern/email';
11
19
  /**
12
20
  * The Vite plugin for Tern apps. It includes the Svelte plugin, so it is
13
21
  * the only plugin a Tern app needs:
@@ -25,6 +33,8 @@ const RENDER_PATH = '/__tern/render';
25
33
  */
26
34
  export function tern(options = {}) {
27
35
  let root = process.cwd();
36
+ let ssrBuild = false;
37
+ const browserBuilds = new Set([...BROWSER_BUILDS, ...(options.ssrBrowserBuilds ?? [])]);
28
38
  const plugin = {
29
39
  name: 'tern',
30
40
  config(config, env) {
@@ -36,11 +46,33 @@ export function tern(options = {}) {
36
46
  resolve: {
37
47
  alias: {
38
48
  $lib: path.join(root, 'src/lib'),
39
- $routes: path.join(root, '.tern/generated/routes.js')
49
+ $routes: path.join(root, '.tern/generated/routes.js'),
50
+ $api: path.join(root, '.tern/generated/api.js')
40
51
  }
41
52
  }
42
53
  };
54
+ if (process.env.TERN_SW_BUILD === '1') {
55
+ const source = ['src/service-worker.ts', 'src/service-worker.js'].map((f) => path.join(root, f)).find((f) => fs.existsSync(f));
56
+ if (!source)
57
+ throw new Error('tern: no src/service-worker.ts to build');
58
+ return {
59
+ ...shared,
60
+ publicDir: false,
61
+ build: {
62
+ outDir: CLIENT_OUT,
63
+ emptyOutDir: false,
64
+ manifest: false,
65
+ minify: true,
66
+ target: 'es2022',
67
+ rolldownOptions: {
68
+ input: source,
69
+ output: { format: 'iife', entryFileNames: 'service-worker.js' }
70
+ }
71
+ }
72
+ };
73
+ }
43
74
  if (env.isSsrBuild) {
75
+ ssrBuild = true;
44
76
  return {
45
77
  ...shared,
46
78
  publicDir: false,
@@ -56,8 +88,10 @@ export function tern(options = {}) {
56
88
  }
57
89
  },
58
90
  // One self-contained file: the renderer runs in an embedded engine
59
- // without a module loader or Node built-ins.
60
- ssr: { noExternal: true },
91
+ // without a module loader or Node built-ins. Without the "node"
92
+ // condition, libraries resolve to their portable builds instead of
93
+ // ones that use Buffer or node:crypto.
94
+ ssr: { noExternal: true, resolve: { conditions: ['module', 'development|production'] } },
61
95
  // Libraries often read process.env.NODE_ENV; the engine has no
62
96
  // process object, so replace it at build time.
63
97
  define: { 'process.env.NODE_ENV': JSON.stringify('production') }
@@ -91,7 +125,26 @@ export function tern(options = {}) {
91
125
  }
92
126
  };
93
127
  },
128
+ resolveId(id) {
129
+ return id === SW_MODULE ? '\0' + SW_MODULE : undefined;
130
+ },
131
+ load(id) {
132
+ if (id !== '\0' + SW_MODULE)
133
+ return undefined;
134
+ return serviceWorkerModule(root, readBasePath(root));
135
+ },
94
136
  configureServer(server) {
137
+ server.middlewares.use(EMAIL_PATH, (req, res) => {
138
+ void emailInDev(server, req).then((body) => {
139
+ res.setHeader('Content-Type', 'application/json');
140
+ res.end(body);
141
+ }, (err) => {
142
+ const e = err instanceof Error ? err : new Error(String(err));
143
+ server.ssrFixStacktrace(e);
144
+ res.statusCode = 500;
145
+ res.end(e.stack ?? e.message);
146
+ });
147
+ });
95
148
  server.middlewares.use(RENDER_PATH, (req, res) => {
96
149
  void renderInDev(server, req).then((body) => {
97
150
  res.setHeader('Content-Type', 'application/json');
@@ -116,7 +169,111 @@ export function tern(options = {}) {
116
169
  const mod = (await server.ssrLoadModule(path.join(root, SERVER_ENTRY)));
117
170
  return mod.render(payload, href);
118
171
  }
119
- return [...svelte(options.svelte), plugin];
172
+ async function emailInDev(server, req) {
173
+ if (req.method !== 'POST')
174
+ throw new Error('POST required');
175
+ const chunks = [];
176
+ for await (const chunk of req)
177
+ chunks.push(chunk);
178
+ const { name, props } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
179
+ const mod = (await server.ssrLoadModule(path.join(root, SERVER_ENTRY)));
180
+ if (!mod.renderEmail)
181
+ return JSON.stringify(name === '' ? {} : { subject: '', html: '', error: 'the app has no src/emails' });
182
+ return mod.renderEmail(name, props);
183
+ }
184
+ // Runs before Vite's own resolver, which would pick the default build.
185
+ const browserBuildsPlugin = {
186
+ name: 'tern:ssr-browser-builds',
187
+ enforce: 'pre',
188
+ resolveId(id, importer) {
189
+ if (!ssrBuild || !browserBuilds.has(id))
190
+ return undefined;
191
+ return browserBuild(id, importer ?? path.join(root, 'package.json'));
192
+ }
193
+ };
194
+ // Email templates carry their styles in the HTML, since mail clients
195
+ // can't load the app's stylesheets.
196
+ const userDynamic = options.svelte?.dynamicCompileOptions;
197
+ const userPreprocess = options.svelte?.preprocess;
198
+ const svelteOptions = {
199
+ ...options.svelte,
200
+ // <script lang="go"> runs before the user's preprocessors.
201
+ preprocess: [goScriptPreprocessor(() => root), ...(Array.isArray(userPreprocess) ? userPreprocess : userPreprocess ? [userPreprocess] : [])],
202
+ dynamicCompileOptions: async (args) => {
203
+ const user = (await userDynamic?.(args)) ?? undefined;
204
+ if (args.filename.includes(`${path.sep}src${path.sep}emails${path.sep}`) || args.filename.includes('/src/emails/')) {
205
+ return { ...user, css: 'injected' };
206
+ }
207
+ return user;
208
+ }
209
+ };
210
+ return [browserBuildsPlugin, imagePlugin(), goScriptPlugin(() => root), ...svelte(svelteOptions), plugin];
211
+ }
212
+ /** The $service-worker module: what this build produced, for caching. */
213
+ function serviceWorkerModule(root, base) {
214
+ const out = path.join(root, CLIENT_OUT);
215
+ const build = [];
216
+ try {
217
+ const manifest = JSON.parse(fs.readFileSync(path.join(out, '.vite/manifest.json'), 'utf8'));
218
+ const seen = new Set();
219
+ for (const chunk of Object.values(manifest)) {
220
+ for (const f of [chunk.file, ...(chunk.css ?? []), ...(chunk.assets ?? [])]) {
221
+ if (!seen.has(f)) {
222
+ seen.add(f);
223
+ build.push(`${base}/${f}`);
224
+ }
225
+ }
226
+ }
227
+ }
228
+ catch {
229
+ // Development: nothing is built yet.
230
+ }
231
+ const files = walk(path.join(root, 'static')).map((f) => `${base}/${f}`);
232
+ const prerendered = walk(out)
233
+ .filter((f) => f === 'index.html' || f.endsWith('/index.html'))
234
+ .map((f) => `${base}/${f.replace(/\/?index\.html$/, '')}` || '/');
235
+ const version = Date.now().toString(36);
236
+ return [
237
+ `export const base = ${JSON.stringify(base)};`,
238
+ `export const build = ${JSON.stringify(build.sort())};`,
239
+ `export const files = ${JSON.stringify(files.sort())};`,
240
+ `export const prerendered = ${JSON.stringify(prerendered.sort())};`,
241
+ `export const version = ${JSON.stringify(version)};`
242
+ ].join('\n');
243
+ }
244
+ /** The file a package's "browser" export points to, or undefined to resolve normally. */
245
+ function browserBuild(pkg, importer) {
246
+ try {
247
+ const require = createRequire(importer);
248
+ const manifestPath = require.resolve(`${pkg}/package.json`);
249
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
250
+ const dot = manifest.exports?.['.'];
251
+ let target = typeof dot === 'object' && dot !== null ? dot.browser : undefined;
252
+ if (typeof target === 'object' && target !== null)
253
+ target = target.default ?? target.import;
254
+ if (typeof target !== 'string' && typeof manifest.browser === 'string')
255
+ target = manifest.browser;
256
+ return typeof target === 'string' ? path.join(path.dirname(manifestPath), target) : undefined;
257
+ }
258
+ catch {
259
+ return undefined;
260
+ }
261
+ }
262
+ /** Lists files below dir, relative to it, with forward slashes. */
263
+ function walk(dir, prefix = '') {
264
+ let entries;
265
+ try {
266
+ entries = fs.readdirSync(dir, { withFileTypes: true });
267
+ }
268
+ catch {
269
+ return [];
270
+ }
271
+ return entries.flatMap((e) => {
272
+ if (e.name.startsWith('.') || e.name.endsWith('.gz'))
273
+ return [];
274
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
275
+ return e.isDirectory() ? walk(path.join(dir, e.name), rel) : [rel];
276
+ });
120
277
  }
121
278
  /** Reads basePath from tern.json; the Go side validates it. */
122
279
  function readBasePath(root) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ternkit/tern",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Client runtime and Vite plugin for Tern: Svelte on the client, Go on the server",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,6 +29,11 @@
29
29
  "svelte": "./dist/runtime/client.js",
30
30
  "default": "./dist/runtime/client.js"
31
31
  },
32
+ "./goscript": {
33
+ "types": "./dist/goscript/index.svelte.d.ts",
34
+ "svelte": "./dist/goscript/index.svelte.js",
35
+ "default": "./dist/goscript/index.svelte.js"
36
+ },
32
37
  "./server": {
33
38
  "types": "./dist/server/index.d.ts",
34
39
  "svelte": "./dist/server/index.js",
@@ -55,20 +60,26 @@
55
60
  "test": "vitest run"
56
61
  },
57
62
  "peerDependencies": {
63
+ "sharp": ">=0.33.0",
58
64
  "svelte": "^5.46.4",
59
65
  "vite": "^8.0.0"
60
66
  },
61
67
  "dependencies": {
62
- "@sveltejs/vite-plugin-svelte": "^7.3.1",
63
- "core-js": "^3.51.0"
68
+ "@sveltejs/vite-plugin-svelte": "^7.3.1"
64
69
  },
65
70
  "devDependencies": {
66
71
  "@sveltejs/package": "^3.0.2",
67
72
  "@types/node": "^24.0.0",
73
+ "sharp": "^0.35.5",
68
74
  "svelte": "^5.57.3",
69
75
  "svelte-check": "^4.7.8",
70
76
  "typescript": "^6.0.0",
71
77
  "vite": "^8.3.7",
72
78
  "vitest": "^5.1.0"
79
+ },
80
+ "peerDependenciesMeta": {
81
+ "sharp": {
82
+ "optional": true
83
+ }
73
84
  }
74
85
  }