Skip to content

Commit

Permalink
Support for 3rd-party simulator extensions (#10213)
Browse files Browse the repository at this point in the history
* support for simulator extensions

* lint

* lint

* n

* restore

* restore

* read

* wording

* simxdevmode

* add test simx

* async componentDidMount

* `e` -> `x`
  • Loading branch information
eanders-ms authored Oct 3, 2024
1 parent ef16f4c commit 0fc8ae5
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 33 deletions.
6 changes: 5 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@
"args": [
"serve",
"--rebundle",
"--noauth"
"--noauth",
"--hostname",
"127.0.0.1",
"--backport",
"8080"
],
"cwd": "${workspaceRoot}/../pxt-microbit",
"runtimeExecutable": null,
Expand Down
6 changes: 6 additions & 0 deletions cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2855,6 +2855,7 @@ export function serveAsync(parsed: commandParser.ParsedCommand) {
browser: parsed.flags["browser"] as string,
serial: !parsed.flags["noSerial"] && !globalConfig.noSerial,
noauth: parsed.flags["noauth"] as boolean || false,
backport: parsed.flags["backport"] as number || 0,
}))
}

Expand Down Expand Up @@ -6966,6 +6967,11 @@ ${pxt.crowdin.KEY_VARIABLE} - crowdin key
noauth: {
description: "disable localtoken-based authentication",
aliases: ["na"],
},
backport: {
description: "port where the locally running backend is listening.",
argument: "backport",
type: "number",
}
}
}, serveAsync);
Expand Down
44 changes: 44 additions & 0 deletions cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,7 @@ export interface ServeOptions {
wsPort?: number;
serial?: boolean;
noauth?: boolean;
backport?: number;
}

// can use http://localhost:3232/streams/nnngzlzxslfu for testing
Expand Down Expand Up @@ -988,6 +989,11 @@ export function serveAsync(options: ServeOptions) {
}
}

// Strip /app/hash-sig from URL.
// This can happen when the locally running backend is serving an uploaded target,
// but has been configured to route simulator urls to port 3232.
req.url = req.url.replace(/^\/app\/[0-9a-f]{40}(?:-[0-9a-f]{10})?(.*)$/i, "$1");

let uri = url.parse(req.url);
let pathname = decodeURI(uri.pathname);
const opts: pxt.Map<string | string[]> = querystring.parse(url.parse(req.url).query);
Expand Down Expand Up @@ -1142,6 +1148,30 @@ export function serveAsync(options: ServeOptions) {
}
}

if (elts[0] == "simx" && serveOptions.backport) {
// Proxy requests for simulator extensions to the locally running backend.
// Should only get here when the backend is running locally and configured to serve the simulator from the cli (via LOCAL_SIM_PORT setting).
const passthruOpts = {
hostname: uri.hostname,
port: serveOptions.backport,
path: uri.path,
method: req.method,
headers: req.headers
};

const passthruReq = http.request(passthruOpts, passthruRes => {
res.writeHead(passthruRes.statusCode, passthruRes.headers);
passthruRes.pipe(res);
});

passthruReq.on("error", e => {
console.error(`Error proxying request to port ${serveOptions.backport} .. ${e.message}`);
return error(500, e.message);
});

return req.pipe(passthruReq);
}

