diff --git a/.changeset/worker-template-compiler.md b/.changeset/worker-template-compiler.md new file mode 100644 index 00000000..45b0e17a --- /dev/null +++ b/.changeset/worker-template-compiler.md @@ -0,0 +1,7 @@ +--- +"@luzzle/web.worker": patch +--- + +Add worker-owned compilation of Svelte templates into standalone server-rendering +modules. The worker bundles the matching Svelte runtime with esbuild; no template +job, publishing lifecycle, or live web integration is enabled by this change. diff --git a/packages/web/worker/README.md b/packages/web/worker/README.md index 85addd24..439538cc 100644 --- a/packages/web/worker/README.md +++ b/packages/web/worker/README.md @@ -26,14 +26,28 @@ The worker isolates intensive CPU/IO tasks from the SvelteKit app, including: The worker registers and processes the following job types: 1. **`publish`** — Triggers the orchestrator pipeline to synchronize the archive - from/to remotes, parse changes, build CDN asset variants, and sync CDN. + from/to remotes, parse changes, build CDN asset variants, and sync CDN. 2. **`preview`** — Performs hot-path parsing of piece modifications in the - editor, returning on-the-fly syntax-highlighted HTML and asset preview URLs. + editor, returning on-the-fly syntax-highlighted HTML and asset preview URLs. 3. **`job-progress-purge`** — Clean-up job to purge completed/failed workflow - job logs based on a retention schedule. + job logs based on a retention schedule. --- +## Template compiler + +`src/templates/compile.ts` contains a low-level `compileTemplate(source, filename)` +function. It uses the worker's Svelte compiler and esbuild to return a standalone +Node ESM module as `code`, alongside the Svelte source `warnings`. + +The generated module exports `render(props)`, which synchronously returns plain +`{ body, head }` strings. `head` includes injected component CSS. Svelte's server +runtime is bundled into that module; callers do not need to install Svelte. + +This utility does not read configuration, validate the supported authoring subset, +cache/publish revisions, or register a background job. Open Graph and thumbnail +processing are unchanged. Template source is trusted site-owner code, not sandboxed. + ## Operations 🛠️ The worker executes inside the `luzzle-worker` Docker container. For diff --git a/packages/web/worker/package.json b/packages/web/worker/package.json index 68f44c54..54f95333 100644 --- a/packages/web/worker/package.json +++ b/packages/web/worker/package.json @@ -48,6 +48,7 @@ "@takumi-rs/core": "1.5.0", "colorlip": "^1.0.0", "devlop": "1.1.0", + "esbuild": "0.25.12", "hastscript": "9.0.1", "kysely": "0.28.17", "mdast-util-from-markdown": "2.0.2", diff --git a/packages/web/worker/src/templates/compile.test.ts b/packages/web/worker/src/templates/compile.test.ts new file mode 100644 index 00000000..35a3da92 --- /dev/null +++ b/packages/web/worker/src/templates/compile.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'vitest' +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { compileTemplate } from './compile.js' + +const exec = promisify(execFile) +const fixture = new URL('../../test/fixtures/templates/page.svelte', import.meta.url) + +// A native Node process, not Vitest's module loader. Any dependency left outside +// the artifact must be a Node builtin, even if the workspace could resolve it. +const runArtifact = ` +import { isBuiltin, registerHooks } from 'node:module'; +const moduleUrl = process.argv[1]; +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier !== moduleUrl && !isBuiltin(specifier)) { + throw new Error('Artifact has an external dependency: ' + specifier); + } + return nextResolve(specifier, context); + } +}); +const { render } = await import(moduleUrl); +const first = render({ data: { piece: { type: 'custom-one', title: '&' } } }); +const second = render({ data: { piece: { type: 'another-kind', title: 'Second' } } }); +console.log(JSON.stringify({ first, second })); +` + +describe('compileTemplate', () => { + test('produces a standalone renderer with escaped content and scoped CSS', async () => { + const source = await readFile(fixture, 'utf8') + const compiled = await compileTemplate(source, fileURLToPath(fixture)) + expect(compiled.warnings).toEqual([]) + + const cache = fileURLToPath(new URL('../../node_modules/.cache/', import.meta.url)) + await mkdir(cache, { recursive: true }) + const directory = await mkdtemp(path.join(cache, 'template-')) + try { + const modulePath = path.join(directory, 'renderer.mjs') + await writeFile(modulePath, compiled.code) + const { stdout } = await exec(process.execPath, [ + '--input-type=module', + '-e', + runArtifact, + pathToFileURL(modulePath).href, + ]) + const { first, second } = JSON.parse(stdout) + expect(Object.keys(first).sort()).toEqual(['body', 'head']) + expect(first.body).toContain('custom-one: <First>&') + expect(first.body).toMatch(/class="piece svelte-/) + expect(first.head).toContain('--color-primary') + expect(first.head).toContain(' { + const source = '

