From c1f688893eb2e5ba53b7338e768a771d9cf7520b Mon Sep 17 00:00:00 2001 From: eleith Date: Mon, 19 Oct 2026 14:44:23 -0700 Subject: [PATCH] [web.worker] bundle named templates with one server runtime Reviewed and explicitly approved before staging. Compile named sources into one standalone renderer with safe name selection, shared Svelte context and isolated render output. Preserve source diagnostics; no configuration discovery, publication, job registration, or web integration. --- .changeset/worker-template-set.md | 8 ++ packages/web/worker/README.md | 23 +++-- .../web/worker/src/templates/compile.test.ts | 83 ++++++++++++++----- packages/web/worker/src/templates/compile.ts | 68 ++++++++++----- .../test/fixtures/templates/context.svelte | 18 ++++ 5 files changed, 151 insertions(+), 49 deletions(-) create mode 100644 .changeset/worker-template-set.md create mode 100644 packages/web/worker/test/fixtures/templates/context.svelte diff --git a/.changeset/worker-template-set.md b/.changeset/worker-template-set.md new file mode 100644 index 00000000..640050e5 --- /dev/null +++ b/.changeset/worker-template-set.md @@ -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. diff --git a/packages/web/worker/README.md b/packages/web/worker/README.md index 439538cc..5be8b5cd 100644 --- a/packages/web/worker/README.md +++ b/packages/web/worker/README.md @@ -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 diff --git a/packages/web/worker/src/templates/compile.test.ts b/packages/web/worker/src/templates/compile.test.ts index 35a3da92..7d12324e 100644 --- a/packages/web/worker/src/templates/compile.test.ts +++ b/packages/web/worker/src/templates/compile.test.ts @@ -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: '&' } } }); -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: '&' } } }); +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('A

') + expect(contextB.body).toContain('>B

') + 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 = '

Hello

' - 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('
', '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: '

Valid

', filename: 'valid.svelte' }, + broken: { source: '
', filename: '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 index 30119ef4..903ad231 100644 --- a/packages/web/worker/src/templates/compile.ts +++ b/packages/web/worker/src/templates/compile.ts @@ -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> +): Promise { + const modules = new Map() + 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 { - 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 } } diff --git a/packages/web/worker/test/fixtures/templates/context.svelte b/packages/web/worker/test/fixtures/templates/context.svelte new file mode 100644 index 00000000..3cbe930d --- /dev/null +++ b/packages/web/worker/test/fixtures/templates/context.svelte @@ -0,0 +1,18 @@ + + +

{getContext('fixture-label')}

+ + -- 2.54.0