if (options.packaged) {
let filename = path.resolve(path.join(packagedDir, pathname))
if (nodeutil.fileExistsSync(filename)) {
Expand Down Expand Up @@ -1248,6 +1278,20 @@ export function serveAsync(options: ServeOptions) {
}
}

// Look for an .html file corresponding to `/---<pathname>`
// Handles serving of `trg-<target>.sim.local:<port>/---simulator`
let match = /^\/?---?(.*)/.exec(pathname)
if (match && match[1]) {
const htmlPathname = `/${match[1]}.html`
for (let dir of dd) {
const filename = path.resolve(path.join(dir, htmlPathname))
if (nodeutil.fileExistsSync(filename)) {
const html = expandHtml(fs.readFileSync(filename, "utf8"), htmlParams)
return sendHtml(html)
}
}
}

if (/simulator\.html/.test(pathname)) {
// Special handling for missing simulator: redirect to the live sim
res.writeHead(302, { location: `https://trg-${pxt.appTarget.id}.userpxt.io/---simulator` });
Expand Down
17 changes: 17 additions & 0 deletions localtypings/pxtarget.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ declare namespace pxt {
// "acme-corp/pxt-widget": "min:v0.1.2" - auto-upgrade to that version
// "acme-corp/pxt-widget": "dv:foo,bar" - add "disablesVariant": ["foo", "bar"] to pxt.json
upgrades?: string[];
// This repo's simulator extension configuration
simx?: SimulatorExtensionConfig;
}

interface SimulatorExtensionConfig {
aspectRatio?: number; // Aspect ratio for the iframe. Default: 1.22.
permanent?: boolean; // If true, don't recycle the iframe between runs. Default: true.
devUrl?: string; // URL to load for local development. Pass `simxdev` on URL to enable. Default: undefined.
index?: string; // The path to the simulator extension's entry point within the repo. Default: "index.html".
// backend-only options
sha?: string; // The commit to checkout (must exist in the branch/ref). Required.
repo?: string; // Actual repo to load simulator extension from. Defaults to key of parent in `approvedRepoLib` map.
ref?: string; // The branch of the repo to sync. Default: "gh-pages".
}

interface ShareConfig {
Expand Down Expand Up @@ -267,6 +280,7 @@ declare namespace pxt {
keymap?: boolean; // when non-empty and autoRun is disabled, this code is run upon simulator first start

// a map of allowed simulator channel to URL to handle specific control messages
// DEPRECATED. Use `simx` in targetconfig.json approvedRepoLib instead.
messageSimulators?: pxt.Map<{
// the URL to load the simulator, $PARENT_ORIGIN$ will be replaced by the parent
// origin to validate messages
Expand All @@ -277,6 +291,9 @@ declare namespace pxt {
// don't recycle the iframe between runs
permanent?: boolean;
}>;
// This is for testing new simulator extensions before adding them to targetconfig.json.
// DO NOT SHIP SIMULATOR EXTENSIONS HERE. Add them to targetconfig.json/approvedRepoLib instead.
testSimulatorExtensions?: pxt.Map<SimulatorExtensionConfig>;
}

interface TargetCompileService {
Expand Down
2 changes: 1 addition & 1 deletion pxtlib/browserutils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ namespace pxt.BrowserUtils {
export function isLocalHost(ignoreFlags?: boolean): boolean {
try {
return typeof window !== "undefined"
&& /^http:\/\/(localhost|127\.0\.0\.1|192\.168\.\d+\.\d+):\d+\//.test(window.location.href)
&& /^http:\/\/(?:localhost|127\.0\.0\.1|192\.168\.\d{1,3}\.\d{1,3}|[a-zA-Z0-9.-]+\.local):\d+\/?/.test(window.location.href)
&& (ignoreFlags || !/nolocalhost=1/.test(window.location.href))
&& !(pxt?.webConfig?.isStatic);
} catch (e) { return false; }
Expand Down
8 changes: 5 additions & 3 deletions pxtsim/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,13 @@ namespace pxsim {
return isPxtElectron() || isIpcRenderer();
}

export function testLocalhost(url: string): boolean {
return /^http:\/\/(?:localhost|127\.0\.0\.1|192\.168\.\d{1,3}\.\d{1,3}|[a-zA-Z0-9.-]+\.local):\d+\/?/.test(url) && !/nolocalhost=1/.test(url);
}

export function isLocalHost(): boolean {
try {
return typeof window !== "undefined"
&& /^http:\/\/(localhost|127\.0\.0\.1):\d+\//.test(window.location.href)
&& !/nolocalhost=1/.test(window.location.href);
return typeof window !== "undefined" && testLocalhost(window.location.href);
} catch (e) { return false; }
}

Expand Down
112 changes: 89 additions & 23 deletions pxtsim/simdriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,24 @@ namespace pxsim {
nestedEditorSim?: boolean;
parentOrigin?: string;
mpRole?: string; // multiplayer role: "client", "server", or undefined
// `messageSimulators` is @DEPRECATED. Use `simulatorExtensions` instead.
messageSimulators?: pxt.Map<{
url: string;
localHostUrl?: string;
aspectRatio?: number;
permanent?: boolean;
}>;
// needed when messageSimulators are used to provide to their frame
// Simulator extensions read from targetconfig.json's `approvedRepoLib` entry.
simulatorExtensions?: pxt.Map<{
// Fields from pxt.SimulatorExtensionConfig
aspectRatio?: number;
permanent?: boolean;
index?: string;
devUrl?: string;
// Additional fields outside of pxt.SimulatorExtensionConfig
url?: string; // Computed URL
}>;
// needed when simulatorExtensions are used to provide to their frame
userLanguage?: string;
}

Expand Down Expand Up @@ -120,16 +131,45 @@ namespace pxsim {

this._allowedOrigins.push(this.getSimUrl().origin);

const messageSimulators = options?.messageSimulators
if (messageSimulators) {
Object.keys(messageSimulators)
.map(channel => messageSimulators[channel])
.forEach(messageSimulator => {
this._allowedOrigins.push(new URL(messageSimulator.url).origin);
if (messageSimulator.localHostUrl)
this._allowedOrigins.push(new URL(messageSimulator.localHostUrl).origin);
});
}
// Legacy support for message simulators
const messageSimulators = options?.messageSimulators || {};
Object.keys(messageSimulators)
.map(channel => messageSimulators[channel])
.forEach(messageSimulator => {
this._allowedOrigins.push(new URL(messageSimulator.url).origin);
if (messageSimulator.localHostUrl)
this._allowedOrigins.push(new URL(messageSimulator.localHostUrl).origin);
});

// Preprocess simulator extensions
const simXDevMode = U.isLocalHost() && /[?&]simxdev(?:[=&#]|$)/i.test(window.location.href);
Object.entries(options?.simulatorExtensions || {}).forEach(([key, simx]) => {
// Verify essential `simx` config was provided
if (
!simx ||
!simx.index ||
!simx.aspectRatio ||
simx.permanent === undefined
) {
return;
}
// Compute the effective URL
if (simXDevMode && simx.devUrl) {
// Use the dev URL if the dev flag is set (and we're on localhost)
simx.url = new URL(simx.index, simx.devUrl).toString();
} else {
const simUrl = this.getSimUrl();
// Ensure we preserve upload target path (/app/<sha>---simulator)
const simPath = simUrl.pathname.replace(/---?.*/, "");
// Construct the path. The "-" element delineates the extension key from the resource name.
const simxPath = [simPath, "simx", key, "-", simx.index].join("/");
simx.url = new URL(simxPath, simUrl.origin).toString();
}

// Add the origin to the allowed origins
this._allowedOrigins.push(new URL(simx.url).origin);
});

this._allowedOrigins = U.unique(this._allowedOrigins, f => f);
}

Expand Down Expand Up @@ -349,8 +389,43 @@ namespace pxsim {
const messageSimulator = messageChannel &&
this.options.messageSimulators &&
this.options.messageSimulators[messageChannel];
// should we start an extension editor?
if (messageSimulator) {
const simulatorExtension = messageChannel &&
this.options.simulatorExtensions &&
this.options.simulatorExtensions[messageChannel];

const startSimulatorExtension = (url: string, permanent: boolean) => {
let wrapper = this.createFrame(url);
this.container.appendChild(wrapper);
const messageFrame = wrapper.firstElementChild as HTMLIFrameElement;
messageFrame.dataset[FRAME_DATA_MESSAGE_CHANNEL] = messageChannel;
pxsim.U.addClass(wrapper, "simmsg")
pxsim.U.addClass(wrapper, "simmsg" + messageChannel)
if (permanent)
messageFrame.dataset[PERMANENT] = "true";
this.startFrame(messageFrame);
frames = this.simFrames(); // refresh
}

// should we start a simulator extension for this message?
if (simulatorExtension) {
// find a frame already running that simulator
let messageFrame = frames.find(frame => frame.dataset[FRAME_DATA_MESSAGE_CHANNEL] === messageChannel);
// not found, spin a new one
if (!messageFrame) {
const url = new URL(simulatorExtension.url);
if (this.options.parentOrigin)
url.searchParams.set("parentOrigin", encodeURIComponent(this.options.parentOrigin));
if (this.options.userLanguage)
url.searchParams.set("language", encodeURIComponent(this.options.userLanguage));
startSimulatorExtension(url.toString(), simulatorExtension.permanent);
}
// not running the current run, restart
else if (messageFrame.dataset['runid'] != this.runId) {
this.startFrame(messageFrame);
}
}
// (legacy: messageSimulator) should we start a message simulator for this message?
else if (messageSimulator) {
// find a frame already running that simulator
let messageFrame = frames.find(frame => frame.dataset[FRAME_DATA_MESSAGE_CHANNEL] === messageChannel);
// not found, spin a new one
Expand All @@ -359,16 +434,7 @@ namespace pxsim {
const url = ((useLocalHost && messageSimulator.localHostUrl) || messageSimulator.url)
.replace("$PARENT_ORIGIN$", encodeURIComponent(this.options.parentOrigin || ""))
.replace("$LANGUAGE$", encodeURIComponent(this.options.userLanguage))
let wrapper = this.createFrame(url);
this.container.appendChild(wrapper);
messageFrame = wrapper.firstElementChild as HTMLIFrameElement;
messageFrame.dataset[FRAME_DATA_MESSAGE_CHANNEL] = messageChannel;
pxsim.U.addClass(wrapper, "simmsg")
pxsim.U.addClass(wrapper, "simmsg" + messageChannel)
if (messageSimulator.permanent)
messageFrame.dataset[PERMANENT] = "true";
this.startFrame(messageFrame);
frames = this.simFrames(); // refresh
startSimulatorExtension(url, messageSimulator.permanent);
}
// not running the curren run, restart
else if (messageFrame.dataset['runid'] != this.runId) {
Expand Down
9 changes: 5 additions & 4 deletions webapp/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1054,9 +1054,9 @@ export class ProjectView
this.initDragAndDrop();
}

public componentDidMount() {
public async componentDidMount() {
this.allEditors.forEach(e => e.prepare())
simulator.init(document.getElementById("boardview"), {
await simulator.initAsync(document.getElementById("boardview"), {
orphanException: brk => {
// TODO: start debugging session
// TODO: user friendly error message
Expand Down Expand Up @@ -1119,9 +1119,10 @@ export class ProjectView
pkg.mainEditorPkg().setSimState(k, v)
},
editor: this.state.header ? this.state.header.editor : ''
})
this.forceUpdate(); // we now have editors prepared
});

// we now have editors prepared
this.forceUpdate();
// start blockly load
this.loadBlocklyAsync();

Expand Down
Loading

0 comments on commit 0fc8ae5

Please sign in to comment.