[web.worker] bundle named server templates together #125

Merged
eleith merged 1 commits from feat/worker-template-set into main 2026-10-19 21:49:10 +00:00
5 changed files with 151 additions and 49 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
"@luzzle/web.worker": patch
---
Compile a named set of server templates into one standalone module sharing a
single Svelte runtime. The renderer selects a template by name, keeps output
isolated between calls, and retains source diagnostics. Configuration discovery,
revision publication and live web integration remain unchanged.
+17 -6
View File
@@ -36,13 +36,24 @@ The worker registers and processes the following job types:
## 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`.
`src/templates/compile.ts` contains a low-level `compileTemplates(templates)`
function. Each name maps to `{ source, filename }`. It compiles all sources with
Svelte and bundles them together with esbuild into one standalone Node ESM module,
returned as `code` alongside 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.
```ts
const compiled = await compileTemplates({
page: { source: pageSource, filename: 'page.svelte' },
icon: { source: iconSource, filename: 'icon.svelte' },
})
```
The generated module exports `render(name, props)`, which synchronously returns
plain `{ body, head }` strings. `head` includes only the selected render's component
CSS. All templates share one Svelte server runtime; callers do not need to install
Svelte. Unknown names throw an error, not a fallback. Names are identifiers, not
filesystem paths or a piece-type allowlist. Diagnostics retain source filenames.
The names and sources are caller-supplied; this utility does not discover them.
This utility does not read configuration, validate the supported authoring subset,
cache/publish revisions, or register a background job. Open Graph and thumbnail
@@ -4,13 +4,14 @@ 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'
import { compileTemplates } from './compile.js'
const exec = promisify(execFile)
const fixture = new URL('../../test/fixtures/templates/page.svelte', import.meta.url)
const pageFixture = new URL('../../test/fixtures/templates/page.svelte', import.meta.url)
const contextFixture = new URL('../../test/fixtures/templates/context.svelte', import.meta.url)
const quotedName = 'quoted"\\\nname'
// 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.
// Native Node rejects dependencies the workspace might otherwise supply.
const runArtifact = `
import { isBuiltin, registerHooks } from 'node:module';
const moduleUrl = process.argv[1];
@@ -23,15 +24,40 @@ registerHooks({
}
});
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 }));
const first = render('page', { data: { piece: { type: 'custom-one', title: '<First>&' } } });
const contexts = [];
const capture = value => contexts.push(value);
const contextA = render('context-a', { data: { label: 'A', capture } });
const contextB = render('context-b', { data: { label: 'B', capture } });
if (contexts.length !== 2 || contexts[0] !== contexts[1]) {
throw new Error('Templates do not share one Svelte runtime');
}
let contextCleared = false;
try { contexts[0]('fixture-label'); } catch { contextCleared = true; }
if (!contextCleared) throw new Error('Svelte context leaked outside rendering');
const second = render('page', { data: { piece: { type: 'another-kind', title: 'Second' } } });
const props = { data: { piece: { type: 'other', title: 'Named' } } };
const reserved = render('__proto__', props);
const quoted = render(process.argv[2], props);
let unknown;
try { render('toString', props); } catch (error) { unknown = error.message; }
console.log(JSON.stringify({ first, contextA, contextB, second, reserved, quoted, unknown }));
`
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))
describe('compileTemplates', () => {
test('bundles named templates with one runtime and independent render output', async () => {
const page = {
source: await readFile(pageFixture, 'utf8'),
filename: fileURLToPath(pageFixture),
}
const contextSource = await readFile(contextFixture, 'utf8')
const compiled = await compileTemplates({
page,
'context-a': { source: contextSource, filename: 'context-a.svelte' },
'context-b': { source: contextSource, filename: 'context-b.svelte' },
['__proto__']: page,
[quotedName]: page,
})
expect(compiled.warnings).toEqual([])
const cache = fileURLToPath(new URL('../../node_modules/.cache/', import.meta.url))
@@ -45,32 +71,47 @@ describe('compileTemplate', () => {
'-e',
runArtifact,
pathToFileURL(modulePath).href,
quotedName,
])
const { first, second } = JSON.parse(stdout)
const { first, contextA, contextB, second, reserved, quoted, unknown } = 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(first.head).not.toContain('--color-secondary')
expect(contextA.body).toContain('>A</p>')
expect(contextB.body).toContain('>B</p>')
expect(contextA.head).toContain('--color-secondary')
expect(contextA.head).not.toContain('--color-primary')
expect(second.body).toContain('another-kind: Second')
expect(second.body).not.toContain('First')
expect(second.head).toBe(first.head)
expect(reserved.body).toContain('other: Named')
expect(quoted.body).toBe(reserved.body)
expect(unknown).toBe('Unknown template: toString')
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test('preserves Svelte source warnings for the caller', async () => {
test('collects warnings while preserving each source filename', async () => {
const source = '<p>Hello</p><style>.unused { color: var(--color-primary); }</style>'
const compiled = await compileTemplate(source, 'unused.svelte')
const compiled = await compileTemplates({
first: { source, filename: 'first.svelte' },
second: { source, filename: 'second.svelte' },
})
expect(compiled.warnings).toEqual([
expect.objectContaining({ code: 'css_unused_selector', filename: 'unused.svelte' }),
expect.objectContaining({ code: 'css_unused_selector', filename: 'first.svelte' }),
expect.objectContaining({ code: 'css_unused_selector', filename: 'second.svelte' }),
])
})
test('reports invalid source with its original filename', async () => {
await expect(compileTemplate('<div>', 'broken.svelte')).rejects.toMatchObject({
code: 'element_unclosed',
filename: 'broken.svelte',
})
test('rejects invalid source without losing the original filename', async () => {
await expect(
compileTemplates({
valid: { source: '<p>Valid</p>', filename: 'valid.svelte' },
broken: { source: '<div>', filename: 'broken.svelte' },
})
).rejects.toMatchObject({ code: 'element_unclosed', filename: 'broken.svelte' })
})
})
+46 -22
View File
@@ -1,31 +1,55 @@
import { build } from 'esbuild'
import { compile, type Warning } from 'svelte/compiler'
export interface CompiledTemplate {
export interface TemplateSource {
source: string
filename: string
}
export interface CompiledTemplates {
code: string
warnings: Warning[]
}
const templateModuleId = 'luzzle:template'
const renderEntry = `
import Template from '${templateModuleId}';
import { render as renderSvelte } from 'svelte/server';
const templateModulePrefix = 'luzzle:template/'
export function render(props) {
export async function compileTemplates(
templates: Readonly<Record<string, TemplateSource>>
): Promise<CompiledTemplates> {
const modules = new Map<string, string>()
const imports: string[] = []
const entries: string[] = []
const warnings: Warning[] = []
for (const [name, template] of Object.entries(templates)) {
const component = `Template${modules.size}`
const moduleId = `${templateModulePrefix}${modules.size}`
const compiled = compile(template.source, {
filename: template.filename,
generate: 'server',
css: 'injected',
dev: false,
experimental: { async: false },
})
modules.set(moduleId, compiled.js.code)
warnings.push(...compiled.warnings)
imports.push(`import ${component} from ${JSON.stringify(moduleId)};`)
entries.push(`[${JSON.stringify(name)}, ${component}]`)
}
const renderEntry = `
${imports.join('\n')}
import { render as renderSvelte } from 'svelte/server';
const templates = new Map([${entries.join(',\n')}]);
export function render(name, props) {
const Template = templates.get(name);
if (!Template) throw new Error('Unknown template: ' + name);
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,
@@ -37,14 +61,14 @@ export async function compileTemplate(source: string, filename: string): Promise
define: { 'process.env.NODE_ENV': '"production"' },
plugins: [
{
name: 'compiled-template',
name: 'compiled-templates',
setup(builder) {
builder.onResolve({ filter: new RegExp(`^${templateModuleId}$`) }, () => ({
path: filename,
namespace: templateModuleId,
builder.onResolve({ filter: new RegExp(`^${templateModulePrefix}`) }, ({ path }) => ({
path,
namespace: templateModulePrefix,
}))
builder.onLoad({ filter: /.*/, namespace: templateModuleId }, () => ({
contents: compiled.js.code,
builder.onLoad({ filter: /.*/, namespace: templateModulePrefix }, ({ path }) => ({
contents: modules.get(path),
loader: 'js',
resolveDir: import.meta.dirname,
}))
@@ -53,5 +77,5 @@ export async function compileTemplate(source: string, filename: string): Promise
],
})
return { code: bundled.outputFiles[0].text, warnings: compiled.warnings }
return { code: bundled.outputFiles[0].text, warnings }
}
@@ -0,0 +1,18 @@
<script>
import { getContext, setContext, untrack } from 'svelte'
let { data } = $props()
// Exercise runtime identity and context, not the supported authoring subset.
untrack(() => {
setContext('fixture-label', data.label)
data.capture(getContext)
})
</script>
<p class="context">{getContext('fixture-label')}</p>
<style>
.context {
color: var(--color-secondary);
}
</style>