[web.worker] bundle named server templates together #125
No files matched your search
@@ -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.
|
||||
@@ -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: <First>&')
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user