Hello

' + const compiled = await compileTemplate(source, 'unused.svelte') + expect(compiled.warnings).toEqual([ + expect.objectContaining({ code: 'css_unused_selector', filename: 'unused.svelte' }), + ]) + }) + + test('reports invalid source with its original filename', async () => { + await expect(compileTemplate('
', 'broken.svelte')).rejects.toMatchObject({ + code: 'element_unclosed', + filename: 'broken.svelte', + }) + }) +}) diff --git a/packages/web/worker/src/templates/compile.ts b/packages/web/worker/src/templates/compile.ts new file mode 100644 index 00000000..30119ef4 --- /dev/null +++ b/packages/web/worker/src/templates/compile.ts @@ -0,0 +1,57 @@ +import { build } from 'esbuild' +import { compile, type Warning } from 'svelte/compiler' + +export interface CompiledTemplate { + code: string + warnings: Warning[] +} + +const templateModuleId = 'luzzle:template' +const renderEntry = ` +import Template from '${templateModuleId}'; +import { render as renderSvelte } from 'svelte/server'; + +export function render(props) { + const { body, head } = renderSvelte(Template, { props }); + return { body, head }; +} +` + +export async function compileTemplate(source: string, filename: string): Promise { + const compiled = compile(source, { + filename, + generate: 'server', + css: 'injected', + dev: false, + experimental: { async: false }, + }) + + const bundled = await build({ + stdin: { contents: renderEntry, resolveDir: import.meta.dirname }, + bundle: true, + write: false, + platform: 'node', + format: 'esm', + target: 'node24', + conditions: ['node', 'production'], + define: { 'process.env.NODE_ENV': '"production"' }, + plugins: [ + { + name: 'compiled-template', + setup(builder) { + builder.onResolve({ filter: new RegExp(`^${templateModuleId}$`) }, () => ({ + path: filename, + namespace: templateModuleId, + })) + builder.onLoad({ filter: /.*/, namespace: templateModuleId }, () => ({ + contents: compiled.js.code, + loader: 'js', + resolveDir: import.meta.dirname, + })) + }, + }, + ], + }) + + return { code: bundled.outputFiles[0].text, warnings: compiled.warnings } +} diff --git a/packages/web/worker/test/fixtures/templates/page.svelte b/packages/web/worker/test/fixtures/templates/page.svelte new file mode 100644 index 00000000..3b258ced --- /dev/null +++ b/packages/web/worker/test/fixtures/templates/page.svelte @@ -0,0 +1,12 @@ + + +
{label}
+ + diff --git a/packages/web/worker/vite.config.ts b/packages/web/worker/vite.config.ts index e1873375..8d2009e6 100644 --- a/packages/web/worker/vite.config.ts +++ b/packages/web/worker/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ 'dist/**', 'node_modules/**', 'coverage/**', + 'test/fixtures/**', '**/*.d.ts', 'src/index.ts', 'src/pieces/.compiled/**', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c121afb..62714ad3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -779,6 +779,9 @@ importers: devlop: specifier: 1.1.0 version: 1.1.0 + esbuild: + specifier: 0.25.12 + version: 0.25.12 hastscript: specifier: 9.0.1 version: 9.0.1