-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement initial support for SSR
- Loading branch information
1 parent
f6ea9d6
commit 0b9a09d
Showing
24 changed files
with
863 additions
and
201 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
20.9.0 | ||
v20.11.0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; | ||
import { parentPort, workerData } from 'worker_threads'; | ||
import { render } from '@lit-labs/ssr'; | ||
import { build } from 'vite'; | ||
import { createHash } from 'crypto'; | ||
import { dirname, join, relative, resolve } from 'path'; | ||
|
||
if (parentPort === null) { | ||
throw new Error('worker.js must only be run in a worker thread'); | ||
} | ||
|
||
const { template, modules } = workerData; | ||
if (modules?.length) { | ||
const buildCacheOutDir = new URL(`../dist/testout/${moduleHash(modules)}/`, import.meta.url); | ||
const moduleIndex = new URL('index.js', buildCacheOutDir).pathname; | ||
if (!existsSync(moduleIndex)) { | ||
await buildModules(buildCacheOutDir); | ||
} | ||
|
||
await import(moduleIndex); | ||
} | ||
// Dangerously spoof TemplateStringsArray by adding back the \`raw\` property | ||
// property which gets stripped during serialization of the TemplateResult. | ||
// This is needed to get through the check here | ||
// https://github.com/lit/lit/blob/1fbd2b7a1e6da09912f5c681d2b6eaf1c4920bb4/packages/lit-html/src/lit-html.ts#L867 | ||
const strings = template.strings; | ||
strings.raw = strings; | ||
let rendered = ''; | ||
for (const str of render({ ...template, strings }, { deferHydration: true })) { | ||
rendered += str; | ||
} | ||
parentPort.postMessage(rendered); | ||
|
||
/** | ||
* We are dynamically building the given modules, as Node.js cannot currently | ||
* use loaders in workers. | ||
* It works by creating a directory containing the hash | ||
* of all used modules and (recursive) imports of these modules | ||
* and building an index.js file, which is the result of the build. | ||
* | ||
* TODO: Remove once Node.js supports loading .ts files. | ||
*/ | ||
async function buildModules(buildCacheOutDir) { | ||
mkdirSync(buildCacheOutDir, { recursive: true }); | ||
|
||
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); | ||
const entry = new URL('index.ts', buildCacheOutDir).pathname; | ||
const importStatement = modules | ||
.map((m) => `export * from '${relative(buildCacheOutDir.pathname, m)}';`) | ||
.join('\n'); | ||
writeFileSync(entry, importStatement, 'utf8'); | ||
|
||
await build({ | ||
root: new URL('..', import.meta.url).pathname, | ||
mode: 'development', | ||
logLevel: 'warn', | ||
build: { | ||
lib: { | ||
entry, | ||
formats: ['es'], | ||
}, | ||
ssr: true, | ||
outDir: buildCacheOutDir.pathname, | ||
emptyOutDir: false, | ||
rollupOptions: { external: Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }) }, | ||
sourcemap: 'inline', | ||
}, | ||
}); | ||
} | ||
|
||
/** Generate a hash from the contents of the given modules and their import chain. */ | ||
function moduleHash(modules) { | ||
const fileMap = new Map(); | ||
modules.forEach((m) => resolveImports(m, fileMap)); | ||
return Array.from(fileMap) | ||
.sort((a, b) => a[0].localeCompare(b[0])) | ||
.map((f) => f[1]) | ||
.reduce((current, next) => current.update(next), createHash('sha256')) | ||
.digest('hex'); | ||
} | ||
|
||
/** Resolve all used import/files recursively starting from the given file. */ | ||
function resolveImports(file, fileMap) { | ||
const content = readFileSync(file, 'utf8'); | ||
fileMap.set(file, content); | ||
|
||
const dir = dirname(file); | ||
[...content.matchAll(/from '(\.[^']+)|import '(.[^']+)/g)] | ||
.map((m) => m[1] ?? m[2]) | ||
.filter((m) => !m.includes('?')) | ||
.map((m) => resolve(dir, m)) | ||
.flatMap((m) => [`${m}.ts`, join(m, 'index.ts')].filter((f) => existsSync(f))) | ||
.filter((v, i, a) => a.indexOf(v) === i) | ||
.filter((m) => !fileMap.has(m)) | ||
.sort() | ||
.forEach((m) => resolveImports(m, fileMap)); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
import { readFileSync, writeFileSync } from 'fs'; | ||
import { basename, dirname, join, relative } from 'path'; | ||
|
||
import * as glob from 'glob'; | ||
// eslint-disable-next-line @typescript-eslint/naming-convention | ||
import MagicString from 'magic-string'; | ||
import ts from 'typescript'; | ||
|
||
function* iterate(node: ts.Node): Generator<ts.Node, void, unknown> { | ||
yield node; | ||
const childNodes: ts.Node[] = []; | ||
ts.forEachChild(node, (n) => { | ||
childNodes.push(n); | ||
}); | ||
for (const childNode of childNodes) { | ||
yield* iterate(childNode); | ||
} | ||
} | ||
|
||
const testingDir = new URL('../src/components/core/testing', import.meta.url).pathname; | ||
const e2eFiles = glob.sync('**/*.e2e.ts', { cwd: new URL('..', import.meta.url), absolute: true }); | ||
const componentIndexes = glob | ||
.sync('src/components/**/index.ts', { cwd: new URL('..', import.meta.url), absolute: true }) | ||
.filter((f) => !f.includes('/components/core/')) | ||
.sort() | ||
.sort((a, b) => b.length - a.length); | ||
for (const file of e2eFiles) { | ||
const fileDir = dirname(file); | ||
const content = readFileSync(file, 'utf8'); | ||
const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.ES2021, true); | ||
|
||
const imports: ts.ImportDeclaration[] = []; | ||
const fixtureUsages: ts.CallExpression[] = []; | ||
let testingImport: ts.ImportDeclaration | null = null; | ||
let wcFixture: ts.ImportSpecifier | null = null; | ||
for (const node of iterate(sourceFile)) { | ||
if (ts.isImportDeclaration(node)) { | ||
if (node.moduleSpecifier.getText() === `'@open-wc/testing'`) { | ||
wcFixture = | ||
(node.importClause?.namedBindings as ts.NamedImports).elements.find( | ||
(e) => e.name.getText() === 'fixture', | ||
) ?? null; | ||
} else if (node.moduleSpecifier.getText().endsWith(`/core/testing'`)) { | ||
testingImport = node; | ||
} else if ( | ||
!node.moduleSpecifier.getText().includes('/core/') && | ||
node.moduleSpecifier.getText().startsWith(`'.`) && | ||
(!node.importClause || !node.importClause.isTypeOnly) | ||
) { | ||
imports.push(node); | ||
} | ||
} else if ( | ||
ts.isCallExpression(node) && | ||
ts.isIdentifier(node.expression) && | ||
node.expression.getText() === 'fixture' | ||
) { | ||
fixtureUsages.push(node); | ||
} | ||
} | ||
|
||
if (wcFixture) { | ||
const newContent = new MagicString(content); | ||
newContent.remove(wcFixture.getStart(), wcFixture.getStart() + wcFixture.getWidth()); | ||
newContent.replaceAll( | ||
/\ndescribe\(['"`]([^'"`]+)['"`]/g, | ||
(_, m) => `\ndescribe(\`${m} with \${fixture.name}\``, | ||
); | ||
if (testingImport) { | ||
newContent.appendRight( | ||
testingImport.importClause!.getStart() + testingImport.importClause!.getWidth() - 1, | ||
', fixture', | ||
); | ||
} else { | ||
const reverseImports = imports.slice().reverse(); | ||
const lastRelativeImport = | ||
reverseImports.find((i) => i.moduleSpecifier.getText().startsWith(`'..`)) ?? | ||
reverseImports.find((i) => i.moduleSpecifier.getText().startsWith(`'.`))!; | ||
newContent.appendRight( | ||
lastRelativeImport.getStart() + lastRelativeImport.getWidth(), | ||
`\nimport { fixture } from '${relative(file, testingDir)}';`, | ||
); | ||
} | ||
|
||
const missingImports = new Set<string>(); | ||
for (const fixture of fixtureUsages) { | ||
const usedElements = [...fixture.arguments[0].getText().matchAll(/<sbb-([^\s>]+)/g)] | ||
.map((m) => m[1]) | ||
.filter((v, i, a) => a.indexOf(v) === i); | ||
const importPaths = usedElements | ||
.map((e) => componentIndexes.find((i) => i.endsWith(`/${e}/index.ts`))!) | ||
.map((path) => { | ||
do { | ||
const shortPath = join(dirname(dirname(path)), 'index.ts'); | ||
if ( | ||
fileDir === dirname(path) || | ||
!componentIndexes.includes(shortPath) || | ||
relative(fileDir, shortPath).length > relative(fileDir, path).length || | ||
['..', '../index.ts'].includes(relative(fileDir, shortPath)) | ||
) { | ||
const relPath = relative(fileDir, path); | ||
return `'${relPath.startsWith('.') ? relPath : `./${basename(dirname(file))}.ts`}'`; | ||
} | ||
|
||
path = shortPath; | ||
// eslint-disable-next-line no-constant-condition | ||
} while (true); | ||
}); | ||
|
||
importPaths | ||
.map((i) => i.replace(/.ts'$/, `'`).replace(/\/index'$/, `'`)) | ||
.filter((ip) => imports.every((i) => i.moduleSpecifier.getText() !== ip)) | ||
.forEach((i) => missingImports.add(i)); | ||
|
||
newContent.appendRight( | ||
fixture!.getStart() + fixture!.getWidth() - 1, | ||
`, { modules: [${importPaths.join(', ')}] }`, | ||
); | ||
} | ||
if (missingImports.size) { | ||
console.log( | ||
`${file} is missing imports:\n${[...missingImports].map((i) => `- ${i}\n`).join('')}\n`, | ||
); | ||
} | ||
|
||
writeFileSync(file, newContent.toString(), 'utf8'); | ||
} | ||
} | ||
|
||
console.log('Done'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.