[web.worker] compile standalone server templates #124

Merged
eleith merged 1 commits from feat/worker-template-preparation into main 2026-10-19 21:16:58 +00:00
8 changed files with 174 additions and 3 deletions

No files matched your search

+7
View File
@@ -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.
+17 -3
View File
@@ -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
+1
View File
@@ -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",
@@ -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: '<First>&' } } });
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: &lt;First>&amp;')
expect(first.body).toMatch(/class="piece svelte-/)
expect(first.head).toContain('--color-primary')
expect(first.head).toContain('<style')
expect(second.body).toContain('another-kind: Second')
expect(second.body).not.toContain('First')
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test('preserves Svelte source warnings for the caller', async () => {
const source = '<p>Hello</p><style>.unused { color: var(--color-primary); }</style>'
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('<div>', 'broken.svelte')).rejects.toMatchObject({
code: 'element_unclosed',
filename: 'broken.svelte',
})
})
})
@@ -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<CompiledTemplate> {
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 }
}
+12
View File
@@ -0,0 +1,12 @@
<script>
let { data } = $props()
const label = $derived(`${data.piece.type}: ${data.piece.title}`)
</script>
<article class="piece">{label}</article>
<style>
.piece {
color: var(--color-primary);
}
</style>
+1
View File
@@ -14,6 +14,7 @@ export default defineConfig({
'dist/**',
'node_modules/**',
'coverage/**',
'test/fixtures/**',
'**/*.d.ts',
'src/index.ts',
'src/pieces/.compiled/**',
+3
View File
@@ -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