TypePHP 编译器
https://swoole.com/aot/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
235 lines
9.1 KiB
235 lines
9.1 KiB
import fs from 'node:fs/promises';
|
|
import http from 'node:http';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import puppeteer from 'puppeteer-core';
|
|
|
|
const [, , artifactDir, chromePath, optionsFile] = process.argv;
|
|
if (!artifactDir || !chromePath || !optionsFile) {
|
|
throw new Error('Usage: browser-runner.mjs <artifact-dir> <chrome> <options.json>');
|
|
}
|
|
|
|
const harnessDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const shimDir = path.join(harnessDir, 'node_modules', '@bytecodealliance', 'preview2-shim');
|
|
const options = JSON.parse(await fs.readFile(optionsFile, 'utf8'));
|
|
const stdin = await new Promise((resolve, reject) => {
|
|
let value = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => value += chunk);
|
|
process.stdin.on('end', () => resolve(value));
|
|
process.stdin.on('error', reject);
|
|
});
|
|
|
|
const contentTypes = new Map([
|
|
['.html', 'text/html; charset=utf-8'],
|
|
['.js', 'text/javascript; charset=utf-8'],
|
|
['.json', 'application/json; charset=utf-8'],
|
|
['.wasm', 'application/wasm'],
|
|
]);
|
|
|
|
function safePath(root, requestPath) {
|
|
const relative = decodeURIComponent(requestPath).replace(/^\/+/, '');
|
|
const resolved = path.resolve(root, relative);
|
|
const base = path.resolve(root) + path.sep;
|
|
return resolved.startsWith(base) ? resolved : null;
|
|
}
|
|
|
|
const pageOptions = JSON.stringify({ ...options, stdin }).replace(/</g, '\\u003c');
|
|
const html = `<!doctype html>
|
|
<meta charset="utf-8">
|
|
<script type="importmap">
|
|
{"imports":{"@bytecodealliance/preview2-shim":"/deps/dist/browser/index.js","@bytecodealliance/preview2-shim/":"/deps/dist/browser/"}}
|
|
</script>
|
|
<script>globalThis.TYPEPHP_WASM_TEST_OPTIONS = ${pageOptions};</script>
|
|
<script type="module" src="/harness.js"></script>`;
|
|
|
|
const harnessSource = `
|
|
import { _setStderr, _setStdin, _setStdout } from '/deps/dist/browser/cli.js';
|
|
import { _getPreopens, _setFileData, types as filesystemTypes } from '/deps/dist/browser/filesystem.js';
|
|
import { WASIShim } from '/deps/dist/common/instantiation.js';
|
|
import { instantiate } from '/artifact/program.js';
|
|
|
|
const decoder = new TextDecoder();
|
|
const encoder = new TextEncoder();
|
|
const options = globalThis.TYPEPHP_WASM_TEST_OPTIONS;
|
|
let output = '';
|
|
globalThis.TYPEPHP_WASM_TEST_STAGE = 'module-loaded';
|
|
|
|
function outputHandler() {
|
|
return {
|
|
write(bytes) {
|
|
output += decoder.decode(bytes, { stream: true });
|
|
return BigInt(bytes.byteLength);
|
|
},
|
|
blockingFlush() {},
|
|
};
|
|
}
|
|
|
|
function inputHandler(text) {
|
|
const bytes = encoder.encode(text);
|
|
let offset = 0;
|
|
return {
|
|
blockingRead(length) {
|
|
if (offset >= bytes.byteLength) throw { tag: 'closed' };
|
|
const end = Math.min(offset + Number(length), bytes.byteLength);
|
|
const value = bytes.slice(offset, end);
|
|
offset = end;
|
|
return value;
|
|
},
|
|
};
|
|
}
|
|
|
|
function installMutableFilesystem(fileData) {
|
|
const descriptorEntries = new WeakMap();
|
|
for (const [descriptor] of _getPreopens()) descriptorEntries.set(descriptor, fileData);
|
|
|
|
function resolve(entry, guestPath) {
|
|
for (const segment of String(guestPath).split('/')) {
|
|
if (segment === '' || segment === '.') continue;
|
|
if (segment === '..' || !entry?.dir?.[segment]) throw { tag: 'no-entry' };
|
|
entry = entry.dir[segment];
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
function remove(descriptor, guestPath, directory) {
|
|
const root = descriptorEntries.get(descriptor);
|
|
if (!root) throw { tag: 'bad-descriptor' };
|
|
const segments = String(guestPath).split('/').filter((segment) => segment !== '' && segment !== '.');
|
|
const name = segments.pop();
|
|
if (!name || name === '..' || segments.includes('..')) throw { tag: 'no-entry' };
|
|
const parent = resolve(root, segments.join('/'));
|
|
const entry = parent?.dir?.[name];
|
|
if (!entry) throw { tag: 'no-entry' };
|
|
if (directory ? !entry.dir : entry.dir) throw { tag: directory ? 'not-directory' : 'is-directory' };
|
|
if (directory && Object.keys(entry.dir).length !== 0) throw { tag: 'not-empty' };
|
|
delete parent.dir[name];
|
|
}
|
|
|
|
const descriptor = filesystemTypes.Descriptor.prototype;
|
|
const openAt = descriptor.openAt;
|
|
descriptor.openAt = function (...args) {
|
|
const opened = openAt.apply(this, args);
|
|
const parent = descriptorEntries.get(this);
|
|
if (parent) descriptorEntries.set(opened, resolve(parent, args[1]));
|
|
return opened;
|
|
};
|
|
descriptor.unlinkFileAt = function (guestPath) {
|
|
remove(this, guestPath, false);
|
|
};
|
|
descriptor.removeDirectoryAt = function (guestPath) {
|
|
remove(this, guestPath, true);
|
|
};
|
|
}
|
|
|
|
try {
|
|
if (typeof WebAssembly.Suspending !== 'function' || typeof WebAssembly.promising !== 'function') {
|
|
throw new Error('Chrome does not provide WebAssembly JSPI');
|
|
}
|
|
const fileData = { dir: { sandbox: { dir: {} } } };
|
|
_setFileData(fileData);
|
|
installMutableFilesystem(fileData);
|
|
_setStdin(inputHandler(String(options.stdin || '')));
|
|
_setStdout(outputHandler());
|
|
_setStderr(outputHandler());
|
|
const wasi = new WASIShim({ sandbox: {
|
|
args: [String(options.argv0 || 'test.wasm'), ...(options.args || []).map(String)],
|
|
env: { ...(options.env || {}) },
|
|
enableNetwork: true,
|
|
}});
|
|
globalThis.TYPEPHP_WASM_TEST_STAGE = 'instantiating';
|
|
const component = await instantiate(null, wasi.getImportObject());
|
|
globalThis.TYPEPHP_WASM_TEST_STAGE = 'running';
|
|
const result = await component.run.run();
|
|
globalThis.TYPEPHP_WASM_TEST_RESULT = { output, result };
|
|
} catch (error) {
|
|
if (error?.exitError && error.code === 0) {
|
|
globalThis.TYPEPHP_WASM_TEST_RESULT = { output };
|
|
} else {
|
|
globalThis.TYPEPHP_WASM_TEST_RESULT = { output, error: error?.stack || String(error) };
|
|
}
|
|
}
|
|
`;
|
|
|
|
const server = http.createServer(async (request, response) => {
|
|
try {
|
|
const url = new URL(request.url, 'http://127.0.0.1');
|
|
if (url.pathname === '/') {
|
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
response.end(html);
|
|
return;
|
|
}
|
|
if (url.pathname === '/harness.js') {
|
|
response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
|
|
response.end(harnessSource);
|
|
return;
|
|
}
|
|
const mapping = url.pathname.startsWith('/artifact/')
|
|
? [artifactDir, url.pathname.slice('/artifact/'.length)]
|
|
: url.pathname.startsWith('/deps/')
|
|
? [shimDir, url.pathname.slice('/deps/'.length)]
|
|
: null;
|
|
if (!mapping) {
|
|
response.writeHead(404).end();
|
|
return;
|
|
}
|
|
let filename = safePath(mapping[0], mapping[1]);
|
|
if (!filename) {
|
|
response.writeHead(403).end();
|
|
return;
|
|
}
|
|
let data;
|
|
try {
|
|
data = await fs.readFile(filename);
|
|
} catch (error) {
|
|
// The browser shim publishes Node-style extensionless ESM imports.
|
|
// Resolve those within the already validated static root only.
|
|
if (error?.code !== 'ENOENT' || path.extname(filename) !== '') throw error;
|
|
filename += '.js';
|
|
data = await fs.readFile(filename);
|
|
}
|
|
response.writeHead(200, { 'content-type': contentTypes.get(path.extname(filename)) || 'application/octet-stream' });
|
|
response.end(data);
|
|
} catch (error) {
|
|
response.writeHead(error?.code === 'ENOENT' ? 404 : 500).end(String(error));
|
|
}
|
|
});
|
|
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
|
|
let browser;
|
|
try {
|
|
const address = server.address();
|
|
browser = await puppeteer.launch({
|
|
executablePath: chromePath,
|
|
headless: true,
|
|
args: ['--no-sandbox', '--disable-dev-shm-usage', '--enable-features=WebAssemblyJSPI'],
|
|
});
|
|
const page = await browser.newPage();
|
|
const diagnostics = [];
|
|
page.on('console', (message) => diagnostics.push(`console: ${message.text()}`));
|
|
page.on('pageerror', (error) => diagnostics.push(`pageerror: ${error.stack || error}`));
|
|
page.on('requestfailed', (request) => diagnostics.push(
|
|
`requestfailed: ${request.url()} (${request.failure()?.errorText || 'unknown error'})`
|
|
));
|
|
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: 'load' });
|
|
try {
|
|
await page.waitForFunction(() => globalThis.TYPEPHP_WASM_TEST_RESULT !== undefined, { timeout: 120000 });
|
|
} catch (error) {
|
|
const stage = await page.evaluate(() => globalThis.TYPEPHP_WASM_TEST_STAGE || 'page-loading');
|
|
throw new Error(`${error.message}\nstage: ${stage}\n${diagnostics.join('\n')}`);
|
|
}
|
|
const result = await page.evaluate(() => globalThis.TYPEPHP_WASM_TEST_RESULT);
|
|
process.stdout.write(result.output || '');
|
|
if (result.error) {
|
|
process.stderr.write(result.error + '\n');
|
|
process.exitCode = 1;
|
|
}
|
|
} finally {
|
|
await browser?.close();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
|