[web.worker] compile standalone server templates #124
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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: <First>&')
|
||||
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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -14,6 +14,7 @@ export default defineConfig({
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'coverage/**',
|
||||
'test/fixtures/**',
|
||||
'**/*.d.ts',
|
||||
'src/index.ts',
|
||||
'src/pieces/.compiled/**',
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user